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
18 changes: 9 additions & 9 deletions .claude/plans/p1-sdk-egress-routes.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,16 +145,16 @@ CAS sends no `COMPUTERAGENT_*` URLs, no provider key, no git token, no MCP key a
| D20 | Model gateway handling (C1) | Removed: `_require_supported`'s `model_gateway` branch (`agentos_agent.py:177-184`; the `orchestration` refusal stays), `_require_consistent_gateway` (`:193-209`, call `:265`), `ResolvedAgent.gateway_type`, `gateway_base_url`, `gateway_completions_url`, `has_native_gateway` (`:70-75, 86-88`), the `gateway=` field of the resolve debug log (`:290-297`). `dispatch_model` (`gateway_envs.py:43-58`): for gitagent a model without `:` gets `openai:` (every model call goes to `llm_url`, the agent gateway), a prefixed one is unchanged; the Claude engine strips a `provider:` prefix as today. The kong model is otherwise sent unchanged. | AgentOS no longer returns `model_gateway`/`modelGatewayId`; the agent gateway accepts only its kong route's model (contract §7.1). | no |
| D21 | Per-turn key (C2) | `gateway_envs.key_envs(api_key) -> dict[str, str]` returns `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `LYZR_API_KEY`; `build_gateway_envs(*, llm_url)` keeps the URLs and flags only. `HarnessCoordinator` takes `api_key: Callable[[], str]` (`Settings.api_key`) and the bound server names from `build_mcp_servers`; boot (`coordinator.py:208`) and every `run_chat` (`:367-380`) use `{**self._envs, **key_envs(api_key())}`; each turn's manifest is a copy whose bound MCP servers carry `headers["x-api-key"] = api_key()` (GAP `gap_tools` and caller-supplied servers untouched). `LYZR_API_KEY` unset at a turn → `SettingsError` before the engine starts, no value in the message. An explicit `api_key=` is the root of its chain: every turn uses it or its newest reissued successor (D2), never the env. Every other SDK HTTP call gets the newest key from the D2 request hooks; the Claude CLI's requests also pass the D25 forwarder. | The Claude CLI and gitagent read keys from the per-turn env and MCP headers from the manifest; old CAS sends a fresh token every turn and the next turn may come from another caller (old-CAS G15, Q17). | no |
| D22 | App server (C3; old-CAS G14) | Shipped from the SDK as `computeragent.server` (extra `server`: `starlette>=0.37`, `uvicorn>=0.30`), run as `python -m computeragent.server` on `0.0.0.0:8080` (pending Q16). Routes, bodies and events are CAS's harness REST contract (phase 11 table; reference `computeragent-enterprise/packages/harness-server/src/routes/*`). One `ComputerAgent` per harness `sessionId`, built at `POST /v1/sessions` from the image's settings with **no** `api_key=` (so `Settings.api_key()` reads `os.environ`); `envs.LYZR_API_KEY` seeds `os.environ["LYZR_API_KEY"]`, every other `envs` key is ignored; `messages` (resume history, Q18) and `options` (caller options) passed through; `identity.source` ignored (the agent is baked into the image), `engine` only validated. Before each `chat()`: `os.environ["LYZR_API_KEY"] = credential` when the message has one. Turns run one at a time per process (the key is process-wide). The app never logs `envs`, `credential` or request headers. | One app for every stage image; old CAS uses only existing wire fields (G15). | yes: `src/computeragent/server/app.py` |
| D23 | Events (C3) | `server/events.py` maps the coordinator's `EngineEvent`/`TelemetryEvent` stream onto `computeragent-enterprise/packages/protocol/src/sse-events.ts`: `sdk_message` (engine messages verbatim), `ca_session_started`, `ca_turn_started`, `ca_permission_request` / `ca_permission_decision` (the coordinator's permission resolver, answered by `POST …/permission/:callId`), `ca_usage_snapshot`, `ca_warning`, `ca_redaction_decision` (guardrails `rewrite`/`block`, D5), and always a final `ca_session_ended`, also on error and cancel. The per-event field mapping is fixed by Q17 before `implement`. | CAS turns these into its `ca_*` stream for AgentOS and ends a turn only on `ca_session_ended`. | yes: `src/computeragent/server/events.py` |
| D23 | Events (C3) | `server/events.py` maps the coordinator's `EngineEvent`/`TelemetryEvent` stream onto `computeragent-enterprise/packages/protocol/src/sse-events.ts`: `sdk_message` (engine messages verbatim), `ca_session_started`, `ca_turn_started`, `ca_permission_request` / `ca_permission_decision` (the coordinator's permission resolver, answered by `POST …/permission/:callId`), `ca_usage_snapshot`, `ca_warning`, `ca_redaction_decision` (guardrails `rewrite`/`block`, D5), and one `ca_session_ended` when the session ends (input closed, cancel, delete, engine error). As in the reference harness (`session.ts`, `run-session.ts`), a turn ends with its `result` `sdk_message` (a blocked input too, marked `ca_policy_blocked`), not with `ca_session_ended`; the turn's `ca_usage_snapshot` is sent before its `result`, since the CAS client stops reading a turn there. Tool-gate decisions arrive as `ca_permission_decision` with `source: "policy"`. | CAS turns these into its `ca_*` stream for AgentOS and ends a turn only on `ca_session_ended`. | yes: `src/computeragent/server/events.py` |
| D24 | AgentCore (C4; old-CAS G14, PR #158) | The same process serves `GET /ping` → `{"status": "Healthy" \| "HealthyBusy", "time_of_last_update": <unix seconds>}` (`HealthyBusy` while any turn runs) and `POST /invocations` = relay envelope v1 (normative copy `computeragent-enterprise/packages/runtime-agentcore/src/relay.ts`): request `application/json` `{relay: 1, method, path, headers, body \| bodyBase64}` (`path` includes the query; `bodyBase64` when the bytes are not UTF-8), dispatched as an in-process ASGI call to the D22 app (body chunks streamed through, no buffering); response always HTTP 200: an SSE answer is `text/event-stream` whose first frame is the comment `: ca-relay {"instance":"<nonce>","status":<code>}` followed by the handler's bytes verbatim, anything else `application/json` `{relay: 1, instance, status, headers, body \| bodyBase64}`; `instance` is a random nonce generated once per process; a malformed envelope → 200 JSON envelope with `status: 400`. The image is built `linux/arm64`. | AgentCore turns a 4xx/5xx into a body-less `RuntimeClientError`; CAS reads a changed `instance` as "AgentCore replaced the microVM" and fails the turn cleanly; one code path for both runtimes. | yes: `src/computeragent/server/relay.py` |
| D25 | Claude CLI through a loopback forwarder (R6) | New `src/computeragent/harness/egress_forwarder.py`: `EgressForwarder` listens on `127.0.0.1:0`, one listener per egress origin the CLI uses (normally one), started by the coordinator on its event loop before the first Claude CLI turn and closed with the coordinator; HTTP/1.1 over asyncio streams with `h11` (already installed through `httpx`'s `httpcore`; declared in the `agentos` extra). Each request is replayed on its egress origin with the same method, path, query and end-to-end headers through one `httpx.AsyncClient` with the D2 hooks, so it is stamped with `resolve(sent)` and its answer observed; bodies stream both ways without buffering (SSE from `/llm/v1/messages`, MCP streamable HTTP); hop-by-hop headers are dropped; a transport failure → 502 without detail. It forwards only to egress origins. Where the coordinator builds the Claude engine's env and options (`coordinator.py:208, 215-224`; per turn after phase 10), it rewrites `ANTHROPIC_BASE_URL` and every `mcp_servers[*].url` on an egress origin (bound and caller-supplied servers alike) to the forwarder's origin, path unchanged. gitagent does not use it: its clients are hooked in-process. | The CLI's env and headers are fixed at spawn and its answers are invisible to the SDK. The CLI's own `apiKeyHelper` (cached 5 min, rerun on 401/403) and MCP `headersHelper` (run at connect, rerun on 401/403) could pick up a new token but never show the SDK a reissue, so a stretch of only CLI traffic in the token's last 15 min would end in 401. The forwarder sees every CLI request and answer. | yes: `src/computeragent/harness/egress_forwarder.py` |

### Open questions
| # | Question | Owner |
|---|---|---|
| Q16 | Does the SDK ship the app server (`computeragent.server`, D22), or does each stage image write its own? Recommendation: ship it; phases 11–12 assume so. | SDK team |
| Q17 | Which `sse-events.ts` events can the SDK produce today from its `EngineEvent`/`TelemetryEvent` stream, and which need new mapping (`ca_permission_request` for approvals, `ca_usage_snapshot`, `ca_redaction_decision`)? Fixes the D23 mapping. | SDK team |
| Q18 | Session resume: CAS sends prior turns in `CreateSessionBody.messages` and may send an `http` session-store descriptor (phase 7); which does the app use? | SDK team |
| Q16 | Does the SDK ship the app server (`computeragent.server`, D22), or does each stage image write its own? **Resolved (user, 2026-10-26): the SDK ships it**; each stage image installs `computer-agent-py[server]` and runs `python -m computeragent.server`. | SDK team |
| Q17 | Which `sse-events.ts` events can the SDK produce today from its `EngineEvent`/`TelemetryEvent` stream, and which need new mapping (`ca_permission_request` for approvals, `ca_usage_snapshot`, `ca_redaction_decision`)? Fixes the D23 mapping. **Resolved (user, 2026-10-26: the plan's mapping)**: D23 as amended. | SDK team |
| Q18 | Session resume: CAS sends prior turns in `CreateSessionBody.messages` and may send an `http` session-store descriptor (phase 7); which does the app use? **Resolved (user, 2026-10-26: the plan's D22)**: `messages` are the session's first turns, as in the reference harness (`streamingInput: false` closes input after them); resume across sandboxes is the `http` session store keyed by `sessionId` (phase 7). | SDK team |
| Q19 | Idle tokens: the proxy reissues only on an answer in the token's last 15 min. With no egress call in that window (an idle library-mode agent; the app server gets a fresh token per turn from old CAS), the token expires and the next call gets 401. Add a keepalive, e.g. `POST /llm/v1/messages/count_tokens` (answered locally by the proxy) every ≤ 10 min while the process holds an unexpired chain? Not in this plan until decided. | SDK team |

Resolved in this round (2026-10-26):
Expand All @@ -167,7 +167,7 @@ Resolved in this round (2026-10-26):
- OQ4 (non-ASCII MCP names): option B. AgentOS restricts `tool_name` (old-CAS directive O5); the SDK does no check of its own.
- Sandbox prefix: `/computer-agent-api` is kept (proxy D5).

Resolved and designed here: R2 (phase 1), Q4 events (phase 2), R4 + Q9 + Q10 (phase 3), R5 (phase 4), R6 / row 15 (phase 5), Q3 OTLP (phase 6), R7 + Q13 + Q14 (phase 7), Q1 + Q15 MCP (phase 8), C1 agent document (phase 9), C2 per-turn key (phase 10), C3 app server (phase 11, pending Q16–Q18), C4 AgentCore (phase 12), Q7 (D15, docs only). R1, Q8 and Q11 need no SDK change.
Resolved and designed here: R2 (phase 1), Q4 events (phase 2), R4 + Q9 + Q10 (phase 3), R5 (phase 4), R6 / row 15 (phase 5), Q3 OTLP (phase 6), R7 + Q13 + Q14 (phase 7), Q1 + Q15 MCP (phase 8), C1 agent document (phase 9), C2 per-turn key (phase 10), C3 app server (phase 11; Q16–Q18 resolved), C4 AgentCore (phase 12), Q7 (D15, docs only). R1, Q8 and Q11 need no SDK change.

## 4. Design

Expand Down Expand Up @@ -502,17 +502,17 @@ Files (8): `gateway_envs.py`, `harness/__init__.py`, `coordinator.py`, `test_tur

### Phase 11: app server on Lambda MicroVM (C3)
- Commit: `feat(server): serve the CAS harness API from the stage image`
- Needs phase 10. Pending Q16 (who ships it), Q17 (event mapping), Q18 (resume).
- Needs phase 10. Q16–Q18 resolved (§3).

Routes (D22; reference `computeragent-enterprise/packages/harness-server/src/routes/*`):
| Route | Contract | Notes |
|---|---|---|
| `POST /aws/lambda-microvms/runtime/v1/{ready,validate,run,suspend,resume,terminate}` | 200 | Lambda MicroVM lifecycle hooks; the endpoint forwards no traffic until `/run` answers 200. |
| `GET /v1/health` | 200 `{ok: true}` once the SDK and engines import | CAS's readiness probe. |
| `POST /v1/sessions` | `CreateSessionBody` → 201 `{sessionId}` | D22: `envs.LYZR_API_KEY` only; `messages`, `options` used; `identity.source` ignored; `engine` validated. |
| `POST /v1/sessions` | `CreateSessionBody` → 201 `{sessionId, engine, identity, capabilities, eventsUrl}` | D22: `envs.LYZR_API_KEY` only; `messages`, `options` used; `identity.source` ignored; `engine` validated. |
| `GET /v1/sessions/:id`, `DELETE /v1/sessions/:id` | status / dispose | |
| `POST /v1/sessions/:id/messages` | `SendMessageBody {message, credential?}` → 202 | before `chat()`: `os.environ["LYZR_API_KEY"] = credential` when present. |
| `GET /v1/sessions/:id/events` | SSE of `sse-events.ts` events (D23) | a turn ends with `ca_session_ended`. |
| `POST /v1/sessions/:id/messages` | `SendMessageBody {message, credential?}` → 200 `{ok: true}` | before `chat()`: `os.environ["LYZR_API_KEY"] = credential` when present. |
| `GET /v1/sessions/:id/events` | SSE of `sse-events.ts` events (D23), `Last-Event-ID` replay | a turn ends with its `result` `sdk_message`; `ca_session_ended` once, when the session ends. |
| `POST /v1/sessions/:id/end-input`, `/cancel`, `/permission/:callId` | as `routes/{end-input,cancel,permission}.ts` | cancel and tool approvals. |
| `GET/PUT/DELETE /v1/sessions/:id/fs/file`, `GET …/fs/tree`, `POST …/fs/{edit,mkdir,move}` | as `routes/fs.ts` | the AgentOS Files tab and artifact download; confined to the session workspace. |

Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@ Every call the SDK makes now matches a route the CAS egress proxy serves (plan

### Added

- **The app server for stage images** (`computeragent.server`, extra `server`).
`python -m computeragent.server` serves CAS's harness REST API on `0.0.0.0:8080`:
- the Lambda MicroVM lifecycle hooks, health, sessions, messages, events,
end-input, cancel, approvals and the workspace file routes;
- one `ComputerAgent` per session, built from the image's settings;
- the sandbox token from the session's `envs`, replaced by each turn's
`credential`;
- events in CAS's `sse-events.ts` shapes, including the turn start, the
input-guardrail outcome, tool-gate decisions and usage.

`ComputerAgent.workspace` and `ComputerAgent.telemetry_pipeline`, and
`computeragent.engines.registered_engines()`, are new.
- **Sandbox token reissue is adopted by every client.** An egress answer that
carries `X-Cas-Token` / `X-Cas-Token-Expires-At` (an `sbt_` token with a later
expiry, on a request sent with the newest token) makes every later egress
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ pip install 'computer-agent-py[agentos]'
```

`[agentos]` is not optional in practice — every call the SDK makes is HTTP. Add `[otel]` for OTLP
telemetry and `[gap]` for GAP mode.
telemetry, `[gap]` for GAP mode, and `[server]` for the app server a stage image runs
(`python -m computeragent.server`; see `docs/USING_THE_SDK.md` §14).

## Documentation

Expand Down
59 changes: 59 additions & 0 deletions docs/USING_THE_SDK.md
Original file line number Diff line number Diff line change
Expand Up @@ -680,3 +680,62 @@ cooperative and cannot enforce a deadline on a wedged turn.
`capture_content` defaults to **False**. Without `telemetry_url`, an agent
sends no telemetry at all. Nothing is written to your console unless you build your own pipeline with
`configure(exporter="console")` and pass it as `telemetry_pipeline=`.

---

## 14. The app server (stage images)

A stage image (Lambda MicroVM or AgentCore) runs the agent behind CAS's harness REST API. The SDK
ships that server:

```bash
pip install 'computer-agent-py[server]'
python -m computeragent.server # 0.0.0.0:8080; PORT overrides the port
```

The image bakes in the agent's settings (`COMPUTERAGENT_*`, §3). CAS creates one session per sandbox
and the server builds one `ComputerAgent` for it, with no `api_key=`:

- `envs.LYZR_API_KEY` in `POST /v1/sessions` (the sandbox token) seeds `LYZR_API_KEY`; every other
`envs` key is ignored.
- Each `POST /v1/sessions/:id/messages` may carry the turn's `credential`; it replaces
`LYZR_API_KEY` before the turn. The key is process-wide, so turns run one at a time per process.
- `identity` is not read (the agent is baked into the image). `engine` must be a registered engine;
the image's own engine runs.
- `messages` are the session's first turns, and `streamingInput: false` closes its input after
them. Resume across sandboxes comes from the `http` session store (`sessionStore`, §7), keyed by
`sessionId`.

| Route | Answer |
|---|---|
| `POST /aws/lambda-microvms/runtime/v1/{ready,validate,run,suspend,resume,terminate}` | 200 (MicroVM lifecycle hooks) |
| `GET /v1/health` | `{ok, version, engines, loaders}` |
| `POST /v1/sessions` | 201 `{sessionId, engine, identity, capabilities, eventsUrl}` |
| `GET /v1/sessions/:id` | `{sessionId, engine, identity, status}` |
| `DELETE /v1/sessions/:id` | `{ok: true}`; cancels the session and disposes its agent |
| `POST /v1/sessions/:id/messages` | `{ok: true}`; `409 SESSION_ENDED` once the session ended |
| `GET /v1/sessions/:id/events` | Server-sent events, `Last-Event-ID` (or `?lastEventId=`) replay, `:keepalive` every 20 s |
| `POST /v1/sessions/:id/end-input`, `/cancel` | `{ok: true}` |
| `POST /v1/sessions/:id/permission/:callId` | `{decision: allow \| deny \| modify, input?, reason?}` → `{ok: true}` |
| `GET\|PUT\|DELETE /v1/sessions/:id/fs/file`, `GET …/fs/tree`, `POST …/fs/{edit,mkdir,move}` | Files in the agent's workspace, confined to it |

**Events** follow CAS's `sse-events.ts`:
- `ca_session_started` comes once, when the session is created.
- Each turn sends `ca_turn_started` (the message after the input guardrail), then
`ca_redaction_decision` (the guardrail's outcome), then one `sdk_message` per engine message, in
the TS Claude Agent SDK's JSON shape.
- Tool-gate decisions arrive as `ca_permission_decision` (`source: "policy"`); approvals as
`ca_permission_request` and the client's `ca_permission_decision`.
- `ca_usage_snapshot` comes last before the result. A turn ends with its `result` message; a
blocked input gets a `result` carrying the policy's reason and `ca_policy_blocked: true`.
- `ca_session_ended` comes once, when the session ends: input closed, cancel, delete or an engine
error.

Errors have CAS's harness shape: `{detail, code, retry, severity, source, meta}`.

**Files.** Paths are relative to the workspace (`gap_dir` in GAP mode, else the working directory)
and may not leave it, through `..`, an absolute path or a symlink (`400 PATH_ESCAPE_REJECTED`). A
delete removes a file or an empty directory only: a non-empty directory answers
`409 WORKDIR_DIRECTORY_NOT_EMPTY`, also with `recursive=true`.

The server never logs `envs`, a `credential`, a request header or a body.
8 changes: 8 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ gitagent = [
gap = [
"pyyaml>=6,<7",
]
server = [
"starlette>=0.37",
"uvicorn>=0.30",
"httpx>=0.27",
"h11>=0.14",
]
all = [
"pymongo>=4.9",
"opentelemetry-api>=1.27",
Expand All @@ -60,6 +66,8 @@ all = [
"opentelemetry-semantic-conventions>=0.48b0",
"httpx>=0.27",
"h11>=0.14",
"starlette>=0.37",
"uvicorn>=0.30",
"cedarpy>=4,<5",
"openai>=1,<2",
"anthropic>=0.40,<1",
Expand Down
6 changes: 6 additions & 0 deletions src/computeragent/engines/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ def resolve_engine(name: str) -> EngineDriver:
) from exc


def registered_engines() -> dict[str, EngineDriver]:
"""A copy of the registry: every engine name and its driver."""
return dict(_REGISTRY)


# Built-ins.
register_engine("claude-agent-sdk", ClaudeAgentEngine())
register_engine("gitagent", GitAgentEngine())
Expand All @@ -57,5 +62,6 @@ def resolve_engine(name: str) -> EngineDriver:
"ClaudeAgentEngine",
"GitAgentEngine",
"register_engine",
"registered_engines",
"resolve_engine",
]
Loading
Loading