From 5721c0ff240193066c40624056a2d5e07fa34b7f Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Thu, 10 Sep 2026 04:23:41 -0400 Subject: [PATCH] feat(service-runner): unarmed engine spawn mode for the hub cutover (#955) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AMICODE_ENGINE_UNARMED=1 (CLI env) / engineUnarmed (opts): the runner spawns the vendored engine WITHOUT OPENCODE_SERVER_PASSWORD — the fork hub's anonymous boundary posture (2026-08-07 incident: 'canonical serves anonymous 200'; the SSH mesh is the boundary). The app-proxy forwards client requests verbatim, so an armed engine would 401 the fleet panel's anonymous calls. - child env omits the key entirely (never empty); health probe goes anonymous in the posture; the service gets enginePassword: undefined (its authMode still resolves independently — AMICODE_SERVICE_AUTH=open alongside is the hub posture pair); boot line names the posture - unarmed wins over an explicit enginePassword (never silently half-armed) - armed default byte-identical for every existing caller - tests: headless fake-engine env-dump asserts the child env (armed + unarmed + precedence), live-gated unarmed boot with an anonymous proxied GET /session -> 200 (the #958 live convention) --- .../extension/src/amicode_service_runner.ts | 59 +++++-- .../src/amicode_service_runner_cli.ts | 6 + .../test/amicode_service_runner.test.ts | 157 +++++++++++++++++- 3 files changed, 208 insertions(+), 14 deletions(-) diff --git a/packages/extension/src/amicode_service_runner.ts b/packages/extension/src/amicode_service_runner.ts index 6fb798165..f2e7b2a31 100644 --- a/packages/extension/src/amicode_service_runner.ts +++ b/packages/extension/src/amicode_service_runner.ts @@ -1,8 +1,9 @@ // ============================================================================ // amicode_service_runner — the PRODUCTION SERVICE RUNNER (#955, the hub // cutover spec spec-20260910-080000): ONE long-lived headless process that -// spawns the vendored opencode engine (password-armed) and serves the amicode -// service alongside it — the permanent orchestration the #822 boot probe only +// spawns the vendored opencode engine (password-armed by default; UNARMED in +// the hub posture, see engineUnarmed) and serves the amicode service alongside +// it — the permanent orchestration the #822 boot probe only // rehearses and the extension host only provides inside VS Code. The hub has // no extension host; this is what runs there instead. // @@ -85,6 +86,18 @@ export interface AmicodeServiceRunnerOptions { * carrier and any co-launched consumers share the engine's auth — the * fork's own respawn convention (a session's spawns REUSE the value). */ enginePassword?: string; + /** #955 (the hub cutover's unarmed posture): spawn the engine WITHOUT + * OPENCODE_SERVER_PASSWORD — the anonymous engine the fork hub deploys + * (the 2026-08-07 incident record: "canonical serves anonymous 200"; the + * SSH mesh is the security boundary). The service's app-proxy forwards + * client requests verbatim (no header rewriting), so an armed engine + * would 401 the fleet panel's anonymous calls. Expected ALONGSIDE + * AMICODE_SERVICE_AUTH=open — the service's authMode resolves + * independently (its own env passthrough in createAmicodeService); the + * pair is the hub posture. Wins over enginePassword when both are given. + * Default: armed (the per-boot mint convention) — zero change for every + * existing caller. */ + engineUnarmed?: boolean; /** Engine health-wait budget. Default 30_000 (the ServerManager budget). */ healthTimeoutMs?: number; /** Log sink (the structural-interface convention — vscode-free). */ @@ -96,9 +109,10 @@ export interface AmicodeServiceRunnerBoot { url: string; /** The service's per-boot Basic header (accepts the engine token too). */ authHeader: string; - /** The engine credential the runner minted for this boot (needed by - * harnesses that assert the proxy surfaces with the engine token). */ - enginePassword: string; + /** The engine credential the runner armed the spawn with — undefined in + * the unarmed posture (#955). Needed by harnesses that assert the proxy + * surfaces with the engine token. */ + enginePassword: string | undefined; /** The engine's origin (the proxy's upstream). */ engineUrl: string; /** The spawned engine child (exposed for tests + exit supervision). */ @@ -130,12 +144,17 @@ function freePort(): Promise { } /** The ServerManager health-probe idiom: WITH the armed credential, else a - * healthy password-armed boot 401s an anonymous GET and reads as a timeout. */ -async function waitForHealth(baseUrl: string, timeoutMs: number, authorization: string): Promise { + * healthy password-armed boot 401s an anonymous GET and reads as a timeout. + * `authorization` undefined = the unarmed posture's anonymous probe (#955): + * no credential exists, and the unarmed engine answers anonymous GETs. */ +async function waitForHealth(baseUrl: string, timeoutMs: number, authorization: string | undefined): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { try { - const r = await fetch(`${baseUrl}/`, { headers: { Authorization: authorization }, signal: AbortSignal.timeout(500) }); + const r = await fetch(`${baseUrl}/`, { + headers: authorization === undefined ? {} : { Authorization: authorization }, + signal: AbortSignal.timeout(500), + }); if (r.status < 500) return true; } catch { /* not up yet */ @@ -161,8 +180,13 @@ export async function bootAmicodeServiceRunner(opts: AmicodeServiceRunnerOptions throw new AmicodeServiceRunnerError(`no built app dist at ${opts.appDistRoot} (missing index.html) — run \`pnpm --filter amicode run build:app\` or set AMICODE_APP_DIST`); // ── the engine: spawn + health wait (the ServerManager/probe idiom) ────── - const password = opts.enginePassword ?? mintServerPassword(); - const authHeader = serverAuthHeader(password); + // #955: engineUnarmed is the hub's anonymous boundary posture — no + // OPENCODE_SERVER_PASSWORD in the child env, no credential minted, the + // service gets enginePassword: undefined (its authMode still resolves + // independently via AMICODE_SERVICE_AUTH). The armed default is untouched. + const unarmed = opts.engineUnarmed === true; + const password = unarmed ? undefined : (opts.enginePassword ?? mintServerPassword()); + const authHeader = password === undefined ? undefined : serverAuthHeader(password); const enginePort = opts.enginePort ?? 4094; const port = enginePort > 0 ? enginePort : await freePort(); const engineUrl = `http://127.0.0.1:${port}`; @@ -176,10 +200,19 @@ export async function bootAmicodeServiceRunner(opts: AmicodeServiceRunnerOptions ); const dbPin = opts.engineEnv?.OPENCODE_DB; - log(`[service-runner] spawning engine ${opts.engineBin} serve --port=${port} (cwd=${cwd}${dbPin ? `, OPENCODE_DB=${dbPin}` : ", OPENCODE_DB=(host env)"})`); + log( + `[service-runner] spawning engine ${opts.engineBin} serve --port=${port} (cwd=${cwd}${dbPin ? `, OPENCODE_DB=${dbPin}` : ", OPENCODE_DB=(host env)"})${unarmed ? " UNARMED (the hub's anonymous boundary posture)" : ""}`, + ); const engine: ChildProcess = spawn(opts.engineBin, ["serve", "--port", String(port)], { cwd, - env: { ...process.env, OPENCODE_SERVER_PASSWORD: password, ...opts.engineEnv }, + env: { + ...process.env, + // Unarmed = the key is ABSENT, never empty — the fork's route auth only + // engages when the var is set, so an empty value would be a dishonest + // half-posture. + ...(unarmed ? {} : { OPENCODE_SERVER_PASSWORD: password }), + ...opts.engineEnv, + }, stdio: ["ignore", "pipe", "pipe"], }); let engineLog = ""; @@ -222,7 +255,7 @@ export async function bootAmicodeServiceRunner(opts: AmicodeServiceRunnerOptions // No fleetActivation is ever passed here (H3): the hub runs the byte- // identical unarmed base service; arming is an extension-host decision. const service = createAmicodeService({ - engine: { password, getUrl: () => engineUrl }, + engine: { password: unarmed ? undefined : password, getUrl: () => engineUrl }, shelf: { distRoot: opts.appDistRoot }, }); const servicePort = opts.servicePort ?? 4095; diff --git a/packages/extension/src/amicode_service_runner_cli.ts b/packages/extension/src/amicode_service_runner_cli.ts index 4117a1225..dfaa39b29 100644 --- a/packages/extension/src/amicode_service_runner_cli.ts +++ b/packages/extension/src/amicode_service_runner_cli.ts @@ -23,6 +23,11 @@ // deployment sets it: the ops layer owns the // credential so the frontdoor's ?auth_token= carrier // shares the engine's auth. +// AMICODE_ENGINE_UNARMED "=1" spawns the engine WITHOUT any credential — +// the hub's anonymous boundary posture (#955; the +// fork's "canonical serves anonymous 200"). The pair +// with AMICODE_SERVICE_AUTH=open is the hub posture. +// Wins over AMICODE_ENGINE_PASSWORD. // OPENCODE_DB the canonical pin — passed through to the spawned // engine untouched (the hub's session store). // @@ -76,6 +81,7 @@ async function main(): Promise { enginePort: envInt("AMICODE_ENGINE_PORT") ?? 4094, engineCwd: (process.env.AMICODE_ENGINE_CWD ?? "").trim() || undefined, enginePassword: (process.env.AMICODE_ENGINE_PASSWORD ?? "").trim() || undefined, + engineUnarmed: (process.env.AMICODE_ENGINE_UNARMED ?? "").trim() === "1", engineEnv: (process.env.OPENCODE_DB ?? "").trim() ? { OPENCODE_DB: process.env.OPENCODE_DB } : undefined, log: (line) => console.log(line), }); diff --git a/packages/extension/test/amicode_service_runner.test.ts b/packages/extension/test/amicode_service_runner.test.ts index 32767547f..3805aba8c 100644 --- a/packages/extension/test/amicode_service_runner.test.ts +++ b/packages/extension/test/amicode_service_runner.test.ts @@ -14,7 +14,7 @@ // dist, and an engine that never becomes healthy each fail with a NAMED // reason (never a silent half-boot) and tear the spawned child down. import { spawn } from "node:child_process"; -import { chmodSync, existsSync, mkdtempSync, writeFileSync, readdirSync } from "node:fs"; +import { chmodSync, existsSync, mkdtempSync, writeFileSync, readdirSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; @@ -140,6 +140,161 @@ describe.skipIf(!engineAvailable || !distAvailable)( }, ); +describe.skipIf(!engineAvailable || !distAvailable)( + "amicode service runner, UNARMED posture (live: vendored engine + built dist)", + () => { + const boots: AmicodeServiceRunnerBoot[] = []; + afterAll(async () => { + for (const b of boots.splice(0)) await b.shutdown().catch(() => undefined); + }); + + it("boots the unarmed pair and an anonymous proxied GET answers 200 (the hub posture pair)", async () => { + const dbPath = join(mkdtempSync(join(tmpdir(), "amicode-runner-unarmed-")), "unarmed-test.db"); + // The hub posture is a PAIR: unarmed engine (#955) + open service auth + // (AMICODE_SERVICE_AUTH=open, #959's env carrier — the runner passes + // authMode resolution through to createAmicodeService independently). + const prevAuth = process.env.AMICODE_SERVICE_AUTH; + process.env.AMICODE_SERVICE_AUTH = "open"; + let boot: AmicodeServiceRunnerBoot; + try { + boot = await bootAmicodeServiceRunner({ + engineBin: ENGINE_BIN, + appDistRoot: APP_DIST, + engineEnv: { OPENCODE_DB: dbPath }, + engineUnarmed: true, + healthTimeoutMs: 30_000, + servicePort: 0, + enginePort: 0, + log: () => undefined, + }); + } finally { + if (prevAuth === undefined) delete process.env.AMICODE_SERVICE_AUTH; + else process.env.AMICODE_SERVICE_AUTH = prevAuth; + } + boots.push(boot); + + // No engine credential exists in this posture. + expect(boot.enginePassword).toBeUndefined(); + + // The app-proxy forwards the request VERBATIM (no header injection), so + // the anonymous GET reaches the UNARMED engine — the fork hub's + // deployed "canonical serves anonymous 200" posture (2026-08-07). + const session = await fetch(`${boot.url}/session`); + expect(session.status).toBe(200); + expect(session.headers.get("content-type") ?? "").toContain("application/json"); + }, 60_000); + }, +); + +describe("amicode service runner spawn posture (headless, fake engine — no vendored engine needed)", () => { + const boots: AmicodeServiceRunnerBoot[] = []; + afterAll(async () => { + for (const b of boots.splice(0)) await b.shutdown().catch(() => undefined); + }); + + /** A stand-in engine binary (the shebang-node fake-engine idiom): answers + * the health probe with 200 and — when FAKE_ENGINE_DUMP is set in its env + * (rides the runner's engineEnv passthrough) — records whether + * OPENCODE_SERVER_PASSWORD was present at spawn. argv: [node, script, + * "serve", "--port", ]. */ + function writeFakeEngine(dir: string): string { + const bin = join(dir, "fake-engine"); + writeFileSync( + bin, + `#!/usr/bin/env node +const { createServer } = require("node:http"); +const { writeFileSync } = require("node:fs"); +const port = Number(process.argv[4] ?? 0); +if (process.env.FAKE_ENGINE_DUMP) + writeFileSync(process.env.FAKE_ENGINE_DUMP, JSON.stringify({ armed: "OPENCODE_SERVER_PASSWORD" in process.env })); +createServer((_req, res) => { + res.writeHead(200, { "content-type": "text/plain" }); + res.end("fake engine up"); +}).listen(port, "127.0.0.1"); +`, + ); + chmodSync(bin, 0o755); + return bin; + } + + function writeStubShelf(dir: string): string { + writeFileSync(join(dir, "index.html"), "stub shelf"); + return dir; + } + + it("UNARMED spawn: the child env carries NO OPENCODE_SERVER_PASSWORD, the service gets enginePassword: undefined, and the boot line names the posture", async () => { + const dir = mkdtempSync(join(tmpdir(), "amicode-runner-unarmed-headless-")); + const dump = join(dir, "env-dump.json"); + const lines: string[] = []; + const boot = await bootAmicodeServiceRunner({ + engineBin: writeFakeEngine(dir), + appDistRoot: writeStubShelf(mkdtempSync(join(tmpdir(), "amicode-runner-unarmed-shelf-"))), + engineUnarmed: true, + engineEnv: { FAKE_ENGINE_DUMP: dump }, + healthTimeoutMs: 10_000, + servicePort: 0, + enginePort: 0, + log: (l) => lines.push(l), + }); + boots.push(boot); + + // The spawn env, as seen BY THE CHILD (not inferred): the key is absent. + const dumped = JSON.parse(readFileSync(dump, "utf8")) as { armed: boolean }; + expect(dumped.armed).toBe(false); + + // The service wiring: no engine mint handed over. + expect(boot.enginePassword).toBeUndefined(); + + // The posture is named on the boot line, per the hub ops contract. + expect(lines.join("\n")).toContain("UNARMED (the hub's anonymous boundary posture)"); + + // The service itself still boots and answers with its OWN mint (its + // authMode resolves independently — credential default here, since the + // hub's AMICODE_SERVICE_AUTH=open is the ops layer's env decision). + const doc = await fetch(`${boot.url}/`, { headers: { Authorization: boot.authHeader } }); + expect(doc.status).toBe(200); + }, 30_000); + + it("engineUnarmed wins over an explicit enginePassword — the posture is never silently half-armed", async () => { + const dir = mkdtempSync(join(tmpdir(), "amicode-runner-unarmed-wins-")); + const dump = join(dir, "env-dump.json"); + const boot = await bootAmicodeServiceRunner({ + engineBin: writeFakeEngine(dir), + appDistRoot: writeStubShelf(mkdtempSync(join(tmpdir(), "amicode-runner-unarmed-wins-shelf-"))), + engineUnarmed: true, + enginePassword: "explicit-but-ignored", + engineEnv: { FAKE_ENGINE_DUMP: dump }, + healthTimeoutMs: 10_000, + servicePort: 0, + enginePort: 0, + log: () => undefined, + }); + boots.push(boot); + const dumped = JSON.parse(readFileSync(dump, "utf8")) as { armed: boolean }; + expect(dumped.armed).toBe(false); + expect(boot.enginePassword).toBeUndefined(); + }, 30_000); + + it("the DEFAULT (armed) path is unchanged: the child env DOES carry OPENCODE_SERVER_PASSWORD and the mint is surfaced", async () => { + const dir = mkdtempSync(join(tmpdir(), "amicode-runner-armed-headless-")); + const dump = join(dir, "env-dump.json"); + const boot = await bootAmicodeServiceRunner({ + engineBin: writeFakeEngine(dir), + appDistRoot: writeStubShelf(mkdtempSync(join(tmpdir(), "amicode-runner-armed-shelf-"))), + engineEnv: { FAKE_ENGINE_DUMP: dump }, + healthTimeoutMs: 10_000, + servicePort: 0, + enginePort: 0, + log: () => undefined, + }); + boots.push(boot); + const dumped = JSON.parse(readFileSync(dump, "utf8")) as { armed: boolean }; + expect(dumped.armed).toBe(true); + expect(typeof boot.enginePassword).toBe("string"); + expect((boot.enginePassword ?? "").length).toBeGreaterThan(0); + }, 30_000); +}); + describe("amicode service runner (fail-loud, headless — no engine needed)", () => { it("a missing engine binary fails with the named reason BEFORE spawning anything", async () => { const err = await bootAmicodeServiceRunner({