Skip to content

fix(env): the gateway's control routes take the deploy's control token - #492

Open
earakely-scale wants to merge 2 commits into
mainfrom
edgararakelyan/gateway-control-token
Open

earakely-scale wants to merge 2 commits into
mainfrom
edgararakelyan/gateway-control-token

Conversation

@earakely-scale

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

Copy link
Copy Markdown
Collaborator

Why

The env gateway serves the harness's control routes on the same listener it gives the agent as its MCP server, with no check of who's calling. Any caller that reaches the gateway, the agent included, could:

  • lift or set any role's tool rules;
  • erase the task's triggers and register its own;
  • set, freeze or clear the virtual clock;
  • rewrite the notification config.

Computer envs already take a per-deploy control token on their framework-only routes. This gives the gateway the same.

What changes

  • Gateway (env/gateway/).
    • It reads AGENTENV_CONTROL_TOKEN, mirrored in constants.py like the other protocol constants.
    • When it has one, it refuses any call to CONTROL_ROUTES that doesn't carry Authorization: Bearer <token>, with 401 and {"ok": false, "error": "this route takes the env's control token"}. Those routes are:
      • /tools/enable|disable;
      • /triggers/register|remove|clear;
      • /clock/set-time|advance|clear;
      • /notifications/config|take|clear.
    • The check sits in the ASGI wrapper, before routing, with hmac.compare_digest.
    • Every other route stays open for now: /mcp, the card, the site routes, /step, /state, the state reads, /svc and /agentenv.
  • Provider.
    • deploy() draws a token once per deploy, with the computer env's control_token(), so each attempt of a provider chain uses the same one.
    • It passes the token to the gateway in its compose environment (VM mode) or its container env (container mode), and records it on DeployedGatewayEnv.control_token. That field already existed and is serialized.
    • The logged compose masks it, like every *TOKEN* key.
  • create_gateway takes an optional control_token. Its callers build their own records, and the SDK's CUA and iOS envs don't copy fields they don't know. So it draws none by default, and those gateways stay open as today.
  • Callers. Every framework caller of these routes already goes through DeployedEnv.invoke, which sends the record's control headers: tool access, trigger registration, scheduled checks, notification delivery, sync_env_clock and post-turn hooks. No other repo calls these routes; checked in the Scale monorepo and synthetic-pipelines.
  • What still runs open:
    • a gateway started without a token: an older framework, a Harbor, Standalone or preloaded export, or a create_gateway caller that passes none;
    • an older gateway image, which ignores the token.

Tests

  • Unit:

    • every covered route refuses no token, a wrong token and a non-bearer value, and changes nothing;
    • every covered route answers with the token;
    • every other route stays open;
    • a gateway started without a token checks nothing;
    • the compose hands the token to the gateway alone and the logged copy masks it;
    • container mode passes it to the gateway's container alone;
    • deploy() records it and create_gateway forwards it.
  • Integration:

    • integration tests that call these routes directly now send the record's headers, as framework code does;
    • test_gateway_clock_extension checks that a caller without the token gets 401 on every covered route, changes nothing, and can still read /clock/time. It fails on main, where the no-token caller arms the clock.
  • On an agent-environments-admin EC2 box:

    • the full gateway suite (27 passed);
    • tst/integration/task_step (36);
    • the env, artifact and bundle suites (82);
    • the unit suite, whose only failures also fail on main on that box.
  • Chaos, main against this branch on the same box:

    Scenario main this branch
    All 10 covered routes, no token or a wrong one 200; afterwards tool rules wildcarded, an attacker's trigger registered, clock cleared 401; nothing changes
    The website browser, from inside the env, POSTing to http://gateway:18765/tools/disable and two more every tool disabled for every role nothing changes
    After a gateway restart and after docker compose up -d --force-recreate 200 401
    4 concurrent deploys no tokens 4 distinct tokens; one deploy's token gets 401 on another's gateway
    The framework's own calls through the record, including a record reloaded from stored JSON work work

    Mixed versions on the box: this branch's framework with main's gateway image, and main's framework with this branch's image. Both stay open, exactly as today, and the framework's calls work.

  • Real Modal, from a MultiEnv with an MCP server, on modal_vm and on Modal containers:

    • without the token, every covered route gets 401, and the clock, role rules and tool list are unchanged;
    • through the record, disabling and re-enabling a tool, arming the clock and registering a trigger all work, and /clock/time answers.

Rollout

Nothing changes in prod until the gateway image this builds is promoted and Scale's framework pin is bumped. Either alone keeps today's behaviour.

Not covered here

  • The rest of the gateway's control surface: /svc, /agentenv, the state reads, /trajectory and /changes. Also the SQL consoles. These come in a follow-up, because callers outside this repo use them and need the token first.
  • /step, /state, GET /clock/time and child cards stay open on purpose: generated MCP CLIs, OpenClaw skills, Harbor verifiers and synced env servers call them from sandboxes.

🤖 Generated with Claude Code

RetriggerView in GreptileConfidence Score: 5/5

The PR appears safe to merge; no actionable defects were found.

Summary

The gateway now checks a per-deploy token before serving the covered control routes.

  • Gateway control routes now require the deployment's token when one is set.

No actionable issues were found. Tests were inspected, not run.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    D[Deploy] --> T[Create one token]
    T --> G[Gateway environment]
    T --> R[Deployment record]
    R --> F[Framework request with token]
    F --> C{Covered control route?}
    A[Agent request] --> C
    C -->|No| O[Existing route behavior]
    C -->|Yes| V{Token matches?}
    V -->|Yes| H[Control handler]
    V -->|No| U[401 before handler]
Loading

Reviews (2) · Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." · Reviewed by Greptile

Each gateway deploy draws a control token, as computer envs already do, passes it to the gateway as AGENTENV_CONTROL_TOKEN and keeps it on the deploy record. The gateway then refuses any call without it to the routes that change tool access, triggers, the clock and notifications. Their framework callers already send the record's control headers. Every other route stays open for now.

A gateway started without a token checks none: an older framework, an export, or a create_gateway caller that passes none. An older gateway image ignores the token.

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 4, 2026 18:58
…y-control-token

# Conflicts:
#	src/agent_env/env/gateway/entrypoint.py
#	src/agent_env/env/gateway/gateway.py
#	src/agent_env/providers/env_providers/env_gateway_provider.py

This branch has not been deployed

No deployments
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