diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..b14bd08 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{ts,js,mjs,cjs,json}] +indent_size = 2 + +[*.{yml,yaml}] +indent_size = 2 + +[*.md] +indent_size = 2 +trim_trailing_whitespace = false diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..9a4605d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "window.title": "sbx-kit-lambda" +} diff --git a/AGENTS.md b/AGENTS.md index 61e762c..b8b4e2c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,18 +2,70 @@ ## Overview -This repository defines the `lambda` Docker SBX kit. It installs Pi behind the -`codex` command and preserves native agent CLIs under `sbx-*` names. +This repository defines the `lambda` Docker SBX **agent kit** (`kind: sandbox`, +`schemaVersion: "2"`). It builds on `docker/sandbox-templates:shell-docker`, +installs Pi, and exposes it as the `lambda` agent binary. Because Lambda is its +own agent, `codex` and `claude` remain the real upstream CLIs. ## Development -- Validate kit changes with `sbx kit validate .`. +- Validate kit changes with `sbx kit validate .` and review the resolved shape + with `sbx kit inspect .`. - Keep `README.md` aligned with `spec.yaml` installation, authentication, and command behavior. -- Keep Pi extensions self-contained TypeScript modules that Pi can load via - `~/.pi/agent/extensions/`. -- Native subagents must be invoked through `sbx-codex` and `sbx-claude` by - default; never invoke `codex` from the extension because it launches Pi. +- Keep Pi extensions self-contained TypeScript modules that Pi auto-discovers + from `~/.pi/agent/extensions/`. +- Indent with spaces, never tabs. `.editorconfig` is authoritative: two spaces + for TypeScript, JavaScript, JSON, and YAML. +- Install supported standalone CLI tools with Webi; use an upstream installer + or package manager only when Webi does not provide the tool. Pi, Codex, and + Claude Code are the documented exceptions. +- `npm install -g` runs as the agent user (UID 1000): the base image ships an + agent-owned `/usr/local/share/npm-global` that is already on `PATH`. +- Native subagents must be invoked as `codex` and `claude`. Never shell out to + `lambda` or `pi` from the subagent extension. +- Never start a native subagent proactively. The current user must clearly ask + to run or delegate to Codex, Claude, or a native subagent. +- Once the user has clearly requested delegation, `run_subagent` must execute + without a second authorization heuristic; responsibility stays with the + calling model, and the native agent must not ask for permission. + +## Authentication + +- Proxy-managed OAuth does not activate for this kit. Docker gates OAuth + interception on built-in provenance, which a third-party sandbox kit cannot + have. Verified: the proxy never substitutes the OAuth sentinel. +- API-key injection does work, so host-managed auth is rebuilt on top of it. + The host mints and refreshes tokens; the proxy substitutes them per request. + Never store a real token in the sandbox or in kit files. +- `chatgpt-codex` must not be renamed to `openai`. `sbx secret set --command` + cannot combine with `--oauth`, so reusing `openai` would replace the built-in + Codex agent's OAuth registration and break plain `sbx run codex`. +- `apiKey.inject` overwrites whatever the named header contains, so a consumer + only has to emit the header. Pi does that through `sbx-codex.ts`; the native + Codex CLI through the `sandboxd` model provider in `~/.codex/config.toml`. + Do not delete either: without them no header is sent and there is nothing for + the proxy to substitute. +- Never seed `apiKeyHelper` into `~/.claude/settings.json`. It only works with + provenance-backed OAuth interception; here it would make Claude Code send a + dead sentinel and never prompt. `SBX_CRED_ANTHROPIC_MODE` is not a usable + gate either — it reports `apikey` for an OAuth-only declaration. +- Pi's Anthropic provider is intentionally not wired up. It is outside the + `--models` picker scope, and third-party harness usage bills per token from + Anthropic extra usage rather than against the plan, so the subscription only + pays off through the native `claude` CLI. +- Every credential a third-party v2 kit declares needs a user-approved binding. + Adding a credential or a new inject domain means a new first-run prompt. + +## Volumes + +- Persist `~/.pi/agent/sessions`, `~/.claude`, and `~/.codex`. +- Never mount a volume over `~/.pi/agent` itself: the kit ships `models.json` + and its extensions there, and a volume would shadow kit-owned files across + kit updates. +- Volumes are keyed to the sandbox name and cannot be shared between sandboxes. + Never treat a volume as a place to keep credentials; that would force a login + per project. Credentials belong in the host secret store. ## Subagents @@ -22,8 +74,11 @@ This repository defines the `lambda` Docker SBX kit. It installs Pi behind the their non-interactive permission-bypass options. - Plan/review work may run concurrently; code work against a shared workspace must be serialized. -- Codex uses `gpt-5.6-sol` and Claude uses `fable` for plan/review; coding - defaults to Codex `gpt-5.6-terra` and Claude `opus` (override per role with - `LAMBDA___MODEL`, for example `LAMBDA_CLAUDE_CODE_MODEL=sonnet`). -- If Claude authentication is unavailable, tell the user to run - `!sbx-claude auth login`. +- Every role defaults to Codex `sol` (`gpt-5.6-sol`, high) and Claude `opus` + (Claude Opus 5, high). Codex alternatives are `terra` (`gpt-5.6-terra`, + ultra) and `luna` (`gpt-5.6-luna`, high); Claude also supports `sonnet` + (Claude Sonnet 5, high). Override a role with + `LAMBDA___MODEL` and `LAMBDA___EFFORT`, or the + binary with `LAMBDA__EXECUTABLE`. +- If Claude authentication is unavailable, tell the user to run `!claude` and + complete `/login` inside the sandbox. diff --git a/README.md b/README.md index 4169f5d..5713b95 100644 --- a/README.md +++ b/README.md @@ -1,77 +1,139 @@ -# lambda Pi Kit +# lambda SBX agent kit -This kit installs Pi behind the `codex` command inside Docker SBX's built-in -`codex` agent. -Using the built-in identity is intentional: SBX 0.35 associates the stored -OpenAI OAuth credential with the `codex` agent name. The launch command is -therefore: +Lambda is a Docker Sandboxes **agent kit**: it defines its own sandbox agent +rather than layering on top of a built-in one. The agent binary is `lambda`, a +symlink to [Pi](https://pi.dev), launched with Lambda's provider and model +defaults: ```bash -sbx run --kit git+https://github.com/withlogicco/sbx-kit-lambda codex +sbx run lambda --kit git+https://github.com/withlogicco/sbx-kit-lambda ``` -The default `codex` command launches the lambda agent. Native agent CLIs remain -available under `sbx-*` names: +Because Lambda is its own agent, the native CLIs keep their real names: -```bash -codex # lambda (Pi) -sbx-codex # native Codex CLI +```text +lambda # Lambda (Pi) +codex # native Codex CLI claude # native Claude Code CLI -sbx-claude # native Claude Code CLI, used by lambda subagents ``` -Pi includes native-agent subcommands and can also delegate to them itself: +There are no `sbx-codex` / `sbx-claude` shims any more. Those existed only +because the previous mixin had to hijack the `codex` entrypoint. -```text -/codex plan map the authentication flow -/claude review review the current working tree -/codex code implement the approved plan +## Authentication + +Proxy-managed OAuth does not work for third-party sandbox agent kits — Docker +gates OAuth interception on built-in provenance, and this kit cannot have it +(see `agents/plans/2026-08-31-agent-kit.md` for the verification). API-key +injection *does* work, so Lambda rebuilds host-managed auth on top of it: the +host mints and refreshes the token, and the sandbox proxy substitutes it into +outbound requests. **No token enters the sandbox, and no credential is +per-project — one setup serves every sandbox you ever create.** + +### One-time host setup + +```bash +# ChatGPT subscription, minted fresh on every request by Pi on the host +sbx secret set chatgpt-codex \ + --command 'pi auth print-bearer-token --provider openai-codex' \ + --refresh on-demand + +# Claude subscription: generate a long-lived token, then store it +claude setup-token +sbx secret set claude-code --token + +# OpenCode Go, and GitHub for gh and git over HTTPS +sbx secret set opencode-go +sbx secret set github --command 'gh auth token' ``` -`plan` and `review` runs are read-only and can run concurrently. `code` runs -modify the shared workspace and are serialized. Native agents run with their -permission-bypass options because SBX provides the isolation boundary. +The first run of the kit asks you to approve a +[credential binding](https://docs.docker.com/ai/sandboxes/configuration/credentials/) +for each service and the domains it may inject into. Approvals live in +`credentials.yaml` on the host and are reused by every later sandbox. -Plan and review use `gpt-5.6-sol` (Codex) and `fable` (Claude). Coding defaults -to `gpt-5.6-terra` (Codex) and `opus` (Claude); use `sonnet` by setting -`LAMBDA_CLAUDE_CODE_MODEL=sonnet`. Any role can be overridden with -`LAMBDA___MODEL`. Pi's `run_subagent` tool also accepts an -optional `model` argument; without one it uses these same role defaults. +### How each consumer reaches its credential -Pi starts on `openai-codex/gpt-5.6-terra` with high thinking. Its model picker -is scoped to all Codex models and OpenCode Go models. +| Consumer | Emits | Proxy injects into | Credential | +| -------- | ----- | ------------------ | ---------- | +| Pi (`lambda`) | Authorization header via `sbx-codex.ts` | `chatgpt.com` | `chatgpt-codex` | +| Native `codex` | Authorization header via the `sandboxd` model provider in `~/.codex/config.toml` | `chatgpt.com` | `chatgpt-codex` | +| Native `claude` | Authorization bearer from `CLAUDE_CODE_OAUTH_TOKEN` | `api.anthropic.com` | `claude-code` | +| Pi's OpenCode Go provider | `OPENCODE_API_KEY` | `opencode.ai` | `opencode-go` | +| `gh`, `git` | `GH_TOKEN` | `api.github.com`, `github.com`, `raw.githubusercontent.com` | `github` | -## Setup +Both ChatGPT consumers only need to *emit* an `Authorization` header — +`apiKey.inject` overwrites whatever it contains, so the placeholder values in +`sbx-codex.ts` and `config.toml` are not secrets and are never sent upstream. -Store OpenCode Go API key: +> [!NOTE] +> The `chatgpt-codex` service is deliberately not named `openai`. `sbx secret +> set --command` cannot be combined with `--oauth`, so reusing the `openai` +> service id would replace the built-in Codex agent's OAuth registration and +> break plain `sbx run codex`. -```bash -sbx secret set -g opencode-go -``` +### Why not the subscription's own OAuth -Authenticate with OpenAI: +Neither subscription can be used with an API key: ChatGPT Plus/Pro and Claude +Pro/Max are OAuth-only, and an OpenAI or Anthropic API key is a separate, +pay-per-token account. The host-minted-token approach above is what keeps the +subscription in play without a per-sandbox browser login. -```bash -sbx secret set -g openai --oauth +If you would rather log in inside the sandbox, that still works and persists in +the `~/.claude` and `~/.codex` volumes — but only for that one sandbox, and the +real token then lives in the VM. + +Global credentials are applied when a sandbox is created. Recreate an existing +sandbox after adding or changing one. + +## Native subagents + +Pi provides direct commands and a `run_subagent` tool: + +```text +/codex plan map the authentication flow +/claude review review the current working tree +/codex code implement the approved plan ``` -SBX retains the credential on the host and does not expose its value in the -sandbox. +`plan` and `review` runs are instructed to remain read-only and may run +concurrently. `code` runs can modify the shared workspace and are serialized. +Native CLIs run with their permission-bypass options because the outer SBX +sandbox is the security boundary. -Claude Code authentication must be started **inside the sandbox**; Anthropic -OAuth cannot be created with `sbx secret set`. After the sandbox starts, run: +Lambda's model must never delegate proactively. It may call `run_subagent` only +after the current user clearly asks for Codex, Claude, or a native subagent. +Once called, the tool does not apply a second regex authorization check or +reject the model's interpretation of that request; the delegated agent proceeds +without asking for another approval. `/codex` and `/claude` are themselves +explicit requests. -```bash -sbx-claude auth login +All roles default to `sol` (`gpt-5.6-sol`, high) for Codex and `opus` (Claude +Opus 5, high) for Claude. Available aliases are: + +- Codex: `sol` (high), `terra` (ultra), and `luna` (high) +- Claude: `opus` (high) and `sonnet` (Claude Sonnet 5, high) + +Choose an alias after the role, for example: + +```text +/codex code terra implement the approved plan +/claude review sonnet review the current tree ``` -If a Claude subagent cannot authenticate, lambda prints this command in its -result. Recreating a sandbox may require signing in again. +Override any role with `LAMBDA___MODEL` and +`LAMBDA___EFFORT`. `LAMBDA_CODEX_EXECUTABLE` and +`LAMBDA_CLAUDE_EXECUTABLE` override which binary is invoked. The +`run_subagent` tool accepts the same optional model aliases. + +Pi starts on `openai-codex/gpt-5.6-sol` with high thinking. Its model picker is +scoped to Sol (high), Terra (`max` in Pi, mapped to upstream `ultra` by +`files/home/.pi/agent/models.json`), Luna (high), and OpenCode Go models. ## Run -Add this function to `~/.zshrc` to create or resume a sandbox named -`lambda-`: +Add this function to `~/.zshrc` to create or resume a sandbox named after the +current directory: ```zsh lambda() { @@ -85,27 +147,89 @@ lambda() { if sbx ls --quiet | grep -Fxq -- "$name"; then sbx run --name "$name" else - sbx run --kit "$kit" --name "$name" codex + sbx run --name "$name" --kit "$kit" lambda fi } ``` -Reload your Zsh configuration with `source ~/.zshrc`, then run `lambda` from -the project directory. To remove the directory's existing sandbox and create a -fresh one, run `lambda --reset`. +Reload with `source ~/.zshrc`, then run `lambda`. Use `lambda --reset` to +recreate the current directory's sandbox after changing the kit or global +credentials. + +Without the helper: + +```bash +# Create and attach +sbx run --name lambda-my-project \ + --kit git+https://github.com/withlogicco/sbx-kit-lambda \ + lambda + +# Reattach later +sbx run --name lambda-my-project +``` + +Loading the kit from GitHub requires the source to be allowlisted once: ```bash -sbx run --kit git+https://github.com/withlogicco/sbx-kit-lambda codex +sbx settings set kit.allowedSources '["docker.io/","github.com/withlogicco/"]' ``` -Run lambda after the sandbox is created: +## Persistent state + +Volumes survive sandbox recreation: + +| Path | Contents | +| ---- | -------- | +| `/home/agent/.pi/agent/sessions` | Pi session history | +| `/home/agent/.claude` | Claude Code credentials, settings, and session state | +| `/home/agent/.codex` | Codex CLI state | + +`~/.pi/agent` as a whole is deliberately not persisted: the kit ships +`models.json` and its extensions into that directory, and a volume there would +shadow kit-owned files across kit updates. Pi's `auth.json` therefore does not +survive recreation, which does not matter because auth is host-managed rather +than stored in the sandbox. + +Volumes are keyed to the sandbox name, so they are never shared between +sandboxes. That is why credentials are resolved from the host secret store +rather than kept in a volume — otherwise every project would need its own +logins. + +## Installed tools + +| Tool | Source | +| ---- | ------ | +| Pi (`pi`, `lambda`) | `pi.dev` installer | +| Codex CLI (`codex`) | npm `@openai/codex` | +| Claude Code (`claude`) | npm `@anthropic-ai/claude-code` | +| GitHub CLI (`gh`) | [Webi](https://webi.sh/gh), into `~/.local/bin` | + +Webi is the default for standalone CLI tools. Pi, Codex, and Claude Code use +their upstream installers because Webi does not package them. + +## VS Code Remote SSH + +One-time host setup: ```bash -sbx run --kit git+https://github.com/withlogicco/sbx-kit-lambda --name codex +sbx setup ssh ``` -Validate the kit with: +Connect VS Code's Remote - SSH extension to `.sbx`. The network +policy allows the VS Code server and extension gallery download hosts. + +## Validation ```bash sbx kit validate . +sbx kit inspect . +``` + +A clean local smoke sandbox: + +```bash +sbx create --name lambda-v2-smoke --kit . lambda . +sbx run --name lambda-v2-smoke ``` + +Remove it afterwards with `sbx rm --force lambda-v2-smoke`. diff --git a/agents/CHANGELOG.md b/agents/CHANGELOG.md index c7787b9..149bb5a 100644 --- a/agents/CHANGELOG.md +++ b/agents/CHANGELOG.md @@ -1,3 +1,5 @@ # Agent Changelog +- 2026-08-31 — [Convert the mixin into an agent kit](plans/2026-08-31-agent-kit.md): make Lambda a schema v2 sandbox kit with its own `lambda` binary, OAuth for OpenAI and Anthropic, and native `codex`/`claude` CLIs. +- 2026-08-28 — [SBX schema v2 kit repair](plans/2026-08-28-v2-kit-repair.md): migrate the Lambda kit and restore Codex OAuth, Claude OAuth, OpenCode Go, and reliable delegated subagents. - 2026-07-16 — [Native Codex and Claude subagents](plans/2026-07-16-native-subagents.md): add Pi orchestration and Claude Code support to the Lambda kit. diff --git a/agents/plans/2026-07-16-native-subagents.md b/agents/plans/2026-07-16-native-subagents.md index 0b90c88..181683c 100644 --- a/agents/plans/2026-07-16-native-subagents.md +++ b/agents/plans/2026-07-16-native-subagents.md @@ -8,14 +8,16 @@ native Codex and Claude Code CLIs inside an SBX sandbox. ## Relevant files - `files/home/.pi/agent/extensions/native-subagents.ts` +- `files/home/.pi/agent/models.json` - `spec.yaml` - `README.md` ## Steps -1. Add a self-contained Pi extension with direct commands and an LLM tool. -2. Stream native JSON output, retain bounded final output, and serialize shared - workspace code runs. +1. Add a self-contained Pi extension with direct commands and an LLM tool whose + prompt guidance tells the model to delegate only after a clear user request. +2. Stream native JSON output, retain bounded final output, serialize shared + workspace code runs, and expose concise model aliases with fixed effort. 3. Install Claude Code, expose it as `sbx-claude`, and install the extension in the kit. 4. Add Anthropic network capabilities and document its in-sandbox login flow. @@ -34,5 +36,10 @@ native Codex and Claude Code CLIs inside an SBX sandbox. - No extra write confirmation or noninteractive write restriction. - Native executable defaults are `sbx-codex` and `sbx-claude`; never fall back from Codex to Lambda's `codex` Pi wrapper. -- Plan/review use Codex `gpt-5.6-sol` and Claude `fable`; coding defaults to - Codex `gpt-5.6-terra` and Claude `opus`, with per-role environment overrides. +- All roles default to Codex `sol` (`gpt-5.6-sol`, high) and Claude `opus` + (Claude Opus 5, high). Alternatives are Codex `terra` (ultra), Codex `luna` + (high), and Claude `sonnet` (Claude Sonnet 5, high), with per-role model and + effort environment overrides. +- Slash commands are explicit requests. The model must not call `run_subagent` + proactively, but once it calls the tool there is no second regex-based + authorization check that can incorrectly reject the user's delegation. diff --git a/agents/plans/2026-08-28-v2-kit-repair.md b/agents/plans/2026-08-28-v2-kit-repair.md new file mode 100644 index 0000000..34f2d6c --- /dev/null +++ b/agents/plans/2026-08-28-v2-kit-repair.md @@ -0,0 +1,53 @@ +# Repair the Lambda kit for SBX schema v2 + +## Scope + +Migrate the kit to the current schema v2 grammar and restore working Pi, +Codex OAuth, Claude Code OAuth, OpenCode Go, and native subagents. + +## Relevant files + +- `spec.yaml` +- `files/home/.pi/agent/extensions/native-subagents.ts` +- `files/home/.pi/agent/extensions/sbx-codex.ts` +- `README.md` +- `AGENTS.md` + +## Implementation + +1. Replace legacy `agentContext`, `caps`, and `commands` fields with + `agentInstructions`, `permissions`, and `setup`, and require the built-in + Codex base agent. +2. Keep the built-in Codex identity so host-managed OpenAI OAuth remains + available to both Pi and native Codex. Preserve the native CLI as + `sbx-codex` and use the `codex` entrypoint as the Pi launcher. +3. Declare OpenCode Go as a required proxy-managed v2 credential and inject it + only into `opencode.ai` requests. +4. Install Claude Code, allow its OAuth endpoints, and document its supported + in-sandbox login and the schema v2 mixin limitation. +5. Remove the subagent tool's regex authorization gate. Keep the prohibition + on proactive delegation in model instructions and tool guidance. +6. Update setup, run, and troubleshooting documentation for current SBX. + +## Verification + +- `sbx kit validate .` +- Create a clean sandbox with `sbx create --kit . codex .`. +- Verify Pi, Codex, Claude Code, and GitHub CLI installation. +- Smoke-test Pi and native Codex with host-managed OpenAI OAuth. +- Verify an authenticated OpenCode Go request reaches account quota handling + rather than failing authentication. +- Verify Pi loads the bundled model config and native-subagent extension. + +## Decisions + +- Keep the built-in `codex` base identity: schema v2 does not support + proxy-managed OAuth for a third-party sandbox agent, including one that + extends a built-in agent. A custom `lambda` sandbox would therefore break + host-managed Codex OAuth. +- Claude subscription OAuth is performed inside the sandbox with + `sbx-claude auth login`; no Anthropic API key is required. Schema v2 rejects + OAuth declarations on mixins, so host interception is unavailable while the + kit remains a Codex mixin. +- Native subagents bypass their own approval prompts because SBX is the + security boundary. diff --git a/agents/plans/2026-08-31-agent-kit.md b/agents/plans/2026-08-31-agent-kit.md new file mode 100644 index 0000000..a0aa932 --- /dev/null +++ b/agents/plans/2026-08-31-agent-kit.md @@ -0,0 +1,197 @@ +# Convert the Lambda mixin into an SBX agent kit + +## Scope + +Turn the Lambda kit from a `kind: mixin` that hijacks the built-in `codex` +agent into a standalone `kind: sandbox` agent kit on schema v2, with OAuth for +OpenAI and Anthropic, a token for OpenCode Go, and `lambda` as the agent +binary. + +## Relevant files + +- `spec.yaml` +- `files/home/.pi/agent/extensions/native-subagents.ts` +- `files/home/.pi/agent/extensions/sbx-codex.ts` +- `files/home/.pi/agent/models.json` +- `README.md` +- `AGENTS.md` + +## Implementation + +1. Replace the mixin with `kind: sandbox`, `sandbox.image: + docker/sandbox-templates:shell-docker`, `sandbox.entrypoint: [lambda]`, and + Pi's provider/model flags in `sandbox.command`. Drop `requires.agent`. +2. Install Pi with the `pi.dev` installer and symlink `lambda` to it, so the + agent binary carries no wrapper logic. Install the native Codex and Claude + Code CLIs from npm as the agent user, and `gh` with Webi. +3. Remove the `sbx-codex` / `sbx-claude` shims and the `codex`-launches-Pi + wrapper. Point `native-subagents.ts` at `codex` and `claude`, and delete the + now-unused `isExecutable` PATH probe. +4. Declare four credentials: `openai` (OAuth), `anthropic` (OAuth, with a + `credentialFile` for `~/.claude/.credentials.json`), `opencode-go` + (proxy-managed API key), and `github` (proxy-managed API key, bearer for the + API hosts and HTTP Basic for `github.com`). Allow `auth.openai.com` so the + OpenAI token endpoint is reachable. +5. Replicate the built-in `claude` kit's seeding: `~/.claude.json` trust and + onboarding flags as root, and `~/.claude/settings.json` with `apiKeyHelper` + gated on `SBX_CRED_ANTHROPIC_MODE`. +6. Declare volumes for `~/.pi/agent/sessions`, `~/.claude`, and `~/.codex`. +7. Set `agentInstructions.filename: AGENTS.md` and carry the previous + instruction content over, updating the subagent section for the new binary + names. +8. Rewrite `README.md` and `AGENTS.md` for the agent-kit launch flow + (`sbx run lambda --kit ...`) and the new credential model. + +## Verification + +- `sbx kit validate .` and `sbx kit inspect .`. +- Create a clean sandbox with `sbx create --kit . lambda .`. +- Verify `lambda`, `pi`, `codex`, `claude`, and `gh` are installed and on PATH. +- Verify Pi loads `models.json` and both bundled extensions. +- Verify whether host-managed OpenAI and Anthropic OAuth actually reaches the + sandbox, or whether an in-sandbox `/login` is required. +- Verify an authenticated OpenCode Go request reaches account quota handling + rather than failing authentication. + +## Verification results (2026-08-31, sbx v0.39.0) + +Smoke sandbox `lambda-v2-smoke` created with `sbx create --kit . lambda .`. + +Working: + +- `lambda`, `pi` (0.84.4), `codex` (0.151.0), `claude` (2.1.251), and `gh` all + install and resolve on `PATH`. `lambda` is a symlink to `pi`. +- Static files land correctly: `~/.pi/agent/models.json` and both extensions. +- All three volumes mount; `~/.claude/settings.json` and `~/.claude.json` are + seeded as intended. +- Proxy-managed **API key** injection works. `pi --provider opencode-go` + returned a workspace-scoped `RegionError` naming the real OpenCode workspace, + which is only reachable with the real key substituted in. + +Not working: + +- Proxy-managed **OAuth** does not activate. `sbx` recorded an `openai` OAuth + binding non-interactively (`auth.openai.com`, `chatgpt.com`), but a request to + `chatgpt.com/backend-api/codex/responses` carrying the + `oai-oat01-proxy-managed` sentinel returns a 401 byte-identical to one + carrying a garbage token — the proxy never substitutes. Pi reports "Could not + parse your authentication token". Docker's documented limitation holds. +- `SBX_CRED_ANTHROPIC_MODE` reports `apikey` even though the kit declares only + an `oauth` mechanism for `anthropic`, so the mode variable is not a reliable + gate for third-party kits. + +Working auth path (verified): + +- Copying the host's `~/.pi/agent/auth.json` into the sandbox authenticates Pi + against the ChatGPT subscription: `pi --provider openai-codex` returned the + expected sentence, and it still worked after a stop/start cycle. +- `sbx cp` preserves the host uid/gid (501:20 on macOS) rather than mapping to + `agent`, and the file is mode 600, so Pi fails with `EACCES` until the copy is + followed by `sudo chown agent:agent`. Any copy-based flow must include it. +- The token does not survive sandbox recreation, because `~/.pi/agent` is not a + volume. It must be re-copied after each create. + +## Follow-up: host-minted tokens instead of OAuth + +The verification above rules out proxy-managed OAuth but confirms API-key +injection, so auth was rebuilt on the mechanism that works. + +1. Replace the `openai` and `anthropic` OAuth declarations with two API-key + services: `chatgpt-codex` (injects `Authorization: Bearer` into + `chatgpt.com`) and `claude-code` (injects into `api.anthropic.com` from + `CLAUDE_CODE_OAUTH_TOKEN`). +2. On the host, register dynamic sources: + `sbx secret set chatgpt-codex --command 'pi auth print-bearer-token + --provider openai-codex' --refresh on-demand`, and store the output of + `claude setup-token` as `sbx secret set claude-code --token`. +3. Seed `~/.codex/config.toml` with a `sandboxd` model provider pointing at + `https://chatgpt.com/backend-api/codex` and `requires_openai_auth = false`, + mirroring the built-in codex kit, so the native CLI emits a header for the + proxy to overwrite. +4. Keep `sbx-codex.ts` for the same reason on the Pi side. + +Decisions specific to this follow-up: + +- **The service is `chatgpt-codex`, not `openai`.** `sbx secret set --command` + is mutually exclusive with `--oauth`, so reusing the `openai` service id would + replace the built-in Codex agent's OAuth registration. The built-in kit only + injects `openai` into `api.openai.com` and `openai.com`, not `chatgpt.com`, so + plain `sbx run codex` would then fail. +- **`sbx-codex.ts` is kept, reversing an earlier inclination to delete it.** Its + original premise — that the proxy swaps its OAuth sentinel — was wrong, but + `apiKey.inject` overwrites whatever the header contains, so its real job is to + make Pi emit an `Authorization` header at all. Without it there is nothing to + substitute. +- **Pi's Anthropic provider is not wired up.** It falls outside the `--models` + picker scope, and Pi's own docs note that third-party harness usage bills per + token from Anthropic extra usage rather than against plan limits, so the + subscription only pays off through the native `claude` CLI. Wiring it would + also collide with `claude-code` on the same header and domain. +- **Credentials live in the host secret store, not in volumes.** Volumes are + keyed to the sandbox name and cannot be shared, so a volume-based credential + would mean one login per project. The host store is global by default. + +Verified end to end (2026-08-31), with all four secrets stored and bindings +approved, on a sandbox recreated so injection was active: + +- **Pi** (`--provider openai-codex`): reached the account. Returned "Codex + error: The usage limit has been reached" — an account-scoped response, versus + the pre-fix "Could not parse your authentication token", so the host-minted + token was substituted. +- **Native `claude`**: returned the expected sentence. `CLAUDE_CODE_OAUTH_TOKEN` + is accepted as `proxy-managed` — Claude Code does not validate the token + shape, so `sbx secret set-custom` is not needed. +- **Native `codex`**: reached the account. Returned "Your workspace is out of + credits", again account-scoped rather than an auth failure. +- **The exact `run_subagent` Claude invocation** (`claude -p --model opus + --effort high --output-format stream-json --verbose + --dangerously-skip-permissions`) completed on `claude-opus-5` and emitted the + stream-json shape the extension parses. `--effort` is a valid flag. The + rate-limit event reported `isUsingOverage: false`, confirming native Claude + Code draws on the subscription plan rather than per-token extra usage. +- Registering `chatgpt-codex` left the existing `openai --oauth` secret intact, + so plain `sbx run codex` is unaffected. + +Remaining ChatGPT-side errors are account billing state, not kit behavior. + +Still unverified: + +- Whether `--refresh on-demand` keeps pace with ChatGPT access token lifetimes + over a long session. + +## Decisions + +- **Base image is `shell-docker`, not `extends:`.** `extends: codex` would + inherit the Codex image and credentials but only helps one of the two OAuth + providers, and it inherits install commands that write sandbox-managed Codex + config. Building from the generic base keeps every install and credential + explicit. +- **`lambda` is a symlink, not a wrapper script.** Pi's provider, model, + thinking level, and model-picker scope live in `sandbox.command`, which the + sandbox appends to the entrypoint. `command` uses the list shorthand so + interactive and non-interactive launches get the same flags. +- **`sbx-codex.ts` stays.** Pi's bundled `providers.md` documents only + `api_key` entries in `~/.pi/agent/auth.json`; OAuth entries are written by + `/login` and have no published schema, so an `oauth.credentialFile` targeting + it cannot be authored reliably. The extension registering the `openai-codex` + provider and rewriting the `Authorization` header remains the supported path. +- **No `apiKeyHelper` for Claude Code.** The built-in `claude` kit seeds + `apiKeyHelper: "echo proxy-managed"` so Claude Code presents the proxy + sentinel. That only works with provenance-backed OAuth interception, which + this kit does not get, so the helper would make Claude Code send a dead + sentinel and never prompt — breaking the in-sandbox `/login` that is the only + working Claude path. Gating it on `SBX_CRED_ANTHROPIC_MODE` is not a fix + either, since that variable reports `apikey` for an OAuth-only declaration. + Only the trust, onboarding, and bypass flags are seeded. +- **`~/.pi/agent` is not a volume.** The kit ships `models.json` and its + extensions into that directory; a volume there would shadow or stale-cache + kit-owned files across kit updates, the same reason the built-in `claude` kit + mounts subdirectories of `~/.claude` rather than the directory itself. The + cost is that Pi's `auth.json` does not survive recreation. +- **OAuth is declared despite the documented limitation.** Docker's credentials + and kit-examples pages both state that proxy-managed OAuth is not supported + for third-party sandbox agents. `sbx` v0.39.0 nevertheless ships + `bindingconsent.runOAuthApproval` and `OauthDomainsForService`, i.e. real + machinery for approving a kit-declared OAuth credential, so the declaration + is made and verified empirically rather than assumed broken. The in-sandbox + `/login` fallback is documented for the case where it does not activate. diff --git a/files/home/.pi/agent/extensions/native-subagents.ts b/files/home/.pi/agent/extensions/native-subagents.ts index 8d9bec4..21ec270 100644 --- a/files/home/.pi/agent/extensions/native-subagents.ts +++ b/files/home/.pi/agent/extensions/native-subagents.ts @@ -1,7 +1,6 @@ import { spawn } from "node:child_process"; -import { mkdir, appendFile, access } from "node:fs/promises"; -import { constants } from "node:fs"; -import { delimiter, join } from "node:path"; +import { mkdir, appendFile } from "node:fs/promises"; +import { join } from "node:path"; import { tmpdir } from "node:os"; import { StringEnum } from "@earendil-works/pi-ai"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; @@ -9,275 +8,329 @@ import { Type } from "typebox"; const MAX_OUTPUT_BYTES = 50 * 1024; const roles = ["plan", "review", "code"] as const; +const modelAliases = ["sol", "terra", "luna", "opus", "sonnet"] as const; type Role = (typeof roles)[number]; type Backend = "codex" | "claude"; +type ModelAlias = (typeof modelAliases)[number]; +type Effort = "high" | "ultra"; + +interface ModelSelection { + alias?: ModelAlias; + model: string; + effort: Effort | string; +} interface RunResult { - backend: Backend; - role: Role; - executable: string; - model: string; - output: string; - stderr: string; - exitCode: number; - logFile: string; - authRequired: boolean; + backend: Backend; + role: Role; + executable: string; + model: string; + effort: string; + output: string; + stderr: string; + exitCode: number; + logFile: string; + authRequired: boolean; } let codeRun: Promise = Promise.resolve(); function truncate(text: string): string { - if (Buffer.byteLength(text, "utf8") <= MAX_OUTPUT_BYTES) return text; - let result = text.slice(0, MAX_OUTPUT_BYTES); - while (Buffer.byteLength(result, "utf8") > MAX_OUTPUT_BYTES) result = result.slice(0, -1); - return `${result}\n\n[Output truncated; full event log is retained outside Pi context.]`; + if (Buffer.byteLength(text, "utf8") <= MAX_OUTPUT_BYTES) return text; + let result = text.slice(0, MAX_OUTPUT_BYTES); + while (Buffer.byteLength(result, "utf8") > MAX_OUTPUT_BYTES) result = result.slice(0, -1); + return `${result}\n\n[Output truncated; full event log is retained outside Pi context.]`; } -async function isExecutable(command: string): Promise { - if (command.includes("/")) { - try { - await access(command, constants.X_OK); - return true; - } catch { - return false; - } - } - for (const directory of (process.env.PATH ?? "").split(delimiter)) { - try { - await access(join(directory, command), constants.X_OK); - return true; - } catch { - // Try the next PATH entry. - } - } - return false; +async function executableFor(backend: Backend): Promise { + const configured = backend === "codex" ? process.env.LAMBDA_CODEX_EXECUTABLE : process.env.LAMBDA_CLAUDE_EXECUTABLE; + if (configured) return configured; + // Lambda runs as its own `lambda` binary, so `codex` and `claude` are the + // real upstream CLIs rather than wrappers around Pi. + return backend === "codex" ? "codex" : "claude"; } -async function executableFor(backend: Backend): Promise { - const configured = backend === "codex" ? process.env.LAMBDA_CODEX_EXECUTABLE : process.env.LAMBDA_CLAUDE_EXECUTABLE; - if (configured) return configured; - const native = backend === "codex" ? "sbx-codex" : "sbx-claude"; - if (await isExecutable(native)) return native; - // `codex` is Lambda's Pi wrapper in this kit, never the native CLI. - return backend === "codex" ? native : "claude"; +const modelSelections: Record>> = { + codex: { + sol: { alias: "sol", model: "gpt-5.6-sol", effort: "high" }, + terra: { alias: "terra", model: "gpt-5.6-terra", effort: "ultra" }, + luna: { alias: "luna", model: "gpt-5.6-luna", effort: "high" }, + }, + claude: { + opus: { alias: "opus", model: "opus", effort: "high" }, + sonnet: { alias: "sonnet", model: "sonnet", effort: "high" }, + }, +}; + +const defaultModelAlias: Record = { codex: "sol", claude: "opus" }; + +function aliasesFor(backend: Backend): ModelAlias[] { + return Object.keys(modelSelections[backend]) as ModelAlias[]; } -function modelFor(backend: Backend, role: Role): string { - const configured = process.env[`LAMBDA_${backend.toUpperCase()}_${role.toUpperCase()}_MODEL`]; - if (configured) return configured; - if (backend === "codex") return role === "code" ? "gpt-5.6-terra" : "gpt-5.6-sol"; - return role === "code" ? "opus" : "fable"; +function selectionFor(backend: Backend, role: Role, requestedAlias?: ModelAlias): ModelSelection { + const configuredModel = process.env[`LAMBDA_${backend.toUpperCase()}_${role.toUpperCase()}_MODEL`]; + const configuredEffort = process.env[`LAMBDA_${backend.toUpperCase()}_${role.toUpperCase()}_EFFORT`]; + const fallback = modelSelections[backend][defaultModelAlias[backend]]!; + + if (requestedAlias) { + const requested = modelSelections[backend][requestedAlias]; + if (!requested) throw new Error(`Model alias ${requestedAlias} is not available for ${backend}. Use: ${aliasesFor(backend).join(", ")}.`); + return requested; + } + + if (!configuredModel) return configuredEffort ? { ...fallback, effort: configuredEffort } : fallback; + const known = aliasesFor(backend) + .map((alias) => modelSelections[backend][alias]!) + .find((selection) => selection.alias === configuredModel || selection.model === configuredModel); + return { + alias: known?.alias, + model: known?.model ?? configuredModel, + effort: configuredEffort ?? known?.effort ?? fallback.effort, + }; } function roleInstructions(role: Role): string { - switch (role) { - case "plan": - return "Inspect only; do not modify files. Return a concise plan with scope, files, implementation steps, verification, and open decisions."; - case "review": - return "Inspect only; do not modify files. Review for correctness, security, regressions, and missing tests. Report findings by severity with file locations; say explicitly when there are no findings."; - case "code": - return "Implement the task in the workspace. Run relevant tests. Return a concise summary of changes, files changed, tests run, and remaining issues."; - } + switch (role) { + case "plan": + return "Inspect only; do not modify files. Return a concise plan with scope, files, implementation steps, verification, and open decisions."; + case "review": + return "Inspect only; do not modify files. Review for correctness, security, regressions, and missing tests. Report findings by severity with file locations; say explicitly when there are no findings."; + case "code": + return "Implement the task in the workspace. Run relevant tests. Return a concise summary of changes, files changed, tests run, and remaining issues."; + } } function promptFor(role: Role, task: string): string { - return [ - "You are a delegated coding subagent running inside an externally sandboxed SBX environment.", - roleInstructions(role), - "Work directly in the current workspace. Do not ask for permissions or wait for approval.", - "Task:", - task, - ].join("\n\n"); + return [ + "You are a delegated coding subagent running inside an externally sandboxed SBX environment.", + "The delegating model has already decided to invoke you after the user's request. Obey the task immediately; do not second-guess the delegation.", + roleInstructions(role), + "Work directly in the current workspace. Do not ask for permissions or wait for approval.", + "Task:", + task, + ].join("\n\n"); } -function invocation(backend: Backend, executable: string, model: string, cwd: string): { command: string; args: string[] } { - if (backend === "codex") { - return { - command: executable, - args: [ - "exec", "--json", "--ephemeral", "--skip-git-repo-check", - "--dangerously-bypass-approvals-and-sandbox", "--model", model, "--cd", cwd, "-", - ], - }; - } - return { - command: executable, - args: ["-p", "--model", model, "--output-format", "stream-json", "--verbose", "--dangerously-skip-permissions"], - }; +function invocation(backend: Backend, executable: string, selection: ModelSelection, cwd: string): { command: string; args: string[] } { + if (backend === "codex") { + return { + command: executable, + args: [ + "exec", "--json", "--ephemeral", "--skip-git-repo-check", + "--dangerously-bypass-approvals-and-sandbox", "--model", selection.model, + "--config", `model_reasoning_effort="${selection.effort}"`, "--cd", cwd, "-", + ], + }; + } + return { + command: executable, + args: [ + "-p", "--model", selection.model, "--effort", selection.effort, + "--output-format", "stream-json", "--verbose", "--dangerously-skip-permissions", + ], + }; } function textFromEvent(event: unknown): string[] { - if (!event || typeof event !== "object") return []; - const value = event as Record; - const type = typeof value.type === "string" ? value.type : ""; - const item = value.item as Record | undefined; - const message = value.message as Record | undefined; - const content = (item?.content ?? message?.content ?? value.content) as unknown; - const directText = item?.text ?? message?.text ?? value.text; - const isAssistant = type.includes("assistant") || type === "item.completed" || item?.type === "agent_message"; - if (!isAssistant) return []; + if (!event || typeof event !== "object") return []; + const value = event as Record; + const type = typeof value.type === "string" ? value.type : ""; + const item = value.item as Record | undefined; + const message = value.message as Record | undefined; + const content = (item?.content ?? message?.content ?? value.content) as unknown; + const directText = item?.text ?? message?.text ?? value.text; + const isAssistant = type.includes("assistant") || type === "item.completed" || item?.type === "agent_message"; + if (!isAssistant) return []; - if (typeof directText === "string") return [directText]; - if (typeof content === "string") return [content]; - if (!Array.isArray(content)) return []; - return content.flatMap((part) => { - if (typeof part === "string") return [part]; - if (!part || typeof part !== "object") return []; - const text = (part as Record).text; - return typeof text === "string" ? [text] : []; - }); + if (typeof directText === "string") return [directText]; + if (typeof content === "string") return [content]; + if (!Array.isArray(content)) return []; + return content.flatMap((part) => { + if (typeof part === "string") return [part]; + if (!part || typeof part !== "object") return []; + const text = (part as Record).text; + return typeof text === "string" ? [text] : []; + }); } function isAuthFailure(backend: Backend, output: string): boolean { - if (backend !== "claude") return false; - return /(?:not authenticated|not logged in|authentication (?:required|failed)|please (?:log ?in|sign in)|please run \/login|run .*auth login)/i.test(output); + if (backend !== "claude") return false; + return /(?:not authenticated|not logged in|authentication (?:required|failed)|please (?:log ?in|sign in)|please run \/login|run .*auth login)/i.test(output); } async function runNativeAgent( - backend: Backend, - role: Role, - task: string, - cwd: string, - model: string, - signal: AbortSignal | undefined, - onProgress: (text: string) => void, + backend: Backend, + role: Role, + task: string, + cwd: string, + selection: ModelSelection, + signal: AbortSignal | undefined, + onProgress: (text: string) => void, ): Promise { - const executable = await executableFor(backend); - const runDirectory = join(tmpdir(), "lambda-subagents"); - await mkdir(runDirectory, { recursive: true, mode: 0o700 }); - const logFile = join(runDirectory, `${Date.now()}-${backend}-${role}.jsonl`); - const { command, args } = invocation(backend, executable, model, cwd); - const output: string[] = []; - let stderr = ""; - let buffer = ""; + const executable = await executableFor(backend); + const runDirectory = join(tmpdir(), "lambda-subagents"); + await mkdir(runDirectory, { recursive: true, mode: 0o700 }); + const logFile = join(runDirectory, `${Date.now()}-${backend}-${role}.jsonl`); + const { command, args } = invocation(backend, executable, selection, cwd); + const output: string[] = []; + let stderr = ""; + let buffer = ""; - const exitCode = await new Promise((resolve) => { - const child = spawn(command, args, { cwd, shell: false, stdio: ["pipe", "pipe", "pipe"] }); - const abort = () => child.kill("SIGTERM"); - if (signal?.aborted) abort(); - else signal?.addEventListener("abort", abort, { once: true }); + const exitCode = await new Promise((resolve) => { + const child = spawn(command, args, { cwd, shell: false, stdio: ["pipe", "pipe", "pipe"] }); + const abort = () => child.kill("SIGTERM"); + if (signal?.aborted) abort(); + else signal?.addEventListener("abort", abort, { once: true }); - const receive = (line: string) => { - if (!line.trim()) return; - void appendFile(logFile, `${line}\n`, { encoding: "utf8", mode: 0o600 }); - try { - const text = textFromEvent(JSON.parse(line)); - if (text.length > 0) { - output.push(...text); - onProgress(truncate(output.join("\n"))); - } - } catch { - // Native tools may emit a non-JSON diagnostic; stderr retains it when available. - } - }; + const receive = (line: string) => { + if (!line.trim()) return; + void appendFile(logFile, `${line}\n`, { encoding: "utf8", mode: 0o600 }); + try { + const text = textFromEvent(JSON.parse(line)); + if (text.length > 0) { + output.push(...text); + onProgress(truncate(output.join("\n"))); + } + } catch { + // Native tools may emit a non-JSON diagnostic; stderr retains it when available. + } + }; - child.stdout.on("data", (chunk: Buffer) => { - buffer += chunk.toString("utf8"); - const lines = buffer.split("\n"); - buffer = lines.pop() ?? ""; - for (const line of lines) receive(line); - }); - child.stderr.on("data", (chunk: Buffer) => { - stderr += chunk.toString("utf8"); - }); - child.once("error", (error) => { - stderr += `${error.message}\n`; - resolve(127); - }); - child.on("close", (code) => { - if (buffer) receive(buffer); - signal?.removeEventListener("abort", abort); - resolve(code ?? 1); - }); - child.stdin.end(promptFor(role, task)); - }); + child.stdout.on("data", (chunk: Buffer) => { + buffer += chunk.toString("utf8"); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + for (const line of lines) receive(line); + }); + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf8"); + }); + child.once("error", (error) => { + stderr += `${error.message}\n`; + resolve(127); + }); + child.on("close", (code) => { + if (buffer) receive(buffer); + signal?.removeEventListener("abort", abort); + resolve(code ?? 1); + }); + child.stdin.end(promptFor(role, task)); + }); - const combined = `${output.join("\n")}\n${stderr}`; - return { - backend, - role, - executable, - model, - output: truncate(output.join("\n").trim() || stderr.trim() || "(no final output)"), - stderr, - exitCode, - logFile, - authRequired: isAuthFailure(backend, combined), - }; + const combined = `${output.join("\n")}\n${stderr}`; + return { + backend, + role, + executable, + model: selection.model, + effort: selection.effort, + output: truncate(output.join("\n").trim() || stderr.trim() || "(no final output)"), + stderr, + exitCode, + logFile, + authRequired: isAuthFailure(backend, combined), + }; } async function withCodeLock(role: Role, action: () => Promise): Promise { - if (role !== "code") return action(); - const previous = codeRun; - let release!: () => void; - codeRun = new Promise((resolve) => { release = resolve; }); - await previous; - try { - return await action(); - } finally { - release(); - } + if (role !== "code") return action(); + const previous = codeRun; + let release!: () => void; + codeRun = new Promise((resolve) => { release = resolve; }); + await previous; + try { + return await action(); + } finally { + release(); + } } function resultText(result: RunResult): string { - const header = `${result.backend} ${result.role} (${result.model}; ${result.executable})`; - if (result.authRequired) { - return `${header} could not authenticate.\n\nClaude Code authentication is required. Run:\n\n!sbx-claude auth login\n\nLog: ${result.logFile}`; - } - const status = result.exitCode === 0 ? "completed" : `failed (exit ${result.exitCode})`; - return `${header} ${status}.\n\n${result.output}\n\nLog: ${result.logFile}`; + const header = `${result.backend} ${result.role} (${result.model}; effort ${result.effort}; ${result.executable})`; + if (result.authRequired) { + return `${header} could not authenticate.\n\nClaude Code authentication is required. Run \`!claude\` and complete \`/login\` inside the sandbox.\n\nLog: ${result.logFile}`; + } + const status = result.exitCode === 0 ? "completed" : `failed (exit ${result.exitCode})`; + return `${header} ${status}.\n\n${result.output}\n\nLog: ${result.logFile}`; } const parameters = Type.Object({ - backend: StringEnum(["codex", "claude"] as const, { description: "Native subagent backend" }), - role: StringEnum(roles, { description: "plan and review are read-only; code may modify the workspace" }), - task: Type.String({ description: "Focused task for the native subagent" }), - model: Type.Optional(Type.String({ description: "Native model override. Defaults to the slash-command role model." })), + backend: StringEnum(["codex", "claude"] as const, { description: "Native subagent backend" }), + role: StringEnum(roles, { description: "plan and review are read-only; code may modify the workspace" }), + task: Type.String({ description: "Focused task for the native subagent" }), + model: Type.Optional(StringEnum(modelAliases, { + description: "Model alias. Codex: sol (default), terra, luna. Claude: opus (default), sonnet.", + })), }); export default function (pi: ExtensionAPI) { - const execute = async ( - backend: Backend, - role: Role, - task: string, - cwd: string, - model: string | undefined, - signal: AbortSignal | undefined, - onProgress: (text: string) => void, - ) => withCodeLock(role, () => runNativeAgent(backend, role, task, cwd, model ?? modelFor(backend, role), signal, onProgress)); + const execute = async ( + backend: Backend, + role: Role, + task: string, + cwd: string, + modelAlias: ModelAlias | undefined, + signal: AbortSignal | undefined, + onProgress: (text: string) => void, + ) => { + const selection = selectionFor(backend, role, modelAlias); + return withCodeLock(role, () => runNativeAgent(backend, role, task, cwd, selection, signal, onProgress)); + }; - pi.registerTool({ - name: "run_subagent", - label: "Run Subagent", - description: "Delegate a focused plan, review, or coding task to native Codex or Claude Code. Code runs share a workspace lock; plan and review are read-only.", - parameters, - async execute(_id, params, signal, onUpdate, ctx) { - const result = await execute(params.backend, params.role, params.task, ctx.cwd, params.model, signal, (text) => { - onUpdate?.({ content: [{ type: "text", text }] }); - }); - return { content: [{ type: "text", text: resultText(result) }], details: result }; - }, - }); + pi.registerTool({ + name: "run_subagent", + label: "Run Subagent", + description: "Run native Codex or Claude Code only when the current user explicitly asks for that subagent. Never use it proactively. Code runs share a workspace lock; plan and review are read-only.", + promptGuidelines: [ + "Never call run_subagent proactively; call it only when the current user explicitly asks to run or delegate to a native subagent, Codex, or Claude.", + ], + parameters, + prepareArguments(args) { + if (!args || typeof args !== "object") return args; + const input = args as { model?: unknown }; + const legacyAliases: Record = { + "gpt-5.6-sol": "sol", + "gpt-5.6-terra": "terra", + "gpt-5.6-luna": "luna", + }; + return typeof input.model === "string" && legacyAliases[input.model] + ? { ...input, model: legacyAliases[input.model] } + : args; + }, + async execute(_id, params, signal, onUpdate, ctx) { + const result = await execute(params.backend, params.role, params.task, ctx.cwd, params.model, signal, (text) => { + onUpdate?.({ content: [{ type: "text", text }] }); + }); + return { content: [{ type: "text", text: resultText(result) }], details: result }; + }, + }); - for (const backend of ["codex", "claude"] as const) { - pi.registerCommand(backend, { - description: `Run native ${backend === "codex" ? "Codex" : "Claude Code"}: /${backend} `, - handler: async (args, ctx) => { - const [role, ...taskParts] = args.trim().split(/\s+/); - if (!roles.includes(role as Role) || taskParts.length === 0) { - ctx.ui.notify(`Usage: /${backend} `, "error"); - return; - } - ctx.ui.setStatus("native-subagent", `${backend} ${role} running…`); - try { - const result = await execute(backend, role as Role, taskParts.join(" "), ctx.cwd, modelFor(backend, role as Role), undefined, (text) => { - ctx.ui.setStatus("native-subagent", `${backend} ${role}: ${text.slice(-80)}`); - }); - pi.sendMessage({ customType: "native-subagent", content: resultText(result), display: true, details: result }); - } finally { - ctx.ui.setStatus("native-subagent", undefined); - } - }, - }); - } + for (const backend of ["codex", "claude"] as const) { + const availableAliases = aliasesFor(backend); + pi.registerCommand(backend, { + description: `Run native ${backend === "codex" ? "Codex" : "Claude Code"}: /${backend} [${availableAliases.join("|")}] `, + handler: async (args, ctx) => { + const [roleToken, ...remainingParts] = args.trim().split(/\s+/); + const requestedAlias = availableAliases.includes(remainingParts[0] as ModelAlias) + ? remainingParts.shift() as ModelAlias + : undefined; + if (!roles.includes(roleToken as Role) || remainingParts.length === 0) { + ctx.ui.notify(`Usage: /${backend} [${availableAliases.join("|")}] `, "error"); + return; + } + const role = roleToken as Role; + const selection = selectionFor(backend, role, requestedAlias); + ctx.ui.setStatus("native-subagent", `${backend} ${role} (${selection.alias ?? selection.model}) running…`); + try { + const result = await execute(backend, role, remainingParts.join(" "), ctx.cwd, requestedAlias, undefined, (text) => { + ctx.ui.setStatus("native-subagent", `${backend} ${role}: ${text.slice(-80)}`); + }); + pi.sendMessage({ customType: "native-subagent", content: resultText(result), display: true, details: result }); + } finally { + ctx.ui.setStatus("native-subagent", undefined); + } + }, + }); + } } diff --git a/files/home/.pi/agent/extensions/sbx-codex.ts b/files/home/.pi/agent/extensions/sbx-codex.ts index deacd42..61b1e97 100644 --- a/files/home/.pi/agent/extensions/sbx-codex.ts +++ b/files/home/.pi/agent/extensions/sbx-codex.ts @@ -1,19 +1,24 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; // Pi parses Codex API keys as JWTs before sending them. This non-secret value -// satisfies that local parser; the header hook below swaps it for Codex's SBX -// sentinel, which the built-in Codex sandbox OAuth proxy replaces on the host. +// satisfies that local parser so that Pi emits an Authorization header at all. +// The header hook below rewrites it to a placeholder, and the sandbox proxy +// overwrites that header on requests to chatgpt.com with the host-minted token +// from the kit's `chatgpt-codex` credential. The placeholder value itself is +// irrelevant: apiKey injection replaces whatever the header contains. Without +// this extension Pi sends no Authorization header, so there is nothing for the +// proxy to substitute. const PI_CODEX_SENTINEL = - "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoicHJveHktbWFuYWdlZCJ9fQ.proxy-managed"; + "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoicHJveHktbWFuYWdlZCJ9fQ.proxy-managed"; const SBX_CODEX_SENTINEL = "oai-oat01-proxy-managed"; export default function configureSbxCodex(pi: ExtensionAPI): void { - pi.registerProvider("openai-codex", { - apiKey: PI_CODEX_SENTINEL, - }); - pi.on("before_provider_headers", (event) => { - if (event.headers.Authorization === `Bearer ${PI_CODEX_SENTINEL}`) { - event.headers.Authorization = `Bearer ${SBX_CODEX_SENTINEL}`; - } - }); + pi.registerProvider("openai-codex", { + apiKey: PI_CODEX_SENTINEL, + }); + pi.on("before_provider_headers", (event) => { + if (event.headers.Authorization === `Bearer ${PI_CODEX_SENTINEL}`) { + event.headers.Authorization = `Bearer ${SBX_CODEX_SENTINEL}`; + } + }); } diff --git a/files/home/.pi/agent/models.json b/files/home/.pi/agent/models.json new file mode 100644 index 0000000..6434076 --- /dev/null +++ b/files/home/.pi/agent/models.json @@ -0,0 +1,15 @@ +{ + "providers": { + "openai-codex": { + "modelOverrides": { + "gpt-5.6-terra": { + "thinkingLevelMap": { + "xhigh": "xhigh", + "max": "ultra", + "minimal": "low" + } + } + } + } + } +} diff --git a/spec.yaml b/spec.yaml index 6753d4b..470121e 100644 --- a/spec.yaml +++ b/spec.yaml @@ -1,54 +1,158 @@ schemaVersion: "2" -kind: mixin +kind: sandbox name: lambda -displayName: lambda -description: "lambda agent" +displayName: Lambda +description: "Pi coding agent with native Codex and Claude subagents" +sourceURL: https://github.com/withlogicco/sbx-kit-lambda -agentContext: | - ## AGENTS.md scope and ownership +sandbox: + # shell-docker satisfies the base-agent contract (agent user at UID 1000, + # passwordless sudo, proxy env forwarding) and ships Docker for agents that + # need containers. Every agent binary is installed by setup.install below. + image: "docker/sandbox-templates:shell-docker" + # `lambda` is a symlink to `pi`, so the model/provider defaults ride in + # `command` rather than in a wrapper script. + entrypoint: [lambda] + command: + - --provider + - openai-codex + - --model + - gpt-5.6-sol + - --thinking + - high + - --models + - "openai-codex/gpt-5.6-sol:high,openai-codex/gpt-5.6-terra:max,openai-codex/gpt-5.6-luna:high,opencode-go/*" - - Keep `AGENTS.md` for rules and knowledge that affect the whole repository. - - Create and keep app-specific rules in `/AGENTS.md` for Django projects, when guidance only applies to one app. - - When both global and local rules exist for a given scope, apply both, with the more specific file taking precedence for that scope. +agentInstructions: + filename: AGENTS.md + content: | + ## AGENTS.md scope and ownership - ## Maintaining session learnings + - Keep `AGENTS.md` for rules and knowledge that affect the whole repository. + - Create and keep app-specific rules in `/AGENTS.md` for Django projects, when guidance only applies to one app. + - When both global and local rules exist for a given scope, apply both, with the more specific file taking precedence for that scope. - - Every time the user corrects an important behavior or implementation decision, record that learning in the relevant `AGENTS.md` file in the same change set. - - Treat repeated corrections and workflow mistakes as high-priority learnings to persist. - - Keep learnings concise, actionable, and scoped to the right level (global, app-local, or `src`). - - Do not include learnings for things that make sense only in that session context. + ## Maintaining session learnings - ## Testing with Docker Compose + - Every time the user corrects an important behavior or implementation decision, record that learning in the relevant `AGENTS.md` file in the same change set. + - Treat repeated corrections and workflow mistakes as high-priority learnings to persist. + - Keep learnings concise, actionable, and scoped to the right level (global, app-local, or `src`). + - Do not include learnings for things that make sense only in that session context. - - When a Docker Compose configuration is present, boot its dependent services with the local Docker daemon (for example, `docker compose up -d`) and ensure their published ports are available on `localhost`. - - Run the web app's tests from the workspace, not from a Compose container. The web app's test command must supply the environment variables it needs to connect to those services through `localhost`. - - Use the .github/workflows files to understand what tests need to be run and how + ## Testing with Docker Compose - ## Plan conventions + - When a Docker Compose configuration is present, boot its dependent services with the local Docker daemon (for example, `docker compose up -d`) and ensure their published ports are available on `localhost`. + - Run the web app's tests from the workspace, not from a Compose container. The web app's test command must supply the environment variables it needs to connect to those services through `localhost`. + - Use the .github/workflows files to understand what tests need to be run and how. - - When creating a plan, always write it to `agents/plans/yyyy-mm-dd-slug.md`. - - Keep plans concise: scope, steps, relevant files, verification, and decisions. - - When done, keep only the plan as a log of the changes and decisions. - - Include an `agents/CHANGELOG.md` entry with the date, plan link, and short description for each plan. + ## Plan conventions -caps: + - When creating a plan, always write it to `agents/plans/yyyy-mm-dd-slug.md`. + - Keep plans concise: scope, steps, relevant files, implementation, verification, and decisions. + - When done, keep only the plan as a log of the changes and decisions. + - Include an `agents/CHANGELOG.md` entry with the date, plan link, and short description for each plan. + + ## Native subagents + + - Never invoke Codex, Claude, or `run_subagent` proactively. Only delegate after the current user clearly asks for a native subagent. + - Once the user has requested delegation, invoke `run_subagent` normally. The tool itself does not second-guess or block the model's interpretation of that request. + - `codex` and `claude` are the real upstream CLIs in this sandbox; `lambda` is Pi. Never shell out to `lambda` from a subagent. + - Native subagents run inside the SBX security boundary and must proceed without asking for permission or approval. + + ## Tool installation + + - Install supported standalone CLI tools with Webi. Use an upstream installer or package manager only when Webi does not provide the tool, and document that exception. + + ## Rules of engagement + + - Do not create, amend, or push commits unless the user explicitly asks for a commit or PR. + +permissions: network: allow: - pi.dev + - webi.sh - registry.npmjs.org - opencode.ai - github.com - raw.githubusercontent.com - release-assets.githubusercontent.com + # OpenAI: OAuth token endpoint plus the Codex/ChatGPT backend hosts + - auth.openai.com - chatgpt.com + - api.openai.com + - openai.com + # Anthropic: OAuth token endpoint plus API and download hosts + - platform.claude.com - api.anthropic.com - claude.ai - console.anthropic.com - - platform.claude.com + - downloads.claude.ai + - claude.com + - registry.terraform.io + - docs.cloud.google.com + # VS Code server download and extension gallery (Remote-SSH installs) + - update.code.visualstudio.com + - vscode.download.prss.microsoft.com + - marketplace.visualstudio.com + - vscode.blob.core.windows.net + - "*.vsassets.io" + # GitHub CLI API access + - api.github.com + - objects.githubusercontent.com + +environment: + variables: + # Claude Code checks this before accepting --dangerously-skip-permissions. + IS_SANDBOX: "1" + CODEX_HOME: /home/agent/.codex + # A 401 from the credentials proxy must fail fast instead of making git + # open /dev/tty for a username, which would freeze the TUI. + GIT_TERMINAL_PROMPT: "0" credentials: + # Host-minted ChatGPT access token. Proxy-managed OAuth does not activate for + # a third-party sandbox kit (verified), but API-key injection does, so the + # host mints and refreshes the token and the proxy substitutes it per request. + # The token never enters the sandbox. + # + # sbx secret set chatgpt-codex \ + # --command 'pi auth print-bearer-token --provider openai-codex' \ + # --refresh on-demand + # + # Deliberately NOT named `openai`: --command cannot combine with --oauth, so + # reusing that service id would replace the built-in codex agent's OAuth + # registration and break plain `sbx run codex`. + # + # Both consumers must emit an Authorization header for chatgpt.com before the + # proxy can overwrite it: Pi does so via sbx-codex.ts, and the native Codex + # CLI via the `sandboxd` model provider seeded into ~/.codex/config.toml. + - service: chatgpt-codex + description: "ChatGPT subscription access token, minted on the host by Pi" + required: true + apiKey: + name: LAMBDA_CHATGPT_TOKEN + proxyManaged: true + inject: + - domain: chatgpt.com + header: Authorization + format: "Bearer %s" + # Long-lived Claude Code token from `claude setup-token` on the host, stored + # with `sbx secret set claude-code --token`. Claude Code sends an OAuth token + # as a bearer, so the proxy overwrites the same header. + - service: claude-code + description: "Long-lived Claude Code token from `claude setup-token`" + required: false + apiKey: + name: CLAUDE_CODE_OAUTH_TOKEN + proxyManaged: true + inject: + - domain: api.anthropic.com + header: Authorization + format: "Bearer %s" - service: opencode-go - description: "OpenCode Go API key" + description: "OpenCode Go API key used by Pi" + required: true apiKey: name: OPENCODE_API_KEY proxyManaged: true @@ -56,66 +160,135 @@ credentials: - domain: opencode.ai header: Authorization format: "Bearer %s" + - service: github + description: "GitHub token for the gh CLI and git over HTTPS" + required: false + apiKey: + name: GH_TOKEN + proxyManaged: true + inject: + - domain: api.github.com + scheme: bearer + - domain: raw.githubusercontent.com + scheme: bearer + - domain: github.com + scheme: basic + username: x-access-token + +# Volumes survive sandbox recreation. `~/.pi/agent` as a whole is deliberately +# NOT mounted: the kit ships its extensions and models.json into +# `~/.pi/agent/extensions/` and `~/.pi/agent/models.json`, and a volume there +# would shadow or stale-cache kit-owned files across kit updates. Only Pi's +# session storage is persisted. +volumes: + - path: /home/agent/.pi/agent/sessions + size: 2g + - path: /home/agent/.claude + size: 2g + - path: /home/agent/.codex + size: 1g -commands: +setup: install: - - command: "npm config set proxy $HTTP_PROXY && npm config set https-proxy $HTTP_PROXY" + - command: | + set -eu + mkdir -p /home/agent/.pi/agent/extensions /home/agent/.pi/agent/sessions \ + /home/agent/.claude /home/agent/.codex /home/agent/.agents + chown -R agent:agent /home/agent/.pi /home/agent/.claude /home/agent/.codex /home/agent/.agents + user: "0" + description: "Ensure agent ownership of the Pi, Claude, and Codex home directories" + - command: 'if [ -n "${HTTP_PROXY:-}" ]; then npm config set proxy="$HTTP_PROXY" https-proxy="${HTTPS_PROXY:-$HTTP_PROXY}"; fi' user: "1000" - description: "Configure npm proxy for the Pi installer" - - command: 'i=0; while [ $i -lt 5 ]; do if curl -fsSL https://pi.dev/install.sh -o /tmp/pi-install.sh && sh /tmp/pi-install.sh; then break; fi; i=$((i+1)); echo "Retrying ($i/5)..."; sleep 2; done; [ $i -lt 5 ]' + description: "Configure npm to use the sandbox proxy" + - command: 'command -v pi >/dev/null 2>&1 || { i=0; while [ $i -lt 5 ]; do if curl -fsSL https://pi.dev/install.sh -o /tmp/pi-install.sh && sh /tmp/pi-install.sh; then break; fi; i=$((i+1)); echo "Retrying ($i/5)..."; sleep 2; done; [ $i -lt 5 ]; }' user: "1000" description: "Install Pi coding agent with the pi.dev installer" - command: | set -eu - version="0.30.0" - case "$(uname -m)" in - x86_64|amd64) - asset="jj-v${version}-x86_64-unknown-linux-musl.tar.gz" - sha256="5540b5438d71867ccde3339d041e57eecf60edf8b4ecc3dfcce2af106e08f70b" - ;; - aarch64|arm64) - asset="jj-v${version}-aarch64-unknown-linux-musl.tar.gz" - sha256="36001ee38b14d0241e8c061f3147e4c52754b02cc32c4e3f09ebf641cb28f6b9" - ;; - *) echo "Unsupported jj architecture: $(uname -m)" >&2; exit 1 ;; - esac - archive="/tmp/${asset}" - curl -fsSL --retry 4 "https://github.com/jj-vcs/jj/releases/download/v${version}/${asset}" -o "$archive" - echo "${sha256} ${archive}" | sha256sum -c - - install_dir="$(mktemp -d)" - tar -xzf "$archive" -C "$install_dir" - install -m 0755 "$(find "$install_dir" -type f -name jj -print -quit)" /usr/local/bin/jj.new - mv /usr/local/bin/jj.new /usr/local/bin/jj - jj --version - rm -rf "$install_dir" "$archive" - user: "0" - description: "Install pinned Jujutsu for private Lambda turn history" - - command: "npm install -g @anthropic-ai/claude-code" - user: "0" + pi_path="$(command -v pi)" + ln -sfn "$pi_path" "$(dirname "$pi_path")/lambda" + lambda --version + user: "1000" + description: "Install the lambda agent as a symlink to pi" + - command: "command -v codex >/dev/null 2>&1 || npm install -g @openai/codex" + user: "1000" + description: "Install the native Codex CLI" + - command: "command -v claude >/dev/null 2>&1 || npm install -g @anthropic-ai/claude-code" + user: "1000" description: "Install the native Claude Code CLI" - command: | - cat > /usr/local/share/npm-global/bin/sbx-claude <<'SH' - #!/bin/sh - exec claude "$@" - SH - chmod +x /usr/local/share/npm-global/bin/sbx-claude - user: "0" - description: "Preserve native Claude Code as sbx-claude" - - command: | - mv /usr/local/share/npm-global/bin/codex /usr/local/share/npm-global/bin/sbx-codex - cat > /usr/local/share/npm-global/bin/codex <<'SH' - #!/bin/sh set -eu - if [ "${1:-}" = "--dangerously-bypass-approvals-and-sandbox" ]; then - shift + if [ ! -x "$HOME/.local/bin/gh" ]; then + installer="$(mktemp)" + curl -fsSL --retry 4 https://webi.sh/gh -o "$installer" + sh "$installer" + rm -f "$installer" fi - exec pi \ - --provider openai-codex \ - --model gpt-5.6-terra \ - --thinking high \ - --models "openai-codex/*,opencode-go/*" \ - "$@" - SH - chmod +x /usr/local/share/npm-global/bin/codex + "$HOME/.local/bin/gh" --version + user: "1000" + description: "Install GitHub CLI with Webi" + # Point the native Codex CLI at the ChatGPT backend through a custom model + # provider, mirroring what the built-in codex kit does in `oauth` mode. The + # bearer token here is only a placeholder that makes Codex emit an + # Authorization header; the proxy overwrites it with the host-minted token + # from the `chatgpt-codex` credential. requires_openai_auth = false stops + # Codex from insisting on its own login, and forced_login_method = "api" + # stops a remote client from replacing this with sandbox-local OAuth. + # Rewritten unconditionally on every create, so it is deterministic and + # idempotent even though ~/.codex is a volume. + - command: | + set -eu + mkdir -p /home/agent/.codex + cat > /home/agent/.codex/config.toml <<'EOF' + approval_policy = "never" + sandbox_mode = "danger-full-access" + mcp_oauth_credentials_store = "file" + forced_login_method = "api" + model_provider = "sandboxd" + + [model_providers.sandboxd] + name = "Sandbox Proxy" + base_url = "https://chatgpt.com/backend-api/codex" + experimental_bearer_token = "oai-oat01-proxy-managed" + requires_openai_auth = false + EOF + printf '%s' '{"OPENAI_API_KEY":"proxy-managed"}' > /home/agent/.codex/auth.json + chmod 600 /home/agent/.codex/auth.json + user: "1000" + description: "Point the native Codex CLI at the proxy-injected ChatGPT backend" + # ~/.claude.json holds Claude Code's onboarding/trust/bypass flags and is + # credential-independent. Written as root and chowned because the file may + # already exist with restrictive ownership. + - command: | + set -e + ws="${WORKSPACE_DIR:-/}" + esc=$(printf '%s' "$ws" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g; s/\r/\\r/g') + projects="\"/\": { \"hasTrustDialogAccepted\": true }" + [ "$ws" = "/" ] || projects="$projects, \"$esc\": { \"hasTrustDialogAccepted\": true }" + printf '%s\n' "{ + \"bypassPermissionsModeAccepted\": true, + \"hasCompletedOnboarding\": true, + \"projects\": { $projects } + }" > /home/agent/.claude.json + chown agent:agent /home/agent/.claude.json user: "0" - description: "Make Codex launch lambda while retaining SBX OAuth identity" + description: "Seed Claude Code bypass and trust flags" + # No apiKeyHelper here, deliberately. The built-in claude kit seeds + # `apiKeyHelper: "echo proxy-managed"` so Claude Code presents the proxy + # sentinel, but proxy-managed OAuth does not activate for a third-party + # sandbox kit (verified: the proxy never substitutes the sentinel). Writing + # the helper would make Claude Code send a dead sentinel and never prompt, + # which breaks the in-sandbox `/login` that is the only working path here. + # SBX_CRED_ANTHROPIC_MODE is not a usable gate either: it reports `apikey` + # even for a credential this kit declares as OAuth-only. + - command: | + set -e + mkdir -p /home/agent/.claude + printf '%s' "{ + \"alwaysThinkingEnabled\": true, + \"defaultMode\": \"bypassPermissions\", + \"bypassPermissionsModeAccepted\": true + } + " > /home/agent/.claude/settings.json + user: "1000" + description: "Seed Claude Code settings"