diff --git a/packages/amico-run/esbuild.config.mjs b/packages/amico-run/esbuild.config.mjs index f7921c551..2f058e175 100644 --- a/packages/amico-run/esbuild.config.mjs +++ b/packages/amico-run/esbuild.config.mjs @@ -1,9 +1,11 @@ import { build } from "esbuild"; import { chmodSync } from "node:fs"; -// Two bins from one package: the historical `amico-run` (entry cli.ts) and the new `amico` -// verb router (entry amico.ts, issue #108). Both share the launch path (src/launch.ts); -// amico.ts additionally bundles the spine verbs + the mcp-serve facade. +// Three bins from one package: the historical `amico-run` (entry cli.ts), the `amico` +// verb router (entry amico.ts, issue #108) — both sharing the launch path (src/launch.ts; +// amico.ts additionally bundles the spine verbs + the mcp-serve facade) — and the +// `amico-pasqal` connector launcher (entry pasqal_cli.ts, issue #168: token env-injection, +// secrets off argv). const common = { bundle: true, platform: "node", @@ -19,6 +21,7 @@ const common = { for (const [entry, outfile] of [ ["src/cli.ts", "dist/amico-run.js"], ["src/amico.ts", "dist/amico.js"], + ["src/pasqal_cli.ts", "dist/amico-pasqal.js"], ]) { await build({ ...common, entryPoints: [entry], outfile }); chmodSync(outfile, 0o755); diff --git a/packages/amico-run/launcher/amico-pasqal b/packages/amico-run/launcher/amico-pasqal new file mode 100755 index 000000000..52e3c770a --- /dev/null +++ b/packages/amico-run/launcher/amico-pasqal @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Thin launcher for `amico-pasqal`: resolve node, exec the bundled Pasqal connector +# launcher. No logic lives here (mirror of launcher/amico-run). See src/pasqal_cli.ts — +# the secret travels ONLY in the child env the bundle builds; nothing here touches it. +set -euo pipefail +SOURCE="${BASH_SOURCE[0]}" +while [ -h "$SOURCE" ]; do # resolve symlink chains (node_modules/.bin) + DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)" + SOURCE="$(readlink "$SOURCE")" + [[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE" +done +DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)" +if ! command -v node >/dev/null 2>&1; then + echo "amico-pasqal: node >= 20 not found on PATH (install node or fix PATH; see provisioning runbook)" >&2 + exit 64 +fi +exec node "$DIR/../dist/amico-pasqal.js" "$@" diff --git a/packages/amico-run/package.json b/packages/amico-run/package.json index b1b188216..4e104bb02 100644 --- a/packages/amico-run/package.json +++ b/packages/amico-run/package.json @@ -7,7 +7,8 @@ "types": "./src/index.ts", "bin": { "amico-run": "./launcher/amico-run", - "amico": "./launcher/amico" + "amico": "./launcher/amico", + "amico-pasqal": "./launcher/amico-pasqal" }, "engines": { "node": ">=20" diff --git a/packages/amico-run/src/pasqal_cli.ts b/packages/amico-run/src/pasqal_cli.ts new file mode 100644 index 000000000..a77130f18 --- /dev/null +++ b/packages/amico-run/src/pasqal_cli.ts @@ -0,0 +1,18 @@ +// The `amico-pasqal` bin entry point (issue #168). The launcher logic lives in +// pasqal_launch.ts (the cli.ts / launch.ts split) — this file is intentionally thin: +// resolve argv → pasqalLaunch() → set the process exit code. SECURITY: even the +// unexpected-error lane prints only the error's own text — pasqal_launch.ts +// guarantees no ConfigError message ever carries the token, and the token itself +// lives only in the child env, never in any argv or log line. +import { pasqalLaunch } from "./pasqal_launch.js"; + +pasqalLaunch(process.argv.slice(2)).then( + (c) => { + process.exitCode = c; + }, + (e) => { + // Any unexpected throw is a launcher fault, not a connector failure → 64. + console.error(`amico-pasqal: unexpected error: ${e instanceof Error ? (e.stack ?? e.message) : e}`); + process.exitCode = 64; + }, +); diff --git a/packages/amico-run/src/pasqal_launch.ts b/packages/amico-run/src/pasqal_launch.ts new file mode 100644 index 000000000..1012205f1 --- /dev/null +++ b/packages/amico-run/src/pasqal_launch.ts @@ -0,0 +1,159 @@ +// packages/amico-run/src/pasqal_launch.ts +// The `amico-pasqal` launcher (issue #168, 159/S8): hand the stored Pasqal +// credential to a connector script WITHOUT the secret ever riding a command +// line. SECURITY: the token value must never appear in any argv at any layer +// (ps-visible, transcript-persisted), in an error message, or in a log line +// (the remote_config.ts / llm_creds.mjs stance) — the child environment +// (PASQAL_TOKEN + PASQAL_PROJECT_ID) is the secret's ONLY carriage, and the +// child env is built from scratch (never a process.env spread). +import { spawn } from "node:child_process"; +import { accessSync, constants as fsConstants, existsSync, readFileSync } from "node:fs"; +import { constants as osConstants, homedir } from "node:os"; +import { delimiter, join, resolve } from "node:path"; +import { ConfigError } from "./types.js"; + +export interface PasqalCredentials { + projectId: string; + token: string; // always a real string — no nulls at rest (slice #162 contract) + expiresAt?: string; // ISO 8601; absent = the panel stored no expiry +} + +/** $AMICO_PASQAL_FILE overrides the path (tests) — the cloudConfigFile idiom. */ +export function pasqalCredentialFile(env: NodeJS.ProcessEnv = process.env): string { + const v = env.AMICO_PASQAL_FILE; + if (v && v.trim() !== "") return v; + return join(homedir(), ".amico", "pasqal.json"); +} + +/** Read + shape-check the credential file. Distinct, actionable, TOKEN-FREE errors + * (AC3): the launcher never prompts and never mints — absent/expired credentials + * are the Connections panel's job to fix. Expiry is checked separately + * (assertPasqalFresh) so parse-level consumers (golden-fixture tests) stay + * time-independent. All failures are ConfigError — exit-64 class: nothing ran. */ +export function readPasqalCredentials(env: NodeJS.ProcessEnv = process.env): PasqalCredentials { + const file = pasqalCredentialFile(env); + if (!existsSync(file)) + throw new ConfigError(`not connected — add Pasqal credentials in the Connections panel (no credential file at ${file})`); + let raw: unknown; + try { + raw = JSON.parse(readFileSync(file, "utf8")); + } catch { + throw new ConfigError(`malformed Pasqal credential file at ${file} — reconnect in the Connections panel to rewrite it`); + } + const d = (typeof raw === "object" && raw !== null ? raw : {}) as Record; + // Shape errors name KEYS only — a value could be a mistyped secret. + if (typeof d.project_id !== "string" || d.project_id === "" || typeof d.token !== "string" || d.token === "") + throw new ConfigError( + `Pasqal credential file at ${file} needs non-empty string keys "project_id" and "token" — reconnect in the Connections panel`, + ); + if (d.expires_at !== undefined && (typeof d.expires_at !== "string" || Number.isNaN(Date.parse(d.expires_at)))) + throw new ConfigError( + `Pasqal credential file at ${file} has an unreadable "expires_at" — reconnect in the Connections panel`, + ); + return { projectId: d.project_id, token: d.token, expiresAt: d.expires_at as string | undefined }; +} + +/** Expired token → distinct actionable error (AC3). The expiry timestamp is not a + * secret; the token value never appears. `now` is injectable for tests. */ +export function assertPasqalFresh(creds: PasqalCredentials, now: Date = new Date()): void { + if (creds.expiresAt !== undefined && Date.parse(creds.expiresAt) <= now.getTime()) + throw new ConfigError( + `Pasqal token expired at ${creds.expiresAt} — reconnect in the Connections panel to mint a fresh one`, + ); +} + +/** Interpreter resolution: $AMICO_PYTHON override (the documented escape hatch for + * GUI-launched macOS PATH issues), else python3 on PATH. Returns an ABSOLUTE path + * so the spawn is deterministic. Misconfiguration here is exit-64 class — clearly + * distinct from the connector's own exit-3 "service unreachable", which the + * launcher passes through untouched. */ +export function resolvePasqalInterpreter(env: NodeJS.ProcessEnv = process.env): string { + const override = env.AMICO_PYTHON; + const bin = override && override.trim() !== "" ? override : "python3"; + const candidates = bin.includes("/") + ? [resolve(bin)] + : (env.PATH ?? "") + .split(delimiter) + .filter(Boolean) + .map((d) => join(d, bin)); + for (const c of candidates) { + try { + accessSync(c, fsConstants.X_OK); + return c; + } catch { + /* keep looking */ + } + } + throw override + ? new ConfigError(`AMICO_PYTHON interpreter not found or not executable: ${override}`) + : new ConfigError(`python3 not found on PATH — install Python 3, or set AMICO_PYTHON to your interpreter`); +} + +function signalCode(signal: NodeJS.Signals | null): number { + const n = signal ? (osConstants.signals as Record)[signal] : undefined; + return 128 + (n ?? 1); +} + +const USAGE = `usage: amico-pasqal [args…] + credential file: $AMICO_PASQAL_FILE, else ~/.amico/pasqal.json + interpreter: $AMICO_PYTHON, else python3 on PATH +The Pasqal token + project id reach the connector in ENV ONLY +(PASQAL_TOKEN, PASQAL_PROJECT_ID) — never on any command line. +The launcher takes NO secret arguments and passes everything after +the script through to the connector verbatim.`; + +/** The launcher body. Config-class failures print one actionable line and exit 64 + * (nothing ran); once the connector is spawned, its exit code passes through. */ +export async function pasqalLaunch(argv: string[], env: NodeJS.ProcessEnv = process.env): Promise { + const head = argv[0]; + if (head === "--help" || head === "-h") { + console.log(USAGE); + return 0; + } + if (!head) { + console.error(`amico-pasqal: no connector script given\n${USAGE}`); + return 64; + } + if (head.startsWith("-")) { + // No launcher flags exist by design — nothing can put a secret on OUR argv. + console.error(`amico-pasqal: unknown flag ${head} (the launcher takes no flags)\n${USAGE}`); + return 64; + } + const script = resolve(head); + if (!existsSync(script)) { + console.error(`amico-pasqal: connector script not found: ${script}`); + return 64; + } + + let creds: PasqalCredentials; + let python: string; + try { + creds = readPasqalCredentials(env); + assertPasqalFresh(creds); + python = resolvePasqalInterpreter(env); + } catch (e) { + if (e instanceof ConfigError) { + console.error(`amico-pasqal: ${e.message}`); + return 64; + } + throw e; + } + + // Minimal child env, built from scratch: the resolved PATH (shebang/interpreter + // resolution inside the child) + the credential pair. NOTHING else crosses over. + const childEnv: Record = { + PASQAL_TOKEN: creds.token, + PASQAL_PROJECT_ID: creds.projectId, + }; + if (env.PATH) childEnv.PATH = env.PATH; + + return new Promise((resolveP) => { + const child = spawn(python, [script, ...argv.slice(1)], { stdio: "inherit", env: childEnv }); + child.on("error", (e) => { + // Post-resolution spawn fault (raced deletion, EACCES) — still config-class. + console.error(`amico-pasqal: failed to start interpreter: ${(e as NodeJS.ErrnoException).code ?? "spawn error"}`); + resolveP(64); + }); + child.on("close", (code, signal) => resolveP(code ?? signalCode(signal))); + }); +} diff --git a/packages/amico-run/test/cross_repo_fixtures.test.ts b/packages/amico-run/test/cross_repo_fixtures.test.ts new file mode 100644 index 000000000..e14af5b8c --- /dev/null +++ b/packages/amico-run/test/cross_repo_fixtures.test.ts @@ -0,0 +1,59 @@ +// packages/amico-run/test/cross_repo_fixtures.test.ts — the amicode half of the +// cross-repo golden-fixture handshake (issue #168 AC4, corpus minted by #162). +// +// PROVENANCE: test/fixtures/credentials/{cloud.json,pasqal.json} are vendored +// BYTE-FOR-BYTE from the opencode fork's canonical corpus at +// packages/opencode/test/server/fixtures/credentials/cloud.json +// packages/opencode/test/server/fixtures/credentials/pasqal.json +// (source of record; do not hand-edit the vendored copies). Both sides pin the +// same bytes: if either repo drifts — key order, whitespace, the 2-space-JSON + +// trailing-newline at-rest serialization, or any value — THIS test fails before +// the panels can disagree about what a credential file looks like. +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { readRemoteConfig } from "../src/remote_config.js"; +import { readPasqalCredentials } from "../src/pasqal_launch.js"; + +const FIXTURES = join(__dirname, "fixtures", "credentials"); + +// The canonical at-rest bytes (2-space JSON + trailing newline), spelled out so a +// drifted vendored copy AND a drifted serializer both fail loudly. +const CLOUD_BYTES = `{ + "base_url": "https://solves.staging.harmoniqs.co", + "token": "tok-fixture-company-compute" +} +`; +const PASQAL_BYTES = `{ + "project_id": "proj-fixture-pasqal", + "token": "tok-fixture-pasqal", + "expires_at": "2026-08-01T00:00:00Z" +} +`; + +describe("cross-repo golden fixtures (AC4) — the #162 corpus, parsed by the REAL readers", () => { + it("cloud.json: vendored bytes are exactly the canonical serialization", () => { + expect(readFileSync(join(FIXTURES, "cloud.json"), "utf8")).toBe(CLOUD_BYTES); + }); + + it("cloud.json: the real readRemoteConfig (via AMICO_CLOUD_FILE) parses it to the expected {baseUrl, token}", () => { + const c = readRemoteConfig({ AMICO_CLOUD_FILE: join(FIXTURES, "cloud.json") } as NodeJS.ProcessEnv); + expect(c).toEqual({ + baseUrl: "https://solves.staging.harmoniqs.co", + token: "tok-fixture-company-compute", + }); + }); + + it("pasqal.json: vendored bytes are exactly the canonical serialization", () => { + expect(readFileSync(join(FIXTURES, "pasqal.json"), "utf8")).toBe(PASQAL_BYTES); + }); + + it("pasqal.json: the real readPasqalCredentials (via AMICO_PASQAL_FILE) parses it — expiry deliberately NOT checked here (parse-level, time-independent)", () => { + const c = readPasqalCredentials({ AMICO_PASQAL_FILE: join(FIXTURES, "pasqal.json") } as NodeJS.ProcessEnv); + expect(c).toEqual({ + projectId: "proj-fixture-pasqal", + token: "tok-fixture-pasqal", + expiresAt: "2026-08-01T00:00:00Z", + }); + }); +}); diff --git a/packages/amico-run/test/fixtures/credentials/cloud.json b/packages/amico-run/test/fixtures/credentials/cloud.json new file mode 100644 index 000000000..c1575b4c0 --- /dev/null +++ b/packages/amico-run/test/fixtures/credentials/cloud.json @@ -0,0 +1,4 @@ +{ + "base_url": "https://solves.staging.harmoniqs.co", + "token": "tok-fixture-company-compute" +} diff --git a/packages/amico-run/test/fixtures/credentials/pasqal.json b/packages/amico-run/test/fixtures/credentials/pasqal.json new file mode 100644 index 000000000..7a4937a97 --- /dev/null +++ b/packages/amico-run/test/fixtures/credentials/pasqal.json @@ -0,0 +1,5 @@ +{ + "project_id": "proj-fixture-pasqal", + "token": "tok-fixture-pasqal", + "expires_at": "2026-08-01T00:00:00Z" +} diff --git a/packages/amico-run/test/pasqal_cli.test.ts b/packages/amico-run/test/pasqal_cli.test.ts new file mode 100644 index 000000000..7e93b01da --- /dev/null +++ b/packages/amico-run/test/pasqal_cli.test.ts @@ -0,0 +1,79 @@ +// packages/amico-run/test/pasqal_cli.test.ts — the `amico-pasqal` BIN end-to-end +// (issue #168): esbuild bundle → node dist/amico-pasqal.js, the cli.test.ts idiom. +// The unit contract lives in pasqal_launch.test.ts; this file proves the wiring — +// entry point, exit-code relay, stderr carriage — through the shipped artifact. +import { describe, it, expect, beforeAll } from "vitest"; +import { execFileSync } from "node:child_process"; +import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpRoot } from "./helpers.js"; + +const BUNDLE = join(__dirname, "..", "dist", "amico-pasqal.js"); +beforeAll(() => { + execFileSync("node", [join(__dirname, "..", "esbuild.config.mjs")], { cwd: join(__dirname, "..") }); +}); + +const TOKEN = "tok-sekret-bin-layer"; +const PROJECT = "proj-bin-layer"; + +function run(args: string[], env: Record = {}): { code: number; stdout: string; stderr: string } { + try { + const stdout = execFileSync("node", [BUNDLE, ...args], { encoding: "utf8", env: { ...process.env, ...env } }); + return { code: 0, stdout, stderr: "" }; + } catch (e) { + const err = e as { status?: number; stdout?: string; stderr?: string }; + return { code: err.status ?? -1, stdout: err.stdout ?? "", stderr: err.stderr ?? "" }; + } +} + +describe("amico-pasqal bin (bundle e2e)", () => { + it("happy lane: env-injects the credential pair into the connector, exit 0, silent stderr", () => { + const root = tmpRoot(); + const out = join(root, "record.json"); + const shim = join(root, "fake-python"); + writeFileSync( + shim, + `#!/usr/bin/env node\nrequire("node:fs").writeFileSync(${JSON.stringify(out)}, JSON.stringify({ argv: process.argv, env: process.env }));\n`, + ); + chmodSync(shim, 0o755); + const cred = join(root, "pasqal.json"); + writeFileSync(cred, JSON.stringify({ project_id: PROJECT, token: TOKEN }, null, 2) + "\n"); + const script = join(root, "connector.py"); + writeFileSync(script, "# fake connector\n"); + + const r = run([script, "--devices", "FRESNEL"], { AMICO_PASQAL_FILE: cred, AMICO_PYTHON: shim }); + expect(r.code).toBe(0); + expect(r.stderr).toBe(""); + const rec = JSON.parse(readFileSync(out, "utf8")) as { argv: string[]; env: Record }; + expect(rec.env.PASQAL_TOKEN).toBe(TOKEN); + expect(rec.env.PASQAL_PROJECT_ID).toBe(PROJECT); + expect(rec.argv.slice(2)).toEqual([script, "--devices", "FRESNEL"]); + for (const a of rec.argv) expect(a).not.toContain(TOKEN); // AC2 holds at the bin layer too + }); + + it("missing credential file → 64, 'not connected … Connections panel' on stderr", () => { + const root = tmpRoot(); + const script = join(root, "connector.py"); + writeFileSync(script, "# fake connector\n"); + const r = run([script], { AMICO_PASQAL_FILE: join(root, "absent.json") }); + expect(r.code).toBe(64); + expect(r.stderr).toMatch(/not connected/); + expect(r.stderr).toMatch(/Connections panel/); + }); + + it("--help → 0 and documents the env-only contract; a bare call is a usage error (64)", () => { + const help = run(["--help"]); + expect(help.code).toBe(0); + expect(help.stdout).toMatch(/PASQAL_TOKEN, PASQAL_PROJECT_ID/); + expect(help.stdout).toMatch(/ENV ONLY/); + expect(run([]).code).toBe(64); + }); + + it("the bin is declared and its launcher script exists (package wiring)", () => { + const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf8")) as { + bin: Record; + }; + expect(pkg.bin["amico-pasqal"]).toBe("./launcher/amico-pasqal"); + expect(existsSync(join(__dirname, "..", "launcher", "amico-pasqal"))).toBe(true); + }); +}); diff --git a/packages/amico-run/test/pasqal_launch.test.ts b/packages/amico-run/test/pasqal_launch.test.ts new file mode 100644 index 000000000..86ebf8843 --- /dev/null +++ b/packages/amico-run/test/pasqal_launch.test.ts @@ -0,0 +1,260 @@ +// packages/amico-run/test/pasqal_launch.test.ts — the amico-pasqal launcher (issue #168, 159/S8). +// SECURITY CONTRACT under test: the Pasqal token travels to the connector in ENV ONLY +// (PASQAL_TOKEN + PASQAL_PROJECT_ID) — never in any argv at any layer (ps-visible, +// transcript-persisted), and never in an error message. The fake-interpreter shim +// records its argv + env to a file so the assertions are adversarial, not trusting. +import { describe, it, expect } from "vitest"; +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { delimiter, join } from "node:path"; +import { tmpRoot } from "./helpers.js"; +import { + assertPasqalFresh, + pasqalCredentialFile, + pasqalLaunch, + readPasqalCredentials, + resolvePasqalInterpreter, +} from "../src/pasqal_launch.js"; +import { ConfigError } from "../src/types.js"; + +const TOKEN = "tok-sekret-do-not-print"; +const PROJECT = "proj-11111111-2222-3333-4444-555555555555"; + +/** Write a valid credential file (2-space JSON + trailing newline, the at-rest shape). */ +function credFile(dir: string, overrides: Record = {}): string { + const p = join(dir, "pasqal.json"); + const body = { project_id: PROJECT, token: TOKEN, expires_at: "2099-01-01T00:00:00Z", ...overrides }; + writeFileSync(p, JSON.stringify(body, null, 2) + "\n"); + return p; +} + +/** Fake-interpreter shim: an executable that records {argv, env} to outFile, then runs body. + * The record path is BAKED INTO the script — the minimal child env carries no test plumbing. */ +function fakeInterpreter(dir: string, name: string, outFile: string, body = ""): string { + const p = join(dir, name); + writeFileSync( + p, + `#!/usr/bin/env node\n` + + `require("node:fs").writeFileSync(${JSON.stringify(outFile)}, ` + + `JSON.stringify({ argv: process.argv, env: process.env }));\n` + + `${body}\n`, + ); + chmodSync(p, 0o755); + return p; +} + +/** A do-nothing connector script for the shim to "run". */ +function connectorScript(dir: string): string { + const p = join(dir, "connector.py"); + writeFileSync(p, "# fake pasqal connector\n"); + return p; +} + +/** Hermetic launch env: only what the test explicitly grants. PATH is passed through so + * the node-shebang shim resolves; a canary proves nothing else may leak into the child. */ +function launchEnv(cred: string, extra: Record = {}): NodeJS.ProcessEnv { + return { + PATH: process.env.PATH, + AMICO_PASQAL_FILE: cred, + AMICO_TEST_CANARY: "must-never-reach-the-child", + ...extra, + } as NodeJS.ProcessEnv; +} + +describe("amico-pasqal launcher — AC1: exact env contract via fake-interpreter shim", () => { + it("spawns [passthrough] with EXACTLY the minimal child env", async () => { + const root = tmpRoot(); + const out = join(root, "record.json"); + const shim = fakeInterpreter(root, "fake-python", out); + const cred = credFile(root); + const script = connectorScript(root); + + const code = await pasqalLaunch([script, "--devices", "FRESNEL"], launchEnv(cred, { AMICO_PYTHON: shim })); + expect(code).toBe(0); + + const rec = JSON.parse(readFileSync(out, "utf8")) as { argv: string[]; env: Record }; + // env carries the credential pair (the declared contract, slice #164) + expect(rec.env.PASQAL_TOKEN).toBe(TOKEN); + expect(rec.env.PASQAL_PROJECT_ID).toBe(PROJECT); + // MINIMAL env: PATH + the two PASQAL vars and NOTHING else (never a process.env spread). + // Keys starting with "_" are platform noise (e.g. macOS __CF_*), not launcher carriage. + const keys = Object.keys(rec.env).filter((k) => !k.startsWith("_")); + expect(keys.sort()).toEqual(["PASQAL_PROJECT_ID", "PASQAL_TOKEN", "PATH"]); + expect(rec.env.AMICO_TEST_CANARY).toBeUndefined(); + expect(rec.env.AMICO_PASQAL_FILE).toBeUndefined(); + // argv: exactly the connector script + passthrough args, nothing injected + expect(rec.argv.slice(2)).toEqual([script, "--devices", "FRESNEL"]); + }); + + it("resolves python3 from PATH when AMICO_PYTHON is unset (first hit wins)", async () => { + const root = tmpRoot(); + const bin = join(root, "bin"); + mkdirSync(bin); + const out = join(root, "record.json"); + fakeInterpreter(bin, "python3", out); + const cred = credFile(root); + const script = connectorScript(root); + + const env = launchEnv(cred); + env.PATH = `${bin}${delimiter}${process.env.PATH}`; + const code = await pasqalLaunch([script], env); + expect(code).toBe(0); + const rec = JSON.parse(readFileSync(out, "utf8")) as { argv: string[] }; + // the launcher spawned OUR python3 (resolved to its absolute path), not some other one + expect(rec.argv[1]).toBe(join(bin, "python3")); + }); + + it("child exit code passes through verbatim (connector exit-3 'unreachable' stays 3)", async () => { + const root = tmpRoot(); + const out = join(root, "record.json"); + const shim = fakeInterpreter(root, "fake-python", out, "process.exit(3);"); + const code = await pasqalLaunch([connectorScript(root)], launchEnv(credFile(root), { AMICO_PYTHON: shim })); + expect(code).toBe(3); + }); + + it("expires_at is optional — a credential file without it launches", async () => { + const root = tmpRoot(); + const out = join(root, "record.json"); + const shim = fakeInterpreter(root, "fake-python", out); + const p = join(root, "pasqal.json"); + writeFileSync(p, JSON.stringify({ project_id: PROJECT, token: TOKEN }, null, 2) + "\n"); + const code = await pasqalLaunch([connectorScript(root)], launchEnv(p, { AMICO_PYTHON: shim })); + expect(code).toBe(0); + expect(existsSync(out)).toBe(true); + }); + + it("default credential path is ~/.amico/pasqal.json; $AMICO_PASQAL_FILE overrides", () => { + expect(pasqalCredentialFile({} as NodeJS.ProcessEnv)).toMatch(/\.amico\/pasqal\.json$/); + expect(pasqalCredentialFile({ AMICO_PASQAL_FILE: "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/x/y.json" } as NodeJS.ProcessEnv)).toBe("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/x/y.json"); + }); +}); + +describe("amico-pasqal launcher — AC2: no secret in any argv at any layer (adversarial)", () => { + it("no recorded argv element contains the token (or any part of it)", async () => { + const root = tmpRoot(); + const out = join(root, "record.json"); + const shim = fakeInterpreter(root, "fake-python", out); + const code = await pasqalLaunch( + [connectorScript(root), "--devices", "FRESNEL"], + launchEnv(credFile(root), { AMICO_PYTHON: shim }), + ); + expect(code).toBe(0); + const rec = JSON.parse(readFileSync(out, "utf8")) as { argv: string[] }; + for (const a of rec.argv) { + expect(a).not.toContain(TOKEN); + expect(a).not.toContain("sekret"); // no substring smuggling either + } + }); + + it("the launcher accepts NO secret-bearing flags — a leading flag is a usage error, nothing spawns", async () => { + const root = tmpRoot(); + const out = join(root, "record.json"); + const shim = fakeInterpreter(root, "fake-python", out); + const code = await pasqalLaunch( + ["--token", TOKEN, connectorScript(root)], + launchEnv(credFile(root), { AMICO_PYTHON: shim }), + ); + expect(code).toBe(64); + expect(existsSync(out)).toBe(false); // the shim never ran + }); +}); + +describe("amico-pasqal launcher — AC3: distinct actionable errors, all token-free", () => { + /** Capture the ConfigError message a thunk throws (fails the test if it doesn't throw). */ + function configErrorMessage(fn: () => unknown): string { + try { + fn(); + } catch (e) { + expect(e).toBeInstanceOf(ConfigError); + return (e as Error).message; + } + expect.unreachable("expected a ConfigError"); + return ""; + } + + it("missing credential file → 'not connected', points at the Connections panel", () => { + const root = tmpRoot(); + const msg = configErrorMessage(() => + readPasqalCredentials({ AMICO_PASQAL_FILE: join(root, "absent.json") } as NodeJS.ProcessEnv), + ); + expect(msg).toMatch(/not connected/); + expect(msg).toMatch(/Connections panel/); + expect(msg).toContain(join(root, "absent.json")); + }); + + it("unparseable credential file → 'malformed', distinct from 'not connected'", () => { + const root = tmpRoot(); + const p = join(root, "pasqal.json"); + writeFileSync(p, "{nope"); + const msg = configErrorMessage(() => readPasqalCredentials({ AMICO_PASQAL_FILE: p } as NodeJS.ProcessEnv)); + expect(msg).toMatch(/malformed/); + expect(msg).toMatch(/reconnect/i); + expect(msg).not.toMatch(/not connected/); + }); + + it("wrong-shape file → names the KEYS, never a value (a value could be a mistyped secret)", () => { + const root = tmpRoot(); + const p = join(root, "pasqal.json"); + writeFileSync(p, JSON.stringify({ project_id: PROJECT, token: 42 })); + const msg = configErrorMessage(() => readPasqalCredentials({ AMICO_PASQAL_FILE: p } as NodeJS.ProcessEnv)); + expect(msg).toMatch(/"project_id" and "token"/); + expect(msg).not.toContain(PROJECT); + // null token at rest is a shape violation too (#162: token always a real string) + writeFileSync(p, JSON.stringify({ project_id: PROJECT, token: null })); + expect(() => readPasqalCredentials({ AMICO_PASQAL_FILE: p } as NodeJS.ProcessEnv)).toThrow(ConfigError); + }); + + it("expired token → 'expired … reconnect'; the token value NEVER appears", () => { + const creds = { projectId: PROJECT, token: TOKEN, expiresAt: "2020-01-01T00:00:00Z" }; + let msg = ""; + try { + assertPasqalFresh(creds, new Date("2026-07-19T00:00:00Z")); + } catch (e) { + expect(e).toBeInstanceOf(ConfigError); + msg = (e as Error).message; + } + expect(msg).toMatch(/expired at 2020-01-01T00:00:00Z/); + expect(msg).toMatch(/reconnect/); + expect(msg).not.toContain(TOKEN); + // a still-fresh expiry is quiet + expect(() => assertPasqalFresh(creds, new Date("2019-01-01T00:00:00Z"))).not.toThrow(); + // no expires_at at rest = never locally expired (the panel owns freshness) + expect(() => assertPasqalFresh({ projectId: PROJECT, token: TOKEN })).not.toThrow(); + }); + + it("expired credential file → launch exits 64 and the connector never spawns", async () => { + const root = tmpRoot(); + const out = join(root, "record.json"); + const shim = fakeInterpreter(root, "fake-python", out); + const cred = credFile(root, { expires_at: "2020-01-01T00:00:00Z" }); + const code = await pasqalLaunch([connectorScript(root)], launchEnv(cred, { AMICO_PYTHON: shim })); + expect(code).toBe(64); + expect(existsSync(out)).toBe(false); + }); + + it("AMICO_PYTHON pointing nowhere → error NAMES the override (misconfigured ≠ unreachable)", () => { + const msg = configErrorMessage(() => + resolvePasqalInterpreter({ AMICO_PYTHON: "/no/such/python", PATH: process.env.PATH } as NodeJS.ProcessEnv), + ); + expect(msg).toMatch(/AMICO_PYTHON/); + expect(msg).toContain("/no/such/python"); + }); + + it("no python3 on PATH → distinct error suggesting AMICO_PYTHON (the GUI-launch escape hatch)", () => { + const root = tmpRoot(); + const emptyBin = join(root, "empty"); + mkdirSync(emptyBin); + const msg = configErrorMessage(() => resolvePasqalInterpreter({ PATH: emptyBin } as NodeJS.ProcessEnv)); + expect(msg).toMatch(/python3 not found on PATH/); + expect(msg).toMatch(/AMICO_PYTHON/); + }); + + it("interpreter misconfiguration exits 64 — cleanly distinct from the connector's exit-3 unreachable lane", async () => { + const root = tmpRoot(); + const emptyBin = join(root, "empty"); + mkdirSync(emptyBin); + const env = launchEnv(credFile(root)); + env.PATH = emptyBin; // no python3, no AMICO_PYTHON + const code = await pasqalLaunch([connectorScript(root)], env); + expect(code).toBe(64); // vs. exit 3 passthrough covered under AC1 + }); +}); diff --git a/packages/extension/opencode.lock.json b/packages/extension/opencode.lock.json index 7bc0732bf..8c84997ba 100644 --- a/packages/extension/opencode.lock.json +++ b/packages/extension/opencode.lock.json @@ -1,17 +1,17 @@ { "version": "1.17.3", "repo": "harmoniqs/opencode", - "tag": "v1.17.3-amicode.5", + "tag": "v1.17.3-amicode.6", "source": "release", - "ref": "d6ffa97cd22f00f07c5eb2242ceeff7df947fa77", + "ref": "f0b78b669b61e5c6fd38fa47f531f85b6daf0681", "platforms": { "darwin-arm64": { "asset": "opencode-darwin-arm64.zip", - "sha256": "010b5df27a25f47e54b9415719eea69f4a56fdd62c7e8ba6d3de22f30ada9472" + "sha256": "c48ef5cf21041a0ec5195f156ff1ce70ce05e2b3978d658758a851aafb910097" }, "linux-x64": { "asset": "opencode-linux-x64.tar.gz", - "sha256": "f70f0fd85075cda18fd88b9570a212f2b0c3a9b66d0bc7001afed18510da79e6" + "sha256": "f5706510fc04243388af3ee814a864fc83b1015d5eb8368beec3e61a219046ca" } } } diff --git a/packages/extension/package.json b/packages/extension/package.json index cb1ae726c..59d2f2c41 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -184,8 +184,8 @@ }, "amicode.cloudUrl": { "type": "string", - "default": "https://qy2gwqy5s5.execute-api.us-east-1.amazonaws.com", - "description": "Base URL of the Amico cloud Solve Service, used by \"Amico: Connect Cloud\" to validate your API key and written into ~/.amico/cloud.json (where RemoteExecutor reads it). Empty = the built-in production endpoint." + "default": "", + "description": "Base URL of the Amico cloud Solve Service. \"Amico: Connect Cloud\" submits it with your API key to the local Amico server, which validates and stores both (~/.amico/cloud.json, where RemoteExecutor reads them). Empty = the built-in production endpoint (DEFAULT_CLOUD_URL in cloud_key.ts — the single source)." }, "amicode.runsRoot": { "type": "string", diff --git a/packages/extension/scripts/pasqal-connector/.gitignore b/packages/extension/scripts/pasqal-connector/.gitignore new file mode 100644 index 000000000..7a60b85e1 --- /dev/null +++ b/packages/extension/scripts/pasqal-connector/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/packages/extension/scripts/pasqal-connector/pasqal_validate.py b/packages/extension/scripts/pasqal-connector/pasqal_validate.py new file mode 100644 index 000000000..ef46086ef --- /dev/null +++ b/packages/extension/scripts/pasqal-connector/pasqal_validate.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +"""Auth-only Pasqal Cloud validation: authenticate, mint a token, list devices. + +Validates credentials against Pasqal Cloud without building a sequence or +submitting any job. On success prints EXACTLY ONE JSON line to stdout: + + {"ok": true, "project_id": ..., "devices": [...], + "token": , "expires_at": } + +The bearer token is extracted best-effort through the SDK's token-provider +mechanism; when the installed SDK exposes no way to obtain it, "token" is +null — the consumer's signal to fall back to session-only handling. This +script never persists anything. + +Credentials are read from environment variables ONLY (never from argv, never +written to disk): PASQAL_USERNAME, PASQAL_PASSWORD, PASQAL_PROJECT_ID. The +caller (the connections panel's validator route) is expected to set these for +this invocation alone. + +Exit codes: 0 success; 1 missing environment variable; 2 authentication +failure; 3 network failure / service unreachable; 4 project-authorization +failure. Failure messages are fixed strings — exception text is never +echoed, so neither passwords nor tokens can leak to an output stream. +""" + +import base64 +import json +import os +import sys +from datetime import datetime, timezone + +EXIT_MISSING_ENV = 1 +EXIT_AUTH_FAILURE = 2 +EXIT_NETWORK_FAILURE = 3 +EXIT_PROJECT_FAILURE = 4 + +MSG_AUTH_FAILURE = "error: Pasqal Cloud authentication failed (invalid credentials)" +MSG_NETWORK_FAILURE = "error: Pasqal Cloud is unreachable (network failure)" +MSG_PROJECT_FAILURE = ( + "error: Pasqal Cloud project authorization failed (check PASQAL_PROJECT_ID)" +) + + +def _require_env(name: str) -> str: + value = os.environ.get(name, "") + if not value: + print(f"error: missing required environment variable {name}", file=sys.stderr) + sys.exit(EXIT_MISSING_ENV) + return value + + +def _is_network_error(exc: BaseException) -> bool: + # requests' ConnectionError/Timeout subclass OSError; cover renames by name. + if isinstance(exc, (ConnectionError, TimeoutError, OSError)): + return True + name = type(exc).__name__ + return any(hint in name for hint in ("Connection", "Timeout", "Network", "Unreachable")) + + +def _underlying_sdk(connection): + # pasqal-cloud 0.23: PasqalCloudConnection.cloud_client is the + # PasqalCloudClient (nee SDK); older spellings kept defensively. + for attr in ("cloud_client", "_sdk", "_sdk_connection", "sdk"): + candidate = getattr(connection, attr, None) + if candidate is not None: + return candidate + return connection + + +def _list_devices(connection) -> list: + # Prefer the plain specs dict (device name -> serialized specs): it is + # pure SDK, whereas fetch_available_devices deserializes via pulser. + sdk = _underlying_sdk(connection) + specs = getattr(sdk, "get_device_specs_dict", None) + if callable(specs): + return sorted(specs()) + fetch = getattr(connection, "fetch_available_devices", None) + if callable(fetch): + available = fetch() + if isinstance(available, dict): + return sorted(available) + return sorted(getattr(device, "name", str(device)) for device in available) + return [] + + +def _find_token_provider(connection): + """Walk to the SDK's token provider (pasqal-cloud 0.23: + SDK._client.authenticator.token_provider), defensively enough that an + SDK-internal rename degrades to None — the session-only fallback — + instead of a crash.""" + sdk = _underlying_sdk(connection) + client = getattr(sdk, "_client", None) or getattr(sdk, "client", None) or sdk + for holder in (client, sdk, connection): + if holder is None: + continue + for attr in ("authenticator", "token_provider", "_token_provider"): + candidate = getattr(holder, attr, None) + if candidate is None: + continue + nested = getattr(candidate, "token_provider", None) + if nested is not None and callable(getattr(nested, "get_token", None)): + return nested + if callable(getattr(candidate, "get_token", None)): + return candidate + return None + + +def _token_expiry(provider, token: str): + # pasqal-cloud 0.23: ExpiringTokenProvider caches (expiry, token); the + # cached expiry is exact even when the token is not a decodable JWT. + cache = getattr(provider, "_ExpiringTokenProvider__token_cache", None) + if ( + isinstance(cache, tuple) + and len(cache) == 2 + and isinstance(cache[0], datetime) + ): + return cache[0].astimezone(timezone.utc).isoformat() + # Auth0 access tokens are JWTs; the exp claim is the expiry. + try: + payload_b64 = token.split(".")[1] + padded = payload_b64 + "=" * (-len(payload_b64) % 4) + payload = json.loads(base64.urlsafe_b64decode(padded)) + return datetime.fromtimestamp(float(payload["exp"]), tz=timezone.utc).isoformat() + except Exception: # noqa: BLE001 - expiry is best-effort metadata + return None + + +def _extract_token(connection): + """Best-effort (token, expires_at) via the SDK's token-provider mechanism.""" + provider = _find_token_provider(connection) + if provider is None: + return None, None + try: + token = provider.get_token() + except Exception: # noqa: BLE001 - token minting is best-effort by design + return None, None + if not isinstance(token, str) or not token: + return None, None + return token, _token_expiry(provider, token) + + +def main() -> None: + username = _require_env("PASQAL_USERNAME") + password = _require_env("PASQAL_PASSWORD") + project_id = _require_env("PASQAL_PROJECT_ID") + + # Imported lazily so the env guard above fails cleanly even where + # pasqal-cloud is not installed, and so tests can pre-inject a stub. + # Misconfigured python renders distinctly from unreachable-service. + try: + from pasqal_cloud import PasqalCloudConnection + from pasqal_cloud.authentication import TokenProviderError + except ImportError: + print( + f"error: the pasqal-cloud SDK is not installed for {sys.executable}", + file=sys.stderr, + ) + sys.exit(EXIT_MISSING_ENV) + + # Constructing the connection performs the auth handshake. Fixed messages + # only: exception text may echo credentials and must never be printed. + try: + connection = PasqalCloudConnection( + username=username, password=password, project_id=project_id + ) + except TokenProviderError: + print(MSG_AUTH_FAILURE, file=sys.stderr) + sys.exit(EXIT_AUTH_FAILURE) + except Exception as exc: # noqa: BLE001 - classified, never echoed + if _is_network_error(exc): + print(MSG_NETWORK_FAILURE, file=sys.stderr) + sys.exit(EXIT_NETWORK_FAILURE) + print(MSG_AUTH_FAILURE, file=sys.stderr) + sys.exit(EXIT_AUTH_FAILURE) + + # Auth succeeded; the device listing is the first project-scoped API call, + # so a non-network failure here means the project refused us. + try: + devices = _list_devices(connection) + except Exception as exc: # noqa: BLE001 - classified, never echoed + if _is_network_error(exc): + print(MSG_NETWORK_FAILURE, file=sys.stderr) + sys.exit(EXIT_NETWORK_FAILURE) + print(MSG_PROJECT_FAILURE, file=sys.stderr) + sys.exit(EXIT_PROJECT_FAILURE) + + token, expires_at = _extract_token(connection) + + print( + json.dumps( + { + "ok": True, + "project_id": project_id, + "devices": devices, + "token": token, + "expires_at": expires_at, + } + ) + ) + + +if __name__ == "__main__": + main() diff --git a/packages/extension/scripts/pasqal-connector/requirements.txt b/packages/extension/scripts/pasqal-connector/requirements.txt new file mode 100644 index 000000000..985275e42 --- /dev/null +++ b/packages/extension/scripts/pasqal-connector/requirements.txt @@ -0,0 +1 @@ +pasqal-cloud==0.23.0 diff --git a/packages/extension/scripts/pasqal-connector/tests/slow_live.py b/packages/extension/scripts/pasqal-connector/tests/slow_live.py new file mode 100644 index 000000000..251bdda5c --- /dev/null +++ b/packages/extension/scripts/pasqal-connector/tests/slow_live.py @@ -0,0 +1,43 @@ +"""Optional live smoke: real Pasqal Cloud, real credentials, auth only. + +Excluded from default discovery (filename does not match test_*). Run +explicitly, with pasqal-cloud installed and real credentials in env: + + PASQAL_LIVE_SMOKE=1 PASQAL_USERNAME=... PASQAL_PASSWORD=... \ + PASQAL_PROJECT_ID=... python3 -m unittest tests.slow_live -v +""" + +import json +import os +import subprocess +import sys +import unittest +from pathlib import Path + +SCRIPT = Path(__file__).resolve().parent.parent / "pasqal_validate.py" + + +@unittest.skipUnless( + os.environ.get("PASQAL_LIVE_SMOKE") == "1", + "live smoke: set PASQAL_LIVE_SMOKE=1 and real PASQAL_* credentials", +) +class TestLiveValidate(unittest.TestCase): + def test_live_auth_only_validation(self): + result = subprocess.run( + [sys.executable, str(SCRIPT)], + capture_output=True, text=True, timeout=120, + ) + self.assertEqual(result.returncode, 0, result.stderr) + lines = result.stdout.splitlines() + self.assertEqual(len(lines), 1, "stdout must be exactly one JSON line") + payload = json.loads(lines[0]) + self.assertIs(payload["ok"], True) + self.assertEqual(payload["project_id"], os.environ["PASQAL_PROJECT_ID"]) + self.assertTrue(payload["devices"], "expected at least one device") + # Never echo the token in test output on failure: assert shape only. + self.assertIn("token", payload) + self.assertIn("expires_at", payload) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/extension/scripts/pasqal-connector/tests/test_pasqal_validate.py b/packages/extension/scripts/pasqal-connector/tests/test_pasqal_validate.py new file mode 100644 index 000000000..0db9ecf10 --- /dev/null +++ b/packages/extension/scripts/pasqal-connector/tests/test_pasqal_validate.py @@ -0,0 +1,421 @@ +"""Contract tests for pasqal_validate.py: exit codes, single-JSON-line stdout, +secret hygiene. + +The pasqal_cloud SDK is fully stubbed via sys.modules injection — these tests +never touch the network and do not require pasqal-cloud to be installed. The +stub records every SDK call so tests can assert the no-job-submission +invariant, and it embeds a poison password in every exception message so +tests can prove exception text never reaches an output stream. +""" + +import base64 +import io +import json +import os +import subprocess +import sys +import types +import unittest +from contextlib import redirect_stderr, redirect_stdout +from datetime import datetime, timezone +from pathlib import Path + +CONNECTOR_DIR = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(CONNECTOR_DIR)) + +POISON_PASSWORD = "hunter2-P0ison-pa55word" +USERNAME = "kate@example.com" +PROJECT_ID = "proj-0000-aaaa-bbbb" +CRED_ENV = { + "PASQAL_USERNAME": USERNAME, + "PASQAL_PASSWORD": POISON_PASSWORD, + "PASQAL_PROJECT_ID": PROJECT_ID, +} +DEVICES = ("FRESNEL", "EMU_FREE") + + +class Recorder: + """Records the name of every SDK call the script makes (never values).""" + + def __init__(self): + self.calls = [] + + +def build_stub( + recorder, + scenario="ok", + token="tok-opaque-bearer", + provider="full", + token_cache_expiry=None, + devices=DEVICES, +): + """Build stub pasqal_cloud modules mirroring pasqal-cloud 0.23.0's real + shape: PasqalCloudConnection.cloud_client (PasqalCloudClient) ._client + (HTTPClient) .authenticator (HTTPBearerAuthenticator) .token_provider + (ExpiringTokenProvider, caching (expiry, token) in __token_cache). + + scenario: "ok" | "auth" | "network-init" | "network-devices" | "project" + provider: "full" (token provider reachable) | "absent" (SDK exposes no + way to obtain the token — the session-only fallback signal) + + Every raised exception embeds POISON_PASSWORD in its message, so any + test that finds the poison in an output stream has caught a leak. + """ + root = types.ModuleType("pasqal_cloud") + auth = types.ModuleType("pasqal_cloud.authentication") + + class TokenProviderError(Exception): + pass + + auth.TokenProviderError = TokenProviderError + root.authentication = auth + + class _TokenProvider: # mirrors ExpiringTokenProvider + def get_token(self): + recorder.calls.append("get_token") + return token + + if token_cache_expiry is not None: + setattr( + _TokenProvider, + "_ExpiringTokenProvider__token_cache", + (token_cache_expiry, token), + ) + + class _Authenticator: # mirrors HTTPBearerAuthenticator + def __init__(self): + if provider == "full": + self.token_provider = _TokenProvider() + + class _HTTPClient: + def __init__(self): + self.authenticator = _Authenticator() if provider == "full" else None + + class _PasqalCloudClient: + def __init__(self): + self._client = _HTTPClient() + + def get_device_specs_dict(self): + recorder.calls.append("get_device_specs_dict") + if scenario == "network-devices": + raise ConnectionError("connection dropped; secret=" + POISON_PASSWORD) + if scenario == "project": + raise RuntimeError( + "403: project not authorized; secret=" + POISON_PASSWORD + ) + return {name: "" for name in devices} + + class PasqalCloudConnection: + def __init__(self, **kwargs): + # Record argument NAMES only — values include the password. + recorder.calls.append("PasqalCloudConnection(%s)" % ",".join(sorted(kwargs))) + if scenario == "auth": + raise TokenProviderError( + "login denied for password=%s" % kwargs.get("password") + ) + if scenario == "network-init": + raise ConnectionError( + "cannot reach auth endpoint; password=%s" % kwargs.get("password") + ) + self.cloud_client = _PasqalCloudClient() + + def fetch_available_devices(self): + # The real method deserializes device specs via pulser; the + # validator should stay on the plain specs-dict path instead. + recorder.calls.append("fetch_available_devices") + return { + name: object() for name in self.cloud_client.get_device_specs_dict() + } + + def submit(self, *args, **kwargs): + recorder.calls.append("submit") + + class RemoteEmuFreeBackend: + def __init__(self, *args, **kwargs): + recorder.calls.append("RemoteEmuFreeBackend") + + def run(self, *args, **kwargs): + recorder.calls.append("backend.run") + + root.PasqalCloudConnection = PasqalCloudConnection + root.RemoteEmuFreeBackend = RemoteEmuFreeBackend + return {"pasqal_cloud": root, "pasqal_cloud.authentication": auth} + + +def _purge_modules(): + for name in [ + m + for m in list(sys.modules) + if m == "pasqal_cloud" or m.startswith("pasqal_cloud.") or m == "pasqal_validate" + ]: + del sys.modules[name] + + +def run_validator(env, stub=None): + """Run pasqal_validate.main() in-process with a stubbed SDK. + + Returns (exit_code, stdout, stderr). + """ + _purge_modules() + if stub is not None: + sys.modules.update(stub) + saved = {k: os.environ.pop(k) for k in list(os.environ) if k.startswith("PASQAL_")} + os.environ.update(env) + stdout, stderr = io.StringIO(), io.StringIO() + code = 0 + try: + with redirect_stdout(stdout), redirect_stderr(stderr): + import pasqal_validate + + try: + pasqal_validate.main() + except SystemExit as exc: + code = int(exc.code or 0) + finally: + for key in env: + os.environ.pop(key, None) + os.environ.update(saved) + _purge_modules() + return code, stdout.getvalue(), stderr.getvalue() + + +def make_jwt(exp_epoch): + def b64(obj): + return base64.urlsafe_b64encode(json.dumps(obj).encode()).rstrip(b"=") + + return b".".join([b64({"alg": "none"}), b64({"exp": exp_epoch}), b"sig"]).decode() + + +class TestValidCredentials(unittest.TestCase): + """AC1: valid credentials → exit 0 + one JSON line (project, devices, token).""" + + def test_exit_zero_and_single_json_line(self): + recorder = Recorder() + code, stdout, stderr = run_validator(CRED_ENV, build_stub(recorder)) + self.assertEqual(code, 0, stderr) + self.assertEqual(stderr, "") + lines = stdout.splitlines() + self.assertEqual(len(lines), 1, "stdout must be exactly one JSON line") + payload = json.loads(lines[0]) + self.assertIs(payload["ok"], True) + self.assertEqual(payload["project_id"], PROJECT_ID) + self.assertEqual(payload["devices"], sorted(DEVICES)) + self.assertEqual(payload["token"], "tok-opaque-bearer") + self.assertIn("expires_at", payload) + self.assertIsNone(payload["expires_at"]) # opaque token: no expiry known + + def test_jwt_token_reports_expiry(self): + exp = 1900000000 + token = make_jwt(exp) + recorder = Recorder() + code, stdout, _ = run_validator(CRED_ENV, build_stub(recorder, token=token)) + self.assertEqual(code, 0) + payload = json.loads(stdout) + self.assertEqual(payload["token"], token) + expected = datetime.fromtimestamp(exp, tz=timezone.utc).isoformat() + self.assertEqual(payload["expires_at"], expected) + + def test_provider_token_cache_expiry_wins_over_opaque_token(self): + # pasqal-cloud's ExpiringTokenProvider caches (expiry, token); the + # cached expiry is exact even when the token is not a decodable JWT. + expiry = datetime(2027, 1, 2, 3, 4, 5, tzinfo=timezone.utc) + recorder = Recorder() + code, stdout, _ = run_validator( + CRED_ENV, build_stub(recorder, token_cache_expiry=expiry) + ) + self.assertEqual(code, 0) + payload = json.loads(stdout) + self.assertEqual(payload["expires_at"], expiry.isoformat()) + + def test_null_token_when_sdk_cannot_yield_one(self): + # The session-only fallback signal: still exit 0, token: null. + recorder = Recorder() + code, stdout, stderr = run_validator( + CRED_ENV, build_stub(recorder, provider="absent") + ) + self.assertEqual(code, 0, stderr) + payload = json.loads(stdout) + self.assertIs(payload["ok"], True) + self.assertIsNone(payload["token"]) + self.assertIsNone(payload["expires_at"]) + self.assertEqual(payload["devices"], sorted(DEVICES)) + + +class TestFailureClassification(unittest.TestCase): + """AC2: exit 2 = auth, 3 = network, 4 = project — token- and + password-free stderr, nothing on stdout.""" + + def _assert_failure(self, scenario, expected_code): + recorder = Recorder() + code, stdout, stderr = run_validator(CRED_ENV, build_stub(recorder, scenario=scenario)) + self.assertEqual(code, expected_code) + self.assertEqual(stdout, "", "failures must print nothing to stdout") + self.assertTrue(stderr.strip(), "failures must explain themselves on stderr") + self.assertNotIn(POISON_PASSWORD, stderr) + self.assertNotIn("tok-opaque-bearer", stderr) + return stderr + + def test_auth_failure_exits_2(self): + self._assert_failure("auth", 2) + + def test_network_failure_at_connect_exits_3(self): + self._assert_failure("network-init", 3) + + def test_network_failure_at_device_fetch_exits_3(self): + self._assert_failure("network-devices", 3) + + def test_project_authorization_failure_exits_4(self): + self._assert_failure("project", 4) + + def test_distinct_messages_per_failure_class(self): + messages = { + scenario: self._assert_failure(scenario, code) + for scenario, code in (("auth", 2), ("network-init", 3), ("project", 4)) + } + self.assertEqual(len(set(messages.values())), 3) + + +class TestNoSubmissionPath(unittest.TestCase): + """AC3: auth only — the stub records every call; no run/submit/job path.""" + + FORBIDDEN = ("run", "submit", "batch", "backend", "job", "sequence") + + def test_no_run_or_submit_invoked(self): + recorder = Recorder() + code, _, _ = run_validator(CRED_ENV, build_stub(recorder)) + self.assertEqual(code, 0) + # Positive control: the calls we DO expect were recorded. + self.assertIn("PasqalCloudConnection(password,project_id,username)", recorder.calls) + self.assertIn("get_device_specs_dict", recorder.calls) + self.assertIn("get_token", recorder.calls) + # Device names must come from the plain specs dict, not from + # fetch_available_devices (which deserializes specs via pulser). + self.assertNotIn("fetch_available_devices", recorder.calls) + for call in recorder.calls: + for forbidden in self.FORBIDDEN: + self.assertNotIn( + forbidden, call.lower(), + f"job-submission call path invoked: {call}", + ) + + def test_no_submission_attempted_even_on_project_failure(self): + recorder = Recorder() + run_validator(CRED_ENV, build_stub(recorder, scenario="project")) + joined = " ".join(recorder.calls).lower() + for forbidden in self.FORBIDDEN: + self.assertNotIn(forbidden, joined) + + +class TestSecretHygiene(unittest.TestCase): + """AC4: the password appears in no argv and no output stream.""" + + SCENARIOS = ("ok", "auth", "network-init", "network-devices", "project") + + def test_password_never_in_output_streams(self): + for scenario in self.SCENARIOS: + with self.subTest(scenario=scenario): + recorder = Recorder() + _, stdout, stderr = run_validator( + CRED_ENV, build_stub(recorder, scenario=scenario) + ) + self.assertNotIn(POISON_PASSWORD, stdout) + self.assertNotIn(POISON_PASSWORD, stderr) + + def test_password_never_in_argv(self): + # The script takes credentials from env ONLY: its invocation argv is + # just [interpreter, script]. Prove the in-process run never saw the + # password in argv, and that a real spawn (against an on-disk poison + # stub — never the live service) needs no secret arguments and leaks + # nothing on either stream. + recorder = Recorder() + run_validator(CRED_ENV, build_stub(recorder)) + self.assertNotIn(POISON_PASSWORD, " ".join(sys.argv)) + argv = [sys.executable, str(CONNECTOR_DIR / "pasqal_validate.py")] + self.assertNotIn(POISON_PASSWORD, " ".join(argv)) + with _spawn_stub(SPAWN_STUB_AUTH_FAIL) as stub_path: + result = _spawn_validator(argv, env_extra=CRED_ENV, pythonpath=stub_path) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertNotIn(POISON_PASSWORD, result.stdout + result.stderr) + + def test_missing_env_fails_cleanly_before_sdk_import(self): + # Spawned with no PASQAL_* vars: the env guard must fire (exit 1, + # names the variable) before any SDK import is attempted — the stub + # here would explode the run if it were imported. + argv = [sys.executable, str(CONNECTOR_DIR / "pasqal_validate.py")] + with _spawn_stub(SPAWN_STUB_IMPORT_BOMB) as stub_path: + result = _spawn_validator(argv, env_extra={}, pythonpath=stub_path) + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn("PASQAL_USERNAME", result.stderr) + self.assertEqual(result.stdout, "") + + def test_missing_sdk_renders_distinctly_not_as_traceback(self): + # Misconfigured python (no pasqal-cloud) must render as its own fixed + # error — never an uncaught traceback, never a network/auth code. + argv = [sys.executable, str(CONNECTOR_DIR / "pasqal_validate.py")] + with _spawn_stub(SPAWN_STUB_IMPORT_BOMB) as stub_path: + result = _spawn_validator(argv, env_extra=CRED_ENV, pythonpath=stub_path) + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn("pasqal-cloud", result.stderr) + self.assertNotIn("Traceback", result.stderr) + self.assertNotIn(POISON_PASSWORD, result.stderr) + self.assertEqual(result.stdout, "") + + def test_missing_password_named_specifically(self): + argv = [sys.executable, str(CONNECTOR_DIR / "pasqal_validate.py")] + with _spawn_stub(SPAWN_STUB_IMPORT_BOMB) as stub_path: + result = _spawn_validator( + argv, + env_extra={"PASQAL_USERNAME": USERNAME, "PASQAL_PROJECT_ID": PROJECT_ID}, + pythonpath=stub_path, + ) + self.assertEqual(result.returncode, 1) + self.assertIn("PASQAL_PASSWORD", result.stderr) + + +# On-disk stubs for subprocess runs: a pasqal_cloud package on PYTHONPATH +# shadows any locally installed SDK, so spawn tests stay hermetic (no +# network) even on machines where pasqal-cloud is really installed. +SPAWN_STUB_AUTH_FAIL = { + "__init__.py": ( + "from pasqal_cloud.authentication import TokenProviderError\n" + "\n" + "class PasqalCloudConnection:\n" + " def __init__(self, **kwargs):\n" + " raise TokenProviderError(\n" + " 'denied; password=%s' % kwargs.get('password'))\n" + ), + "authentication.py": "class TokenProviderError(Exception):\n pass\n", +} +SPAWN_STUB_IMPORT_BOMB = { + "__init__.py": "raise ImportError('pasqal-cloud not installed (simulated)')\n", +} + + +class _spawn_stub: + def __init__(self, files): + self.files = files + + def __enter__(self): + import tempfile + + self.tmpdir = tempfile.TemporaryDirectory() + pkg = Path(self.tmpdir.name) / "pasqal_cloud" + pkg.mkdir() + for name, body in self.files.items(): + (pkg / name).write_text(body) + return self.tmpdir.name + + def __exit__(self, *exc_info): + self.tmpdir.cleanup() + return False + + +def _spawn_validator(argv, env_extra, pythonpath): + env = {k: v for k, v in os.environ.items() if not k.startswith("PASQAL_")} + env["PYTHONPATH"] = pythonpath + env.update(env_extra) + return subprocess.run(argv, capture_output=True, text=True, env=env, timeout=60) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/extension/src/chat_panel.ts b/packages/extension/src/chat_panel.ts index 2e88e17e8..6c299cdb2 100644 --- a/packages/extension/src/chat_panel.ts +++ b/packages/extension/src/chat_panel.ts @@ -58,8 +58,9 @@ export class ChatPanel { private constructor( private readonly panel: vscode.WebviewPanel, opencodeUrl: URL, + authToken?: string, ) { - this.panel.webview.html = this.renderHtml(opencodeUrl); + this.panel.webview.html = this.renderHtml(opencodeUrl, authToken); this.panel.onDidDispose(() => this.dispose(), null, this.disposables); // Live theme bridge: editor theme changes flow extension → outer relay → // iframe → the app's setColorScheme (boot theme rides ?colorScheme=). @@ -198,7 +199,12 @@ export class ChatPanel { ); } - static openOrReveal(ctx: vscode.ExtensionContext, opencodeUrl: URL): ChatPanel { + /** `authToken` is the per-boot server credential (#163) as the app's + * `?auth_token=` bootstrap value — base64("opencode:"), from + * serverAuthToken(). The app adopts it for its authenticated-fetch path and + * strips it from the URL (entry-level history.replaceState). One value per + * activation, so revealing an existing panel never needs a re-render. */ + static openOrReveal(ctx: vscode.ExtensionContext, opencodeUrl: URL, authToken?: string): ChatPanel { if (ChatPanel.current) { ChatPanel.current.panel.reveal(vscode.ViewColumn.One); return ChatPanel.current; @@ -212,11 +218,11 @@ export class ChatPanel { localResourceRoots: [vscode.Uri.joinPath(ctx.extensionUri, "media")], }); panel.iconPath = tabIconPath(ctx); - ChatPanel.current = new ChatPanel(panel, opencodeUrl); + ChatPanel.current = new ChatPanel(panel, opencodeUrl, authToken); return ChatPanel.current; } - private renderHtml(opencodeUrl: URL): string { + private renderHtml(opencodeUrl: URL, authToken?: string): string { // CSP: allow the iframe to load opencode's localhost origin. The frame // itself is isolated, but VS Code's webview CSP needs to explicitly grant // the localhost frame-src. The nonce authorizes the one relay script below. @@ -234,6 +240,11 @@ export class ChatPanel { // inside the webview iframe reports the OS, not VS Code). const framed = new URL(opencodeUrl.href); framed.searchParams.set("colorScheme", themeKindToScheme(vscode.window.activeColorTheme.kind)); + // Per-boot server credential (#163): ride the app's own ?auth_token= + // bootstrap — its entry adopts it for every authenticated fetch and strips + // it from the URL. The iframe src is the credential's ONLY carriage here; + // it never appears in a log line or any other surface. + if (authToken) framed.searchParams.set("auth_token", authToken); return /* html */ ` diff --git a/packages/extension/src/cloud_key.ts b/packages/extension/src/cloud_key.ts index b12d2b4a1..6b8714ddd 100644 --- a/packages/extension/src/cloud_key.ts +++ b/packages/extension/src/cloud_key.ts @@ -1,67 +1,185 @@ -// Cloud API-key onboarding (amicode.setCloudKey). Pure, testable helpers for -// validating a cloud credential against the live Solve Service and shaping the -// ~/.amico/cloud.json file that packages/amico-run/src/remote_config.ts reads. +// Cloud API-key onboarding (amicode.setCloudKey) — the command half of the +// connections seam (#171). The command no longer validates, writes, or flips +// anything itself: it POSTs the credential to the LOCAL opencode server's +// connections submit route and renders the returned terminal state. The server +// (fork routes; #165 write, #167 flip) owns validate → write ~/.amico/cloud.json +// → HP entitlement + switch, so the panel and this command share ONE write path +// and ONE flip path (ADR 0001). There is deliberately no fallback to a direct +// file write here — if the server is down, the command fails actionably (AC4). // -// SECURITY (remote_config.ts stance): the token value must never appear in any -// returned error/outcome string, log line, or the request URL. It rides ONLY in -// the Authorization header — nowhere else. The tests assert this adversarially. +// SECURITY (remote_config.ts stance, unchanged): the cloud token must never +// appear in any returned outcome string, log line, or request URL. It rides +// ONLY in the JSON body of the one POST to the local server — a call +// authenticated with the #163 per-boot credential (server_auth.ts), NOT with +// the cloud token. Server-provided error text is token-redacted before it can +// surface (the tests assert this adversarially). -/** Production Solve Service base URL — the default for the `amicode.cloudUrl` - * setting (so it's configurable, not hardcoded-only). */ +/** Production Solve Service base URL — the single source (review finding 6). + * package.json's `amicode.cloudUrl` default is "" and its description points + * here ("the built-in production endpoint"); a test pins the non-duplication. */ export const DEFAULT_CLOUD_URL = "https://qy2gwqy5s5.execute-api.us-east-1.amazonaws.com"; -export type ValidationOutcome = - | { kind: "valid" } - | { kind: "invalid"; message: string } - | { kind: "error"; message: string }; +/** The one credential the command manages — the same connection id the panel + * submits, so both entry points converge on the same server-side record. */ +export const CLOUD_CONNECTION_ID = "company-compute"; + +/** The fork's one-round-trip submit route: validate → write → flip, answering + * {ok, connection:{id, state, …}, error}. */ +export const CREDENTIAL_ROUTE = "/amicode/connections/credential"; + +/** AC4 copy — the actionable failure when the LOCAL server can't be reached. + * Fixed and token-free by construction (never interpolates a caught error). */ +export const SERVER_DOWN_MESSAGE = "Amico server not running — open the Amico panel first"; + +export type SubmitOutcome = + | { kind: "connected" } // the #167 warning-free path: saved AND flipped + | { kind: "connected-warning"; message: string } // saved, but the HP flip warned (finding 1) + | { kind: "invalid"; message: string } // key rejected — not saved + | { kind: "error"; message: string } // unreachable / server error — not saved + | { kind: "server-down"; message: string }; // could not reach the LOCAL server at all /** Strip trailing slashes — keep aligned with remote_config.ts's - * `base_url.replace(/\/+$/, "")` so both halves normalize identically. */ + * `base_url.replace(/\/+$/, "")` so the server stores what its reader expects. */ function trimUrl(url: string): string { return url.replace(/\/+$/, ""); } -/** Classify the probe's HTTP status into a validation outcome. - * - * The Solve Service runs its authorizer BEFORE the handler, so: - * - 401 → the bearer was rejected: the key is INVALID (don't save). - * - 403/404 → auth PASSED (authorizer accepted); the handler then rejected - * the fake probe task (not the caller's / doesn't exist). That proves the - * key is VALID. - * - 2xx → auth passed and the handler answered: VALID. - * - anything else (5xx, gateway/network-shaped) → we can't tell: ERROR, no save. - * No branch ever includes the token in its message. */ -export function classifyValidation(status: number): ValidationOutcome { - if (status === 401) return { kind: "invalid", message: "cloud key rejected (401) — check the key and try again" }; - if (status === 403 || status === 404 || (status >= 200 && status < 300)) return { kind: "valid" }; - return { kind: "error", message: `unexpected response from the Solve Service (HTTP ${status})` }; +/** Server-provided text can adversarially echo the token (it shouldn't, but we + * don't trust it) — strip every occurrence before the text can reach a toast. */ +function redactToken(text: string, token: string): string { + return token ? text.split(token).join("[redacted]") : text; } -/** The EXACT shape remote_config.ts reads: non-empty string keys base_url + token, - * and nothing else (its readers key off these two only). base_url is trimmed to - * match remote_config's own normalization. */ -export function buildCloudConfig(baseUrl: string, token: string): { base_url: string; token: string } { - return { base_url: trimUrl(baseUrl), token }; +interface CredentialRouteResponse { + ok?: boolean; + connection?: { id?: string; state?: string }; + error?: string; } -/** Validate `token` against the live poll API BEFORE saving. Probes a fake task - * so a good bearer reaches the handler (404/403) while a bad one is 401'd by the - * authorizer. `fetchImpl` is injectable so unit tests never touch the network. - * The token rides only in the Authorization header — never in the URL. */ -export async function validateCloudKey( - baseUrl: string, - token: string, - fetchImpl: typeof fetch = fetch, -): Promise { - const url = `${trimUrl(baseUrl)}/solves/__validate__/status`; +/** POST the credential to the local server's connections submit route and map + * the one-round-trip response to a terminal outcome. `fetchImpl` is injectable + * so unit tests never touch the network. This is the command's ONLY side + * effect — no filesystem write, no entitlement grant, no switch request. */ +export async function submitCloudCredential(opts: { + /** Local opencode server base URL (extension.ts's opencodeReadyUrl). */ + serverUrl: string; + /** The #163 boot credential header value (serverAuthHeader(serverPassword)). */ + authorization: string; + /** Solve Service base URL to store (resolved amicode.cloudUrl setting). */ + baseUrl: string; + token: string; + fetchImpl?: typeof fetch; +}): Promise { + const fetchImpl = opts.fetchImpl ?? fetch; + const url = new URL(CREDENTIAL_ROUTE, opts.serverUrl).toString(); let res: Response; try { - res = await fetchImpl(url, { method: "GET", headers: { Authorization: `Bearer ${token}` } }); - } catch (e) { - // Never interpolate the caller-supplied error verbatim — it could echo the - // token (adversarial test). Report a fixed, token-free reason. - void e; - return { kind: "error", message: "could not reach the Solve Service (network error)" }; + res = await fetchImpl(url, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: opts.authorization }, + body: JSON.stringify({ id: CLOUD_CONNECTION_ID, base_url: trimUrl(opts.baseUrl), token: opts.token }), + }); + } catch { + // Never interpolate the caught error — it could echo the request body + // (adversarial test). Fixed, actionable, token-free copy only. + return { kind: "server-down", message: SERVER_DOWN_MESSAGE }; + } + + let body: CredentialRouteResponse | undefined; + try { + body = (await res.json()) as CredentialRouteResponse; + } catch { + body = undefined; + } + if (body === undefined || typeof body !== "object") { + // The route always answers JSON; anything else is the local server + // misbehaving (or a 401/403 from the #163 auth middleware). + const auth = res.status === 401 || res.status === 403 ? " — the boot credential was refused; try \"Amicode: Restart opencode server\"" : ""; + return { kind: "error", message: `unexpected response from the Amico server (HTTP ${res.status})${auth}` }; + } + + const state = body.connection?.state; + const detail = typeof body.error === "string" && body.error !== "" ? redactToken(body.error, opts.token) : undefined; + if (state === "connected") { + // #167: a saved key whose HP flip warned rides back as state "connected" + // WITH an error field — that is not a clean success (review finding 1: the + // old command showed the success toast over a failed flip). + return detail ? { kind: "connected-warning", message: detail } : { kind: "connected" }; + } + if (state === "invalid") { + return { kind: "invalid", message: "cloud key rejected — check the key and try again" }; + } + if (state === "unreachable") { + return { kind: "error", message: `Solve Service unreachable${detail ? `: ${detail}` : " — check amicode.cloudUrl and your network"}` }; + } + return { kind: "error", message: detail ?? `connection failed (HTTP ${res.status}${state ? `, state: ${state}` : ""})` }; +} + +/** The vscode.window surface the command touches — injected so the command core + * (UX contract included) is unit-testable without the VS Code host. */ +export interface CloudKeyUi { + showInputBox(options: { prompt: string; password: boolean; ignoreFocusOut: boolean }): Thenable; + withProgress(title: string, task: () => Promise): Promise | Thenable; + showInformationMessage(message: string): void; + showWarningMessage(message: string): void; + showErrorMessage(message: string): void; +} + +export interface SetCloudKeyDeps { + ui: CloudKeyUi; + /** Raw `amicode.cloudUrl` setting value; ""/blank → DEFAULT_CLOUD_URL. */ + cloudUrl: string; + /** The running local server, or undefined when it isn't up (→ AC4 copy). */ + server: { url: string; authorization: string } | undefined; + fetchImpl?: typeof fetch; + /** Token-free output-channel logging. */ + log?: (line: string) => void; +} + +/** Command core for amicode.setCloudKey: same UX as the merged command + * (password-masked input, progress notification, per-class copy), but the + * terminal state comes from the connections seam, not from local work. */ +export async function runSetCloudKeyCommand(deps: SetCloudKeyDeps): Promise { + const { ui } = deps; + // Fail fast BEFORE asking for the key: with the server down there is nothing + // the command could do with it (and no direct-write fallback exists — AC4). + if (!deps.server) { + ui.showErrorMessage(`Amicode: ${SERVER_DOWN_MESSAGE}.`); + return; + } + const server = deps.server; + + const key = await ui.showInputBox({ + prompt: "Paste your Amico cloud API key", + password: true, + ignoreFocusOut: true, + }); + if (!key || key.trim() === "") return; // empty / cancel → no-op + const token = key.trim(); + const cloudUrl = deps.cloudUrl.trim() || DEFAULT_CLOUD_URL; + + const outcome = await ui.withProgress("Amicode: connecting cloud…", () => + submitCloudCredential({ serverUrl: server.url, authorization: server.authorization, baseUrl: cloudUrl, token, fetchImpl: deps.fetchImpl }), + ); + + switch (outcome.kind) { + case "connected": + deps.log?.(`[cloud] connected via ${CREDENTIAL_ROUTE} (server owns write + HP flip); base_url=${trimUrl(cloudUrl)}`); + ui.showInformationMessage("Cloud connected — HP mode enabled (Piccolissimo + Altissimo solves)."); + return; + case "connected-warning": + // Key saved, HP flip warned — a WARNING, never the success toast (finding 1). + deps.log?.(`[cloud] key saved, but the HP flip warned: ${outcome.message}`); + ui.showWarningMessage(`Amicode: cloud key saved, but HP enable needs attention — ${outcome.message}`); + return; + case "invalid": + ui.showErrorMessage(`Amicode: ${outcome.message}`); + return; + case "server-down": + ui.showErrorMessage(`Amicode: ${outcome.message}.`); + return; + case "error": + ui.showErrorMessage(`Amicode: cloud key not saved — ${outcome.message}`); + return; } - return classifyValidation(res.status); } diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index aea8a9748..c0382cbc2 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -17,13 +17,14 @@ import { profileHasIdentity, } from "./opencode_config"; import { resolveAmicoRunBinDir, resolveRunsRoot } from "./opencode_paths"; +import { mintServerPassword, serverAuthHeader, serverAuthToken, buildServerSpawnEnv } from "./server_auth"; import { resolveLabTomlPath, checkLabToml } from "./lab_config"; import { OpencodeEventClient } from "./sse_client"; import { RunsManager } from "./runs_manager"; import { stageDemoRun } from "./demo_replay"; import { writeStopFile, savePulseTo, catalogPulsesDir, stopPlan, forceStop, runLogMtime } from "./run_controls"; -import { watchSolverMode, applyEntitlementForMode, readSolverModeState, writeSolverModeSwitching } from "./solver_mode"; -import { validateCloudKey, buildCloudConfig, DEFAULT_CLOUD_URL } from "./cloud_key"; +import { watchSolverMode, applyEntitlementForMode, readSolverModeState } from "./solver_mode"; +import { runSetCloudKeyCommand } from "./cloud_key"; import { amicodeOpsDir } from "./substrate/vault_store"; import { createLocalPersonalVault, sanitizeVaultName, suggestVaultName, shouldOfferVaultSetup } from "./substrate/vault_setup"; import { @@ -328,6 +329,19 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { } else throw e; } + // Per-boot server password (#163, ADR 0002 graft 1): arms the fork's route + // auth, which is a no-op without OPENCODE_SERVER_PASSWORD in the spawn env. + // Minted fresh each activation, held in memory only — never persisted, never + // logged. ONE value for the whole activation: respawns (solver switch, vault + // refresh, restart) reuse it, because the open chat iframe carries the boot + // credential and a mid-session rotation would strand it on 401s. + const serverPassword = mintServerPassword(); + // The extension's own calls to the server (health probe aside — ServerManager + // derives its own from the spawn env) authenticate with the matching Basic + // credential: SSE /event, the /config* signal probes, and the chat iframe + // (via the app's ?auth_token= bootstrap). + const serverAuthHeaders = { Authorization: serverAuthHeader(serverPassword) }; + if (binary !== undefined) { // amico-run is argv-only (β.1) — no AMICO_* env propagation (S37). The agent // gets the Julia project from AGENTS.md (substituted at session-copy time) @@ -339,8 +353,9 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { } // opencode owns the LLM credential (0.3): amico injects NO key into the // spawn env — opencode resolves its provider from its own env / config / - // auth.json. The spawn env carries only PATH (so amico-run resolves) and the - // amico instructions/permission config. + // auth.json. The spawn env carries only PATH (so amico-run resolves), the + // amico instructions/permission config, and the per-boot server password + // that arms the fork's route auth (#163). const configuredPort = vscode.workspace.getConfiguration("amicode").get("opencodePort", 0); if (configuredPort > 0) { opencodeChannel.appendLine(`[boot] amicode.opencodePort = ${configuredPort} (static)`); @@ -349,14 +364,15 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { binary, cwd: opencodeProject.projectDir, port: configuredPort > 0 ? configuredPort : undefined, - env: { - PATH: `${amicoRunBinDir ? amicoRunBinDir + ":" : ""}${process.env.PATH ?? ""}`, + env: buildServerSpawnEnv({ + amicoRunBinDir, + serverPassword, // Inject the amico solve workflow as opencode `instructions` (loaded for // every session regardless of its cwd) — merges over the user's global // config, so the model/provider are preserved. This is what makes the // chat actually author + run solves instead of behaving like vanilla // opencode (the session cwd is the workspace, not opencodeProject.projectDir). - OPENCODE_CONFIG_CONTENT: buildOpencodeConfigContent( + configContent: buildOpencodeConfigContent( opencodeProject.agentsPath, opencodeProject.templatePath, runsRoot, @@ -375,7 +391,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // picker still overrides per session. vscode.workspace.getConfiguration("amicode").get("defaultModel", "").trim() || resolveModelPin(), ), - }, + }), channel: opencodeChannel, }); ctx.subscriptions.push({ dispose: () => void serverManager?.stop() }); @@ -410,9 +426,10 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { binary: binary!, cwd: project2.projectDir, port: configuredPort > 0 ? configuredPort : undefined, - env: { - PATH: `${amicoRunBinDir ? amicoRunBinDir + ":" : ""}${process.env.PATH ?? ""}`, - OPENCODE_CONFIG_CONTENT: buildOpencodeConfigContent( + env: buildServerSpawnEnv({ + amicoRunBinDir, + serverPassword, // per-boot value survives the switch (chat iframe keeps its credential) + configContent: buildOpencodeConfigContent( project2.agentsPath, project2.templatePath, runsRoot, @@ -426,7 +443,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // Same pin rule as boot: only an explicit amicode.defaultModel pins. vscode.workspace.getConfiguration("amicode").get("defaultModel", "").trim() || resolveModelPin(), ), - }, + }), channel: opencodeChannel, }); serverManager.onReady((url) => { @@ -470,8 +487,13 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { opencodeChannel.appendLine(`[boot] no personal vault resolved — distiller disabled, session unpersonalized`); } - // SSE event channel — opens once opencode is healthy. - sseClient = new OpencodeEventClient({ channel: opencodeChannel, statusBar }); + // SSE event channel — opens once opencode is healthy. Carries the per-boot + // credential (#163): the fork 401s an anonymous /event. + sseClient = new OpencodeEventClient({ + channel: opencodeChannel, + statusBar, + authorization: serverAuthHeaders.Authorization, + }); ctx.subscriptions.push(sseClient); serverManager.onReady((url) => { @@ -481,12 +503,12 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // Open the chat as soon as the server is up (amicode.chat.autoOpen, // default on) — the chat IS the product's front door. if (vscode.workspace.getConfiguration("amicode").get("chat.autoOpen", true)) { - ChatPanel.openOrReveal(ctx, url); + ChatPanel.openOrReveal(ctx, url, serverAuthToken(serverPassword)); } // Surface ONE explicit LLM-provider signal at boot, read from opencode's // OWN resolution (its live /config/providers) — not a silent hang at the // chat box (Q129). Key-free; never logs a credential. - void fetchProviderSignal(url.toString()).then((sig) => { + void fetchProviderSignal(url.toString(), { headers: serverAuthHeaders }).then((sig) => { opencodeChannel.appendLine( sig.ok ? `[boot] LLM provider: configured (${sig.provider}${sig.source ? ` via ${sig.source}` : ""})` @@ -534,9 +556,10 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { binary, cwd: project2.projectDir, port: port > 0 ? port : undefined, - env: { - PATH: `${amicoRunBinDir ? amicoRunBinDir + ":" : ""}${process.env.PATH ?? ""}`, - OPENCODE_CONFIG_CONTENT: buildOpencodeConfigContent( + env: buildServerSpawnEnv({ + amicoRunBinDir, + serverPassword, // per-boot value survives the vault respawn too + configContent: buildOpencodeConfigContent( project2.agentsPath, project2.templatePath, runsRoot, @@ -548,7 +571,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { project2.mounts, vscode.workspace.getConfiguration("amicode").get("defaultModel", "").trim() || resolveModelPin(), ), - }, + }), channel: opencodeChannel, }); serverManager.onReady((url) => { @@ -737,7 +760,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // LLM provider (opencode's own resolution). if (opencodeReadyUrl) { try { - const sig = await fetchProviderSignal(opencodeReadyUrl.toString()); + const sig = await fetchProviderSignal(opencodeReadyUrl.toString(), { headers: serverAuthHeaders }); results.push({ name: "LLM creds", ok: sig.ok, @@ -764,65 +787,30 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { }; ctx.subscriptions.push(vscode.commands.registerCommand("amicode.healthcheck", () => void runHealthcheck())); - // Connect Cloud (amicode.setCloudKey): prompt for the cloud API key, VALIDATE - // it against the live Solve Service BEFORE saving, write it where - // RemoteExecutor reads it (~/.amico/cloud.json, {base_url, token}), then flip - // to HP so Piccolissimo + Altissimo (cloud) solves become usable. - // SECURITY: the token never enters a log line or the opencodeChannel — only - // the Authorization header inside validateCloudKey and the on-disk file. - const runSetCloudKey = async (): Promise => { - const key = await vscode.window.showInputBox({ - prompt: "Paste your Amico cloud API key", - password: true, - ignoreFocusOut: true, + // Connect Cloud (amicode.setCloudKey): prompt for the cloud API key and POST + // it to the local server's connections submit route (#171) — the SERVER owns + // validate → write ~/.amico/cloud.json → HP flip (#165/#167), so this command + // and the panel share ONE write path and ONE flip path (ADR 0001). No direct + // file write, entitlement grant, or switch request remains here, and there is + // no direct-write fallback when the server is down (AC4). + // SECURITY: the token never enters a log line or the opencodeChannel — it + // rides only in the request body of the #163-authenticated local call. + const runSetCloudKey = (): Promise => + runSetCloudKeyCommand({ + ui: { + showInputBox: (options) => vscode.window.showInputBox(options), + withProgress: (title, task) => + vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title }, task), + showInformationMessage: (m) => void vscode.window.showInformationMessage(m), + showWarningMessage: (m) => void vscode.window.showWarningMessage(m), + showErrorMessage: (m) => void vscode.window.showErrorMessage(m), + }, + cloudUrl: vscode.workspace.getConfiguration("amicode").get("cloudUrl", ""), + server: opencodeReadyUrl + ? { url: opencodeReadyUrl.toString(), authorization: serverAuthHeaders.Authorization } + : undefined, + log: (line) => opencodeChannel.appendLine(line), }); - if (!key || key.trim() === "") return; // empty / cancel → no-op - const token = key.trim(); - const cloudUrl = ( - vscode.workspace.getConfiguration("amicode").get("cloudUrl", "").trim() || DEFAULT_CLOUD_URL - ); - - const outcome = await vscode.window.withProgress( - { location: vscode.ProgressLocation.Notification, title: "Amicode: validating cloud key…" }, - () => validateCloudKey(cloudUrl, token), - ); - if (outcome.kind === "invalid") { - void vscode.window.showErrorMessage(`Amicode: ${outcome.message}`); - return; // do NOT save - } - if (outcome.kind === "error") { - void vscode.window.showErrorMessage(`Amicode: cloud key not saved — ${outcome.message}`); - return; // do NOT save - } - - // Valid → write ~/.amico/cloud.json (0600) with the exact remote_config shape. - const cloudFile = path.join(os.homedir(), ".amico", "cloud.json"); - try { - fs.mkdirSync(path.dirname(cloudFile), { recursive: true }); - fs.writeFileSync(cloudFile, JSON.stringify(buildCloudConfig(cloudUrl, token)) + "\n", { mode: 0o600 }); - fs.chmodSync(cloudFile, 0o600); // enforce perms even if the file pre-existed - } catch (e) { - // (e).message can't contain the token (it's never passed to fs paths). - void vscode.window.showErrorMessage(`Amicode: could not write cloud config — ${(e as Error).message}`); - return; - } - opencodeChannel.appendLine(`[cloud] connected — wrote ${cloudFile} (0600); base_url=${cloudUrl}`); - - // Flip to HP so cloud/Piccolissimo solves are actually usable: grant the - // issimo entitlement now, then request a switch so the running watcher does - // the full re-prep (project + server restart) exactly once — reusing the - // solver-mode machinery rather than reimplementing it. - try { - const ents = applyEntitlementForMode("hp", path.join(os.homedir(), ".amico", "amicode")); - opencodeChannel.appendLine(`[cloud] HP entitlement granted (codes: ${ents.codes.join(", ")})`); - writeSolverModeSwitching("hp"); - opencodeChannel.appendLine(`[cloud] HP switch requested (watcher will re-prep the session)`); - } catch (e) { - opencodeChannel.appendLine(`[cloud] HP enable failed: ${(e as Error).message}`); - } - - void vscode.window.showInformationMessage("Cloud connected — Piccolissimo + Altissimo solves enabled."); - }; ctx.subscriptions.push(vscode.commands.registerCommand("amicode.setCloudKey", () => void runSetCloudKey())); // 5. Commands @@ -843,12 +831,12 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // check and silently hang at the chat box (Q129). Ask opencode's own live // resolution (/config/providers, same signal the healthcheck uses) so the // cause is named, not hidden. Key-free. - const creds = await fetchProviderSignal(readyUrl.toString()); + const creds = await fetchProviderSignal(readyUrl.toString(), { headers: serverAuthHeaders }); if (!creds.ok) { vscode.window.showWarningMessage(`Amicode: ${creds.reason} → ${creds.fix}`); return; } - ChatPanel.openOrReveal(ctx, readyUrl); + ChatPanel.openOrReveal(ctx, readyUrl, serverAuthToken(serverPassword)); }), vscode.commands.registerCommand("amicode.openInspector", async () => { await revealInspector(); diff --git a/packages/extension/src/llm_creds.d.mts b/packages/extension/src/llm_creds.d.mts index 14f2978f2..dba9ab037 100644 --- a/packages/extension/src/llm_creds.d.mts +++ b/packages/extension/src/llm_creds.d.mts @@ -19,8 +19,10 @@ export function resolveLlmCreds(args: { providers: ProviderEntry[]; model?: stri /** No-leak boundary: strip the raw /config/providers JSON to key-free {id, source}. */ export function stripProviders(providersJson: unknown): ProviderEntry[]; -/** Async: query a running opencode server for the provider signal (no key ever returned). */ +/** Async: query a running opencode server for the provider signal (no key ever + * returned). `headers` carries the Basic credential for the per-boot server + * password (#163). */ export function fetchProviderSignal( baseUrl: string, - opts?: { fetchImpl?: typeof fetch; timeoutMs?: number }, + opts?: { fetchImpl?: typeof fetch; timeoutMs?: number; headers?: Record }, ): Promise; diff --git a/packages/extension/src/llm_creds.mjs b/packages/extension/src/llm_creds.mjs index abafceba7..2403345d5 100644 --- a/packages/extension/src/llm_creds.mjs +++ b/packages/extension/src/llm_creds.mjs @@ -72,12 +72,14 @@ export function stripProviders(providersJson) { * Used by the chat-not-ready gate (the live extension server) and the boot * probe. A fetch failure yields a not-ok signal (server unreachable) rather * than throwing. `fetchImpl` is injectable so the signal is unit-testable - * without a live server. + * without a live server. `headers` carries the Basic credential for the + * per-boot server password (#163) — without it both probes 401 once route + * auth is armed. */ -export async function fetchProviderSignal(baseUrl, { fetchImpl = fetch, timeoutMs = 4000 } = {}) { +export async function fetchProviderSignal(baseUrl, { fetchImpl = fetch, timeoutMs = 4000, headers } = {}) { const base = String(baseUrl).replace(/\/$/, ""); const getJson = async (path) => { - const r = await fetchImpl(`${base}${path}`, { signal: AbortSignal.timeout(timeoutMs) }); + const r = await fetchImpl(`${base}${path}`, { signal: AbortSignal.timeout(timeoutMs), headers }); if (!r.ok) throw new Error(`${path} → ${r.status}`); return await r.json(); }; diff --git a/packages/extension/src/server_auth.ts b/packages/extension/src/server_auth.ts new file mode 100644 index 000000000..61a98e26d --- /dev/null +++ b/packages/extension/src/server_auth.ts @@ -0,0 +1,70 @@ +import { randomBytes } from "node:crypto"; + +// ============================================================================ +// Per-boot server password (#163, ADR 0002 graft 1). +// +// The vendored fork's route auth (packages/opencode/src/server/auth.ts @ +// v1.17.3-amicode.5) only engages when OPENCODE_SERVER_PASSWORD is set in the +// server's env — without it, every route (including the amicode mutation +// routes) is open to any localhost page. The extension therefore mints a +// cryptographically random password once per activation and injects it into +// EVERY `opencode serve` spawn env. +// +// Carriage (all three read base64("opencode:"), verified at the tag): +// - the extension's own HTTP/SSE calls → `Authorization: Basic …` header +// - the chat iframe / fork app → `?auth_token=…` query param, which +// the app's entry consumes for its authenticated-fetch path and strips +// from the URL bar (history.replaceState) before rendering +// +// Lifetime: in-memory only for the life of the activation — never persisted, +// never logged. Respawns within one activation (solver-mode switch, vault +// refresh, restartServer) REUSE the value: the open chat iframe holds the +// boot credential, so a mid-session rotation would strand it on 401s. A new +// activation mints a new value. +// ============================================================================ + +/** The fork resolves its Basic-auth username from OPENCODE_SERVER_USERNAME ?? + * "opencode" — and the spawned server INHERITS the host env, so a dev override + * there must shape our credential too or every extension call 401s. */ +function serverUsername(): string { + return process.env.OPENCODE_SERVER_USERNAME || "opencode"; +} + +/** Mint the per-boot server password: 32 random bytes, base64url so it rides + * env vars and the auth_token query param unescaped. */ +export function mintServerPassword(): string { + return randomBytes(32).toString("base64url"); +} + +/** The `?auth_token=` value the fork's auth middleware and the app's entry + * bootstrap both decode: base64("opencode:"). */ +export function serverAuthToken(password: string): string { + return Buffer.from(`${serverUsername()}:${password}`).toString("base64"); +} + +/** The `Authorization` header for the extension's own calls to the server — + * mirrors the fork's ServerAuth.header (Basic, username "opencode"). */ +export function serverAuthHeader(password: string): string { + return `Basic ${serverAuthToken(password)}`; +} + +/** Build the env the extension ADDS to the opencode server spawn (the server + * inherits the host env underneath — ServerManager spreads process.env): + * PATH — amico-run launcher dir prepended, so solves run + * OPENCODE_CONFIG_CONTENT — the amico instructions/permission merge + * OPENCODE_SERVER_PASSWORD — arms the fork's route auth (this module) + * One builder for all spawn sites so no respawn path can drop the password. */ +export function buildServerSpawnEnv(opts: { + /** amico-run launcher bin dir; undefined = launcher missing (boot warns). */ + amicoRunBinDir: string | undefined; + /** buildOpencodeConfigContent(...) output for this spawn. */ + configContent: string; + /** The per-boot password from mintServerPassword(). */ + serverPassword: string; +}): Record { + return { + PATH: `${opts.amicoRunBinDir ? opts.amicoRunBinDir + ":" : ""}${process.env.PATH ?? ""}`, + OPENCODE_CONFIG_CONTENT: opts.configContent, + OPENCODE_SERVER_PASSWORD: opts.serverPassword, + }; +} diff --git a/packages/extension/src/server_manager.ts b/packages/extension/src/server_manager.ts index 2b805251a..3cd6760ae 100644 --- a/packages/extension/src/server_manager.ts +++ b/packages/extension/src/server_manager.ts @@ -2,6 +2,7 @@ import * as vscode from "vscode"; import * as cp from "node:child_process"; import * as net from "node:net"; import type { Readable } from "node:stream"; +import { serverAuthHeader } from "./server_auth"; // ============================================================================ // ServerManager — spawn `opencode serve --port=N`, wait for it to come up, @@ -73,7 +74,12 @@ export class ServerManager { this.child = undefined; }); - const ready = await waitForHealth(`http://127.0.0.1:${port}/`, 30_000); + // The probe authenticates with the credential WE injected (#163): with + // OPENCODE_SERVER_PASSWORD armed, the fork 401s an anonymous `GET /`, and + // a healthy boot would read as a 30s timeout. Derived from the same env + // the child gets, so probe and server can never disagree. + const password = this.opts.env.OPENCODE_SERVER_PASSWORD; + const ready = await waitForHealth(`http://127.0.0.1:${port}/`, 30_000, password ? serverAuthHeader(password) : undefined); if (!ready) { this.opts.channel.appendLine(`[server] opencode did not become healthy within 30s`); this.stop(); @@ -132,13 +138,13 @@ function pickFreePort(): Promise { }); } -async function waitForHealth(baseUrl: string, timeoutMs: number): Promise { +async function waitForHealth(baseUrl: string, timeoutMs: number, authorization?: string): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { try { // opencode 1.3.x serves a redirect or HTML at /; just probe for any // 2xx/3xx response on the base URL with a short timeout. - const r = await fetchWithTimeout(baseUrl, 500); + const r = await fetchWithTimeout(baseUrl, 500, authorization); if (r.ok || (r.status >= 200 && r.status < 400)) return true; } catch { // not ready yet @@ -148,11 +154,14 @@ async function waitForHealth(baseUrl: string, timeoutMs: number): Promise { +async function fetchWithTimeout(url: string, ms: number, authorization?: string): Promise { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), ms); try { - return await fetch(url, { signal: ctrl.signal }); + return await fetch(url, { + signal: ctrl.signal, + headers: authorization ? { Authorization: authorization } : undefined, + }); } finally { clearTimeout(timer); } diff --git a/packages/extension/src/solver_mode.ts b/packages/extension/src/solver_mode.ts index a8ed27667..8ce14e88c 100644 --- a/packages/extension/src/solver_mode.ts +++ b/packages/extension/src/solver_mode.ts @@ -40,15 +40,13 @@ export function writeSolverModeReady(mode: SolverMode, file: string = solverMode fs.writeFileSync(file, JSON.stringify({ mode, status: "ready", switched_at: new Date().toISOString() })); } -/** Request a switch to `mode` — the SAME {status:"switching"} write the app's - * toggle POSTs, so the running watchSolverMode picks it up and does the real - * re-prep (entitlement + project + server restart) exactly once. Used by - * amicode.setCloudKey to flip to HP after connecting cloud, without - * reimplementing the switch. */ -export function writeSolverModeSwitching(mode: SolverMode, file: string = solverModeFile()): void { - fs.mkdirSync(path.dirname(file), { recursive: true }); - fs.writeFileSync(file, JSON.stringify({ mode, status: "switching" })); -} +// NOTE: there is deliberately NO extension-side "write {status:switching}" +// helper anymore. Switch requests come from the fork server (the app's toggle +// POST, and the connections credential route's HP flip — #167); the extension +// only WATCHES for them (watchSolverMode) and answers with writeSolverModeReady. +// amicode.setCloudKey used to own such a helper — it re-pointed onto the +// connections seam (#171), so a second client-side flip writer would be the +// exact duplicate-flip bug ADR 0001 forbids. /** Grant (hp) or revoke (piccolo) the `issimo` entitlement, PRESERVING any * other codes in entitlements.toml (a real license file may carry more). */ diff --git a/packages/extension/src/sse_client.ts b/packages/extension/src/sse_client.ts index 30673edf9..3de3c668c 100644 --- a/packages/extension/src/sse_client.ts +++ b/packages/extension/src/sse_client.ts @@ -22,6 +22,10 @@ import type { StatusBarManager } from "./status_bar"; export interface SseClientOptions { channel: vscode.OutputChannel; statusBar?: StatusBarManager; + /** `Authorization` header value for the per-boot server password (#163) — + * without it the fork 401s /event and the reconnect loop spins forever. + * Never logged; the value exists only on the wire. */ + authorization?: string; } export class OpencodeEventClient implements vscode.Disposable { @@ -63,7 +67,10 @@ export class OpencodeEventClient implements vscode.Disposable { port: this.url.port, path: this.url.pathname, method: "GET", - headers: { Accept: "text/event-stream" }, + headers: { + Accept: "text/event-stream", + ...(this.opts.authorization ? { Authorization: this.opts.authorization } : {}), + }, }, (res) => { this.res = res; diff --git a/packages/extension/test/__mocks__/vscode.ts b/packages/extension/test/__mocks__/vscode.ts index 0507e05bc..1ad98c76b 100644 --- a/packages/extension/test/__mocks__/vscode.ts +++ b/packages/extension/test/__mocks__/vscode.ts @@ -8,6 +8,8 @@ export const window = { showInputBox: () => Promise.resolve(undefined), createOutputChannel: () => ({ appendLine() {}, append() {}, dispose() {} }), registerWebviewViewProvider: () => ({ dispose() {} }), + activeColorTheme: { kind: 2 }, // ColorThemeKind.Dark + onDidChangeActiveColorTheme: (_cb: unknown, _thisArg?: unknown, _subs?: unknown) => ({ dispose() {} }), createWebviewPanel: (_viewType: string, _title: string, _column?: unknown, _opts?: unknown) => { const disposeCbs: Array<() => void> = []; return { @@ -44,6 +46,7 @@ export const commands = { executeCommand: (id: string, ...a: unknown[]) => Promise.resolve(registeredCommands.get(id)?.(...a)), }; export const ViewColumn = { One: 1, Two: 2 }; +export const ColorThemeKind = { Light: 1, Dark: 2, HighContrast: 3, HighContrastLight: 4 }; export const workspace = { workspaceFolders: [] as unknown[], getConfiguration: () => ({ get: (_k: string, d?: unknown) => d ?? "" }), diff --git a/packages/extension/test/chat_panel.test.ts b/packages/extension/test/chat_panel.test.ts new file mode 100644 index 000000000..6a48e7855 --- /dev/null +++ b/packages/extension/test/chat_panel.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, afterEach } from "vitest"; +import * as vscode from "vscode"; +import { ChatPanel } from "../src/chat_panel"; +import { mintServerPassword, serverAuthToken } from "../src/server_auth"; + +// ============================================================================ +// #163: with the per-boot server password armed, the fork 401s the chat app's +// document and every fetch it makes. The app's EXISTING credential bootstrap +// (verified at v1.17.3-amicode.5, packages/app/src/entry.tsx) reads +// `?auth_token=base64(opencode:pw)` off its URL, adopts it for the +// authenticated-fetch path, and strips it from the URL bar — so the extension +// carries the credential to the app on the iframe src, and ONLY there. +// ============================================================================ + +type CapturedPanel = { webview: { html: string }; dispose(): void }; + +/** Wrap the mock's createWebviewPanel to capture the panel openOrReveal builds + * (ChatPanel keeps it private; the html is the surface under test). */ +function capturePanel(): { created: CapturedPanel[]; restore: () => void } { + const created: CapturedPanel[] = []; + const w = vscode.window as unknown as { createWebviewPanel: (...a: unknown[]) => CapturedPanel }; + const orig = w.createWebviewPanel; + w.createWebviewPanel = (...a: unknown[]) => { + const p = orig(...a); + created.push(p); + return p; + }; + return { created, restore: () => (w.createWebviewPanel = orig) }; +} + +function fakeCtx(): vscode.ExtensionContext { + return { extensionUri: { fsPath: "/ext" } } as unknown as vscode.ExtensionContext; +} + +const iframeSrc = (html: string): URL => { + const m = html.match(/