@phi-code-admin/phi-code 0.89.0 → 0.91.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,76 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.91.0] - 2026-07-11
4
+
5
+ ### Added — Alibaba Cloud Coding Plan, first-class and one-command
6
+
7
+ The Alibaba Coding Plan support (endpoints, live catalog, ping, wizard) already
8
+ existed but was only reachable through the interactive `/setup` wizard — a user
9
+ could not simply add the provider. Verified against the official docs
10
+ (https://www.alibabacloud.com/help/en/model-studio/coding-plan) and a live API
11
+ round-trip (catalog listing + a real `qwen3.7-plus` completion).
12
+
13
+ - **`/keys set` now bootstraps known providers.** `providers/provider-bootstrap.ts`
14
+ (pure, 11 tests) builds a complete first-time models.json entry — baseUrl, api
15
+ protocol, bundled model list — from a single API key, with each provider's own
16
+ key-format validation applied up front (`sk-sp-` prefix for Alibaba). One
17
+ command configures the provider end to end:
18
+ `/keys set alibaba-codingplan sk-sp-…` — then the session-start refresh
19
+ replaces the bundled list with the live catalog. Bootstrappable ids:
20
+ `alibaba-codingplan`, `alibaba-codingplan-anthropic`, `opencode-go`,
21
+ `opencode-go-anthropic`. Unknown providers keep the previous /setup guidance.
22
+ - **`/keys test` now actually works.** `registerProviderPing` existed but nothing
23
+ ever called it; keys.ts now registers the built-in pings (Alibaba, OpenCode Go)
24
+ at load, so `/keys test alibaba-codingplan` validates the key against the real
25
+ `/v1/models` endpoint.
26
+ - **Provider display names.** `alibaba-codingplan`, `alibaba-codingplan-anthropic`
27
+ and `opencode-go-anthropic` now render with proper names in the /model picker
28
+ (they fell back to raw ids).
29
+
30
+ ## [0.90.0] - 2026-07-11
31
+
32
+ ### Added — the execution sandbox (the guaranteed oracle)
33
+
34
+ This is the infrastructure the 0.89.0 design (`docs/design/plan-debug-build.md`)
35
+ called its "real investment": it turns `/debug` and `/build` from prompt-enforced
36
+ discipline into a **guaranteed oracle**. The agent can no longer merely claim it
37
+ ran the test — a real container runs it and returns the true exit code.
38
+
39
+ - **`providers/sandbox-plan.ts` (pure, 29 tests)** — detects the project toolchain
40
+ (node / python / go / rust / ruby / Dockerfile), builds an environment recipe
41
+ (base image, dependency-install, test command, env), decides the backend
42
+ **honestly**, and constructs the `docker run` argv. The Windows path/quoting
43
+ pitfalls that defeated the earlier SWE-bench harness (bind-mount mangling,
44
+ stdlib shadowing) are handled here and unit-tested: `-v C:/…:/work`,
45
+ `PYTHONSAFEPATH=1`.
46
+ - **`providers/sandbox.ts` (IO shell, 8 tests incl. a real-Docker smoke)** — the
47
+ `Sandbox` interface with three backends: `docker` (guaranteed environment),
48
+ `local` (real runs, but not dependency-guaranteed), and `unavailable` (honest —
49
+ every exec reports `SANDBOX UNAVAILABLE` so the modes emit `BLOCKED`, never a
50
+ fabricated pass). Ephemeral container, host bind-mount, so a `/debug` fix lands
51
+ on disk and the next run sees it. Docker is demanded-but-absent → `unavailable`,
52
+ never a silent downgrade.
53
+ - **`sandbox_run` tool** — exposed to the `/debug` and `/build` phase agents. The
54
+ reproduction, the suite, and acceptance/red-team checks run through it; the
55
+ verdict is tied to its structured result (`passed`, real `exitCode`), not to the
56
+ model's prose. `execution.ts` gained `runArgv` (no-shell argv spawn) for safe
57
+ `docker` invocation.
58
+ - **`/sandbox` command** — `status` (detected toolchain, backend, recipe),
59
+ `prepare` (pull image / build Dockerfile / install deps), `run <cmd>` (one-off).
60
+ A `.phi/sandbox.json` overrides everything (image, setup, test, backend, memory,
61
+ cpus, network).
62
+
63
+ 50 new tests (1425 total, green). The `/debug` and `/build` phase instructions now
64
+ route every oracle run through `sandbox_run` and downgrade to `BLOCKED` when the
65
+ sandbox is unavailable.
66
+
67
+ ### Still honest
68
+
69
+ The sandbox makes the thesis **measurable** — it is the missing piece that let the
70
+ model self-grade before. It does not by itself prove `/debug` beats a single shot;
71
+ that is now a runnable measurement (containerized SWE-bench-lite with `sandbox_run`
72
+ as the oracle), and it is the next step, not a claim made here.
73
+
3
74
  ## [0.89.0] - 2026-07-11
4
75
 
5
76
  ### Added
@@ -1 +1 @@
1
- {"version":3,"file":"provider-display-names.d.ts","sourceRoot":"","sources":["../../src/core/provider-display-names.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,+BAA+B,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CA+BlE,CAAC","sourcesContent":["export const BUILT_IN_PROVIDER_DISPLAY_NAMES: Record<string, string> = {\n\tanthropic: \"Anthropic\",\n\t\"amazon-bedrock\": \"Amazon Bedrock\",\n\t\"azure-openai-responses\": \"Azure OpenAI Responses\",\n\tcerebras: \"Cerebras\",\n\t\"cloudflare-ai-gateway\": \"Cloudflare AI Gateway\",\n\t\"cloudflare-workers-ai\": \"Cloudflare Workers AI\",\n\tdeepseek: \"DeepSeek\",\n\tfireworks: \"Fireworks\",\n\tgoogle: \"Google Gemini\",\n\t\"google-vertex\": \"Google Vertex AI\",\n\tgroq: \"Groq\",\n\thuggingface: \"Hugging Face\",\n\t\"kimi-coding\": \"Kimi For Coding\",\n\tmistral: \"Mistral\",\n\tminimax: \"MiniMax\",\n\t\"minimax-cn\": \"MiniMax (China)\",\n\tmoonshotai: \"Moonshot AI\",\n\t\"moonshotai-cn\": \"Moonshot AI (China)\",\n\topencode: \"OpenCode Zen\",\n\t\"opencode-go\": \"OpenCode Go\",\n\topenai: \"OpenAI\",\n\topenrouter: \"OpenRouter\",\n\ttogether: \"Together AI\",\n\t\"vercel-ai-gateway\": \"Vercel AI Gateway\",\n\txai: \"xAI\",\n\tzai: \"ZAI\",\n\txiaomi: \"Xiaomi MiMo\",\n\t\"xiaomi-token-plan-cn\": \"Xiaomi MiMo Token Plan (China)\",\n\t\"xiaomi-token-plan-ams\": \"Xiaomi MiMo Token Plan (Amsterdam)\",\n\t\"xiaomi-token-plan-sgp\": \"Xiaomi MiMo Token Plan (Singapore)\",\n};\n"]}
1
+ {"version":3,"file":"provider-display-names.d.ts","sourceRoot":"","sources":["../../src/core/provider-display-names.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,+BAA+B,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAkClE,CAAC","sourcesContent":["export const BUILT_IN_PROVIDER_DISPLAY_NAMES: Record<string, string> = {\n\t\"alibaba-codingplan\": \"Alibaba Coding Plan\",\n\t\"alibaba-codingplan-anthropic\": \"Alibaba Coding Plan (Anthropic-compat)\",\n\tanthropic: \"Anthropic\",\n\t\"amazon-bedrock\": \"Amazon Bedrock\",\n\t\"azure-openai-responses\": \"Azure OpenAI Responses\",\n\tcerebras: \"Cerebras\",\n\t\"cloudflare-ai-gateway\": \"Cloudflare AI Gateway\",\n\t\"cloudflare-workers-ai\": \"Cloudflare Workers AI\",\n\tdeepseek: \"DeepSeek\",\n\tfireworks: \"Fireworks\",\n\tgoogle: \"Google Gemini\",\n\t\"google-vertex\": \"Google Vertex AI\",\n\tgroq: \"Groq\",\n\thuggingface: \"Hugging Face\",\n\t\"kimi-coding\": \"Kimi For Coding\",\n\tmistral: \"Mistral\",\n\tminimax: \"MiniMax\",\n\t\"minimax-cn\": \"MiniMax (China)\",\n\tmoonshotai: \"Moonshot AI\",\n\t\"moonshotai-cn\": \"Moonshot AI (China)\",\n\topencode: \"OpenCode Zen\",\n\t\"opencode-go\": \"OpenCode Go\",\n\t\"opencode-go-anthropic\": \"OpenCode Go (Anthropic-compat)\",\n\topenai: \"OpenAI\",\n\topenrouter: \"OpenRouter\",\n\ttogether: \"Together AI\",\n\t\"vercel-ai-gateway\": \"Vercel AI Gateway\",\n\txai: \"xAI\",\n\tzai: \"ZAI\",\n\txiaomi: \"Xiaomi MiMo\",\n\t\"xiaomi-token-plan-cn\": \"Xiaomi MiMo Token Plan (China)\",\n\t\"xiaomi-token-plan-ams\": \"Xiaomi MiMo Token Plan (Amsterdam)\",\n\t\"xiaomi-token-plan-sgp\": \"Xiaomi MiMo Token Plan (Singapore)\",\n};\n"]}
@@ -1,4 +1,6 @@
1
1
  export const BUILT_IN_PROVIDER_DISPLAY_NAMES = {
2
+ "alibaba-codingplan": "Alibaba Coding Plan",
3
+ "alibaba-codingplan-anthropic": "Alibaba Coding Plan (Anthropic-compat)",
2
4
  anthropic: "Anthropic",
3
5
  "amazon-bedrock": "Amazon Bedrock",
4
6
  "azure-openai-responses": "Azure OpenAI Responses",
@@ -19,6 +21,7 @@ export const BUILT_IN_PROVIDER_DISPLAY_NAMES = {
19
21
  "moonshotai-cn": "Moonshot AI (China)",
20
22
  opencode: "OpenCode Zen",
21
23
  "opencode-go": "OpenCode Go",
24
+ "opencode-go-anthropic": "OpenCode Go (Anthropic-compat)",
22
25
  openai: "OpenAI",
23
26
  openrouter: "OpenRouter",
24
27
  together: "Together AI",
@@ -1 +1 @@
1
- {"version":3,"file":"provider-display-names.js","sourceRoot":"","sources":["../../src/core/provider-display-names.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,+BAA+B,GAA2B;IACtE,SAAS,EAAE,WAAW;IACtB,gBAAgB,EAAE,gBAAgB;IAClC,wBAAwB,EAAE,wBAAwB;IAClD,QAAQ,EAAE,UAAU;IACpB,uBAAuB,EAAE,uBAAuB;IAChD,uBAAuB,EAAE,uBAAuB;IAChD,QAAQ,EAAE,UAAU;IACpB,SAAS,EAAE,WAAW;IACtB,MAAM,EAAE,eAAe;IACvB,eAAe,EAAE,kBAAkB;IACnC,IAAI,EAAE,MAAM;IACZ,WAAW,EAAE,cAAc;IAC3B,aAAa,EAAE,iBAAiB;IAChC,OAAO,EAAE,SAAS;IAClB,OAAO,EAAE,SAAS;IAClB,YAAY,EAAE,iBAAiB;IAC/B,UAAU,EAAE,aAAa;IACzB,eAAe,EAAE,qBAAqB;IACtC,QAAQ,EAAE,cAAc;IACxB,aAAa,EAAE,aAAa;IAC5B,MAAM,EAAE,QAAQ;IAChB,UAAU,EAAE,YAAY;IACxB,QAAQ,EAAE,aAAa;IACvB,mBAAmB,EAAE,mBAAmB;IACxC,GAAG,EAAE,KAAK;IACV,GAAG,EAAE,KAAK;IACV,MAAM,EAAE,aAAa;IACrB,sBAAsB,EAAE,gCAAgC;IACxD,uBAAuB,EAAE,oCAAoC;IAC7D,uBAAuB,EAAE,oCAAoC;CAC7D,CAAC","sourcesContent":["export const BUILT_IN_PROVIDER_DISPLAY_NAMES: Record<string, string> = {\n\tanthropic: \"Anthropic\",\n\t\"amazon-bedrock\": \"Amazon Bedrock\",\n\t\"azure-openai-responses\": \"Azure OpenAI Responses\",\n\tcerebras: \"Cerebras\",\n\t\"cloudflare-ai-gateway\": \"Cloudflare AI Gateway\",\n\t\"cloudflare-workers-ai\": \"Cloudflare Workers AI\",\n\tdeepseek: \"DeepSeek\",\n\tfireworks: \"Fireworks\",\n\tgoogle: \"Google Gemini\",\n\t\"google-vertex\": \"Google Vertex AI\",\n\tgroq: \"Groq\",\n\thuggingface: \"Hugging Face\",\n\t\"kimi-coding\": \"Kimi For Coding\",\n\tmistral: \"Mistral\",\n\tminimax: \"MiniMax\",\n\t\"minimax-cn\": \"MiniMax (China)\",\n\tmoonshotai: \"Moonshot AI\",\n\t\"moonshotai-cn\": \"Moonshot AI (China)\",\n\topencode: \"OpenCode Zen\",\n\t\"opencode-go\": \"OpenCode Go\",\n\topenai: \"OpenAI\",\n\topenrouter: \"OpenRouter\",\n\ttogether: \"Together AI\",\n\t\"vercel-ai-gateway\": \"Vercel AI Gateway\",\n\txai: \"xAI\",\n\tzai: \"ZAI\",\n\txiaomi: \"Xiaomi MiMo\",\n\t\"xiaomi-token-plan-cn\": \"Xiaomi MiMo Token Plan (China)\",\n\t\"xiaomi-token-plan-ams\": \"Xiaomi MiMo Token Plan (Amsterdam)\",\n\t\"xiaomi-token-plan-sgp\": \"Xiaomi MiMo Token Plan (Singapore)\",\n};\n"]}
1
+ {"version":3,"file":"provider-display-names.js","sourceRoot":"","sources":["../../src/core/provider-display-names.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,+BAA+B,GAA2B;IACtE,oBAAoB,EAAE,qBAAqB;IAC3C,8BAA8B,EAAE,wCAAwC;IACxE,SAAS,EAAE,WAAW;IACtB,gBAAgB,EAAE,gBAAgB;IAClC,wBAAwB,EAAE,wBAAwB;IAClD,QAAQ,EAAE,UAAU;IACpB,uBAAuB,EAAE,uBAAuB;IAChD,uBAAuB,EAAE,uBAAuB;IAChD,QAAQ,EAAE,UAAU;IACpB,SAAS,EAAE,WAAW;IACtB,MAAM,EAAE,eAAe;IACvB,eAAe,EAAE,kBAAkB;IACnC,IAAI,EAAE,MAAM;IACZ,WAAW,EAAE,cAAc;IAC3B,aAAa,EAAE,iBAAiB;IAChC,OAAO,EAAE,SAAS;IAClB,OAAO,EAAE,SAAS;IAClB,YAAY,EAAE,iBAAiB;IAC/B,UAAU,EAAE,aAAa;IACzB,eAAe,EAAE,qBAAqB;IACtC,QAAQ,EAAE,cAAc;IACxB,aAAa,EAAE,aAAa;IAC5B,uBAAuB,EAAE,gCAAgC;IACzD,MAAM,EAAE,QAAQ;IAChB,UAAU,EAAE,YAAY;IACxB,QAAQ,EAAE,aAAa;IACvB,mBAAmB,EAAE,mBAAmB;IACxC,GAAG,EAAE,KAAK;IACV,GAAG,EAAE,KAAK;IACV,MAAM,EAAE,aAAa;IACrB,sBAAsB,EAAE,gCAAgC;IACxD,uBAAuB,EAAE,oCAAoC;IAC7D,uBAAuB,EAAE,oCAAoC;CAC7D,CAAC","sourcesContent":["export const BUILT_IN_PROVIDER_DISPLAY_NAMES: Record<string, string> = {\n\t\"alibaba-codingplan\": \"Alibaba Coding Plan\",\n\t\"alibaba-codingplan-anthropic\": \"Alibaba Coding Plan (Anthropic-compat)\",\n\tanthropic: \"Anthropic\",\n\t\"amazon-bedrock\": \"Amazon Bedrock\",\n\t\"azure-openai-responses\": \"Azure OpenAI Responses\",\n\tcerebras: \"Cerebras\",\n\t\"cloudflare-ai-gateway\": \"Cloudflare AI Gateway\",\n\t\"cloudflare-workers-ai\": \"Cloudflare Workers AI\",\n\tdeepseek: \"DeepSeek\",\n\tfireworks: \"Fireworks\",\n\tgoogle: \"Google Gemini\",\n\t\"google-vertex\": \"Google Vertex AI\",\n\tgroq: \"Groq\",\n\thuggingface: \"Hugging Face\",\n\t\"kimi-coding\": \"Kimi For Coding\",\n\tmistral: \"Mistral\",\n\tminimax: \"MiniMax\",\n\t\"minimax-cn\": \"MiniMax (China)\",\n\tmoonshotai: \"Moonshot AI\",\n\t\"moonshotai-cn\": \"Moonshot AI (China)\",\n\topencode: \"OpenCode Zen\",\n\t\"opencode-go\": \"OpenCode Go\",\n\t\"opencode-go-anthropic\": \"OpenCode Go (Anthropic-compat)\",\n\topenai: \"OpenAI\",\n\topenrouter: \"OpenRouter\",\n\ttogether: \"Together AI\",\n\t\"vercel-ai-gateway\": \"Vercel AI Gateway\",\n\txai: \"xAI\",\n\tzai: \"ZAI\",\n\txiaomi: \"Xiaomi MiMo\",\n\t\"xiaomi-token-plan-cn\": \"Xiaomi MiMo Token Plan (China)\",\n\t\"xiaomi-token-plan-ams\": \"Xiaomi MiMo Token Plan (Amsterdam)\",\n\t\"xiaomi-token-plan-sgp\": \"Xiaomi MiMo Token Plan (Singapore)\",\n};\n"]}
@@ -200,6 +200,18 @@ too-new Python — exactly what defeated the 3362 measurement), the modes must:
200
200
  Reusing phi's `run` / `verify` skills for local projects is the first target;
201
201
  a per-project container is the general solution.
202
202
 
203
+ **Status (2026-07-11): implemented.** `providers/sandbox-plan.ts` (pure: toolchain
204
+ detection → recipe → backend decision → `docker run` argv) and `providers/sandbox.ts`
205
+ (the `Sandbox` IO shell: `docker` | `local` | `unavailable`) provide the guaranteed
206
+ environment. The `sandbox_run` tool exposes it to the /debug and /build phase agents
207
+ — the reproduction, the suite, and acceptance/red-team checks now run in a real
208
+ container and return its true exit code, so a PASS cannot be asserted, only earned.
209
+ When Docker is absent and nothing is containerizable, the backend is `unavailable`
210
+ and every `sandbox_run` returns `SANDBOX UNAVAILABLE`, which the phase instructions
211
+ turn into `BLOCKED` — never a fabricated pass. The `/sandbox` command
212
+ (`status` | `prepare` | `run`) inspects and provisions it. A `.phi/sandbox.json`
213
+ overrides detection (image, setup, test, backend, resource caps).
214
+
203
215
  ---
204
216
 
205
217
  ## Triage / adaptive depth (cost discipline)
@@ -244,6 +256,11 @@ contract above; (2) make `acceptance[]` + `runRecipe` first-class /plan outputs;
244
256
  the real-run verify path. Items 1–4 are prompt/orchestration changes on existing
245
257
  machinery; item 5 is the real infrastructure investment.
246
258
 
259
+ Status: (1)–(4) shipped in 0.89.0; (5) the execution sandbox shipped in 0.90.0
260
+ (`providers/sandbox*.ts`, the `sandbox_run` tool, `/sandbox`). What remains is the
261
+ *measurement*: running /debug against a containerized SWE-bench-lite with the
262
+ sandbox as the oracle, to get the number.
263
+
247
264
  ## How we will know it worked (this must be measured, not shipped on faith)
248
265
 
249
266
  - `/debug`: on SWE-bench-lite (bug-fixing), with a real executable environment,
@@ -21,6 +21,9 @@
21
21
  */
22
22
 
23
23
  import { ApiKeyStore, type ExtensionAPI, getApiKeyStore, getConfigWatcher } from "phi-code";
24
+ import { pingAlibaba } from "./providers/alibaba.js";
25
+ import { pingOpenCodeGo } from "./providers/opencode-go.js";
26
+ import { bootstrapProviderConfig, isBootstrappableProvider } from "./providers/provider-bootstrap.js";
24
27
 
25
28
  type ProviderPingFn = (key: string, timeoutMs?: number) => Promise<{ ok: boolean; error?: string }>;
26
29
 
@@ -38,6 +41,13 @@ export default function keysExtension(pi: ExtensionAPI) {
38
41
  const store = getApiKeyStore();
39
42
  const watcher = getConfigWatcher();
40
43
 
44
+ // Built-in pings so `/keys test <id>` works out of the box for the providers
45
+ // phi ships builders for (previously nothing ever registered a ping).
46
+ registerProviderPing("alibaba-codingplan", pingAlibaba);
47
+ registerProviderPing("alibaba-codingplan-anthropic", pingAlibaba);
48
+ registerProviderPing("opencode-go", pingOpenCodeGo);
49
+ registerProviderPing("opencode-go-anthropic", pingOpenCodeGo);
50
+
41
51
  pi.registerCommand("keys", {
42
52
  description: "Manage API keys (list / set / remove / test / reload)",
43
53
  handler: async (args, ctx) => {
@@ -75,15 +85,35 @@ export default function keysExtension(pi: ExtensionAPI) {
75
85
  return;
76
86
  }
77
87
  // A bare `set` only updates the key; it cannot supply baseUrl/api/models.
78
- // For a never-configured id this would persist an unusable provider
79
- // (apiKey but no endpoint), so require an existing entry with a baseUrl.
88
+ // For a never-configured id: bootstrap the full entry when phi ships a
89
+ // builder for it (Alibaba Coding Plan, OpenCode Go); otherwise keep the
90
+ // old behaviour and point at /setup.
80
91
  const existing = store.getProvider(id);
81
92
  if (!existing?.baseUrl) {
93
+ if (!isBootstrappableProvider(id)) {
94
+ ctx.ui.notify(
95
+ `\`${id}\` is not a configured provider (no baseUrl on file). ` +
96
+ `Run \`/setup\` to add it with an endpoint and models first; ` +
97
+ `\`/keys set\` only updates the key of an already-configured provider.`,
98
+ "warning",
99
+ );
100
+ return;
101
+ }
102
+ const boot = bootstrapProviderConfig(id, key);
103
+ if (!boot.ok) {
104
+ ctx.ui.notify(`Cannot configure \`${id}\`: ${boot.error}`, "warning");
105
+ return;
106
+ }
107
+ watcher.muteForWrite("models_json_changed");
108
+ store.setKey(id, key, {
109
+ baseUrl: boot.config.baseUrl,
110
+ api: boot.config.api,
111
+ models: boot.config.models,
112
+ });
82
113
  ctx.ui.notify(
83
- `\`${id}\` is not a configured provider (no baseUrl on file). ` +
84
- `Run \`/setup\` to add it with an endpoint and models first; ` +
85
- `\`/keys set\` only updates the key of an already-configured provider.`,
86
- "warning",
114
+ `Configured \`${id}\` (\`${ApiKeyStore.maskKey(key)}\`) ${boot.note}\n` +
115
+ `Stored in ${store.configPath}. Use \`/keys test ${id}\` to validate, then pick a model with \`/model\`.`,
116
+ "info",
87
117
  );
88
118
  return;
89
119
  }
@@ -25,6 +25,7 @@ import type { ExtensionAPI } from "phi-code";
25
25
  import { type AgentDef, loadAgentDef } from "./providers/agent-def.js";
26
26
  import { buildVerifyInstruction, debugPhaseInstructions } from "./providers/debug-build-commands.js";
27
27
  import { type FailingState, parseFailingState } from "./providers/debug-contract.js";
28
+ import { passed, tail } from "./providers/execution.js";
28
29
  import { defaultExplorerSpecs, READONLY_EXPLORER_TOOLS, runExploreFanout } from "./providers/explore-fanout.js";
29
30
  import {
30
31
  analyzePhaseMessages,
@@ -33,6 +34,7 @@ import {
33
34
  resolvePhaseOutcome,
34
35
  type StructuredPhaseResult,
35
36
  } from "./providers/phase-machine.js";
37
+ import { resolveSandbox, type Sandbox } from "./providers/sandbox.js";
36
38
  import { triage } from "./providers/triage.js";
37
39
 
38
40
  // ─── Types ───────────────────────────────────────────────────────────────
@@ -390,6 +392,66 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
390
392
  },
391
393
  });
392
394
 
395
+ // ─── Execution sandbox — the guaranteed oracle ──────────────────
396
+ // Resolved once per cwd and cached (resolveSandbox probes the Docker daemon).
397
+ // This is what turns "the agent says it ran the test" into "a real container
398
+ // ran the test and here is its exit code".
399
+ let sessionSandbox: { cwd: string; sandbox: Sandbox } | null = null;
400
+ function getSessionSandbox(cwd: string): Sandbox {
401
+ if (!sessionSandbox || sessionSandbox.cwd !== cwd) {
402
+ sessionSandbox = { cwd, sandbox: resolveSandbox({ cwd }) };
403
+ }
404
+ return sessionSandbox.sandbox;
405
+ }
406
+
407
+ pi.registerTool({
408
+ name: "sandbox_run",
409
+ label: "Sandbox Run",
410
+ description:
411
+ "Run a command in the project's guaranteed environment (a Docker container when available) and get its REAL exit code. Use this for the reproduction, the test suite, and acceptance checks in /debug and /build — the verdict MUST come from this tool's result, not from your own reasoning. If it reports SANDBOX UNAVAILABLE, do not fabricate a result — emit BLOCKED.",
412
+ promptGuidelines: [
413
+ "In /debug and /build, run the reproduction, the existing suite, and acceptance/red-team checks with sandbox_run — never claim a pass you did not get back from it.",
414
+ "A command PASSES iff sandbox_run returns exit 0. Treat any non-zero exit, TIMEOUT, or UNAVAILABLE as not-passing.",
415
+ "The sandbox mounts the project, so edits you make are visible to the next sandbox_run (fix, then re-run to verify).",
416
+ ],
417
+ parameters: Type.Object({
418
+ command: Type.String({
419
+ description: "The shell command to run in the project sandbox (e.g. 'pytest tests/x.py::test_y').",
420
+ }),
421
+ timeoutSeconds: Type.Optional(
422
+ Type.Number({ description: "Max seconds before the run is killed (default 300)." }),
423
+ ),
424
+ }),
425
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
426
+ const p = params as { command: string; timeoutSeconds?: number };
427
+ const cwd = ctx?.cwd || process.cwd();
428
+ const sandbox = getSessionSandbox(cwd);
429
+ const result = sandbox.exec(p.command, {
430
+ timeoutMs: Math.max(1, Math.min(1800, p.timeoutSeconds ?? 300)) * 1000,
431
+ });
432
+ const verdict = !sandbox.available()
433
+ ? "UNAVAILABLE"
434
+ : result.timedOut
435
+ ? "TIMEOUT"
436
+ : passed(result)
437
+ ? "PASS"
438
+ : "FAIL";
439
+ const text =
440
+ `[sandbox: ${sandbox.describe()}]\n$ ${p.command}\n→ exit ${result.exitCode ?? "?"} ${verdict}\n\n` +
441
+ `${tail(result, 60) || "(no output)"}`;
442
+ return {
443
+ content: [{ type: "text", text }],
444
+ details: {
445
+ backend: sandbox.backend,
446
+ exitCode: result.exitCode,
447
+ passed: passed(result),
448
+ timedOut: result.timedOut,
449
+ verdict,
450
+ },
451
+ };
452
+ },
453
+ });
454
+
393
455
  // A phase's report file is not always named <key>-<ts>.md: PLAN writes its
394
456
  // handoff into todo-<ts>.md and CODE into progress-<ts>.md. Map key -> file
395
457
  // stem so the text fallback for HANDOFF/BLOCKING actually finds them.
@@ -940,6 +1002,7 @@ Tag the note with relevant keywords for vector search.
940
1002
  "ontology_add",
941
1003
  "ontology_query",
942
1004
  "phase_result",
1005
+ "sandbox_run",
943
1006
  ];
944
1007
  const agentTools = [...agentDef.tools, ...forcedTools.filter((t) => !agentDef.tools.includes(t))];
945
1008
  _activeAgentTools = agentTools;
@@ -1867,4 +1930,69 @@ It reports SUCCESS only when a real run meets the acceptance criteria — otherw
1867
1930
  );
1868
1931
  },
1869
1932
  });
1933
+
1934
+ // ─── /sandbox Command — inspect / prepare the guaranteed environment ──
1935
+
1936
+ pi.registerCommand("sandbox", {
1937
+ description:
1938
+ "Inspect or prepare the project's execution sandbox (Docker when available) — status | prepare | run <cmd>",
1939
+ handler: async (args, ctx) => {
1940
+ const cwd = ctx.cwd || process.cwd();
1941
+ const raw = args.trim();
1942
+ const [sub, ...rest] = raw.split(/\s+/);
1943
+ const sandbox = getSessionSandbox(cwd);
1944
+
1945
+ if (!sub || sub === "status") {
1946
+ const r = sandbox.recipe;
1947
+ ctx.ui.notify(
1948
+ `🧪 **Sandbox status**\n` +
1949
+ ` Backend: \`${sandbox.backend}\` — ${sandbox.reason}\n` +
1950
+ ` Environment: ${sandbox.describe()}\n` +
1951
+ ` Image: \`${r.image}\` (source: ${r.source})\n` +
1952
+ ` Setup: ${r.setup ? `\`${r.setup}\`` : "—"}\n` +
1953
+ ` Test: ${r.test ? `\`${r.test}\`` : "—"}\n` +
1954
+ (sandbox.backend === "unavailable"
1955
+ ? `\n⚠️ No guaranteed environment. /debug and /build will emit BLOCKED rather than fabricate a pass. Install/start Docker, or add \`.phi/sandbox.json\`.`
1956
+ : sandbox.backend === "local"
1957
+ ? `\n⚠️ Running on the host (not dependency-guaranteed). Start Docker for a guaranteed environment.`
1958
+ : `\n✅ Guaranteed environment ready. Run \`/sandbox prepare\` to pull the image and install deps.`),
1959
+ "info",
1960
+ );
1961
+ return;
1962
+ }
1963
+
1964
+ if (sub === "prepare") {
1965
+ ctx.ui.notify(`🧪 Preparing sandbox (${sandbox.describe()})… this can take a while on first run.`, "info");
1966
+ const p = sandbox.prepare();
1967
+ ctx.ui.notify(
1968
+ `${p.ok ? "✅" : "❌"} **Sandbox prepare** — ${p.detail}` +
1969
+ (p.result && !p.ok ? `\n\n\`\`\`\n${tail(p.result, 30)}\n\`\`\`` : ""),
1970
+ p.ok ? "info" : "warning",
1971
+ );
1972
+ return;
1973
+ }
1974
+
1975
+ if (sub === "run") {
1976
+ const command = rest.join(" ").trim();
1977
+ if (!command) {
1978
+ ctx.ui.notify("**Usage:** `/sandbox run <command>`", "warning");
1979
+ return;
1980
+ }
1981
+ if (!sandbox.available()) {
1982
+ ctx.ui.notify(`❌ Sandbox unavailable — ${sandbox.reason}. Cannot run.`, "warning");
1983
+ return;
1984
+ }
1985
+ ctx.ui.notify(`🧪 \`${command}\` in ${sandbox.describe()}…`, "info");
1986
+ const result = sandbox.exec(command);
1987
+ const verdict = result.timedOut ? "TIMEOUT" : passed(result) ? "PASS" : "FAIL";
1988
+ ctx.ui.notify(
1989
+ `${passed(result) ? "✅" : "❌"} exit ${result.exitCode ?? "?"} ${verdict}\n\n\`\`\`\n${tail(result, 40) || "(no output)"}\n\`\`\``,
1990
+ passed(result) ? "info" : "warning",
1991
+ );
1992
+ return;
1993
+ }
1994
+
1995
+ ctx.ui.notify("**Usage:** `/sandbox [status | prepare | run <command>]`", "info");
1996
+ },
1997
+ });
1870
1998
  }
@@ -33,7 +33,8 @@ const DEBUG_RULES = `
33
33
  ---
34
34
  ## /debug operating rules (non-negotiable)
35
35
  - **Execution is the only oracle.** No verdict without a real run whose output you paste. Never write FIXED because the code "looks right".
36
- - **No fabricated PASS.** If you cannot run the reproduction (missing env/deps), emit \`BLOCKED: no executable environment\` do NOT reconstruct a mock and grade your own reconstruction.
36
+ - **Use the \`sandbox_run\` tool for every oracle run** (reproduction, suite, acceptance). It runs in the project's guaranteed environment and returns the REAL exit code — a PASS means \`sandbox_run\` returned exit 0, nothing less.
37
+ - **No fabricated PASS.** If \`sandbox_run\` reports \`SANDBOX UNAVAILABLE\` (or you otherwise cannot run the reproduction), emit \`BLOCKED: no executable environment\` — do NOT reconstruct a mock and grade your own reconstruction.
37
38
  - **Minimal fix wins.** Prefer the smallest change; every added guard/condition is a liability that can hide the bug (an over-clever guard is exactly how these fixes go wrong).
38
39
  - **Root cause, not workaround.** No skipped tests, no \`--no-verify\`, no mock that hides the failure.
39
40
  - The user does NOT answer during these phases. Act autonomously; do not end with a question.`;
@@ -51,12 +52,12 @@ export function debugPhaseInstructions(state: FailingState): DebugInstructions {
51
52
  ${failing}
52
53
 
53
54
  **Do exactly this:**
54
- 1. Run the reproduction on the CURRENT, unmodified code: \`${repro}\`.
55
+ 1. Run the reproduction on the CURRENT, unmodified code with the \`sandbox_run\` tool: \`sandbox_run ${repro}\`.
55
56
  2. Paste the exact command and its full output.
56
- 3. Decide:
57
+ 3. Decide from what \`sandbox_run\` returned:
57
58
  - If it FAILS as reported → capture the precise symptom (assertion, exception, exit code) and hand off to LOCALIZE.
58
59
  - If it PASSES → STOP. Write \`BLOCKED: cannot reproduce — passes on current code\` with the run pasted. Do not invent a bug.
59
- - If it cannot be run at all (missing deps/env) → STOP. Write \`BLOCKED: no executable environment\` with what failed.
60
+ - If \`sandbox_run\` reports \`SANDBOX UNAVAILABLE\` → STOP. Write \`BLOCKED: no executable environment\`.
60
61
  4. Do NOT edit any source yet. This phase only observes.
61
62
  5. **Last action:** call \`phase_result\` — \`verdict: PASS\` if it reproduced (proceed to LOCALIZE), or \`verdict: BLOCKED\` with the reason if you stopped.` +
62
63
  DEBUG_RULES,
@@ -90,9 +91,9 @@ ${failing}
90
91
  ${failing}
91
92
 
92
93
  **Do exactly this, pasting every command's output:**
93
- 1. Re-run the reproduction: \`${repro}\`. It MUST now pass.
94
- 2. Run the existing test suite (the project's test command). It MUST NOT regress.
95
- 3. Verdict:
94
+ 1. Re-run the reproduction with \`sandbox_run ${repro}\`. It MUST now return exit 0.
95
+ 2. Run the existing test suite with \`sandbox_run <test command>\`. It MUST NOT regress.
96
+ 3. Verdict (from what \`sandbox_run\` returned, not from inspection):
96
97
  - Both green → \`FIXED\`, and paste the before(fail)/after(pass) reproduction runs and the green suite as evidence.
97
98
  - Reproduction still fails, or the suite regresses → \`BLOCKED\` with the closest diagnostic. Do NOT ship the least-bad patch; a wrong fix is worse than an honest BLOCKED.
98
99
  4. Write the final verdict block, then call \`phase_result\` with \`verdict: PASS\` (FIXED) or \`verdict: BLOCKED\`, plus a one-line handoff:
@@ -113,10 +114,12 @@ export function buildVerifyInstruction(spec: string): string {
113
114
 
114
115
  **Original spec:** ${spec}
115
116
 
117
+ **Every run below goes through the \`sandbox_run\` tool** (the project's guaranteed environment). If it reports \`SANDBOX UNAVAILABLE\`, do not fabricate results — mark the affected criteria ❔ and say the environment was unavailable.
118
+
116
119
  **Do exactly this, pasting every command's output:**
117
- 1. **Run recipe.** From the brief's \`## Run Recipe\` (or package.json / Makefile / Dockerfile), build and start the app. Distinguish a real failure from a stale launch recipe.
118
- 2. **Acceptance.** For each acceptance criterion derived from the SPEC (not the code), run a concrete check that exits 0 iff it holds. Mark each ✅ (ran+passed), ❌ (ran+failed), or ❔ (could not be executed — NEVER count ❔ as passing).
119
- 3. **Executable red-team.** Attack the specific input regimes the change touched (empty, null, boundary, wrong-type, and buffered-vs-streaming / malformed / auth as relevant). Each attack must be a RUNNABLE test that goes RED if it breaks the code — an opinion is not a finding.
120
+ 1. **Run recipe.** From the brief's \`## Run Recipe\` (or package.json / Makefile / Dockerfile), build and start the app via \`sandbox_run\`. Distinguish a real failure from a stale launch recipe.
121
+ 2. **Acceptance.** For each acceptance criterion derived from the SPEC (not the code), \`sandbox_run\` a concrete check that exits 0 iff it holds. Mark each ✅ (sandbox_run returned 0), ❌ (non-zero), or ❔ (could not be executed — NEVER count ❔ as passing).
122
+ 3. **Executable red-team.** Attack the specific input regimes the change touched (empty, null, boundary, wrong-type, and buffered-vs-streaming / malformed / auth as relevant). Each attack must be a RUNNABLE test \`sandbox_run\` can execute and that goes RED if it breaks the code — an opinion is not a finding.
120
123
  4. **Route real failures.** For every ❌ criterion and every red-team break, treat it as a concrete failing state (failing test / repro command / expected) and fix it with the /debug protocol: REPRODUCE → LOCALIZE → minimal FIX → VERIFY (re-run the reproduction AND the suite).
121
124
  5. **Honest verdict.** When all checkable criteria pass and the red-team finds no break, write \`BUILD: SUCCESS\`. If rounds/budget run out with failures open, write \`BUILD: PARTIAL\` and LIST exactly which criteria still fail — never a confident-wrong SUCCESS. Then call \`phase_result\` with \`verdict: PASS\` (SUCCESS) or \`verdict: FAIL\`/\`BLOCKED\` and the handoff.
122
125
  \`\`\`
@@ -10,6 +10,8 @@
10
10
 
11
11
  import { spawnSync } from "node:child_process";
12
12
 
13
+ const DEFAULT_MAX_BUFFER = 32 * 1024 * 1024;
14
+
13
15
  export interface CommandResult {
14
16
  command: string;
15
17
  exitCode: number | null;
@@ -58,7 +60,7 @@ export function runCommand(command: string, options: RunOptions = {}): CommandRe
58
60
  env: options.env ?? process.env,
59
61
  shell: true,
60
62
  encoding: "utf-8",
61
- maxBuffer: 32 * 1024 * 1024,
63
+ maxBuffer: DEFAULT_MAX_BUFFER,
62
64
  });
63
65
  const durationMs = Date.now() - start;
64
66
  // spawnSync sets error with code "ETIMEDOUT" on timeout, and signal SIGTERM.
@@ -73,3 +75,33 @@ export function runCommand(command: string, options: RunOptions = {}): CommandRe
73
75
  timedOut,
74
76
  };
75
77
  }
78
+
79
+ /**
80
+ * Run a program by ARGV with NO shell. This is what the Docker sandbox uses:
81
+ * passing `docker` + its arguments directly avoids shell quoting and — on
82
+ * Windows Git Bash — the MSYS path mangling that corrupts `-v C:\x:/work` and
83
+ * `//var/run/docker.sock`. `label` is the human-readable command echoed back in
84
+ * the result (the argv itself is not a shell string). Never throws.
85
+ */
86
+ export function runArgv(file: string, args: string[], options: RunOptions & { label?: string } = {}): CommandResult {
87
+ const start = Date.now();
88
+ const res = spawnSync(file, args, {
89
+ cwd: options.cwd,
90
+ timeout: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
91
+ env: options.env ?? process.env,
92
+ shell: false,
93
+ encoding: "utf-8",
94
+ maxBuffer: DEFAULT_MAX_BUFFER,
95
+ });
96
+ const durationMs = Date.now() - start;
97
+ const timedOut = res.error !== undefined && (res.error as NodeJS.ErrnoException).code === "ETIMEDOUT";
98
+ const spawnFailed = res.error !== undefined && !timedOut;
99
+ return {
100
+ command: options.label ?? `${file} ${args.join(" ")}`.trim(),
101
+ exitCode: spawnFailed ? null : (res.status ?? null),
102
+ stdout: res.stdout ?? "",
103
+ stderr: spawnFailed ? `${(res.error as Error).message}\n${res.stderr ?? ""}` : (res.stderr ?? ""),
104
+ durationMs,
105
+ timedOut,
106
+ };
107
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Provider bootstrap — first-time, non-interactive provider configuration.
3
+ *
4
+ * `/keys set <id> <key>` used to refuse a provider that had no models.json
5
+ * entry yet ("run /setup first"), which made adding a known provider (e.g. the
6
+ * Alibaba Coding Plan) impossible outside the interactive wizard. This module
7
+ * closes that gap: for providers phi ships a config builder for, it produces a
8
+ * complete models.json entry (baseUrl + api + model list) from a single API
9
+ * key, with the provider's own key-format validation applied first.
10
+ *
11
+ * Pure (no fs, no network) so every branch is unit-tested; keys.ts is the only
12
+ * call site.
13
+ */
14
+
15
+ import { ALIBABA_PROVIDERS, buildAlibabaProviderConfig, validateAlibabaApiKey } from "./alibaba.js";
16
+ import {
17
+ buildOpenCodeGoAnthropicProviderConfig,
18
+ buildOpenCodeGoProviderConfig,
19
+ OPENCODE_GO_FALLBACK_MODELS,
20
+ type OpenCodeGoModel,
21
+ validateOpenCodeGoApiKey,
22
+ } from "./opencode-go.js";
23
+
24
+ export interface BootstrapModel {
25
+ id: string;
26
+ name: string;
27
+ reasoning: boolean;
28
+ input: readonly ("text" | "image")[];
29
+ contextWindow: number;
30
+ maxTokens: number;
31
+ compat?: Record<string, unknown>;
32
+ }
33
+
34
+ export interface BootstrapConfig {
35
+ baseUrl: string;
36
+ api: string;
37
+ apiKey: string;
38
+ models: BootstrapModel[];
39
+ }
40
+
41
+ export type BootstrapResult = { ok: true; config: BootstrapConfig; note: string } | { ok: false; error: string };
42
+
43
+ /** Provider ids `/keys set` can fully configure from just an API key. */
44
+ export const BOOTSTRAPPABLE_PROVIDERS = [
45
+ "alibaba-codingplan",
46
+ "alibaba-codingplan-anthropic",
47
+ "opencode-go",
48
+ "opencode-go-anthropic",
49
+ ] as const;
50
+
51
+ export function isBootstrappableProvider(id: string): boolean {
52
+ return (BOOTSTRAPPABLE_PROVIDERS as readonly string[]).includes(id);
53
+ }
54
+
55
+ /**
56
+ * Build a complete first-time provider config from an API key. Validates the
57
+ * key FORMAT (prefix/length) per provider; liveness is `/keys test`'s job.
58
+ * The bundled model list seeds the entry — the session-start refresh and
59
+ * `/models refresh` replace it with the live catalog afterwards.
60
+ */
61
+ export function bootstrapProviderConfig(id: string, apiKey: string): BootstrapResult {
62
+ const key = apiKey.trim();
63
+
64
+ switch (id) {
65
+ case "alibaba-codingplan":
66
+ case "alibaba-codingplan-anthropic": {
67
+ const invalid = validateAlibabaApiKey(key);
68
+ if (invalid) return { ok: false, error: invalid };
69
+ const variant = id === "alibaba-codingplan-anthropic" ? "anthropic" : "openai";
70
+ const config: BootstrapConfig = buildAlibabaProviderConfig(variant, key);
71
+ return {
72
+ ok: true,
73
+ config,
74
+ note: `${ALIBABA_PROVIDERS[variant].displayName} — ${config.models.length} bundled model(s); a live refresh follows on next session start (or run /models refresh ${id}).`,
75
+ };
76
+ }
77
+ case "opencode-go":
78
+ case "opencode-go-anthropic": {
79
+ const invalid = validateOpenCodeGoApiKey(key);
80
+ if (invalid) return { ok: false, error: invalid };
81
+ const models: OpenCodeGoModel[] = [...OPENCODE_GO_FALLBACK_MODELS];
82
+ const config: BootstrapConfig =
83
+ id === "opencode-go-anthropic"
84
+ ? buildOpenCodeGoAnthropicProviderConfig(key, models)
85
+ : buildOpenCodeGoProviderConfig(key, models);
86
+ return {
87
+ ok: true,
88
+ config,
89
+ note: `OpenCode Go — ${config.models.length} bundled model(s); a live refresh follows on next session start (or run /models refresh ${id}).`,
90
+ };
91
+ }
92
+ default:
93
+ return {
94
+ ok: false,
95
+ error: `\`${id}\` has no bootstrap builder. Run \`/setup\` to configure it interactively, or add it to ~/.phi/agent/models.json. Bootstrappable ids: ${BOOTSTRAPPABLE_PROVIDERS.join(", ")}.`,
96
+ };
97
+ }
98
+ }
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Sandbox planning — the pure core that turns "what project is this?" into "how
3
+ * do I run its code in a guaranteed environment?" (docs/design/plan-debug-build.md,
4
+ * §Execution grounding).
5
+ *
6
+ * The SWE-bench measurement was defeated because the host Python was too new to
7
+ * run the target library, so the model reconstructed a mock and graded itself.
8
+ * A per-project container fixes that: run the real reproduction/suite with the
9
+ * project's real toolchain. Everything here is pure (no fs, no spawn) so the
10
+ * toolchain detection, the recipe, the backend decision, and — critically — the
11
+ * `docker run` argv (where the Windows path/quoting bugs live) are all unit-tested.
12
+ */
13
+
14
+ export type ToolchainKind = "node" | "python" | "go" | "rust" | "ruby" | "unknown";
15
+
16
+ export interface Toolchain {
17
+ kind: ToolchainKind;
18
+ /** The marker file that determined it (e.g. "package.json"). */
19
+ marker: string | null;
20
+ hasDockerfile: boolean;
21
+ hasCompose: boolean;
22
+ }
23
+
24
+ /** `.phi/sandbox.json` — explicit project override, always wins over detection. */
25
+ export interface SandboxConfig {
26
+ backend?: "docker" | "local" | "auto";
27
+ image?: string;
28
+ setup?: string;
29
+ test?: string;
30
+ workdir?: string;
31
+ env?: Record<string, string>;
32
+ /** Allow network inside the container (default true — deps often need it). */
33
+ network?: boolean;
34
+ memory?: string;
35
+ cpus?: string;
36
+ }
37
+
38
+ export type RecipeSource = "detected" | "dockerfile" | "config";
39
+
40
+ export interface SandboxRecipe {
41
+ image: string;
42
+ /** One-off dependency install, run against the mounted project. */
43
+ setup?: string;
44
+ /** Best-guess suite command (a floor; the caller may override). */
45
+ test?: string;
46
+ workdir: string;
47
+ env: Record<string, string>;
48
+ network: boolean;
49
+ memory?: string;
50
+ cpus?: string;
51
+ source: RecipeSource;
52
+ }
53
+
54
+ const MOUNT_WORKDIR = "/work";
55
+
56
+ const BASE_IMAGES: Record<Exclude<ToolchainKind, "unknown">, string> = {
57
+ node: "node:20-slim",
58
+ python: "python:3.12-slim",
59
+ go: "golang:1.22-bookworm",
60
+ rust: "rust:1-slim",
61
+ ruby: "ruby:3-slim",
62
+ };
63
+
64
+ const DEFAULT_ENV: Record<ToolchainKind, Record<string, string>> = {
65
+ node: { CI: "1" },
66
+ // PYTHONSAFEPATH stops a stray local module from shadowing the stdlib (the
67
+ // `select.py`/`resource` class of failures seen in the SWE-bench harness).
68
+ python: { PYTHONSAFEPATH: "1", PYTHONDONTWRITEBYTECODE: "1" },
69
+ go: {},
70
+ rust: {},
71
+ ruby: {},
72
+ unknown: {},
73
+ };
74
+
75
+ /** Detect the toolchain from a project's top-level file names. Order = priority. */
76
+ export function detectToolchain(files: string[]): Toolchain {
77
+ const set = new Set(files.map((f) => f.trim()).filter(Boolean));
78
+ const has = (name: string) => set.has(name);
79
+ const hasDockerfile = has("Dockerfile");
80
+ const hasCompose = has("docker-compose.yml") || has("compose.yml") || has("docker-compose.yaml");
81
+
82
+ let kind: ToolchainKind = "unknown";
83
+ let marker: string | null = null;
84
+ if (has("package.json")) {
85
+ kind = "node";
86
+ marker = "package.json";
87
+ } else if (has("pyproject.toml") || has("requirements.txt") || has("setup.py")) {
88
+ kind = "python";
89
+ marker = has("pyproject.toml") ? "pyproject.toml" : has("requirements.txt") ? "requirements.txt" : "setup.py";
90
+ } else if (has("go.mod")) {
91
+ kind = "go";
92
+ marker = "go.mod";
93
+ } else if (has("Cargo.toml")) {
94
+ kind = "rust";
95
+ marker = "Cargo.toml";
96
+ } else if (has("Gemfile")) {
97
+ kind = "ruby";
98
+ marker = "Gemfile";
99
+ }
100
+ return { kind, marker, hasDockerfile, hasCompose };
101
+ }
102
+
103
+ function detectedSetup(tc: Toolchain, files: Set<string>): string | undefined {
104
+ switch (tc.kind) {
105
+ case "node":
106
+ return files.has("package-lock.json") ? "npm ci || npm install" : "npm install";
107
+ case "python":
108
+ if (files.has("pyproject.toml") || files.has("setup.py")) return "pip install -e . || pip install .";
109
+ if (files.has("requirements.txt")) return "pip install -r requirements.txt";
110
+ return undefined;
111
+ case "go":
112
+ return "go mod download";
113
+ case "rust":
114
+ return "cargo fetch";
115
+ case "ruby":
116
+ return "bundle install";
117
+ default:
118
+ return undefined;
119
+ }
120
+ }
121
+
122
+ function detectedTest(tc: Toolchain): string | undefined {
123
+ switch (tc.kind) {
124
+ case "node":
125
+ return "npm test";
126
+ case "python":
127
+ return "pytest";
128
+ case "go":
129
+ return "go test ./...";
130
+ case "rust":
131
+ return "cargo test";
132
+ case "ruby":
133
+ return "bundle exec rake test";
134
+ default:
135
+ return undefined;
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Build the environment recipe from detected toolchain + files. A project
141
+ * Dockerfile is honored (source "dockerfile"); otherwise a base image is chosen.
142
+ */
143
+ export function defaultRecipe(tc: Toolchain, files: string[] = []): SandboxRecipe {
144
+ const set = new Set(files);
145
+ const base: SandboxRecipe = {
146
+ image: tc.kind === "unknown" ? "debian:bookworm-slim" : BASE_IMAGES[tc.kind],
147
+ setup: detectedSetup(tc, set),
148
+ test: detectedTest(tc),
149
+ workdir: MOUNT_WORKDIR,
150
+ env: { ...DEFAULT_ENV[tc.kind] },
151
+ network: true,
152
+ source: tc.hasDockerfile ? "dockerfile" : "detected",
153
+ };
154
+ return base;
155
+ }
156
+
157
+ /** Merge an explicit `.phi/sandbox.json` over a detected recipe (config wins). */
158
+ export function applyConfig(recipe: SandboxRecipe, config?: SandboxConfig): SandboxRecipe {
159
+ if (!config) return recipe;
160
+ return {
161
+ image: config.image ?? recipe.image,
162
+ setup: config.setup ?? recipe.setup,
163
+ test: config.test ?? recipe.test,
164
+ workdir: config.workdir ?? recipe.workdir,
165
+ env: { ...recipe.env, ...(config.env ?? {}) },
166
+ network: config.network ?? recipe.network,
167
+ memory: config.memory ?? recipe.memory,
168
+ cpus: config.cpus ?? recipe.cpus,
169
+ source: config.image || config.setup ? "config" : recipe.source,
170
+ };
171
+ }
172
+
173
+ export type Backend = "docker" | "local" | "unavailable";
174
+
175
+ export interface BackendSignals {
176
+ dockerAvailable: boolean;
177
+ requested?: "docker" | "local" | "auto";
178
+ toolchainKnown: boolean;
179
+ hasDockerfile: boolean;
180
+ }
181
+
182
+ export interface BackendDecision {
183
+ backend: Backend;
184
+ reason: string;
185
+ }
186
+
187
+ /**
188
+ * Choose the execution backend, honestly. Docker is the guaranteed oracle but
189
+ * only helps when we can containerize (a known toolchain or a Dockerfile). When
190
+ * the caller explicitly demanded docker and it is absent, the answer is
191
+ * `unavailable` — NOT a silent downgrade to a non-guaranteed local run.
192
+ */
193
+ export function decideBackend(s: BackendSignals): BackendDecision {
194
+ const req = s.requested ?? "auto";
195
+ if (req === "local") return { backend: "local", reason: "backend: local (requested)" };
196
+ if (req === "docker") {
197
+ return s.dockerAvailable
198
+ ? { backend: "docker", reason: "backend: docker (requested, available)" }
199
+ : { backend: "unavailable", reason: "docker requested but the daemon is not available" };
200
+ }
201
+ // auto
202
+ if (s.dockerAvailable && (s.toolchainKnown || s.hasDockerfile)) {
203
+ return { backend: "docker", reason: "backend: docker (guaranteed environment)" };
204
+ }
205
+ if (!s.dockerAvailable)
206
+ return {
207
+ backend: "local",
208
+ reason: "backend: local (docker unavailable — runs are real but not dependency-guaranteed)",
209
+ };
210
+ return { backend: "local", reason: "backend: local (unknown toolchain, no Dockerfile — nothing to containerize)" };
211
+ }
212
+
213
+ /**
214
+ * Normalize a host path into a Docker bind source. On Windows, forward-slash the
215
+ * path but keep the drive colon (`C:/Users/…`) — the form Docker Desktop accepts
216
+ * and, crucially, one that survives argv passing without MSYS mangling.
217
+ */
218
+ export function toBindSource(hostPath: string, platform: NodeJS.Platform = process.platform): string {
219
+ if (platform === "win32") return hostPath.replace(/\\/g, "/");
220
+ return hostPath;
221
+ }
222
+
223
+ export interface DockerRunSpec {
224
+ image: string;
225
+ /** The command run inside the container via `sh -c`. */
226
+ command: string;
227
+ /** Host path to bind-mount as the workdir. */
228
+ mountSource: string;
229
+ workdir: string;
230
+ env: Record<string, string>;
231
+ network: boolean;
232
+ memory?: string;
233
+ cpus?: string;
234
+ platform?: NodeJS.Platform;
235
+ }
236
+
237
+ /**
238
+ * Build the argv passed to `docker` (after the program name). Pure and total, so
239
+ * the exact invocation — mount, workdir, env, network isolation, resource caps —
240
+ * is asserted in tests instead of discovered in production.
241
+ */
242
+ export function buildDockerRunArgs(spec: DockerRunSpec): string[] {
243
+ const bind = `${toBindSource(spec.mountSource, spec.platform)}:${spec.workdir}`;
244
+ const args: string[] = ["run", "--rm", "-v", bind, "-w", spec.workdir];
245
+ for (const [k, v] of Object.entries(spec.env)) {
246
+ args.push("-e", `${k}=${v}`);
247
+ }
248
+ if (!spec.network) args.push("--network", "none");
249
+ if (spec.memory) args.push("--memory", spec.memory);
250
+ if (spec.cpus) args.push("--cpus", spec.cpus);
251
+ args.push(spec.image, "sh", "-c", spec.command);
252
+ return args;
253
+ }
254
+
255
+ /** A small, dependency-free deterministic hash (djb2) for cache tags. */
256
+ function djb2(input: string): string {
257
+ let h = 5381;
258
+ for (let i = 0; i < input.length; i++) {
259
+ h = ((h << 5) + h + input.charCodeAt(i)) >>> 0;
260
+ }
261
+ return h.toString(16).padStart(8, "0");
262
+ }
263
+
264
+ /** Deterministic image tag for a built recipe (project Dockerfile / config). */
265
+ export function imageTagFor(recipe: SandboxRecipe): string {
266
+ return `phi-sandbox:${djb2(`${recipe.image}|${recipe.setup ?? ""}|${recipe.workdir}`)}`;
267
+ }
268
+
269
+ /** Generate a minimal Dockerfile for a detected recipe (utility / persistence). */
270
+ export function generatedDockerfile(recipe: SandboxRecipe): string {
271
+ const envLines = Object.entries(recipe.env).map(([k, v]) => `ENV ${k}=${v}`);
272
+ return [`FROM ${recipe.image}`, `WORKDIR ${recipe.workdir}`, ...envLines, ""].join("\n");
273
+ }
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Sandbox — the thin IO shell over the pure planner (sandbox-plan.ts). It turns
3
+ * a project directory into a runnable environment and executes commands in it,
4
+ * returning the same CommandResult the oracle cores already consume. Three
5
+ * backends: `docker` (the guaranteed environment), `local` (real runs, but not
6
+ * dependency-guaranteed), and `unavailable` (honest — every exec reports it
7
+ * could not run, so /debug and /build emit BLOCKED instead of a fabricated pass).
8
+ *
9
+ * The fs reads and spawns are injectable so the routing/interpretation is
10
+ * unit-tested without a Docker daemon; a separate docker-gated smoke test does
11
+ * one real container run.
12
+ */
13
+
14
+ import { readdirSync, readFileSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import {
17
+ type CommandResult,
18
+ passed,
19
+ type RunOptions,
20
+ runArgv as realRunArgv,
21
+ runCommand as realRunCommand,
22
+ } from "./execution.js";
23
+ import {
24
+ applyConfig,
25
+ type Backend,
26
+ buildDockerRunArgs,
27
+ decideBackend,
28
+ defaultRecipe,
29
+ detectToolchain,
30
+ imageTagFor,
31
+ type SandboxConfig,
32
+ type SandboxRecipe,
33
+ } from "./sandbox-plan.js";
34
+
35
+ export interface PrepareResult {
36
+ ok: boolean;
37
+ backend: Backend;
38
+ detail: string;
39
+ result?: CommandResult;
40
+ }
41
+
42
+ export interface Sandbox {
43
+ readonly backend: Backend;
44
+ readonly recipe: SandboxRecipe;
45
+ readonly reason: string;
46
+ describe(): string;
47
+ available(): boolean;
48
+ /** Run a command in the environment. Never throws (see execution.ts). */
49
+ exec(command: string, options?: RunOptions): CommandResult;
50
+ /** Build the image / install deps. Idempotent, best-effort. */
51
+ prepare(): PrepareResult;
52
+ }
53
+
54
+ type RunCommandFn = (command: string, options?: RunOptions) => CommandResult;
55
+ type RunArgvFn = (file: string, args: string[], options?: RunOptions & { label?: string }) => CommandResult;
56
+
57
+ /** Injectable seams — real fs/spawn by default, fakes in tests. */
58
+ export interface SandboxDeps {
59
+ runCommand?: RunCommandFn;
60
+ runArgv?: RunArgvFn;
61
+ listFiles?: (cwd: string) => string[];
62
+ readConfig?: (cwd: string) => SandboxConfig | undefined;
63
+ /** Force docker availability in tests; otherwise probed via `docker version`. */
64
+ dockerAvailable?: boolean;
65
+ }
66
+
67
+ export interface ResolveOptions {
68
+ cwd: string;
69
+ requested?: "docker" | "local" | "auto";
70
+ deps?: SandboxDeps;
71
+ }
72
+
73
+ /** List a project's top-level entries (best-effort; empty on error). */
74
+ export function listProjectFiles(cwd: string): string[] {
75
+ try {
76
+ return readdirSync(cwd);
77
+ } catch {
78
+ return [];
79
+ }
80
+ }
81
+
82
+ /** Read and parse `.phi/sandbox.json` if present. */
83
+ export function readSandboxConfig(cwd: string): SandboxConfig | undefined {
84
+ try {
85
+ const raw = readFileSync(join(cwd, ".phi", "sandbox.json"), "utf-8");
86
+ const parsed = JSON.parse(raw);
87
+ return parsed && typeof parsed === "object" ? (parsed as SandboxConfig) : undefined;
88
+ } catch {
89
+ return undefined;
90
+ }
91
+ }
92
+
93
+ /** Probe the Docker daemon (server version reachable). Cheap, capped timeout. */
94
+ export function probeDocker(ra: RunArgvFn): boolean {
95
+ const r = ra("docker", ["version", "--format", "{{.Server.Version}}"], { timeoutMs: 8000, label: "docker version" });
96
+ return passed(r) && r.stdout.trim().length > 0;
97
+ }
98
+
99
+ const UNAVAILABLE_MESSAGE = "SANDBOX UNAVAILABLE — no executable environment; do not fabricate a result, emit BLOCKED.";
100
+
101
+ function unavailableResult(command: string): CommandResult {
102
+ return { command, exitCode: null, stdout: "", stderr: UNAVAILABLE_MESSAGE, durationMs: 0, timedOut: false };
103
+ }
104
+
105
+ /**
106
+ * Resolve the sandbox for a project directory: detect toolchain, merge config,
107
+ * decide the backend honestly, and return an executor. Docker exec runs
108
+ * `docker run --rm -v <cwd>:/work …`; edits land on the host mount, so /debug's
109
+ * fix-then-rerun works and the container stays ephemeral.
110
+ */
111
+ export function resolveSandbox(opts: ResolveOptions): Sandbox {
112
+ const deps = opts.deps ?? {};
113
+ const rc = deps.runCommand ?? realRunCommand;
114
+ const ra = deps.runArgv ?? realRunArgv;
115
+ const files = (deps.listFiles ?? listProjectFiles)(opts.cwd);
116
+ const config = (deps.readConfig ?? readSandboxConfig)(opts.cwd);
117
+ const tc = detectToolchain(files);
118
+ const recipe = applyConfig(defaultRecipe(tc, files), config);
119
+ const requested = opts.requested ?? config?.backend ?? "auto";
120
+ const dockerAvailable = deps.dockerAvailable ?? probeDocker(ra);
121
+ const decision = decideBackend({
122
+ dockerAvailable,
123
+ requested,
124
+ toolchainKnown: tc.kind !== "unknown",
125
+ hasDockerfile: tc.hasDockerfile,
126
+ });
127
+
128
+ // Effective image can change after prepare() builds a project Dockerfile.
129
+ const state = { image: recipe.image };
130
+
131
+ const dockerExec = (command: string, options?: RunOptions): CommandResult => {
132
+ const args = buildDockerRunArgs({
133
+ image: state.image,
134
+ command,
135
+ mountSource: opts.cwd,
136
+ workdir: recipe.workdir,
137
+ env: recipe.env,
138
+ network: recipe.network,
139
+ memory: recipe.memory,
140
+ cpus: recipe.cpus,
141
+ });
142
+ return ra("docker", args, {
143
+ cwd: opts.cwd,
144
+ timeoutMs: options?.timeoutMs,
145
+ label: `docker[${state.image}] ${command}`,
146
+ });
147
+ };
148
+
149
+ const base = { backend: decision.backend, recipe, reason: decision.reason };
150
+
151
+ if (decision.backend === "unavailable") {
152
+ return {
153
+ ...base,
154
+ describe: () => `unavailable — ${decision.reason}`,
155
+ available: () => false,
156
+ exec: (command) => unavailableResult(command),
157
+ prepare: () => ({ ok: false, backend: "unavailable", detail: decision.reason }),
158
+ };
159
+ }
160
+
161
+ if (decision.backend === "local") {
162
+ return {
163
+ ...base,
164
+ describe: () => `local host (${opts.cwd})`,
165
+ available: () => true,
166
+ exec: (command, options) => rc(command, { cwd: opts.cwd, ...options }),
167
+ // Deliberately no host-side dependency install — running `npm install`
168
+ // etc. on the user's host is intrusive; local is best-effort as-is.
169
+ prepare: () => ({ ok: true, backend: "local", detail: "local host — no preparation performed" }),
170
+ };
171
+ }
172
+
173
+ // docker
174
+ return {
175
+ ...base,
176
+ describe: () => `docker (${state.image})`,
177
+ available: () => true,
178
+ exec: dockerExec,
179
+ prepare: () => {
180
+ if (recipe.source === "dockerfile") {
181
+ const tag = imageTagFor(recipe);
182
+ const built = ra("docker", ["build", "-t", tag, "-f", "Dockerfile", "."], {
183
+ cwd: opts.cwd,
184
+ label: `docker build ${tag}`,
185
+ });
186
+ if (passed(built)) state.image = tag;
187
+ return {
188
+ ok: passed(built),
189
+ backend: "docker",
190
+ detail: passed(built) ? `built image ${tag} from Dockerfile` : "docker build failed",
191
+ result: built,
192
+ };
193
+ }
194
+ // Base image: pull it, then run the dependency setup against the mount.
195
+ const pulled = ra("docker", ["pull", state.image], { cwd: opts.cwd, label: `docker pull ${state.image}` });
196
+ if (!recipe.setup) {
197
+ return {
198
+ ok: passed(pulled),
199
+ backend: "docker",
200
+ detail: `pulled ${state.image} (no setup step)`,
201
+ result: pulled,
202
+ };
203
+ }
204
+ const setup = dockerExec(recipe.setup);
205
+ return {
206
+ ok: passed(setup),
207
+ backend: "docker",
208
+ detail: passed(setup)
209
+ ? `pulled ${state.image}; ran setup \`${recipe.setup}\``
210
+ : `setup failed: \`${recipe.setup}\``,
211
+ result: setup,
212
+ };
213
+ },
214
+ };
215
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phi-code-admin/phi-code",
3
- "version": "0.89.0",
3
+ "version": "0.91.0",
4
4
  "description": "Coding agent CLI with persistent memory, sub-agents, intelligent routing, and orchestration",
5
5
  "type": "module",
6
6
  "piConfig": {