Skip to content

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-dir and --add-dir), then launches the approved binary with owner kind claude-code-session in 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 private TMPDIR (<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_DIR for a Maia-managed account, DISABLE_AUTOUPDATER=1 and the SDK's own markers. The TMPDIR is 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 with configuration_invalid, naming the shorter runtime folder it needs (a shorter XDG_RUNTIME_DIR). Without XDG_RUNTIME_DIR the runtime root is under /tmp, where the Node's own read-denial rules already refuse a reading Session. An ambient ANTHROPIC_API_KEY never reaches Claude, so every turn uses the account's subscription; an account without one fails with authentication_failed.
  • Read-only policy: settingSources: [] (no user or project settings or hooks), strictMcpConfig with 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. Unlike canUseTool, the hook also sees auto-allowed reads.
  • A person approves the binary with maia integration plan claude-code and maia integration apply claude-code --plan <plan-id>. The official installer hard-links the current version into ClaudeCode.app, and Maia's vouch policy refuses a user-owned binary with more than one link. plan therefore 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_event with status: rejected) marks the account limitedUntil its reset and fails the turn as failed/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 --stdio with owner kind codex-session, CODEX_HOME set to a Maia-managed account root (unset for the system default), a private TMPDIR, and only HOME, LANG, LC_ALL, PATH and USER inherited.

  • Permission profile maia-read plus the MCP and feature switches in containment policy, with approval_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 on default_mode_request_user_input, so its questions reach a person (approvals). Both policies are -c overrides at launch, not thread/start fields: 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 codex and maia 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 with configuration_invalid naming the path, its mode and the fix (decision).

  • A usage limit (usageLimitExceeded) fails the turn as failed/runtime_failed; Maia then reads account/rateLimits/read and marks the account limitedUntil its 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, or unauthorized), and 30 seconds after the first Codex retry notice since the turn last progressed (any record of the turn other than an error notification 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's session turn interrupt meanwhile waits only for the rest of them): if Codex confirms, the turn fails with authentication_failed, or with the class Codex kept retrying (rate_limited for a 429, provider_unavailable for a 5xx, connection_failed otherwise), or completes, if Codex finished it; otherwise it is outcome-unknown with 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 observe reports the turn stalled and session turn interrupt ends 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's error items copy the provider's text, which can echo a key.

  • A Codex on a custom model_provider reports 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_HOME is an empty per-Session root (<node state>/harness/codex/<session-id>/home), so no user configuration mixes in. Maia adds -c overrides for a maia-gateway model_provider (base_url = <gateway>/v1, env_key = MAIA_GATEWAY_API_KEY, wire_api = "responses") and the Session's model, and passes the key in MAIA_GATEWAY_API_KEY alone; shell_environment_policy.exclude keeps that variable out of every command Codex runs. Maia writes no config.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-stdin stores it. The Node keeps the key in <root>/gateway-key (regular file, mode 0600, 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's keyCustody says whether this call stored its key or kept an 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> (MCP connection_models_list) reads /v1/models once, 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 and disable apply 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) and MAIA_NODE_ORIGIN (the exact http://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 same authentication_failed. context.show reports credential: session, the role and the actor actor_<session-id>.
  • The role gate runs before any handler: a worker reaches node.status, context.show, session.list for its Project, its own port.lease.* and its own mailbox (session.message.send to 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); an orchestrator also reaches project.guard.show for its own Project and session.create of children in its own Workspace, within the fan-out guards (a child may itself be an orchestrator, so nesting stops at maxDepth, 1 by default, and never passes 2 levels), and session.show, session.start, turn.send, turn.show and session.stop on 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 is role_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 retried session.stop replays its receipt and ends the runtime it selected only while the Session is still stopped. Anything else is role_denied (HTTP 403). Session requests share a pool of 4 in-flight requests, apart from the operator's, and fail saturated beyond it. Receipts name the Session as actor_<session-id>. The CLI and maia mcp use the credential when MAIA_SESSION_CREDENTIAL is set and never fall back to the profile bearer.
  • session.list without projectId uses the Project resolved by the Node from the Session's Workspace. The scope is applied before SQLite pagination, including state and attention filters. Without afterCursor and limit a list returns the first page of 20 Sessions, so a worker may call session_list with {}. An explicit different Project fails role_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 ~/.gitconfig after git config --global or a shell history file in $HOME, is unreadable to the worker. Under a shared sticky directory such as /tmp that would break every program, so a denied path there fails the launch with read_denial_shared_ancestor: run the Node with XDG_RUNTIME_DIR set (a systemd user unit sets it), so the runtime directory is not under /tmp. The hostile read-denial target tries the files directly, by hard link, symbolic link, O_PATH reopen, 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's maia-read and maia-write profiles as deny entries, 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 its denyRead, 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_worker MCP server (S18c). The vendor starts maia mcp --worker outside 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> (MCP session_create with worktree.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 branch maia/<workspace-id> beside the repository in .maia-worktrees/. The Session runs there; its worktree field 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.json in its canonical checkout, which the Node reads without following links:

    { "schemaVersion": 1, "copy": [".env"], "setup": ["pnpm", "install"] }
    

    "workerSkills": false turns off the maia-worker Skill for the Project's new coding Sessions.

    Each copy path 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, private TMPDIR, 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 with command_not_implemented; the Session's worktree.setup reads failed. 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 under node_modules) must stay in the worktree and outside any .git by 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 see ANTHROPIC_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-write grants 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, so node and node --test could 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 ~/.gitconfig or ~/.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, refs and shallow, so git status, diff and log work; 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, logging maia.node.session_git_read_withheld, when it finds a URL with a password, an HTTP user name (token@) or a secret query parameter, an http.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 (logging maia.node.codex_session_cache_not_removed when it cannot), writes nothing in it and never reads it. It holds the macOS xcrun cache (xcrun_db), without which every Xcode shim (git, make, clang, python3) tried the shared per-user temporary folder and fell back to a slow xcodebuild lookup. Like the worktree, it can fill the Node's disk.
    • GIT_CONFIG_GLOBAL, GIT_CONFIG_NOSYSTEM=1 and xcrun_db reach the worker's commands through shell_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 @openai scope 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, /opt and /srv stay 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 runs git worktree commands 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 runs execution worktree reconcile for a Session's worktree.

  • session diff <session-id> [--stat] (MCP session_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> (MCP session_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 and session.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 with session.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 .git file 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 remove rereads the .git file 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:

    1. session create --base <ref> ... makes the worktree and the branch maia/<workspace-id>.
    2. session stop ends the runtime only. The worktree and its changes stay, and session resume continues in them.
    3. session archive retires 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-worker Skill: into .agents/skills/maia-worker/ in the worktree, ignored through the repository's .git/info/exclude, for Codex, and as a launch plugin with the Skill tool for Claude Code, which reads no Skills folder under Maia's worker profile. It never writes ~/.claude, ~/.codex or 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 maia Claude Code plugin (plugins/maia, listed by the repository's .claude-plugin/marketplace.json) ships the maia-orchestration Skill. It teaches the loop: discover Projects, Workspaces and accounts, create and start Sessions, send turns, join them with session turn wait, watch session observe, and interrupt, stop or resume. Its registry-lock.json claims only registered commands and is checked by the HPS0 bundle contract. Load it with claude --plugin-dir plugins/maia, or add the repository as a marketplace and install maia@maiaos. Codex 0.160 and later install the same plugin, Skill and MCP server included, from the same marketplace: codex plugin marketplace add <checkout>, then codex plugin add maia@maiaos (codex mcp list shows the maia server; usability audit). Without the plugin command, add the server to ~/.codex/config.toml and install a copy of the Skill with the skills CLI (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 -y
    

    Then 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 with turn_wait and reads the results.

  • The Skill never runs integration commands; 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.json starts maia 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 with approval_respond only 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 stop records the stop, fails queued turns as turn_rejected, settles an in-flight turn outcome-unknown, then ends the process tree. The Session keeps its vendor thread (Codex thread ID, Claude session ID).
  • session resume reopens 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 for session start. A resume waits for the previous runtime to end, or fails retryably, so two runtimes never share one Session.
  • session turn wait waits 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 MCP turn_wait tool 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 polling session 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, --name is 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=~/.claude explicitly does not work: Claude keys its Keychain entry by the variable and reports "Not logged in". maia connection detect and maia setup find 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 Codex model_provider from config.toml, such as a model proxy with an auth command, applies: a first Session ran through it. A Claude endpoint in settings.json does not, because Claude Sessions load no user settings, so setup skips such a CLI; neither does a Codex env_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.json or 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/read and usageLimitExceeded; Claude rate_limit_event and its /usage read inside running Sessions. Maia polls no private usage endpoint. limitedUntil is an availability state, separate from sign-in state. maia connection usage show <id> --refresh reads 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):

  1. skips accounts that are disabled, signed out, inside a limitedUntil window, 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);
  2. 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 connectionPool belongs to exactly one Workspace or Project; its members must be Connections granted to that scope. A Project pool overrides its Workspace pool (decision).
  • Resolution: --connection or 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.
  • single always uses its one member. rotate applies the account selection above to the pool's members at Session admission.
  • A Session that hits a limit ends blocked with usage_limit and resetsAt. 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.created and the result's executor record the account only.

Containment policy

Containment is the vendor sandbox that Maia configures, read-only first (decision).

PhaseCodexClaude
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 a session.created event that names the cwd and 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, and session resume continues 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-authority is 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, project and local settings sources, so the user's own settings endpoint, hooks and MCP servers apply. It sets no CLAUDE_CONFIG_DIR and 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 same vendor-sandbox process-host mode without read denials, and passes the caller's --permission mode (the SDK's default when 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, so approval list and approval respond answer 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 own config.toml does. Each thread/start and thread/resume names the sandbox danger-full-access and the Session's approval policy (untrusted, on-request or never; on-request when none); the launch fails closed unless Codex's response reports both. on-failure is not a Codex value (its app-server rejects it). Only a system-default Connection runs a host Session, so CODEX_HOME stays 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, so untrusted and on-request suit 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=false for 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 sets web_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 the maia-worker Skill as developer_instructions instead (decision);
  • ends the Session with configuration_invalid when 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/requestUserInput requests. 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 its on-request policy 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. requestDigest covers that text; approval.respond refuses 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_request item naming its approvalId on the Session's stream and makes session observe report attention: approval_pending (a question: question). maia session approval list, the MCP tool approval_list and Desktop read them.
  • Who answers. A person: maia session approval approve, deny and answer at an interactive terminal, or Desktop through the native sheet, which shows the request the Node returned for approval.show and refuses an answer it has not shown. A coordinator answers only where the Project's guard grants it, set by a person with maia project guard set --approval-coordinator (D-199, proposed): none by default, answer takes a question's answer, decide also approves and denies. An orchestrator Session answers its direct children's questions and never approves or denies (D-186). maia mcp reaches the Node as the coordinator principal (x-maia-principal: coordinator beside the operator's bearer) and its approval_respond tool is refused with role_denied without 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 names coordinator:actor_local_user and the guard revision. Interaction answers and every confirmation stay role_denied. Starting the Node with MAIA_NODE_HEADLESS_APPROVALS=off ignores every grant for that run; node.status reports headlessApprovals.
  • 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.kill stops the tree, and an empty, removed cgroup is the linux_cgroup_empty cleanup proof. A separate reaper removes the tree if the helper dies.
  • Launches need cgroup v2 at /sys/fs/cgroup and a delegated parent cgroup owned by the Node's user with the cpu, memory and pids controllers; otherwise they fail closed with cgroup_delegation_unavailable, and maia status reports that reason instead of hard containment. The supported setup is a systemd user unit with Delegate=yes and DelegateSubgroup=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, and maia status reports landlock_scoped_unavailable with next step upgrade_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_privs and 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 /sys gets no launch (cgroupfs_outside_sys).
  • A seccomp filter for what Landlock does not mediate: clone3 answers ENOSYS (with CLONE_INTO_CGROUP it could start a child in another cgroup; libc falls back to clone), and prlimit64, sched_setattr, sched_setscheduler, sched_setparam, setpriority and ioprio_set act 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 holds cgroup.kill and cgroup.events open from prepare, so a chmod cannot 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_lost otherwise. A target file changed between prepare and start is refused, and a failed exec is reported as target_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-sandbox filesystem 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 /sys write 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: its memory.max and pids.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 and cgroup.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 /sys write 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 through TIOCGPTPEER) 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: sudo and the person's own sandboxes work as in any terminal. The tree keeps its cgroup, without limits: cgroup.kill ends 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 is cooperative_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's TIOCSIG takes 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 returns EINVAL: 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 or SIGPIPE. The runner prints uname -r and runs the signal scenarios again on a build that forces the leader-only path, so that path is covered on every kernel. As with TIOCSIG, 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 /proc scan. 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 SIGHUP and 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-shell writes to a login shell in the PTY, checks stty size before and after a resize, ends a foreground job with SIGINT while the shell lives on, and closes the tree;
  • terminal-signals sends interrupt, hangup and terminate to a foreground job that reports it owns the terminal, and checks the shell survives each;
  • terminal-exit-burst and terminal-exit-burst-large write 8 KiB and about 200 KB and exit at once, and check every byte arrives before the exit frame;
  • terminal-stale-group leaves 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-members hangs 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-input saturates 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-slave moves a process that ignores SIGHUP out 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-end sends 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-dropped sends 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-program starts a program the way the Node starts one;
  • terminal-tier-refused checks the helper refuses the cooperative tier;
  • terminal-control-eof drops 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, ~/.ssh and 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 --user over 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:

NeedsCodex, any accessClaude Code, codingCode when missing
bwrap on the Node's PATHyesyeshost_bubblewrap_missing
socat on the Node's PATH (Claude's network bridge)noyeshost_socat_missing
user namespaces for bubblewrapyesyeshost_user_namespaces_restricted
nested user namespaces with capabilities (apply-seccomp inside Claude's sandbox)noyeshost_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