From a3759fa24ba258de966506f3dc245de83046e545 Mon Sep 17 00:00:00 2001 From: Antonis Kalipetis Date: Mon, 31 Aug 2026 13:22:06 +0300 Subject: [PATCH] feat!: convert the mixin into a standalone agent kit Lambda was a `kind: mixin` that required the built-in `codex` agent and hijacked its entrypoint to launch Pi, exiling the native CLIs to `sbx-codex` and `sbx-claude`. That existed only to keep host-managed OpenAI OAuth, which is gated on built-in provenance. Lambda is now a `kind: sandbox` agent kit built on `docker/sandbox-templates:shell-docker`, with `lambda` as its own agent binary (a symlink to `pi`, with Pi's provider and model defaults in `sandbox.command`). `codex` and `claude` are the real upstream CLIs again and every shim is gone. Proxy-managed OAuth does not activate for a third-party sandbox kit: the proxy never substitutes the sentinel, verified by a request carrying it returning a 401 byte-identical to one carrying a garbage token. API-key injection does work, so host-managed auth is rebuilt on top of it. The host mints and refreshes tokens and the proxy substitutes them per request, so no token enters the sandbox and no credential is per-project: - `chatgpt-codex` injects into `chatgpt.com`, sourced from `pi auth print-bearer-token --provider openai-codex` with `--refresh on-demand`. Deliberately not named `openai`, because `--command` cannot combine with `--oauth` and reusing that service id would break plain `sbx run codex`. - `claude-code` injects into `api.anthropic.com` from a `claude setup-token` token. - `opencode-go` and `github` are unchanged. Verified end to end: native `claude` returns a completion on `claude-opus-5` drawing on the plan rather than overage; Pi and native `codex` both reach the account and return account-scoped billing messages rather than auth failures. Also switches TypeScript indentation to spaces and adds `.editorconfig` to enforce it. BREAKING CHANGE: the agent is now `lambda`, not `codex`. Launch with `sbx run lambda --kit ...`, and recreate existing sandboxes to pick it up. The `sbx-codex` and `sbx-claude` commands no longer exist. Co-Authored-By: Claude Opus 5 --- .editorconfig | 18 + .vscode/settings.json | 3 + AGENTS.md | 79 ++- README.md | 232 ++++++-- agents/CHANGELOG.md | 2 + agents/plans/2026-07-16-native-subagents.md | 17 +- agents/plans/2026-08-28-v2-kit-repair.md | 53 ++ agents/plans/2026-08-31-agent-kit.md | 197 +++++++ .../.pi/agent/extensions/native-subagents.ts | 497 ++++++++++-------- files/home/.pi/agent/extensions/sbx-codex.ts | 27 +- files/home/.pi/agent/models.json | 15 + spec.yaml | 325 +++++++++--- 12 files changed, 1085 insertions(+), 380 deletions(-) create mode 100644 .editorconfig create mode 100644 .vscode/settings.json create mode 100644 agents/plans/2026-08-28-v2-kit-repair.md create mode 100644 agents/plans/2026-08-31-agent-kit.md create mode 100644 files/home/.pi/agent/models.json 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"