Vendor executors and the external brain
Status: Source candidate: the Codex executor (S3), session observe
(S3b), the Claude Code executor (S6), stop, resume, wait and the maia plugin
(S4a), maia mcp (S4c), Node account selection, the Linux process host
(first part of S10), the S18a Session-bound worker credential with S18b
fan-out guards, coding Sessions (S5), and the coordinator principal with
worker approvals and questions (S1, S4b). Planned: S2 pools, S7 scale,
the rest of S10, and S11
(roadmap).
Outcome
An external brain (Claude Code or Codex CLI on the user's own subscription)
drives Maia through the maia plugin's MCP tools and Skill, or through the
maia CLI. Maia Node is the headless backend: it owns Sessions, turns, events,
receipts, account binding, slots and recovery, and runs vendor executor
workers in Maia-created Git worktrees
(decision). Every agent reaches other
agents through the Node (decision).
Claude Code / Codex CLI (brain) ── maia plugin (MCP + Skill) ──> maia ──> Maia Node
│ Sessions, turns, events,
│ receipts, account slots
┌───────────────────────────────────────────────────────┴──────┐
codex app-server (CODEX_HOME=<account root>) claude via Agent SDK (CLAUDE_CONFIG_DIR=<account root>)
permission profile maia-read PreToolUse hook: worktree-only reads
Custody of an executor account stays with the vendor CLI: Maia never reads, stores or proxies its Claude.ai or ChatGPT credential. The user accepts the vendor terms for subscription use per account.
Claude Code executor
Claude Code Sessions run the Agent SDK (@anthropic-ai/claude-agent-sdk,
pinned to 0.3.285) inside the Node as a protocol client, while the claude
process belongs to Maia's process manager
(decision).
- One long-lived Claude session per Maia Session, in streaming-input mode. The Maia Session keeps the Claude session ID (a UUID Maia chooses), so a resume continues the same conversation.
- The SDK asks for its process through
spawnClaudeCodeProcess. Maia returns a bridge, checks the requested executable, working directory and flags (it refuses--dangerously-skip-permissions,--allow-dangerously-skip-permissions, unexpected--mcp-config,--plugin-dirand--add-dir), then launches the approved binary with owner kindclaude-code-sessionin the cooperative tier Codex Sessions use: 4 GiB memory, 128 processes, 12 hours of CPU and 24 hours of wall time. - With SDK 0.3.285 and these options the SDK starts no other process and never
touches credentials. Maia never passes
sessionStore, the one option whose code path copies the OAuth token. - Environment:
HOME,LANG,LC_ALL,PATH,USER, a privateTMPDIR(<runtime root>/ct/<profile hash>/<12 hex of the Session ID>, beside the profile's runtime directory, which the Node denies to workers; recreated, private and not a link, at every Session start, so it needs no state of its own),CLAUDE_CONFIG_DIRfor a Maia-managed account,DISABLE_AUTOUPDATER=1and the SDK's own markers. TheTMPDIRis short because a coding Session's Bash sandbox binds its Linux network bridge sockets there (claude-socks-<16 hex>.sock), and a Unix socket path holds at most 107 bytes; a coding Session whose path would not fit fails at launch withconfiguration_invalid, naming the shorter runtime folder it needs (a shorterXDG_RUNTIME_DIR). WithoutXDG_RUNTIME_DIRthe runtime root is under/tmp, where the Node's own read-denial rules already refuse a reading Session. An ambientANTHROPIC_API_KEYnever reaches Claude, so every turn uses the account's subscription; an account without one fails withauthentication_failed. - Read-only policy:
settingSources: [](no user or project settings or hooks),strictMcpConfigwith no MCP servers, only the Read, Grep and Glob tools,permissionMode: "dontAsk", and a PreToolUse hook that denies any path whose real location leaves the worktree (absolute or..globs, symlinks out) and points Grep and Glob without a path at the worktree. UnlikecanUseTool, the hook also sees auto-allowed reads. - A person approves the binary with
maia integration plan claude-codeandmaia integration apply claude-code --plan <plan-id>. The official installer hard-links the current version intoClaudeCode.app, and Maia's vouch policy refuses a user-owned binary with more than one link.plantherefore copies the resolved binary into a Maia-owned, content-addressed file; Sessions launch that copy, which also pins the approved version against Claude's auto-updates. - Partial messages are on, and the Node streams each Session's items (text,
a marker for thinking without its text, tool calls, Bash commands, file
edits, usage and errors) to
maia session attach(stream). - A rejected subscription window (
rate_limit_eventwithstatus: rejected) marks the accountlimitedUntilits reset and fails the turn asfailed/runtime_failed. The Session also reads the plan's 5-hour and weekly usage (the CLI's own/usage) at start and after each turn.
A Session on a gateway Connection gets its own
CLAUDE_CONFIG_DIR (<node state>/harness/claude/<session-id>/config) and,
added to the list above, ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN (sent as
a bearer token) and ANTHROPIC_MODEL when the Session fixed a model. It needs
no subscription and records no usage or limit window.
Codex executor
-
codex app-server --stdiowith owner kindcodex-session,CODEX_HOMEset to a Maia-managed account root (unset for the system default), a privateTMPDIR, and onlyHOME,LANG,LC_ALL,PATHandUSERinherited. -
Permission profile
maia-readplus the MCP and feature switches in containment policy, withapproval_policy="never". A coding Session (maia-write) runs a granular approval policy that refuses escalations, patches outside the worktree, rule prompts and permission requests, and turns ondefault_mode_request_user_input, so its questions reach a person (approvals). Both policies are-coverrides at launch, notthread/startfields: the app-server takes a granular policy there from experimental clients alone. Maia declines every other request a worker sends (decision). -
A person approves the binary with
maia integration plan codexandmaia integration apply codex --plan <plan-id>. The vouch policy checks the resolved file (for an npm install,bin/codex.js) at plan, apply and every launch: owned by the Node's user or root, writable by nobody else but the user's private group (Ubuntu's 775 default), one link and executable, at any size (D-145). For an npm install the entry script is the target; the vouch does not cover the native binary the script starts. A refused file fails withconfiguration_invalidnaming the path, its mode and the fix (decision). -
A usage limit (
usageLimitExceeded) fails the turn asfailed/runtime_failed; Maia then readsaccount/rateLimits/readand marks the accountlimitedUntilits reset. -
A turn Codex reports failed is a definite
failed/runtime_failed, not an unknown outcome. Maia also gives up on a turn Codex still runs: at once when Codex retries a credential its structured error class says the provider refused (HTTP 401 or 403, orunauthorized), and 30 seconds after the first Codex retry notice since the turn last progressed (any record of the turn other than anerrornotification ends the window, and the next retry starts a new one; no window runs while a request waits on a person). Maia then interrupts the turn and waits up to 10 seconds (a person'ssession turn interruptmeanwhile waits only for the rest of them): if Codex confirms, the turn fails withauthentication_failed, or with the class Codex kept retrying (rate_limitedfor a 429,provider_unavailablefor a 5xx,connection_failedotherwise), or completes, if Codex finished it; otherwise it isoutcome-unknownwith the same reason, and the Session's Codex may still be working on that turn while the next one starts (decision). A provider that never answers and draws no retry notice has no such bound:session observereports the turn stalled andsession turn interruptends it. Records of an ended turn never reach the next one. The reason names the class of Codex's error and its HTTP status in Maia's words; neither the reason nor the stream'serroritems copy the provider's text, which can echo a key. -
A Codex on a custom
model_providerreports no OpenAI account and says it needs no OpenAI sign-in. A usage refresh treats a missing account as signed out only when Codex requires OpenAI sign-in, so such a Connection stays usable, and the next refresh repairs a snapshot an older Node stored as signed out. -
On a gateway Connection,
CODEX_HOMEis an empty per-Session root (<node state>/harness/codex/<session-id>/home), so no user configuration mixes in. Maia adds-coverrides for amaia-gatewaymodel_provider(base_url=<gateway>/v1,env_key=MAIA_GATEWAY_API_KEY,wire_api = "responses") and the Session'smodel, and passes the key inMAIA_GATEWAY_API_KEYalone;shell_environment_policy.excludekeeps that variable out of every command Codex runs. Maia writes noconfig.toml, so the key reaches no file Codex reads.
Gateway Connections
A gateway Connection (S24, decision) points every executor at one AI gateway, such as Vercel AI Gateway or a self-hosted model proxy, that holds its own accounts and models.
maia login gateway --name <name> --base-url <url> --api-key-stdinstores it. The Node keeps the key in<root>/gateway-key(regular file, mode0600, one hard link, read without following a symbolic link) inside the Connection's Maia-managed root; the database, journal, receipts, events, logs and command output never carry it. The Node writes the key to a temporary file, syncs it and links it into place, so a reader never sees a partial key and a second store keeps the first; the result'skeyCustodysays whether this callstoredits key orkeptan earlier one. The store needs hard links in the Node state directory, so FAT, exFAT and some network mounts are unsupported. If the key cannot be stored after the record was, the error names the command id; running the same command with--command-id <id>finishes the gateway. A gateway whose key file is missing or unsafe fails closed: disable it and log in again under a new name.maia connection models list --connection <connection>(MCPconnection_models_list) reads/v1/modelsonce, at most 1 MiB in 10 seconds, without following a redirect, and accepts the OpenAI and Anthropic list forms.session create --workspace <workspace> --connection <gateway> --executor <claude-code|codex-cli> [--model <id>]binds the gateway. Slot caps, holds, receipts anddisableapply as for a vendor account; a disabled gateway or an unreadable key fails the launch closed. Gateways never take part in--executor-only account selection.- Pi and the Maia agent do not use gateway Connections yet: Pi Sessions bind a provider pool, not a Connection. That wiring is an S24 follow-up.
- A subscription proxy can breach a provider's terms. API-key gateways are the default recommendation; the owner decides per gateway.
Register an OpenAI-compatible model proxy
Use a proxy reachable from the Node with Anthropic Messages support for
Claude Code, or OpenAI Responses support for Codex, plus /v1/models.
Register the proxy's root URL without a trailing /v1 (HTTPS, or HTTP on
loopback). For example, with its API key supplied by your secret manager in
PROXY_API_KEY:
printf '%s' "$PROXY_API_KEY" | maia login gateway --name proxy \
--base-url http://127.0.0.1:8317 --api-key-stdin
maia connection models list --connection proxy
maia session create --workspace <workspace-id> --connection proxy \
--executor claude-code --model <model-id>
Choose a model ID from the model list. Use --executor codex-cli for Codex.
The key is the proxy's client API key; Maia does not import its vendor login
or run the CLI's credential helper. Approve the executor with
maia integration <claude-code|codex> plan and apply <plan-id> if needed.
When maia setup skips Claude's custom endpoint, detection and the setup
preview carry the suggested login command in endpoint.hint, including in
SDK results and CLI JSON. The hint uses the detected URL when safe, removes
trailing /v1 and slashes, and substitutes <url> for a URL containing
credentials, a query, a fragment or unsafe characters. Replace <name> with
a Connection name and supply your proxy key through stdin. Registration is
an explicit owner action; setup never imports the key.
Session-bound worker credential
Status: source candidate (S18a, decision). A worker reaches the Node only with its own credential, never the operator's bearer.
- Each Codex or Claude Code launch mints 32 random bytes. The Node keeps
their SHA-256 digest with the Session and its role, never the bytes, and
adds exactly two variables to the launch allow-list:
MAIA_SESSION_CREDENTIAL(base64url) andMAIA_NODE_ORIGIN(the exacthttp://127.0.0.1:<port>origin). Nothing goes in argv, receipts, events or logs; Codex's MCP-discovery app-server gets no credential. A launch whose credential cannot be recorded does not start. - A request with
Authorization: Maia-Session <credential>resolves to the Session principal while the Session holds a runtime slot, is starting, running, waiting, interrupted or recovering, and the credential is under 24 hours old. The credential belongs to its launch scope: closing that scope revokes that credential alone, so a relaunch's credential survives the previous runtime's cleanup; a relaunch replaces the old one and Node start revokes all. Every failure is the sameauthentication_failed.context.showreportscredential: session, the role and the actoractor_<session-id>. - The role gate runs before any handler: a
workerreachesnode.status,context.show,session.listfor its Project, its ownport.lease.*and its own mailbox (session.message.sendto a Session of its execution workspace (checkout) today, Project reach planned in D-166,session.inbox.check,session.inbox.ack; the Node names the sender); anorchestratoralso reachesproject.guard.showfor its own Project andsession.createof children in its own Workspace, within the fan-out guards (a child may itself be an orchestrator, so nesting stops atmaxDepth, 1 by default, and never passes 2 levels), andsession.show,session.start,turn.send,turn.showandsession.stopon its own children only (D-151): the Node reads the child link on each request (a turn's link is its Session's), and any other Session isrole_denied. A coding child's worktree is created after the guards admit the child, one child creation at a time per parent (a racing duplicate gets its receipt), and its receipts name the parent Session. A retriedsession.stopreplays its receipt and ends the runtime it selected only while the Session is still stopped. Anything else isrole_denied(HTTP 403). Session requests share a pool of 4 in-flight requests, apart from the operator's, and failsaturatedbeyond it. Receipts name the Session asactor_<session-id>. The CLI andmaia mcpuse the credential whenMAIA_SESSION_CREDENTIALis set and never fall back to the profile bearer. session.listwithoutprojectIduses the Project resolved by the Node from the Session's Workspace. The scope is applied before SQLite pagination, including state and attention filters. WithoutafterCursorandlimita list returns the first page of 20 Sessions, so a worker may callsession_listwith{}. An explicit different Project failsrole_denied; an unavailable credential or Project scope never falls back to a whole-Node list. Humans and external coordinators retain their current list behavior; coordinator Project grants are a separate follow-up (decision).- Linux: the process host denies worker trees file reads beneath the
profile runtime directory (connection descriptor, bearer) and
$MAIA_HOME/auth.json, with Landlock. The helper resolves each path, grants reads beneath every other entry of every ancestor and fails the launch closed when it cannot or when a path is not normalized. Directory listings stay allowed. Landlock grants attach to the files that exist at launch, so a file created or replaced (write, then rename) later directly in an ancestor directory, such as~/.gitconfigaftergit config --globalor a shell history file in$HOME, is unreadable to the worker. Under a shared sticky directory such as/tmpthat would break every program, so a denied path there fails the launch withread_denial_shared_ancestor: run the Node withXDG_RUNTIME_DIRset (a systemd user unit sets it), so the runtime directory is not under/tmp. The hostileread-denialtarget tries the files directly, by hard link, symbolic link,O_PATHreopen, rename and/proc/self/root, and reads none of them. Managed Pi workers (paused, S23) get no read denial. Codex trees get none either: Codex's own bubblewrap sandbox cannot start inside Landlock filesystem rules, so the Node writes both paths into Codex'smaia-readandmaia-writeprofiles asdenyentries, on every platform, and they hold even beneath a granted folder (decision). Coding Claude Code trees get none for the same reason: on every platform Claude's Bash sandbox lists both paths, as named and where they lie, in itsdenyRead, Claude's rules deny its file tools reading them, and Maia's guard refuses a Read, Grep or Glob that reaches them, named, through a link or from a search root above one (decision). - macOS: no such denial exists. A worker running as the same user can read the runtime directory and the auth file unless its vendor sandbox stops it (Codex's profiles and a coding Claude Code Session's sandbox, rules and guard deny both; a reading Claude Code Session's guard covers Read, Grep and Glob only). Its own credential still bounds what it can do through the Node, but the operator's bearer is readable.
- Coding Sessions and orchestrator Sessions reach the Node through the
launch-configured
maia_workerMCP server (S18c). The vendor startsmaia mcp --workeroutside its tool sandbox with the Session environment. A reading worker has no MCP server; a reading orchestrator has one and stays read-only (D-151). See worker MCP at launch.
Coding Sessions
A coding Session (S5) writes code in a worktree of its own (decision).
-
session create --workspace <workspace-id> --executor <kind> --base <ref>(MCPsession_createwithworktree.baseRef) resolves the ref to a commit in the Workspace's registered repository and creates a new worktree Workspace through the existing worktree owner (execution.worktree.create), on branchmaia/<workspace-id>beside the repository in.maia-worktrees/. The Session runs there; itsworktreefield shows the base, branch, setup outcome, the worker Skills its latest launch injected (skills) and state (active,archived,removed). -
The Project lists what each worktree gets in
.maia/worktree.jsonin its canonical checkout, which the Node reads without following links:{ "schemaVersion": 1, "copy": [".env"], "setup": ["pnpm", "install"] }"workerSkills": falseturns off themaia-workerSkill for the Project's new coding Sessions.Each
copypath must be relative, outside.git, git-ignored in the canonical checkout, a regular file of up to 1 MiB (8 MiB together), and never reached through a link on either side; the Node checks every file before it creates the worktree and writes copies with mode 0600. The setup command is recorded at creation and runs once, before the first launch, as a managed Project command in the worktree (15 minutes, privateTMPDIR, output drained). A Node whose process manager admits no Project command (today every Node outside tests: the execution command authority is test-only) fails such a launch closed withcommand_not_implemented; the Session'sworktree.setupreadsfailed. Planned with S47b (D-174): the setup command runs inside the Session's sandbox before its first turn; a failed setup runs again on the Session's next launch, at most 3 attempts in all, and its last 64 KiB of output stays with the outcome, read through the Session's trace. -
Writes stay in the worktree. Codex runs profile
maia-write. Claude Code gets the editors, whose real target (through the nearest existing folder, never a link to nothing, never a file with another hard link, such as a package-store file undernode_modules) must stay in the worktree and outside any.gitby any case, and Bash in its sandbox: writes to the worktree and private temp only, no reads of user folders (/Users,/home, home), the Node's state (account roots), its runtime directory, the Maia auth file, sibling worktrees or the folder holding the repository, no network, never unsandboxed; on a gateway Connection its commands never seeANTHROPIC_AUTH_TOKEN. Claude's own rules, passed on its command line, deny its file tools reading the runtime directory and the auth file and editing anything under/sys, and the guard refuses those calls outright, with no person to ask (decision). A guard that cannot check a call refuses it. Paths starting with~are refused, because Claude expands them itself. Neither worker can write the repository's Git metadata, so it cannot commit; the Node commits for it. Toolchains outside the minimal system paths (for example under the home folder) are unreadable too. -
Beyond its worktree,
maia-writegrants a Codex worker these paths (decision), each for one need:/System/Library/OpenSSL/openssl.cnf, read, on macOS: Node's OpenSSL reads it at startup, sonodeandnode --testcould not start.<node state>/harness/codex/<session-id>/gitconfig, read: the Git configuration the Node writes for the worker's commands (no global ignore or attributes file), so Git never reads the owner's~/.gitconfigor~/.config/git. Workers cannot commit, so it names no identity.- Git metadata, read: the worktree's own Git directory and, in the
repository's
.git,HEAD,config,info,objects,packed-refs,refsandshallow, sogit status,diffandlogwork; hooks, logs, submodules and other worktrees' folders stay denied. Before each launch the Node reads the repository's configuration (local and worktree scopes, includes resolved, through its pinned Git directory) and withholds the grant, loggingmaia.node.session_git_read_withheld, when it finds a URL with a password, an HTTP user name (token@) or a secret query parameter, anhttp.extraHeader, a credential helper other than a bare name, or a setting named for a secret (*pass,*token,*secret), or when Git cannot read it. The object database holds every branch, stash and loose object of the Project, including other coding Sessions' uncommitted files the Node staged for their diffs; a worker can read them. A configuration change after the launch takes effect at the next one. <node state>/harness/codex/<session-id>/cache, write: the one writable path outside the worktree. The Node empties it at each launch and removes it when the Session's runtime ends (loggingmaia.node.codex_session_cache_not_removedwhen it cannot), writes nothing in it and never reads it. It holds the macOSxcruncache (xcrun_db), without which every Xcode shim (git,make,clang,python3) tried the shared per-user temporary folder and fell back to a slowxcodebuildlookup. Like the worktree, it can fill the Node's disk.GIT_CONFIG_GLOBAL,GIT_CONFIG_NOSYSTEM=1andxcrun_dbreach the worker's commands throughshell_environment_policy.set, never the app-server's own environment, so Codex's Git outside the profile never reads a file the worker can write.- On Linux, Codex's own program, read: the
@openaiscope folder of the npm package (or the approved binary itself), because Codex's sandbox starts the program again inside bubblewrap (decision). Both profiles, reading and coding, get this grant, and both deny the Node's runtime directory and the Maia auth file even beneath a granted folder.
-
Known limit (cooperative tier): Claude's own file tools run in the Claude process, outside its sandbox; the guard decides on the path as it is when the call arrives, so a worker that swaps in a link between that check and the write can still escape. On Linux, Landlock no longer backs the guard in a coding Claude Code tree (decision); Claude refuses a read whose link changed after its own check, observed once in four reads of a link swapped in a loop. Codex's sandbox has no such gap. Claude's Bash reads are a deny list, so system-wide folders such as
/tmp, the Node user's temp folders,/optand/srvstay readable; Codex's reads are an allow list. -
The copied files never reach the branch or the diff, even where the base commit does not ignore them, and a copy the base commit already holds refuses creation before any worktree exists. This excludes the copied paths only: the worker can read them, so it can write their content to any other file, which then reaches the branch. The exclusion is not secret containment. A create that fails after its worktree exists (no account free, for example) leaves the worktree Workspace; retrying the same command ID reuses it, and a person removes an abandoned one with
execution worktree remove. -
Parallel creates in one Project wait instead of failing: repository inspections take one of two slots (62 more wait, 30 seconds each, then a retryable
repository_unavailable), and the Node runsgit worktreecommands one at a time, because Git fails one that lists worktrees while another is added. When the Node cannot confirm the worktree it added, the create reconciles that unknown outcome itself, at once and on each retry of the same command ID: the exact worktree at the base commit completes it and the Session uses it; an absent one fails it definitely, and a new create then needs a new command ID. A person never runsexecution worktree reconcilefor a Session's worktree. -
session diff <session-id> [--stat](MCPsession_diff) lists each changed file with its line counts, then the patch, cut at 48 KiB. An active worktree diffs live, untracked files included, through a Node-private index seeded from the worktree's own, which stays untouched; an archived one diffs its branch snapshot. -
session archive <session-id> --expected-revision <revision>(MCPsession_archive, destructive) stops the Session if it runs, waits for its runtime to end, commits every uncommitted, non-ignored change to the Session branch as Maia (no hooks, no signing) and records the archive with a receipt andsession.archived. The branch always stays; start and resume refuse an archived Session. -
One hour later the Node prunes the worktree through
execution.worktree.remove: the removal plan refuses a dirty, moved, re-pointed or busy worktree and anything Maia did not create, and the succeeded removal operation (folder gone, unregistered from Git) is the cleanup proof the Node records withsession.worktree.removed. A blocked worktree stays and is retried every five minutes; a copied file the base does not ignore keeps it dirty until a person deals with it. -
Before any Git command in a worktree the Node checks, without following a link, that its
.gitfile still names its own Git directory, and its own Git commands there (diff, snapshot, status, removal planning, checkpoint) pin that directory, so a worker cannot point Git at a configuration whose filters or hooks would run as the Node.git worktree removerereads the.gitfile itself, but refuses one that no longer points back to its Git directory before it checks the worktree; pruning runs only after the archive stopped the worker. -
A person creates a coding Session, or an orchestrator creates one as its own child (D-151): the fan-out guards and the Connection's Session cap bound it either way.
-
Integration (rebase, tests, merge, pull request) stays with a person.
-
A coding Session's lane, from start to retirement:
session create --base <ref> ...makes the worktree and the branchmaia/<workspace-id>.session stopends the runtime only. The worktree and its changes stay, andsession resumecontinues in them.session archiveretires the lane: it commits the changes to the branch, and an hour later the Node removes the worktree folder. The branch is never deleted; a person or a script merges or pushes it, and deletes it with Git when done. Archive is the cleanup verb; a stopped Session keeps its folder until it is archived.
-
Before each launch the Node injects the
maia-workerSkill: into.agents/skills/maia-worker/in the worktree, ignored through the repository's.git/info/exclude, for Codex, and as a launch plugin with theSkilltool for Claude Code, which reads no Skills folder under Maia's worker profile. It never writes~/.claude,~/.codexor an account root (worker Skill at launch). The Skill uses the worker MCP tools, so it needs no CLI or loopback access from the vendor tool sandbox (S18c).
Coordinator plugin and maia mcp
-
The
maiaClaude Code plugin (plugins/maia, listed by the repository's.claude-plugin/marketplace.json) ships themaia-orchestrationSkill. It teaches the loop: discover Projects, Workspaces and accounts, create and start Sessions, send turns, join them withsession turn wait, watchsession observe, and interrupt, stop or resume. Itsregistry-lock.jsonclaims only registered commands and is checked by the HPS0 bundle contract. Load it withclaude --plugin-dir plugins/maia, or add the repository as a marketplace and installmaia@maiaos. Codex 0.160 and later install the same plugin, Skill and MCP server included, from the same marketplace:codex plugin marketplace add <checkout>, thencodex plugin add maia@maiaos(codex mcp listshows themaiaserver; usability audit). Without the plugin command, add the server to~/.codex/config.tomland install a copy of the Skill with theskillsCLI (Skills):[mcp_servers.maia] command = "maia" args = ["mcp"]DISABLE_TELEMETRY=1 DO_NOT_TRACK=1 npx --yes skills@<pinned> add \ <checkout>/plugins/maia --skill maia-orchestration -g -a codex --copy -yThen ask, for example: "Use Maia to start two Codex and two Claude Code workers on this Project, ask each one to review a different package, and report the answers." In Codex, mention
$maia:maia-orchestration. The Skill creates the Sessions, sends one turn to each, joins them withturn_waitand reads the results. -
The Skill never runs
integrationcommands; a person approves executables. When the user asks, it lists accounts, creates a Codex account and hands the user its device code, or stores a model-provider key from stdin; the person finishes every vendor sign-in, and Claude Code signs in only at a terminal. -
The plugin's
.mcp.jsonstartsmaia mcp, an MCP server over stdio. Its tools are generated from the protocol's coordinator tool registry (packages/protocol/src/coordinator-tool/registry.ts): each tool is one Node operation with the wire parameters as JSON arguments, so the brain needs no shell quoting or stdin. The CLI stays the fallback and the human surface. -
The server uses the operator's local credential and profile as the coordinator principal and exposes no human-only operation: approving executables, creating or changing accounts, integrations, Projects and containers, answering interactions, and confirming previews stay with a person, and the Node refuses them to the coordinator with
role_denied. It lists and shows pending approvals (approval_list,approval_show) so the brain can tell the user what waits, and answers them withapproval_respondonly where the Project's guard grants it (D-199, default none). Failures come back as tool errors carrying the protocol error and any command ID to reconcile. -
Over SSH (
ssh host maia mcp) the same server reaches another host's Node without a new listener.
Stop, resume and wait
Besides session turn send, a brain drives each Session with three primitives:
session stoprecords the stop, fails queued turns asturn_rejected, settles an in-flight turnoutcome-unknown, then ends the process tree. The Session keeps its vendor thread (Codex thread ID, Claude session ID).session resumereopens a stopped or failed Session on that thread, so the conversation keeps its context; a Session stopped before its first launch starts fresh. Account slots and limit windows apply as forsession start. A resume waits for the previous runtime to end, or fails retryably, so two runtimes never share one Session.session turn waitwaits on 1 to 16 turns (all, or--any). Each Node wait lasts at most 4 seconds; the CLI repeats it until--timeout(1s to 24h), and the MCPturn_waittool makes one bounded wait per call. The bound keeps a wait inside the SDK's 5-second request deadline and limits how long it holds one of the Node's request permits; a longer wait needs a per-operation deadline and a separate admission pool first. A brain can fan out turns and join them without pollingsession turn show. A wait holds nothing a turn needs to settle.
A failed Session or turn says why: failure carries a protocol error code and
a message of at most 256 bytes that Maia wrote, never vendor output or a
credential. session show, session.runtime.failed, session turn show, session turn wait,
turn.failed and the stream's failed turn.completed record carry it, and
the CLI prints it as a failed: <code>: <message> line. A stop or resume
clears a Session's failure. session create refuses an executor the Node
cannot launch, such as a vendor binary nobody approved or, on Linux, a host
without what the vendor's sandbox needs
(prerequisites), with what fixes it,
before it records anything.
When Codex answers a request with a JSON-RPC error, such as thread/start
(bwrap: Failed to make / slave: Operation not permitted), the failure code
names the method (codex_thread_start_rejected, codex_thread_resume_rejected,
codex_turn_start_rejected) and the sentence says the cause class and what
to check: for a bwrap or sandbox exec failure, that bwrap is installed and
may create user namespaces and that Maia is up to date
(decision); for any other cause, which Maia does not
recognise and keeps no copy of, maia doctor. The Node
log gets maia.node.codex_request_rejected (a warning; debug level for the
optional rate-limit read) with the method, the cause class, the code when it
is in JSON-RPC's reserved range (other otherwise, none without one) and
the message's length, never vendor text, which can hold a credential with no
label to find it by.
After a crash the Node settles what the previous process left
(decision, amended). One
Node runs per profile: it holds the profile's instance lock before recovery
starts. A running turn becomes outcome-unknown and its Session recovers. A
dispatching launch permit never reached START, so its process never ran, and
it settles as failed. A started permit's tree ended only if something ended
it, so the permit records, before START, where its host keeps the tree, and
recovery checks that record:
- On macOS the host leads a process group and spawns its target into it. It ends the tree when its Node dies, noticed by its parent changing or its control pipe closing, and exits once the group lists it alone and no member it tracked lives; otherwise it stays. Recovery settles the permit as failed once the group is empty, checking for up to 5 seconds after the Node started.
- On Linux the host creates each tree's cgroup beside its own, and a reaper kills that cgroup if the host dies. Recovery settles the permit as failed once every such cgroup under that parent is gone or unpopulated, for up to 8 seconds; another live tree under the same parent keeps it fenced. This rests on the host's source and fixture tests; no run has killed a Linux Node.
- On Windows (unsupported in v1) the job ends every process the tree started with its host (not one started through another service such as Task Scheduler), so a Windows Node settles the permit as failed 8 seconds after it started, as before.
- A survivor, a check that cannot decide, or a permit with no record (one
started by an older Node) fences the authorization as
cleanup_unknown, and Sessions on that vendor fail until a person acts.
The macOS check is evidence, not a proof: a descendant that double-forks and
leaves the group between two of the host's 100 ms samples is never tracked,
and a tracked one that left the group escapes the check if the host and its
target both died abnormally. To recover from cleanup_unknown, a person
checks for a leftover vendor process or maia-process-host from before the
crash (for example with ps), ends it, and runs
maia integration <codex|claude-code> remove: the authorization becomes
revoked, and plan and apply work again. remove ends only trees the
running Node owns, never one from before the crash. A launch whose START
arrives after its 15-second permit window fails that launch alone,
retryably, with a reason that names the cause.
Accounts (Connections)
A Connection is a named vendor account: executor (codex-cli or
claude-code), configRoot (maia-managed or system-default), state, a
slot cap maxConcurrentSessions (default 2, at most 64), limitedUntil and a
usage snapshot. The Node admits at most 8 active Sessions in total by default.
Both defaults are configurable from 1 to 64 and existing account limits are
kept (admission settings).
- Maia-managed root:
<profile state directory>/accounts/<connection-id>.maia login <claude|codex> [--name <name>]creates the account and signs it in. At a terminal it runs the vendor sign-in with the variable set explicitly (CODEX_HOME=<root> codex login,CLAUDE_CONFIG_DIR=<root> claude auth login). Without an interactive terminal, or with--json,--nameis required, Codex signs in through the Node's app-server with a device code, and Claude refuses. - System default: the variable is unset, reusing the user's normal login.
Setting
CLAUDE_CONFIG_DIR=~/.claudeexplicitly does not work: Claude keys its Keychain entry by the variable and reports "Not logged in".maia connection detectandmaia setupfind the installed CLIs and make one system-default account each, with the executable approved (decision). Such a worker runs with the user's own vendor config under Maia's containment and shares its rate limits with the user's other Codex or Claude clients. A Codexmodel_providerfromconfig.toml, such as a model proxy with an auth command, applies: a first Session ran through it. A Claude endpoint insettings.jsondoes not, because Claude Sessions load no user settings, so setup skips such a CLI; neither does a Codexenv_key, whose variable Sessions do not pass. Behind a proxy, usage and limits are the proxy's; Maia's per-account tracking may not show them. - Maia never opens
auth.json,.credentials.jsonor the Keychain. Every launch sets or unsets the variable explicitly and never inherits it from the Node process. - Usage and limits come only from what the vendor CLI reports: Codex
account/read,account/rateLimits/readandusageLimitExceeded; Clauderate_limit_eventand its/usageread inside running Sessions. Maia polls no private usage endpoint.limitedUntilis an availability state, separate from sign-in state.maia connection usage show <id> --refreshreads Codex now through a short-lived app-server; a Claude account shows the last snapshot a Session recorded.
Account selection
session create --connection <id> binds one account (a gateway also takes
--executor and --model);
session create --executor <codex-cli|claude-code> (MCP session_create
with executor) lets the Node pick one
(decision). The Node
(services/node/src/session/storage/executor/selection.ts):
- skips accounts that are disabled, signed out, inside a
limitedUntilwindow, used up (a live 5-hour or weekly window at 100%) or full. Full means the Sessions holding or acquiring a runtime on the account reach its slot cap; a created Session that never started holds no slot (decision); - picks an account with an unreserved slot first, then the least used account by its highest live window, then the one with the most free slots, then by name. A Session created in the last 10 minutes and not yet started reserves its slot, so a burst of creates spreads out; when reservations cover every slot, the Session still binds and its start waits for a live slot.
The choice happens inside the create command, so a retried command ID replays
the same account; session.created and the result's executor record it.
When no account fits, saturated (retryable) names when the first one frees
up if a reset time is known, configuration_invalid asks for a sign-in or
usage refresh, and resource_not_found means no account of that kind exists.
Every later turn stays on the Session's account, because the vendor thread
lives in that account's root. session start and session resume refuse an
account that is disabled, limited or out of slots (or a Node at its 8-Session
cap); session turn send refuses a limited account and names its reset time.
Connection pools
Source candidate (S2). Operations and storage: contracts, decision.
- A
connectionPoolbelongs to exactly one Workspace or Project; its members must be Connections granted to that scope. A Project pool overrides its Workspace pool (decision). - Resolution:
--connectionor an Agent definition pins one account, which a grant to the Project or its Workspace must still offer; otherwise the Project pool, then the Workspace pool, then every account granted to either; with no grant the request fails closed. No default crosses Workspaces, so pools replace today's choice among all of the Node's accounts of one executor kind. Pi Sessions have no Connection and need no grant. singlealways uses its one member.rotateapplies the account selection above to the pool's members at Session admission.- A Session that hits a limit ends
blockedwithusage_limitandresetsAt. The brain may dispatch the task again as a new Session only after the blocked one has settled, and the new Session records which one it supersedes. - Planned: every selection and skip is an event with pool, Connection and
reason; today
session.createdand the result'sexecutorrecord the account only.
Containment policy
Containment is the vendor sandbox that Maia configures, read-only first (decision).
| Phase | Codex | Claude |
|---|---|---|
| Read-only (current) | Profile maia-read (extends :read-only): root deny, minimal read, worktree read, network off, passed only as -c overrides; approvals off. | Read, Grep and Glob only inside the worktree; Bash, writes, web and MCP denied; no interactive approval. |
| Coding (S5, source candidate) | Profile maia-write (extends :read-only): worktree write, the same denials plus the three grants; qualified on macOS against decoys with codex sandbox. Asks a person its questions (S4b); refuses escalations. | Read, Grep, Glob, Edit, Write, NotebookEdit inside the worktree; Bash only in Claude's sandbox. A read or edit outside the worktree, and AskUserQuestion, wait for a person (S4b). |
/tmp stays reachable under the Codex profile, so each Session gets its own
temporary directory.
Host authority (pending owner)
One Session may opt out of this policy: session create --host-authority
(D-198). Its harness runs in an existing directory as
the Node's user, with no Maia sandbox profile, guard, MCP or feature
overrides, and the user's own vendor configuration and hooks. Everything
above applies to every other Session unchanged.
- The Node refuses it unless its owner started it with
MAIA_HOST_AUTHORITY=enabled(host_authority_disabled), and refuses a Session credential or coordinator that asks (role_denied). - The Session reports
containment: none; creation writes the usual receipt and asession.createdevent that names thecwdand permission mode. session adopt <claude:id> --host-authority [--permission <mode>]binds an adopted Session to host authority by the same rules: the kill switch is checked at preview and again at confirmation, the mode is part of the preview digest, andsession resumecontinues the native session with the user's own Claude configuration, so no subscription login or transcript copy is needed. Claude Code only: adopting a Codex session with--host-authorityis refused (adoption_refused), while a created Codex host Session starts a new thread.- The child's environment is an allow-list; no
MAIA_*variable, Maia bearer or Session credential reaches it. It starts inside the Node's delegated cgroup, but that accounting is cooperative: an unrestricted child can move descendants out of it, so the limits bind only the processes that stay (a limit accepted in D-198). - Source candidate status: the protocol, storage, kill switch, receipt, event and status field, and the Claude Code and Codex launches, are in the source.
- A host Claude Code launch loads the user's
user,projectandlocalsettings sources, so the user's own settings endpoint, hooks and MCP servers apply. It sets noCLAUDE_CONFIG_DIRand no SDK marker, so Claude reads the user's own folder, and a Maia-managed Connection is refused (use a system-default one). It adds no Maia--settings, tool list, MCP config or plugin, runs through the samevendor-sandboxprocess-host mode without read denials, and passes the caller's--permissionmode (the SDK'sdefaultwhen none). A gateway Connection is refused. The permission mode and the user's own rules decide what Claude asks about; a call it would ask its user about goes through the SDK's permission callback to Maia's approval path, soapproval listandapproval respondanswer it as they do for a coding worker. With no person to ask, the call is denied at once. - A host Codex launch is the plain
codex app-server --stdio: none of the profile, feature, MCP, web-search or prompt overrides below apply, so the user's ownconfig.tomldoes. Eachthread/startandthread/resumenames the sandboxdanger-full-accessand the Session's approval policy (untrusted,on-requestornever;on-requestwhen none); the launch fails closed unless Codex's response reports both.on-failureis not a Codex value (its app-server rejects it). Only a system-default Connection runs a host Session, soCODEX_HOMEstays unset; a Maia-managed Connection or a gateway is refused. A request the approval policy raises is declined and no person can approve it through Maia, sountrustedandon-requestsuit work that needs no command approval. The user's own MCP servers start and do not end the Session.
Codex also inherits the account's config.toml, and -c mcp_servers={}
merges into the user's MCP table instead of replacing it
(decision). Maia therefore:
- names the configured MCP servers (at most 64) with a short-lived app-server
that starts no thread, then launches the Session with
-c mcp_servers.<name>.enabled=falsefor each; - disables, with
--disable, the vendor features that run outside the profile, reach the network or read other projects' memory (apps,browser_use,browser_use_external,computer_use,hooks,image_generation,in_app_browser,memories,multi_agent,plugins,remote_plugin,skill_mcp_dependency_install,tool_suggest), and setsweb_search="disabled"because hosted search bypasses the local network denial; - drops prompt sections each model call re-sends and a worker cannot use:
the Skills list (
skills.include_instructions=false), the bundled system Skills (skills.bundled.enabled=false) and the multi-agent messages (agents.enabled=false), and gives a coding worker themaia-workerSkill asdeveloper_instructionsinstead (decision); - ends the Session with
configuration_invalidwhen app-server still reports an MCP server starting, so a server from a project config layer cannot survive silently.
Only a person answers a worker's approval request; a brain never approves its own worker's request (approvals).
Approvals and worker questions
Status: source candidate (S1, S4b, decision).
- Who asks. A reading Session never asks: its profile denies writes
outright. A Codex coding Session asks its questions only: Maia holds its
item/tool/requestUserInputrequests. Codex runs an approved escalation with every path the profile denies still denied, reads and writes alike (observed with Codex 0.160), so its granular policy has Codex refuse escalations, patches outside the worktree, rule prompts and permission requests up front and tell the worker why; Maia declines any such request that arrives anyway without asking (decision). A thread a coding Session started before this keeps itson-requestpolicy when it resumes, because Codex keeps a thread's own policy over the launch's; Maia declines its command and patch requests. Claude Code's PreToolUse guard holds a read or edit whose path leaves the worktree (never Git metadata, a link, an unavailable tool or a call the guard cannot check) and AskUserQuestion. A Project-wide "ask for everything" option is not built. - What a person sees. The Node records each request with its Session,
turn, kind and the exact text the decision binds: the command and its
folder, a patch's files with line counts and a digest of every diff, a
tool call with its whole input, or one question with its options.
requestDigestcovers that text;approval.respondrefuses any other digest (approval_request_changed). A request too long to show (8 KiB), a patch Maia never saw, several or secret questions at once, or text with terminal controls, invisible format characters or line separators is declined without asking, as is a request beyond the 16 a Session may have waiting (approval_pending_limit). - Where it shows. Each request is an
approval_requestitem naming itsapprovalIdon the Session's stream and makessession observereportattention: approval_pending(a question:question).maia session approval list, the MCP toolapproval_listand Desktop read them. - Who answers. A person:
maia session approval approve,denyandanswerat an interactive terminal, or Desktop through the native sheet, which shows the request the Node returned forapproval.showand refuses an answer it has not shown. A coordinator answers only where the Project's guard grants it, set by a person withmaia project guard set --approval-coordinator(D-199, proposed):noneby default,answertakes a question's answer,decidealso approves and denies. An orchestrator Session answers its direct children's questions and never approves or denies (D-186).maia mcpreaches the Node as the coordinator principal (x-maia-principal: coordinatorbeside the operator's bearer) and itsapproval_respondtool is refused withrole_deniedwithout the grant, as is every other cause (unknown approval, other Project, too low a grant, the kill switch). The CLI answers as the coordinator with--as-coordinator, without a terminal. The Node checks the grant when it commits the answer; the receipt namescoordinator:actor_local_userand the guard revision. Interaction answers and every confirmation stayrole_denied. Starting the Node withMAIA_NODE_HEADLESS_APPROVALS=offignores every grant for that run;node.statusreportsheadlessApprovals. - Known limits. The coordinator mark is the coordinator's own choice, so
a grant to coordinators is a policy for whoever holds the operator's
bearer, not proof of who they are: a brain with the operator's shell could
send the mark itself. Without a grant the CLI refuses to run without a
terminal but cannot tell a person from a program driving a
pseudo-terminal.
maia --node <name>carries the coordinator mark over the SSH bridge too.
Linux process host
services/node/native/process-host/linux.c is a source candidate, not
release-qualified (decision). Service setup
is in operations.
Every worker tree runs under hard containment; a person's Terminal is the one exception, below.
- One cgroup v2 directory per tree, a sibling of the Node's own cgroup inside the delegated subtree. The kernel enforces the memory limit and caps the tree at 64 tasks per allowed process, at most 8,192 (threads included); the helper counts processes, as on macOS, and enforces that count, CPU and wall time.
cgroup.killstops the tree, and an empty, removed cgroup is thelinux_cgroup_emptycleanup proof. A separate reaper removes the tree if the helper dies.- Launches need cgroup v2 at
/sys/fs/cgroupand a delegated parent cgroup owned by the Node's user with thecpu,memoryandpidscontrollers; otherwise they fail closed withcgroup_delegation_unavailable, andmaia statusreports that reason instead of hard containment. The supported setup is a systemd user unit withDelegate=yesandDelegateSubgroup=node. - Hard containment also needs Landlock ABI 6 (Linux 6.12). The Node asks the
helper with
--landlock-abi(which prints the ABI before any setup); below 6 the helper refuses every hard-contained launch, andmaia statusreportslandlock_scoped_unavailablewith next stepupgrade_kernel. A probe that fails or times out reports the same, and is tried again after 30 seconds. An ABI of 6 or more does not guarantee a launch.
The cgroup files belong to the user that runs the worker, so the helper confines each target before it runs:
no_new_privsand a Landlock domain that denies writes anywhere under/sys(limits,cgroup.procs, sibling cgroups) and signals and abstract unix sockets outside the tree. A host with cgroupfs mounted outside/sysgets no launch (cgroupfs_outside_sys).- A seccomp filter for what Landlock does not mediate:
clone3answersENOSYS(withCLONE_INTO_CGROUPit could start a child in another cgroup; libc falls back toclone), andprlimit64,sched_setattr,sched_setscheduler,sched_setparam,setpriorityandioprio_setact on the caller only, so a target cannot kill or starve its helper, the reaper or the Node. - The helper is not dumpable (no reading its pipes or memory through
/proc) and holdscgroup.killandcgroup.eventsopen from prepare, so achmodcannot take away its kill switch or the reaper's. - Once a second it checks that the limits and the leader's cgroup are
unchanged and fails the run as
containment_lostotherwise. A target file changed between prepare and start is refused, and a failed exec is reported astarget_exec_failed, not as an exit. - Landlock signal scoping needs Linux 6.12 or later; on an older kernel the
helper refuses every hard-contained launch with
landlock_unavailable. - A Codex Session tree runs in the
vendor-sandboxfilesystem mode (decision): Codex confines each command it runs with bubblewrap, which must change the mount topology, and Landlock forbids that inside a domain that handles filesystem access. Its domain keeps the signal and abstract socket scoping and handles no filesystem right; the cgroup, limits, seccomp filter and containment check stay. The/syswrite denial and the read denials then rest on Codex's own profile for every command the model runs; the app-server, the worker MCP server and anything Codex runs outside its sandbox are trusted as the approved binary. Such a process can write under the delegated cgroup parent: itsmemory.maxandpids.max(the containment check fails the run), its other settings (unseen), its descendants' membership (so the cleanup proof is best effort for a Codex tree), and other trees' limits andcgroup.kill. The mode refuses read denials (filesystem_mode_invalid). On Linux the profile also grants read access to Codex's own program, which its sandbox starts again inside bubblewrap. - A coding Claude Code Session tree runs in the same mode
(decision), for the same reason: Claude runs its
Bash in bubblewrap. Its launch asks for the mode and carries no read
denials; the process manager refuses the mode for any other owner or
beside read denials, and the helper honors it only for the approved Claude
binary. The Node asks for the mode only after Claude's command line, read
by the Agent SDK's grammar, carries exactly the sandbox settings Maia
planned, no user, project or local settings source and Maia's denial
rules, each once; it refuses any other launch as
configuration_invalid. The host's administrator-managed policy settings still apply and are trusted. Claude's sandbox holds the denials and the/syswrite denial for every command Bash runs, and Claude's rules and Maia's guard for its file tools, which run in the Claude process. That process and the worker MCP server are trusted as the approved binary, as for Codex. A reading Claude Code Session runs no sandbox and keeps Maia's Landlock filesystem rules.
A person's Terminal on Linux
A person's live Terminal runs in the local_user_interactive_terminal tier,
as on macOS: as the Node account's own user, with the person's authority, as
an SSH login does, never an agent's. D-175 says so:
"Linux needs an interactive terminal tier like macOS's", and a person's
terminal "runs as the Node's account with the person's authority, as an SSH
login does". The Node picks that tier for the interactive-terminal owner
only. Sessions, workers, execution commands and every other tree keep hard
containment.
- The helper opens a PTY (
posix_openpt, its slave throughTIOCGPTPEER) at the launch's size. The child starts a session, takes the slave as its controlling terminal and as fds 0–2, and execs the vouched login shell. - STARTED names the Terminal. The child passes its gate only once that frame is written, so the Node records the Terminal before its shell runs.
- No Landlock, seccomp or
no_new_privs:sudoand the person's own sandboxes work as in any terminal. The tree keeps its cgroup, without limits:cgroup.killends it, the empty cgroup proves it gone, and the reaper removes it if the helper dies. The helper claims the wall limit only, and the cleanup proof iscooperative_known_descendants_absent, because a process with the person's authority can move itself out of the cgroup. - Write, resize (
TIOCSWINSZ), signal and close are acknowledged as on macOS. Linux'sTIOCSIGtakes only keyboard signals, so interrupt uses it; hangup and terminate mirror it: they go to the PTY's foreground process group, through a pidfd of the group leader (PIDFD_SIGNAL_PROCESS_GROUP), after the helper checks on that process that the group lies in the session the target started, so they never reach a process that reused the group's number. They reach the foreground group verified in the Terminal's session or, if its leader has since started its own session, only that leader and its descendants: the kernel cannot hold session membership atomically with the signal. On Linux 6.9 and later the whole foreground group gets the signal atomically. On kernels before 6.9 (the group flag returnsEINVAL: Ubuntu 24.04's GA kernel, Debian 12, RHEL 9) only the group's leader does, atomically through the same pidfd, with no PID-reuse window: a one-command job is its own group, and for a pipeline the leader is its first command, while the other members usually end on EOF orSIGPIPE. The runner printsuname -rand runs the signal scenarios again on a build that forces the leader-only path, so that path is covered on every kernel. As withTIOCSIG, a signal with no foreground group to receive it is acknowledged with nothing delivered and the Terminal lives on: a job can exit before its shell takes the terminal back, and the group is then gone. The same holds for a group whose leader is gone while members remain, which a pidfd cannot name; that edge is left undelivered, with no/procscan. Only an error those checks cannot explain (EPERM,EINVAL) ends the Terminal. Close kills the tree and is acknowledged after the tree is gone, before the cleanup frame. - When the shell or program exits, the helper ends the rest of the tree and
reads the PTY to end-of-file before it sends the exit frame, so no output
is lost. When the reap is prompt, the read ends 1.5 s into the 3 s cleanup
window at the latest (and after at most 1 s), which leaves room before the Node's 2 s terminate
and 4 s close deadlines when the reap is prompt; a slower reap ends fail
closed at the Node's deadline. A PTY that does not empty in time is
reported as incomplete output, never as a clean exit, and only the cleanup
proof follows that failure (the Node accepts nothing else): pending input is
cancelled and no write, resize, signal or close is acknowledged. A process outside the tree that ignores
SIGHUPand keeps the slave open leaves the master with nothing to read (EAGAIN) and costs that wait. - An operation that reaches the helper after the Terminal began to end is dropped without an acknowledgement, and the cleanup proof still follows.
- The Node's terminal list decides what runs: the account's login shell from a fixed list, vouched by path at startup, or a listed program through that shell (CLI reference).
services/node/native/process-host/testing/run-linux-host-checks.sh runs 45
lifecycle scenarios on the release and AddressSanitizer builds. Thirteen cover a
person's Terminal; terminal-signal-members expects the whole group or the
leader alone from the kernel's capability and the build, the others do not
depend on the kernel:
terminal-shellwrites to a login shell in the PTY, checksstty sizebefore and after a resize, ends a foreground job withSIGINTwhile the shell lives on, and closes the tree;terminal-signalssends interrupt, hangup and terminate to a foreground job that reports it owns the terminal, and checks the shell survives each;terminal-exit-burstandterminal-exit-burst-largewrite 8 KiB and about 200 KB and exit at once, and check every byte arrives before the exit frame;terminal-stale-groupleaves the PTY's foreground group gone (a job hands the terminal to a child's group and reaps it), sends terminate, and checks it is acknowledged, the Terminal runs on and the shell answers;terminal-signal-membershangs up a foreground job of a leader and one more member and checks, from the member itself (an interrupt to the whole group makes it print whether it had taken the hangup), that both get the hangup when the kernel takes the group flag and the build is not forced, and only the leader otherwise (before Linux 6.9 or on the forced build; the driver probes the flag once and the runner names the forced build);terminal-saturated-inputsaturates the output while a write is pending and checks only the cleanup follows the failure (it guards the saturation path, not the microsecond race of a write finishing in the same pass);terminal-stray-slavemoves a process that ignoresSIGHUPout of the tree so it keeps the slave open, and checks the helper reports incomplete output and then sends only the cleanup proof, in 2 s, in an order the Node's lifecycle accepts;terminal-operation-after-endsends a write, a resize and a signal in the same pass as a close, and checks the close acknowledgement and the cleanup proof still arrive;terminal-close-after-droppedsends a terminate, then a write, a resize, a signal and a close in one pass, and checks the close acknowledgement returns this Terminal's identity and the exact next sequence, and the cleanup proof follows;terminal-programstarts a program the way the Node starts one;terminal-tier-refusedchecks the helper refuses the cooperative tier;terminal-control-eofdrops the Node's link right after START and checks the tree and its cgroup are gone.
Six are
for the vendor-sandbox mode: bubblewrap (/usr/bin/bwrap, skipped where it
is not installed) fails to start its sandbox by default and runs in that
mode, a target in it still cannot signal or weaken its helper, open its
pipes, clone3 into another cgroup or reach an outside abstract socket, a
limit it raises fails the run as containment_lost, and the helper refuses
an unknown mode and the mode with read denials. Its hostile
breakout target tries to raise its limits, create a sibling cgroup, move
itself out, clone3 into another cgroup, signal or prlimit its helper, open
the helper's pipes, reach an outside abstract socket and chmod its kill
switch; every attempt is refused and termination still proves cleanup.
Known limits: this bounds the tree, not the user.
- A hostile worker running as the same user is not isolated from that user's
files, credentials, sockets or services. It can read whatever the user can
(including
~/.maia,~/.sshand the Node API token), write outside/sys(shell startup files, systemd user units, Git hooks, executables that run later), and ask a user service (systemd-run --userover the session bus) to start a program outside the tree. The cleanup proof covers tree membership only. - Workers on one Node are not isolated from each other: one can pin another's CPUs or raise the Node's OOM score. A worker that changes the modes of sibling trees or the shared parent cannot stop them, because helpers hold their cgroup descriptors and restore the delegated modes before each launch and removal; preventing those mode changes is part of S7.
- Closing these gaps needs a per-executor filesystem allowlist and, for full isolation, a separate worker account; neither is in v1. S18a denies reading Claude Code trees, and the vendors' own sandboxes deny Codex commands and a coding Claude Code Session's, reads of the Node runtime directory and the auth file (worker credential); other account roots under the state directory stay readable to Claude Code trees.
Vendor sandbox prerequisites on Linux
Codex confines every command with bubblewrap, and a coding Claude Code
Session runs its Bash in Claude's own sandbox, which also uses bubblewrap. On
Linux, session create (and so a Routine fire or an Agent) checks the host
first and refuses, before it records anything:
| Needs | Codex, any access | Claude Code, coding | Code when missing |
|---|---|---|---|
bwrap on the Node's PATH | yes | yes | host_bubblewrap_missing |
socat on the Node's PATH (Claude's network bridge) | no | yes | host_socat_missing |
| user namespaces for bubblewrap | yes | yes | host_user_namespaces_restricted |
nested user namespaces with capabilities (apply-seccomp inside Claude's sandbox) | no | yes | host_user_namespaces_restricted |
A reading Claude Code Session runs no sandbox and needs none of them. The
Node decides without running anything: it looks for executable bwrap and
socat files in the absolute folders of its own PATH, which every vendor
launch inherits (a relative entry would resolve inside the worktree, so it
skips those), and reads kernel.apparmor_restrict_unprivileged_userns
and the loaded AppArmor profiles
(/sys/kernel/security/apparmor/policy/profiles). With the sysctl at 1,
bubblewrap gets a user namespace only through Ubuntu's bwrap profile
(bwrap-userns-restrict), and that profile's enforced unpriv_bwrap child
strips the capabilities a nested one needs, at any sysctl value. The sysctl
at 1 with bwrap loaded and no unpriv_bwrap, which is not Ubuntu's layout,
also refuses coding Claude Code Sessions; that state is an inference, not an
observation. A sysctl the Node cannot read counts as a kernel without that
restriction, and the vendor's sandbox still refuses at launch, with its
cause; a loaded profile whose mode it cannot read counts as enforced. The
message names the package, or the sysctl and the profile; the owner applies
the fix, and Maia never installs a package or changes a sysctl or profile.
maia status shows one line per prerequisite, with the fix when it is
missing.
Where the host provides them, a coding Claude Code Session's Bash runs in
Claude's sandbox, because its tree runs in the vendor-sandbox mode
(D-184); Maia's Landlock filesystem rules would
refuse bubblewrap's mount change (bwrap: Failed to make / slave: Operation not permitted). Observed on Ubuntu 26.04, Linux 7.0, Claude Code 2.1.302,
29 October 2026: a coding Session edited a worktree of Maia's repository and
its test passed.
Remote bootstrap and discovery
Remote bootstrap (S10). The operator grants SSH access once. In source
today, maia-node remote probes the host (OS, architecture, kernel, cgroup v2
delegation, Landlock, systemd and lingering, disk), installs a pinned private
Node.js runtime, the Maia build and its linux-x64 process host, registers a
systemd user service, and rolls back or uninstalls; every step is idempotent,
reports what it changed and stops at the first missing prerequisite, such as
a privilege it cannot obtain (operations).
Planned: the same steps as maia node install and agent operations, installed
harness discovery, and enrollment (MN1). Until enrollment exists, SSH carries
the bootstrap and the Node API through the SSH stdio bridge
(decision); it is not a control protocol of its own.
Discovery and adoption (S11, source candidate,
D-078). discovery.sessions.list reads Claude
Code sessions through the Agent SDK, Codex threads through codex app-server
thread/list (state database only), then dedupes on (executor, native ID)
and groups by Git common directory, read from .git without running Git. It
returns metadata (native ID, directory, repository, branch, last activity,
live, ended or unknown); titles, first prompts and previews need
includeContentFields.
Maia's own Sessions and Codex worker threads are counted, not listed.
session.history.read returns a bounded page of a native session's messages
through the vendor and keeps nothing. Adoption is explicit and applies to an
ended session: Claude Code is live while its sessions/<pid>.json record
exists (Maia reads no other file there), and Codex keeps no liveness Maia can read, so a person attests that it ended.
Confirmation rechecks liveness and claims the native session once, then
creates a stopped Session on the system-default Connection in the
Workspace's primary checkout; session resume continues the native session
under Maia's policy. A session still running outside Maia is shown as live
and stays read-only; Maia never signals or takes over a process it did not
launch.
Imported from Maia OS docs at 3dc3e07