Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ An opted-in AI Gateway key (the NAME `AI_GATEWAY_API_KEY`) is never handed to th
- **What a shutdown does:** the pass ends over budget, so the stage Blocks there, and the relay refuses every later call, the model list included. The dollars are marked a lower bound as well, since a run of calls none of which could be priced gives no evidence the estimates cover them. The Block reason and the progress line name it first (`gateway relay shut: 7 calls in a row could not be priced (<why>); Factory's max_usd was not reached`, `gateway relay shut: 6 calls in this stage could not be priced (<why>)`, or `gateway relay shut: a complete answer could not be priced (<why>)`); only an actual `max_usd` overrun reads "dollar cap reached". Nothing has to persist across stages: the Block stops the Objective until a person re-applies it.
- **The call log:** each call the relay answers is one JSON line in the stage's usage directory (`relay-calls.jsonl`, which no sandbox writes, mode 600): its time, method, relayed path, model (the allowlisted model header, else a JSON body's or a form's `model`, kept only with a model id's shape: `provider/name`, at most 80 characters), status, the dollars booked and how (`reported`, `lookup`, `refused`, `unsent`, `redirect`, `unavailable`, `gateway-refused`, `estimate`, `shut`), its generation id, why it was not priced, or why the relay refused it (a route it does not relay is `refused: route`, never the path asked for; a call whose client hung up while it waited is `refused: gone`, never sent). Never a body, a header's value, a message or the key, and the stage's held secrets are masked in it when the stage ends, as in `events.jsonl`. At most 2,000 lines; one closing line counts the calls past them. A lookup that settles later adds a line of its own, with `reconciled`, the dollars it changed the booking by: one that raises a reported cost, one that prices a refusal's generation, and the one for a failed call booked at the estimate (its record's cost, or 0 when no record came and the estimate stands). `result.json`'s `workerKey` summarises it: the estimate (`estimated`, `estimatedUsd`), the shutdown (`shut`: calls, why, and `complete` for a complete answer) and `callLog` (the file, its line count and any calls left out).
- **The cap is live and shared with the harness:** calls are relayed only while a pass runs. With a cap, billable calls go one at a time (at most 8 wait their turn, more get 429 with a `Retry-After`; a pass's end refuses those still waiting, and one whose pass ended while its turn came; one whose client hung up while it waited is never sent), each only once the one before it is priced or booked at its estimate, and only while the pass's spend through the key plus the harness's own spend in the pass so far is under what `max_usd` left when the pass started; past it the relay answers 402 with a message the worker sees, and the pass counts as over budget, so the stage stops or Blocks as when the harness reaches its own cap. Model spend plus key spend can pass the cap by about two calls and what the standing estimates understate (the money bound above), and by what the harness spends after the check. The harness's own spend is read live (`lib/live-spend.mjs`): Claude Code's per-message usage from its stream, priced from `lib/claude-prices.json` (an unlisted model at the dearest row); Codex's newest thread rollout (its `token_usage_record` lines, one per model response) and its turns, priced from the Codex table (an unset or unlisted model at its dearest rates), sub-agent threads once the pass ends; Copilot's usage checkpoints (its running AI credits). A live figure that cannot be read refuses the call (503, with a `Retry-After`) rather than count as $0. A parallel pipeline is serialised while a cap is on: N concurrent calls take about N times one call's time. With no cap nothing is serialised, and each call of unknown cost still books its own estimate.
- **How commands reach it:** the relay listens on a Unix socket in a directory the agent cannot write (a directory of the stage's own under `/var/lib/factory-relay` on a runner and in a cloud sandbox, the socket group-readable by the agent at 660, removed when the stage ends). Locally, and in a developer's own session, the socket goes in a short directory of the stage's own, `/tmp/factory-relay-<uid>/<12 hex>`, named by a hash of the stage's usage directory: both levels 700, the socket 600, removed when the stage ends. A Unix socket's path is limited to 107 bytes on Linux (103 on macOS), and the usage directory's path grows with the checkout's. A parent that is a link, a file, another user's or open to others is refused, and a socket path that would still pass the limit Blocks the stage before the run, naming the limit. Claude Code's and Codex's sandboxes start every command with the `bwrap` first on PATH. Factory puts its shim there (`lib/relay-shim.mjs`, `bash -p`, builtins only), which starts a forwarder on the command's own network namespace's `127.0.0.1:<port>` to the socket before the command runs; it walks bubblewrap's options by their arity, so only `--unshare-net` before the command's separator marks a command sandbox. The harness's network policy, proxy and seccomp filter are unchanged; the forwarder carries one port to the relay alone, and the commands themselves cannot open the socket (both harnesses' filters refuse Unix sockets). Copilot's own bwrap shim (`lib/copilot-runner.mjs` `bwrapShim`, already first on its PATH) hands each call to the relay's shim for Copilot, at a path in the stage's own relay directory written into Copilot's shim for the stage (on a runner and in a cloud sandbox once the relay opens, locally by `serve`), never read from an environment; concurrent stages on one host each hand to their own, and a Copilot stage removes the one shared path an earlier Factory used. For a Copilot command sandbox that shim also binds the socket's directory read-only and sets `NO_PROXY`/`no_proxy` to `127.0.0.1`, so loopback stays off Copilot's proxy and reaches the namespace's own forwarder. `allowLocalNetwork` stays false and Copilot's host allow-list is unchanged. Copilot's filter lets commands open a Unix socket, so a Copilot command can reach the relay's socket directly too: it is the same relay behind the same token check. The worker needs no egress to the gateway's host. Locally the outer sandbox keeps the socket's directory reachable, never writable; it is the one socket directory it does not hide.
- **How commands reach it:** the relay listens on a Unix socket in a directory the agent cannot write (a directory of the stage's own under `/var/lib/factory-relay` on a runner and in a cloud sandbox, the socket group-readable by the agent at 660, removed when the stage ends). Locally, and in a developer's own session, the socket goes in a short directory of the stage's own, `/tmp/factory-relay-<uid>/<12 hex>`, named by a hash of the stage's usage directory: both levels 700, the socket 600. A Unix socket's path is limited to 107 bytes on Linux (103 on macOS), and the usage directory's path grows with the checkout's. `/tmp` (its real path) must be root's with the sticky bit, which alone keeps another user from swapping this user's directory there between Factory's checks and its listen; otherwise the stage Blocks before the run, naming why. A per-user directory that is a link, a file, another user's or open to others is refused, and the stage's socket goes in a private directory instead, `/tmp/factory-relay-<uid>-XXXXXX` (made by `mkdtemp`, 700). Locally `serve` makes the directory before the stage starts, records it in the stage's usage directory, names it in the outer sandbox and hands it to the stage, which takes no other; a stage that relays nothing has no socket directory reachable. After the listen the stage confirms the socket is in the very directory it checked, or Blocks before any command starts. The directory is removed when the stage ends; one a dead stage left, `serve` removes when it reaps the stage, and only if it still passes those checks at every level. A socket path that would still pass the limit Blocks the stage before the run, naming the limit. Claude Code's and Codex's sandboxes start every command with the `bwrap` first on PATH. Factory puts its shim there (`lib/relay-shim.mjs`, `bash -p`, builtins only), which starts a forwarder on the command's own network namespace's `127.0.0.1:<port>` to the socket before the command runs; it walks bubblewrap's options by their arity, so only `--unshare-net` before the command's separator marks a command sandbox. The harness's network policy, proxy and seccomp filter are unchanged; the forwarder carries one port to the relay alone, and the commands themselves cannot open the socket (both harnesses' filters refuse Unix sockets). Copilot's own bwrap shim (`lib/copilot-runner.mjs` `bwrapShim`, already first on its PATH) hands each call to the relay's shim for Copilot, at a path in the stage's own relay directory written into Copilot's shim for the stage (on a runner and in a cloud sandbox once the relay opens, locally by `serve`), never read from an environment; concurrent stages on one host each hand to their own, and a Copilot stage removes the one shared path an earlier Factory used. For a Copilot command sandbox that shim also binds the socket's directory read-only and sets `NO_PROXY`/`no_proxy` to `127.0.0.1`, so loopback stays off Copilot's proxy and reaches the namespace's own forwarder. `allowLocalNetwork` stays false and Copilot's host allow-list is unchanged. Copilot's filter lets commands open a Unix socket, so a Copilot command can reach the relay's socket directly too: it is the same relay behind the same token check. The worker needs no egress to the gateway's host. Locally the outer sandbox keeps the socket's directory reachable, never writable; it is the one socket directory it does not hide.
- **Probes and fail-closed starts:** before a stage's first relayed Codex pass, Codex's own sandbox (`codex sandbox`, the run's profile, the shim on PATH) must reach the relay's ping, or the stage Blocks (`relay`) before the run. Copilot's probe before every start (Copilot's sandbox, above) gets a rule of its own: a command in Copilot's sandbox reaches this relay's ping through the forwarder, or the stage Blocks (`relay`). Each command sandbox's forwarder pings the relay when it comes up, and a pass whose commands ran with no ping is logged. On a machine without `bwrap` or `socat` the stage Blocks before any run, with the key out of the shell. Factory never falls back to handing the key over.
- **Other opted-in secrets** keep the behaviour above: they reach the worker's shell as they are, and rely on their provider's own cap.

Expand Down
2 changes: 1 addition & 1 deletion plugin/scripts/lib/harness.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export const hiddenFromShell = (shellSecrets = []) => SHELL_SECRET_KEYS.filter((
* acts as, its progress comment's key, its read token, local mode's host-only settings. Each is read by the process it was
* given to (from process.env), never by a child, so scrubbedEnv removes them from every child.
*/
export const FACTORY_CONTROL_KEYS = ["FACTORY_STAGE", "FACTORY_STAGE_ISSUE", "FACTORY_STAGE_CLAIM", "FACTORY_STAGE_PR", "FACTORY_STAGE_RECORD", "FACTORY_STAGE_COMMAND_MS", "FACTORY_STAGE_SESSION_END", "FACTORY_STAGE_SESSION_CAP", "FACTORY_APP_SLUG", "FACTORY_PROGRESS_RUN", "FACTORY_READ_TOKEN", "FACTORY_IMAGE_CHECKOUT", "FACTORY_IMAGE_ENV_FILE", "FACTORY_ADAPTER_INSTALL", "FACTORY_SANDBOX_VCPUS", "FACTORY_LOCAL_FIRST_SETUP_MS", "FACTORY_LOCAL_APP_ID", "FACTORY_LOCAL_APP_KEY", "FACTORY_GATEWAY_URL", "FACTORY_STATE_DIR"];
export const FACTORY_CONTROL_KEYS = ["FACTORY_STAGE", "FACTORY_STAGE_ISSUE", "FACTORY_STAGE_CLAIM", "FACTORY_STAGE_PR", "FACTORY_STAGE_RECORD", "FACTORY_STAGE_COMMAND_MS", "FACTORY_STAGE_SESSION_END", "FACTORY_STAGE_SESSION_CAP", "FACTORY_APP_SLUG", "FACTORY_PROGRESS_RUN", "FACTORY_READ_TOKEN", "FACTORY_IMAGE_CHECKOUT", "FACTORY_IMAGE_ENV_FILE", "FACTORY_ADAPTER_INSTALL", "FACTORY_SANDBOX_VCPUS", "FACTORY_LOCAL_FIRST_SETUP_MS", "FACTORY_LOCAL_APP_ID", "FACTORY_LOCAL_APP_KEY", "FACTORY_GATEWAY_URL", "FACTORY_STATE_DIR", "FACTORY_RELAY_SOCKET_DIR"];

/**
* Factory's internal names that do reach children, on purpose: the hand-off and the task card's (FACTORY_IN, FACTORY_OUT,
Expand Down
5 changes: 3 additions & 2 deletions plugin/scripts/lib/local.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1518,8 +1518,9 @@ export function hostSockets(env = {}, uid = process.getuid?.() ?? 0, { roots = S
* `projectDir`: the directory claudeProjectLink links the clone's Claude Code project to, writable here (Claude Code only).
* `bwrapPath`: the bubblewrap srt runs (Copilot: writeBwrapTun's wrapper).
* `ripgrep`: nestedGitScanScript's path (writeNestedGitScan).
* `relaySocketDir`: the directory of the stage's gateway relay socket (worker-relay.mjs relaySocketDir): the one socket
* directory that stays reachable inside, for the forwarder Factory's bwrap shim starts in each command's sandbox
* `relaySocketDir`: the directory of the stage's gateway relay socket, as serve made it (worker-relay.mjs handSocketDir;
* null for a stage that relays nothing, which then reaches no socket directory at all): the one socket directory that
* stays reachable inside, for the forwarder Factory's bwrap shim starts in each command's sandbox
* (relay-shim.mjs); never writable.
*/
export function harnessOuterSettings({ harness, domains = LOCAL_DOMAINS, writable = [], clone = null, home = homedir(), env = {}, readOnly = [], install = [], projectDir = null, bwrapPath = null, relaySocketDir = null, ripgrep = null } = {}) {
Expand Down
Loading
Loading