diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f2dd32f..b909ba7c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -206,6 +206,29 @@ jobs: name: amicode-vsix path: packages/extension/amicode.vsix if-no-files-found: error + app-shelf-boot-proof: + # #822 the env-gated LIVE boot proof lane (the telaio_app_probe precedent). + # HONEST STUB: ci.yml does NOT build the app dist in this slice (the full + # bun recipe — upstream tarball fetch + ~4,700-package install — is the + # packaging chore issue #825, which also wires build:app into the release + # pipeline), so the probe SKIPS here with the reason + # printed. It runs FOR REAL once a dist is present: pass AMICODE_APP_DIST + # pointing at packages/extension/dist/app after a build:app step. The + # contract itself is held by the vitest suite (mock engine + mock dist); + # this lane is the end-to-end proof, never a false green — a skip is + # printed as a skip. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v7 + with: { node-version: 20, cache: pnpm } + - run: pnpm install --frozen-lockfile + - run: pnpm --filter amicode fetch:opencode + env: + GH_TOKEN: ${{ secrets.OPENCODE_FETCH_TOKEN }} # the probe's engine default is the vendored binary + - name: boot proof — real service + real engine + real dist (self-skips until the dist-build chore lands) + run: AMICODE_APP_DIST=${AMICODE_APP_DIST:-} node packages/extension/scripts/amicode_service_boot_probe.mjs boot-smoke: strategy: matrix: diff --git a/packages/extension/package.json b/packages/extension/package.json index 108d1c8a..b126c16f 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -226,6 +226,11 @@ "default": "", "description": "Override the extension's resource root for scores, templates, and bin/ (dev only). Empty = use the installed extension path. Set via the Developer Tools section in settings." }, + "amicode.appBundleDir": { + "type": "string", + "default": "", + "description": "Path to a built app-bundle dist (dev override). The amicode service serves it as the app UI on its origin. Empty = the packaged dist (dist/app inside the extension, staged by `pnpm --filter amicode run build:app`). When neither exists the service serves an honest needs-setup placeholder — the /amicode/* API surface is unaffected." + }, "amicode.opencodePort": { "type": "number", "default": 43117, @@ -380,6 +385,7 @@ "opencode:build": "node scripts/opencode_dev.mjs build", "opencode:pin": "node scripts/opencode_dev.mjs pin", "build:exemplars": "node scripts/build_exemplars.mjs", + "build:app": "node scripts/build_app_bundle.mjs", "healthcheck": "node scripts/healthcheck.mjs", "package": "pnpm --filter @amicode/amico-run build && pnpm run build && pnpm run build:exemplars && pnpm run fetch:opencode && vsce package --no-dependencies --allow-missing-repository -o amicode.vsix", "dev:pulseplot": "esbuild dev/pulseplot_harness/main.ts --bundle --format=iife --outfile=dev/pulseplot_harness/main.js && open dev/pulseplot_harness/index.html", diff --git a/packages/extension/scripts/amicode_service_boot_probe.mjs b/packages/extension/scripts/amicode_service_boot_probe.mjs new file mode 100644 index 00000000..b2fc4a9b --- /dev/null +++ b/packages/extension/scripts/amicode_service_boot_probe.mjs @@ -0,0 +1,73 @@ +#!/usr/bin/env node +// amicode_service_boot_probe.mjs — the #822 env-gated LIVE boot proof +// (the telaio_app_probe.mjs convention). +// +// Boots the REAL amicode service (bundled from this repo's own source — no +// transcribed logic, no drift) against a REAL spawned engine (the vendored +// opencode binary, password-armed) and a REAL built app dist, and asserts +// end-to-end from the service origin: the app document, an engine API call +// through the proxy, an SSE connect through the proxy, and one /amicode/* +// route — all with the ENGINE credential (the framed app's bootstrap, the +// zero-app-side-change contract). +// +// ENV-GATED (CI never builds the dist in this slice — the packaging-chore +// issue is the follow-up): with AMICODE_APP_DIST absent (or without an +// index.html) the probe SKIPS (exit 0) and says why. AMICODE_ENGINE_BIN +// defaults to the vendored binary (fetch:opencode); absent → skip. +// +// USAGE (the live phase / a dev machine): +// node packages/extension/scripts/build_app_bundle.mjs # stages dist/app +// pnpm --filter amicode fetch:opencode # vendors the engine +// AMICODE_APP_DIST=packages/extension/dist/app \ +// node packages/extension/scripts/amicode_service_boot_probe.mjs +// +// READ-ONLY EVIDENCE: boots, probes, reports, stops — never mutates state. +import { spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { build } from "esbuild"; + +const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); + +const skip = (msg) => { + console.log(`[boot-probe] SKIP: ${msg}`); + process.exit(0); +}; + +const APP_DIST = (process.env.AMICODE_APP_DIST ?? "").trim(); +if (!APP_DIST) skip("AMICODE_APP_DIST not set — the env-gated live-phase script (CI has no built dist in this slice)"); +if (!existsSync(join(APP_DIST, "index.html"))) + skip(`AMICODE_APP_DIST has no index.html (${APP_DIST}) — run \`pnpm --filter amicode run build:app\` first`); + +const ENGINE_BIN = (process.env.AMICODE_ENGINE_BIN ?? "").trim() || join(PKG_ROOT, "vendor", "opencode", `${process.platform}-${process.arch}`, "opencode"); +if (!existsSync(ENGINE_BIN)) skip(`no engine binary at ${ENGINE_BIN} — run \`pnpm --filter amicode fetch:opencode\` or set AMICODE_ENGINE_BIN`); + +// Bundle the REAL service + probe entry from source (the same esbuild the +// extension build uses) into a throwaway ESM file, then run it. Bundling — +// not transpiling-by-hand — is the no-drift rule: the probe runs this repo's +// actual createAmicodeService, shelf, and proxy code. +const outDir = mkdtempSync(join(tmpdir(), "amicode-boot-probe-")); +const entry = join(PKG_ROOT, "src", "amicode_service_boot_probe.ts"); +const outfile = join(outDir, "probe.mjs"); +try { + await build({ + entryPoints: [entry], + bundle: true, + platform: "node", + target: "node20", + format: "esm", + outfile, + sourcemap: false, + minify: false, + logLevel: "warning", + }); + const r = spawnSync(process.execPath, [outfile], { + stdio: "inherit", + env: { ...process.env, AMICODE_APP_DIST: APP_DIST, AMICODE_ENGINE_BIN: ENGINE_BIN }, + }); + process.exit(r.status ?? 1); +} finally { + rmSync(outDir, { recursive: true, force: true }); +} diff --git a/packages/extension/scripts/build_app_bundle.mjs b/packages/extension/scripts/build_app_bundle.mjs new file mode 100644 index 00000000..f631b097 --- /dev/null +++ b/packages/extension/scripts/build_app_bundle.mjs @@ -0,0 +1,100 @@ +#!/usr/bin/env node +// build_app_bundle.mjs — stage the built app-bundle dist into the extension +// (#822, the fetch:opencode precedent for a build product riding the VSIX). +// The app-bundle README's proven recipe: materialize (canonical base + +// overlay) → bun install → build the app (vite production build) → copy the +// app dist into /dist/app — the path resolveAppDistRoot's DEFAULT +// expects, which the amicode service's shelf serves at its origin. +// +// USAGE +// node scripts/build_app_bundle.mjs # full recipe +// node scripts/build_app_bundle.mjs --dist # stage an already-built dist +// node scripts/build_app_bundle.mjs --work # materialize/reuse this tree +// AMICODE_APP_BUNDLE_WORK= # --work via env +// +// FAILS LOUDLY, never a silent skip: a packaging step that no-ops is the +// "silently no-op'd fetch" trap the vsix-gate exists to catch. The RUNTIME half +// is honest independently of this script: with no dist staged, the shelf +// serves the needs-setup placeholder (never a silent 404-as-app). +// +// CI NOTE (honest stub): the full bun build (≈4,700 packages + upstream +// tarball fetch) is NOT wired into ci.yml in this slice — build:app is a +// manual/release-pipeline hook until the packaging-chore issue lands. The +// app-shelf-boot-proof CI lane runs the env-gated probe, which skips with the +// reason printed until a dist is built there. +import { spawnSync } from "node:child_process"; +import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const EXT_ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const REPO_ROOT = join(EXT_ROOT, "..", ".."); +const BUNDLE_PKG = join(REPO_ROOT, "packages", "app-bundle"); + +const args = process.argv.slice(2); +const flag = (n) => { + const i = args.indexOf(`--${n}`); + return i >= 0 ? args[i + 1] : undefined; +}; + +const fail = (msg, code = 1) => { + console.error(`[build:app] FAIL: ${msg}`); + process.exit(code); +}; + +const run = (cmd, cmdArgs, cwd, note) => { + console.log(`[build:app] ${note}: ${cmd} ${cmdArgs.join(" ")} (cwd=${cwd})`); + const r = spawnSync(cmd, cmdArgs, { cwd, stdio: "inherit" }); + if (r.status !== 0) fail(`${note} failed (exit ${r.status})`, 2); +}; + +const stageDist = (distDir) => { + if (!existsSync(join(distDir, "index.html"))) + fail(`no dist to stage: ${distDir} has no index.html`); + const target = join(EXT_ROOT, "dist", "app"); + rmSync(target, { recursive: true, force: true }); + mkdirSync(join(target, ".."), { recursive: true }); + cpSync(distDir, target, { recursive: true }); + if (!existsSync(join(target, "index.html"))) fail(`staging ${distDir} → ${target} lost the index document`); + const files = readdirSync(target); + console.log(`[build:app] staged ${files.length} top-level entries → packages/extension/dist/app`); + console.log("[build:app] DONE — the amicode service's shelf serves this at its origin"); +}; + +// ── stage-only mode: an already-built dist (the telaio probe's recipe) ─────── +const prebuilt = flag("dist"); +if (prebuilt) { + stageDist(prebuilt); + process.exit(0); +} + +// ── the full recipe ────────────────────────────────────────────────────────── +const work = flag("work") ?? process.env.AMICODE_APP_BUNDLE_WORK ?? join(BUNDLE_PKG, ".materialized"); + +if (!existsSync(join(work, "package.json"))) { + run("node", [join(BUNDLE_PKG, "scripts", "materialize.mjs"), "--out", work], REPO_ROOT, "materialize (canonical base + overlay)"); +} +if (!existsSync(join(work, "packages", "app"))) + fail(`${work} has no packages/app — not a materialized app tree (pass --work to point at one)`); + +const bun = spawnSync("which", ["bun"], { encoding: "utf8" }); +if (bun.status !== 0 || !bun.stdout.trim()) + fail("bun is not on PATH — the app-bundle README's recipe installs with bun (https://bun.sh)"); +run("bun", ["install"], work, "bun install (the app tree's ~4,700 packages)"); +// bun, not pnpm, and cwd-scoped: the materialized tree pins +// `"packageManager": "bun@…"` (corepack-managed pnpm refuses to run scripts +// in it) and bun's --filter finds no packages (the tree's workspace layout); +// running the app package's OWN `vite build` script in its directory just +// works — the README's proven 14s build. +run("bun", ["run", "build"], join(work, "packages", "app"), "app build (vite production build)"); + +const built = join(work, "packages", "app", "dist"); +if (!existsSync(join(built, "index.html"))) { + const appDir = join(work, "packages", "app"); + const candidates = existsSync(appDir) ? readdirSync(appDir).filter((e) => existsSync(join(appDir, e, "index.html"))) : []; + fail( + `no built dist at ${built} (packages/app build output with index.html). ` + + (candidates.length > 0 ? `Found index.html in: ${candidates.join(", ")}` : "No index.html anywhere under packages/app — the build did not emit the app document."), + ); +} +stageDist(built); diff --git a/packages/extension/src/amicode_service/app_shelf.ts b/packages/extension/src/amicode_service/app_shelf.ts new file mode 100644 index 00000000..02aa50bc --- /dev/null +++ b/packages/extension/src/amicode_service/app_shelf.ts @@ -0,0 +1,180 @@ +// APP SHELF (#822, the fork-cutover static slice): static serving of the +// built app-bundle dist from the amicode service's origin — the piece that +// lets the framed amicode app come from the extension service instead of the +// fork binary's origin. Pattern lifted from the telaio file-server loops +// (19–20): SPA fallback for GETs that accept HTML, correct content types for +// asset requests, path-traversal refusal, and an HONEST needs-setup +// placeholder when no dist is present (never a silent 404-as-app — a missing +// build must read as a missing build, not as a broken app). +// +// vscode-free on purpose (the service's founding discipline): the dist root +// is RESOLVED by the caller (wiring reads the dev-override setting and the +// extension root) and handed in; this module only serves what it is given. +import { existsSync, readFileSync, statSync } from "node:fs"; +import { join, resolve as resolvePath, sep } from "node:path"; + +/** The needs-setup placeholder's honest marker — the probe asserts the LIVE + * dist never serves it (a served placeholder means the dist did not reach + * the shelf). */ +export const APP_SHELF_NEEDS_SETUP_MARKER = "amicode-app-shelf-needs-setup"; + +export interface AppShelfResult { + status?: number; + /** Assets ship as utf8 strings when they're text (js/css/svg/html), Buffers + * when they're binary (icons/fonts/wasm) — res.end takes both. */ + body: string | Buffer; + contentType?: string; + headers?: Record; +} + +/** The dev override wins; the packaged dist rides the extension at + * /dist/app (the fetch:opencode precedent for a build product riding + * the VSIX — build_app_bundle.mjs stages it there). Pure path logic, so the + * wiring tests exercise it without vscode. */ +export function resolveAppDistRoot(override: string, extensionRoot: string): string { + const trimmed = (override ?? "").trim(); + if (trimmed !== "") return trimmed; + return join(extensionRoot, "dist", "app"); +} + +// The asset kinds a vite build emits (plus the icons the index references). +// Anything unknown falls back to octet-stream — the browser sniffs, and a +// wrong text/html on an asset is the failure mode that actually matters +// (it turns a script into a document). +const CONTENT_TYPES: Record = { + ".html": "text/html; charset=utf-8", + ".htm": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".map": "application/json; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".avif": "image/avif", + ".ico": "image/x-icon", + ".wasm": "application/wasm", + ".txt": "text/plain; charset=utf-8", + ".xml": "application/xml", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", + ".otf": "font/otf", + ".webm": "video/webm", + ".mp4": "video/mp4", +}; + +export function contentTypeFor(pathname: string): string { + const dot = pathname.lastIndexOf("."); + const ext = dot === -1 ? "" : pathname.slice(dot).toLowerCase(); + return CONTENT_TYPES[ext] ?? "application/octet-stream"; +} + +export function needsSetupHtml(): string { + // The honest "no app here" page: names what is missing and both fixes + // (build the dist, or point the override setting at one). Served 200 so a + // browser (the iframe) renders it — the placeholder IS the app surface's + // honest state, not an error to trap. + return ` +Amicode — app not built + +

Amicode app not built

+

${APP_SHELF_NEEDS_SETUP_MARKER}

+

The extension service is up, but no app-bundle dist is present, so there is nothing to serve at this origin.

+
    +
  • Package path: run pnpm --filter amicode run build:app (materialize → bun install → vite build), which stages the dist into dist/app inside the extension.
  • +
  • Dev override: set amicode.appBundleDir to a built app dist directory.
  • +
+

The /amicode/* API surface is unaffected — only the app UI is absent.

+ +`; +} + +export interface AppShelfOptions { + /** The built app dist root (must hold index.html to count as present). */ + distRoot?: string; +} + +export class AppShelf { + private readonly distRoot: string | undefined; + + constructor(opts: AppShelfOptions = {}) { + this.distRoot = opts.distRoot; + } + + /** Does the configured root hold a servable dist? (checked per request — + * a dev override dir appearing mid-session needs no re-boot.) */ + private distPresent(): boolean { + return !!this.distRoot && existsSync(join(this.distRoot, "index.html")); + } + + /** Refuse traversal BEFORE anything else: a hostile probe gets the honest + * 403 regardless of whether a dist is present. Decoding happens here so + * the encoded form (%2e%2e) is as dead as the literal one. */ + private resolveUnderRoot(pathname: string): string | { traversal: true } { + let decoded: string; + try { + decoded = decodeURIComponent(pathname); + } catch { + return { traversal: true }; + } + if (decoded.includes("\0")) return { traversal: true }; + const root = resolvePath(this.distRoot!); + const target = resolvePath(root, "." + decoded.replace(/\\/g, "/")); + if (target !== root && !target.startsWith(root + sep)) return { traversal: true }; + return target; + } + + /** + * Serve one request from the shelf, or return undefined when the request + * is NOT the shelf's business (the caller falls through to the engine + * proxy). Precedence per #822: exact /amicode/* routes outrank the shelf + * (the caller checks them first); a shelf hit outranks the proxy. + * + * - non-GET → undefined (the proxy owns it) + * - traversal → 403 (refused outright) + * - dist present: + * / → index.html (the origin document) + * existing file → the file, with its content type + * GET + accepts HTML → index.html (SPA fallback) + * otherwise → undefined (API surface → the proxy) + * - dist missing: + * / or GET + accepts HTML → the needs-setup placeholder + * otherwise → undefined (API GETs keep honest non-HTML answers) + */ + handle(method: string, pathname: string, accept: string): AppShelfResult | undefined { + if (method !== "GET") return undefined; + if (this.distPresent()) { + const under = this.resolveUnderRoot(pathname); + if (typeof under !== "string") { + return { status: 403, body: JSON.stringify({ ok: false, error: "forbidden" }), contentType: "application/json" }; + } + const index = join(this.distRoot!, "index.html"); + if (under === resolvePath(index) || !existsSync(under) || !statSync(under).isFile()) { + // Not a file on disk: SPA fallback for document GETs only — the API + // surface keeps honest answers (falls to the proxy), never HTML. + if (acceptsHtml(accept) || pathname === "/") { + return { body: readFileSync(index, "utf8"), contentType: "text/html; charset=utf-8" }; + } + return undefined; + } + const contentType = contentTypeFor(under); + const raw = readFileSync(under); + return { body: contentType.startsWith("text/") ? raw.toString("utf8") : raw, contentType }; + } + // No dist (or no root configured): the honest needs-setup placeholder + // for document GETs — never a silent 404-as-app. + if (pathname === "/" || acceptsHtml(accept)) { + return { body: needsSetupHtml(), contentType: "text/html; charset=utf-8" }; + } + return undefined; + } +} + +function acceptsHtml(accept: string): boolean { + return accept.includes("text/html"); +} diff --git a/packages/extension/src/amicode_service/engine_proxy.ts b/packages/extension/src/amicode_service/engine_proxy.ts new file mode 100644 index 00000000..0d11828a --- /dev/null +++ b/packages/extension/src/amicode_service/engine_proxy.ts @@ -0,0 +1,89 @@ +// ENGINE PROXY (#822, the fork-cutover proxy slice): the transparent reverse +// proxy from the amicode service's origin to the spawned engine (the vendored +// opencode server). Every non-amicode, non-static request streams through: +// method, headers, and body preserved — deliberately NO header rewriting (the +// framed app bootstraps with the ENGINE credential, so its Authorization is +// already exactly what the engine wants; the app's own auth machinery is the +// same one that works against the engine today). SSE rides the same pipe: +// response chunks are piped as they arrive, never buffered. +// +// vscode-free on purpose (the service's founding discipline); the upstream is +// LATE-BOUND by a getter because the engine's URL is only known once its +// ServerManager reports ready (ephemeral port) and CHANGES across restarts +// (solver-mode switches, config re-preps) — the getter is read per request. +import * as http from "node:http"; + +export interface EngineProxyOptions { + /** The engine origin (e.g. http://127.0.0.1:43117), read per request; + * undefined = the engine is not bound yet (boot gap, restart gap). */ + getUrl(): string | undefined; +} + +/** Hop-by-hop headers a proxy must not forward verbatim (RFC 7230 §6.1): + * `host` names the service origin, not the engine — node recomputes it for + * the upstream; `connection` negotiates THIS hop's options. Everything else + * (Authorization included) rides unchanged. */ +const HOP_BY_HOP = ["host", "connection"] as const; + +export class EngineProxy { + constructor(private readonly opts: EngineProxyOptions) {} + + /** + * Stream one request through to the engine. Returns false when no upstream + * is bound yet (the caller sends the honest 503); true once the request is + * in flight — the caller must not touch `res` afterwards. Never throws: + * upstream failures collapse into the honest 502 JSON shape. + */ + handle(req: http.IncomingMessage, res: http.ServerResponse): boolean { + const upstreamBase = this.opts.getUrl(); + if (!upstreamBase) return false; + try { + // req.url is the raw path+query as received — reconstruct against the + // engine origin so the full request line (query included) is preserved. + const target = new URL(req.url ?? "/", upstreamBase); + const headers: Record = { ...req.headers }; + for (const h of HOP_BY_HOP) delete headers[h]; + const upstream = http.request(target, { method: req.method, headers }, (up) => { + res.writeHead(up.statusCode ?? 502, up.headers); + up.pipe(res); + up.on("error", () => { + try { + res.end(); + } catch { + /* already gone */ + } + }); + }); + upstream.on("error", (err) => { + // The engine went away mid-flight (restart gap, crash): honest 502 + // JSON when headers aren't sent yet, else just close the stream. + try { + if (!res.headersSent) { + res.writeHead(502, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: false, error: `engine upstream failed: ${err}` })); + } else { + res.end(); + } + } catch { + /* response already torn down — never throw into the server */ + } + }); + // The request body rides as a stream — never buffered, so large POSTs + // (file uploads) pass through with no service-side memory cost. + req.pipe(upstream); + return true; + } catch { + try { + if (!res.headersSent) { + res.writeHead(502, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: false, error: "engine upstream unavailable" })); + } else { + res.end(); + } + } catch { + /* never throw into the server */ + } + return true; + } + } +} diff --git a/packages/extension/src/amicode_service/index.ts b/packages/extension/src/amicode_service/index.ts index e1d49102..69bc32d9 100644 --- a/packages/extension/src/amicode_service/index.ts +++ b/packages/extension/src/amicode_service/index.ts @@ -32,6 +32,8 @@ import { libraryBody, saveLibraryFile } from "./library"; import { widgetsResponse, widgetCodeResponse, forkWidgetResponse, loadRegistry } from "./widgets"; import { dashboardResponse, saveDashboardResponse } from "./dashboard"; import { widgetFrameHtml, WIDGET_CSP } from "./widget_frame_html"; +import { AppShelf } from "./app_shelf"; +import { EngineProxy } from "./engine_proxy"; import { createProject, listProjects } from "./project"; import { addCustomConnectionResponse, @@ -222,9 +224,28 @@ export function registerSolverModeRoutes(server: AmicodeServiceServer): AmicodeS } /** The service with every ported slice mounted. The extension wiring slice - * boots this at activation; the contract tests boot it in-process. */ -export function createAmicodeService(opts: { password?: string } = {}): AmicodeServiceServer { - const server = new AmicodeServiceServer(opts); + * boots this at activation; the contract tests boot it in-process. + * + * #822 additions, both optional so the parity-contract boots stay + * byte-identical: `shelf` mounts the app-bundle static server (the built + * dist this origin serves the framed app from), `engine` arms engine-token + * auth acceptance + the reverse proxy to the spawned opencode server. */ +export function createAmicodeService( + opts: { + password?: string; + shelf?: { distRoot?: string }; + engine?: { password?: string; getUrl?: () => string | undefined }; + } = {}, +): AmicodeServiceServer { + const server = new AmicodeServiceServer({ + password: opts.password, + // The engine mint arms accept-both auth even before the URL getter + // binds (the engine token is valid on /amicode/* from boot, not just + // once the upstream is reachable). + enginePassword: opts.engine?.password, + }); + if (opts.shelf !== undefined) server.attachAppShelf(new AppShelf(opts.shelf)); + if (opts.engine?.getUrl !== undefined) server.attachEngineProxy(new EngineProxy({ getUrl: opts.engine.getUrl })); registerProfileRoutes(server); registerVaultRoutes(server); registerProblemRoutes(server); diff --git a/packages/extension/src/amicode_service/server.ts b/packages/extension/src/amicode_service/server.ts index bc27f564..f9f8a192 100644 --- a/packages/extension/src/amicode_service/server.ts +++ b/packages/extension/src/amicode_service/server.ts @@ -18,6 +18,8 @@ import * as http from "node:http"; import { timingSafeEqual } from "node:crypto"; import { mintServerPassword, serverAuthHeader } from "../server_auth"; import { setBindHostname } from "./bind_host"; +import { AppShelf, type AppShelfResult } from "./app_shelf"; +import { EngineProxy } from "./engine_proxy"; export interface AmicodeRequestCtx { /** Fully-parsed request URL (query params included — POST /amicode/profile @@ -57,10 +59,20 @@ export class AmicodeServiceServer { private readonly routes = new Map(); private server?: http.Server; private _port?: number; + private shelf?: AppShelf; + private engineProxy?: EngineProxy; readonly password: string; + /** #822: the spawned engine's per-boot mint, accepted ALONGSIDE the + * service's own — the framed app bootstraps with the ENGINE credential + * (its auth machinery is the one that works against the engine today), + * so every surface on this origin must take it with zero app-side + * change. undefined = no engine bound (the proxy-less boots stay + * single-mint, byte-compatible with the pre-#822 contract). */ + private readonly enginePassword?: string; - constructor(opts: { password?: string } = {}) { + constructor(opts: { password?: string; enginePassword?: string } = {}) { this.password = opts.password ?? mintServerPassword(); + this.enginePassword = opts.enginePassword; } get port(): number | undefined { @@ -84,15 +96,37 @@ export class AmicodeServiceServer { return this; } + /** Mount the app shelf (#822): static serving of the built app dist, + * consulted AFTER the exact route table and BEFORE the engine proxy. */ + attachAppShelf(shelf: AppShelf): this { + this.shelf = shelf; + return this; + } + + /** Mount the engine reverse proxy (#822): the fallback for non-amicode, + * non-static requests, streaming to/from the spawned engine. */ + attachEngineProxy(proxy: EngineProxy): this { + this.engineProxy = proxy; + return this; + } + private authorized(req: http.IncomingMessage): boolean { const header = req.headers.authorization ?? ""; if (!header.startsWith("Basic ")) return false; // Decode the base64 credentials before comparing — the wire form is // base64("opencode:"), the comparison form is the raw pair. const given = Buffer.from(header.slice(6).trim(), "base64"); - const want = Buffer.from(`opencode:${this.password}`, "utf8"); - if (given.length !== want.length) return false; - return timingSafeEqual(given, want); + // #822: accept BOTH mints — the service's own AND the engine's (the + // framed app bootstraps with the engine credential; the proxy forwards + // it unchanged, and the /amicode/* routes take it too so one credential + // works everywhere on this origin). Length checks before the constant- + // time compare, per credential, so a wrong-mint probe learns nothing. + for (const mint of [this.password, this.enginePassword]) { + if (mint === undefined) continue; + const want = Buffer.from(`opencode:${mint}`, "utf8"); + if (given.length === want.length && timingSafeEqual(given, want)) return true; + } + return false; } private async readBody(req: http.IncomingMessage): Promise { @@ -107,7 +141,7 @@ export class AmicodeServiceServer { } private async dispatch(req: http.IncomingMessage, res: http.ServerResponse): Promise { - const send = (r: AmicodeHandlerResult) => { + const send = (r: AmicodeHandlerResult | AppShelfResult) => { res.statusCode = r.status ?? 200; res.setHeader("Content-Type", r.contentType ?? "application/json"); for (const [k, v] of Object.entries(r.headers ?? {})) res.setHeader(k, v); @@ -122,13 +156,32 @@ export class AmicodeServiceServer { const host = req.headers.host ?? "127.0.0.1"; const url = new URL(req.url ?? "/", `http://${host}`); const route = this.routes.get(`${req.method} ${url.pathname}`); - if (!route) { + if (route) { + const body = await this.readBody(req); + const result = await route.handler({ url, body }); + send(result); + return; + } + // #822 precedence, after the exact route table: the /amicode/* + // namespace is OWNED by this service (unmatched paths 404 here — the + // fork-parity discipline; stock canonical serves no /amicode/* so + // proxying them would just launder our 404) → the app shelf → the + // engine proxy. + if (url.pathname === "/amicode" || url.pathname.startsWith("/amicode/")) { send({ status: 404, body: JSON.stringify({ ok: false, error: `no route: ${req.method} ${url.pathname}` }) }); return; } - const body = await this.readBody(req); - const result = await route.handler({ url, body }); - send(result); + const shelfHit = this.shelf?.handle(req.method ?? "GET", url.pathname, String(req.headers.accept ?? "")); + if (shelfHit) { + send(shelfHit); + return; + } + if (this.engineProxy) { + // Streams method/headers/body through to the engine (SSE included); + // false = no upstream bound yet → the honest 503 below. + if (this.engineProxy.handle(req, res)) return; + } + send({ status: 503, body: JSON.stringify({ ok: false, error: "engine upstream not available" }) }); } catch (err) { // Never crash the service on one bad request; mirror the fork's // collapse-into-one-shape discipline at the transport layer. diff --git a/packages/extension/src/amicode_service_boot_probe.ts b/packages/extension/src/amicode_service_boot_probe.ts new file mode 100644 index 00000000..672f89a1 --- /dev/null +++ b/packages/extension/src/amicode_service_boot_probe.ts @@ -0,0 +1,147 @@ +// amicode_service_boot_probe — the #822 env-gated LIVE boot proof ENTRY +// (bundled + spawned by scripts/amicode_service_boot_probe.mjs; do not run +// by hand — the wrapper carries the env gating). Boots the REAL service +// (this same source, esbuild-bundled — no transcribed logic) against a REAL +// spawned engine (the vendored opencode binary, password-armed) and a REAL +// built app dist, then asserts the four end-to-end surfaces from the service +// origin, all with the ENGINE credential (the framed app's bootstrap): +// +// 1. the app document from the shelf (200 text/html, NOT the placeholder) +// 2. an engine API call through the proxy (GET /session, real engine answer) +// 3. an SSE connect through the proxy (GET /event, text/event-stream) +// 4. one /amicode/* route (GET /amicode/profile, ok:true) +// +// CI never runs this (no dist there until the packaging-chore issue lands — +// the wrapper skips honestly); the gate runs it once for real. The vitest +// suite covers the contract with mock engine + mock dist. +import { spawn, type ChildProcess } from "node:child_process"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createServer } from "node:net"; +import { createAmicodeService } from "./amicode_service"; +import { APP_SHELF_NEEDS_SETUP_MARKER } from "./amicode_service/app_shelf"; +import { serverAuthHeader } from "./server_auth"; + +const APP_DIST = (process.env.AMICODE_APP_DIST ?? "").trim(); +const ENGINE_BIN = (process.env.AMICODE_ENGINE_BIN ?? "").trim(); +const ENGINE_PASSWORD = "amicode-boot-probe-engine-mint"; + +const fail = (msg: string): never => { + console.error(`[boot-probe] FAIL: ${msg}`); + process.exit(1); +}; + +function freePort(): Promise { + return new Promise((resolve, reject) => { + const srv = createServer(); + srv.listen(0, "127.0.0.1", () => { + const p = srv.address(); + if (typeof p === "object" && p) srv.close(() => resolve(p.port)); + else srv.close(), reject(new Error("no port")); + }); + srv.on("error", reject); + }); +} + +async function main(): Promise { + if (APP_DIST === "" || ENGINE_BIN === "") fail("AMICODE_APP_DIST / AMICODE_ENGINE_BIN missing (the wrapper gates these)"); + + // ── boot the engine (the ServerManager spawn idiom, password-armed) ──────── + const proj = mkdtempSync(join(tmpdir(), "amicode-boot-probe-engine-")); + mkdirSync(join(proj, ".opencode"), { recursive: true }); + writeFileSync(join(proj, "AGENTS.md"), "# amicode boot probe\n"); + writeFileSync(join(proj, ".opencode", "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" }, null, 2)); + + const enginePort = await freePort(); + const engineUrl = `http://127.0.0.1:${enginePort}`; + let engineLog = ""; + const engine: ChildProcess = spawn(ENGINE_BIN, ["serve", "--port", String(enginePort)], { + cwd: proj, + env: { ...process.env, OPENCODE_SERVER_PASSWORD: ENGINE_PASSWORD }, + stdio: ["ignore", "pipe", "pipe"], + }); + engine.stdout?.on("data", (d: Buffer) => (engineLog += d)); + engine.stderr?.on("data", (d: Buffer) => (engineLog += d)); + const engineAuth = serverAuthHeader(ENGINE_PASSWORD); + + // ── boot the REAL service: shelf + engine proxy + engine-token auth ─────── + const service = createAmicodeService({ + engine: { password: ENGINE_PASSWORD, getUrl: () => engineUrl }, + shelf: { distRoot: APP_DIST }, + }); + const origin = (await service.start()).toString().replace(/\/$/, ""); + + try { + // Engine readiness (the ServerManager health-probe idiom: WITH the armed + // credential, else a healthy boot 401s and reads as a timeout). + let up = false; + const deadline = Date.now() + 30_000; + while (Date.now() < deadline && !up) { + try { + const r = await fetch(`${engineUrl}/`, { headers: { Authorization: engineAuth }, signal: AbortSignal.timeout(500) }); + if (r.status < 500) up = true; + } catch { + /* not up yet */ + } + if (!up) await new Promise((r) => setTimeout(r, 250)); + } + if (!up) fail(`engine not up within 30s\n--- engine output ---\n${engineLog}`); + console.log(`[boot-probe] engine up at ${engineUrl}`); + + // 1. the app document from the service origin (the shelf, not the proxy). + const doc = await fetch(`${origin}/`, { headers: { Authorization: engineAuth, Accept: "text/html" } }); + const docBody = await doc.text(); + if (doc.status !== 200) fail(`GET / → ${doc.status}, want 200`); + if (!(doc.headers.get("content-type") ?? "").includes("text/html")) fail(`GET / content-type: ${doc.headers.get("content-type")}`); + if (docBody.includes(APP_SHELF_NEEDS_SETUP_MARKER)) + fail("GET / served the NEEDS-SETUP placeholder — the dist did not reach the shelf"); + console.log(`[boot-probe] ✓ app document from the service origin (${docBody.length} bytes, not the placeholder)`); + + // 2. an engine API call through the proxy (the real engine answers). + const session = await fetch(`${origin}/session`, { headers: { Authorization: engineAuth } }); + if (session.status !== 200) fail(`proxied GET /session → ${session.status}, want 200 (the real engine's answer)`); + const sessions = (await session.json().catch(() => undefined)) as unknown; + if (!(session.headers.get("content-type") ?? "").includes("application/json") || sessions === undefined) + fail("proxied GET /session did not answer the engine's JSON"); + console.log("[boot-probe] ✓ engine API call through the proxy (GET /session)"); + + // 3. an SSE connect through the proxy (unbuffered stream). + const sse = await fetch(`${origin}/event`, { headers: { Authorization: engineAuth } }); + if (sse.status !== 200) fail(`proxied GET /event → ${sse.status}, want 200`); + if (!(sse.headers.get("content-type") ?? "").includes("text/event-stream")) + fail(`proxied GET /event content-type: ${sse.headers.get("content-type")}, want text/event-stream`); + { + // Read the FIRST chunk then stop — the connect is what's under test. + const reader = sse.body!.getReader(); + const { value } = await reader.read(); + if (!value || value.length === 0) fail("proxied SSE stream delivered an empty first chunk"); + await reader.cancel().catch(() => undefined); + } + console.log("[boot-probe] ✓ SSE connect through the proxy (GET /event, first chunk delivered)"); + + // 4. one /amicode/* route with the ENGINE credential (accept-both auth). + const profile = await fetch(`${origin}/amicode/profile`, { headers: { Authorization: engineAuth } }); + if (profile.status !== 200) fail(`GET /amicode/profile → ${profile.status}, want 200`); + if (((await profile.json()) as { ok?: boolean }).ok !== true) fail("GET /amicode/profile did not answer the service's ok:true shape"); + console.log("[boot-probe] ✓ /amicode/* route with the engine credential (GET /amicode/profile)"); + + console.log("[boot-probe] PASS — the service origin serves the app, fronts the engine, and owns /amicode/*"); + } finally { + await service.stop(); + try { + engine.kill("SIGTERM"); + } catch { + /* already gone */ + } + setTimeout(() => { + try { + engine.kill("SIGKILL"); + } catch { + /* already gone */ + } + }, 3000).unref(); + } +} + +void main(); diff --git a/packages/extension/src/amicode_service_wiring.ts b/packages/extension/src/amicode_service_wiring.ts index 9debb561..a929baff 100644 --- a/packages/extension/src/amicode_service_wiring.ts +++ b/packages/extension/src/amicode_service_wiring.ts @@ -1,21 +1,33 @@ -// AMICODE SERVICE wiring (#451, M1) — boot the extension-host amicode service -// at activation, alongside the fork opencode server (the parallel-run harness: -// both serve the amicode route surface; consumers stay on the fork until the -// M3 cutover, while the contract tests + dogfood probes hold the port to -// parity). +// AMICODE SERVICE wiring (#451, M1; #822 adds the engine upstream + the app +// shelf) — boot the extension-host amicode service at activation, alongside +// the fork opencode server (the parallel-run harness: both serve the amicode +// route surface; consumers stay on the fork until the M3 cutover, while the +// contract tests + dogfood probes hold the port to parity). // // vscode-free on purpose (the log sink is a structural interface, mirroring -// the service's own discipline) so the boot/lifecycle logic is unit-testable. +// the service's own discipline) so the boot/lifecycle logic is unit-testable; +// the CALLER (extension.ts) resolves the engine context and the dist root — +// this module only wires what it is handed. // // Lifecycle notes: // - The service is STATELESS across requests (every route reads state at call -// time via env overrides — same contract as the fork's routes), so unlike -// the opencode server it needs NO restart on solver-mode switches, config +// time via env overrides — same contract as the fork's routes), so unlike the +// opencode server it needs NO restart on solver-mode switches, config // re-preps, or telemetry flips. One boot per activation; dispose stops it. -// - The password is the service's OWN per-boot mint (server_auth idiom) — -// deliberately NOT the opencode server's: the two surfaces have separate -// consumers, and a credential shared with the spawn env would let anything -// that can read the terminal env hit the chat server's routes too. +// - The password is the service's OWN per-boot mint (server_auth idiom). +// #822 SUPERSEDES the old total separation from the opencode server's +// credential: the service now ALSO accepts the ENGINE token (on /amicode/* +// routes and proxied paths alike) because the framed app bootstraps with +// the engine credential and must work everywhere on this origin with zero +// app-side change. This is an accepted-ALONGSIDE credential, not a shared +// store: each surface still 401s everything but its own mints, and the +// per-boot service mint (what the terminal env carries) still never +// reaches the spawned engine's auth. +// - The engine upstream is LATE-BOUND (a URL getter read per request): the +// engine restarts on solver-mode switches and config re-preps while this +// service deliberately does not, so the proxy must always resolve the +// CURRENT engine, never a boot-time snapshot. During a restart gap the +// getter yields undefined and the proxy answers the honest 503. import { createAmicodeService } from "./amicode_service"; import type { AmicodeServiceServer } from "./amicode_service/server"; @@ -29,20 +41,47 @@ export interface AmicodeServiceBoot extends AmicodeServiceHandle { service: AmicodeServiceServer; } +/** The engine context the extension hands the service at boot (#822): + * the spawned opencode server's per-boot mint (the same value spawnEnv + * injects as OPENCODE_SERVER_PASSWORD) and its origin, read LATE (per + * request) because the engine restarts under the service's feet. */ +export interface AmicodeServiceEngineContext { + password: string; + getUrl(): string | undefined; +} + +export interface AmicodeServiceWiringOptions { + /** Arm engine-token auth + the reverse proxy to the spawned engine. */ + engine?: AmicodeServiceEngineContext; + /** The app-bundle dist root to serve statically (the shelf's needs-setup + * placeholder covers a missing dist honestly). Absent = no shelf (the + * pre-#822 boot shape, kept for parity-contract tests). */ + appDistRoot?: string; +} + /** * Boot the amicode service on an ephemeral loopback port. Never throws past * activation wiring: a boot failure is logged and returns undefined — the * extension must keep working with the fork server alone (parallel-run means * the service is additive, never load-bearing, until the M3 cutover). */ -export async function startAmicodeService(log: { - appendLine(line: string): void; -}): Promise { +export async function startAmicodeService( + log: { + appendLine(line: string): void; + }, + opts: AmicodeServiceWiringOptions = {}, +): Promise { try { - const service = createAmicodeService(); + const service = createAmicodeService({ + engine: opts.engine, + shelf: opts.appDistRoot !== undefined ? { distRoot: opts.appDistRoot } : undefined, + }); const url = await service.start(); + const authNote = opts.engine !== undefined ? "per-boot Basic + engine token" : "per-boot Basic"; + const engineNote = opts.engine !== undefined ? "; engine proxy armed (late-bound upstream)" : ""; + const shelfNote = opts.appDistRoot !== undefined ? "; app shelf mounted" : ""; log.appendLine( - `[amicode-service] parallel-run: listening on ${url.toString()} (${service.routeCount} routes; auth: per-boot Basic)`, + `[amicode-service] parallel-run: listening on ${url.toString()} (${service.routeCount} routes; auth: ${authNote})${engineNote}${shelfNote}`, ); return { service, url: url.toString().replace(/\/$/, ""), authHeader: service.authHeader }; } catch (err) { diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 945e217b..fcbcf144 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -63,6 +63,7 @@ import { isFleetClient, getFleetRole, goStandalone, readFleetConfig, migrateLega import { resolveHubTarget, restartHub } from "./hub_ops"; import { registerAmicodeTerminal } from "./terminal"; import { amicodeServiceDisposal, startAmicodeService } from "./amicode_service_wiring"; +import { resolveAppDistRoot } from "./amicode_service/app_shelf"; import { registerOpencodeUpdater } from "./opencode_updater_wiring"; import { stageOpencodeCliLink } from "./opencode_cli_link"; import { resolveMountStack, personalMount, defaultVaultsRoot } from "./substrate/mount_store"; @@ -761,11 +762,28 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { }); ctx.subscriptions.push({ dispose: () => void serverManager?.stop() }); - // Amicode service (#451 M1): the extension-host port of the 31 fork - // amicode routes, booted in PARALLEL-RUN alongside the fork server (the - // chat/widgets still hit the fork until the M3 cutover). Stateless — no - // restart coupling with solver-mode switches or config re-preps. - const serviceBoot = await startAmicodeService(opencodeChannel); + // Amicode service (#451 M1; #822 adds the shelf + the engine proxy): the + // extension-host port of the 31 fork amicode routes, booted in + // PARALLEL-RUN alongside the fork server (the chat/widgets still hit the + // fork until the M3 cutover). Stateless — no restart coupling with + // solver-mode switches or config re-preps. #822: it also serves the app + // dist (the shelf) and fronts the spawned engine (the proxy) so the + // framed app can come from THIS origin — the engine context is LATE-BOUND + // (the module-level serverManager is REPLACED on solver-mode switches / + // vault respawns while this service keeps running, so the getter reads it + // per request; a restart gap reads as the honest 503), and the engine + // mint is accepted alongside the service's own (the framed app + // bootstraps with the engine credential). + const serviceBoot = await startAmicodeService(opencodeChannel, { + engine: { + password: serverPassword, + getUrl: () => serverManager?.url?.toString(), + }, + appDistRoot: resolveAppDistRoot( + vscode.workspace.getConfiguration("amicode").get("appBundleDir", ""), + ctx.extensionPath, + ), + }); amicodeService = serviceBoot ?? undefined; ctx.subscriptions.push(amicodeServiceDisposal(serviceBoot)); diff --git a/packages/extension/test/amicode_service_app_shelf.test.ts b/packages/extension/test/amicode_service_app_shelf.test.ts new file mode 100644 index 00000000..2cf045f9 --- /dev/null +++ b/packages/extension/test/amicode_service_app_shelf.test.ts @@ -0,0 +1,170 @@ +// Amicode-service app shelf (#822) — the fork-cutover static slice: the +// extension-host service serves the built app-bundle dist (SPA fallback, +// content types, traversal refusal, honest needs-setup placeholder when no +// dist exists) ahead of the engine proxy, with exact /amicode/* routes always +// winning (the precedence contract). All tests use mock dists in tmp dirs — +// the heavy real build is the env-gated probe's business, not the unit suite's. +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, basename } from "node:path"; +import * as http from "node:http"; +import { createAmicodeService } from "../src/amicode_service"; + +/** Raw-path GET (fetch normalizes dot segments; the server must also survive + * clients that don't). Returns {status, body}. */ +function rawGet(origin: string, rawPath: string, auth: string): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + const u = new URL(origin); + const req = http.request( + { host: u.hostname, port: u.port, path: rawPath, headers: { Authorization: auth, Accept: "text/html" } }, + (res) => { + let body = ""; + res.on("data", (c: Buffer) => (body += c.toString())); + res.on("end", () => resolve({ status: res.statusCode ?? 0, body })); + }, + ); + req.on("error", reject); + req.end(); + }); +} + +/** A minimal app dist: an index document + the asset kinds a vite build emits. */ +function buildMockDist(root: string): string { + const dist = join(root, "dist"); + mkdirSync(join(dist, "assets"), { recursive: true }); + writeFileSync( + join(dist, "index.html"), + "amicode app
", + ); + writeFileSync(join(dist, "assets", "app.js"), "console.log('app bundle');\n"); + writeFileSync(join(dist, "assets", "app.css"), "body { color: rebeccapurple; }\n"); + writeFileSync(join(dist, "assets", "logo.svg"), "\n"); + writeFileSync(join(dist, "favicon.ico"), "\x00\x00\x01\x00"); + return dist; +} + +describe("amicode service — app shelf (static serving of the app dist)", () => { + let root: string; + let dist: string; + let service: ReturnType; + let base: string; + let auth: string; + + beforeAll(async () => { + root = mkdtempSync(join(tmpdir(), "amicode-shelf-")); + dist = buildMockDist(root); + service = createAmicodeService({ password: "shelf-test-password", shelf: { distRoot: dist } }); + const url = await service.start(); + base = url.toString().replace(/\/$/, ""); + auth = service.authHeader; + }); + + afterAll(async () => { + await service.stop(); + rmSync(root, { recursive: true, force: true }); + }); + + it("GET / serves the app dist's index document (the origin document, text/html)", async () => { + const r = await fetch(base + "/", { headers: { Authorization: auth, Accept: "text/html" } }); + expect(r.status).toBe(200); + expect((r.headers.get("content-type") ?? "").startsWith("text/html")).toBe(true); + const body = await r.text(); + expect(body).toContain("
"); + }); + + it("asset requests get correct content types (never text/html for an asset)", async () => { + const cases: Array<[string, string, string]> = [ + ["/assets/app.js", "text/javascript", "app bundle"], + ["/assets/app.css", "text/css", "rebeccapurple"], + ["/assets/logo.svg", "image/svg+xml", " { + const doc = await fetch(base + "/runs/some-client-route", { + headers: { Authorization: auth, Accept: "text/html" }, + }); + expect(doc.status).toBe(200); + expect((doc.headers.get("content-type") ?? "").startsWith("text/html")).toBe(true); + expect(await doc.text()).toContain("
"); + // The API surface keeps honest non-HTML answers: a JSON GET that is no + // static file falls through toward the engine (no engine attached here → + // the honest 503), never the SPA document. + const api = await fetch(base + "/session/xyz", { + headers: { Authorization: auth, Accept: "application/json" }, + }); + expect(api.status).toBe(503); + expect(api.headers.get("content-type")).toContain("application/json"); + }); + + it("path traversal is refused (403, never a file outside the root)", async () => { + const root = mkdtempSync(join(tmpdir(), "amicode-shelf-outside-")); + writeFileSync(join(root, "secret.txt"), "the shelf must never serve this\n"); + const outside = basename(root); + // NOTE on what counts as a traversal request: the WHATWG URL parser + // (both fetch client-side AND the server's new URL()) collapses pure dot + // segments — literal `../` and exact `%2e%2e` — before the shelf ever + // sees them. The carriers that DO reach the shelf decode to paths that + // escape the dist root; those must be refused 403, and no response may + // ever leak the outside file. Raw http.request because fetch normalizes + // its inputs the same way the server does. + const evildoers = [ + `/..%2f${encodeURIComponent(outside)}%2fsecret.txt`, + `/..%2F${encodeURIComponent(outside)}%2Fsecret.txt`, + "/a/..%2f..%2f/secret.txt", + "/..%2f..%2fetc%2fpasswd", + ]; + for (const evil of evildoers) { + const r = await rawGet(base, evil, auth); + expect(r.status, `${evil} must be refused`).toBe(403); + expect(r.body).not.toContain("the shelf must never serve this"); + } + // The pure-dot forms are normalized away before the shelf (they 403/SPA + // as ordinary paths) — but they still must never leak the file either. + for (const normalized of [`/../${outside}/secret.txt`, "/%2e%2e/%2e%2e/etc/passwd"]) { + const r = await rawGet(base, normalized, auth); + expect(r.body).not.toContain("the shelf must never serve this"); + } + rmSync(root, { recursive: true, force: true }); + }); + + it("with no dist present, document GETs get the honest needs-setup placeholder (never a silent 404)", async () => { + const emptyRoot = mkdtempSync(join(tmpdir(), "amicode-shelf-empty-")); + const bare = createAmicodeService({ password: "shelf-empty-password", shelf: { distRoot: emptyRoot } }); + const bareBase = (await bare.start()).toString().replace(/\/$/, ""); + try { + const r = await fetch(bareBase + "/", { headers: { Authorization: bare.authHeader, Accept: "text/html" } }); + expect(r.status).toBe(200); + expect((r.headers.get("content-type") ?? "").startsWith("text/html")).toBe(true); + const body = await r.text(); + expect(body).toContain("amicode-app-shelf-needs-setup"); + expect(body).toContain("build:app"); + // The /amicode/* surface is unaffected by the missing dist. + const profile = await fetch(bareBase + "/amicode/profile", { headers: { Authorization: bare.authHeader } }); + expect(profile.status).toBe(200); + expect((await profile.json()).ok).toBe(true); + } finally { + await bare.stop(); + rmSync(emptyRoot, { recursive: true, force: true }); + } + }); + + it("without the shelf option the service keeps its pre-#822 fallback (no static serving)", async () => { + const bare = createAmicodeService({ password: "shelf-absent-password" }); + const bareBase = (await bare.start()).toString().replace(/\/$/, ""); + try { + const r = await fetch(bareBase + "/", { headers: { Authorization: bare.authHeader, Accept: "text/html" } }); + expect(r.status).toBe(503); // honest no-engine answer, NOT a static doc + } finally { + await bare.stop(); + } + }); +}); diff --git a/packages/extension/test/amicode_service_engine_proxy.test.ts b/packages/extension/test/amicode_service_engine_proxy.test.ts new file mode 100644 index 00000000..d505b436 --- /dev/null +++ b/packages/extension/test/amicode_service_engine_proxy.test.ts @@ -0,0 +1,182 @@ +// Amicode-service engine proxy (#822) — the fork-cutover proxy slice: every +// non-amicode, non-static request streams through to the spawned engine +// (method/headers/body passthrough, SSE unbuffered), while exact /amicode/* +// routes and static shelf hits never reach it (the precedence contract), and +// auth accepts the engine token everywhere (the framed app bootstraps with the +// engine credential — zero app-side change). Mock upstream: a node:http +// engine that records what it receives, per the issue's Testing Decisions. +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as http from "node:http"; +import { AddressInfo } from "node:net"; +import { createAmicodeService } from "../src/amicode_service"; +import { serverAuthHeader } from "../src/server_auth"; + +interface RecordedHit { + method: string; + url: string; + headers: Record; + body: string; +} + +/** The mock engine: records every request it receives; /session answers JSON, + * /echo echoes the body + content-type, /event is an SSE stream with spaced + * chunks (the unbuffered-passthrough probe). */ +async function startMockEngine(): Promise<{ hits: RecordedHit[]; url: string; stop(): Promise }> { + const hits: RecordedHit[] = []; + const server = http.createServer((req, res) => { + let body = ""; + req.on("data", (c: Buffer) => (body += c.toString())); + req.on("end", () => { + hits.push({ method: req.method ?? "GET", url: req.url ?? "/", headers: { ...req.headers }, body }); + if (req.method === "GET" && req.url === "/session") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: true, engine: true, sessions: [] })); + return; + } + if (req.method === "POST" && req.url === "/echo") { + res.writeHead(200, { "content-type": String(req.headers["content-type"] ?? "text/plain") }); + res.end(body); + return; + } + if (req.method === "GET" && req.url?.startsWith("/event")) { + // SSE with deliberate spacing: a BUFFERING proxy would deliver all + // chunks at once when the response ends; an unbuffered one delivers + // them as the engine sends them. + res.writeHead(200, { "content-type": "text/event-stream" }); + res.write("data: chunk-1\n\n"); + setTimeout(() => res.write("data: chunk-2\n\n"), 120); + setTimeout(() => res.end("data: chunk-3\n\n"), 240); + return; + } + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ message: "not found", mockEngine: true })); + }); + }); + await new Promise((r) => server.listen(0, "127.0.0.1", r)); + const port = (server.address() as AddressInfo).port; + return { + hits, + url: `http://127.0.0.1:${port}`, + stop: () => new Promise((r) => server.close(() => r())), + }; +} + +describe("amicode service — engine proxy (transparent passthrough to the spawned engine)", () => { + let root: string; + let engine: Awaited>; + let service: ReturnType; + let base: string; + const enginePassword = "engine-mint-test-password"; + let engineAuth: string; + + beforeAll(async () => { + root = mkdtempSync(join(tmpdir(), "amicode-proxy-")); + const dist = join(root, "dist"); + mkdirSync(join(dist, "assets"), { recursive: true }); + writeFileSync(join(dist, "index.html"), "
app
"); + writeFileSync(join(dist, "assets", "app.js"), "console.log('app');\n"); + + engine = await startMockEngine(); + service = createAmicodeService({ + password: "service-own-mint", + engine: { password: enginePassword, getUrl: () => engine.url }, + shelf: { distRoot: dist }, + }); + base = (await service.start()).toString().replace(/\/$/, ""); + engineAuth = serverAuthHeader(enginePassword); + }); + + afterAll(async () => { + await service.stop(); + await engine.stop(); + rmSync(root, { recursive: true, force: true }); + }); + + it("a non-amicode, non-static request proxies to the engine: method, path, headers, body preserved", async () => { + const r = await fetch(base + "/session", { headers: { Authorization: engineAuth } }); + expect(r.status).toBe(200); + expect(r.headers.get("content-type")).toContain("application/json"); + const body = (await r.json()) as { engine?: boolean }; + expect(body.engine).toBe(true); // the MOCK engine answered — the request really went upstream + const hit = engine.hits.find((h) => h.url === "/session"); + expect(hit, "the engine must have received the request").toBeDefined(); + expect(hit!.method).toBe("GET"); + // Transparent: the client's Authorization rides unchanged — no rewriting. + expect(hit!.headers.authorization).toBe(engineAuth); + }); + + it("POST bodies and content-types ride through unchanged", async () => { + const payload = JSON.stringify({ parts: [{ type: "text", text: "hello" }] }); + const r = await fetch(base + "/echo", { + method: "POST", + headers: { Authorization: engineAuth, "Content-Type": "application/json", "X-Custom-Probe": "carried" }, + body: payload, + }); + expect(r.status).toBe(200); + expect(r.headers.get("content-type")).toContain("application/json"); + expect(await r.text()).toBe(payload); + const hit = engine.hits.find((h) => h.url === "/echo"); + expect(hit!.method).toBe("POST"); + expect(hit!.body).toBe(payload); + expect(hit!.headers["content-type"]).toBe("application/json"); + expect(hit!.headers["x-custom-probe"]).toBe("carried"); + }); + + it("the ENGINE token is accepted on an /amicode/* route (accept-both auth); an unknown token is still 401", async () => { + // The framed app bootstraps with the engine credential — the service's + // own /amicode/* route table must take it, not just the proxied paths. + const r = await fetch(base + "/amicode/profile", { headers: { Authorization: engineAuth } }); + expect(r.status).toBe(200); + expect(((await r.json()) as { ok: boolean }).ok).toBe(true); + // The service's own mint keeps working everywhere (nothing was replaced). + const ownAuth = serverAuthHeader("service-own-mint"); + const own = await fetch(base + "/amicode/profile", { headers: { Authorization: ownAuth } }); + expect(own.status).toBe(200); + // A token that is NEITHER mint is rejected — accept-both is not accept-all. + const unknown = serverAuthHeader("not-any-mint"); + const bad = await fetch(base + "/amicode/profile", { headers: { Authorization: unknown } }); + expect(bad.status).toBe(401); + }); + + it("precedence: an exact /amicode/* route never reaches the proxy, and a static shelf hit never reaches the proxy", async () => { + // /amicode/* is the service's OWN namespace — the mock engine must never + // see it (a proxied /amicode/* would launder our route table upstream). + const route = await fetch(base + "/amicode/profile", { headers: { Authorization: engineAuth } }); + expect(route.status).toBe(200); + expect(engine.hits.some((h) => h.url.startsWith("/amicode")), "no /amicode/* hit may reach the engine").toBe(false); + // A static asset is served by the shelf — the engine never sees it. + const asset = await fetch(base + "/assets/app.js", { headers: { Authorization: engineAuth } }); + expect(asset.status).toBe(200); + expect(asset.headers.get("content-type")).toContain("text/javascript"); + expect(await asset.text()).toContain("console.log"); + expect(engine.hits.some((h) => h.url.startsWith("/assets")), "no /assets hit may reach the engine").toBe(false); + }); + + it("SSE (/event) streams UNBUFFERED through the proxy (chunks arrive as the engine sends them)", async () => { + const r = await fetch(base + "/event", { headers: { Authorization: engineAuth } }); + expect(r.status).toBe(200); + expect(r.headers.get("content-type")).toContain("text/event-stream"); + const chunks: Array<{ text: string; at: number }> = []; + const started = Date.now(); + if (!r.body) throw new Error("no response body stream"); + const reader = r.body.getReader(); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push({ text: Buffer.from(value).toString(), at: Date.now() - started }); + } + const joined = chunks.map((c) => c.text).join(""); + expect(joined).toContain("chunk-1"); + expect(joined).toContain("chunk-3"); + // The engine spaces its chunks 120ms apart; a buffering proxy would flush + // everything at response end (all arrival times within a few ms). Allow a + // generous margin against scheduling noise — the property under test is + // "progressive", not "low latency". + const first = chunks[0]!.at; + const last = chunks[chunks.length - 1]!.at; + expect(last - first, `first@${first}ms last@${last}ms — buffered, not streamed`).toBeGreaterThanOrEqual(180); + }); +}); diff --git a/packages/extension/test/amicode_service_wiring.test.ts b/packages/extension/test/amicode_service_wiring.test.ts index 0c5a8ba2..9cd2823e 100644 --- a/packages/extension/test/amicode_service_wiring.test.ts +++ b/packages/extension/test/amicode_service_wiring.test.ts @@ -1,16 +1,43 @@ -// amicode_service wiring tests (#451, M1) — the activation/lifecycle slice: -// boot on an ephemeral port, serve a route with the per-boot auth, export the -// terminal-env handle shape, dispose cleanly, and NEVER throw into activation -// (a boot failure logs and returns undefined — parallel-run means the service -// is additive, never load-bearing, until the M3 cutover). +// amicode_service wiring tests (#451, M1; #822 adds the engine/shelf boot +// shape) — the activation/lifecycle slice: boot on an ephemeral port, serve a +// route with the per-boot auth, export the terminal-env handle shape, dispose +// cleanly, and NEVER throw into activation (a boot failure logs and returns +// undefined — parallel-run means the service is additive, never load-bearing, +// until the M3 cutover). The #822 tests cover the full boot: engine context +// (late-bound URL getter + engine mint) and the app dist root both reach the +// booted service — against a mock engine upstream (node:http), per the issue's +// Testing Decisions. import { describe, it, expect } from "vitest"; +import * as http from "node:http"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { AddressInfo } from "node:net"; import { amicodeServiceDisposal, startAmicodeService } from "../src/amicode_service_wiring"; +import { serverAuthHeader } from "../src/server_auth"; const sinkLog = () => { const lines: string[] = []; return { lines, log: { appendLine: (l: string) => lines.push(l) } }; }; +/** A one-endpoint mock engine: /probe answers JSON so the wiring test can see + * the proxy actually reaching the upstream handed in at boot. */ +async function startMockEngine(): Promise<{ url: string; stop(): Promise }> { + const server = http.createServer((req, res) => { + if (req.method === "GET" && req.url === "/probe") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: true, upstream: true })); + return; + } + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ message: "not found" })); + }); + await new Promise((r) => server.listen(0, "127.0.0.1", r)); + const port = (server.address() as AddressInfo).port; + return { url: `http://127.0.0.1:${port}`, stop: () => new Promise((r) => server.close(() => r())) }; +} + describe("startAmicodeService", () => { it("boots on an ephemeral loopback port, serves a route with the per-boot auth, and logs the URL", async () => { const { lines, log } = sinkLog(); @@ -54,4 +81,70 @@ describe("startAmicodeService", () => { // Failed boot → disposal is a safe no-op. expect(() => amicodeServiceDisposal(undefined).dispose()).not.toThrow(); }); + + it("#822 wires the engine context + app dist root: engine token works on /amicode/*, the proxy reaches the CURRENT upstream (late-bound), and the shelf serves the dist", async () => { + const { lines, log } = sinkLog(); + const engine = await startMockEngine(); + // The late-bound getter contract: a variable the "restart" reassigns — + // the proxy must read it PER REQUEST, never capture the boot-time value. + let currentUrl: string | undefined = engine.url; + const root = mkdtempSync(join(tmpdir(), "amicode-wiring-")); + const dist = join(root, "dist"); + mkdirSync(dist, { recursive: true }); + writeFileSync(join(dist, "index.html"), "
wired
"); + const boot = await startAmicodeService(log, { + engine: { password: "engine-wiring-mint", getUrl: () => currentUrl }, + appDistRoot: dist, + }); + expect(boot).toBeDefined(); + if (!boot) return; + try { + expect(lines.some((l) => l.includes("engine proxy armed"))).toBe(true); + expect(lines.some((l) => l.includes("app shelf mounted"))).toBe(true); + const engineAuth = serverAuthHeader("engine-wiring-mint"); + // Engine token on an /amicode/* route (accept-both auth through the wiring). + const route = await fetch(`${boot.url}/amicode/profile`, { headers: { Authorization: engineAuth } }); + expect(route.status).toBe(200); + expect(((await route.json()) as { ok: boolean }).ok).toBe(true); + // Proxied path through to the upstream handed in at boot. + const via1 = await fetch(`${boot.url}/probe`, { headers: { Authorization: engineAuth } }); + expect(via1.status).toBe(200); + expect(((await via1.json()) as { upstream?: boolean }).upstream).toBe(true); + // The app document from the shelf. + const doc = await fetch(`${boot.url}/`, { + headers: { Authorization: engineAuth, Accept: "text/html" }, + }); + expect(doc.status).toBe(200); + expect(await doc.text()).toContain("wired"); + // The restart gap: getter yields undefined → the honest 503, and the + // /amicode/* route table is unaffected (the shelf/proxy never shadow it). + currentUrl = undefined; + const gap = await fetch(`${boot.url}/probe`, { headers: { Authorization: engineAuth } }); + expect(gap.status).toBe(503); + const stillOk = await fetch(`${boot.url}/amicode/profile`, { headers: { Authorization: engineAuth } }); + expect(stillOk.status).toBe(200); + } finally { + await boot.service.stop(); + await engine.stop(); + rmSync(root, { recursive: true, force: true }); + } + }); + + it("#822 without options the boot keeps its pre-#822 shape (no engine note, no shelf)", async () => { + const { lines, log } = sinkLog(); + const boot = await startAmicodeService(log); + expect(boot).toBeDefined(); + if (!boot) return; + try { + const joined = lines.join("\n"); + expect(joined).toContain("auth: per-boot Basic)"); + expect(joined).not.toContain("engine proxy armed"); + expect(joined).not.toContain("app shelf mounted"); + // No shelf → a document GET is NOT a static doc (honest 503, no engine). + const r = await fetch(`${boot.url}/`, { headers: { Authorization: boot.authHeader, Accept: "text/html" } }); + expect(r.status).toBe(503); + } finally { + await boot.service.stop(); + } + }); });