Skip to content

feat(computer)!: only agent-env reaches a computer's framework-only routes - #430

Merged
earakely-scale merged 18 commits into
mainfrom
edgararakelyan/fd-4612-m28-control-token
Nov 1, 2026
Merged

earakely-scale merged 18 commits into
mainfrom
edgararakelyan/fd-4612-m28-control-token

Conversation

@earakely-scale

@earakely-scale earakely-scale commented Nov 1, 2026 •

Copy link
Copy Markdown
Collaborator

What

Only agent-env reaches a computer's framework-only routes. Before this, a computer's driver served computer-exec, the data plane, tool access, recordings and the display setter on the same port as its MCP endpoint, with no auth. An agent with a shell could curl computer-exec/run and solve a GUI task from the command line, re-enable a tool its task disabled, or end a recording.

Now, when agent-env deploys a computer, it boots it with a per-deploy control token, and the computer refuses every framework-only call that doesn't carry it:

  • The token. It's made per deploy (secrets.token_urlsafe(32)) and passed to the computer as AGENTENV_CONTROL_TOKEN, beside its settings.

    • It's kept on the deployment record (DeployedEnv.control_token), so a resumed run or another worker can still reach the computer.
    • A reset reboots the computer with the same token.
    • Gate 3's probe boots with a token of its own.
  • What's guarded. Everything the protocol SDK mounts except the MCP endpoint and the card:

    • the data plane (POST /agentenv);
    • every @extension route;
    • the tool-access routes;
    • the driver's /svc/<app>/ routes to its apps.

    A call without the token, or with another, is answered 401 unauthorized before the route runs. An agent reaches MCP and the card exactly as before.

  • Unchanged without a token. An env run by hand, or deployed by an older agent-env, is served exactly as before.

  • agent-env's own calls carry it:

    • DeployedEnv.invoke;
    • the computer's loads, file reads and commands (inside);
    • boot's readiness;
    • snapshots, apply_server_config, sync_env_clock and post-turn hooks.
  • Every step gets it without asking. Task.run runs each step under a grant of each deployed env's control headers (protocol_v1.authorized). A protocol-client call a step makes to a deployed env's address, naming no headers of its own, carries that env's token, and no other address gets it. So a verifier like the shipped desktop example's reaches computer-exec as written, and so does a plugin step. The grant ends with the step.

  • Scripts inside the computer. A run_code script that runs inside the computer gets the token as input["env"]["headers"]. A script that runs in an agent's sandbox never gets it.

  • The conformance probe sends the token it's given (run_probe(..., token=), or --token, defaulting to $AGENTENV_CONTROL_TOKEN). Given one, it also checks that a framework-only route refuses a call without it and with a wrong one.

Issue: FD-4612 (M2.8). The plan is on the issue. Follow-up for the gateway and for MCP server and website envs: FD-4913.

Breaking: a script running inside a computer that calls the driver's framework-only routes now has to send input["env"]["headers"]. Reading the card, and the MCP endpoint, need nothing.

Builds on #426 (M1.4, merged), whose /svc/<app>/ routes and app servers this guards.

How

  • Protocol SDK.
    • CONTROL_TOKEN_ENV lives in types.py.
    • control_guard() in agent_env_environment.py wraps a handler: with the variable set, it compares the Authorization header to Bearer <token> with hmac.compare_digest, and otherwise answers 401 with the protocol's error body. add_routes_to_app wraps the data plane, each extension route and the tool-access routes with it; the card and MCP aren't wrapped.
    • The driver wraps its /svc/{name}/{path} route the same way.
    • An app's own server (serve_app) drops the variable before it mounts. It serves on a Unix socket only the computer reaches, and the menu launcher and the driver's readiness checks call it there without one.
  • Protocol client.
    • reset_data, add_data, get_data, invoke_extension and get_interface_manifest take headers.
    • control_headers(token) builds them.
    • authorized({url: headers}) is a context-variable grant: a call that names no headers sends those of the granted address it's at or under. request_headers(url, headers) decides which, through granted_headers(url, grants), the one address rule. The grant carries headers, not a token, so only control_headers knows the token's form.
    • The probe's Driver takes a token, and falls back to a grant without one, as the recording test's verifier needs.
  • Framework.
    • DeployedEnv.control_token (stored, read back by _fields_from), control_headers, and control_headers_for(url), the token only for a URL at or under the env's address. Snapshot captures and service_state use it, so a gateway_url override never gets the token.
    • Task.run runs each step under the grant. Core still passes deployed.control_headers explicitly, since its helpers also run outside a step (deploy, DeployedEnv.invoke, tests that call a step directly).
    • env_computer_provider makes the token at deploy, boots with it, records it, and reboots with the record's.
    • boot(..., token=) hands the computer its token (control_environment) and asks ready with it; boot.control_token() makes one.
    • The composer's probe boots and probes with its own.
    • verify_env_core_protocol probes the data plane with the token, and reads a 401 as unknown rather than as support.
  • Docs. The protocol README has a section on framework-only routes and the token, and a note on the probe's token. AGENTS.md and run_code's docstring say where the token comes from.

Not in this PR

  • The gateway's mutating endpoints, and MCP server and website envs: FD-4913. The SDK half is ready for SDK-built MCP servers once their provider passes a token.
  • Code inside the computer can read the token from its environment, as it can the computer's secret settings (M0.9, choice 15). The driver runs as the session user, so an agent that opens a terminal through the GUI can read it and call the framework-only routes on loopback. Keeping the token from the session is FD-4922.
  • Persistence. The token is kept in the run's stored context, in the env-instances record and in agent-env task run's local output file, like the env's address. It's useless once the computer is torn down.
  • Images composed before this change. They keep their older driver, which ignores the token, so their framework-only routes stay open until the env and its driver are put again with this release. A deploy doesn't check this. A new put composes the image afresh, and gate 3 checks the refusal.

Testing

Unit (on an agent-environments-admin c6i.4xlarge, Ubuntu 24.04, on this PR's head, ed9e0bf): 12,460 passed. The three that failed fail on main on the same box too: run_docker_container_test needs unzip, and two test_mcp_cli_builder tests need httpx on the system Python. New and extended tests:

  • Protocol:
    • test_control_token.py:
      • with the token, the data plane, an extension route and tool access refuse a call without it, or with another, before the route runs, and answer one with it;
      • the card and MCP stay open, and without the variable every route is open;
      • a call sends its own headers, or the token its grant gives an address under the env's (nested grants restore on exit);
      • invoke_extension and the data plane send them.
    • The driver forwards to an app only with the token. An app's server drops the variable.
    • legacy_protocol.service_state reads a child's state with the deployment's token (Greptile).
    • The probe, given the token, passes and checks the refusal; without it, it fails with 401.
  • Framework:
    • A deploy makes a token, boots with it, records it, and gets a fresh one per deploy. A reset reboots with the record's.
    • Gate 3's probe boots and probes with its own.
    • Boot's readiness asks with it.
    • A record sends it on every extension call, gives it only at or under its address, and keeps it through storage.
    • The computer's loads, reads and commands send it, and a run_code script inside the computer gets it in input["env"].
    • Under Task.run (control_grant_test.py), a step's calls carry each deployed env's own token, an env without one gets nothing, an env an earlier step deployed is granted to the steps after it, a call's own headers win, and the grant ends with the step.
    • A snapshot sends it on data/get and on a trajectory read from the card's address, and not to an override.
    • The core-protocol validator probes with it, and a 401 reads as unknown.

Integration (local Docker on the same box): on ed9e0bf, desktop_example_test.py, computer_env_test.py and computer_app_test.py passed, 13 tests, with 1 skip for model_endpoint_configured. The desktop example's and the recording task's verifiers run through agent-env run, so they reach the computer through the step grant. On the first head, base_computer_test.py and recording_chaos_test.py passed too, all but one test (see below). They include the new test_only_agent_env_reaches_a_deployed_computers_framework_only_routes_while_its_mcp_and_card_stay_open:

  • A deployed desktop refuses, without the token and with a wrong one, computer-exec run, computer-ready status, computer-display get, computer-recording end_all, disable-tool and data/get. The refused command left nothing behind.
  • A container on the host network gets 401 for computer-exec.
  • The card answers, and MCP lists and calls the computer_* tools.
  • agent-env's own driver calls work, and after a reset the same token still works and none is still refused.

The app data-plane test passes with the token through load, snapshot, the in-computer reset of each app over /svc/<app>/, and snapshot.

The one integration failure. recording_chaos_test::test_a_restarted_driver_forgets_its_recording_and_keeps_the_segments_it_closed restarts a desktop container started with plain docker run. That container has no token, so the guard is off there. It then waits 60 s for it to be ready again, and timed out. It also failed once on #426's CI and passed on rerun. Run alone on the same box, it passed 3 of 3 on this branch and 3 of 3 on main, in the same ~55 s. So it's a timing flake, not this change: after the other suites, its 60 s wait ran out. This PR now gives the restarted desktop the 180 s a deploy gives a booting computer (boot.READY_TIMEOUT_S); a desktop that never comes back still fails.

Chaos (a second agent-environments-admin box, terminated; about $1 of the $20). Seven scenarios on real computers:

  • Header variants: none, wrong, a lowercase scheme, padded, a bare token, Basic, a custom header, a query parameter, a prefix of the token, a longer token, and another computer's token. Each got 401 on all six framework-only routes; only the computer's own token got 200. Nothing ran.
  • An agent's container: on the Docker bridge (host.docker.internal, as local agents reach envs) and on the host network, computer-exec got 401, while the card and MCP initialize got 200.
  • A reset, then a driver restart (supervisorctl restart): the token kept working and none kept being refused. Commands ran after.
  • Three concurrent deploys had three tokens; each computer took only its own (a 3x3 matrix).
  • A record written to disk and loaded in a new process ran a command. With its token stripped, the call was refused: 401 unauthorized.
  • 300 refused calls during 10 real commands: every refusal was 401, every command ran, and the server stayed up.
  • A computer with apps: /svc/notes and /svc/clock gave 401 without the token or with a wrong one, and 200 with it. The menu launcher still launches an app over its socket.

🤖 Generated with Claude Code

RetriggerView in GreptileConfidence Score: 5/5

The PR appears safe to merge within its documented rollout limits.

Summary

Adds per-deploy control tokens to protect a computer's framework-only routes while leaving MCP and the card open.

  • Framework-only computer routes require the computer’s control token.
  • Task steps inherit control headers only for the deployed environment they call.
  • Each computer deployment keeps its token through resets and framework operations.
  • The computer probe checks that framework routes reject missing or wrong tokens.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Framework["agent-env calls"] --> Headers["Deployment's control headers"]
  Headers --> Guard["control_guard"]
  Agent["Agent without token"] --> Guard
  Guard -->|Matching token| Routes["Framework-only routes"]
  Guard -->|Missing or wrong token| Refused["401 unauthorized"]
  Agent --> Public["MCP and card"]
Loading

Reviews (5) · Last reviewed commit: "refactor(env): one rule for where a cont..." · Reviewed by Greptile

earakely-scale and others added 8 commits October 31, 2026 19:18
- An app declares the paths it keeps its data in under the session user's
  home (@linux(paths=...)). With no data hooks, they serve its data plane:
  data/add writes files under them, data/get returns them as a file tree
  (declared on its card), data/reset puts back the copy its server keeps
  before it first serves on a boot, the app as installed.
- @add_data, @get_data and @reset_data hooks override paths. The base class
  serves either around the app's contract: a load and a reset stop the app
  it launched (its pid kept in the app's folder) and relaunch it after, a
  load that fails is followed by a reset, and each call runs within
  data_timeout_s.
- An app serves its skill file through skill/v1, unless it writes its
  skill itself.
- computer-app/v1's params carry the app's paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nments

- The driver's card lists each app composed onto the computer as a child
  environment, named for its folder: its own server's card with every path
  under /svc/<name>/, or its name and route alone while its server isn't
  answering. A new AgentEnvEnvironment hook, children_environments(), lists
  them each time the card is served.
- The driver forwards every request under /svc/<name>/ to that app's
  server on its socket, with no timeout of its own; a name it has no app
  for is a 404.
- The driver's own data/get leaves out the files under any path an app
  keeps its data in, as the apps' cards say.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s apps by name

A load sends each environment to the data plane its name reaches: the computer's own to the driver,
an app's to the app's route. A computer's snapshot keeps its files beside each app's export, with
data/get params given per environment, and leaves out an app that keeps nothing yet. add_skills
installs the computer's skill and each app's. Gate 1 refuses apps whose paths overlap and an app
named like its computer; gate 3's probe asks each app for its export once; boot records the card
read after ready, with every app on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ut safely

Data calls and launches take one turn at a time, so two loads never overlap and a launch never
starts the app mid-load. A sync hook runs in a thread, timed from the start; a call past
data_timeout_s fails then, and its reset and relaunch, and the next call, wait for its work to end.
Stopping an app ends its whole process group, though its first process exited. An app's paths may
not overlap, and a load may not write through a link out of them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ing for its threads

A shielded async hook that never ended held the app's turn for good, so every later data call and
launch timed out. A call past data_timeout_s is now cancelled: what awaits stops at once, and a step
run in a thread, a sync hook's or the defaults' disk work, is still waited for to its end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ent the app data plane

An app's @add_data hook gets its parts as they were sent, inline or as a read grant;
agentenv_protocol.computer.part_bytes returns a part's bytes either way, as the paths defaults
read them. The protocol README now says how an app's server serves its data plane and skill and
how the driver fronts its apps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s apps

A computer env now checks its env.toml before anything is written (accept_toml), and a computer
env's and a computer app env's environment_name default to their folder's there too, so the bundle
check knows a computer env's name and its apps' and refuses a seed named after none of them, as it
does for an MCP server's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…outes

agent-env boots each computer with a per-deploy control token and keeps it on the deployment
record. With the token set, the SDK refuses a call that doesn't carry it on every route but the
MCP endpoint and the card: the data plane, every extension route, tool access, and the driver's
app routes. agent-env's own calls send it; env_outcome_verifier runs a task's verify() under a
grant of it; a run_code script inside the computer gets it as input["env"]["headers"]; the
conformance probe takes it and checks a framework-only route refuses a call without it. An env
booted without a token is served as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@earakely-scale
earakely-scale requested a review from a team as a code owner November 1, 2026 07:58
earakely-scale and others added 3 commits November 1, 2026 01:03
…oting computer

Run alone the restart test passes in ~55 s on this branch and on main; after the other computer
suites on a loaded machine, its 60 s wait ran out (once on #426's CI, once on an EC2 box).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ame the token's size

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
earakely-scale and others added 2 commits November 1, 2026 07:41
…erations and the driver's app errors in one shape

The data-call logic (turns, stop and relaunch, the reset after a failed load, timeouts) moves out of
ComputerAppEnvironment, the class app authors subclass, into app_data.AppDataPlane, which the app's
handlers and launch hand their calls to. client.operations(card) replaces each hand-rolled read of a
card's capabilities.operations. The driver answers a request for an app it doesn't have, or one whose
server doesn't answer, with the protocol's errors (target_unavailable, delivery_failed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, the driver's app errors

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Base automatically changed from edgararakelyan/fd-4369-m14-app-data-planes-through-the-driver to main November 1, 2026 16:08
earakely-scale and others added 5 commits November 1, 2026 08:15
… a 401 is unknown

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ol headers

A protocol-client call a step makes to a deployed env's address, naming no headers of its
own, sends that env's control headers, so plugin steps and verifiers reach a computer's
framework-only routes without passing the token. The grant now carries headers, not a token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pass the token, not its variable

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… address, as a step's grant

DeployedEnv.control_headers_for(url) gives the token only to a URL at or under the env's
address, through protocol_v1.granted_headers, the rule request_headers applies to a grant.
Snapshot captures and service_state use it instead of checking for a gateway_url override.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@earakely-scale

Copy link
Copy Markdown
Collaborator Author

@greptileai review

@earakely-scale
earakely-scale merged commit d4eedd5 into main Nov 1, 2026
29 checks passed
@earakely-scale
earakely-scale deleted the edgararakelyan/fd-4612-m28-control-token branch November 1, 2026 17:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant