Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 46 additions & 13 deletions packages/extension/src/amicode_service_runner.ts
Original file line number Diff line number Diff line change
@@ -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.
//
Expand Down Expand Up @@ -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). */
Expand All @@ -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). */
Expand Down Expand Up @@ -130,12 +144,17 @@ function freePort(): Promise<number> {
}

/** 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<boolean> {
* 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<boolean> {
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 */
Expand All @@ -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}`;
Expand All @@ -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 = "";
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions packages/extension/src/amicode_service_runner_cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
//
Expand Down Expand Up @@ -76,6 +81,7 @@ async function main(): Promise<never> {
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),
});
Expand Down
157 changes: 156 additions & 1 deletion packages/extension/test/amicode_service_runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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", <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"), "<!doctype html><title>stub shelf</title>");
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({
Expand Down
Loading