From 450dc3c347bea90340d6f7756468a90ce365aa01 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Sun, 19 Jul 2026 20:47:07 -0400 Subject: [PATCH 01/44] =?UTF-8?q?feat(amicode):=20CredentialStore=20seam?= =?UTF-8?q?=20=E2=80=94=20company-compute=20backend=20+=20golden=20fixture?= =?UTF-8?q?=20(159/S1=20AC1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read/write/clear seam in the fork server's amicode module space; the company-compute backend writes the FROZEN cloud.json byte shape ({base_url trimmed of trailing slashes, token}, nothing else), locked by a golden fixture the amicode-side tests can consume byte-for-byte. Path resolution honors the same AMICO_CLOUD_FILE override the CLI's remote-config reader uses, so the test seam is the compat seam. Co-Authored-By: Claude Fable 5 --- .../src/server/amicode/credentials.ts | 139 ++++++++++++++++++ .../test/server/amicode-credentials.test.ts | 76 ++++++++++ .../server/fixtures/credentials/cloud.json | 4 + 3 files changed, 219 insertions(+) create mode 100644 packages/opencode/src/server/amicode/credentials.ts create mode 100644 packages/opencode/test/server/amicode-credentials.test.ts create mode 100644 packages/opencode/test/server/fixtures/credentials/cloud.json diff --git a/packages/opencode/src/server/amicode/credentials.ts b/packages/opencode/src/server/amicode/credentials.ts new file mode 100644 index 0000000000..bd537bb8cd --- /dev/null +++ b/packages/opencode/src/server/amicode/credentials.ts @@ -0,0 +1,139 @@ +// AMICODE: the CredentialStore — the ONE seam through which connection +// credentials reach disk (ADR 0001, amicode#159/#162). Per-connection-type +// backends declare their file (env override → ~/.amico default, the +// problems.ts idiom — here the override vars are the SAME ones the amicode +// CLI honors, so the test seam and the CLI-compatibility seam are one +// mechanism) and their schema. Byte shapes are golden-fixture locked +// (test/server/fixtures/credentials): cloud.json must stay parseable by the +// amicode CLI's remote-config reader (amico-run/src/remote_config.ts) +// unchanged. SECURITY: no credential value ever appears in an error message +// or log line — errors carry structure, never bytes. +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { homedir } from "node:os" +import path from "node:path" + +export type ConnectionType = "company-compute" | "pasqal-cloud" + +/** FROZEN byte shape — every existing CLI consumer parses this unchanged. */ +export interface CompanyComputeCredential { + base_url: string + token: string +} +/** Token-only at rest: project id + real token (+ optional expiry metadata). + * A password NEVER has a field to land in — see the poison guard below. */ +export interface PasqalCredential { + project_id: string + token: string + expires_at?: string +} +export type Credential = CompanyComputeCredential | PasqalCredential + +/** $AMICO_CLOUD_FILE overrides the path — the same override the amicode CLI's + * remote-config reader honors (remote_config.ts cloudConfigFile). */ +export function cloudFile(): string { + const env = process.env.AMICO_CLOUD_FILE + if (env && env.trim() !== "") return env + return path.join(homedir(), ".amico", "cloud.json") +} +/** $AMICO_PASQAL_FILE override; default lives beside cloud.json. */ +export function pasqalFile(): string { + const env = process.env.AMICO_PASQAL_FILE + if (env && env.trim() !== "") return env + return path.join(homedir(), ".amico", "pasqal.json") +} + +// --- poison guard: writing any object carrying a password-like key through +// this seam must be impossible. The encoders below are allowlist-only (they +// build a fresh object from the declared schema keys), so a stray key can +// never serialize; this guard additionally makes the attempt LOUD instead of +// silently trimmed. The message never echoes the key or its value. +const POISON_KEY = /pass|pwd|user|login|secret/i +function rejectPoisonKeys(value: Record): void { + for (const key of Object.keys(value)) { + if (POISON_KEY.test(key)) throw new Error("credential rejected: password-like keys are never persisted") + } +} + +interface Backend { + file(): string + /** allowlist-encode to the frozen byte shape; throws on schema violations */ + encode(value: Record): string + /** tolerant decode: anything off-schema is absent, never a throw */ + decode(raw: unknown): Credential | undefined +} + +const BACKENDS: Record = { + "company-compute": { + file: cloudFile, + encode(value) { + rejectPoisonKeys(value) + const base = typeof value.base_url === "string" ? value.base_url.trim().replace(/\/+$/, "") : "" + const token = typeof value.token === "string" ? value.token.trim() : "" + if (base === "" || token === "") + throw new Error('company-compute credential needs non-empty "base_url" and "token"') + return JSON.stringify({ base_url: base, token }, null, 2) + "\n" + }, + decode(raw) { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined + const d = raw as Record + if (typeof d.base_url !== "string" || d.base_url === "") return undefined + if (typeof d.token !== "string" || d.token === "") return undefined + return { base_url: d.base_url.replace(/\/+$/, ""), token: d.token } + }, + }, + "pasqal-cloud": { + file: pasqalFile, + encode(value) { + rejectPoisonKeys(value) + const project = typeof value.project_id === "string" ? value.project_id.trim() : "" + const token = typeof value.token === "string" ? value.token.trim() : "" + if (project === "" || token === "") + throw new Error('pasqal-cloud credential needs non-empty "project_id" and "token"') + const out: PasqalCredential = { project_id: project, token } + if (typeof value.expires_at === "string" && value.expires_at.trim() !== "") out.expires_at = value.expires_at.trim() + return JSON.stringify(out, null, 2) + "\n" + }, + decode(raw) { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined + const d = raw as Record + if (typeof d.project_id !== "string" || d.project_id === "") return undefined + if (typeof d.token !== "string" || d.token === "") return undefined + const out: PasqalCredential = { project_id: d.project_id, token: d.token } + if (typeof d.expires_at === "string" && d.expires_at !== "") out.expires_at = d.expires_at + return out + }, + }, +} + +// --- the seam surface: read / write / clear per connection type --- + +export function readCredential(type: "company-compute"): CompanyComputeCredential | undefined +export function readCredential(type: "pasqal-cloud"): PasqalCredential | undefined +export function readCredential(type: ConnectionType): Credential | undefined +export function readCredential(type: ConnectionType): Credential | undefined { + const backend = BACKENDS[type] + const file = backend.file() + let raw: unknown + try { + if (!existsSync(file)) return undefined + raw = JSON.parse(readFileSync(file, "utf8")) + } catch { + return undefined // missing/unreadable/unparseable → absent, never a throw + } + return backend.decode(raw) +} + +export function writeCredential(type: "company-compute", value: CompanyComputeCredential): void +export function writeCredential(type: "pasqal-cloud", value: PasqalCredential): void +export function writeCredential(type: ConnectionType, value: Credential): void { + const backend = BACKENDS[type] + const bytes = backend.encode(value as unknown as Record) + const target = backend.file() + mkdirSync(path.dirname(target), { recursive: true }) + writeFileSync(target, bytes) +} + +/** Remove the credential file; absent is a no-op. */ +export function clearCredential(type: ConnectionType): void { + rmSync(BACKENDS[type].file(), { force: true }) +} diff --git a/packages/opencode/test/server/amicode-credentials.test.ts b/packages/opencode/test/server/amicode-credentials.test.ts new file mode 100644 index 0000000000..a9602ac72b --- /dev/null +++ b/packages/opencode/test/server/amicode-credentials.test.ts @@ -0,0 +1,76 @@ +// AMICODE: CredentialStore seam tests (amicode#162 / parent #159, ADR 0001). +// Golden fixtures in ./fixtures/credentials lock the two on-disk byte shapes; +// an amicode-side test can consume the same fixture bytes to prove the CLI +// reader parses what this store writes. All paths go through the same env +// overrides the CLI honors (AMICO_CLOUD_FILE / AMICO_PASQAL_FILE) — the test +// seam and the compatibility seam are one mechanism. +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { mkdtempSync, readFileSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" +import { cloudFile, readCredential, writeCredential, clearCredential } from "@/server/amicode/credentials" + +const FIXTURES = path.join(import.meta.dir, "fixtures", "credentials") +const golden = (name: string) => readFileSync(path.join(FIXTURES, name), "utf8") + +const ENV_KEYS = ["AMICO_CLOUD_FILE", "AMICO_PASQAL_FILE"] as const +let savedEnv: Record +let dir: string + +beforeEach(() => { + savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])) + dir = mkdtempSync(path.join(tmpdir(), "amicode-creds-")) + process.env.AMICO_CLOUD_FILE = path.join(dir, "cloud.json") + process.env.AMICO_PASQAL_FILE = path.join(dir, "pasqal.json") +}) +afterEach(() => { + for (const k of ENV_KEYS) { + if (savedEnv[k] === undefined) delete process.env[k] + else process.env[k] = savedEnv[k] + } +}) + +describe("company-compute backend (AC1)", () => { + test("write emits the frozen golden bytes — trailing slash trimmed, nothing else in the file", () => { + writeCredential("company-compute", { + base_url: "https://solves.staging.harmoniqs.co/", + token: "tok-fixture-company-compute", + }) + expect(readFileSync(cloudFile(), "utf8")).toBe(golden("cloud.json")) + }) + + test("written file satisfies the CLI remote-config reader contract", () => { + // Mirrors amico-run/src/remote_config.ts: JSON object, exactly the keys + // "base_url" and "token", both non-empty strings, no trailing slash. + writeCredential("company-compute", { base_url: "https://solves.example.co///", token: "tok-1" }) + const raw = JSON.parse(readFileSync(cloudFile(), "utf8")) as Record + expect(Object.keys(raw).sort()).toEqual(["base_url", "token"]) + expect(raw.base_url).toBe("https://solves.example.co") + expect(typeof raw.token).toBe("string") + expect(raw.token).not.toBe("") + }) + + test("golden fixture reads back through the store (reader side of the frozen shape)", () => { + writeCredential("company-compute", { + base_url: "https://solves.staging.harmoniqs.co", + token: "tok-fixture-company-compute", + }) + expect(readCredential("company-compute")).toEqual({ + base_url: "https://solves.staging.harmoniqs.co", + token: "tok-fixture-company-compute", + }) + }) + + test("empty token or base_url is rejected; nothing lands on disk", () => { + expect(() => writeCredential("company-compute", { base_url: "https://x.co", token: "" })).toThrow() + expect(() => writeCredential("company-compute", { base_url: "", token: "tok" })).toThrow() + expect(readCredential("company-compute")).toBeUndefined() + }) + + test("clear removes the file; read reports absent", () => { + writeCredential("company-compute", { base_url: "https://x.co", token: "tok" }) + clearCredential("company-compute") + expect(readCredential("company-compute")).toBeUndefined() + clearCredential("company-compute") // idempotent — clearing an absent file is a no-op + }) +}) diff --git a/packages/opencode/test/server/fixtures/credentials/cloud.json b/packages/opencode/test/server/fixtures/credentials/cloud.json new file mode 100644 index 0000000000..c1575b4c0c --- /dev/null +++ b/packages/opencode/test/server/fixtures/credentials/cloud.json @@ -0,0 +1,4 @@ +{ + "base_url": "https://solves.staging.harmoniqs.co", + "token": "tok-fixture-company-compute" +} From c1d877aee1d175e1026b38c9c67ece7614a55fa5 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Sun, 19 Jul 2026 20:48:33 -0400 Subject: [PATCH 02/44] feat(amicode): atomic 0600-at-birth credential writer (159/S1 AC2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Write a sibling tmp file with mode 0o600 passed at creation (never the 0666&~umask default, never a post-hoc chmod), then rename over the target — the inode swap also corrects a pre-existing wrong-permission file. The rename step is an injectable hook so the test asserts the TMP file's mode before it ever becomes the target. Co-Authored-By: Claude Fable 5 --- .../src/server/amicode/credentials.ts | 42 +++++++++++++++---- .../test/server/amicode-credentials.test.ts | 36 +++++++++++++++- 2 files changed, 69 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/server/amicode/credentials.ts b/packages/opencode/src/server/amicode/credentials.ts index bd537bb8cd..e9c9ac4359 100644 --- a/packages/opencode/src/server/amicode/credentials.ts +++ b/packages/opencode/src/server/amicode/credentials.ts @@ -8,7 +8,8 @@ // amicode CLI's remote-config reader (amico-run/src/remote_config.ts) // unchanged. SECURITY: no credential value ever appears in an error message // or log line — errors carry structure, never bytes. -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs" +import { randomBytes } from "node:crypto" import { homedir } from "node:os" import path from "node:path" @@ -105,6 +106,33 @@ const BACKENDS: Record = { }, } +// --- atomic 0600-at-birth writer --- + +export interface WriteHooks { + /** Test seam: observe or replace the rename step, e.g. to assert the tmp + * file's mode BEFORE it becomes the target (mode-at-birth, never a + * post-rename chmod). Production callers pass nothing. */ + rename?: (tmp: string, target: string) => void +} + +/** Atomic replace: write a sibling tmp file with mode 0600 set AT CREATION + * (the mode option on the open — the Bun/Node default is 0666 & ~umask, + * i.e. world-readable), then rename over the target. rename() swaps the + * inode, so a pre-existing wrong-permission target comes out 0600 too. On + * ANY failure the tmp is removed: the target is never partial — it holds + * either the old bytes or the new bytes, nothing in between. */ +export function atomicWriteFileSync(target: string, data: string, hooks?: WriteHooks): void { + mkdirSync(path.dirname(target), { recursive: true }) + const tmp = path.join(path.dirname(target), `.${path.basename(target)}.${randomBytes(6).toString("hex")}.tmp`) + try { + writeFileSync(tmp, data, { mode: 0o600 }) + ;(hooks?.rename ?? renameSync)(tmp, target) + } catch (err) { + rmSync(tmp, { force: true }) + throw err + } +} + // --- the seam surface: read / write / clear per connection type --- export function readCredential(type: "company-compute"): CompanyComputeCredential | undefined @@ -123,14 +151,12 @@ export function readCredential(type: ConnectionType): Credential | undefined { return backend.decode(raw) } -export function writeCredential(type: "company-compute", value: CompanyComputeCredential): void -export function writeCredential(type: "pasqal-cloud", value: PasqalCredential): void -export function writeCredential(type: ConnectionType, value: Credential): void { +export function writeCredential(type: "company-compute", value: CompanyComputeCredential, hooks?: WriteHooks): void +export function writeCredential(type: "pasqal-cloud", value: PasqalCredential, hooks?: WriteHooks): void +export function writeCredential(type: ConnectionType, value: Credential, hooks?: WriteHooks): void { const backend = BACKENDS[type] - const bytes = backend.encode(value as unknown as Record) - const target = backend.file() - mkdirSync(path.dirname(target), { recursive: true }) - writeFileSync(target, bytes) + const bytes = backend.encode(value as unknown as Record) // encode BEFORE touching disk + atomicWriteFileSync(backend.file(), bytes, hooks) } /** Remove the credential file; absent is a no-op. */ diff --git a/packages/opencode/test/server/amicode-credentials.test.ts b/packages/opencode/test/server/amicode-credentials.test.ts index a9602ac72b..84c152e43a 100644 --- a/packages/opencode/test/server/amicode-credentials.test.ts +++ b/packages/opencode/test/server/amicode-credentials.test.ts @@ -5,11 +5,13 @@ // overrides the CLI honors (AMICO_CLOUD_FILE / AMICO_PASQAL_FILE) — the test // seam and the compatibility seam are one mechanism. import { afterEach, beforeEach, describe, expect, test } from "bun:test" -import { mkdtempSync, readFileSync } from "node:fs" +import { chmodSync, mkdtempSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import path from "node:path" import { cloudFile, readCredential, writeCredential, clearCredential } from "@/server/amicode/credentials" +const mode = (file: string) => statSync(file).mode & 0o777 + const FIXTURES = path.join(import.meta.dir, "fixtures", "credentials") const golden = (name: string) => readFileSync(path.join(FIXTURES, name), "utf8") @@ -74,3 +76,35 @@ describe("company-compute backend (AC1)", () => { clearCredential("company-compute") // idempotent — clearing an absent file is a no-op }) }) + +describe("atomic 0600-at-birth writer (AC2)", () => { + test("tmp file is 0600 AT CREATION — asserted at the tmp stage, before rename", () => { + let tmpSeen: string | undefined + let tmpMode: number | undefined + writeCredential("company-compute", { base_url: "https://x.co", token: "tok" }, { + rename(tmp, target) { + tmpSeen = tmp + tmpMode = mode(tmp) // stat BEFORE the rename — birth mode, not post-hoc chmod + expect(path.dirname(tmp)).toBe(path.dirname(target)) // same dir → same fs, rename is atomic + renameSync(tmp, target) + }, + }) + expect(tmpSeen).toBeDefined() + expect(tmpMode).toBe(0o600) + expect(mode(cloudFile())).toBe(0o600) + }) + + test("final file is 0600 with the default rename", () => { + writeCredential("company-compute", { base_url: "https://x.co", token: "tok" }) + expect(mode(cloudFile())).toBe(0o600) + }) + + test("a pre-existing wrong-permission file is corrected to 0600 on write", () => { + writeFileSync(cloudFile(), '{"base_url":"https://old.co","token":"old"}\n') + chmodSync(cloudFile(), 0o644) + expect(mode(cloudFile())).toBe(0o644) + writeCredential("company-compute", { base_url: "https://new.co", token: "new-tok" }) + expect(mode(cloudFile())).toBe(0o600) + expect(readCredential("company-compute")).toEqual({ base_url: "https://new.co", token: "new-tok" }) + }) +}) From 234f81c3ed5bea5300939705c22545eb62b7a564 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Sun, 19 Jul 2026 20:49:45 -0400 Subject: [PATCH 03/44] test(amicode): injected-failure atomicity coverage for the credential writer (159/S1 AC3) A thrown rename leaves the old credential byte-for-byte intact (or no file at all on a first write) and never a tmp leftover. Verified the tests bite via a mutation check: removing the writer's tmp cleanup fails exactly these two tests. Co-Authored-By: Claude Fable 5 --- .../test/server/amicode-credentials.test.ts | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/server/amicode-credentials.test.ts b/packages/opencode/test/server/amicode-credentials.test.ts index 84c152e43a..e48f46738b 100644 --- a/packages/opencode/test/server/amicode-credentials.test.ts +++ b/packages/opencode/test/server/amicode-credentials.test.ts @@ -5,7 +5,7 @@ // overrides the CLI honors (AMICO_CLOUD_FILE / AMICO_PASQAL_FILE) — the test // seam and the compatibility seam are one mechanism. import { afterEach, beforeEach, describe, expect, test } from "bun:test" -import { chmodSync, mkdtempSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs" +import { chmodSync, mkdtempSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import path from "node:path" import { cloudFile, readCredential, writeCredential, clearCredential } from "@/server/amicode/credentials" @@ -108,3 +108,31 @@ describe("atomic 0600-at-birth writer (AC2)", () => { expect(readCredential("company-compute")).toEqual({ base_url: "https://new.co", token: "new-tok" }) }) }) + +describe("write atomicity under injected failure (AC3)", () => { + test("failure at the rename step leaves the OLD file byte-for-byte intact, no tmp debris", () => { + writeCredential("company-compute", { base_url: "https://old.co", token: "old-tok" }) + const before = readFileSync(cloudFile(), "utf8") + expect(() => + writeCredential("company-compute", { base_url: "https://new.co", token: "new-tok" }, { + rename() { + throw new Error("injected: disk full at rename") + }, + }), + ).toThrow("injected") + expect(readFileSync(cloudFile(), "utf8")).toBe(before) // old credential untouched + expect(readdirSync(dir)).toEqual(["cloud.json"]) // no partial tmp left behind + }) + + test("failure on a first-ever write leaves NO credential file at all", () => { + expect(() => + writeCredential("company-compute", { base_url: "https://x.co", token: "tok" }, { + rename() { + throw new Error("injected: power loss") + }, + }), + ).toThrow("injected") + expect(readdirSync(dir)).toEqual([]) // nothing partial, nothing corrupt + expect(readCredential("company-compute")).toBeUndefined() + }) +}) From 8c45bd3daf7d034636d1771d109182492098043a Mon Sep 17 00:00:00 2001 From: kate bonner Date: Sun, 19 Jul 2026 20:51:03 -0400 Subject: [PATCH 04/44] =?UTF-8?q?test(amicode):=20pasqal=20golden=20fixtur?= =?UTF-8?q?e=20+=20poison=20coverage=20=E2=80=94=20token-only=20at=20rest?= =?UTF-8?q?=20(159/S1=20AC4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pasqal.json fixture locks the {project_id, token, expires_at?} byte shape. Poison tests push password/username-bearing objects through the seam (both backends): the write throws (message never echoes a value), the seeded credential stays byte-identical, and a whole-dir byte scan proves the secret exists in no file. Null tokens are rejected; unknown non-poison keys are allowlist-dropped. Mutation check: disabling the poison guard fails exactly the poison test. Co-Authored-By: Claude Fable 5 --- .../test/server/amicode-credentials.test.ts | 67 ++++++++++++++++++- .../server/fixtures/credentials/pasqal.json | 5 ++ 2 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/server/fixtures/credentials/pasqal.json diff --git a/packages/opencode/test/server/amicode-credentials.test.ts b/packages/opencode/test/server/amicode-credentials.test.ts index e48f46738b..b54de2f4e4 100644 --- a/packages/opencode/test/server/amicode-credentials.test.ts +++ b/packages/opencode/test/server/amicode-credentials.test.ts @@ -8,7 +8,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test" import { chmodSync, mkdtempSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import path from "node:path" -import { cloudFile, readCredential, writeCredential, clearCredential } from "@/server/amicode/credentials" +import { cloudFile, pasqalFile, readCredential, writeCredential, clearCredential } from "@/server/amicode/credentials" const mode = (file: string) => statSync(file).mode & 0o777 @@ -136,3 +136,68 @@ describe("write atomicity under injected failure (AC3)", () => { expect(readCredential("company-compute")).toBeUndefined() }) }) + +/** every byte currently under the credential dir — the poison scan surface */ +const dirBytes = () => + readdirSync(dir) + .map((f) => readFileSync(path.join(dir, f), "utf8")) + .join("\n") + +describe("pasqal-cloud backend — token-only at rest (AC4)", () => { + test("write emits the frozen golden bytes (project_id + token + expiry, nothing else)", () => { + writeCredential("pasqal-cloud", { + project_id: "proj-fixture-pasqal", + token: "tok-fixture-pasqal", + expires_at: "2026-08-01T00:00:00Z", + }) + expect(readFileSync(pasqalFile(), "utf8")).toBe(golden("pasqal.json")) + expect(readCredential("pasqal-cloud")).toEqual({ + project_id: "proj-fixture-pasqal", + token: "tok-fixture-pasqal", + expires_at: "2026-08-01T00:00:00Z", + }) + }) + + test("expiry metadata is optional; the key set is exact either way", () => { + writeCredential("pasqal-cloud", { project_id: "proj-1", token: "tok-1" }) + const raw = JSON.parse(readFileSync(pasqalFile(), "utf8")) as Record + expect(Object.keys(raw).sort()).toEqual(["project_id", "token"]) + }) + + test("a null token can never persist — this store only writes real tokens", () => { + expect(() => writeCredential("pasqal-cloud", { project_id: "proj-1", token: null } as any)).toThrow() + expect(readdirSync(dir)).toEqual([]) + }) + + test("poison: a password/username-bearing object can never land those keys on disk", () => { + // seed a valid credential first — the poison write must not even dirty it + writeCredential("pasqal-cloud", { project_id: "proj-1", token: "tok-1" }) + const before = readFileSync(pasqalFile(), "utf8") + for (const poison of [ + { project_id: "proj-1", token: "tok-2", password: "hunter2-super-secret" }, + { project_id: "proj-1", token: "tok-2", username: "kate@harmoniqs.co" }, + { project_id: "proj-1", token: "tok-2", user_password: "hunter2-super-secret" }, + { base_url: "https://x.co", token: "tok-2", password: "hunter2-super-secret" }, // company side too + ]) { + let threw: Error | undefined + try { + writeCredential(("base_url" in poison ? "company-compute" : "pasqal-cloud") as any, poison as any) + } catch (err) { + threw = err as Error + } + expect(threw).toBeDefined() + expect(threw!.message).not.toContain("hunter2") // errors never echo the value + expect(threw!.message).not.toContain("kate@") + } + expect(readFileSync(pasqalFile(), "utf8")).toBe(before) // seeded credential untouched + expect(dirBytes()).not.toContain("hunter2") // the secret exists in NO file + expect(dirBytes()).not.toContain("kate@harmoniqs.co") + }) + + test("unknown non-poison keys are allowlist-dropped, never serialized", () => { + writeCredential("pasqal-cloud", { project_id: "proj-1", token: "tok-1", note: "scribble" } as any) + const raw = JSON.parse(readFileSync(pasqalFile(), "utf8")) as Record + expect(Object.keys(raw).sort()).toEqual(["project_id", "token"]) + expect(dirBytes()).not.toContain("scribble") + }) +}) diff --git a/packages/opencode/test/server/fixtures/credentials/pasqal.json b/packages/opencode/test/server/fixtures/credentials/pasqal.json new file mode 100644 index 0000000000..7a4937a976 --- /dev/null +++ b/packages/opencode/test/server/fixtures/credentials/pasqal.json @@ -0,0 +1,5 @@ +{ + "project_id": "proj-fixture-pasqal", + "token": "tok-fixture-pasqal", + "expires_at": "2026-08-01T00:00:00Z" +} From e91ba3e5a7a1222f1d3232b40370dc562078ed91 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Sun, 19 Jul 2026 20:52:04 -0400 Subject: [PATCH 05/44] =?UTF-8?q?test(amicode):=20tolerant=20credential=20?= =?UTF-8?q?read=20path=20=E2=80=94=20absent,=20never=20a=20throw=20(159/S1?= =?UTF-8?q?=20AC5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Missing, unparseable, and off-schema (hand-edited) credential files all read back as undefined; the golden fixture bytes parse through the same read path, closing the reader side of the cross-repo contract. Mutation check: a throwing read path fails the unparseable-file test. Co-Authored-By: Claude Fable 5 --- .../test/server/amicode-credentials.test.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/opencode/test/server/amicode-credentials.test.ts b/packages/opencode/test/server/amicode-credentials.test.ts index b54de2f4e4..1cf3ee16b2 100644 --- a/packages/opencode/test/server/amicode-credentials.test.ts +++ b/packages/opencode/test/server/amicode-credentials.test.ts @@ -201,3 +201,33 @@ describe("pasqal-cloud backend — token-only at rest (AC4)", () => { expect(dirBytes()).not.toContain("scribble") }) }) + +describe("read-path tolerance (AC5)", () => { + test("missing file → absent, never a throw", () => { + expect(readCredential("company-compute")).toBeUndefined() + expect(readCredential("pasqal-cloud")).toBeUndefined() + }) + + test("unparseable file → absent, never a throw", () => { + writeFileSync(cloudFile(), "not json {{{") + expect(readCredential("company-compute")).toBeUndefined() + writeFileSync(pasqalFile(), "\x00\x01 binary junk") + expect(readCredential("pasqal-cloud")).toBeUndefined() + }) + + test("off-schema JSON → absent (hand-edited files degrade, never crash)", () => { + writeFileSync(cloudFile(), '{"base_url": "", "token": 42}') + expect(readCredential("company-compute")).toBeUndefined() + writeFileSync(pasqalFile(), '["not", "an", "object"]') + expect(readCredential("pasqal-cloud")).toBeUndefined() + writeFileSync(pasqalFile(), '{"project_id": "p"}') // token missing entirely + expect(readCredential("pasqal-cloud")).toBeUndefined() + }) + + test("the golden fixture bytes parse through the read path (cross-repo contract, reader side)", () => { + writeFileSync(cloudFile(), golden("cloud.json")) + expect(readCredential("company-compute")?.token).toBe("tok-fixture-company-compute") + writeFileSync(pasqalFile(), golden("pasqal.json")) + expect(readCredential("pasqal-cloud")?.project_id).toBe("proj-fixture-pasqal") + }) +}) From 5c027e97b7a7344c3e77d0e758d7730e1283cb4f Mon Sep 17 00:00:00 2001 From: kate bonner Date: Sun, 19 Jul 2026 20:55:14 -0400 Subject: [PATCH 06/44] style(amicode): prettier pass on the credential store and its tests Co-Authored-By: Claude Fable 5 --- .../src/server/amicode/credentials.ts | 3 +- .../test/server/amicode-credentials.test.ts | 42 ++++++++++++------- 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/packages/opencode/src/server/amicode/credentials.ts b/packages/opencode/src/server/amicode/credentials.ts index e9c9ac4359..1ea38b50be 100644 --- a/packages/opencode/src/server/amicode/credentials.ts +++ b/packages/opencode/src/server/amicode/credentials.ts @@ -91,7 +91,8 @@ const BACKENDS: Record = { if (project === "" || token === "") throw new Error('pasqal-cloud credential needs non-empty "project_id" and "token"') const out: PasqalCredential = { project_id: project, token } - if (typeof value.expires_at === "string" && value.expires_at.trim() !== "") out.expires_at = value.expires_at.trim() + if (typeof value.expires_at === "string" && value.expires_at.trim() !== "") + out.expires_at = value.expires_at.trim() return JSON.stringify(out, null, 2) + "\n" }, decode(raw) { diff --git a/packages/opencode/test/server/amicode-credentials.test.ts b/packages/opencode/test/server/amicode-credentials.test.ts index 1cf3ee16b2..390f9734dd 100644 --- a/packages/opencode/test/server/amicode-credentials.test.ts +++ b/packages/opencode/test/server/amicode-credentials.test.ts @@ -81,14 +81,18 @@ describe("atomic 0600-at-birth writer (AC2)", () => { test("tmp file is 0600 AT CREATION — asserted at the tmp stage, before rename", () => { let tmpSeen: string | undefined let tmpMode: number | undefined - writeCredential("company-compute", { base_url: "https://x.co", token: "tok" }, { - rename(tmp, target) { - tmpSeen = tmp - tmpMode = mode(tmp) // stat BEFORE the rename — birth mode, not post-hoc chmod - expect(path.dirname(tmp)).toBe(path.dirname(target)) // same dir → same fs, rename is atomic - renameSync(tmp, target) + writeCredential( + "company-compute", + { base_url: "https://x.co", token: "tok" }, + { + rename(tmp, target) { + tmpSeen = tmp + tmpMode = mode(tmp) // stat BEFORE the rename — birth mode, not post-hoc chmod + expect(path.dirname(tmp)).toBe(path.dirname(target)) // same dir → same fs, rename is atomic + renameSync(tmp, target) + }, }, - }) + ) expect(tmpSeen).toBeDefined() expect(tmpMode).toBe(0o600) expect(mode(cloudFile())).toBe(0o600) @@ -114,11 +118,15 @@ describe("write atomicity under injected failure (AC3)", () => { writeCredential("company-compute", { base_url: "https://old.co", token: "old-tok" }) const before = readFileSync(cloudFile(), "utf8") expect(() => - writeCredential("company-compute", { base_url: "https://new.co", token: "new-tok" }, { - rename() { - throw new Error("injected: disk full at rename") + writeCredential( + "company-compute", + { base_url: "https://new.co", token: "new-tok" }, + { + rename() { + throw new Error("injected: disk full at rename") + }, }, - }), + ), ).toThrow("injected") expect(readFileSync(cloudFile(), "utf8")).toBe(before) // old credential untouched expect(readdirSync(dir)).toEqual(["cloud.json"]) // no partial tmp left behind @@ -126,11 +134,15 @@ describe("write atomicity under injected failure (AC3)", () => { test("failure on a first-ever write leaves NO credential file at all", () => { expect(() => - writeCredential("company-compute", { base_url: "https://x.co", token: "tok" }, { - rename() { - throw new Error("injected: power loss") + writeCredential( + "company-compute", + { base_url: "https://x.co", token: "tok" }, + { + rename() { + throw new Error("injected: power loss") + }, }, - }), + ), ).toThrow("injected") expect(readdirSync(dir)).toEqual([]) // nothing partial, nothing corrupt expect(readCredential("company-compute")).toBeUndefined() From 4f99fd8bd650d1b014d0f59e49b7977218aabfe9 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Sun, 19 Jul 2026 21:11:16 -0400 Subject: [PATCH 07/44] test+feat(amicode): probe-first classification for Company Compute keys (165 AC2 core) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 401 → invalid, 2xx/403/404 → valid, everything else (incl. network failure) → unreachable — the parent #159 fake-task probe contract. Token rides the Authorization header only, asserted never-in-URL. FetchImpl is the injectable seam; no live network in tests. Co-Authored-By: Claude Fable 5 --- .../src/server/amicode/connections.ts | 42 +++++++++++++++ .../test/server/amicode-connections.test.ts | 51 +++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 packages/opencode/src/server/amicode/connections.ts create mode 100644 packages/opencode/test/server/amicode-connections.test.ts diff --git a/packages/opencode/src/server/amicode/connections.ts b/packages/opencode/src/server/amicode/connections.ts new file mode 100644 index 0000000000..2aab5a9afb --- /dev/null +++ b/packages/opencode/src/server/amicode/connections.ts @@ -0,0 +1,42 @@ +// AMICODE: Connections routes data source (amicode#165 / parent #159, ADR +// 0002) — Company Compute connect path. Probe-first validation: a submitted +// key is classified against the solve service's fake-task status route BEFORE +// anything touches disk; only auth-passed classes write through the #162 +// CredentialStore seam. SECURITY: secrets ride POST bodies and Authorization +// headers ONLY — never URLs, never query params, never error messages or +// logs. Every status response is built through a redacting whitelist parser, +// so no input (cache file, in-memory state) can leak a token into a body. +export type ProbeOutcome = "valid" | "invalid" | "unreachable" + +/** Injectable fetch seam — tests stub this; production uses global fetch. + * Only the status code matters to classification. */ +export type FetchImpl = ( + url: string, + init: { method: "GET"; headers: Record }, +) => Promise<{ status: number }> + +const PROBE_PATH = "/solves/__validate__/status" + +/** Classify a Company Compute credential against the fake-task status route + * (parent #159 probe contract: the authorizer rejects bad keys before the + * handler; good keys reach the handler's not-found/forbidden). + * 401 → invalid (authorizer rejected the key) + * 2xx / 403 / 404 → valid (key got past the authorizer) + * anything else → unreachable (service or network trouble) + * The token rides the Authorization header ONLY — never the URL. */ +export async function probeCompanyCompute( + baseUrl: string, + token: string, + fetchImpl: FetchImpl = fetch, +): Promise { + const url = baseUrl.replace(/\/+$/, "") + PROBE_PATH + let status: number + try { + status = (await fetchImpl(url, { method: "GET", headers: { authorization: `Bearer ${token}` } })).status + } catch { + return "unreachable" + } + if (status === 401) return "invalid" + if ((status >= 200 && status < 300) || status === 403 || status === 404) return "valid" + return "unreachable" +} diff --git a/packages/opencode/test/server/amicode-connections.test.ts b/packages/opencode/test/server/amicode-connections.test.ts new file mode 100644 index 0000000000..c3a2d42f43 --- /dev/null +++ b/packages/opencode/test/server/amicode-connections.test.ts @@ -0,0 +1,51 @@ +// AMICODE: Connections module tests (amicode#165 / parent #159, ADR 0002). +// Company Compute connect path: probe-first validation, redacting-whitelist +// status rendering, loopback-gated mutations. No live network — every probe +// runs through an injected FetchImpl. Credential + status-cache files ride the +// same env overrides production honors (AMICO_CLOUD_FILE / +// AMICODE_CONNECTIONS_FILE), so the test seam and the deploy seam are one +// mechanism (the #162 idiom). +import { describe, expect, test } from "bun:test" +import { probeCompanyCompute, type FetchImpl } from "@/server/amicode/connections" + +const respond = + (status: number): FetchImpl => + async () => ({ status }) + +describe("probe classification (AC2)", () => { + test("authorizer-rejection class: 401 → invalid", async () => { + expect(await probeCompanyCompute("https://solves.example.co", "tok-1", respond(401))).toBe("invalid") + }) + + test("auth-passed classes → valid: 403, 404, and 2xx all mean the key got past the authorizer", async () => { + // Parent contract: the authorizer rejects bad keys BEFORE the handler; a + // good key reaches the handler's not-found/forbidden for the fake task. + for (const status of [200, 204, 403, 404]) { + expect(await probeCompanyCompute("https://solves.example.co", "tok-1", respond(status))).toBe("valid") + } + }) + + test("server-error / network classes → unreachable: 5xx, odd 4xx, thrown fetch", async () => { + for (const status of [400, 429, 500, 502, 503]) { + expect(await probeCompanyCompute("https://solves.example.co", "tok-1", respond(status))).toBe("unreachable") + } + const network: FetchImpl = async () => { + throw new Error("ECONNREFUSED") + } + expect(await probeCompanyCompute("https://solves.example.co", "tok-1", network)).toBe("unreachable") + }) + + test("probe hits the fake-task status route; the token rides ONLY the Authorization header, never the URL", async () => { + let seenUrl = "" + let seenAuth = "" + const capture: FetchImpl = async (url, init) => { + seenUrl = url + seenAuth = init.headers.authorization ?? "" + return { status: 404 } + } + await probeCompanyCompute("https://solves.example.co///", "tok-secret-xyz", capture) + expect(seenUrl).toBe("https://solves.example.co/solves/__validate__/status") + expect(seenUrl).not.toContain("tok-secret-xyz") + expect(seenAuth).toBe("Bearer tok-secret-xyz") + }) +}) From 455b4cc1dad3ca6ed92a8171663422fef0a0a83b Mon Sep 17 00:00:00 2001 From: kate bonner Date: Sun, 19 Jul 2026 21:13:22 -0400 Subject: [PATCH 08/44] test+feat(amicode): redacting-whitelist status renderer + connections.json cache (165 AC3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET body is built ONLY through a whitelist parser that rebuilds fresh objects from declared, type-checked fields — poison test seeds token/ password keys into the cache file AND the in-memory overlay at every level and asserts absence. Cache rides the sibling ops-dir env-override idiom ($AMICODE_CONNECTIONS_FILE → ~/.amico/connections.json); the credential file stays the truth for 'connected'; overlay entries render 'validating'; stale is computed freshness metadata (STALE_MS). Co-Authored-By: Claude Fable 5 --- .../src/server/amicode/connections.ts | 198 ++++++++++++++++++ .../test/server/amicode-connections.test.ts | 145 ++++++++++++- 2 files changed, 341 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/server/amicode/connections.ts b/packages/opencode/src/server/amicode/connections.ts index 2aab5a9afb..9066393aef 100644 --- a/packages/opencode/src/server/amicode/connections.ts +++ b/packages/opencode/src/server/amicode/connections.ts @@ -6,6 +6,204 @@ // headers ONLY — never URLs, never query params, never error messages or // logs. Every status response is built through a redacting whitelist parser, // so no input (cache file, in-memory state) can leak a token into a body. +import { existsSync, readFileSync } from "node:fs" +import { homedir } from "node:os" +import path from "node:path" +import { readCredential, type ConnectionType } from "./credentials" + +// --- status contract (parent #159 data contract; secret-free by construction) --- + +/** This slice only ever produces connected / needs-key / invalid / + * unreachable / validating for company-compute; expired + unentitled are + * forward-compatible states later slices fill in. */ +export type ConnectionState = + | "connected" + | "needs-key" + | "invalid" + | "expired" + | "unreachable" + | "unentitled" + | "validating" + +export interface ConnectionDevice { + id?: string + name?: string + state?: string +} + +export interface ConnectionStatus { + id: ConnectionType + state: ConnectionState + identity?: string + entitlements?: string[] + expires_at?: string + devices?: ConnectionDevice[] + validated_at: string | null + stale: boolean +} + +/** The connection cards this slice serves. Later slices append "pasqal-cloud". */ +export const CONNECTION_IDS: ConnectionType[] = ["company-compute"] + +/** A connected claim older than this renders stale:true — the UI's cue to + * offer revalidation. Freshness metadata only; never blocks anything. */ +export const STALE_MS = 24 * 60 * 60 * 1000 + +/** Non-secret status cache — the ops-dir env-override idiom the siblings use + * (problems.ts / profile.ts / credentials.ts): $AMICODE_CONNECTIONS_FILE + * overrides, default lives beside cloud.json under ~/.amico. Holds ONLY + * whitelisted status fields; credentials live in the #162 store. */ +export function connectionsFile(): string { + const env = process.env.AMICODE_CONNECTIONS_FILE + if (env && env.trim() !== "") return env + return path.join(homedir(), ".amico", "connections.json") +} + +/** In-memory in-flight state: while a submit/revalidate probe runs, its id + * maps to an entry here and concurrent GETs render "validating". Exported as + * the in-memory seam the poison test seeds — whatever lands in an entry, only + * the whitelist below can reach a response. */ +export const inflightOverlay = new Map>() + +// --- the redacting whitelist parser: the ONLY way status inputs become a +// response. It builds a FRESH object from declared fields with type checks — +// unknown keys (token, password, anything) have no path into the output. + +const KNOWN_STATES: ReadonlySet = new Set([ + "connected", + "needs-key", + "invalid", + "expired", + "unreachable", + "unentitled", + "validating", +]) + +function str(v: unknown): string | undefined { + return typeof v === "string" && v !== "" ? v : undefined +} + +function whitelistDevices(v: unknown): ConnectionDevice[] | undefined { + if (!Array.isArray(v)) return undefined + const out: ConnectionDevice[] = [] + for (const raw of v) { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue + const d = raw as Record + const device: ConnectionDevice = {} + if (str(d.id)) device.id = d.id as string + if (str(d.name)) device.name = d.name as string + if (str(d.state)) device.state = d.state as string + if (Object.keys(device).length > 0) out.push(device) + } + return out.length > 0 ? out : undefined +} + +/** Whitelist one persisted cache entry: only known-safe fields survive, each + * type-checked and rebuilt. Everything else — poisoned or not — is dropped. */ +function whitelistPersisted(raw: unknown): Partial { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {} + const d = raw as Record + const out: Partial = {} + const state = str(d.state) + if (state && KNOWN_STATES.has(state)) out.state = state as ConnectionState + if (str(d.identity)) out.identity = d.identity as string + if (Array.isArray(d.entitlements)) { + const entitlements = d.entitlements.filter((e): e is string => typeof e === "string" && e !== "") + if (entitlements.length > 0) out.entitlements = entitlements + } + if (str(d.expires_at)) out.expires_at = d.expires_at as string + const devices = whitelistDevices(d.devices) + if (devices) out.devices = devices + if (str(d.validated_at)) out.validated_at = d.validated_at as string + return out +} + +function computeStale(state: ConnectionState, validated_at: string | null, now: number): boolean { + if (state !== "connected") return false + if (!validated_at) return true + const at = Date.parse(validated_at) + if (!Number.isFinite(at)) return true + return now - at > STALE_MS +} + +/** Derive the rendered status for one connection from its whitelisted cache + * entry, the in-flight overlay, and credential presence (the truth for + * "connected"). Output carries ONLY whitelisted fields. */ +function renderStatus( + id: ConnectionType, + persisted: Partial, + input: { inflight: boolean; credential: boolean; now: number }, +): ConnectionStatus { + let state: ConnectionState + if (input.inflight) state = "validating" + else if (persisted.state === "connected") state = input.credential ? "connected" : "needs-key" + else if (persisted.state) state = persisted.state + else state = input.credential ? "connected" : "needs-key" + + const validated_at = state === "needs-key" ? null : (persisted.validated_at ?? null) + const out: ConnectionStatus = { id, state, validated_at, stale: computeStale(state, validated_at, input.now) } + if (state !== "needs-key") { + if (persisted.identity) out.identity = persisted.identity + if (persisted.entitlements) out.entitlements = persisted.entitlements + if (persisted.expires_at) out.expires_at = persisted.expires_at + if (persisted.devices) out.devices = persisted.devices + } + return out +} + +function readCacheFile(file: string): Record { + try { + if (!existsSync(file)) return {} + const raw: unknown = JSON.parse(readFileSync(file, "utf8")) + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {} + return raw as Record + } catch { + return {} // missing/unreadable/unparseable → empty, never a throw + } +} + +export function synthesizeConnections(code: string, detail: string): string { + return JSON.stringify({ ok: false, connections: [], error: `${code}: ${detail}` }) +} + +export interface StatusInput { + file: string + overlay: ReadonlyMap> + hasCredential: (id: ConnectionType) => boolean + now?: number +} + +/** Pure body-builder over injectable inputs (profile.ts idiom); the route + * entrypoint below binds the real file/overlay/credential store. */ +export function statusBody(input: StatusInput): string { + const cache = readCacheFile(input.file) + const now = input.now ?? Date.now() + const connections = CONNECTION_IDS.map((id) => + renderStatus(id, whitelistPersisted(cache[id]), { + inflight: input.overlay.has(id), + credential: input.hasCredential(id), + now, + }), + ) + return JSON.stringify({ ok: true, connections, error: null }) +} + +/** GET /amicode/connections — never rejects; failures collapse into the one + * success shape like every other amicode route. */ +export function statusResponse(): string { + try { + return statusBody({ + file: connectionsFile(), + overlay: inflightOverlay, + hasCredential: (id) => readCredential(id) !== undefined, + }) + } catch (err) { + return synthesizeConnections("bad_output", String(err)) + } +} + +// --- probe validation --- + export type ProbeOutcome = "valid" | "invalid" | "unreachable" /** Injectable fetch seam — tests stub this; production uses global fetch. diff --git a/packages/opencode/test/server/amicode-connections.test.ts b/packages/opencode/test/server/amicode-connections.test.ts index c3a2d42f43..58f03ef662 100644 --- a/packages/opencode/test/server/amicode-connections.test.ts +++ b/packages/opencode/test/server/amicode-connections.test.ts @@ -5,13 +5,49 @@ // same env overrides production honors (AMICO_CLOUD_FILE / // AMICODE_CONNECTIONS_FILE), so the test seam and the deploy seam are one // mechanism (the #162 idiom). -import { describe, expect, test } from "bun:test" -import { probeCompanyCompute, type FetchImpl } from "@/server/amicode/connections" +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { mkdtempSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" +import { writeCredential } from "@/server/amicode/credentials" +import { + connectionsFile, + inflightOverlay, + probeCompanyCompute, + statusResponse, + STALE_MS, + statusBody, + type FetchImpl, +} from "@/server/amicode/connections" const respond = (status: number): FetchImpl => async () => ({ status }) +// Same env-override discipline as the credentials suite: point every file the +// module touches into a per-test tmp dir, restore after. +const ENV_KEYS = ["AMICO_CLOUD_FILE", "AMICO_PASQAL_FILE", "AMICODE_CONNECTIONS_FILE"] as const +let savedEnv: Record +let dir: string + +beforeEach(() => { + savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])) + dir = mkdtempSync(path.join(tmpdir(), "amicode-conn-")) + process.env.AMICO_CLOUD_FILE = path.join(dir, "cloud.json") + process.env.AMICO_PASQAL_FILE = path.join(dir, "pasqal.json") + process.env.AMICODE_CONNECTIONS_FILE = path.join(dir, "connections.json") + inflightOverlay.clear() +}) +afterEach(() => { + for (const k of ENV_KEYS) { + if (savedEnv[k] === undefined) delete process.env[k] + else process.env[k] = savedEnv[k] + } + inflightOverlay.clear() +}) + +const cloudCredential = { base_url: "https://solves.example.co", token: "tok-stored-secret" } + describe("probe classification (AC2)", () => { test("authorizer-rejection class: 401 → invalid", async () => { expect(await probeCompanyCompute("https://solves.example.co", "tok-1", respond(401))).toBe("invalid") @@ -49,3 +85,108 @@ describe("probe classification (AC2)", () => { expect(seenAuth).toBe("Bearer tok-secret-xyz") }) }) + +describe("status list rendering (redacting whitelist, AC3)", () => { + test("connections file honors the env override; default lives beside cloud.json", () => { + expect(connectionsFile()).toBe(path.join(dir, "connections.json")) + delete process.env.AMICODE_CONNECTIONS_FILE + expect(connectionsFile()).toContain(path.join(".amico", "connections.json")) + }) + + test("no credential, no cache → needs-key", () => { + const parsed = JSON.parse(statusResponse()) + expect(parsed.ok).toBe(true) + expect(parsed.error).toBeNull() + expect(parsed.connections).toEqual([ + { id: "company-compute", state: "needs-key", validated_at: null, stale: false }, + ]) + }) + + test("credential + fresh connected cache → connected, not stale", () => { + writeCredential("company-compute", cloudCredential) + const at = new Date().toISOString() + writeFileSync(connectionsFile(), JSON.stringify({ "company-compute": { state: "connected", validated_at: at } })) + const parsed = JSON.parse(statusResponse()) + expect(parsed.connections[0].state).toBe("connected") + expect(parsed.connections[0].validated_at).toBe(at) + expect(parsed.connections[0].stale).toBe(false) + }) + + test("connected cache older than STALE_MS → stale:true; unvalidated credential → connected + stale", () => { + writeCredential("company-compute", cloudCredential) + const old = new Date(Date.now() - STALE_MS - 60_000).toISOString() + writeFileSync(connectionsFile(), JSON.stringify({ "company-compute": { state: "connected", validated_at: old } })) + expect(JSON.parse(statusResponse()).connections[0].stale).toBe(true) + // a credential that exists but was never validated (e.g. CLI-written + // cloud.json) renders connected-but-stale, prompting a revalidate + writeFileSync(connectionsFile(), "{}") + const unvalidated = JSON.parse(statusResponse()).connections[0] + expect(unvalidated.state).toBe("connected") + expect(unvalidated.validated_at).toBeNull() + expect(unvalidated.stale).toBe(true) + }) + + test("connected cache but the credential file is gone → needs-key (credential file is the truth)", () => { + writeFileSync( + connectionsFile(), + JSON.stringify({ "company-compute": { state: "connected", validated_at: new Date().toISOString() } }), + ) + expect(JSON.parse(statusResponse()).connections[0].state).toBe("needs-key") + }) + + test("in-flight overlay wins: entry present → validating", () => { + writeCredential("company-compute", cloudCredential) + inflightOverlay.set("company-compute", { state: "validating" }) + expect(JSON.parse(statusResponse()).connections[0].state).toBe("validating") + }) + + test("unreadable/garbage cache file degrades to needs-key, never a throw", () => { + writeFileSync(connectionsFile(), "not json {{{") + const parsed = JSON.parse(statusResponse()) + expect(parsed.ok).toBe(true) + expect(parsed.connections[0].state).toBe("needs-key") + }) + + test("POISON: token/password keys seeded into EVERY input never reach the response", () => { + writeCredential("company-compute", cloudCredential) + // input 1: the cache file, poisoned at every level + writeFileSync( + connectionsFile(), + JSON.stringify({ + token: "POISON-top", + "company-compute": { + state: "connected", + validated_at: new Date().toISOString(), + identity: "kate", + token: "POISON-entry", + password: "POISON-password", + authorization: "Bearer POISON-header", + nested: { secret: "POISON-nested" }, + devices: [{ name: "qpu-1", state: "online", token: "POISON-device" }], + entitlements: ["solve", { token: "POISON-entitlement" }], + }, + }), + ) + // input 2: the in-memory overlay state + inflightOverlay.set("company-compute", { state: "validating", token: "POISON-overlay", secret: "POISON-mem" }) + const body = statusResponse() + expect(body).not.toContain("POISON") + expect(body).not.toContain("tok-stored-secret") // the stored credential itself must never surface + const entry = JSON.parse(body).connections[0] + const allowed = ["id", "state", "identity", "entitlements", "expires_at", "devices", "validated_at", "stale"] + for (const key of Object.keys(entry)) expect(allowed).toContain(key) + for (const device of entry.devices ?? []) { + for (const key of Object.keys(device)) expect(["id", "name", "state"]).toContain(key) + } + }) + + test("statusBody is a pure builder over injectable inputs (profile.ts idiom)", () => { + const file = path.join(dir, "alt-connections.json") + writeFileSync(file, JSON.stringify({ "company-compute": { state: "invalid", validated_at: "2026-07-19T00:00:00Z" } })) + const parsed = JSON.parse( + statusBody({ file, overlay: new Map(), hasCredential: () => true, now: Date.parse("2026-07-19T01:00:00Z") }), + ) + expect(parsed.connections[0].state).toBe("invalid") + expect(parsed.connections[0].stale).toBe(false) + }) +}) From d6efc05651af42d71c28061b470c05c162d84864 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Sun, 19 Jul 2026 21:15:54 -0400 Subject: [PATCH 09/44] =?UTF-8?q?test+feat(amicode):=20submit-credential?= =?UTF-8?q?=20path=20=E2=80=94=20probe=20=E2=86=92=20seam=20write=20?= =?UTF-8?q?=E2=86=92=20terminal=20status,=20one=20round=20trip=20(165=20AC?= =?UTF-8?q?1+AC2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Valid class writes via the #162 CredentialStore and answers 'connected' in the same response; 401 answers 'invalid' and writes NOTHING (an existing credential survives untouched); server/network trouble answers 'unreachable' with a fixed token-free message. While the probe runs the in-flight overlay renders 'validating' for concurrent GETs. Malformed bodies fail value-free without firing a probe. Status-cache writes ride the same whitelist + atomic writer, so a poisoned file is scrubbed on the next write. Co-Authored-By: Claude Fable 5 --- .../src/server/amicode/connections.ts | 110 +++++++++++++++++- .../test/server/amicode-connections.test.ts | 90 +++++++++++++- 2 files changed, 198 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/server/amicode/connections.ts b/packages/opencode/src/server/amicode/connections.ts index 9066393aef..a30d970646 100644 --- a/packages/opencode/src/server/amicode/connections.ts +++ b/packages/opencode/src/server/amicode/connections.ts @@ -9,7 +9,7 @@ import { existsSync, readFileSync } from "node:fs" import { homedir } from "node:os" import path from "node:path" -import { readCredential, type ConnectionType } from "./credentials" +import { atomicWriteFileSync, readCredential, writeCredential, type ConnectionType } from "./credentials" // --- status contract (parent #159 data contract; secret-free by construction) --- @@ -238,3 +238,111 @@ export async function probeCompanyCompute( if ((status >= 200 && status < 300) || status === 403 || status === 404) return "valid" return "unreachable" } + +// --- status cache writes: everything lands through the same whitelist, so a +// poisoned in-memory object can never serialize, and a poisoned file gets +// scrubbed on the next write. Atomic replace via the #162 writer. + +function persistStatus(id: ConnectionType, entry: Partial): void { + const file = connectionsFile() + const cache = readCacheFile(file) + const out: Record = {} + for (const key of CONNECTION_IDS) if (key in cache) out[key] = whitelistPersisted(cache[key]) + out[id] = whitelistPersisted(entry) + atomicWriteFileSync(file, JSON.stringify(out, null, 2) + "\n") +} + +function clearStatus(id: ConnectionType): void { + const file = connectionsFile() + const cache = readCacheFile(file) + const out: Record = {} + for (const key of CONNECTION_IDS) if (key !== id && key in cache) out[key] = whitelistPersisted(cache[key]) + atomicWriteFileSync(file, JSON.stringify(out, null, 2) + "\n") +} + +// --- mutation bodies (POST routes). One shape per route family, sibling +// discipline: never reject, ok:false + "code: detail" on failure. SECURITY: +// every failure message is a FIXED string — nothing the caller sent (token, +// base_url, anything an encoder rejects) is ever echoed. + +export function synthesizeConnection(code: string, detail: string): string { + return JSON.stringify({ ok: false, connection: null, error: `${code}: ${detail}` }) +} + +const MAX_BODY_BYTES = 16 * 1024 // credentials are small; bigger is a mistake + +export interface MutationDeps { + fetchImpl?: FetchImpl +} + +function renderCurrent(id: ConnectionType): string { + const cache = readCacheFile(connectionsFile()) + const connection = renderStatus(id, whitelistPersisted(cache[id]), { + inflight: inflightOverlay.has(id), + credential: readCredential(id) !== undefined, + now: Date.now(), + }) + return JSON.stringify({ ok: true, connection, error: null }) +} + +function parseMutationBody(rawBody: string): { id?: unknown; base_url?: unknown; token?: unknown } | undefined { + if (rawBody.length > MAX_BODY_BYTES) return undefined + try { + const parsed: unknown = JSON.parse(rawBody) + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined + return parsed as { id?: unknown; base_url?: unknown; token?: unknown } + } catch { + return undefined + } +} + +function isHttpUrl(value: string): boolean { + try { + const url = new URL(value) + return url.protocol === "http:" || url.protocol === "https:" + } catch { + return false + } +} + +/** POST /amicode/connections/credential — body {id:"company-compute", + * base_url, token}. Probe FIRST; only the auth-passed class writes through + * the #162 seam. The terminal status rides back in the SAME response; while + * the probe runs, the overlay renders "validating" for concurrent GETs. */ +export async function submitCredentialResponse(rawBody: string, deps: MutationDeps = {}): Promise { + const body = parseMutationBody(rawBody) + if (!body) return synthesizeConnection("bad_request", "body must be JSON {id, base_url, token}") + if (body.id !== "company-compute") { + if (body.id === "pasqal-cloud") + return synthesizeConnection("unsupported_connection", "pasqal-cloud lands in a later slice") + return synthesizeConnection("unknown_connection", "id must be a known connection id") + } + const base = typeof body.base_url === "string" ? body.base_url.trim().replace(/\/+$/, "") : "" + const token = typeof body.token === "string" ? body.token.trim() : "" + if (base === "" || token === "") + return synthesizeConnection("bad_request", "non-empty base_url and token are required") + if (!isHttpUrl(base)) return synthesizeConnection("bad_request", "base_url must be an http(s) URL") + + const id: ConnectionType = "company-compute" + inflightOverlay.set(id, { state: "validating" }) + let outcome: ProbeOutcome + try { + outcome = await probeCompanyCompute(base, token, deps.fetchImpl) + } finally { + inflightOverlay.delete(id) + } + const validated_at = new Date().toISOString() + if (outcome === "valid") { + try { + writeCredential(id, { base_url: base, token }) + } catch { + // value-free by contract: never echo what the encoder rejected + return synthesizeConnection("write_failed", "credential could not be saved") + } + persistStatus(id, { state: "connected", validated_at }) + } else { + // nothing written — an existing credential (if any) stays untouched + persistStatus(id, { state: outcome, validated_at }) + } + return renderCurrent(id) +} diff --git a/packages/opencode/test/server/amicode-connections.test.ts b/packages/opencode/test/server/amicode-connections.test.ts index 58f03ef662..7c45e07de0 100644 --- a/packages/opencode/test/server/amicode-connections.test.ts +++ b/packages/opencode/test/server/amicode-connections.test.ts @@ -9,7 +9,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test" import { mkdtempSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import path from "node:path" -import { writeCredential } from "@/server/amicode/credentials" +import { readCredential, writeCredential } from "@/server/amicode/credentials" import { connectionsFile, inflightOverlay, @@ -17,6 +17,7 @@ import { statusResponse, STALE_MS, statusBody, + submitCredentialResponse, type FetchImpl, } from "@/server/amicode/connections" @@ -190,3 +191,90 @@ describe("status list rendering (redacting whitelist, AC3)", () => { expect(parsed.connections[0].stale).toBe(false) }) }) + +describe("submit credential — probe → save → terminal status, one round trip (AC1, AC2)", () => { + test("submit: valid key → credential written through the #162 seam + connected in the SAME response (AC1)", async () => { + const body = JSON.stringify({ id: "company-compute", base_url: "https://solves.example.co/", token: "tok-good" }) + const parsed = JSON.parse(await submitCredentialResponse(body, { fetchImpl: respond(404) })) + expect(parsed.ok).toBe(true) + expect(parsed.error).toBeNull() + expect(parsed.connection.id).toBe("company-compute") + expect(parsed.connection.state).toBe("connected") + expect(parsed.connection.stale).toBe(false) + expect(Date.now() - Date.parse(parsed.connection.validated_at)).toBeLessThan(10_000) + // written via the seam: the frozen byte shape, trailing slash trimmed + expect(readCredential("company-compute")).toEqual({ base_url: "https://solves.example.co", token: "tok-good" }) + // a follow-up GET agrees without another probe + expect(JSON.parse(statusResponse()).connections[0].state).toBe("connected") + }) + + test("submit: 401 → invalid, NOTHING written; a pre-existing credential survives untouched (AC2)", async () => { + const body = JSON.stringify({ id: "company-compute", base_url: "https://solves.example.co", token: "tok-bad" }) + const rejected = JSON.parse(await submitCredentialResponse(body, { fetchImpl: respond(401) })) + expect(rejected.ok).toBe(true) + expect(rejected.connection.state).toBe("invalid") + expect(readCredential("company-compute")).toBeUndefined() + expect(JSON.parse(statusResponse()).connections[0].state).toBe("invalid") + // now with an older good credential on disk: the failed attempt must not clobber it + writeCredential("company-compute", cloudCredential) + await submitCredentialResponse(body, { fetchImpl: respond(401) }) + expect(readCredential("company-compute")).toEqual(cloudCredential) + }) + + test("submit: network/server trouble → unreachable, nothing written, token-free fixed message (AC2)", async () => { + const boom: FetchImpl = async () => { + throw new Error("connect ECONNREFUSED 10.0.0.1:443") + } + for (const fetchImpl of [boom, respond(500)]) { + const raw = await submitCredentialResponse( + JSON.stringify({ id: "company-compute", base_url: "https://solves.example.co", token: "tok-hidden" }), + { fetchImpl }, + ) + const parsed = JSON.parse(raw) + expect(parsed.connection.state).toBe("unreachable") + expect(raw).not.toContain("tok-hidden") + expect(readCredential("company-compute")).toBeUndefined() + } + }) + + test("submit: while the probe is in flight, concurrent GETs render 'validating'; terminal state clears it", async () => { + let release!: (v: { status: number }) => void + const gate = new Promise<{ status: number }>((resolve) => (release = resolve)) + const blocking: FetchImpl = () => gate + const pending = submitCredentialResponse( + JSON.stringify({ id: "company-compute", base_url: "https://solves.example.co", token: "tok-slow" }), + { fetchImpl: blocking }, + ) + await Bun.sleep(0) // let the submit reach the probe await + expect(JSON.parse(statusResponse()).connections[0].state).toBe("validating") + release({ status: 200 }) + expect(JSON.parse(await pending).connection.state).toBe("connected") + expect(inflightOverlay.size).toBe(0) + expect(JSON.parse(statusResponse()).connections[0].state).toBe("connected") + }) + + test("submit: malformed bodies → ok:false with VALUE-FREE errors; nothing written, no probe fired", async () => { + let probes = 0 + const counting: FetchImpl = async () => { + probes++ + return { status: 200 } + } + const cases = [ + "not json {{{", + JSON.stringify({ id: "company-compute", token: "tok-orphan-xyz" }), // base_url required in body + JSON.stringify({ id: "company-compute", base_url: "https://x.co", token: "" }), + JSON.stringify({ id: "company-compute", base_url: "ftp://x.co", token: "tok-orphan-xyz" }), + JSON.stringify({ id: "pasqal-cloud", base_url: "https://x.co", token: "tok-orphan-xyz" }), // later slice + JSON.stringify({ id: "who-knows", base_url: "https://x.co", token: "tok-orphan-xyz" }), + ] + for (const body of cases) { + const raw = await submitCredentialResponse(body, { fetchImpl: counting }) + const parsed = JSON.parse(raw) + expect(parsed.ok).toBe(false) + expect(parsed.connection).toBeNull() + expect(raw).not.toContain("tok-orphan-xyz") // error text never echoes what was rejected + } + expect(probes).toBe(0) + expect(readCredential("company-compute")).toBeUndefined() + }) +}) From 889eb39223a29a435a937cc3105f17f01d1801ec Mon Sep 17 00:00:00 2001 From: kate bonner Date: Sun, 19 Jul 2026 21:17:17 -0400 Subject: [PATCH 10/44] test+feat(amicode): disconnect + revalidate round out the lifecycle (165 AC4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Disconnect clears the credential via the #162 seam, drops the cache entry, answers needs-key; idempotent on an absent credential. Revalidate re-runs the probe from the STORED credential — the secret never rides the request — and refreshes validated_at on every outcome; invalid keeps the credential (re-entry is the user's call, only disconnect deletes). Co-Authored-By: Claude Fable 5 --- .../src/server/amicode/connections.ts | 51 +++++++++++++- .../test/server/amicode-connections.test.ts | 70 +++++++++++++++++++ 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/server/amicode/connections.ts b/packages/opencode/src/server/amicode/connections.ts index a30d970646..e0448595a0 100644 --- a/packages/opencode/src/server/amicode/connections.ts +++ b/packages/opencode/src/server/amicode/connections.ts @@ -9,7 +9,7 @@ import { existsSync, readFileSync } from "node:fs" import { homedir } from "node:os" import path from "node:path" -import { atomicWriteFileSync, readCredential, writeCredential, type ConnectionType } from "./credentials" +import { atomicWriteFileSync, clearCredential, readCredential, writeCredential, type ConnectionType } from "./credentials" // --- status contract (parent #159 data contract; secret-free by construction) --- @@ -346,3 +346,52 @@ export async function submitCredentialResponse(rawBody: string, deps: MutationDe } return renderCurrent(id) } + +/** id-only mutation bodies (disconnect/revalidate) — the secret NEVER rides + * these requests; revalidation reads the stored credential server-side. */ +function parseIdBody(rawBody: string): ConnectionType | undefined { + const body = parseMutationBody(rawBody) + if (!body) return undefined + const id = body.id + if (typeof id !== "string" || !(CONNECTION_IDS as string[]).includes(id)) return undefined + return id as ConnectionType +} + +/** POST /amicode/connections/disconnect — body {id}. Clears the credential + * through the #162 seam and drops the cache entry; status becomes needs-key. + * Idempotent: disconnecting an absent credential is a no-op. */ +export function disconnectResponse(rawBody: string): string { + const id = parseIdBody(rawBody) + if (!id) return synthesizeConnection("bad_request", "body must be JSON {id} with a known connection id") + try { + clearCredential(id) + clearStatus(id) + } catch { + return synthesizeConnection("write_failed", "credential could not be cleared") + } + return renderCurrent(id) +} + +/** POST /amicode/connections/revalidate — body {id}. Re-runs the probe from + * the STORED credential and refreshes validated_at; the secret never rides + * the request. Absent credential → needs-key, no probe fired. */ +export async function revalidateResponse(rawBody: string, deps: MutationDeps = {}): Promise { + const id = parseIdBody(rawBody) + if (!id) return synthesizeConnection("bad_request", "body must be JSON {id} with a known connection id") + const credential = readCredential("company-compute") + if (!credential) { + clearStatus(id) // a status claim without a credential behind it is noise + return renderCurrent(id) + } + inflightOverlay.set(id, { state: "validating" }) + let outcome: ProbeOutcome + try { + outcome = await probeCompanyCompute(credential.base_url, credential.token, deps.fetchImpl) + } finally { + inflightOverlay.delete(id) + } + // credential is kept on EVERY outcome — invalid signals re-entry, it does + // not destroy user data; only disconnect removes the file. + persistStatus(id, { state: outcome === "valid" ? "connected" : outcome, validated_at: new Date().toISOString() }) + return renderCurrent(id) +} diff --git a/packages/opencode/test/server/amicode-connections.test.ts b/packages/opencode/test/server/amicode-connections.test.ts index 7c45e07de0..9719d45595 100644 --- a/packages/opencode/test/server/amicode-connections.test.ts +++ b/packages/opencode/test/server/amicode-connections.test.ts @@ -18,6 +18,8 @@ import { STALE_MS, statusBody, submitCredentialResponse, + disconnectResponse, + revalidateResponse, type FetchImpl, } from "@/server/amicode/connections" @@ -278,3 +280,71 @@ describe("submit credential — probe → save → terminal status, one round tr expect(readCredential("company-compute")).toBeUndefined() }) }) + +describe("disconnect + revalidate (AC4)", () => { + test("disconnect clears the credential; status becomes needs-key; idempotent", async () => { + await submitCredentialResponse( + JSON.stringify({ id: "company-compute", base_url: "https://solves.example.co", token: "tok-good" }), + { fetchImpl: respond(200) }, + ) + const parsed = JSON.parse(disconnectResponse(JSON.stringify({ id: "company-compute" }))) + expect(parsed.ok).toBe(true) + expect(parsed.connection).toEqual({ id: "company-compute", state: "needs-key", validated_at: null, stale: false }) + expect(readCredential("company-compute")).toBeUndefined() + expect(JSON.parse(statusResponse()).connections[0].state).toBe("needs-key") + // disconnecting an already-absent credential is a no-op, not an error + expect(JSON.parse(disconnectResponse(JSON.stringify({ id: "company-compute" }))).ok).toBe(true) + }) + + test("disconnect: malformed body / unknown id → ok:false", () => { + expect(JSON.parse(disconnectResponse("nope")).ok).toBe(false) + expect(JSON.parse(disconnectResponse(JSON.stringify({ id: "who-knows" }))).ok).toBe(false) + }) + + test("revalidate runs from the STORED credential — no secret rides the request (AC4)", async () => { + writeCredential("company-compute", cloudCredential) + const stale = new Date(Date.now() - STALE_MS - 60_000).toISOString() + writeFileSync(connectionsFile(), JSON.stringify({ "company-compute": { state: "connected", validated_at: stale } })) + let seenUrl = "" + let seenAuth = "" + const capture: FetchImpl = async (url, init) => { + seenUrl = url + seenAuth = init.headers.authorization ?? "" + return { status: 404 } + } + const request = JSON.stringify({ id: "company-compute" }) // the whole body — token never leaves the server + const parsed = JSON.parse(await revalidateResponse(request, { fetchImpl: capture })) + expect(seenUrl).toBe("https://solves.example.co/solves/__validate__/status") + expect(seenAuth).toBe(`Bearer ${cloudCredential.token}`) + expect(parsed.ok).toBe(true) + expect(parsed.connection.state).toBe("connected") + expect(parsed.connection.stale).toBe(false) + expect(Date.parse(parsed.connection.validated_at)).toBeGreaterThan(Date.parse(stale)) // timestamp refreshed + }) + + test("revalidate: 401 → invalid but the stored credential is KEPT; unreachable refreshes the timestamp too", async () => { + writeCredential("company-compute", cloudCredential) + const invalid = JSON.parse( + await revalidateResponse(JSON.stringify({ id: "company-compute" }), { fetchImpl: respond(401) }), + ) + expect(invalid.connection.state).toBe("invalid") + expect(readCredential("company-compute")).toEqual(cloudCredential) // user data survives; re-entry is their call + const unreachable = JSON.parse( + await revalidateResponse(JSON.stringify({ id: "company-compute" }), { fetchImpl: respond(503) }), + ) + expect(unreachable.connection.state).toBe("unreachable") + expect(unreachable.connection.validated_at).not.toBeNull() + }) + + test("revalidate with no stored credential → needs-key, no probe fired", async () => { + let probes = 0 + const counting: FetchImpl = async () => { + probes++ + return { status: 200 } + } + const parsed = JSON.parse(await revalidateResponse(JSON.stringify({ id: "company-compute" }), { fetchImpl: counting })) + expect(parsed.ok).toBe(true) + expect(parsed.connection.state).toBe("needs-key") + expect(probes).toBe(0) + }) +}) From fd6df1c760623f1428cbdee7499e8bce07934686 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Sun, 19 Jul 2026 21:18:55 -0400 Subject: [PATCH 11/44] =?UTF-8?q?test+feat(amicode):=20loopback=20guard=20?= =?UTF-8?q?=E2=80=94=20mutations=20refuse=20non-loopback=20binds=20with=20?= =?UTF-8?q?a=20distinct=20error=20(165=20AC5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isLoopbackHostname recognizes the mdns gate's loopback family widened to 127/8 + the v4-mapped form; undefined (in-process webHandler, no socket) counts as loopback. Server.listen will record the bind at listen time; setBindHostname doubles as the test seam, and MutationDeps takes a pure bindHostname override. Refusal code: non_loopback. The read-only status route keeps serving. Co-Authored-By: Claude Fable 5 --- .../src/server/amicode/connections.ts | 40 ++++++++++++++- .../test/server/amicode-connections.test.ts | 51 +++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/server/amicode/connections.ts b/packages/opencode/src/server/amicode/connections.ts index e0448595a0..3399548087 100644 --- a/packages/opencode/src/server/amicode/connections.ts +++ b/packages/opencode/src/server/amicode/connections.ts @@ -269,10 +269,42 @@ export function synthesizeConnection(code: string, detail: string): string { return JSON.stringify({ ok: false, connection: null, error: `${code}: ${detail}` }) } +// --- loopback guard: credential mutations serve LOCAL callers only. The bind +// hostname is recorded by Server.listen (server.ts) at listen time; the +// in-process webHandler never binds a socket, so "never recorded" counts as +// loopback. setBindHostname doubles as the injectable test seam. + +let bindHostname: string | undefined + +export function setBindHostname(hostname: string | undefined): void { + bindHostname = hostname +} + +/** Same loopback family the mdns gate recognizes (server.ts), widened to the + * whole 127/8 block and the v4-mapped form. undefined = in-process handler. */ +export function isLoopbackHostname(hostname: string | undefined): boolean { + if (hostname === undefined) return true + const host = hostname.toLowerCase() + if (host === "localhost" || host === "::1") return true + if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host)) return true + if (host.startsWith("::ffff:127.")) return true + return false +} + +/** The distinct refusal every mutation route answers on a non-loopback bind + * (AC5); undefined when the bind is fine. */ +function loopbackRefusal(bind: string | undefined): string | undefined { + if (isLoopbackHostname(bind)) return undefined + return synthesizeConnection("non_loopback", "credential mutations serve loopback binds only") +} + const MAX_BODY_BYTES = 16 * 1024 // credentials are small; bigger is a mistake export interface MutationDeps { fetchImpl?: FetchImpl + /** override the recorded bind hostname (pure-injection alternative to + * setBindHostname) */ + bindHostname?: string } function renderCurrent(id: ConnectionType): string { @@ -310,6 +342,8 @@ function isHttpUrl(value: string): boolean { * the #162 seam. The terminal status rides back in the SAME response; while * the probe runs, the overlay renders "validating" for concurrent GETs. */ export async function submitCredentialResponse(rawBody: string, deps: MutationDeps = {}): Promise { + const refusal = loopbackRefusal(deps.bindHostname ?? bindHostname) + if (refusal) return refusal const body = parseMutationBody(rawBody) if (!body) return synthesizeConnection("bad_request", "body must be JSON {id, base_url, token}") if (body.id !== "company-compute") { @@ -360,7 +394,9 @@ function parseIdBody(rawBody: string): ConnectionType | undefined { /** POST /amicode/connections/disconnect — body {id}. Clears the credential * through the #162 seam and drops the cache entry; status becomes needs-key. * Idempotent: disconnecting an absent credential is a no-op. */ -export function disconnectResponse(rawBody: string): string { +export function disconnectResponse(rawBody: string, deps: MutationDeps = {}): string { + const refusal = loopbackRefusal(deps.bindHostname ?? bindHostname) + if (refusal) return refusal const id = parseIdBody(rawBody) if (!id) return synthesizeConnection("bad_request", "body must be JSON {id} with a known connection id") try { @@ -376,6 +412,8 @@ export function disconnectResponse(rawBody: string): string { * the STORED credential and refreshes validated_at; the secret never rides * the request. Absent credential → needs-key, no probe fired. */ export async function revalidateResponse(rawBody: string, deps: MutationDeps = {}): Promise { + const refusal = loopbackRefusal(deps.bindHostname ?? bindHostname) + if (refusal) return refusal const id = parseIdBody(rawBody) if (!id) return synthesizeConnection("bad_request", "body must be JSON {id} with a known connection id") const credential = readCredential("company-compute") diff --git a/packages/opencode/test/server/amicode-connections.test.ts b/packages/opencode/test/server/amicode-connections.test.ts index 9719d45595..45196ca9da 100644 --- a/packages/opencode/test/server/amicode-connections.test.ts +++ b/packages/opencode/test/server/amicode-connections.test.ts @@ -20,6 +20,8 @@ import { submitCredentialResponse, disconnectResponse, revalidateResponse, + isLoopbackHostname, + setBindHostname, type FetchImpl, } from "@/server/amicode/connections" @@ -47,6 +49,7 @@ afterEach(() => { else process.env[k] = savedEnv[k] } inflightOverlay.clear() + setBindHostname(undefined) }) const cloudCredential = { base_url: "https://solves.example.co", token: "tok-stored-secret" } @@ -348,3 +351,51 @@ describe("disconnect + revalidate (AC4)", () => { expect(probes).toBe(0) }) }) + +describe("loopback guard on mutations (AC5)", () => { + test("loopback classification: 127/8, localhost, ::1 (and v4-mapped) are loopback; wildcard/LAN binds are not", () => { + for (const host of [undefined, "127.0.0.1", "127.1.2.3", "localhost", "LOCALHOST", "::1", "::ffff:127.0.0.1"]) { + expect(isLoopbackHostname(host)).toBe(true) + } + for (const host of ["0.0.0.0", "::", "192.168.1.5", "10.0.0.2", "example.co", ""]) { + expect(isLoopbackHostname(host)).toBe(false) + } + }) + + test("bound beyond loopback → every mutation refuses with the DISTINCT error; nothing happens", async () => { + setBindHostname("0.0.0.0") + let probes = 0 + const counting: FetchImpl = async () => { + probes++ + return { status: 200 } + } + const submit = JSON.parse( + await submitCredentialResponse( + JSON.stringify({ id: "company-compute", base_url: "https://solves.example.co", token: "tok-lan" }), + { fetchImpl: counting }, + ), + ) + const disconnect = JSON.parse(disconnectResponse(JSON.stringify({ id: "company-compute" }))) + const revalidate = JSON.parse(await revalidateResponse(JSON.stringify({ id: "company-compute" }), { fetchImpl: counting })) + for (const parsed of [submit, disconnect, revalidate]) { + expect(parsed.ok).toBe(false) + expect(parsed.error).toStartWith("non_loopback:") + } + expect(probes).toBe(0) + expect(readCredential("company-compute")).toBeUndefined() + // the read-only status route still serves + expect(JSON.parse(statusResponse()).ok).toBe(true) + }) + + test("back on a loopback bind, mutations serve again", async () => { + setBindHostname("127.0.0.1") + const parsed = JSON.parse( + await submitCredentialResponse( + JSON.stringify({ id: "company-compute", base_url: "https://solves.example.co", token: "tok-good" }), + { fetchImpl: respond(200) }, + ), + ) + expect(parsed.ok).toBe(true) + expect(parsed.connection.state).toBe("connected") + }) +}) From 7c144d9cf4cd65964f9db367e31c59eebea58cd3 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Sun, 19 Jul 2026 21:24:17 -0400 Subject: [PATCH 12/44] test+feat(amicode): register /amicode/connections routes; standalone-serve lifecycle with no extension host (165 AC6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route block rides the sibling raw-route idiom + the same authOnlyRouterLayer auth wrapper (#163): GET /amicode/connections, POST /amicode/connections/{credential,disconnect,revalidate}. The credential rides the POST body (library idiom) — never query params. Server.listen records its bind hostname so mutations refuse beyond loopback; proven over live HTTP against real 0.0.0.0 and 127.0.0.1 listeners, with a node:http stub standing in for the solve service. Route-level auth (401 before any handler), poison, and invalid-class coverage included. Co-Authored-By: Claude Fable 5 --- .../src/server/amicode/connections.ts | 36 ++- .../server/routes/instance/httpapi/server.ts | 43 ++- packages/opencode/src/server/server.ts | 5 + .../server/amicode-connections-routes.test.ts | 255 ++++++++++++++++++ .../test/server/amicode-connections.test.ts | 13 +- 5 files changed, 336 insertions(+), 16 deletions(-) create mode 100644 packages/opencode/test/server/amicode-connections-routes.test.ts diff --git a/packages/opencode/src/server/amicode/connections.ts b/packages/opencode/src/server/amicode/connections.ts index 3399548087..c03ee2b4b9 100644 --- a/packages/opencode/src/server/amicode/connections.ts +++ b/packages/opencode/src/server/amicode/connections.ts @@ -9,7 +9,13 @@ import { existsSync, readFileSync } from "node:fs" import { homedir } from "node:os" import path from "node:path" -import { atomicWriteFileSync, clearCredential, readCredential, writeCredential, type ConnectionType } from "./credentials" +import { + atomicWriteFileSync, + clearCredential, + readCredential, + writeCredential, + type ConnectionType, +} from "./credentials" // --- status contract (parent #159 data contract; secret-free by construction) --- @@ -83,6 +89,10 @@ function str(v: unknown): string | undefined { return typeof v === "string" && v !== "" ? v : undefined } +function isKnownState(v: string): v is ConnectionState { + return KNOWN_STATES.has(v) +} + function whitelistDevices(v: unknown): ConnectionDevice[] | undefined { if (!Array.isArray(v)) return undefined const out: ConnectionDevice[] = [] @@ -90,9 +100,12 @@ function whitelistDevices(v: unknown): ConnectionDevice[] | undefined { if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue const d = raw as Record const device: ConnectionDevice = {} - if (str(d.id)) device.id = d.id as string - if (str(d.name)) device.name = d.name as string - if (str(d.state)) device.state = d.state as string + const id = str(d.id) + const name = str(d.name) + const state = str(d.state) + if (id) device.id = id + if (name) device.name = name + if (state) device.state = state if (Object.keys(device).length > 0) out.push(device) } return out.length > 0 ? out : undefined @@ -105,16 +118,19 @@ function whitelistPersisted(raw: unknown): Partial { const d = raw as Record const out: Partial = {} const state = str(d.state) - if (state && KNOWN_STATES.has(state)) out.state = state as ConnectionState - if (str(d.identity)) out.identity = d.identity as string + if (state && isKnownState(state)) out.state = state + const identity = str(d.identity) + if (identity) out.identity = identity if (Array.isArray(d.entitlements)) { const entitlements = d.entitlements.filter((e): e is string => typeof e === "string" && e !== "") if (entitlements.length > 0) out.entitlements = entitlements } - if (str(d.expires_at)) out.expires_at = d.expires_at as string + const expires = str(d.expires_at) + if (expires) out.expires_at = expires const devices = whitelistDevices(d.devices) if (devices) out.devices = devices - if (str(d.validated_at)) out.validated_at = d.validated_at as string + const validated = str(d.validated_at) + if (validated) out.validated_at = validated return out } @@ -387,8 +403,8 @@ function parseIdBody(rawBody: string): ConnectionType | undefined { const body = parseMutationBody(rawBody) if (!body) return undefined const id = body.id - if (typeof id !== "string" || !(CONNECTION_IDS as string[]).includes(id)) return undefined - return id as ConnectionType + if (typeof id !== "string") return undefined + return CONNECTION_IDS.find((known) => known === id) } /** POST /amicode/connections/disconnect — body {id}. Clears the credential diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 0e3daeab91..d5f19ceaea 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -65,6 +65,7 @@ import * as AmicodeDashboard from "@/server/amicode/dashboard" import * as AmicodeWidgetFrame from "@/server/amicode/widget-frame-html" import * as AmicodeLibrary from "@/server/amicode/library" import * as AmicodeProfile from "@/server/amicode/profile" +import * as AmicodeConnections from "@/server/amicode/connections" import { ServerAuth } from "@/server/auth" import { InstanceHttpApi, RootHttpApi } from "./api" import { Api } from "@opencode-ai/server/api" @@ -269,9 +270,7 @@ const amicodeProblemsRoute = HttpRouter.use((router) => const amicodeWidgetsRoute = HttpRouter.use((router) => Effect.gen(function* () { yield* router.add("GET", "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/amicode/widgets", () => - Effect.sync(() => - HttpServerResponse.text(AmicodeWidgets.widgetsResponse(), { contentType: "application/json" }), - ), + Effect.sync(() => HttpServerResponse.text(AmicodeWidgets.widgetsResponse(), { contentType: "application/json" })), ) // The frame document is served (not srcdoc) so it carries its OWN CSP // header — srcdoc would inherit the app's CSP, which forbids the inline @@ -319,6 +318,43 @@ const amicodeWidgetsRoute = HttpRouter.use((router) => }), ).pipe(Layer.provide(authOnlyRouterLayer)) +// amicode: Connections panel routes (spec #159/S3) — Company Compute connect +// path. Same raw-route idiom + auth as the problems routes; body-builders +// live in amicode/connections.ts and never reject. SECURITY: the credential +// rides the POST BODY (the library idiom) — never query params, never URLs; +// mutation routes refuse non-loopback binds inside the body-builders. +const amicodeConnectionsRoute = HttpRouter.use((router) => + Effect.gen(function* () { + yield* router.add("GET", "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/amicode/connections", () => + Effect.sync(() => + HttpServerResponse.text(AmicodeConnections.statusResponse(), { contentType: "application/json" }), + ), + ) + yield* router.add("POST", "/amicode/connections/credential", (request) => + Effect.gen(function* () { + const body = yield* Effect.orDie(request.text) + const out = yield* Effect.promise(() => AmicodeConnections.submitCredentialResponse(body)) + return HttpServerResponse.text(out, { contentType: "application/json" }) + }), + ) + yield* router.add("POST", "/amicode/connections/disconnect", (request) => + Effect.gen(function* () { + const body = yield* Effect.orDie(request.text) + return HttpServerResponse.text(AmicodeConnections.disconnectResponse(body), { + contentType: "application/json", + }) + }), + ) + yield* router.add("POST", "/amicode/connections/revalidate", (request) => + Effect.gen(function* () { + const body = yield* Effect.orDie(request.text) + const out = yield* Effect.promise(() => AmicodeConnections.revalidateResponse(body)) + return HttpServerResponse.text(out, { contentType: "application/json" }) + }), + ) + }), +).pipe(Layer.provide(authOnlyRouterLayer)) + const uiRoute = HttpRouter.use((router) => Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -350,6 +386,7 @@ export function createRoutes( amicodeVaultsRoute, amicodeProblemsRoute, amicodeWidgetsRoute, + amicodeConnectionsRoute, uiRoute, ).pipe( Layer.provide([ diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 93e452ced0..ff8b2ebb9b 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -6,6 +6,7 @@ import { HttpRouter, HttpServer } from "effect/unstable/http" import { OpenApi } from "effect/unstable/httpapi" import { createServer } from "node:http" import { MDNS } from "./mdns" +import * as AmicodeConnections from "./amicode/connections" import { HttpApiApp } from "./routes/instance/httpapi/server" import { disposeMiddleware } from "./routes/instance/httpapi/lifecycle" import { WebSocketTracker } from "./routes/instance/httpapi/websocket-tracker" @@ -81,6 +82,10 @@ export async function listen(opts: ListenOptions): Promise { const listenEffect: (opts: ListenOptions) => Effect.Effect = Effect.fn("Server.listen")( function* (opts: ListenOptions) { + // amicode: record the bind so credential-mutation routes can refuse to + // serve beyond loopback (amicode#165 AC5). Last listener wins — the + // in-process webHandler never binds, so "never recorded" stays loopback. + yield* Effect.sync(() => AmicodeConnections.setBindHostname(opts.hostname)) const state = yield* startWithPortFallback(opts) const address = yield* tcpAddress(state) const listenerUrl = makeURL(opts.hostname, address.port) diff --git a/packages/opencode/test/server/amicode-connections-routes.test.ts b/packages/opencode/test/server/amicode-connections-routes.test.ts new file mode 100644 index 0000000000..7c710aac30 --- /dev/null +++ b/packages/opencode/test/server/amicode-connections-routes.test.ts @@ -0,0 +1,255 @@ +// AMICODE: Connections ROUTE tests (amicode#165 AC6, plus AC2/AC3/AC5 at the +// route level) — the full Company Compute lifecycle against the same route +// tree standalone serve binds, with NO VSCode/extension host involved. A local +// node:http stub stands in for the solve service; the only network is +// loopback. Auth rides the same registration wrapper as every amicode route +// (per #163), asserted here with a configured password. +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { createServer } from "node:http" +import type { AddressInfo } from "node:net" +import { mkdtempSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" +import { ConfigProvider, Layer } from "effect" +import { HttpRouter } from "effect/unstable/http" +import { HttpApiApp } from "@/server/routes/instance/httpapi/server" +import { ServerAuth } from "@/server/auth" +import { readCredential } from "@/server/amicode/credentials" +import { connectionsFile, inflightOverlay, setBindHostname } from "@/server/amicode/connections" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances } from "../fixture/fixture" + +// --- the in-process app over the SAME routes standalone serve binds +// (httpapi-instance-route-auth.test.ts idiom) --- +function app(input: { password?: string; username?: string } = {}) { + const handler = HttpRouter.toWebHandler( + HttpApiApp.routes.pipe( + Layer.provide( + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + OPENCODE_SERVER_PASSWORD: input.password, + OPENCODE_SERVER_USERNAME: input.username, + }), + ), + ), + ), + { disableLogger: true }, + ).handler + + return { + fetch: (request: Request) => handler(request, HttpApiApp.context), + request(input: string | URL | Request, init?: RequestInit) { + return this.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init)) + }, + } +} + +function basic(username: string, password: string) { + return ServerAuth.header({ username, password }) ?? "" +} + +// --- local stub solve service: answers the probe with a scripted status and +// records what it saw, so tests can assert the token rode the header only --- +type StubSeen = { method: string; url: string; authorization: string | undefined } +async function stubSolveService(status: () => number) { + const seen: StubSeen[] = [] + const server = createServer((req, res) => { + seen.push({ method: req.method ?? "", url: req.url ?? "", authorization: req.headers.authorization }) + res.statusCode = status() + res.end() + }) + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) + const { port } = server.address() as AddressInfo + return { + url: `http://127.0.0.1:${port}`, + seen, + close: () => new Promise((resolve) => server.close(() => resolve())), + } +} + +const post = (body: unknown): RequestInit => ({ method: "POST", body: JSON.stringify(body) }) + +const ENV_KEYS = ["AMICO_CLOUD_FILE", "AMICO_PASQAL_FILE", "AMICODE_CONNECTIONS_FILE"] as const +let savedEnv: Record +let dir: string + +beforeEach(() => { + savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])) + dir = mkdtempSync(path.join(tmpdir(), "amicode-conn-routes-")) + process.env.AMICO_CLOUD_FILE = path.join(dir, "cloud.json") + process.env.AMICO_PASQAL_FILE = path.join(dir, "pasqal.json") + process.env.AMICODE_CONNECTIONS_FILE = path.join(dir, "connections.json") + inflightOverlay.clear() +}) +afterEach(() => { + for (const k of ENV_KEYS) { + if (savedEnv[k] === undefined) delete process.env[k] + else process.env[k] = savedEnv[k] + } + inflightOverlay.clear() + setBindHostname(undefined) +}) + +describe("connections routes — full lifecycle, no extension host (AC6)", () => { + test("needs-key → submit → connected → revalidate → disconnect → needs-key, one round trip each", async () => { + const server = app() + let probeStatus = 404 // authorizer passed; fake task not found → valid + const stub = await stubSolveService(() => probeStatus) + try { + const initial = await (await server.request("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/amicode/connections")).json() + expect(initial.ok).toBe(true) + expect(initial.connections).toEqual([ + { id: "company-compute", state: "needs-key", validated_at: null, stale: false }, + ]) + + // submit: secret rides the POST body (library idiom), never a query param + const submitted = await ( + await server.request( + "/amicode/connections/credential", + post({ id: "company-compute", base_url: stub.url, token: "tok-lifecycle" }), + ) + ).json() + expect(submitted.ok).toBe(true) + expect(submitted.connection.state).toBe("connected") // terminal status in the SAME response (AC1) + expect(readCredential("company-compute")).toEqual({ base_url: stub.url, token: "tok-lifecycle" }) + expect(stub.seen).toHaveLength(1) + expect(stub.seen[0].url).toBe("/solves/__validate__/status") + expect(stub.seen[0].url).not.toContain("tok-lifecycle") + expect(stub.seen[0].authorization).toBe("Bearer tok-lifecycle") + + const connected = await (await server.request("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/amicode/connections")).json() + expect(connected.connections[0].state).toBe("connected") + expect(connected.connections[0].stale).toBe(false) + + // revalidate: id-only body — the stored secret never rides the request (AC4) + probeStatus = 200 + const revalidated = await ( + await server.request("/amicode/connections/revalidate", post({ id: "company-compute" })) + ).json() + expect(revalidated.connection.state).toBe("connected") + expect(stub.seen).toHaveLength(2) + expect(stub.seen[1].authorization).toBe("Bearer tok-lifecycle") + + const disconnected = await ( + await server.request("/amicode/connections/disconnect", post({ id: "company-compute" })) + ).json() + expect(disconnected.connection.state).toBe("needs-key") + expect(readCredential("company-compute")).toBeUndefined() + + const final = await (await server.request("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/amicode/connections")).json() + expect(final.connections[0].state).toBe("needs-key") + } finally { + await stub.close() + } + }) + + test("authorizer-rejecting service → invalid over the route; nothing written (AC2)", async () => { + const server = app() + const stub = await stubSolveService(() => 401) + try { + const rejected = await ( + await server.request( + "/amicode/connections/credential", + post({ id: "company-compute", base_url: stub.url, token: "tok-rejected" }), + ) + ).json() + expect(rejected.ok).toBe(true) + expect(rejected.connection.state).toBe("invalid") + expect(readCredential("company-compute")).toBeUndefined() + } finally { + await stub.close() + } + }) + + test("a poisoned status-cache file never leaks through the GET route (AC3)", async () => { + writeFileSync( + connectionsFile(), + JSON.stringify({ + "company-compute": { + state: "connected", + validated_at: new Date().toISOString(), + token: "POISON-file", + password: "POISON-password", + }, + }), + ) + const response = await app().request("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/amicode/connections") + expect(await response.text()).not.toContain("POISON") + }) +}) + +describe("connections routes — auth per #163", () => { + test("with a password configured, unauthenticated requests are rejected; basic auth serves", async () => { + const server = app({ password: "secret" }) + + const missingGet = await server.request("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/amicode/connections") + expect(missingGet.status).toBe(401) + + const missingPost = await server.request( + "/amicode/connections/credential", + post({ id: "company-compute", base_url: "https://solves.example.co", token: "tok-unauthed" }), + ) + expect(missingPost.status).toBe(401) + expect(readCredential("company-compute")).toBeUndefined() // rejected before any handler ran + + const authed = await server.request("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/amicode/connections", { + headers: { authorization: basic("opencode", "secret") }, + }) + expect(authed.status).toBe(200) + expect((await authed.json()).ok).toBe(true) + }) +}) + +describe("connections routes — loopback guard (AC5)", () => { + test("simulated non-loopback bind: mutations answer the distinct refusal; the read stays available", async () => { + const server = app() + setBindHostname("0.0.0.0") + + const refused = await ( + await server.request( + "/amicode/connections/credential", + post({ id: "company-compute", base_url: "https://solves.example.co", token: "tok-lan" }), + ) + ).json() + expect(refused.ok).toBe(false) + expect(refused.error).toStartWith("non_loopback:") + expect(readCredential("company-compute")).toBeUndefined() + + const status = await server.request("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/amicode/connections") + expect(status.status).toBe(200) + expect((await status.json()).ok).toBe(true) + }) +}) + +describe("connections routes — standalone serve records the bind", () => { + afterEach(async () => { + await disposeAllInstances() + await resetDatabase() + }) + + test("a real 0.0.0.0 listener refuses mutations over live HTTP; a 127.0.0.1 listener serves them", async () => { + const { Server } = await import("@/server/server") + const stub = await stubSolveService(() => 404) + const body = post({ id: "company-compute", base_url: stub.url, token: "tok-real-bind" }) + + const wide = await Server.listen({ hostname: "0.0.0.0", port: 0 }) + try { + const refused = await (await fetch(`http://127.0.0.1:${wide.port}/amicode/connections/credential`, body)).json() + expect(refused.ok).toBe(false) + expect(refused.error).toStartWith("non_loopback:") + } finally { + await wide.stop(true) + } + + const local = await Server.listen({ hostname: "127.0.0.1", port: 0 }) + try { + const accepted = await (await fetch(`http://127.0.0.1:${local.port}/amicode/connections/credential`, body)).json() + expect(accepted.ok).toBe(true) + expect(accepted.connection.state).toBe("connected") + expect(readCredential("company-compute")).toEqual({ base_url: stub.url, token: "tok-real-bind" }) + } finally { + await local.stop(true) + await stub.close() + } + }) +}) diff --git a/packages/opencode/test/server/amicode-connections.test.ts b/packages/opencode/test/server/amicode-connections.test.ts index 45196ca9da..d4c11bf53e 100644 --- a/packages/opencode/test/server/amicode-connections.test.ts +++ b/packages/opencode/test/server/amicode-connections.test.ts @@ -188,7 +188,10 @@ describe("status list rendering (redacting whitelist, AC3)", () => { test("statusBody is a pure builder over injectable inputs (profile.ts idiom)", () => { const file = path.join(dir, "alt-connections.json") - writeFileSync(file, JSON.stringify({ "company-compute": { state: "invalid", validated_at: "2026-07-19T00:00:00Z" } })) + writeFileSync( + file, + JSON.stringify({ "company-compute": { state: "invalid", validated_at: "2026-07-19T00:00:00Z" } }), + ) const parsed = JSON.parse( statusBody({ file, overlay: new Map(), hasCredential: () => true, now: Date.parse("2026-07-19T01:00:00Z") }), ) @@ -345,7 +348,9 @@ describe("disconnect + revalidate (AC4)", () => { probes++ return { status: 200 } } - const parsed = JSON.parse(await revalidateResponse(JSON.stringify({ id: "company-compute" }), { fetchImpl: counting })) + const parsed = JSON.parse( + await revalidateResponse(JSON.stringify({ id: "company-compute" }), { fetchImpl: counting }), + ) expect(parsed.ok).toBe(true) expect(parsed.connection.state).toBe("needs-key") expect(probes).toBe(0) @@ -376,7 +381,9 @@ describe("loopback guard on mutations (AC5)", () => { ), ) const disconnect = JSON.parse(disconnectResponse(JSON.stringify({ id: "company-compute" }))) - const revalidate = JSON.parse(await revalidateResponse(JSON.stringify({ id: "company-compute" }), { fetchImpl: counting })) + const revalidate = JSON.parse( + await revalidateResponse(JSON.stringify({ id: "company-compute" }), { fetchImpl: counting }), + ) for (const parsed of [submit, disconnect, revalidate]) { expect(parsed.ok).toBe(false) expect(parsed.error).toStartWith("non_loopback:") From ab07412f920262b4baf4f52878da7f32146e90a9 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Sun, 19 Jul 2026 21:33:15 -0400 Subject: [PATCH 13/44] fix(amicode): the recorded bind dies with its listener (165 AC5 hardening) Full-suite run exposed it: the mdns test's stopped 0.0.0.0 listener left the recorded bind behind, and a later in-process mutation was refused. Server.listen now restores the PREVIOUS bind when the listener scope closes (or the listen fails), asserted in the real-bind route test; the connections tests also reset bind state in beforeEach for isolation. Co-Authored-By: Claude Fable 5 --- .../opencode/src/server/amicode/connections.ts | 7 ++++++- packages/opencode/src/server/server.ts | 14 ++++++++++---- .../test/server/amicode-connections-routes.test.ts | 7 +++++++ .../test/server/amicode-connections.test.ts | 1 + 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/server/amicode/connections.ts b/packages/opencode/src/server/amicode/connections.ts index c03ee2b4b9..9fa440445a 100644 --- a/packages/opencode/src/server/amicode/connections.ts +++ b/packages/opencode/src/server/amicode/connections.ts @@ -292,8 +292,13 @@ export function synthesizeConnection(code: string, detail: string): string { let bindHostname: string | undefined -export function setBindHostname(hostname: string | undefined): void { +/** Returns the previous value so a listener can RESTORE it when it stops — a + * dead 0.0.0.0 listener must not keep refusing mutations for a later + * loopback/in-process handler (see server.ts). */ +export function setBindHostname(hostname: string | undefined): string | undefined { + const previous = bindHostname bindHostname = hostname + return previous } /** Same loopback family the mdns gate recognizes (server.ts), widened to the diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index ff8b2ebb9b..e0be7104de 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -83,10 +83,16 @@ export async function listen(opts: ListenOptions): Promise { const listenEffect: (opts: ListenOptions) => Effect.Effect = Effect.fn("Server.listen")( function* (opts: ListenOptions) { // amicode: record the bind so credential-mutation routes can refuse to - // serve beyond loopback (amicode#165 AC5). Last listener wins — the - // in-process webHandler never binds, so "never recorded" stays loopback. - yield* Effect.sync(() => AmicodeConnections.setBindHostname(opts.hostname)) - const state = yield* startWithPortFallback(opts) + // serve beyond loopback (amicode#165 AC5). Last listener wins while it + // lives; the previous value is restored when this listener's scope closes + // (or the listen fails), so a dead 0.0.0.0 bind never lingers. The + // in-process webHandler never binds — "never recorded" stays loopback. + const previousBind = yield* Effect.sync(() => AmicodeConnections.setBindHostname(opts.hostname)) + const restoreBind = Effect.sync(() => { + AmicodeConnections.setBindHostname(previousBind) + }) + const state = yield* startWithPortFallback(opts).pipe(Effect.onError(() => restoreBind)) + yield* Scope.addFinalizer(state.scope, restoreBind) const address = yield* tcpAddress(state) const listenerUrl = makeURL(opts.hostname, address.port) url = listenerUrl diff --git a/packages/opencode/test/server/amicode-connections-routes.test.ts b/packages/opencode/test/server/amicode-connections-routes.test.ts index 7c710aac30..d803b6c793 100644 --- a/packages/opencode/test/server/amicode-connections-routes.test.ts +++ b/packages/opencode/test/server/amicode-connections-routes.test.ts @@ -80,6 +80,7 @@ beforeEach(() => { process.env.AMICO_PASQAL_FILE = path.join(dir, "pasqal.json") process.env.AMICODE_CONNECTIONS_FILE = path.join(dir, "connections.json") inflightOverlay.clear() + setBindHostname(undefined) // isolation from any listener another test file bound }) afterEach(() => { for (const k of ENV_KEYS) { @@ -241,6 +242,12 @@ describe("connections routes — standalone serve records the bind", () => { await wide.stop(true) } + // the recorded bind dies WITH the listener: once the wide listener stops, + // an in-process (loopback-default) handler serves mutations again + const released = await (await app().request("/amicode/connections/credential", body)).json() + expect(released.ok).toBe(true) + expect(released.connection.state).toBe("connected") + const local = await Server.listen({ hostname: "127.0.0.1", port: 0 }) try { const accepted = await (await fetch(`http://127.0.0.1:${local.port}/amicode/connections/credential`, body)).json() diff --git a/packages/opencode/test/server/amicode-connections.test.ts b/packages/opencode/test/server/amicode-connections.test.ts index d4c11bf53e..5d8dbed489 100644 --- a/packages/opencode/test/server/amicode-connections.test.ts +++ b/packages/opencode/test/server/amicode-connections.test.ts @@ -42,6 +42,7 @@ beforeEach(() => { process.env.AMICO_PASQAL_FILE = path.join(dir, "pasqal.json") process.env.AMICODE_CONNECTIONS_FILE = path.join(dir, "connections.json") inflightOverlay.clear() + setBindHostname(undefined) // isolation from any listener another test file bound }) afterEach(() => { for (const k of ENV_KEYS) { From b88820e77b494a35ab5b8414df30baf796a8453e Mon Sep 17 00:00:00 2001 From: kate bonner Date: Sun, 19 Jul 2026 21:34:02 -0400 Subject: [PATCH 14/44] docs(amicode): flag the per-id probe branch point for the Pasqal slice in revalidate Co-Authored-By: Claude Fable 5 --- packages/opencode/src/server/amicode/connections.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/opencode/src/server/amicode/connections.ts b/packages/opencode/src/server/amicode/connections.ts index 9fa440445a..2a49588af1 100644 --- a/packages/opencode/src/server/amicode/connections.ts +++ b/packages/opencode/src/server/amicode/connections.ts @@ -437,6 +437,8 @@ export async function revalidateResponse(rawBody: string, deps: MutationDeps = { if (refusal) return refusal const id = parseIdBody(rawBody) if (!id) return synthesizeConnection("bad_request", "body must be JSON {id} with a known connection id") + // parseIdBody only admits CONNECTION_IDS, and this slice serves exactly + // company-compute — the Pasqal slice adds its own per-id probe branch here. const credential = readCredential("company-compute") if (!credential) { clearStatus(id) // a status claim without a credential behind it is noise From cc86f7bd157a42ad8b173c1b5c4a07e8ac3fea7d Mon Sep 17 00:00:00 2001 From: kate bonner Date: Sun, 19 Jul 2026 21:44:20 -0400 Subject: [PATCH 15/44] =?UTF-8?q?test+feat(amicode):=20tolerant=20connecti?= =?UTF-8?q?ons=20wire=20parser=20=E2=80=94=20unknown=20states/fields=20col?= =?UTF-8?q?lapse=20safely=20(166=20AC4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single consumer of the #165 status contract on the UI side (vaults.ts idiom): GET {ok,connections} and POST {ok,connection} both parse through one whitelisting entry parser that never throws — unknown states render via the "unknown" fallback with the raw word preserved, unknown fields (including a poisoned token) have no path into the view. Co-Authored-By: Claude Fable 5 --- packages/ui/src/amicode/connections.test.ts | 186 ++++++++++++++++++++ packages/ui/src/amicode/connections.ts | 88 +++++++++ 2 files changed, 274 insertions(+) create mode 100644 packages/ui/src/amicode/connections.test.ts create mode 100644 packages/ui/src/amicode/connections.ts diff --git a/packages/ui/src/amicode/connections.test.ts b/packages/ui/src/amicode/connections.test.ts new file mode 100644 index 0000000000..3980a76cb9 --- /dev/null +++ b/packages/ui/src/amicode/connections.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, test } from "bun:test" +import { parseConnectionActionResponse, parseConnectionsResponse, validatedAtDisplay } from "./connections" + +// Wire fixtures mirror the #165 status contract (packages/opencode +// src/server/amicode/connections.ts): GET → {ok, connections:[…]}, +// POST → {ok, connection, error}. +const connectedEntry = { + id: "company-compute", + state: "connected", + identity: "kate@harmoniqs.co", + entitlements: ["solve"], + expires_at: "2026-12-31T00:00:00.000Z", + devices: [{ id: "d1", name: "fresnel", state: "online" }], + validated_at: "2026-07-19T10:00:00.000Z", + stale: false, +} + +describe("validatedAtDisplay", () => { + test("renders a parseable ISO timestamp as a locale string", () => { + const display = validatedAtDisplay("2026-07-19T10:00:00.000Z") + expect(display).not.toBe("—") + expect(display).toContain("2026") + }) + test("maps absent, non-string, empty, and unparseable values to an em dash", () => { + expect(validatedAtDisplay(undefined)).toBe("—") + expect(validatedAtDisplay(null)).toBe("—") + expect(validatedAtDisplay(42)).toBe("—") + expect(validatedAtDisplay("")).toBe("—") + expect(validatedAtDisplay("not-a-date")).toBe("—") + }) +}) + +describe("parseConnectionsResponse", () => { + test("happy path: a connected entry passes through with renamed fields", () => { + const view = parseConnectionsResponse({ ok: true, connections: [connectedEntry], error: null }) + expect(view.ok).toBe(true) + expect(view.connections).toHaveLength(1) + const conn = view.connections[0] + expect(conn.id).toBe("company-compute") + expect(conn.state).toBe("connected") + expect(conn.rawState).toBe("connected") + expect(conn.identity).toBe("kate@harmoniqs.co") + expect(conn.validatedAt).toContain("2026") + expect(conn.stale).toBe(false) + }) + + test("each contract state this slice renders survives the parse", () => { + for (const state of ["connected", "needs-key", "invalid", "unreachable", "validating"]) { + const view = parseConnectionsResponse({ + ok: true, + connections: [{ id: "company-compute", state, validated_at: null, stale: false }], + error: null, + }) + expect(view.ok).toBe(true) + expect(view.connections[0].state).toBe(state as never) + } + }) + + test("unknown states collapse to the safe fallback, raw word preserved (AC4)", () => { + for (const state of ["expired", "unentitled", "some-future-state"]) { + const view = parseConnectionsResponse({ + ok: true, + connections: [{ id: "company-compute", state, validated_at: null, stale: false }], + error: null, + }) + expect(view.ok).toBe(true) + expect(view.connections[0].state).toBe("unknown") + expect(view.connections[0].rawState).toBe(state) + } + }) + + test("ok:false carries the server error string", () => { + const view = parseConnectionsResponse({ ok: false, connections: [], error: "bad_output: boom" }) + expect(view.ok).toBe(false) + expect(view.error).toBe("bad_output: boom") + }) + + test("ok:false with no usable error still produces a message", () => { + const view = parseConnectionsResponse({ ok: false, connections: [], error: null }) + expect(view.ok).toBe(false) + expect(view.error).toBeTruthy() + }) + + test("non-object responses are bad_shape, never a throw", () => { + for (const raw of [null, undefined, 42, "nope", []]) { + const view = parseConnectionsResponse(raw) + expect(view.ok).toBe(false) + expect(view.error).toContain("bad_shape") + } + }) + + test("connections absent or non-array is bad_shape", () => { + const missing = parseConnectionsResponse({ ok: true, error: null }) + expect(missing.ok).toBe(false) + expect(missing.error).toContain("bad_shape") + const nonArray = parseConnectionsResponse({ ok: true, connections: "three", error: null }) + expect(nonArray.ok).toBe(false) + }) + + test("tolerant entries: missing/mistyped fields never throw (AC4)", () => { + const view = parseConnectionsResponse({ + ok: true, + connections: [ + {}, + null, + { id: "company-compute", state: 7, identity: 3, validated_at: 12, stale: "yes" }, + { id: "", state: "connected", base_url: 9 }, + ], + error: null, + }) + expect(view.ok).toBe(true) + expect(view.connections).toHaveLength(4) + expect(view.connections[0]).toEqual({ + id: "(unknown)", + state: "unknown", + rawState: "unknown", + identity: undefined, + baseUrl: undefined, + validatedAt: "—", + stale: false, + }) + expect(view.connections[2].state).toBe("unknown") + expect(view.connections[2].stale).toBe(false) + expect(view.connections[3].id).toBe("(unknown)") + expect(view.connections[3].baseUrl).toBeUndefined() + }) + + test("unknown fields are dropped — a poisoned entry can never carry a secret into the view", () => { + const view = parseConnectionsResponse({ + ok: true, + connections: [{ ...connectedEntry, token: "sk-poison", password: "hunter2", junk: { nested: "sk-deep" } }], + error: null, + }) + const serialized = JSON.stringify(view) + expect(serialized).not.toContain("sk-poison") + expect(serialized).not.toContain("hunter2") + expect(serialized).not.toContain("sk-deep") + }) + + test("base_url prefill survives when the server offers one (forward-compatible)", () => { + const view = parseConnectionsResponse({ + ok: true, + connections: [{ id: "company-compute", state: "needs-key", base_url: "https://solve.example", stale: false }], + error: null, + }) + expect(view.connections[0].baseUrl).toBe("https://solve.example") + }) +}) + +describe("parseConnectionActionResponse", () => { + test("happy path: the terminal connection rides the same response (AC2 wire shape)", () => { + const view = parseConnectionActionResponse({ ok: true, connection: connectedEntry, error: null }) + expect(view.ok).toBe(true) + expect(view.connection?.state).toBe("connected") + expect(view.connection?.identity).toBe("kate@harmoniqs.co") + }) + + test("ok:false carries the server error; missing error still produces a message", () => { + const failed = parseConnectionActionResponse({ ok: false, connection: null, error: "invalid_key: rejected" }) + expect(failed.ok).toBe(false) + expect(failed.error).toBe("invalid_key: rejected") + const silent = parseConnectionActionResponse({ ok: false, connection: null, error: null }) + expect(silent.ok).toBe(false) + expect(silent.error).toBeTruthy() + }) + + test("non-object responses and a missing connection are bad_shape, never a throw", () => { + for (const raw of [null, undefined, 42, "nope", []]) { + const view = parseConnectionActionResponse(raw) + expect(view.ok).toBe(false) + expect(view.error).toContain("bad_shape") + } + const missing = parseConnectionActionResponse({ ok: true, connection: null, error: null }) + expect(missing.ok).toBe(false) + expect(missing.error).toContain("bad_shape") + }) + + test("the submitted token has no path into the parsed view", () => { + const view = parseConnectionActionResponse({ + ok: true, + connection: { ...connectedEntry, token: "sk-echoed" }, + error: null, + }) + expect(JSON.stringify(view)).not.toContain("sk-echoed") + }) +}) diff --git a/packages/ui/src/amicode/connections.ts b/packages/ui/src/amicode/connections.ts new file mode 100644 index 0000000000..55b5a7a4ba --- /dev/null +++ b/packages/ui/src/amicode/connections.ts @@ -0,0 +1,88 @@ +// AMICODE: pure, tolerant parsing + card logic for the Connections panel tab +// (amicode#166 / parent #159). The wire shape is the #165 status contract +// served by GET /amicode/connections ({ok, connections:[…]}) and the three +// POST mutations ({ok, connection, error}) — packages/opencode +// src/server/amicode/connections.ts is the producer. This module is the +// SINGLE consumer of the wire shape (vaults.ts idiom: one schema, one place +// to update). It must never throw on malformed input; unknown fields are +// dropped and unknown states collapse to a safe "unknown" fallback, so a +// poisoned or future-shaped response can neither crash the card nor carry a +// secret into the DOM. + +export const CONNECTION_WIRE_STATES = ["connected", "needs-key", "invalid", "unreachable", "validating"] as const +export type ConnectionWireState = (typeof CONNECTION_WIRE_STATES)[number] +/** Everything the wire might say beyond the five contract states renders via + * the "unknown" fallback (expired / unentitled land in later slices). */ +export type ConnectionCardState = ConnectionWireState | "unknown" + +export type ConnectionView = { + id: string + state: ConnectionCardState + /** what the wire actually said — shown verbatim when state is "unknown" */ + rawState: string + identity?: string + /** server-offered prefill for the key form (forward-compatible) */ + baseUrl?: string + /** display string: locale-formatted timestamp or an em dash */ + validatedAt: string + stale: boolean +} + +export type ConnectionsView = { ok: boolean; connections: ConnectionView[]; error?: string } +export type ConnectionActionView = { ok: boolean; connection?: ConnectionView; error?: string } + +export const COMPANY_COMPUTE_ID = "company-compute" + +const WIRE_STATES: ReadonlySet = new Set(CONNECTION_WIRE_STATES) + +function str(value: unknown): string | undefined { + return typeof value === "string" && value !== "" ? value : undefined +} + +export function validatedAtDisplay(value: unknown): string { + const raw = str(value) + if (!raw) return "—" + const at = Date.parse(raw) + if (!Number.isFinite(at)) return "—" + return new Date(at).toLocaleString() +} + +function parseConnectionEntry(raw: unknown): ConnectionView { + const entry = (typeof raw === "object" && raw !== null && !Array.isArray(raw) ? raw : {}) as Record + const rawState = str(entry.state) ?? "unknown" + return { + id: str(entry.id) ?? "(unknown)", + state: WIRE_STATES.has(rawState) ? (rawState as ConnectionWireState) : "unknown", + rawState, + identity: str(entry.identity), + baseUrl: str(entry.base_url), + validatedAt: validatedAtDisplay(entry.validated_at), + stale: entry.stale === true, + } +} + +export function parseConnectionsResponse(raw: unknown): ConnectionsView { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) + return { ok: false, connections: [], error: "bad_shape: response is not an object" } + const data = raw as Record + if (data.ok !== true) { + const error = str(data.error) ?? "connection status reported a failure" + return { ok: false, connections: [], error } + } + if (!Array.isArray(data.connections)) + return { ok: false, connections: [], error: "bad_shape: connections missing or not a list" } + return { ok: true, connections: data.connections.map(parseConnectionEntry) } +} + +export function parseConnectionActionResponse(raw: unknown): ConnectionActionView { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) + return { ok: false, error: "bad_shape: response is not an object" } + const data = raw as Record + if (data.ok !== true) { + const error = str(data.error) ?? "connection update failed" + return { ok: false, error } + } + if (typeof data.connection !== "object" || data.connection === null || Array.isArray(data.connection)) + return { ok: false, error: "bad_shape: connection missing" } + return { ok: true, connection: parseConnectionEntry(data.connection) } +} From 22205bf30c01a92cfe19593a7c29897a8f1f3bc7 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Sun, 19 Jul 2026 21:44:25 -0400 Subject: [PATCH 16/44] =?UTF-8?q?test+feat(amicode):=20HP=20flip=20on=20va?= =?UTF-8?q?lid=20Company=20Compute=20save=20=E2=80=94=20grant=20issimo=20+?= =?UTF-8?q?=20write=20the=20switching=20request=20(167=20AC1+AC3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A VALID save now writes BOTH shared ops-dir artifacts the amicode extension consumes: entitlements.toml (issimo granted, existing codes + expired list preserved, byte-compatible with applyEntitlementForMode) and solver-mode.json ({mode:"hp",status:"switching"}, the exact shape watchSolverMode re-preps from). Paths resolve via $AMICODE_OPS_DIR → ~/.amico/amicode, the extension's amicodeOpsDir resolution, so tests stay hermetic. Headless the artifacts are plain durable files pending the next extension attach. Co-Authored-By: Claude Fable 5 --- .../src/server/amicode/connections.ts | 62 +++++++++++++++++ .../test/server/amicode-connections.test.ts | 67 ++++++++++++++++++- 2 files changed, 127 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/server/amicode/connections.ts b/packages/opencode/src/server/amicode/connections.ts index 2a49588af1..14968667f1 100644 --- a/packages/opencode/src/server/amicode/connections.ts +++ b/packages/opencode/src/server/amicode/connections.ts @@ -16,6 +16,7 @@ import { writeCredential, type ConnectionType, } from "./credentials" +import { parseTomlLite } from "./toml-lite" // --- status contract (parent #159 data contract; secret-free by construction) --- @@ -276,6 +277,66 @@ function clearStatus(id: ConnectionType): void { atomicWriteFileSync(file, JSON.stringify(out, null, 2) + "\n") } +// --- HP flip on connect (amicode#167 / parent #159, pushed hp-cloud-key +// contract): a VALID Company Compute save grants the `issimo` entitlement and +// writes the durable {mode:"hp",status:"switching"} request. The amicode +// extension's EXISTING watcher (packages/extension/src/solver_mode.ts, +// watchSolverMode) consumes the request and performs the full re-prep exactly +// once — this slice only WRITES the shared file contract, never a second +// switch mechanism. One-way on connect: disconnect never reverts solver mode +// (the user's toggle owns reverting). + +/** $AMICODE_OPS_DIR override → ~/.amico/amicode — the SAME resolution the + * extension's amicodeOpsDir() uses (substrate/vault_store.ts), so the watcher + * reads exactly where we write and tests stay hermetic. */ +export function amicodeOpsDir(): string { + const env = process.env.AMICODE_OPS_DIR + if (env && env.trim() !== "") return env + return path.join(homedir(), ".amico", "amicode") +} + +export function entitlementsFile(): string { + return path.join(amicodeOpsDir(), "entitlements.toml") +} + +export function solverModeFile(): string { + return path.join(amicodeOpsDir(), "solver-mode.json") +} + +/** Grant `issimo` PRESERVING every other code (read-modify-write). The write + * is byte-compatible with the extension's applyEntitlementForMode writer — + * `codes = [...]` (+ optional `expired = [...]`), double-quoted strings — and + * its smol-toml reader parses it unchanged. Absent/corrupt file starts empty + * (the extension's own fallback); an already-granted file is left untouched + * byte-for-byte. Returns whether the grant was already in place. */ +function grantIssimo(file: string): { alreadyGranted: boolean } { + let codes: string[] = [] + let expired: string[] = [] + try { + const parsed = parseTomlLite(readFileSync(file, "utf8")) + if (parsed.ok) { + const value = parsed.value as { codes?: unknown; expired?: unknown } + if (Array.isArray(value.codes)) codes = value.codes.filter((c): c is string => typeof c === "string") + if (Array.isArray(value.expired)) expired = value.expired.filter((c): c is string => typeof c === "string") + } + } catch { + // absent/unreadable → start empty, matching the extension reader + } + if (codes.includes("issimo")) return { alreadyGranted: true } + codes.push("issimo") + const lines = [`codes = [${codes.map((c) => JSON.stringify(c)).join(", ")}]`] + if (expired.length > 0) lines.push(`expired = [${expired.map((c) => JSON.stringify(c)).join(", ")}]`) + atomicWriteFileSync(file, lines.join("\n") + "\n") + return { alreadyGranted: false } +} + +/** After a VALID save: grant the entitlement, then request the hp switch the + * watcher re-preps from. */ +function requestHpFlip(): void { + grantIssimo(entitlementsFile()) + atomicWriteFileSync(solverModeFile(), JSON.stringify({ mode: "hp", status: "switching" })) +} + // --- mutation bodies (POST routes). One shape per route family, sibling // discipline: never reject, ok:false + "code: detail" on failure. SECURITY: // every failure message is a FIXED string — nothing the caller sent (token, @@ -395,6 +456,7 @@ export async function submitCredentialResponse(rawBody: string, deps: MutationDe return synthesizeConnection("write_failed", "credential could not be saved") } persistStatus(id, { state: "connected", validated_at }) + requestHpFlip() // #167: AFTER the save and ONLY on the valid outcome } else { // nothing written — an existing credential (if any) stays untouched persistStatus(id, { state: outcome, validated_at }) diff --git a/packages/opencode/test/server/amicode-connections.test.ts b/packages/opencode/test/server/amicode-connections.test.ts index 5d8dbed489..8de8cad53f 100644 --- a/packages/opencode/test/server/amicode-connections.test.ts +++ b/packages/opencode/test/server/amicode-connections.test.ts @@ -6,14 +6,17 @@ // AMICODE_CONNECTIONS_FILE), so the test seam and the deploy seam are one // mechanism (the #162 idiom). import { afterEach, beforeEach, describe, expect, test } from "bun:test" -import { mkdtempSync, writeFileSync } from "node:fs" +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import path from "node:path" import { readCredential, writeCredential } from "@/server/amicode/credentials" import { + amicodeOpsDir, connectionsFile, + entitlementsFile, inflightOverlay, probeCompanyCompute, + solverModeFile, statusResponse, STALE_MS, statusBody, @@ -31,7 +34,7 @@ const respond = // Same env-override discipline as the credentials suite: point every file the // module touches into a per-test tmp dir, restore after. -const ENV_KEYS = ["AMICO_CLOUD_FILE", "AMICO_PASQAL_FILE", "AMICODE_CONNECTIONS_FILE"] as const +const ENV_KEYS = ["AMICO_CLOUD_FILE", "AMICO_PASQAL_FILE", "AMICODE_CONNECTIONS_FILE", "AMICODE_OPS_DIR"] as const let savedEnv: Record let dir: string @@ -41,6 +44,7 @@ beforeEach(() => { process.env.AMICO_CLOUD_FILE = path.join(dir, "cloud.json") process.env.AMICO_PASQAL_FILE = path.join(dir, "pasqal.json") process.env.AMICODE_CONNECTIONS_FILE = path.join(dir, "connections.json") + process.env.AMICODE_OPS_DIR = path.join(dir, "amicode-ops") // flip artifacts (#167) stay hermetic inflightOverlay.clear() setBindHostname(undefined) // isolation from any listener another test file bound }) @@ -288,6 +292,65 @@ describe("submit credential — probe → save → terminal status, one round tr }) }) +// --- HP flip on Company Compute connect (amicode#167 / parent #159): a VALID +// save grants `issimo` and writes the durable {mode:"hp",status:"switching"} +// request; the amicode extension's EXISTING watcher (solver_mode.ts) does the +// actual re-prep. These tests assert the fork-side artifacts on file bytes — +// the shared ops-dir contract, hermetic via $AMICODE_OPS_DIR. + +const validSubmit = JSON.stringify({ + id: "company-compute", + base_url: "https://solves.example.co", + token: "tok-good", +}) + +describe("HP flip on connect — artifacts in the ops dir (167 AC1, AC3)", () => { + test("flip artifacts resolve through $AMICODE_OPS_DIR, defaulting to ~/.amico/amicode (the extension's amicodeOpsDir)", () => { + expect(amicodeOpsDir()).toBe(path.join(dir, "amicode-ops")) + expect(entitlementsFile()).toBe(path.join(dir, "amicode-ops", "entitlements.toml")) + expect(solverModeFile()).toBe(path.join(dir, "amicode-ops", "solver-mode.json")) + delete process.env.AMICODE_OPS_DIR + expect(entitlementsFile()).toContain(path.join(".amico", "amicode", "entitlements.toml")) + expect(solverModeFile()).toContain(path.join(".amico", "amicode", "solver-mode.json")) + }) + + test("valid save → BOTH artifacts: issimo granted in entitlements.toml AND the hp switching request (AC1)", async () => { + const parsed = JSON.parse(await submitCredentialResponse(validSubmit, { fetchImpl: respond(404) })) + expect(parsed.ok).toBe(true) + expect(parsed.error).toBeNull() + expect(parsed.connection.state).toBe("connected") + // artifact 1: the exact byte shape the extension's applyEntitlementForMode writes/reads + expect(readFileSync(entitlementsFile(), "utf8")).toBe('codes = ["issimo"]\n') + // artifact 2: the exact request shape the extension's watchSolverMode consumes + expect(JSON.parse(readFileSync(solverModeFile(), "utf8"))).toEqual({ mode: "hp", status: "switching" }) + }) + + test("grant PRESERVES existing codes (and the expired list) — read-modify-write, byte-compatible", async () => { + mkdirSync(amicodeOpsDir(), { recursive: true }) + writeFileSync(entitlementsFile(), 'codes = ["pasqal-hackathon-2026"]\nexpired = ["old-2025"]\n') + await submitCredentialResponse(validSubmit, { fetchImpl: respond(200) }) + expect(readFileSync(entitlementsFile(), "utf8")).toBe( + 'codes = ["pasqal-hackathon-2026", "issimo"]\nexpired = ["old-2025"]\n', + ) + }) + + test("headless: no extension host consumes anything — both artifacts persist durably, response path clean (AC3)", async () => { + // this suite runs with NO extension host: headless is the ambient truth here + const raw = await submitCredentialResponse(validSubmit, { fetchImpl: respond(200) }) + const parsed = JSON.parse(raw) + expect(parsed.ok).toBe(true) + expect(parsed.error).toBeNull() + const entitlementBytes = readFileSync(entitlementsFile(), "utf8") + const modeBytes = readFileSync(solverModeFile(), "utf8") + // a later GET disturbs nothing; re-reads see the same bytes — the request + // is still pending for the next extension attach + expect(JSON.parse(statusResponse()).connections[0].state).toBe("connected") + expect(readFileSync(entitlementsFile(), "utf8")).toBe(entitlementBytes) + expect(readFileSync(solverModeFile(), "utf8")).toBe(modeBytes) + expect(JSON.parse(modeBytes)).toEqual({ mode: "hp", status: "switching" }) + }) +}) + describe("disconnect + revalidate (AC4)", () => { test("disconnect clears the credential; status becomes needs-key; idempotent", async () => { await submitCredentialResponse( From 681f0ba8d9230ed1e099efd57f9972c6c8cc61e6 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Sun, 19 Jul 2026 21:45:20 -0400 Subject: [PATCH 17/44] =?UTF-8?q?test+feat(amicode):=20card=20model=20?= =?UTF-8?q?=E2=80=94=20distinct=20copy=20+=20flags=20per=20contract=20stat?= =?UTF-8?q?e,=20safe=20unknown=20fallback=20(166=20AC1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stateCopy/cardModel are the pure state→props mapping the Company Compute card projects: connected exposes actions + validated_at (+ identity when present), the three re-key states expose the form, validating freezes it, and unknown keeps every exit open while badging the raw wire word. The repo has no tsx component harness, so this mapping carries the card's behavioral contract exhaustively. Co-Authored-By: Claude Fable 5 --- packages/ui/src/amicode/connections.test.ts | 104 +++++++++++++++++++- packages/ui/src/amicode/connections.ts | 70 +++++++++++++ 2 files changed, 173 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/amicode/connections.test.ts b/packages/ui/src/amicode/connections.test.ts index 3980a76cb9..545dae9e37 100644 --- a/packages/ui/src/amicode/connections.test.ts +++ b/packages/ui/src/amicode/connections.test.ts @@ -1,5 +1,14 @@ import { describe, expect, test } from "bun:test" -import { parseConnectionActionResponse, parseConnectionsResponse, validatedAtDisplay } from "./connections" +import { + cardModel, + parseConnectionActionResponse, + parseConnectionsResponse, + stateCopy, + validatedAtDisplay, + type ConnectionCardState, + type ConnectionStateLabels, + type ConnectionView, +} from "./connections" // Wire fixtures mirror the #165 status contract (packages/opencode // src/server/amicode/connections.ts): GET → {ok, connections:[…]}, @@ -184,3 +193,96 @@ describe("parseConnectionActionResponse", () => { expect(JSON.stringify(view)).not.toContain("sk-echoed") }) }) + +// --- AC1: the state→props mapping the card renders from. With no tsx +// component harness in this repo (see connections-tab.tsx), this mapping IS +// the component contract, so it gets exhaustive coverage here. + +const CARD_STATES: ConnectionCardState[] = ["connected", "needs-key", "invalid", "unreachable", "validating", "unknown"] + +const labels: ConnectionStateLabels = { + connected: "Connected", + "needs-key": "Not connected — enter a key", + invalid: "Key rejected", + unreachable: "Service unreachable", + validating: "Validating key…", + unknown: "Status needs attention", +} + +function viewFor(state: ConnectionCardState, extra: Partial = {}): ConnectionView { + return { + id: "company-compute", + state, + rawState: state === "unknown" ? "expired" : state, + validatedAt: "—", + stale: false, + ...extra, + } +} + +describe("stateCopy", () => { + test("every card state picks its own distinct copy (AC1)", () => { + const seen = new Set(CARD_STATES.map((state) => stateCopy(viewFor(state), labels))) + expect(seen.size).toBe(CARD_STATES.length) + for (const state of CARD_STATES) { + expect(stateCopy(viewFor(state), labels)).toBe(labels[state]) + } + }) +}) + +describe("cardModel", () => { + test("connected: actions + validated_at, no key form", () => { + const model = cardModel(viewFor("connected", { validatedAt: "7/19/2026", identity: "kate@harmoniqs.co" })) + expect(model.showForm).toBe(false) + expect(model.showActions).toBe(true) + expect(model.showValidatedAt).toBe(true) + expect(model.showIdentity).toBe(true) + expect(model.tone).toBe("success") + }) + + test("connected without identity hides the identity line; stale surfaces the hint", () => { + const bare = cardModel(viewFor("connected")) + expect(bare.showIdentity).toBe(false) + expect(bare.showStale).toBe(false) + const stale = cardModel(viewFor("connected", { stale: true })) + expect(stale.showStale).toBe(true) + }) + + test("needs-key / invalid / unreachable: key form enabled, no actions", () => { + for (const [state, tone] of [ + ["needs-key", "neutral"], + ["invalid", "critical"], + ["unreachable", "warning"], + ] as const) { + const model = cardModel(viewFor(state)) + expect(model.showForm).toBe(true) + expect(model.formDisabled).toBe(false) + expect(model.showActions).toBe(false) + expect(model.showValidatedAt).toBe(false) + expect(model.tone).toBe(tone) + } + }) + + test("validating: form stays visible but disabled — the in-flight render (AC2)", () => { + const model = cardModel(viewFor("validating")) + expect(model.showForm).toBe(true) + expect(model.formDisabled).toBe(true) + expect(model.showActions).toBe(false) + expect(model.tone).toBe("pending") + }) + + test("unknown: safe fallback keeps every exit open and shows the raw wire word (AC4)", () => { + const model = cardModel(viewFor("unknown")) + expect(model.showForm).toBe(true) + expect(model.formDisabled).toBe(false) + expect(model.showActions).toBe(true) + expect(model.showRawState).toBe(true) + expect(model.tone).toBe("neutral") + }) + + test("only unknown renders the raw state word", () => { + for (const state of CARD_STATES.filter((s) => s !== "unknown")) { + expect(cardModel(viewFor(state)).showRawState).toBe(false) + } + }) +}) diff --git a/packages/ui/src/amicode/connections.ts b/packages/ui/src/amicode/connections.ts index 55b5a7a4ba..d65d897b26 100644 --- a/packages/ui/src/amicode/connections.ts +++ b/packages/ui/src/amicode/connections.ts @@ -74,6 +74,76 @@ export function parseConnectionsResponse(raw: unknown): ConnectionsView { return { ok: true, connections: data.connections.map(parseConnectionEntry) } } +// --- card model (AC1): the pure state→props mapping the tab renders from. +// The tsx component is a thin projection of this — with no component harness +// in the repo, this mapping carries the behavioral contract and its tests. + +export type ConnectionStateLabels = Record + +/** Distinct copy per state; anything unrecognized reads the "unknown" line. */ +export function stateCopy(view: ConnectionView, labels: ConnectionStateLabels): string { + return labels[view.state] +} + +export type ConnectionCardModel = { + state: ConnectionCardState + tone: "success" | "critical" | "warning" | "pending" | "neutral" + /** key-entry form (base_url + masked token) */ + showForm: boolean + /** true only while validating — the in-flight render keeps the form frozen */ + formDisabled: boolean + /** disconnect + revalidate */ + showActions: boolean + showValidatedAt: boolean + showIdentity: boolean + showStale: boolean + /** unknown states show the wire's raw word beside the fallback copy */ + showRawState: boolean +} + +export function cardModel(view: ConnectionView): ConnectionCardModel { + const base = { + state: view.state, + formDisabled: false, + showValidatedAt: false, + showIdentity: false, + showStale: false, + showRawState: false, + } + switch (view.state) { + case "connected": + return { + ...base, + tone: "success", + showForm: false, + showActions: true, + showValidatedAt: true, + showIdentity: view.identity !== undefined, + showStale: view.stale, + } + case "needs-key": + return { ...base, tone: "neutral", showForm: true, showActions: false } + case "invalid": + return { ...base, tone: "critical", showForm: true, showActions: false } + case "unreachable": + return { ...base, tone: "warning", showForm: true, showActions: false } + case "validating": + return { ...base, tone: "pending", showForm: true, formDisabled: true, showActions: false } + default: + // safe fallback (AC4): keep every exit open — re-key, disconnect, or + // revalidate — and surface whatever the wire said as a raw badge. + return { + ...base, + tone: "neutral", + showForm: true, + showActions: true, + showRawState: true, + showValidatedAt: view.validatedAt !== "—", + showIdentity: view.identity !== undefined, + } + } +} + export function parseConnectionActionResponse(raw: unknown): ConnectionActionView { if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return { ok: false, error: "bad_shape: response is not an object" } From 200fbfb9ea86ae22791369d076457c34865fb249 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Sun, 19 Jul 2026 21:45:26 -0400 Subject: [PATCH 18/44] test+feat(amicode): only the valid outcome flips; repeat saves stay idempotent (167 AC4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Invalid/unreachable/malformed/refused submits produce no flip artifacts; failed revalidation and disconnect never revoke (the flip is one-way — the user's toggle owns reverting). A repeat valid save on an already-granted, already-hp setup writes NOTHING: the switching request only goes out when a re-prep would change something (mode not hp yet, or the grant was missing), so the watcher — whose re-prep restarts this very server — is never poked for a no-op. Co-Authored-By: Claude Fable 5 --- .../src/server/amicode/connections.ts | 25 ++++++- .../test/server/amicode-connections.test.ts | 65 ++++++++++++++++++- 2 files changed, 86 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/server/amicode/connections.ts b/packages/opencode/src/server/amicode/connections.ts index 14968667f1..dd35c32973 100644 --- a/packages/opencode/src/server/amicode/connections.ts +++ b/packages/opencode/src/server/amicode/connections.ts @@ -330,11 +330,30 @@ function grantIssimo(file: string): { alreadyGranted: boolean } { return { alreadyGranted: false } } +/** Tolerant {mode,status} read — the extension's readSolverModeState + * semantics: anything absent/off-shape collapses to piccolo/ready. */ +function readSolverMode(file: string): { mode: "piccolo" | "hp"; status: "ready" | "switching" } { + try { + const parsed = JSON.parse(readFileSync(file, "utf8")) as { mode?: unknown; status?: unknown } + return { + mode: parsed.mode === "hp" ? "hp" : "piccolo", + status: parsed.status === "switching" ? "switching" : "ready", + } + } catch { + return { mode: "piccolo", status: "ready" } + } +} + /** After a VALID save: grant the entitlement, then request the hp switch the - * watcher re-preps from. */ + * watcher re-preps from — but ONLY when a re-prep would change anything (the + * mode isn't hp yet, or the last prep ran without the grant). A repeat save + * on an already-flipped setup writes nothing, so the watcher — whose one + * re-prep includes restarting THIS server — is never poked for a no-op. */ function requestHpFlip(): void { - grantIssimo(entitlementsFile()) - atomicWriteFileSync(solverModeFile(), JSON.stringify({ mode: "hp", status: "switching" })) + const { alreadyGranted } = grantIssimo(entitlementsFile()) + const modeFile = solverModeFile() + if (alreadyGranted && readSolverMode(modeFile).mode === "hp") return + atomicWriteFileSync(modeFile, JSON.stringify({ mode: "hp", status: "switching" })) } // --- mutation bodies (POST routes). One shape per route family, sibling diff --git a/packages/opencode/test/server/amicode-connections.test.ts b/packages/opencode/test/server/amicode-connections.test.ts index 8de8cad53f..d27283ae66 100644 --- a/packages/opencode/test/server/amicode-connections.test.ts +++ b/packages/opencode/test/server/amicode-connections.test.ts @@ -6,7 +6,7 @@ // AMICODE_CONNECTIONS_FILE), so the test seam and the deploy seam are one // mechanism (the #162 idiom). import { afterEach, beforeEach, describe, expect, test } from "bun:test" -import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs" +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import path from "node:path" import { readCredential, writeCredential } from "@/server/amicode/credentials" @@ -351,6 +351,69 @@ describe("HP flip on connect — artifacts in the ops dir (167 AC1, AC3)", () => }) }) +describe("HP flip on connect — only the valid outcome flips; repeats stay idempotent (167 AC4)", () => { + test("invalid / unreachable / malformed / non-loopback → NO flip artifacts of any kind", async () => { + const boom: FetchImpl = async () => { + throw new Error("ECONNREFUSED") + } + const attempts: [string, FetchImpl][] = [ + [validSubmit, respond(401)], // invalid + [validSubmit, respond(500)], // unreachable (server trouble) + [validSubmit, boom], // unreachable (network) + ["not json {{{", respond(200)], // malformed body — no probe, no save + ] + for (const [body, fetchImpl] of attempts) await submitCredentialResponse(body, { fetchImpl }) + setBindHostname("0.0.0.0") // refused mutations must not flip either + await submitCredentialResponse(validSubmit, { fetchImpl: respond(200) }) + setBindHostname(undefined) + expect(existsSync(entitlementsFile())).toBe(false) + expect(existsSync(solverModeFile())).toBe(false) + }) + + test("a failed revalidation never revokes: pre-granted entitlements survive an invalid outcome untouched", async () => { + await submitCredentialResponse(validSubmit, { fetchImpl: respond(200) }) // granted + switching + const entitlementBytes = readFileSync(entitlementsFile(), "utf8") + const modeBytes = readFileSync(solverModeFile(), "utf8") + await submitCredentialResponse(validSubmit, { fetchImpl: respond(401) }) // key went bad + await revalidateResponse(JSON.stringify({ id: "company-compute" }), { fetchImpl: respond(401) }) + expect(readFileSync(entitlementsFile(), "utf8")).toBe(entitlementBytes) + expect(readFileSync(solverModeFile(), "utf8")).toBe(modeBytes) + }) + + test("disconnect leaves both artifacts alone — the flip is one-way; the user's toggle owns reverting", async () => { + await submitCredentialResponse(validSubmit, { fetchImpl: respond(200) }) + const entitlementBytes = readFileSync(entitlementsFile(), "utf8") + disconnectResponse(JSON.stringify({ id: "company-compute" })) + expect(readFileSync(entitlementsFile(), "utf8")).toBe(entitlementBytes) + expect(JSON.parse(readFileSync(solverModeFile(), "utf8"))).toEqual({ mode: "hp", status: "switching" }) + }) + + test("repeat valid save while already hp+granted: no duplicate codes, no fresh switching write", async () => { + await submitCredentialResponse(validSubmit, { fetchImpl: respond(200) }) + expect(JSON.parse(readFileSync(solverModeFile(), "utf8")).status).toBe("switching") + // the extension watcher settles the request: ready at hp (writeSolverModeReady shape) + writeFileSync( + solverModeFile(), + JSON.stringify({ mode: "hp", status: "ready", switched_at: new Date().toISOString() }), + ) + const entitlementBytes = readFileSync(entitlementsFile(), "utf8") + const again = JSON.parse(await submitCredentialResponse(validSubmit, { fetchImpl: respond(200) })) + expect(again.ok).toBe(true) + expect(again.connection.state).toBe("connected") + expect(readFileSync(entitlementsFile(), "utf8")).toBe(entitlementBytes) // ONE issimo, byte-identical + expect(JSON.parse(readFileSync(solverModeFile(), "utf8")).status).toBe("ready") // watcher NOT poked again + }) + + test("hp already active but the grant is missing → the switch IS re-requested (re-prep must apply the entitlement)", async () => { + mkdirSync(amicodeOpsDir(), { recursive: true }) + writeFileSync(solverModeFile(), JSON.stringify({ mode: "hp", status: "ready" })) + writeFileSync(entitlementsFile(), 'codes = ["pasqal-hackathon-2026"]\n') // issimo absent + await submitCredentialResponse(validSubmit, { fetchImpl: respond(200) }) + expect(readFileSync(entitlementsFile(), "utf8")).toBe('codes = ["pasqal-hackathon-2026", "issimo"]\n') + expect(JSON.parse(readFileSync(solverModeFile(), "utf8"))).toEqual({ mode: "hp", status: "switching" }) + }) +}) + describe("disconnect + revalidate (AC4)", () => { test("disconnect clears the credential; status becomes needs-key; idempotent", async () => { await submitCredentialResponse( From d7e5ae9f9cddaeafa4adf1c4163f5f2cdc43004c Mon Sep 17 00:00:00 2001 From: kate bonner Date: Sun, 19 Jul 2026 21:46:28 -0400 Subject: [PATCH 19/44] =?UTF-8?q?test+feat(amicode):=20submit=20gate=20+?= =?UTF-8?q?=20one-round-trip=20overlay=20=E2=80=94=20empty=20submit=20is?= =?UTF-8?q?=20a=20no-op,=20validating=20in=20flight,=20terminal=20from=20t?= =?UTF-8?q?he=20same=20response=20(166=20AC2+AC3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit submitPayload returns undefined for any empty/whitespace field, so the card fires no request and touches no state (AC3). applyConnectionOverlay is the pure wrapper the app layer puts around ONE POST: {validating:id} renders the in-flight card, then {terminal} — parsed from the SAME response — replaces it; no polling loop, base view never mutated, sibling cards untouched (Pasqal-ready). Co-Authored-By: Claude Fable 5 --- packages/ui/src/amicode/connections.test.ts | 84 +++++++++++++++++++++ packages/ui/src/amicode/connections.ts | 51 +++++++++++++ 2 files changed, 135 insertions(+) diff --git a/packages/ui/src/amicode/connections.test.ts b/packages/ui/src/amicode/connections.test.ts index 545dae9e37..b477c0c17b 100644 --- a/packages/ui/src/amicode/connections.test.ts +++ b/packages/ui/src/amicode/connections.test.ts @@ -1,12 +1,15 @@ import { describe, expect, test } from "bun:test" import { + applyConnectionOverlay, cardModel, parseConnectionActionResponse, parseConnectionsResponse, stateCopy, + submitPayload, validatedAtDisplay, type ConnectionCardState, type ConnectionStateLabels, + type ConnectionsView, type ConnectionView, } from "./connections" @@ -286,3 +289,84 @@ describe("cardModel", () => { } }) }) + +// --- AC3: the submit gate. An empty submission produces NO payload — the +// component fires no request and touches no state when this returns undefined. + +describe("submitPayload", () => { + test("empty or whitespace-only fields yield no payload at all (AC3)", () => { + expect(submitPayload("company-compute", "", "")).toBeUndefined() + expect(submitPayload("company-compute", "https://solve.example", "")).toBeUndefined() + expect(submitPayload("company-compute", "", "sk-key")).toBeUndefined() + expect(submitPayload("company-compute", " ", "sk-key")).toBeUndefined() + expect(submitPayload("company-compute", "https://solve.example", " \t ")).toBeUndefined() + }) + + test("both fields present yields the trimmed wire body", () => { + expect(submitPayload("company-compute", " https://solve.example ", " sk-key ")).toEqual({ + id: "company-compute", + base_url: "https://solve.example", + token: "sk-key", + }) + }) +}) + +// --- AC2: the overlay the app layer applies around one round trip — the +// submit renders "validating" while the POST is in flight, then the terminal +// connection from the SAME response replaces it. No polling loop anywhere. + +describe("applyConnectionOverlay", () => { + const baseView: ConnectionsView = { + ok: true, + connections: [viewFor("needs-key")], + } + + test("no overlay passes the base view through untouched", () => { + expect(applyConnectionOverlay(baseView, {})).toBe(baseView) + expect(applyConnectionOverlay(undefined, {})).toBeUndefined() + }) + + test("validating overlay flips the matching card to the in-flight state (AC2)", () => { + const view = applyConnectionOverlay(baseView, { validating: "company-compute" }) + expect(view?.ok).toBe(true) + expect(view?.connections[0].state).toBe("validating") + expect(view?.connections[0].id).toBe("company-compute") + // the base view is not mutated + expect(baseView.connections[0].state).toBe("needs-key") + }) + + test("terminal overlay replaces the card with the connection from the same response (AC2)", () => { + const terminal = viewFor("connected", { validatedAt: "7/19/2026", identity: "kate@harmoniqs.co" }) + const view = applyConnectionOverlay(baseView, { terminal }) + expect(view?.connections).toHaveLength(1) + expect(view?.connections[0]).toEqual(terminal) + }) + + test("validating wins over a stale terminal while a new request is in flight", () => { + const terminal = viewFor("connected") + const view = applyConnectionOverlay(baseView, { terminal, validating: "company-compute" }) + expect(view?.connections[0].state).toBe("validating") + }) + + test("overlay still renders when the base GET is absent or failed", () => { + const inflight = applyConnectionOverlay(undefined, { validating: "company-compute" }) + expect(inflight?.ok).toBe(true) + expect(inflight?.connections[0].state).toBe("validating") + const failed = applyConnectionOverlay( + { ok: false, connections: [], error: "boom" }, + { terminal: viewFor("connected") }, + ) + expect(failed?.ok).toBe(true) + expect(failed?.connections[0].state).toBe("connected") + }) + + test("an overlay for one id leaves sibling cards untouched (Pasqal-ready)", () => { + const twoCards: ConnectionsView = { + ok: true, + connections: [viewFor("needs-key"), viewFor("connected", { id: "pasqal-cloud" })], + } + const view = applyConnectionOverlay(twoCards, { validating: "company-compute" }) + expect(view?.connections[1]).toEqual(twoCards.connections[1]) + expect(view?.connections[0].state).toBe("validating") + }) +}) diff --git a/packages/ui/src/amicode/connections.ts b/packages/ui/src/amicode/connections.ts index d65d897b26..d116eaf406 100644 --- a/packages/ui/src/amicode/connections.ts +++ b/packages/ui/src/amicode/connections.ts @@ -156,3 +156,54 @@ export function parseConnectionActionResponse(raw: unknown): ConnectionActionVie return { ok: false, error: "bad_shape: connection missing" } return { ok: true, connection: parseConnectionEntry(data.connection) } } + +// --- submit gate (AC3): an empty submission yields NO payload — the card +// fires no request and changes no state when this returns undefined. + +export type CredentialSubmitPayload = { id: string; base_url: string; token: string } + +export function submitPayload(id: string, baseUrl: string, token: string): CredentialSubmitPayload | undefined { + const base = baseUrl.trim() + const key = token.trim() + if (base === "" || key === "") return undefined + return { id, base_url: base, token: key } +} + +// --- action overlay (AC2): the app layer wraps ONE round trip with this — +// {validating: id} while the POST is in flight, then {terminal: connection} +// parsed from the SAME response. Pure and non-mutating; no polling loop. + +export type ConnectionOverlay = { + /** connection id whose card renders "validating" while a request runs */ + validating?: string + /** terminal connection from the mutation response; replaces the card */ + terminal?: ConnectionView +} + +function replaceConnection(base: ConnectionsView | undefined, entry: ConnectionView): ConnectionsView { + if (!base || !base.ok) return { ok: true, connections: [entry] } + const found = base.connections.some((conn) => conn.id === entry.id) + return { + ok: true, + connections: found + ? base.connections.map((conn) => (conn.id === entry.id ? entry : conn)) + : [...base.connections, entry], + } +} + +function validatingEntry(base: ConnectionsView | undefined, id: string): ConnectionView { + const existing = base?.ok ? base.connections.find((conn) => conn.id === id) : undefined + if (existing) return { ...existing, state: "validating", rawState: "validating" } + return { id, state: "validating", rawState: "validating", validatedAt: "—", stale: false } +} + +export function applyConnectionOverlay( + base: ConnectionsView | undefined, + overlay: ConnectionOverlay, +): ConnectionsView | undefined { + let view = base + if (overlay.terminal) view = replaceConnection(view, overlay.terminal) + // applied last: a request in flight outranks any previously stored terminal + if (overlay.validating !== undefined) view = replaceConnection(view, validatingEntry(view, overlay.validating)) + return view +} From 476838c86de4d6bc8f40e4eee160321348108fbc Mon Sep 17 00:00:00 2001 From: kate bonner Date: Sun, 19 Jul 2026 21:46:37 -0400 Subject: [PATCH 20/44] =?UTF-8?q?test+feat(amicode):=20flip=20trouble=20ne?= =?UTF-8?q?ver=20corrupts=20the=20save=20=E2=80=94=20connected=20+=20fixed?= =?UTF-8?q?=20value-free=20warning=20(167=20partial=20failure)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit requestHpFlip never throws; a failed entitlement/solver-mode write surfaces as ok:true + connected with the FIXED "hp_flip_failed: …" string in the response error field (the module's code:detail shape, promoted to a warning channel on renderCurrent). No token, path, or errno ever rides the message; the credential and status cache stand exactly as saved. Co-Authored-By: Claude Fable 5 --- .../src/server/amicode/connections.ts | 36 +++++++++++++------ .../test/server/amicode-connections.test.ts | 20 +++++++++++ 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/server/amicode/connections.ts b/packages/opencode/src/server/amicode/connections.ts index dd35c32973..f0b904b2a1 100644 --- a/packages/opencode/src/server/amicode/connections.ts +++ b/packages/opencode/src/server/amicode/connections.ts @@ -344,16 +344,29 @@ function readSolverMode(file: string): { mode: "piccolo" | "hp"; status: "ready" } } +/** The FIXED partial-failure warning (sibling "code: detail" shape): the + * credential save stands; only the flip write went wrong. Value-free by the + * module contract — never a token, path, or errno. */ +export const HP_FLIP_WARNING = "hp_flip_failed: connected, but the HP solver switch could not be requested" + /** After a VALID save: grant the entitlement, then request the hp switch the * watcher re-preps from — but ONLY when a re-prep would change anything (the * mode isn't hp yet, or the last prep ran without the grant). A repeat save * on an already-flipped setup writes nothing, so the watcher — whose one - * re-prep includes restarting THIS server — is never poked for a no-op. */ -function requestHpFlip(): void { - const { alreadyGranted } = grantIssimo(entitlementsFile()) - const modeFile = solverModeFile() - if (alreadyGranted && readSolverMode(modeFile).mode === "hp") return - atomicWriteFileSync(modeFile, JSON.stringify({ mode: "hp", status: "switching" })) + * re-prep includes restarting THIS server — is never poked for a no-op. + * NEVER throws: flip trouble must not corrupt the credential-save response; + * the caller passes the returned warning (if any) into the response's error + * field beside the connected status. */ +function requestHpFlip(): string | undefined { + try { + const { alreadyGranted } = grantIssimo(entitlementsFile()) + const modeFile = solverModeFile() + if (alreadyGranted && readSolverMode(modeFile).mode === "hp") return undefined + atomicWriteFileSync(modeFile, JSON.stringify({ mode: "hp", status: "switching" })) + return undefined + } catch { + return HP_FLIP_WARNING + } } // --- mutation bodies (POST routes). One shape per route family, sibling @@ -408,14 +421,16 @@ export interface MutationDeps { bindHostname?: string } -function renderCurrent(id: ConnectionType): string { +/** `warning` is the partial-failure channel: ok:true (the mutation stood) with + * a non-null error field carrying a FIXED "code: detail" string (#167). */ +function renderCurrent(id: ConnectionType, warning?: string): string { const cache = readCacheFile(connectionsFile()) const connection = renderStatus(id, whitelistPersisted(cache[id]), { inflight: inflightOverlay.has(id), credential: readCredential(id) !== undefined, now: Date.now(), }) - return JSON.stringify({ ok: true, connection, error: null }) + return JSON.stringify({ ok: true, connection, error: warning ?? null }) } function parseMutationBody(rawBody: string): { id?: unknown; base_url?: unknown; token?: unknown } | undefined { @@ -467,6 +482,7 @@ export async function submitCredentialResponse(rawBody: string, deps: MutationDe inflightOverlay.delete(id) } const validated_at = new Date().toISOString() + let warning: string | undefined if (outcome === "valid") { try { writeCredential(id, { base_url: base, token }) @@ -475,12 +491,12 @@ export async function submitCredentialResponse(rawBody: string, deps: MutationDe return synthesizeConnection("write_failed", "credential could not be saved") } persistStatus(id, { state: "connected", validated_at }) - requestHpFlip() // #167: AFTER the save and ONLY on the valid outcome + warning = requestHpFlip() // #167: AFTER the save and ONLY on the valid outcome } else { // nothing written — an existing credential (if any) stays untouched persistStatus(id, { state: outcome, validated_at }) } - return renderCurrent(id) + return renderCurrent(id, warning) } /** id-only mutation bodies (disconnect/revalidate) — the secret NEVER rides diff --git a/packages/opencode/test/server/amicode-connections.test.ts b/packages/opencode/test/server/amicode-connections.test.ts index d27283ae66..5b966b622a 100644 --- a/packages/opencode/test/server/amicode-connections.test.ts +++ b/packages/opencode/test/server/amicode-connections.test.ts @@ -414,6 +414,26 @@ describe("HP flip on connect — only the valid outcome flips; repeats stay idem }) }) +describe("HP flip on connect — flip trouble never corrupts the save (167 partial failure)", () => { + test("flip write failure → credential SAVED, connected status, fixed value-free warning in the error field", async () => { + // an ops dir that cannot exist: a regular file occupies the parent path + writeFileSync(path.join(dir, "blocker"), "") + process.env.AMICODE_OPS_DIR = path.join(dir, "blocker", "ops") + const raw = await submitCredentialResponse( + JSON.stringify({ id: "company-compute", base_url: "https://solves.example.co", token: "tok-flip-fail" }), + { fetchImpl: respond(200) }, + ) + const parsed = JSON.parse(raw) + expect(parsed.ok).toBe(true) // the save SUCCEEDED — partial failure is a warning, not a failure + expect(parsed.connection.state).toBe("connected") + expect(parsed.error).toStartWith("hp_flip_failed:") // sibling "code: detail" shape, fixed string + expect(raw).not.toContain("tok-flip-fail") // value-free: no token… + expect(raw).not.toContain(dir) // …and no filesystem path/errno detail either + expect(readCredential("company-compute")).toEqual({ base_url: "https://solves.example.co", token: "tok-flip-fail" }) + expect(JSON.parse(statusResponse()).connections[0].state).toBe("connected") // the cache write preceded the flip + }) +}) + describe("disconnect + revalidate (AC4)", () => { test("disconnect clears the credential; status becomes needs-key; idempotent", async () => { await submitCredentialResponse( From 916084e2d5481b322621de498ab3d2fc14a90fd6 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Sun, 19 Jul 2026 21:48:01 -0400 Subject: [PATCH 21/44] test(amicode): flip artifacts asserted over the real route tree (167 AC1/AC3/AC4 at route level) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the #165 route surface: a valid submit through the standalone-serve route tree writes both ops-dir artifacts (no extension host in the process — headless durability), disconnect leaves them untouched (one-way flip), and a 401 submit produces no flip artifacts at all. Ops dir rides $AMICODE_OPS_DIR in both connections suites so every flip write stays hermetic. Co-Authored-By: Claude Fable 5 --- .../server/amicode-connections-routes.test.ts | 60 ++++++++++++++++++- 1 file changed, 57 insertions(+), 3 deletions(-) diff --git a/packages/opencode/test/server/amicode-connections-routes.test.ts b/packages/opencode/test/server/amicode-connections-routes.test.ts index d803b6c793..e3cb53ca1c 100644 --- a/packages/opencode/test/server/amicode-connections-routes.test.ts +++ b/packages/opencode/test/server/amicode-connections-routes.test.ts @@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test" import { createServer } from "node:http" import type { AddressInfo } from "node:net" -import { mkdtempSync, writeFileSync } from "node:fs" +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import path from "node:path" import { ConfigProvider, Layer } from "effect" @@ -15,7 +15,13 @@ import { HttpRouter } from "effect/unstable/http" import { HttpApiApp } from "@/server/routes/instance/httpapi/server" import { ServerAuth } from "@/server/auth" import { readCredential } from "@/server/amicode/credentials" -import { connectionsFile, inflightOverlay, setBindHostname } from "@/server/amicode/connections" +import { + connectionsFile, + entitlementsFile, + inflightOverlay, + setBindHostname, + solverModeFile, +} from "@/server/amicode/connections" import { resetDatabase } from "../fixture/db" import { disposeAllInstances } from "../fixture/fixture" @@ -69,7 +75,7 @@ async function stubSolveService(status: () => number) { const post = (body: unknown): RequestInit => ({ method: "POST", body: JSON.stringify(body) }) -const ENV_KEYS = ["AMICO_CLOUD_FILE", "AMICO_PASQAL_FILE", "AMICODE_CONNECTIONS_FILE"] as const +const ENV_KEYS = ["AMICO_CLOUD_FILE", "AMICO_PASQAL_FILE", "AMICODE_CONNECTIONS_FILE", "AMICODE_OPS_DIR"] as const let savedEnv: Record let dir: string @@ -79,6 +85,7 @@ beforeEach(() => { process.env.AMICO_CLOUD_FILE = path.join(dir, "cloud.json") process.env.AMICO_PASQAL_FILE = path.join(dir, "pasqal.json") process.env.AMICODE_CONNECTIONS_FILE = path.join(dir, "connections.json") + process.env.AMICODE_OPS_DIR = path.join(dir, "amicode-ops") // flip artifacts (#167) stay hermetic inflightOverlay.clear() setBindHostname(undefined) // isolation from any listener another test file bound }) @@ -179,6 +186,53 @@ describe("connections routes — full lifecycle, no extension host (AC6)", () => }) }) +describe("connections routes — HP flip artifacts over the route tree (167 AC1, AC3, AC4)", () => { + test("valid submit over the route → both flip artifacts; disconnect leaves them (one-way); 401 never flips", async () => { + const server = app() + let probeStatus = 404 + const stub = await stubSolveService(() => probeStatus) + try { + // valid save over the REAL route tree → both ops-dir artifacts (AC1), + // durable with no extension host anywhere in this process (AC3) + const submitted = await ( + await server.request( + "/amicode/connections/credential", + post({ id: "company-compute", base_url: stub.url, token: "tok-flip" }), + ) + ).json() + expect(submitted.ok).toBe(true) + expect(submitted.connection.state).toBe("connected") + expect(submitted.error).toBeNull() // flip succeeded — no partial-failure warning + expect(readFileSync(entitlementsFile(), "utf8")).toBe('codes = ["issimo"]\n') + expect(JSON.parse(readFileSync(solverModeFile(), "utf8"))).toEqual({ mode: "hp", status: "switching" }) + + // disconnect reverts NOTHING solver-side — the flip is one-way on connect + await server.request("/amicode/connections/disconnect", post({ id: "company-compute" })) + expect(readFileSync(entitlementsFile(), "utf8")).toBe('codes = ["issimo"]\n') + expect(JSON.parse(readFileSync(solverModeFile(), "utf8"))).toEqual({ mode: "hp", status: "switching" }) + } finally { + await stub.close() + } + + // a rejected key over the route produces no flip artifacts at all (AC4) + process.env.AMICODE_OPS_DIR = path.join(dir, "amicode-ops-rejected") + const rejecting = await stubSolveService(() => 401) + try { + const rejected = await ( + await server.request( + "/amicode/connections/credential", + post({ id: "company-compute", base_url: rejecting.url, token: "tok-bad" }), + ) + ).json() + expect(rejected.connection.state).toBe("invalid") + expect(existsSync(entitlementsFile())).toBe(false) + expect(existsSync(solverModeFile())).toBe(false) + } finally { + await rejecting.close() + } + }) +}) + describe("connections routes — auth per #163", () => { test("with a password configured, unauthenticated requests are rejected; basic auth serves", async () => { const server = app({ password: "secret" }) From 9bd3cf6d9cca5ad8d5ad653328c1a62f5121ab39 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Sun, 19 Jul 2026 21:50:44 -0400 Subject: [PATCH 22/44] =?UTF-8?q?feat(amicode):=20Connections=20tab=20?= =?UTF-8?q?=E2=80=94=20Company=20Compute=20card=20beside=20vaults=20in=20t?= =?UTF-8?q?he=20status=20popover=20(166=20AC1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit connections-tab.tsx is a thin projection of the tested cardModel: status dot + distinct per-state copy, key form (base_url prefills from the wire when offered; token rides a password-masked input, cleared once a submit lands connected, never echoed back), disconnect/revalidate when connected, raw-word badge for unknown states. Registered as a Tabs.Trigger/Content pair beside the vaults tab with the same per-active-server authenticated fetch; mutations wrap ONE round trip via applyConnectionOverlay (validating in flight → terminal from the same response). Re-export shim rides the components/* wildcard; copy lands in en.ts (locale fallback idiom, same as vaults). Co-Authored-By: Claude Fable 5 --- .../src/components/status-popover-body.tsx | 124 ++++++++++ packages/app/src/i18n/en.ts | 16 ++ packages/ui/src/amicode/connections-tab.tsx | 217 ++++++++++++++++++ packages/ui/src/amicode/connections.test.ts | 9 + packages/ui/src/amicode/connections.ts | 6 + .../components/amicode-connections-tab.tsx | 14 ++ 6 files changed, 386 insertions(+) create mode 100644 packages/ui/src/amicode/connections-tab.tsx create mode 100644 packages/ui/src/components/amicode-connections-tab.tsx diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx index 92367fbadb..d4a8b60e64 100644 --- a/packages/app/src/components/status-popover-body.tsx +++ b/packages/app/src/components/status-popover-body.tsx @@ -10,6 +10,7 @@ import { createEffect, createMemo, createResource, + createSignal, For, type JSXElement, onCleanup, @@ -31,6 +32,16 @@ import { parseVaultsResponse, type VaultsView, } from "@opencode-ai/ui/amicode-vaults-tab" +import { + AmicodeConnectionsTab, + applyConnectionOverlay, + parseConnectionActionResponse, + parseConnectionsResponse, + type ConnectionActionView, + type ConnectionOverlay, + type ConnectionsView, + type CredentialSubmitPayload, +} from "@opencode-ai/ui/amicode-connections-tab" import { usePrompt } from "@/context/prompt" import { startPrompt } from "@/utils/start-prompt" import { authTokenFromCredentials } from "@/utils/server" @@ -343,6 +354,99 @@ export function StatusPopoverBody(props: { shown: Accessor; onClose?: ( startPrompt(prompt, AMICODE_MANAGE_VAULTS_PROMPT) } + // amicode: Connections tab (#166) — same per-active-server fetch idiom as + // vaults above. Mutations are ONE round trip (#165 contract): the overlay + // renders "validating" while the POST runs, then the terminal connection + // from the SAME response replaces it. No polling loop. + const amicodeHeaders = (conn: ServerConnection.Any) => { + const headers: Record = {} + if (conn.http.password) + headers.Authorization = `Basic ${authTokenFromCredentials({ + username: conn.http.username, + password: conn.http.password, + })}` + return headers + } + const [connectionsRaw, { refetch: refetchConnections }] = createResource( + () => (props.shown() && server.current ? ServerConnection.key(server.current) : undefined), + async () => { + const conn = server.current + if (!conn) return undefined + const res = await fetch(new URL("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/amicode/connections", conn.http.url), { headers: amicodeHeaders(conn) }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return (await res.json()) as unknown + }, + ) + const [connectionsOverlay, setConnectionsOverlay] = createSignal({}) + const [connectionsActionError, setConnectionsActionError] = createSignal() + createEffect(() => { + connectionsRaw.state // a fresh GET supersedes any leftover action overlay + setConnectionsOverlay({}) + setConnectionsActionError(undefined) + }) + const connectionsView = createMemo(() => { + const base = (() => { + if (connectionsRaw.error) + return { ok: false, connections: [], error: language.t("dialog.connections.fetchFailed") } as ConnectionsView + const raw = connectionsRaw() + if (raw === undefined) return undefined + return parseConnectionsResponse(raw) + })() + return applyConnectionOverlay(base, connectionsOverlay()) + }) + const connectionsCount = () => { + const view = connectionsView() + return view?.ok ? view.connections.filter((conn) => conn.state === "connected").length : 0 + } + const runConnectionAction = async (id: string, path: string, body: unknown): Promise => { + setConnectionsActionError(undefined) + setConnectionsOverlay({ validating: id }) + const result = await (async (): Promise => { + const conn = server.current + if (!conn) return { ok: false, error: language.t("dialog.connections.fetchFailed") } + try { + const res = await fetch(new URL(path, conn.http.url), { + method: "POST", + headers: { ...amicodeHeaders(conn), "content-type": "application/json" }, + body: JSON.stringify(body), + }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return parseConnectionActionResponse((await res.json()) as unknown) + } catch { + return { ok: false, error: language.t("dialog.connections.fetchFailed") } + } + })() + if (result.ok && result.connection) { + setConnectionsOverlay({ terminal: result.connection }) + } else { + setConnectionsOverlay({}) + setConnectionsActionError(result.error ?? language.t("dialog.connections.fetchFailed")) + } + return result + } + const onSubmitCredential = (payload: CredentialSubmitPayload) => + runConnectionAction(payload.id, "/amicode/connections/credential", payload) + const onDisconnectConnection = (id: string) => void runConnectionAction(id, "/amicode/connections/disconnect", { id }) + const onRevalidateConnection = (id: string) => void runConnectionAction(id, "/amicode/connections/revalidate", { id }) + const connectionsLabels = createMemo(() => ({ + empty: language.t("dialog.connections.empty"), + retry: language.t("dialog.connections.retry"), + states: { + connected: language.t("dialog.connections.state.connected"), + "needs-key": language.t("dialog.connections.state.needsKey"), + invalid: language.t("dialog.connections.state.invalid"), + unreachable: language.t("dialog.connections.state.unreachable"), + validating: language.t("dialog.connections.state.validating"), + unknown: language.t("dialog.connections.state.unknown"), + }, + baseUrlPlaceholder: language.t("dialog.connections.baseUrlPlaceholder"), + tokenPlaceholder: language.t("dialog.connections.tokenPlaceholder"), + submit: language.t("dialog.connections.submit"), + disconnect: language.t("dialog.connections.disconnect"), + revalidate: language.t("dialog.connections.revalidate"), + staleHint: language.t("dialog.connections.stale"), + })) + return (
; onClose?: ( {vaultsCount() > 0 ? `${vaultsCount()} ` : ""} {language.t("status.popover.tab.vaults")} + + {connectionsCount() > 0 ? `${connectionsCount()} ` : ""} + {language.t("status.popover.tab.connections")} + {!settings.general.newLayoutDesigns() && ( @@ -570,6 +678,22 @@ export function StatusPopoverBody(props: { shown: Accessor; onClose?: (
+ + +
+
+ +
+
+
) diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index e291090cac..56d724468e 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -686,6 +686,22 @@ export const dict = { "dialog.vaults.empty": "No vaults mounted", "dialog.vaults.retry": "Retry", "dialog.vaults.fetchFailed": "Could not reach the server for vault status", + "status.popover.tab.connections": "Connections", + "dialog.connections.empty": "No connections available", + "dialog.connections.retry": "Retry", + "dialog.connections.fetchFailed": "Could not reach the server for connection status", + "dialog.connections.state.connected": "Connected", + "dialog.connections.state.needsKey": "Not connected — enter a key to connect", + "dialog.connections.state.invalid": "Key rejected — check it and try again", + "dialog.connections.state.unreachable": "Service unreachable — check the URL or try again", + "dialog.connections.state.validating": "Validating key…", + "dialog.connections.state.unknown": "Status needs attention", + "dialog.connections.baseUrlPlaceholder": "Service URL", + "dialog.connections.tokenPlaceholder": "API key", + "dialog.connections.submit": "Connect", + "dialog.connections.disconnect": "Disconnect", + "dialog.connections.revalidate": "Revalidate", + "dialog.connections.stale": "Last check is stale — revalidate to refresh", "amicode.retry": "Retry", "amicode.unavailable": "status unavailable", "amicode.fetchFailed": "Could not reach the server for problem status", diff --git a/packages/ui/src/amicode/connections-tab.tsx b/packages/ui/src/amicode/connections-tab.tsx new file mode 100644 index 0000000000..5feb9d99b0 --- /dev/null +++ b/packages/ui/src/amicode/connections-tab.tsx @@ -0,0 +1,217 @@ +// AMICODE: Connections tab body for the status popover (amicode#166). A thin +// projection of connections.ts — cardModel/stateCopy carry the behavioral +// contract (and its tests; the repo has no tsx component harness), the app +// layer owns fetching and the one-round-trip overlay, and this file only +// renders. SECURITY: the token lives in a password-masked input and the +// submit payload; it is never rendered as text and the input clears when a +// submit lands connected. +import { createEffect, createSignal, For, Show } from "solid-js" +import { + cardModel, + connectionTitle, + stateCopy, + submitPayload, + type ConnectionActionView, + type ConnectionsView, + type ConnectionStateLabels, + type ConnectionView, + type CredentialSubmitPayload, +} from "./connections" + +export type ConnectionsTabLabels = { + empty: string + retry: string + states: ConnectionStateLabels + baseUrlPlaceholder: string + tokenPlaceholder: string + submit: string + disconnect: string + revalidate: string + staleHint: string +} + +export function AmicodeConnectionsTab(props: { + view: ConnectionsView | undefined + labels: ConnectionsTabLabels + actionError?: string + onSubmit: (payload: CredentialSubmitPayload) => Promise + onDisconnect: (id: string) => void + onRevalidate: (id: string) => void + onRetry: () => void +}) { + return ( +
+ } + > + {(view) => ( + +
+ {view().error} + +
+ } + > + 0} + fallback={
{props.labels.empty}
} + > + + {(conn) => ( + + )} + +
+
+ )} +
+
+ ) +} + +const TONE_DOT: Record["tone"], string> = { + success: "bg-icon-success-base", + critical: "bg-icon-critical-base", + warning: "bg-icon-warning-base", + pending: "bg-icon-warning-base animate-pulse", + neutral: "bg-border-weak-base", +} + +function ConnectionCard(props: { + conn: ConnectionView + labels: ConnectionsTabLabels + actionError?: string + onSubmit: (payload: CredentialSubmitPayload) => Promise + onDisconnect: (id: string) => void + onRevalidate: (id: string) => void +}) { + const model = () => cardModel(props.conn) + const [baseUrl, setBaseUrl] = createSignal("") + const [token, setToken] = createSignal("") + + // wire-offered prefill fills an untouched field only, never overwrites input + createEffect(() => { + const wire = props.conn.baseUrl + if (wire && baseUrl() === "") setBaseUrl(wire) + }) + + const submit = async (event: Event) => { + event.preventDefault() + const payload = submitPayload(props.conn.id, baseUrl(), token()) + if (!payload) return // AC3: empty submission — no request, no state change + const result = await props.onSubmit(payload) + // clear the masked input once the key is accepted; it is never echoed back + if (result.ok && result.connection?.state === "connected") setToken("") + } + + return ( +
+
+
+ {connectionTitle(props.conn.id)} + + + {props.conn.rawState} + + +
+ + + {props.conn.validatedAt} + + +
+ +
+ {stateCopy(props.conn, props.labels.states)} + + · {props.conn.identity} + +
+ + +
{props.labels.staleHint}
+
+ + +
+
+ {props.actionError} +
+ + + +
+ setBaseUrl(event.currentTarget.value)} + class="text-12-regular text-text-base bg-surface-base rounded-md px-2 py-1 border border-border-weak-base" + /> + setToken(event.currentTarget.value)} + class="text-12-regular text-text-base bg-surface-base rounded-md px-2 py-1 border border-border-weak-base" + /> + +
+
+ + +
+ + +
+
+
+ ) +} diff --git a/packages/ui/src/amicode/connections.test.ts b/packages/ui/src/amicode/connections.test.ts index b477c0c17b..b05cf77447 100644 --- a/packages/ui/src/amicode/connections.test.ts +++ b/packages/ui/src/amicode/connections.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import { applyConnectionOverlay, cardModel, + connectionTitle, parseConnectionActionResponse, parseConnectionsResponse, stateCopy, @@ -290,6 +291,14 @@ describe("cardModel", () => { }) }) +describe("connectionTitle", () => { + test("company-compute reads as Company Compute; unknown ids render verbatim", () => { + expect(connectionTitle("company-compute")).toBe("Company Compute") + expect(connectionTitle("pasqal-cloud")).toBe("pasqal-cloud") + expect(connectionTitle("(unknown)")).toBe("(unknown)") + }) +}) + // --- AC3: the submit gate. An empty submission produces NO payload — the // component fires no request and touches no state when this returns undefined. diff --git a/packages/ui/src/amicode/connections.ts b/packages/ui/src/amicode/connections.ts index d116eaf406..74bc23d904 100644 --- a/packages/ui/src/amicode/connections.ts +++ b/packages/ui/src/amicode/connections.ts @@ -33,6 +33,12 @@ export type ConnectionActionView = { ok: boolean; connection?: ConnectionView; e export const COMPANY_COMPUTE_ID = "company-compute" +/** Product names are not translated; ids without one render verbatim. */ +export function connectionTitle(id: string): string { + if (id === COMPANY_COMPUTE_ID) return "Company Compute" + return id +} + const WIRE_STATES: ReadonlySet = new Set(CONNECTION_WIRE_STATES) function str(value: unknown): string | undefined { diff --git a/packages/ui/src/components/amicode-connections-tab.tsx b/packages/ui/src/components/amicode-connections-tab.tsx new file mode 100644 index 0000000000..8088b9a1a7 --- /dev/null +++ b/packages/ui/src/components/amicode-connections-tab.tsx @@ -0,0 +1,14 @@ +// AMICODE: re-export shim so packages/app can import the connections tab +// through the existing `"./*": "./src/components/*.tsx"` export wildcard +// without touching packages/ui/package.json. Logic lives in +// ../amicode/connections-tab.tsx and ../amicode/connections.ts. +export { AmicodeConnectionsTab, type ConnectionsTabLabels } from "../amicode/connections-tab" +export { + applyConnectionOverlay, + parseConnectionActionResponse, + parseConnectionsResponse, + type ConnectionActionView, + type ConnectionOverlay, + type ConnectionsView, + type CredentialSubmitPayload, +} from "../amicode/connections" From b03bdd3005f99126fa6ea9144581ce4b4d8d786a Mon Sep 17 00:00:00 2001 From: kate bonner Date: Sun, 19 Jul 2026 22:11:03 -0400 Subject: [PATCH 23/44] =?UTF-8?q?test+feat(amicode):=20Pasqal=20card=20?= =?UTF-8?q?=E2=80=94=20validator=20spawn=20seam=20+=20token-only=20persist?= =?UTF-8?q?ence=20(169=20AC1+AC2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Submit for id=pasqal-cloud spawns the #164 validator through an injectable PasqalSpawn seam: argv is