@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 +71 -0
- package/dist/core/provider-display-names.d.ts.map +1 -1
- package/dist/core/provider-display-names.js +3 -0
- package/dist/core/provider-display-names.js.map +1 -1
- package/docs/design/plan-debug-build.md +17 -0
- package/extensions/phi/keys.ts +36 -6
- package/extensions/phi/orchestrator.ts +128 -0
- package/extensions/phi/providers/debug-build-commands.ts +13 -10
- package/extensions/phi/providers/execution.ts +33 -1
- package/extensions/phi/providers/provider-bootstrap.ts +98 -0
- package/extensions/phi/providers/sandbox-plan.ts +273 -0
- package/extensions/phi/providers/sandbox.ts +215 -0
- package/package.json +1 -1
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,
|
|
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,
|
package/extensions/phi/keys.ts
CHANGED
|
@@ -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
|
|
79
|
-
//
|
|
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
|
-
|
|
84
|
-
`
|
|
85
|
-
|
|
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
|
-
- **
|
|
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:
|
|
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
|
|
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
|
|
94
|
-
2. Run the existing test suite
|
|
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
|
|
118
|
-
2. **Acceptance.** For each acceptance criterion derived from the SPEC (not the code),
|
|
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:
|
|
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
|
+
}
|