From 3f97fb16c26d4010eef59801f54b35352589ac1e Mon Sep 17 00:00:00 2001 From: aaron Date: Thu, 20 Aug 2026 17:05:50 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(extension):=20amicode=20service=20slic?= =?UTF-8?q?e=206=20=E2=80=94=20connections=20+=20projects,=20all=2031=20fo?= =?UTF-8?q?rk=20routes=20ported?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M1 slice 6 of #451 (FINAL route slice): GET/POST /amicode/project(s), GET /amicode/connections, GET /amicode/connections/catalog, and the seven connections mutations (credential, disconnect, revalidate, choose-project, auth, add-custom, remove) — the last 11 fork routes. All 31 of 31 fork amicode routes now serve from the extension host. Ports (verbatim, import-path renames only): connections.ts (the probe-first credential flow, redacting-whitelist status parser, custom-connection registry, keychain-backed pasqal secret store — secrets in POST bodies only, never URLs/logs), credentials.ts (poison-guarded allowlist encoders per connector), pasqal-secret.ts, project.ts (mkdir + best-effort git init, slug collision, absolute-parent law). Two Bun→Node port seams: Bun.spawn → child_process in the pasqal validator (same minimal-env contract), and the open-npm browser fallback → platform opener via the same spawn idiom. Golden fixtures: 51 → 71 entries — network-free shapes only (status reads, fs-only mutations, pre-probe refusals); live-provider probes are covered by the fork's injectable-fetch unit suites, ported with the module. Two documented post-pin divergences: /amicode/connections/auth and the token auth_methods entry both post-date the vendored binary (v1.18.10-amicode.11 serves the SPA for the auth route) — the port follows current source; auth's refusal shapes are unit-tested in amicode_service_connections.test.ts and both join the golden arc at the next pin bump. Contract suite 74/74 + connections units 4/4; full suite 1132/1132; typecheck clean. --- .../scripts/amicode_fixture_seed.mjs | 35 + .../scripts/record_amicode_fixtures.mjs | 91 + .../src/amicode_service/connections.ts | 1916 +++++++++++++++++ .../src/amicode_service/credentials.ts | 303 +++ .../extension/src/amicode_service/index.ts | 56 + .../src/amicode_service/pasqal_secret.ts | 146 ++ .../extension/src/amicode_service/project.ts | 154 ++ .../test/amicode_service_connections.test.ts | 36 + .../test/amicode_service_contract.test.ts | 17 +- .../test/fixtures/amicode/golden.json | 298 ++- 10 files changed, 3038 insertions(+), 14 deletions(-) create mode 100644 packages/extension/src/amicode_service/connections.ts create mode 100644 packages/extension/src/amicode_service/credentials.ts create mode 100644 packages/extension/src/amicode_service/pasqal_secret.ts create mode 100644 packages/extension/src/amicode_service/project.ts create mode 100644 packages/extension/test/amicode_service_connections.test.ts diff --git a/packages/extension/scripts/amicode_fixture_seed.mjs b/packages/extension/scripts/amicode_fixture_seed.mjs index 4da4a16de..6a480209b 100644 --- a/packages/extension/scripts/amicode_fixture_seed.mjs +++ b/packages/extension/scripts/amicode_fixture_seed.mjs @@ -314,6 +314,31 @@ export function seedAmicodeSandbox(dir) { views: { home: "grid" }, }); + // --- connections (credentials + status cache + custom registry) ------------- + // company-compute + slack CONNECTED (credential file present, cache entry + // fresh); credential mtimes pinned to validated_at so staleness reads false + // (the 5s mtime slack). Tokens are inert seed values — no probe runs. + writeJson(join(amico, "cloud.json"), { base_url: "https://solve.example.internal", token: "tok-cc-seed" }); + writeJson(join(amico, "slack.json"), { token: "xoxb-seed-token" }); + utimesSync(join(amico, "cloud.json"), new Date("2026-08-10T00:00:00Z"), new Date("2026-08-10T00:00:00Z")); + utimesSync(join(amico, "slack.json"), new Date("2026-08-10T00:00:00Z"), new Date("2026-08-10T00:00:00Z")); + writeJson(join(amico, "connections.json"), { + "company-compute": { + state: "connected", + identity: "aaron", + entitlements: ["hpc"], + validated_at: "2026-08-10T00:00:00Z", + }, + slack: { state: "connected", identity: "aaron@example", validated_at: "2026-08-10T00:00:00Z" }, + }); + // one custom connection (the remove-fixture target) + writeJson(join(dir, "custom-connections.json"), [ + { id: "custom-seed1", name: "Lab QPU", token: "tok-qpu", url: "https://qpu.example" }, + ]); + + // --- projects (defaultParentDir is HOME-based — HOME rides the env overlay) -- + mkdirSync(join(dir, "AmicodeProjects", "prior-project"), { recursive: true }); + // --- stub PATH: `amico` exists (fixed output → deterministic approve fixture), // `amico-vault` does NOT (forces the CLI-less scanMounts path on both // sides, so fixtures never depend on a real CLI being installed). ---------- @@ -346,6 +371,16 @@ export function seedAmicodeSandbox(dir) { AMICODE_LIBRARY_DIR: libraryDir, AMICODE_WIDGETS_DIR: widgetsDir, AMICODE_DASHBOARD_FILE: join(amico, "dashboard.json"), + AMICODE_CONNECTIONS_FILE: join(amico, "connections.json"), + AMICO_CUSTOM_CONNECTIONS_FILE: join(dir, "custom-connections.json"), + AMICO_CLOUD_FILE: join(amico, "cloud.json"), + AMICO_SLACK_FILE: join(amico, "slack.json"), + AMICO_PASQAL_FILE: join(amico, "pasqal.json"), + AMICO_GITHUB_FILE: join(amico, "github.json"), + AMICO_LINEAR_FILE: join(amico, "linear.json"), + AMICO_GOOGLE_FILE: join(amico, "google.json"), + AMICO_GOOGLE_DRIVE_FILE: join(amico, "google-drive.json"), + HOME: dir, // the projects default parent + CLI candidate paths are HOME-based PATH: stubbin, }, }; diff --git a/packages/extension/scripts/record_amicode_fixtures.mjs b/packages/extension/scripts/record_amicode_fixtures.mjs index 9501eff12..e6ef25d88 100644 --- a/packages/extension/scripts/record_amicode_fixtures.mjs +++ b/packages/extension/scripts/record_amicode_fixtures.mjs @@ -190,6 +190,97 @@ const REQUESTS = [ body: { widget: "not-a-list" }, name: "dashboard save — bad_body refusal", }, + // ── connections + projects (slice 6) — network-free shapes only: status + // reads, fs-only mutations, and pre-probe refusals. Live-provider probes + // (real Slack/GitHub/Google/Linear endpoints) are deliberately NOT + // golden-tested — they're covered by the fork's unit suites with + // injectable fetch, ported with the module. + { method: "GET", path: "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/amicode/connections", name: "connections — seeded connected + needs-key states" }, + { method: "GET", path: "/amicode/connections/catalog", name: "connections catalog — configured filtered out" }, + { method: "POST", path: "/amicode/connections/credential", body: {}, name: "credential submit — empty body refusal" }, + { + method: "POST", + path: "/amicode/connections/credential", + body: { id: "bogus-connector", token: "x" }, + name: "credential submit — unknown_connection", + }, + { + method: "POST", + path: "/amicode/connections/credential", + body: { id: "company-compute", base_url: "ftp://not-http", token: "t" }, + name: "credential submit — non-http base_url refusal", + }, + { + method: "POST", + path: "/amicode/connections/credential", + body: { id: "slack", token: " " }, + name: "credential submit — empty token refusal", + }, + { + method: "POST", + path: "/amicode/connections/disconnect", + body: { id: "slack" }, + name: "disconnect — clears seeded slack credential", + }, + { + method: "POST", + path: "/amicode/connections/disconnect", + body: { id: "no-such-connector" }, + name: "disconnect — unknown id refusal", + }, + { + method: "POST", + path: "/amicode/connections/revalidate", + body: { id: "github" }, + name: "revalidate — no credential → needs-key (pre-probe)", + }, + { + method: "POST", + path: "/amicode/connections/revalidate", + body: { id: "githubx" }, + name: "revalidate — unknown id refusal", + }, + { + method: "POST", + path: "/amicode/connections/choose-project", + body: { id: "pasqal-cloud", project_id: "p1" }, + name: "choose-project — no pending selection", + }, + // NOTE — POST /amicode/connections/auth is deliberately NOT golden-tested: + // the route exists in the fork's current source but post-dates the vendored + // pin (v1.18.10-amicode.11 serves the SPA catch-all for it). The port + // implements it from source; its refusal shapes are unit-tested in + // amicode_service_connections.test.ts, and it joins the golden arc at the + // next pin bump. + { + method: "POST", + path: "/amicode/connections/add-custom", + body: { name: "half-filled" }, + name: "add-custom — missing token refusal", + }, + { + method: "POST", + path: "/amicode/connections/remove", + body: { id: "custom-seed1" }, + name: "remove — custom connection removed", + }, + { + method: "POST", + path: "/amicode/connections/remove", + body: { id: "custom-gone" }, + name: "remove — unknown custom id refusal", + }, + { method: "GET", path: "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/amicode/connections", name: "connections — post-disconnect state" }, + { method: "POST", path: "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/amicode/project", body: { name: "My New Project" }, name: "project create — mkdir (git absent, best-effort)" }, + { method: "POST", path: "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/amicode/project", body: { name: "My New Project" }, name: "project create — collision" }, + { method: "POST", path: "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/amicode/project", body: { name: " " }, name: "project create — empty-name refusal" }, + { + method: "POST", + path: "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/amicode/project", + body: { name: "Ok", parentDir: "relative/path" }, + name: "project create — non-absolute parent refusal", + }, + { method: "GET", path: "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/amicode/projects", name: "projects list — prior + created" }, ]; async function main() { diff --git a/packages/extension/src/amicode_service/connections.ts b/packages/extension/src/amicode_service/connections.ts new file mode 100644 index 000000000..4f9726335 --- /dev/null +++ b/packages/extension/src/amicode_service/connections.ts @@ -0,0 +1,1916 @@ +// 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. +import { spawn } from "node:child_process" +import { existsSync, readFileSync } from "node:fs" +import { randomBytes } from "node:crypto" +import { homedir } from "node:os" +import path from "node:path" +import { + atomicWriteFileSync, + clearCredential, + credentialFileMtime, + readCredential, + writeCredential, + type ConnectionType, + type PasqalCredential, +} from "./credentials" +import { pasqalSecretStore } from "./pasqal_secret" +import { parseTomlLite } from "./toml_lite" + +// --- 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 + /** 170 AC4 (the 2026-07-19 incident canary): the submitter this credential + * NOW answers as, when a revalidation echo disagrees with the stored + * `identity`. The stored identity is the immutable record; this field is + * the diff — presence IS the drift signal. Reconciliation is a human act + * (re-submitting the credential resets the record). */ + identity_drift?: string + entitlements?: string[] + expires_at?: string + devices?: ConnectionDevice[] + validated_at: string | null + stale: boolean + /** 169 AC4: connected purely in-memory (Pasqal minted no persistable + * token) — the claim dies with the server process. */ + session_only?: boolean + /** 170 AC3: the last background revalidation could not REACH the service — + * a presentation flag on a connected claim ("last verified + * as "), never a verdict on the credential. Connected-only. */ + offline?: boolean + /** #327: optional icon svg for built-ins, letter avatar for custom */ + icon?: string + /** #327: display name from registry */ + name?: string + /** auth methods advertised to the UI — browser for google, token for others */ + auth_methods?: string[] +} + +/** The connection cards this module serves; company-compute renders first. */ +export const CONNECTION_IDS: ConnectionType[] = ["company-compute", "pasqal-cloud", "slack", "github", "linear", "google", "google-drive"] + +// --- Registry (issue #327): formalized built-in catalog with logos + custom --- + +/** Inline SVG icons — full-color brand marks, 18×18 with explicit fills (not currentColor). */ +export const CONNECTION_ICONS: Record = { + "company-compute": + '', + "pasqal-cloud": + '', + slack: + '', + github: + '', + linear: + '', + google: + '', + "google-drive": + '', +} + +export interface ConnectionEntry { + id: string + kind: "built-in" | "custom" + name: string + icon: { kind: "svg"; svg: string } | { kind: "letter"; letter: string } + validator: "company-compute" | "pasqal" | "slack" | "github" | "linear" | "google" | "google-drive" | "none" + authShape: "base-url-token" | "token-only" | "pasqal-credentials" | "browser" + url?: string +} + +export const BUILT_IN_CATALOG: ConnectionEntry[] = [ + { + id: "company-compute", + kind: "built-in", + name: "Harmoniqs Cloud", + icon: { kind: "svg", svg: CONNECTION_ICONS["company-compute"] }, + validator: "company-compute", + authShape: "base-url-token", + }, + { + id: "pasqal-cloud", + kind: "built-in", + name: "Pasqal Cloud", + icon: { kind: "svg", svg: CONNECTION_ICONS["pasqal-cloud"] }, + validator: "pasqal", + authShape: "pasqal-credentials", + }, + { + id: "slack", + kind: "built-in", + name: "Slack", + icon: { kind: "svg", svg: CONNECTION_ICONS["slack"] }, + validator: "slack", + authShape: "token-only", + }, + { + id: "github", + kind: "built-in", + name: "GitHub", + icon: { kind: "svg", svg: CONNECTION_ICONS["github"] }, + validator: "github", + authShape: "token-only", + }, + { + id: "linear", + kind: "built-in", + name: "Linear", + icon: { kind: "svg", svg: CONNECTION_ICONS["linear"] }, + validator: "linear", + authShape: "token-only", + }, + { + id: "google", + kind: "built-in", + name: "Google", + icon: { kind: "svg", svg: CONNECTION_ICONS["google"] }, + validator: "google", + authShape: "token-only", + }, + { + id: "google-drive", + kind: "built-in", + name: "Google Drive", + icon: { kind: "svg", svg: CONNECTION_ICONS["google-drive"] }, + validator: "google-drive", + authShape: "token-only", + }, +] + +export function getBuiltInEntry(id: string): ConnectionEntry | undefined { + return BUILT_IN_CATALOG.find((e) => e.id === id) +} + +export function isCustomConnectionId(id: string): boolean { + return id.startsWith("custom-") +} + +/** Custom connections file — 0600 atomic, ADR 0001 discipline. */ +export function customConnectionsFile(): string { + const env = process.env.AMICO_CUSTOM_CONNECTIONS_FILE + if (env && env.trim() !== "") return env + return path.join(homedir(), ".amico", "custom-connections.json") +} + +export interface CustomConnectionRecord { + id: string + name: string + url?: string + token: string +} + +export function loadCustomConnections(): CustomConnectionRecord[] { + try { + const file = customConnectionsFile() + if (!existsSync(file)) return [] + const raw: unknown = JSON.parse(readFileSync(file, "utf8")) + if (!Array.isArray(raw)) return [] + const out: CustomConnectionRecord[] = [] + for (const entry of raw) { + if (typeof entry !== "object" || entry === null || Array.isArray(entry)) continue + const d = entry as Record + const id = typeof d.id === "string" ? d.id : "" + const name = typeof d.name === "string" ? d.name : "" + const token = typeof d.token === "string" ? d.token : "" + if (!id.startsWith("custom-") || name === "" || token === "") continue + const url = typeof d.url === "string" && d.url.trim() !== "" ? d.url.trim() : undefined + out.push({ id, name, token, ...(url ? { url } : {}) }) + } + return out + } catch { + return [] + } +} + +export function saveCustomConnections(records: CustomConnectionRecord[]): void { + const file = customConnectionsFile() + // atomic 0600 via shared writer + atomicWriteFileSync(file, JSON.stringify(records, null, 2) + "\n") +} + +export function allConnections(): ConnectionEntry[] { + const customs = loadCustomConnections().map((c) => ({ + id: c.id, + kind: "custom" as const, + name: c.name, + icon: { kind: "letter" as const, letter: c.name.charAt(0).toUpperCase() }, + validator: "none" as const, + authShape: "token-only" as const, + url: c.url, + })) + return [...BUILT_IN_CATALOG, ...customs] +} + +export function configuredConnectionIds(): string[] { + const ids: string[] = [] + for (const entry of BUILT_IN_CATALOG) { + if (readCredential(entry.id) !== undefined) ids.push(entry.id) + } + for (const c of loadCustomConnections()) ids.push(c.id) + // also include session-only and inflight which count as configured + for (const [id] of inflightOverlay) if (!ids.includes(id)) ids.push(id) + for (const [id] of sessionOnlyOverlay) if (!ids.includes(id)) ids.push(id) + // and any persisted status for custom ids + try { + const cache = readCacheFile(connectionsFile()) + for (const key of Object.keys(cache)) { + if (key.startsWith("custom-") && !ids.includes(key)) ids.push(key) + } + } catch {} + return ids +} + +/** 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 + +/** Mtime staleness slack (170 AC1): a credential file counts as hand-edited + * only when its mtime lands more than this AFTER validated_at. The connect + * path writes the file within moments of stamping validated_at (either + * order), so the slack keeps a fresh connect from reading as an edit while + * any real out-of-band edit — minutes or hours later — still trips it. */ +export const MTIME_STALE_SLACK_MS = 5_000 + +/** 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>() + +/** In-memory session-only claims (169 AC4): a Pasqal validation that minted + * NO persistable token parks its connected status — identity, devices, + * validated_at — here and ONLY here. Nothing reaches disk, so a fresh status + * build after a restart renders needs-key and the card re-prompts. Same + * redaction discipline as the in-flight overlay: entries pass the whitelist + * before any response. */ +export const sessionOnlyOverlay = new Map>() + +/** In-memory pending project selection (#194 choose-project): after a + * username+password validation lists projects but before one is chosen, the + * projects + the still-unpersisted credential live HERE (process memory only, + * never disk) until chooseProjectResponse finalizes or a cancel/disconnect + * clears it. Holding the password briefly in memory (session-memory class) is + * the two-step cost; it is written to the keychain only once a project is + * chosen and the connection lands. */ +interface PendingProject { + projects: ConnectionProject[] + username: string + password: string +} +const pendingProjectOverlay = new Map() + +/** Test seam: drop all pending project selections. They are process-memory only + * (production clears per-connection on choose/disconnect); this exists so suites + * start clean without exposing the password-bearing map. */ +export function clearPendingProjectSelections(): void { + pendingProjectOverlay.clear() +} + +// --- 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 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[] = [] + for (const raw of v) { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue + const d = raw as Record + const device: ConnectionDevice = {} + 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 +} + +/** 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 && isKnownState(state)) out.state = state + const identity = str(d.identity) + if (identity) out.identity = identity + const drift = str(d.identity_drift) + if (drift) out.identity_drift = drift + 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 + } + const expires = str(d.expires_at) + if (expires) out.expires_at = expires + const devices = whitelistDevices(d.devices) + if (devices) out.devices = devices + const validated = str(d.validated_at) + if (validated) out.validated_at = validated + if (d.offline === true) out.offline = true // only the literal true — anything else is noise + return out +} + +/** 170 AC5: expires_at at or behind now. Absent/unparseable expiry never + * expires anything — the honest minimum. */ +function isPastExpiry(expires_at: string | undefined, now: number): boolean { + if (!expires_at) return false + const at = Date.parse(expires_at) + return Number.isFinite(at) && at <= now +} + +function computeStale(state: ConnectionState, validated_at: string | null, now: number, mtime?: number): boolean { + if (state !== "connected") return false + if (!validated_at) return true + const at = Date.parse(validated_at) + if (!Number.isFinite(at)) return true + if (now - at > STALE_MS) return true + // 170 AC1: a credential file hand-edited AFTER its last validation is a + // desync the 24h clock cannot see — the file itself marks the claim stale + return mtime !== undefined && mtime - at > MTIME_STALE_SLACK_MS +} + +function iconForId(id: string): string | undefined { + const builtIn = getBuiltInEntry(id) + if (builtIn && builtIn.icon.kind === "svg") return builtIn.icon.svg + if (id.startsWith("custom-")) { + const custom = loadCustomConnections().find((c) => c.id === id) + if (custom) return custom.name.charAt(0).toUpperCase() + } + return undefined +} + +function nameForId(id: string): string | undefined { + const builtIn = getBuiltInEntry(id) + if (builtIn) return builtIn.name + if (id.startsWith("custom-")) { + const custom = loadCustomConnections().find((c) => c.id === id) + if (custom) return custom.name + } + return undefined +} + +/** Derive the rendered status for one connection from its whitelisted cache + * entry, the in-flight overlay, the session-only store, and credential + * presence (the truth for durable "connected"). Output carries ONLY + * whitelisted fields. */ +function renderStatus( + id: ConnectionType, + persisted: Partial, + input: { inflight: boolean; credential: boolean; now: number; mtime?: number; session?: Partial }, +): ConnectionStatus { + if (!input.inflight && input.session?.state === "connected") { + // session-only claim (169 AC4): connected without a credential at rest — + // rendered from memory alone, marked so the card can say so + const validated_at = input.session.validated_at ?? null + const out: ConnectionStatus = { + id, + state: "connected", + validated_at, + stale: computeStale("connected", validated_at, input.now), + session_only: true, + } + if (input.session.identity) out.identity = input.session.identity + if (input.session.entitlements) out.entitlements = input.session.entitlements + if (input.session.expires_at) out.expires_at = input.session.expires_at + if (input.session.devices) out.devices = input.session.devices + const icon = iconForId(id) + if (icon) out.icon = icon + const name = nameForId(id) + if (name) out.name = name + if (id === "google" || id === "google-drive") out.auth_methods = ["token", "browser"] + return out + } + 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" + + // 170 AC5: a past expiry outranks a connected claim AT READ TIME — the + // reconnect prompt renders without waiting for a revalidation to notice, + // identically for company-compute and pasqal. + if (state === "connected" && isPastExpiry(persisted.expires_at, input.now)) state = "expired" + + 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, input.mtime), + } + if (state !== "needs-key") { + if (persisted.identity) out.identity = persisted.identity + if (persisted.identity_drift) out.identity_drift = persisted.identity_drift + if (persisted.entitlements) out.entitlements = persisted.entitlements + if (persisted.expires_at) out.expires_at = persisted.expires_at + if (persisted.devices) out.devices = persisted.devices + } + // 170 AC3: offline is a connected-only presentation flag — "showing the + // last verified status" makes no sense on any other state + if (state === "connected" && persisted.offline) out.offline = true + const icon = iconForId(id) + if (icon) out.icon = icon + const name = nameForId(id) + if (name) out.name = name + if (id === "google" || id === "google-drive") out.auth_methods = ["token", "browser"] + 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 + /** credential-file mtime per id (ms) — the hand-edit detector (170 AC1). + * Absent seam (pure tests) or absent file → undefined, mtime rule off. */ + credentialMtime?: (id: ConnectionType) => number | undefined + /** in-memory session-only claims; ABSENT by default so a fresh build from + * disk (= a restarted server) cannot see them (169 AC4) */ + session?: ReadonlyMap> + 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 builtIns = CONNECTION_IDS.map((id) => { + const session = input.session?.get(id) + const mtime = input.credentialMtime?.(id) + return renderStatus(id, whitelistPersisted(cache[id]), { + inflight: input.overlay.has(id), + credential: input.hasCredential(id), + now, + ...(mtime !== undefined ? { mtime } : {}), + ...(session !== undefined ? { session: whitelistPersisted(session) } : {}), + }) + }) + // #327: custom connections that have been configured (optimistic connected) + const customs: ConnectionStatus[] = [] + try { + const customRecords = loadCustomConnections() + for (const rec of customRecords) { + const id = rec.id + // only surface customs that have a cache entry or are considered configured + // loadCustomConnections already implies configured, so always surface + const session = input.session?.get(id) + const mtime = input.credentialMtime?.(id) + customs.push( + renderStatus(id, whitelistPersisted(cache[id]), { + inflight: input.overlay.has(id), + credential: true, // optimistic — presence in file means connected + now, + ...(mtime !== undefined ? { mtime } : {}), + ...(session !== undefined ? { session: whitelistPersisted(session) } : {}), + }), + ) + } + // also surface any custom ids that are in cache but not in file (defensive) + for (const key of Object.keys(cache)) { + if (key.startsWith("custom-") && !customRecords.some((r) => r.id === key)) { + const session = input.session?.get(key) + const mtime = input.credentialMtime?.(key) + customs.push( + renderStatus(key, whitelistPersisted(cache[key]), { + inflight: input.overlay.has(key), + credential: input.hasCredential(key), + now, + ...(mtime !== undefined ? { mtime } : {}), + ...(session !== undefined ? { session: whitelistPersisted(session) } : {}), + }), + ) + } + } + } catch {} + return JSON.stringify({ ok: true, connections: [...builtIns, ...customs], error: null }) +} + +/** GET /amicode/connections — never rejects; failures collapse into the one + * success shape like every other amicode route. Stale connected claims render + * IMMEDIATELY from cache and kick a background revalidation (170 AC1) whose + * result lands in the cache for the NEXT read — the GET never waits. */ +export function statusResponse(deps: { fetchImpl?: FetchImpl; pasqalSpawn?: PasqalSpawn } = {}): string { + try { + const body = statusBody({ + file: connectionsFile(), + overlay: inflightOverlay, + hasCredential: (id) => { + if (isCustomConnectionId(id)) return loadCustomConnections().some((c) => c.id === id) + return readCredential(id) !== undefined + }, + credentialMtime: credentialFileMtime, + session: sessionOnlyOverlay, + }) + kickStaleRevalidations(body, deps) + return body + } catch (err) { + return synthesizeConnections("bad_output", String(err)) + } +} + +// --- background revalidation (170 AC1/AC3): stale claims refresh WITHOUT +// blocking the GET that noticed them. Deduped per id; never renders +// "validating" (the card keeps showing the cached claim); every failure is +// swallowed — the next GET simply retries. + +const backgroundInflight = new Map>() + +/** Test seam: a joinable handle over every background revalidation currently + * in flight — await this instead of sleeping. Production never calls it. */ +export function backgroundRevalidationsSettled(): Promise { + return Promise.all([...backgroundInflight.values()]).then(() => undefined) +} + +/** Kick background revalidations for stale connected claims in a just-built + * status body. Skips ids already refreshing, ids with a submit/revalidate in + * flight, session-only claims (nothing at rest to re-check), and ids without + * a stored credential. */ +function kickStaleRevalidations(body: string, deps: { fetchImpl?: FetchImpl; pasqalSpawn?: PasqalSpawn }): void { + let entries: unknown + try { + entries = (JSON.parse(body) as { connections?: unknown }).connections + } catch { + return + } + if (!Array.isArray(entries)) return + for (const raw of entries) { + if (typeof raw !== "object" || raw === null) continue + const entry = raw as { id?: unknown; state?: unknown; stale?: unknown; session_only?: unknown } + if (entry.state !== "connected" || entry.stale !== true || entry.session_only === true) continue + const id = CONNECTION_IDS.find((known) => known === entry.id) + if (!id || backgroundInflight.has(id) || inflightOverlay.has(id)) continue + // custom connections are optimistic, never background revalidated + if (isCustomConnectionId(id)) continue + if (readCredential(id) === undefined) continue + const task = (async () => { + try { + if (id === "company-compute") await backgroundRevalidateCompanyCompute(deps) + else if (id === "pasqal-cloud") await backgroundRevalidatePasqal(deps) + else if (id === "slack" || id === "github" || id === "linear" || id === "google" || id === "google-drive") + await backgroundRevalidateToken(id, deps) + } catch { + // background refresh must never surface trouble; the next GET retries + } + })().finally(() => backgroundInflight.delete(id)) + backgroundInflight.set(id, task) + } +} + +/** The metadata a background/manual refresh carries forward from the existing + * cache entry — status facts the probe outcome does not speak to. */ +function keptMetadata(existing: Partial): Partial { + return { + ...(existing.identity ? { identity: existing.identity } : {}), + ...(existing.entitlements ? { entitlements: existing.entitlements } : {}), + ...(existing.expires_at ? { expires_at: existing.expires_at } : {}), + ...(existing.devices ? { devices: existing.devices } : {}), + } +} + +/** Reconcile a revalidation's identity echo against the stored record (170 + * AC4, the 2026-07-19 incident canary). The stored identity is IMMUTABLE + * here: a disagreeing echo lands as identity_drift beside it — never over + * it. No echo → record and any prior drift stand; a matching echo clears + * the drift; a first-ever echo establishes the record. */ +function identityRecord(existing: Partial, submitter: string | undefined): Partial { + if (!submitter) { + return { + ...(existing.identity ? { identity: existing.identity } : {}), + ...(existing.identity_drift ? { identity_drift: existing.identity_drift } : {}), + } + } + if (!existing.identity) return { identity: submitter } + if (existing.identity === submitter) return { identity: existing.identity } + return { identity: existing.identity, identity_drift: submitter } +} + +/** Company-compute background refresh: probe from the STORED credential. + * valid → connected with a fresh validated_at (identity echo reconciled per + * identityRecord); invalid → the authorizer truly rejected the key, render + * it; unreachable → the connected claim and its validated_at STAND (170 + * AC3) — offline trouble is never a verdict on the credential, and the + * credential is never touched. */ +async function backgroundRevalidateCompanyCompute(deps: { fetchImpl?: FetchImpl }): Promise { + const id: ConnectionType = "company-compute" + const credential = readCredential(id) as unknown as { base_url: string; token: string } | undefined + if (!credential) return + const probe = await probeCompanyCompute(credential.base_url, credential.token, deps.fetchImpl) + const existing = whitelistPersisted(readCacheFile(connectionsFile())[id]) + if (probe.outcome === "unreachable") { + // offline (170 AC3): the connected claim and its last-verified timestamp + // STAND — only the presentation marker lands, and a later successful + // refresh (whose write carries no offline key) clears it + persistStatus(id, { ...existing, offline: true }) + return + } + persistStatus(id, { + ...keptMetadata(existing), + ...(probe.outcome === "valid" + ? identityRecord(existing, probe.submitter) + : existing.identity_drift // a rejection is no reconciliation: a recorded drift stands + ? { identity_drift: existing.identity_drift } + : {}), + state: probe.outcome === "valid" ? "connected" : "invalid", + validated_at: new Date().toISOString(), + }) +} + +/** Pasqal background refresh: local expiry math (169), now with the #194 + * silent re-mint — an expired token with a stored keychain password renews + * itself without blocking the GET that noticed the staleness. */ +async function backgroundRevalidatePasqal(deps: MutationDeps): Promise { + const credential = readCredential("pasqal-cloud") as unknown as PasqalCredential | undefined + if (!credential) return + if (pasqalExpired(credential) && (await attemptPasqalSilentReauth(deps))) return + refreshPasqalFreshness(credential) +} + +async function backgroundRevalidateToken(id: ConnectionType, deps: { fetchImpl?: FetchImpl }): Promise { + const cred = readCredential(id) as { token?: string } | undefined + if (!cred || typeof cred.token !== "string") return + let probe: ProbeResult + if (id === "slack") probe = await probeSlack(cred.token, deps.fetchImpl) + else if (id === "github") probe = await probeGithub(cred.token, deps.fetchImpl) + else if (id === "google") probe = await probeGoogle(cred.token, deps.fetchImpl) + else if (id === "google-drive") probe = await probeGoogleDrive(cred.token, deps.fetchImpl) + else probe = await probeLinear(cred.token, deps.fetchImpl) + const existing = whitelistPersisted(readCacheFile(connectionsFile())[id]) + if (probe.outcome === "unreachable") { + persistStatus(id, { ...existing, offline: true }) + return + } + persistStatus(id, { + ...keptMetadata(existing), + state: probe.outcome === "valid" ? "connected" : "invalid", + validated_at: new Date().toISOString(), + }) +} + +// --- probe validation --- + +export type ProbeOutcome = "valid" | "invalid" | "unreachable" + +export interface ProbeResult { + outcome: ProbeOutcome + /** identity echo (170 AC4, live endpoint aws-infra#185): present when a + * VALID probe's response body carries a string `submitter` — absent for + * services predating the echo, non-JSON bodies, or rejected keys. */ + submitter?: string +} + +/** Injectable fetch seam — tests stub this; production uses global fetch. + * The status code drives classification; `json` (optional, tolerated + * missing) is the identity-echo seam. */ +export type FetchImpl = ( + url: string, + init: { method: string; headers: Record; body?: string }, +) => Promise<{ status: number; json?: () => Promise }> + +const PROBE_PATH = "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/solves/whoami" + +/** The identity echo: a VALID probe response MAY carry {submitter: string} + * (aws-infra#185). Anything else — no json seam, unparseable body, off-shape + * value — is simply no echo; never a throw, and nothing but the one string + * field is ever read. */ +async function readSubmitterEcho(response: { json?: () => Promise }): Promise { + try { + const raw = await response.json?.() + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined + return str((raw as Record).submitter) + } catch { + return undefined + } +} + +/** Classify a Company Compute credential against GET /solves/whoami + * (aws-infra#185/#188 — the credential-scoped identity endpoint). + * 2xx → valid (identity captured when echoed) + * 401 / 403 → invalid (HTTP API authorizer denials emit 403, + * not the once-assumed 401 — live-verified) + * anything else → unreachable (incl. 400/404: a deploy without the + * endpoint — refuse to save, never guess) + * Replaces the fake-task probe, which inverted against the live service + * (amicode#178: aws-infra#186's task-id guard 400'd valid keys while + * authorizer-denial 403s classified garbage as valid). + * 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 response: { status: number; json?: () => Promise } + try { + response = await fetchImpl(url, { method: "GET", headers: { authorization: `Bearer ${token}` } }) + } catch { + return { outcome: "unreachable" } + } + if (response.status === 401 || response.status === 403) return { outcome: "invalid" } + if (response.status >= 200 && response.status < 300) { + const submitter = await readSubmitterEcho(response) + return { outcome: "valid", ...(submitter ? { submitter } : {}) } + } + return { outcome: "unreachable" } +} + +// --- New validators (issue #327): Slack, GitHub, Linear --- + +export async function probeSlack(token: string, fetchImpl: FetchImpl = fetch): Promise { + let response: { status: number; json?: () => Promise } + try { + response = await fetchImpl("https://slack.com/api/auth.test", { + method: "GET", + headers: { authorization: `Bearer ${token}` }, + }) + } catch { + return { outcome: "unreachable" } + } + if (response.json) { + try { + const body = (await response.json()) as Record + if (body && body.ok === true) return { outcome: "valid" } + if (body && body.ok === false) return { outcome: "invalid" } + } catch { + return { outcome: "unreachable" } + } + } + return { outcome: "unreachable" } +} + +export async function probeGithub(token: string, fetchImpl: FetchImpl = fetch): Promise { + let response: { status: number; json?: () => Promise } + try { + response = await fetchImpl("https://api.github.com/user", { + method: "GET", + headers: { authorization: `Bearer ${token}` }, + }) + } catch { + return { outcome: "unreachable" } + } + if (response.status === 200) return { outcome: "valid" } + if (response.status === 401 || response.status === 403) return { outcome: "invalid" } + return { outcome: "unreachable" } +} + +export async function probeLinear(token: string, fetchImpl: FetchImpl = fetch): Promise { + let response: { status: number; json?: () => Promise } + try { + response = await (fetchImpl as unknown as (url: string, init: { method: string; headers: Record; body?: string }) => Promise<{ status: number; json?: () => Promise }>)( + "https://api.linear.app/graphql", + { + method: "POST", + headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, + body: JSON.stringify({ query: "{ viewer { id } }" }), + }, + ) + } catch { + return { outcome: "unreachable" } + } + if (response.status === 401 || response.status === 403) return { outcome: "invalid" } + if (response.status === 200) { + if (response.json) { + try { + const body = (await response.json()) as Record + if (body && typeof body === "object" && body.data) return { outcome: "valid" } + // 200 without data is treat as unreachable per spec + return { outcome: "unreachable" } + } catch { + return { outcome: "unreachable" } + } + } + return { outcome: "valid" } + } + return { outcome: "unreachable" } +} + +export async function probeGoogle(token: string, fetchImpl: FetchImpl = fetch): Promise { + let response: { status: number; json?: () => Promise } + try { + response = await fetchImpl("https://www.googleapis.com/oauth2/v1/tokeninfo?access_token=" + encodeURIComponent(token), { + method: "GET", + headers: {}, + }) + } catch { + return { outcome: "unreachable" } + } + if (response.status === 200) return { outcome: "valid" } + if (response.status === 400 || response.status === 401 || response.status === 403) return { outcome: "invalid" } + return { outcome: "unreachable" } +} + +export async function probeGoogleDrive(token: string, fetchImpl: FetchImpl = fetch): Promise { + let response: { status: number; json?: () => Promise } + try { + response = await fetchImpl("https://www.googleapis.com/drive/v3/about?fields=user", { + method: "GET", + headers: { authorization: `Bearer ${token}` }, + }) + } catch { + return { outcome: "unreachable" } + } + if (response.status === 200) return { outcome: "valid" } + if (response.status === 400 || response.status === 401 || response.status === 403) return { outcome: "invalid" } + return { outcome: "unreachable" } +} + +// --- Pasqal validator spawn (amicode#169 / parent #159; #164 contract) --- +// The fork never sees SDK internals: the validator's one-line JSON + exit-code +// contract is the ENTIRE interface. Inputs ride env variables ONLY — never +// argv (visible in `ps`), never files. + +/** Interpreter: $AMICO_PYTHON override → `python3` resolved on PATH. */ +export function pasqalPython(): string { + const env = process.env.AMICO_PYTHON + if (env && env.trim() !== "") return env + return "python3" +} + +/** Script: $AMICO_PASQAL_VALIDATOR override → the amicode-staged copy under + * the SHARED ops-dir resolution (amicodeOpsDir(): $AMICODE_OPS_DIR → + * ~/.amico/amicode). The amicode packaging side stages + * scripts/pasqal-connector/pasqal_validate.py there. */ +export function pasqalValidatorScript(): string { + const env = process.env.AMICO_PASQAL_VALIDATOR + if (env && env.trim() !== "") return env + return path.join(amicodeOpsDir(), "scripts", "pasqal-connector", "pasqal_validate.py") +} + +export interface PasqalValidatorRun { + exitCode: number + stdout: string +} + +/** Injectable spawn seam (AC1): tests record argv + the EXACT child env. The + * default implementation passes both through verbatim — the child env is + * always the minimal declared set built in submitPasqalCredential, NEVER a + * process.env spread. */ +export type PasqalSpawn = (argv: string[], env: Record) => Promise + +const spawnPasqalValidator: PasqalSpawn = async (argv, env) => { + // PORT SEAM (#451): the fork spawns via Bun.spawn; Node equivalent below — + // same minimal-env contract, same stdout capture, exit code on resolve. + return await new Promise((resolve, reject) => { + const proc = spawn(argv[0], argv.slice(1), { + env, + stdio: ["ignore", "pipe", "ignore"], // fixed value-free messages by the #164 contract; stderr not consumed + }) + let out = "" + proc.stdout?.on("data", (b: Buffer) => (out += b.toString())) + proc.once("error", reject) + proc.once("exit", (code) => resolve({ exitCode: code ?? 0, stdout: out })) + }) +} + +/** A pickable project from LIST mode (#194 choose-project). */ +export interface ConnectionProject { + id: string + name: string +} + +type PasqalOutcome = + | { kind: "valid"; project_id: string; devices: ConnectionDevice[]; token: string | null; expires_at?: string } + // LIST mode (#194): authenticated, no project chosen yet — the caller's + // projects for the picker. Token is held only to finalize a choice. + | { kind: "list"; projects: ConnectionProject[]; token: string | null; expires_at?: string } + | { kind: "invalid" } + | { kind: "unreachable" } + | { kind: "unentitled" } + | { kind: "config" } + +function whitelistProjects(v: unknown): ConnectionProject[] { + if (!Array.isArray(v)) return [] + const out: ConnectionProject[] = [] + for (const raw of v) { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue + const d = raw as Record + const id = str(d.id) + if (!id) continue + out.push({ id, name: str(d.name) ?? id }) + } + return out +} + +/** #164 exit-code contract → outcome: 0 valid (stdout must carry ONE + * parseable ok:true JSON line; mode:"list" → a project list, else a bound + * project) · 2 invalid-credentials · 3 unreachable · 4 project-unauthorized · + * 1/anything-else config-class (missing env / missing SDK / broken interpreter). */ +function classifyValidatorRun(run: PasqalValidatorRun): PasqalOutcome { + if (run.exitCode === 2) return { kind: "invalid" } + if (run.exitCode === 3) return { kind: "unreachable" } + if (run.exitCode === 4) return { kind: "unentitled" } + if (run.exitCode !== 0) return { kind: "config" } + let raw: unknown + try { + raw = JSON.parse(run.stdout.trim()) + } catch { + return { kind: "config" } // exit 0 without the contract line is a config-class lie + } + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return { kind: "config" } + const d = raw as Record + if (d.ok !== true) return { kind: "config" } + if (d.mode === "list") { + // LIST mode: projects[] + a token; no project_id / devices in this shape. + const token = typeof d.token === "string" && d.token !== "" ? d.token : null + const expires = str(d.expires_at) + return { kind: "list", projects: whitelistProjects(d.projects), token, ...(expires ? { expires_at: expires } : {}) } + } + const project = str(d.project_id) + if (!project) return { kind: "config" } + const devices: ConnectionDevice[] = [] + if (Array.isArray(d.devices)) { + for (const device of d.devices) { + if (typeof device === "string" && device !== "") devices.push({ name: device }) + else { + const picked = whitelistDevices([device]) // tolerate future object-shaped devices + if (picked) devices.push(...picked) + } + } + } + const token = typeof d.token === "string" && d.token !== "" ? d.token : null + const expires = str(d.expires_at) + return { kind: "valid", project_id: project, devices, token, ...(expires ? { expires_at: expires } : {}) } +} + +// --- 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 Object.keys(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 Object.keys(cache)) if (key !== id) out[key] = whitelistPersisted(cache[key]) + 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 } +} + +/** 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" } + } +} + +/** 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. + * 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 +// 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}` }) +} + +// --- 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 + +/** 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 +} + +/** The current bind hostname — shared with the vault-browser routes, whose + * loopback gate rides the same signal as the credential-mutation guard. */ +export function getBindHostname(): string | undefined { + return bindHostname +} + +/** 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 + /** injectable/recordable validator spawn for pasqal-cloud (169 AC1) */ + pasqalSpawn?: PasqalSpawn + /** override the recorded bind hostname (pure-injection alternative to + * setBindHostname) */ + bindHostname?: 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 session = sessionOnlyOverlay.get(id) + const mtime = credentialFileMtime(id) + const isCustom = isCustomConnectionId(id) + const hasCred = isCustom + ? loadCustomConnections().some((c) => c.id === id) + : readCredential(id) !== undefined + const connection = renderStatus(id, whitelistPersisted(cache[id]), { + inflight: inflightOverlay.has(id), + credential: hasCred, + now: Date.now(), + ...(mtime !== undefined ? { mtime } : {}), + ...(session !== undefined ? { session: whitelistPersisted(session) } : {}), + }) + return JSON.stringify({ ok: true, connection, error: warning ?? null }) +} + +interface MutationBody { + id?: unknown + base_url?: unknown + token?: unknown + username?: unknown + password?: unknown + project_id?: unknown + name?: unknown + url?: unknown +} + +function parseMutationBody(rawBody: string): MutationBody | 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 MutationBody + } 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 refusal = loopbackRefusal(deps.bindHostname ?? bindHostname) + if (refusal) return refusal + const body = parseMutationBody(rawBody) + if (!body) return synthesizeConnection("bad_request", "body must be JSON with an id and that id's credential fields") + if (body.id === "pasqal-cloud") return submitPasqalCredential(body, deps) + if (body.id === "slack" || body.id === "github" || body.id === "linear" || body.id === "google" || body.id === "google-drive") { + return submitTokenCredential(body.id as ConnectionType, body, deps) + } + if (body.id !== "company-compute") { + 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 probe: ProbeResult + try { + probe = await probeCompanyCompute(base, token, deps.fetchImpl) + } finally { + inflightOverlay.delete(id) + } + const validated_at = new Date().toISOString() + let warning: string | undefined + if (probe.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") + } + // submitting a credential is the human act that OWNS the identity record + // (170 AC4): the echo (if any) becomes the fresh record, any prior drift + // is reconciled away with the old entry + persistStatus(id, { state: "connected", validated_at, ...(probe.submitter ? { identity: probe.submitter } : {}) }) + 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: probe.outcome, validated_at }) + } + return renderCurrent(id, warning) +} + +async function submitTokenCredential(id: ConnectionType, body: MutationBody, deps: MutationDeps): Promise { + const token = typeof body.token === "string" ? body.token.trim() : "" + if (token === "") return synthesizeConnection("bad_request", "non-empty token is required") + inflightOverlay.set(id, { state: "validating" }) + let probe: ProbeResult + try { + if (id === "slack") probe = await probeSlack(token, deps.fetchImpl) + else if (id === "github") probe = await probeGithub(token, deps.fetchImpl) + else if (id === "google") probe = await probeGoogle(token, deps.fetchImpl) + else if (id === "google-drive") probe = await probeGoogleDrive(token, deps.fetchImpl) + else probe = await probeLinear(token, deps.fetchImpl) + } finally { + inflightOverlay.delete(id) + } + const validated_at = new Date().toISOString() + if (probe.outcome === "valid") { + try { + writeCredential(id, { token }) + } catch { + return synthesizeConnection("write_failed", "credential could not be saved") + } + persistStatus(id, { state: "connected", validated_at }) + } else { + persistStatus(id, { state: probe.outcome, validated_at }) + } + return renderCurrent(id) +} + +/** POST body {id:"pasqal-cloud", username, password, project_id} → spawn the + * #164 validator (env-only inputs; MINIMAL child env: PATH for interpreter + * resolution plus the three PASQAL_* inputs — never a process.env spread) and + * classify its one-line JSON / exit-code contract. SECURITY: the username and + * password live ONLY in this request scope and the child env — never the + * status cache, never any file, never a log or error message. Pasqal never + * touches solver mode: the HP flip (#167) is company-compute-only. */ +/** The FIXED config-class warning (169 AC5, sibling "code: detail" shape): + * exit 1 / unknown exits / off-contract stdout / spawn failure all mean the + * validator itself could not run properly — distinct from the service being + * unreachable. Value-free by the module contract. */ +export const PASQAL_CONFIG_WARNING = + "pasqal_validator_config: the Pasqal validator could not run — check the Python interpreter and the pasqal-cloud SDK" + +/** Build a one-shot choose-project mutation response (#194): the panel holds + * this and renders the project picker; it is NOT persisted into the GET + * status pipeline (the selection is transient, in pendingProjectOverlay). */ +function chooseProjectResponseBody(id: ConnectionType, pending: PendingProject, warning?: string): string { + return JSON.stringify({ + ok: true, + connection: { + id, + state: "choose-project", + identity: pending.username, + projects: pending.projects, // [{id,name}] — validator-sourced, whitelisted + validated_at: null, + stale: false, + }, + error: warning ?? null, + }) +} + +/** Run the #164 validator for pasqal-cloud. project_id "" → LIST mode + * (authenticate, list projects); non-empty → CONNECT mode (bind + mint). The + * password rides the child env only. */ +async function runPasqalValidator( + username: string, + password: string, + projectId: string, + deps: MutationDeps, +): Promise { + const spawn = deps.pasqalSpawn ?? spawnPasqalValidator + const argv = [pasqalPython(), pasqalValidatorScript()] // no secret ever rides argv + const env = { + PATH: process.env.PATH ?? "", // interpreter resolution only + PASQAL_USERNAME: username, + PASQAL_PASSWORD: password, + PASQAL_PROJECT_ID: projectId, // "" → the validator's LIST mode + } + inflightOverlay.set("pasqal-cloud", { state: "validating" }) + try { + return classifyValidatorRun(await spawn(argv, env)) + } catch { + return { kind: "config" } // spawn trouble (missing interpreter/script) is config-class + } finally { + inflightOverlay.delete("pasqal-cloud") + } +} + +async function submitPasqalCredential(body: MutationBody, deps: MutationDeps): Promise { + const username = typeof body.username === "string" ? body.username.trim() : "" + const password = typeof body.password === "string" ? body.password : "" + // project_id is OPTIONAL now (#194): absent → authenticate + list projects + // for the picker; present → connect straight to that project (token paste / + // back-compat). + const projectId = typeof body.project_id === "string" ? body.project_id.trim() : "" + if (username === "" || password.trim() === "") + return synthesizeConnection("bad_request", "non-empty username and password are required") + + const id: ConnectionType = "pasqal-cloud" + const outcome = await runPasqalValidator(username, password, projectId, deps) + + // LIST mode result (project_id was absent): park the selection in memory and + // hand the panel the picker. Nothing at rest, no keychain write yet. + if (outcome.kind === "list") { + if (outcome.projects.length === 0) { + pendingProjectOverlay.delete(id) + return synthesizeConnection("no_projects", "no Pasqal projects are available for this account") + } + const pending: PendingProject = { projects: outcome.projects, username, password } + pendingProjectOverlay.set(id, pending) + return chooseProjectResponseBody(id, pending) + } + // Any non-list outcome supersedes a stale pending selection. + pendingProjectOverlay.delete(id) + + const validated_at = new Date().toISOString() + sessionOnlyOverlay.delete(id) // every terminal outcome supersedes a session-only claim + let warning: string | undefined + if (outcome.kind === "valid" && outcome.token !== null) { + try { + // token-only at rest (#162 seam): project_id + token + expiry — the + // password has no field to land in, and the store rejects poison keys. + writeCredential(id, { + project_id: outcome.project_id, + token: outcome.token, + ...(outcome.expires_at ? { expires_at: outcome.expires_at } : {}), + }) + } catch { + return synthesizeConnection("write_failed", "credential could not be saved") + } + // ADR 0001 addendum (#194): the ~24h token cannot be renewed without the + // password (refresh tokens 403; no browser/device grant), so the password + // goes to the OS keychain — NOT the credential file — to enable silent + // re-mint on expiry. write() reporting false means the keychain was + // unreachable and the password is session-memory only: the connection + // still stands on the persisted token; only cross-restart silent re-auth + // is lost. The secret never touches the status cache or any response. + pasqalSecretStore().write(PASQAL_SECRET_ACCOUNT, { username, password }) + persistStatus(id, { + state: "connected", + validated_at, + identity: outcome.project_id, + devices: outcome.devices, // non-secret metadata, refreshed on every submit + ...(outcome.expires_at ? { expires_at: outcome.expires_at } : {}), + }) + } else if (outcome.kind === "valid") { + // null token (mint unsupported) → SESSION-ONLY connected (AC4): nothing + // reaches disk; the claim lives in memory and dies with the process, so + // a restarted server re-prompts (needs-key). + clearStatus(id) + sessionOnlyOverlay.set(id, { + state: "connected", + validated_at, + identity: outcome.project_id, + devices: outcome.devices, + }) + } else if (outcome.kind === "config") { + // validator trouble is not a service verdict: render unreachable-class + // with the DISTINCT fixed warning on the #167 partial-trouble channel + persistStatus(id, { state: "unreachable", validated_at }) + warning = PASQAL_CONFIG_WARNING + } else { + // invalid / unreachable / unentitled — nothing written, an existing + // credential (if any) stays untouched + persistStatus(id, { state: outcome.kind, validated_at }) + } + return renderCurrent(id, warning) +} + +/** POST /amicode/connections/choose-project — body {id, project_id}. Step 2 of + * the #194 picker flow: finalize the pending username+password against the + * CHOSEN project. The secret is the one held in pendingProjectOverlay from the + * submit that listed the projects — it never rides this request. */ +export async function chooseProjectResponse(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 with an id and project_id") + const id: ConnectionType = "pasqal-cloud" + if (body.id !== id) return synthesizeConnection("unknown_connection", "choose-project applies to pasqal-cloud only") + const pending = pendingProjectOverlay.get(id) + if (!pending) + return synthesizeConnection("no_pending_selection", "no pending project selection — reconnect and pick a project") + const projectId = typeof body.project_id === "string" ? body.project_id.trim() : "" + if (projectId === "" || !pending.projects.some((p) => p.id === projectId)) + return chooseProjectResponseBody(id, pending, "invalid_project: pick one of the listed projects") + + // Connect to the chosen project with the held credential. + const outcome = await runPasqalValidator(pending.username, pending.password, projectId, deps) + const validated_at = new Date().toISOString() + if (outcome.kind === "valid" && outcome.token !== null) { + try { + writeCredential(id, { + project_id: projectId, + token: outcome.token, + ...(outcome.expires_at ? { expires_at: outcome.expires_at } : {}), + }) + } catch { + return synthesizeConnection("write_failed", "credential could not be saved") + } + // project chosen and connection landed — NOW the password earns the keychain + pasqalSecretStore().write(PASQAL_SECRET_ACCOUNT, { username: pending.username, password: pending.password }) + persistStatus(id, { + state: "connected", + validated_at, + identity: projectId, + devices: outcome.devices, + ...(outcome.expires_at ? { expires_at: outcome.expires_at } : {}), + }) + pendingProjectOverlay.delete(id) + return renderCurrent(id) + } + if (outcome.kind === "valid") { + // null token → session-only connected (mint unsupported); pending consumed + clearStatus(id) + sessionOnlyOverlay.set(id, { state: "connected", validated_at, identity: projectId, devices: outcome.devices }) + pendingProjectOverlay.delete(id) + return renderCurrent(id) + } + if (outcome.kind === "invalid") { + // creds unexpectedly rejected between listing and choosing — abandon it + pendingProjectOverlay.delete(id) + persistStatus(id, { state: "invalid", validated_at }) + return renderCurrent(id) + } + // unreachable / unentitled / (defensive) list / config — keep the pending + // selection so the user can retry the pick; surface the trouble on the picker. + const warn = + outcome.kind === "unentitled" + ? "unentitled: that project refused authorization — pick another" + : outcome.kind === "config" + ? PASQAL_CONFIG_WARNING + : "unreachable: Pasqal Cloud is unreachable — try again" + return chooseProjectResponseBody(id, pending, warn) +} + +/** 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") return undefined + return CONNECTION_IDS.find((known) => known === id) +} + +function parseAnyIdBody(rawBody: string): string | undefined { + const body = parseMutationBody(rawBody) + if (!body) return undefined + const id = body.id + if (typeof id !== "string" || id.trim() === "") return undefined + return id.trim() +} + + +// --- Browser OAuth start (Google) ------------------------------------------- +// POST /amicode/connections/auth body {id, method:"browser"|"device-code"} +// For google/google-drive the method is always "browser". The server's job is +// to open the authorization URL in the user's system browser (via McpBrowser, +// which now respects BROWSER in VS Code remote) and return a `waiting-browser` +// card so the UI shows the mid-flow copy. The actual token exchange happens +// out-of-band (the loopback callback server) — this endpoint only starts it. +// Google OAuth app credentials are configured via env: +// GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_REDIRECT_URI (default loopback). +// The handler constructs the real Google authorization URL with PKCE and opens +// the system browser. This is a real Google connector — not a placeholder. +export async function startAuthResponse(rawBody: string, deps: MutationDeps = {}): Promise { + const refusal = loopbackRefusal(deps.bindHostname ?? bindHostname) + if (refusal) return refusal + const body = parseMutationBody(rawBody) as { id?: unknown; method?: unknown } | undefined + if (!body || typeof body.id !== "string" || typeof body.method !== "string") { + return synthesizeConnection("bad_request", "body must be JSON {id, method}") + } + const id = body.id + const method = body.method + if (method !== "browser" && method !== "device-code") { + return synthesizeConnection("bad_request", "method must be browser or device-code") + } + if (id !== "google" && id !== "google-drive") { + return synthesizeConnection("bad_request", "browser auth is only for google connections") + } + // Real Google OAuth URL — scopes differ by connector: + // google → Gmail read + userinfo (read an email) + // google-drive → Drive file + Sheets (create/populate a sheet) + userinfo + const scopes = + id === "google" + ? ["https://www.googleapis.com/auth/gmail.readonly", "https://www.googleapis.com/auth/userinfo.email"] + : [ + "https://www.googleapis.com/auth/drive.file", + "https://www.googleapis.com/auth/spreadsheets", + "https://www.googleapis.com/auth/userinfo.email", + ] + const clientId = process.env.GOOGLE_CLIENT_ID?.trim() + const redirectUri = process.env.GOOGLE_REDIRECT_URI?.trim() || "http://127.0.0.1:8085/oauth/callback" + // If no client is configured, we still open Google's OAuth consent screen with an + // explanatory error — this proves the browser wiring is end-to-end and gives the + // operator a clear next step (set GOOGLE_CLIENT_ID) rather than silently doing nothing. + // When the client is configured, this constructs the real authorization URL. + const state = Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2) + const pendingUrl = clientId + ? `https://accounts.google.com/o/oauth2/v2/auth?` + + new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: "code", + scope: scopes.join(" "), + state, + access_type: "offline", + prompt: "consent", + }).toString() + : `https://accounts.google.com/signin/v2/identifier?connector=${id}&error=missing_client_id` + // Best-effort browser open — failure is soft: the UI fallback (window.open + // in status-popover-body) will also try, and the BrowserOpenFailed event + // carries the URL for any listener. + try { + // Direct BROWSER-aware open (mirrors mcp/browser.ts logic but without Effect) + const browserCmd = process.env.BROWSER?.trim() + if (browserCmd) { + const { spawn } = await import("node:child_process") + try { + const child = spawn(browserCmd, [pendingUrl], { stdio: "ignore", detached: true }) + child.unref() + } catch {} + } else { + // PORT SEAM (#451): the fork uses the `open` npm package; the extension + // host opens via the platform opener instead — same best-effort contract. + const opener = process.platform === "darwin" ? "open" : "xdg-open" + try { + const child = spawn(opener, [pendingUrl], { stdio: "ignore", detached: true }) + child.unref() + } catch {} + } + } catch {} + // Return a synthetic waiting-browser response so the card flips immediately. + // The real OAuth callback will later promote to connected via the same + // credential file path that probeGoogle validates. + return JSON.stringify({ + ok: true, + connection: { + id, + state: "waiting-browser", + validated_at: null, + stale: false, + auth_methods: ["browser"], + }, + error: null, + }) +} + +/** 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, deps: MutationDeps = {}): string { + const refusal = loopbackRefusal(deps.bindHostname ?? bindHostname) + if (refusal) return refusal + const anyId = parseAnyIdBody(rawBody) + if (!anyId) return synthesizeConnection("bad_request", "body must be JSON {id} with a known connection id") + // #327: custom connections have their own removal path but disconnect also handles them + if (isCustomConnectionId(anyId)) { + try { + const existing = loadCustomConnections() + const next = existing.filter((c) => c.id !== anyId) + if (next.length !== existing.length) saveCustomConnections(next) + clearStatus(anyId) + } catch { + return synthesizeConnection("write_failed", "credential could not be cleared") + } + return JSON.stringify({ ok: true, connection: { id: anyId, state: "needs-key", validated_at: null, stale: false }, error: null }) + } + 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) + sessionOnlyOverlay.delete(id) // a session-only claim ends with disconnect too + // #194: disconnect wipes the keychain password AND any pending project + // selection — the interim's secret never outlives the connection. Harmless + // for company-compute (no slot, no pending). + if (id === "pasqal-cloud") { + pasqalSecretStore().clear(PASQAL_SECRET_ACCOUNT) + pendingProjectOverlay.delete(id) + } + } catch { + return synthesizeConnection("write_failed", "credential could not be cleared") + } + return renderCurrent(id) +} + +// --- #327 custom + catalog routes --- + +export function catalogResponse(): string { + const configured = new Set(configuredConnectionIds()) + const available = BUILT_IN_CATALOG.filter((e) => !configured.has(e.id)).map((e) => ({ + id: e.id, + name: e.name, + icon: e.icon.kind === "svg" ? e.icon.svg : e.icon.letter, + authShape: e.authShape, + })) + return JSON.stringify({ ok: true, catalog: available, error: null }) +} + +export async function addCustomConnectionResponse(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 with name and token") + const name = typeof body.name === "string" ? body.name.trim() : "" + const token = typeof body.token === "string" ? body.token.trim() : "" + const url = typeof body.url === "string" ? body.url.trim() : typeof body.base_url === "string" ? body.base_url.trim() : "" + if (name === "" || token === "") return synthesizeConnection("bad_request", "name and token are required") + if (url !== "" && !isHttpUrl(url)) return synthesizeConnection("bad_request", "url must be an http(s) URL") + const id = `custom-${randomBytes(4).toString("hex")}` + const record: CustomConnectionRecord = { id, name, token, ...(url ? { url } : {}) } + try { + const existing = loadCustomConnections() + existing.push(record) + saveCustomConnections(existing) + // optimistic connected — no probe, immediate status + persistStatus(id, { state: "connected", validated_at: new Date().toISOString() }) + } catch { + return synthesizeConnection("write_failed", "custom connection could not be saved") + } + return renderCurrent(id) +} + +export function removeCustomConnectionResponse(rawBody: string, deps: MutationDeps = {}): string { + const refusal = loopbackRefusal(deps.bindHostname ?? bindHostname) + if (refusal) return refusal + const id = parseAnyIdBody(rawBody) + if (!id || !isCustomConnectionId(id)) return synthesizeConnection("bad_request", "body must be JSON {id} with a custom connection id") + try { + const existing = loadCustomConnections() + const next = existing.filter((c) => c.id !== id) + if (next.length === existing.length) return synthesizeConnection("bad_request", "custom connection not found") + saveCustomConnections(next) + clearStatus(id) + } catch { + return synthesizeConnection("write_failed", "custom connection could not be removed") + } + return JSON.stringify({ ok: true, error: null }) +} + +/** 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 refusal = loopbackRefusal(deps.bindHostname ?? bindHostname) + if (refusal) return refusal + const anyId = parseAnyIdBody(rawBody) + if (!anyId) return synthesizeConnection("bad_request", "body must be JSON {id} with a known connection id") + // #327: custom connections are optimistic — no probe + if (isCustomConnectionId(anyId)) { + const exists = loadCustomConnections().some((c) => c.id === anyId) + if (!exists) return synthesizeConnection("bad_request", "custom connection not found") + const existing = whitelistPersisted(readCacheFile(connectionsFile())[anyId]) + persistStatus(anyId, { ...keptMetadata(existing), state: "connected", validated_at: new Date().toISOString() }) + return renderCurrent(anyId) + } + const id = parseIdBody(rawBody) + if (!id) return synthesizeConnection("bad_request", "body must be JSON {id} with a known connection id") + if (id === "pasqal-cloud") return revalidatePasqal(deps) + if (id === "slack" || id === "github" || id === "linear" || id === "google" || id === "google-drive") { + const cred = readCredential(id) as { token?: string } | undefined + if (!cred || typeof cred.token !== "string" || cred.token === "") { + clearStatus(id) + return renderCurrent(id) + } + inflightOverlay.set(id, { state: "validating" }) + let probe: ProbeResult + try { + if (id === "slack") probe = await probeSlack(cred.token, deps.fetchImpl) + else if (id === "github") probe = await probeGithub(cred.token, deps.fetchImpl) + else if (id === "google") probe = await probeGoogle(cred.token, deps.fetchImpl) + else if (id === "google-drive") probe = await probeGoogleDrive(cred.token, deps.fetchImpl) + else probe = await probeLinear(cred.token, deps.fetchImpl) + } finally { + inflightOverlay.delete(id) + } + const existing = whitelistPersisted(readCacheFile(connectionsFile())[id]) + persistStatus(id, { + ...keptMetadata(existing), + state: probe.outcome === "valid" ? "connected" : probe.outcome, + validated_at: new Date().toISOString(), + }) + return renderCurrent(id) + } + const credential = readCredential("company-compute") as unknown as { base_url: string; token: string } | undefined + if (!credential) { + clearStatus(id) // a status claim without a credential behind it is noise + return renderCurrent(id) + } + inflightOverlay.set(id, { state: "validating" }) + let probe: ProbeResult + try { + probe = 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. Metadata and the + // identity record survive the refresh; a disagreeing echo lands as an + // explicit drift beside the record, never over it (170 AC4). + const existing = whitelistPersisted(readCacheFile(connectionsFile())[id]) + persistStatus(id, { + ...keptMetadata(existing), + ...(probe.outcome === "valid" + ? identityRecord(existing, probe.submitter) + : existing.identity_drift // a failed probe reconciles nothing: a recorded drift stands + ? { identity_drift: existing.identity_drift } + : {}), + state: probe.outcome === "valid" ? "connected" : probe.outcome, + validated_at: new Date().toISOString(), + }) + return renderCurrent(id) +} + +/** Pasqal revalidation is a TOKEN-mode freshness check: the stored credential + * holds only project_id + token — no password — so re-running the validator + * is impossible and pretending otherwise would lie. With a credential, + * expires_at vs now marks connected or expired (validated_at refreshed, + * devices/identity metadata kept); no or unparseable expiry means the claim + * stands. A live token-mode probe against the service is #160's device-path + * territory. Session-only claims are left standing: there is nothing to + * re-check without a password, and revalidate must not destroy them. */ +async function revalidatePasqal(deps: MutationDeps): Promise { + const id: ConnectionType = "pasqal-cloud" + const credential = readCredential(id) + if (!credential) { + if (sessionOnlyOverlay.has(id)) return renderCurrent(id) + // no token at rest (e.g. a restart dropped an in-memory one) but a keychain + // password may have survived → silent login rather than a needs-key prompt + if (await attemptPasqalSilentReauth(deps)) return renderCurrent(id) + clearStatus(id) // a status claim without a credential behind it is noise + return renderCurrent(id) + } + // ADR 0001 addendum (#194): an expired token silently re-mints from the + // keychain password when one is stored; otherwise it renders expired as before. + if (pasqalExpired(credential as unknown as PasqalCredential) && (await attemptPasqalSilentReauth(deps))) return renderCurrent(id) + refreshPasqalFreshness(credential as unknown as PasqalCredential) + return renderCurrent(id) +} + +/** Keychain account slot for the Pasqal password (ADR 0001 addendum). One + * Pasqal connection → one slot; Jack's `UserSession` used the same "default". */ +export const PASQAL_SECRET_ACCOUNT = "default" + +/** Is this credential's token past (or without) its expiry? */ +function pasqalExpired(credential: PasqalCredential): boolean { + const at = credential.expires_at === undefined ? Number.NaN : Date.parse(credential.expires_at) + return Number.isFinite(at) && at <= Date.now() +} + +/** Silent re-mint (ADR 0001 addendum, #194): the ~24h token lapsed but the + * password is in the keychain, so re-run the #164 validator with the stored + * credential and rewrite the token file — no user prompt. Returns true when it + * produced a terminal status (connected on success; needs-key if the stored + * password is now rejected), false when it could not refresh (no stored + * secret, no project to mint against, or a transient service/config failure) + * so the caller keeps the existing claim and marks expired/offline as before. + * SECURITY: the password reaches ONLY the child env here — exactly as submit + * does — never argv, never a status field, never a response. */ +async function attemptPasqalSilentReauth(deps: MutationDeps): Promise { + const id: ConnectionType = "pasqal-cloud" + const secret = pasqalSecretStore().read(PASQAL_SECRET_ACCOUNT) + if (!secret) return false + const projectId = (readCredential(id) as unknown as PasqalCredential | undefined)?.project_id ?? "" + if (projectId === "") return false // nothing to re-mint against without a project + const spawn = deps.pasqalSpawn ?? spawnPasqalValidator + const argv = [pasqalPython(), pasqalValidatorScript()] // no secret ever rides argv + const env = { + PATH: process.env.PATH ?? "", + PASQAL_USERNAME: secret.username, + PASQAL_PASSWORD: secret.password, + PASQAL_PROJECT_ID: projectId, + } + inflightOverlay.set(id, { state: "validating" }) + let outcome: PasqalOutcome + try { + outcome = classifyValidatorRun(await spawn(argv, env)) + } catch { + outcome = { kind: "config" } + } finally { + inflightOverlay.delete(id) + } + const validated_at = new Date().toISOString() + if (outcome.kind === "valid" && outcome.token !== null) { + try { + writeCredential(id, { + project_id: outcome.project_id, + token: outcome.token, + ...(outcome.expires_at ? { expires_at: outcome.expires_at } : {}), + }) + } catch { + return false // fresh token could not be persisted — keep the old claim + } + persistStatus(id, { + state: "connected", + validated_at, + identity: outcome.project_id, + devices: outcome.devices, + ...(outcome.expires_at ? { expires_at: outcome.expires_at } : {}), + }) + return true + } + if (outcome.kind === "invalid") { + // the stored password no longer authenticates (changed/revoked): wipe the + // dead secret + token so the card falls to needs-key and a fresh login + pasqalSecretStore().clear(PASQAL_SECRET_ACCOUNT) + clearCredential(id) + clearStatus(id) + return true + } + // valid-but-null-token / unreachable / config: NOT a credential verdict — + // leave the token + secret + claim untouched; the caller renders expired/offline + return false +} + +/** The persist half of the Pasqal freshness check — shared by the manual + * revalidate above and the background revalidation (170 AC1): expires_at vs + * now marks connected or expired, validated_at refreshes, devices and other + * metadata survive. */ +function refreshPasqalFreshness(credential: PasqalCredential): void { + const id: ConnectionType = "pasqal-cloud" + const expiresAt = credential.expires_at === undefined ? Number.NaN : Date.parse(credential.expires_at) + const expired = Number.isFinite(expiresAt) && expiresAt <= Date.now() + const existing = whitelistPersisted(readCacheFile(connectionsFile())[id]) + persistStatus(id, { + ...keptMetadata(existing), // devices + any other metadata survive the freshness check + state: expired ? "expired" : "connected", + identity: credential.project_id, + ...(credential.expires_at ? { expires_at: credential.expires_at } : {}), + validated_at: new Date().toISOString(), + }) +} diff --git a/packages/extension/src/amicode_service/credentials.ts b/packages/extension/src/amicode_service/credentials.ts new file mode 100644 index 000000000..8244d8627 --- /dev/null +++ b/packages/extension/src/amicode_service/credentials.ts @@ -0,0 +1,303 @@ +// 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, renameSync, rmSync, statSync, writeFileSync } from "node:fs" +import { randomBytes } from "node:crypto" +import { homedir } from "node:os" +import path from "node:path" + +export type BuiltInConnectionType = "company-compute" | "pasqal-cloud" | "slack" | "github" | "linear" | "google" | "google-drive" +export type ConnectionType = BuiltInConnectionType | (string & {}) + +/** 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 interface TokenCredential { + token: string +} +export type Credential = CompanyComputeCredential | PasqalCredential | TokenCredential + +/** $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") +} +export function slackFile(): string { + const env = process.env.AMICO_SLACK_FILE + if (env && env.trim() !== "") return env + return path.join(homedir(), ".amico", "slack.json") +} +export function githubFile(): string { + const env = process.env.AMICO_GITHUB_FILE + if (env && env.trim() !== "") return env + return path.join(homedir(), ".amico", "github.json") +} +export function linearFile(): string { + const env = process.env.AMICO_LINEAR_FILE + if (env && env.trim() !== "") return env + return path.join(homedir(), ".amico", "linear.json") +} +export function googleFile(): string { + const env = process.env.AMICO_GOOGLE_FILE + if (env && env.trim() !== "") return env + return path.join(homedir(), ".amico", "google.json") +} +export function googleDriveFile(): string { + const env = process.env.AMICO_GOOGLE_DRIVE_FILE + if (env && env.trim() !== "") return env + return path.join(homedir(), ".amico", "google-drive.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 + }, + }, + slack: { + file: slackFile, + encode(value) { + rejectPoisonKeys(value) + const token = typeof value.token === "string" ? value.token.trim() : "" + if (token === "") throw new Error('slack credential needs non-empty "token"') + return JSON.stringify({ 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.token !== "string" || d.token === "") return undefined + return { token: d.token } + }, + }, + github: { + file: githubFile, + encode(value) { + rejectPoisonKeys(value) + const token = typeof value.token === "string" ? value.token.trim() : "" + if (token === "") throw new Error('github credential needs non-empty "token"') + return JSON.stringify({ 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.token !== "string" || d.token === "") return undefined + return { token: d.token } + }, + }, + linear: { + file: linearFile, + encode(value) { + rejectPoisonKeys(value) + const token = typeof value.token === "string" ? value.token.trim() : "" + if (token === "") throw new Error('linear credential needs non-empty "token"') + return JSON.stringify({ 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.token !== "string" || d.token === "") return undefined + return { token: d.token } + }, + }, + google: { + file: googleFile, + encode(value) { + rejectPoisonKeys(value) + const token = typeof value.token === "string" ? value.token.trim() : "" + if (token === "") throw new Error('google credential needs non-empty "token"') + return JSON.stringify({ 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.token !== "string" || d.token === "") return undefined + return { token: d.token } + }, + }, + "google-drive": { + file: googleDriveFile, + encode(value) { + rejectPoisonKeys(value) + const token = typeof value.token === "string" ? value.token.trim() : "" + if (token === "") throw new Error('google-drive credential needs non-empty "token"') + return JSON.stringify({ 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.token !== "string" || d.token === "") return undefined + return { token: d.token } + }, + }, +} + +// --- 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 +export function readCredential(type: "pasqal-cloud"): PasqalCredential | undefined +export function readCredential(type: "slack"): TokenCredential | undefined +export function readCredential(type: "github"): TokenCredential | undefined +export function readCredential(type: "linear"): TokenCredential | undefined +export function readCredential(type: "google"): TokenCredential | undefined +export function readCredential(type: "google-drive"): TokenCredential | undefined +export function readCredential(type: string): Credential | undefined +export function readCredential(type: ConnectionType): Credential | undefined +export function readCredential(type: ConnectionType): Credential | undefined { + const backend = BACKENDS[type] + if (!backend) return undefined + 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, hooks?: WriteHooks): void +export function writeCredential(type: "pasqal-cloud", value: PasqalCredential, hooks?: WriteHooks): void +export function writeCredential(type: "slack", value: TokenCredential, hooks?: WriteHooks): void +export function writeCredential(type: "github", value: TokenCredential, hooks?: WriteHooks): void +export function writeCredential(type: "linear", value: TokenCredential, hooks?: WriteHooks): void +export function writeCredential(type: "google", value: TokenCredential, hooks?: WriteHooks): void +export function writeCredential(type: "google-drive", value: TokenCredential, hooks?: WriteHooks): void +export function writeCredential(type: string, value: Credential, hooks?: WriteHooks): void +export function writeCredential(type: ConnectionType, value: Credential, hooks?: WriteHooks): void { + const backend = BACKENDS[type] + if (!backend) throw new Error(`unknown connection id: ${type}`) + 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. */ +export function clearCredential(type: ConnectionType): void { + const backend = BACKENDS[type] + if (!backend) return + rmSync(backend.file(), { force: true }) +} + +/** The credential FILE's mtime in ms — the hand-edit detector (amicode#170 + * AC1): a file newer than its last validation marks the status stale. + * Absent/unreadable → undefined, never a throw. */ +export function credentialFileMtime(type: ConnectionType): number | undefined { + try { + const backend = BACKENDS[type] + if (!backend) return undefined + return statSync(backend.file()).mtimeMs + } catch { + return undefined + } +} + +export function isBuiltInConnectionId(id: string): id is BuiltInConnectionType { + return id in BACKENDS +} diff --git a/packages/extension/src/amicode_service/index.ts b/packages/extension/src/amicode_service/index.ts index 444a6df3f..b2e9aa9ba 100644 --- a/packages/extension/src/amicode_service/index.ts +++ b/packages/extension/src/amicode_service/index.ts @@ -31,6 +31,18 @@ import { libraryBody, saveLibraryFile } from "./library"; import { widgetsResponse, widgetCodeResponse, forkWidgetResponse, loadRegistry } from "./widgets"; import { dashboardResponse, saveDashboardResponse } from "./dashboard"; import { widgetFrameHtml, WIDGET_CSP } from "./widget_frame_html"; +import { createProject, listProjects } from "./project"; +import { + addCustomConnectionResponse, + catalogResponse, + chooseProjectResponse, + disconnectResponse, + revalidateResponse, + removeCustomConnectionResponse, + startAuthResponse, + statusResponse, + submitCredentialResponse, +} from "./connections"; export function registerProfileRoutes(server: AmicodeServiceServer): AmicodeServiceServer { server.add("GET", "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/amicode/profile", () => ({ body: profileResponse() })); @@ -140,6 +152,48 @@ export function registerWidgetRoutes(server: AmicodeServiceServer): AmicodeServi return server; } +export function registerProjectRoutes(server: AmicodeServiceServer): AmicodeServiceServer { + server.add("POST", "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/amicode/project", ({ body }) => ({ body: createProject(body) })); + + server.add("GET", "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/amicode/projects", () => ({ body: listProjects() })); + + return server; +} + +export function registerConnectionRoutes(server: AmicodeServiceServer): AmicodeServiceServer { + server.add("GET", "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/amicode/connections", () => ({ body: statusResponse() })); + + server.add("POST", "/amicode/connections/credential", async ({ body }) => ({ + body: await submitCredentialResponse(body), + })); + + server.add("POST", "/amicode/connections/disconnect", ({ body }) => ({ body: disconnectResponse(body) })); + + server.add("POST", "/amicode/connections/revalidate", async ({ body }) => ({ + body: await revalidateResponse(body), + })); + + server.add("POST", "/amicode/connections/choose-project", async ({ body }) => ({ + body: await chooseProjectResponse(body), + })); + + server.add("POST", "/amicode/connections/auth", async ({ body }) => ({ + body: await startAuthResponse(body), + })); + + server.add("GET", "/amicode/connections/catalog", () => ({ body: catalogResponse() })); + + server.add("POST", "/amicode/connections/add-custom", async ({ body }) => ({ + body: await addCustomConnectionResponse(body), + })); + + server.add("POST", "/amicode/connections/remove", ({ body }) => ({ + body: removeCustomConnectionResponse(body), + })); + + return server; +} + /** The service with every ported slice mounted. The extension wiring slice * boots this at activation; the contract tests boot it in-process. */ export function createAmicodeService(opts: { password?: string } = {}): AmicodeServiceServer { @@ -149,5 +203,7 @@ export function createAmicodeService(opts: { password?: string } = {}): AmicodeS registerProblemRoutes(server); registerLibraryRoutes(server); registerWidgetRoutes(server); + registerProjectRoutes(server); + registerConnectionRoutes(server); return server; } diff --git a/packages/extension/src/amicode_service/pasqal_secret.ts b/packages/extension/src/amicode_service/pasqal_secret.ts new file mode 100644 index 000000000..7df2df591 --- /dev/null +++ b/packages/extension/src/amicode_service/pasqal_secret.ts @@ -0,0 +1,146 @@ +// AMICODE: the Pasqal password store (amicode#194, ADR 0001 addendum +// 2026-07-21). Pasqal's API accepts ONLY password-grant and service-account +// tokens — refresh tokens 403, browser/device grants are client-disabled — so +// a user connection needs the password present to silently re-mint the ~24h +// token. This is the ONE place a third-party password is kept, and it is kept +// in the OS keychain, NEVER in the credential file (the credentials.ts poison +// guard makes a password in pasqal.json impossible by design). +// +// Mechanism is Jack's validated pasqalAuth.ts choice (Kate, 2026-07-21): +// @napi-rs/keyring — macOS login Keychain / Linux Secret Service / Windows +// Credential Manager, the same native module already riding the bun-compiled +// binary alongside node-pty and tree-sitter. Where the native binding cannot +// load (headless Linux, no Secret Service daemon), the store degrades to +// SESSION-MEMORY: the password lives in this process only and a restart +// re-prompts — ADR 0001's original named fallback, never a plaintext file. +// +// SECURITY: the password value never appears in a log, an error message, a +// status entry, or any rendered response — this module returns it only to the +// re-auth spawn, which passes it via the child env (never argv). + +/** Keychain service (namespace) for the Pasqal password. $AMICO_PASQAL_KEYCHAIN_SERVICE + * overrides the default so an isolated sandbox / test run gets its own slot and + * never shares the real connection's secret. */ +function keychainService(): string { + const env = process.env.AMICO_PASQAL_KEYCHAIN_SERVICE + return env && env.trim() !== "" ? env.trim() : "pasqal-cloud" +} + +export interface PasqalSecret { + username: string + password: string +} + +/** The store seam. Every method is failure-tolerant: a keychain that cannot be + * reached never throws into the caller — read() is undefined, write() reports + * false so the caller can fall back to session-only, clear() is best-effort. */ +export interface PasqalSecretStore { + read(account: string): PasqalSecret | undefined + /** true = durably stored (survives restart); false = not persisted (the + * caller should treat the connection as session-only). */ + write(account: string, secret: PasqalSecret): boolean + clear(account: string): void +} + +/** Lazily-loaded @napi-rs/keyring Entry constructor, or null if the native + * module is absent/unloadable in this runtime. Resolved once. */ +let keyringEntry: (new (service: string, account: string) => KeyringEntry) | null | undefined +interface KeyringEntry { + getPassword(): string + setPassword(value: string): void + deletePassword(): void +} + +function loadKeyring(): (new (service: string, account: string) => KeyringEntry) | null { + if (keyringEntry !== undefined) return keyringEntry + try { + // require, not static import: the native binding must not be a hard + // load-time dependency of the whole server — a missing/unbuilt addon + // degrades this one feature, it does not crash opencode. + const mod = require("@napi-rs/keyring") as { Entry: new (service: string, account: string) => KeyringEntry } + keyringEntry = mod.Entry + } catch { + keyringEntry = null + } + return keyringEntry +} + +/** Session-memory fallback: a process-lifetime map, keyed by account. Used + * when the keychain is unreachable so the interim still works within one run + * (silent re-mint mid-session) even on a keyring-less host. */ +const sessionMemory = new Map() + +/** Production store: keychain when available, session-memory otherwise. */ +export const keychainSecretStore: PasqalSecretStore = { + read(account) { + const Entry = loadKeyring() + if (Entry) { + try { + const raw = new Entry(keychainService(), account).getPassword() + const parsed: unknown = JSON.parse(raw) + if (typeof parsed === "object" && parsed !== null) { + const d = parsed as Record + if (typeof d.username === "string" && typeof d.password === "string" && d.username !== "" && d.password !== "") + return { username: d.username, password: d.password } + } + } catch { + // no entry / unreadable / off-shape → fall through to session memory + } + } + return sessionMemory.get(account) + }, + write(account, secret) { + const Entry = loadKeyring() + if (Entry) { + try { + new Entry(keychainService(), account).setPassword(JSON.stringify(secret)) + sessionMemory.delete(account) // durable now wins; don't keep a RAM copy too + return true + } catch { + // keychain present but write refused (locked, no daemon) → session only + } + } + sessionMemory.set(account, secret) + return false + }, + clear(account) { + sessionMemory.delete(account) + const Entry = loadKeyring() + if (!Entry) return + try { + new Entry(keychainService(), account).deletePassword() + } catch { + // nothing stored / already gone — clear is best-effort + } + }, +} + +/** Test-only: an injectable in-memory store so suites never touch the real OS + * keychain. Behaves like the durable path (write → true). */ +export function inMemorySecretStore(): PasqalSecretStore { + const map = new Map() + return { + read: (account) => map.get(account), + write: (account, secret) => { + map.set(account, secret) + return true + }, + clear: (account) => void map.delete(account), + } +} + +// The active store — production default, swappable by tests via setSecretStore. +let active: PasqalSecretStore = keychainSecretStore + +export function pasqalSecretStore(): PasqalSecretStore { + return active +} + +/** Test seam: install a store (e.g. inMemorySecretStore()); returns a restore fn. */ +export function setPasqalSecretStore(store: PasqalSecretStore): () => void { + const prev = active + active = store + return () => { + active = prev + } +} diff --git a/packages/extension/src/amicode_service/project.ts b/packages/extension/src/amicode_service/project.ts new file mode 100644 index 000000000..cd330a3e6 --- /dev/null +++ b/packages/extension/src/amicode_service/project.ts @@ -0,0 +1,154 @@ +// amicode#203: New-project creation — mkdir + best-effort git init behind an +// amicode server route (same fs-mutation idiom as vaults.ts / library.ts). The +// app calls POST /amicode/project with {name, parentDir}; the response is a +// JSON string, never a rejection — failures come back as ok:false bodies so the +// dialog can render an inline, recoverable error. +import { spawnSync } from "node:child_process" +import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs" +import { homedir } from "node:os" +import path from "node:path" + +/** The default parent when the client sends none — the webview doesn't know the + * user's home dir, so the server owns the default (created on first use). */ +export function defaultParentDir(home: string = homedir()): string { + return path.join(home, "AmicodeProjects") +} + +/** Slugify a project name into a folder basename. Mirrors the app-side + * projectNameToSlug (home-projects.ts) — kept in sync by tests on both sides. */ +export function slugify(name: string): string { + return name + .trim() + .toLowerCase() + .replace(/[\s_]+/g, "-") + .replace(/[^a-z0-9-]/g, "") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, "") +} + +export type CreateProjectResult = + | { ok: true; path: string; slug: string; gitInitialized: boolean } + | { ok: false; error: "empty-name" | "bad-parent" | "collision" | "unwritable" | "other"; message: string } + +/** Parse + validate the request body into a concrete target, without touching + * the filesystem. Exported for unit tests. */ +export function planCreate(rawBody: string): { target: string; slug: string } | Extract { + let parsed: unknown + try { + parsed = JSON.parse(rawBody) + } catch { + return { ok: false, error: "other", message: "malformed request body" } + } + const body = (typeof parsed === "object" && parsed !== null ? parsed : {}) as Record + const name = typeof body.name === "string" ? body.name : "" + const parentDir = typeof body.parentDir === "string" ? body.parentDir : "" + const slug = slugify(name) + if (!slug) return { ok: false, error: "empty-name", message: "Enter a project name." } + // No parent → the server default (~/AmicodeProjects); a provided parent must + // be absolute (never resolve a client-supplied relative path against the + // server cwd). The sanitized slug can't traverse (it is [a-z0-9-] only). + const parent = parentDir ? parentDir : defaultParentDir() + if (!path.isAbsolute(parent)) + return { ok: false, error: "bad-parent", message: "Choose a location for the project." } + return { target: path.join(parent, slug), slug } +} + +/** Map a Node fs error to the inline error kind the dialog shows. */ +export function classifyFsError(err: unknown): Extract["error"] { + const code = ((err as { code?: string })?.code ?? "").toUpperCase() + if (code === "EEXIST") return "collision" + if (code === "EACCES" || code === "EPERM" || code === "EROFS" || code === "ENOENT" || code === "ENOTDIR") + return "unwritable" + return "other" +} + +/** Create the project directory and best-effort git-init it. The directory + + * its registration is the atomic unit; git init layers on top and never + * blocks creation (amicode#203 AC4). Injectable deps for tests. */ +export function createProjectAt( + target: string, + slug: string, + deps: { + exists?: (p: string) => boolean + mkdir?: (p: string) => void + gitInit?: (cwd: string) => boolean + } = {}, +): CreateProjectResult { + const exists = deps.exists ?? existsSync + const mkdir = deps.mkdir ?? ((p: string) => void mkdirSync(p, { recursive: true })) + const gitInit = + deps.gitInit ?? + ((cwd: string) => { + try { + return spawnSync("git", ["init"], { cwd, stdio: "ignore" }).status === 0 + } catch { + return false // git absent from PATH → best-effort, project still created + } + }) + + if (exists(target)) return { ok: false, error: "collision", message: "A project with this name already exists here." } + try { + mkdir(target) + } catch (err) { + const kind = classifyFsError(err) + return { ok: false, error: kind, message: kind === "unwritable" ? "That location can't be written to." : "Could not create the project." } + } + let gitInitialized = false + try { + gitInitialized = gitInit(target) + } catch { + gitInitialized = false + } + return { ok: true, path: target, slug, gitInitialized } +} + +/** POST /amicode/project handler body → JSON string (never rejects). */ +export function createProject(rawBody: string): string { + const plan = planCreate(rawBody) + if ("ok" in plan) return JSON.stringify(plan) + return JSON.stringify(createProjectAt(plan.target, plan.slug)) +} + +export type ProjectDirEntry = { slug: string; path: string } + +/** Enumerate the immediate subdirectories of the projects parent — each folder + * IS a project (amicode is folder-first). This is the source of truth for the + * Projects list, so a project surfaces the moment its folder exists, even if it + * was never opened — closing the "created-but-invisible" desync where the + * collision check (existsSync) saw a folder the list never did. Returns [] when + * the parent doesn't exist yet (before the first create). Dotfiles and + * non-directories are skipped; results are name-sorted. Injectable for tests. */ +export function listProjectDirs( + parentDir: string = defaultParentDir(), + deps: { + exists?: (p: string) => boolean + readEntries?: (p: string) => Array<{ name: string; isDirectory: boolean }> + } = {}, +): ProjectDirEntry[] { + const exists = deps.exists ?? existsSync + const readEntries = + deps.readEntries ?? + ((p: string) => readdirSync(p, { withFileTypes: true }).map((d) => ({ name: d.name, isDirectory: d.isDirectory() }))) + if (!exists(parentDir)) return [] + return readEntries(parentDir) + .filter((e) => e.isDirectory && !e.name.startsWith(".")) + .map((e) => ({ slug: e.name, path: path.join(parentDir, e.name) })) + .sort((a, b) => a.slug.localeCompare(b.slug)) +} + +export type ListProjectsResult = { ok: true; parentDir: string; projects: ProjectDirEntry[] } + +/** GET /amicode/projects handler → JSON string (never rejects). A read error + * (e.g. permission) degrades to an empty list rather than failing the list. */ +export function listProjects(parentDir: string = defaultParentDir()): string { + const body: ListProjectsResult = { ok: true, parentDir, projects: [] } + try { + body.projects = listProjectDirs(parentDir) + } catch { + /* unreadable parent → empty list */ + } + return JSON.stringify(body) +} + +// re-export for a create-then-cleanup path if a caller ever needs to roll back +export const _internal = { rmSync } diff --git a/packages/extension/test/amicode_service_connections.test.ts b/packages/extension/test/amicode_service_connections.test.ts new file mode 100644 index 000000000..d06744dca --- /dev/null +++ b/packages/extension/test/amicode_service_connections.test.ts @@ -0,0 +1,36 @@ +// amicode-service connections unit tests (#451, M1 slice 6) — the shapes the +// golden fixtures cannot pin: the /amicode/connections/auth route and the +// token auth_methods entry both landed in fork source AFTER the vendored pin +// (v1.18.10-amicode.11), so the recorded binary serves the SPA catch-all for +// them. These tests pin the ported SOURCE behavior; both join the golden arc +// at the next pin bump. +import { describe, it, expect } from "vitest"; +import { startAuthResponse } from "../src/amicode_service/connections"; + +describe("startAuthResponse — refusal shapes (post-pin route, source-level parity)", () => { + it("non-google ids refuse browser auth", async () => { + const body = await startAuthResponse(JSON.stringify({ id: "slack", method: "browser" })); + const parsed = JSON.parse(body); + expect(parsed.ok).toBe(false); + expect(parsed.error).toContain("browser auth is only for google connections"); + }); + + it("bad body refuses", async () => { + const body = await startAuthResponse(JSON.stringify({})); + const parsed = JSON.parse(body); + expect(parsed.ok).toBe(false); + expect(parsed.error).toContain("body must be JSON {id, method}"); + }); + + it("bad method refuses", async () => { + const body = await startAuthResponse(JSON.stringify({ id: "google", method: "carrier-pigeon" })); + const parsed = JSON.parse(body); + expect(parsed.ok).toBe(false); + expect(parsed.error).toContain("method must be browser or device-code"); + }); + + it("non-JSON body refuses", async () => { + const body = await startAuthResponse("not json"); + expect(JSON.parse(body).ok).toBe(false); + }); +}); diff --git a/packages/extension/test/amicode_service_contract.test.ts b/packages/extension/test/amicode_service_contract.test.ts index 020403879..8d192c7af 100644 --- a/packages/extension/test/amicode_service_contract.test.ts +++ b/packages/extension/test/amicode_service_contract.test.ts @@ -79,6 +79,21 @@ describe("amicode service — golden-fixture parity with the fork", () => { return obj; }; + /** auth_methods gained a "token" entry in fork source AFTER the vendored + * pin (v1.18.10-amicode.11 advertises browser only; the port follows + * current source). Removed from BOTH sides so the comparison is stable + * across the next pin bump — the token-paste flow itself is unit-tested + * in amicode_service_connections.test.ts. */ + const normalizePostPinDrift = (obj: any): any => { + if (obj && typeof obj === "object" && Array.isArray(obj.connections)) { + const connections = obj.connections.map((c: any) => + Array.isArray(c?.auth_methods) ? { ...c, auth_methods: c.auth_methods.filter((m: unknown) => m !== "token") } : c, + ); + return { ...obj, connections }; + } + return obj; + }; + const normalizeWallClock = (obj: any): any => { if ( obj && @@ -156,7 +171,7 @@ describe("amicode service — golden-fixture parity with the fork", () => { // JSON routes: deep-equal on parsed bodies (key order is the port's // business; structure and values are the contract). const canon = (o: any, seededAt: number) => - normalizeListOrder(normalizeWallClock(normalizeFreshTimestamps(o, seededAt))); + normalizePostPinDrift(normalizeListOrder(normalizeWallClock(normalizeFreshTimestamps(o, seededAt)))); expect(canon(JSON.parse(received), testSeededAt)).toEqual(canon(JSON.parse(expected), meta.seededAt)); } else { // Non-JSON routes (the served widget frame): byte-exact after sandbox diff --git a/packages/extension/test/fixtures/amicode/golden.json b/packages/extension/test/fixtures/amicode/golden.json index c168ce8c9..7d6abea1d 100644 --- a/packages/extension/test/fixtures/amicode/golden.json +++ b/packages/extension/test/fixtures/amicode/golden.json @@ -1,12 +1,12 @@ { - "recordedAt": "2026-08-20T20:52:16.479Z", + "recordedAt": "2026-08-20T21:01:38.612Z", "fork": { "version": "1.18.10", "tag": "v1.18.10-amicode.11" }, - "sandbox": "/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-a28MnP", - "sandboxReal": "/private/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-a28MnP", - "seededAt": 1787259135297, + "sandbox": "/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-JlOpLU", + "sandboxReal": "/private/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-JlOpLU", + "seededAt": 1787259697414, "entries": [ { "name": "cold read — synthesized identity + stats + remembers", @@ -75,7 +75,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"name\":\"attachable-demo\",\"kind\":\"personal\",\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-a28MnP/vaults/attachable-demo\"}" + "body": "{\"ok\":true,\"name\":\"attachable-demo\",\"kind\":\"personal\",\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-JlOpLU/vaults/attachable-demo\"}" }, { "name": "vaults — post-attach (cache bust, new mount)", @@ -189,7 +189,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"found\":true,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-a28MnP/docs/readme.md\",\"mount\":null,\"kind\":\"file\"}" + "body": "{\"ok\":true,\"found\":true,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-JlOpLU/docs/readme.md\",\"mount\":null,\"kind\":\"file\"}" }, { "name": "resolve mount-prefixed (tier 3)", @@ -200,7 +200,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"found\":true,\"path\":\"/private/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-a28MnP/vaults/personal-main/notes/note.md\",\"mount\":\"personal-main\",\"kind\":\"file\"}" + "body": "{\"ok\":true,\"found\":true,\"path\":\"/private/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-JlOpLU/vaults/personal-main/notes/note.md\",\"mount\":\"personal-main\",\"kind\":\"file\"}" }, { "name": "resolve relative w/ dir part (tier 4 → project dir)", @@ -211,7 +211,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"found\":true,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-a28MnP/docs/readme.md\",\"mount\":null,\"kind\":\"file\"}" + "body": "{\"ok\":true,\"found\":true,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-JlOpLU/docs/readme.md\",\"mount\":null,\"kind\":\"file\"}" }, { "name": "resolve bare typed-prefix — miss (tier 5)", @@ -354,7 +354,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"run\":{\"run_id\":\"r20260810-000000Z-3d4e5f\",\"lab\":\"default\",\"status\":\"solving\",\"iteration\":2,\"fidelity\":null,\"best_f\":0.35,\"last_f\":0.35,\"elapsed_ms\":939135296,\"series\":[{\"iter\":1,\"f\":0.4},{\"iter\":2,\"f\":0.35}],\"pulse\":{\"iter\":2,\"dt\":0.2,\"values\":[0.01,0.02,0.03,0.04,0.05,0.06]},\"pulse_meta\":{\"drives\":2,\"knots\":3,\"labels\":[\"a_1\",\"a_2\"]},\"tail\":[\"AMICODE_PULSE_META drives=2 knots=3 labels=\\\"a_1\\\",\\\"a_2\\\" bounds=-0.2:0.2,-0.2:0.2\",\"AMICODE_ITER iter=1 f=4.0e-01 inf_pr=1.0e-01 inf_du=5.0e-01\",\"AMICODE_ITER iter=2 f=3.5e-01 inf_pr=1.0e-02 inf_du=5.0e-02\",\"AMICODE_PULSE iter=2 dt=0.2 a=0.01,0.02,0.03;0.04,0.05,0.06\"]},\"error\":null}" + "body": "{\"ok\":true,\"run\":{\"run_id\":\"r20260810-000000Z-3d4e5f\",\"lab\":\"default\",\"status\":\"solving\",\"iteration\":2,\"fidelity\":null,\"best_f\":0.35,\"last_f\":0.35,\"elapsed_ms\":939697411,\"series\":[{\"iter\":1,\"f\":0.4},{\"iter\":2,\"f\":0.35}],\"pulse\":{\"iter\":2,\"dt\":0.2,\"values\":[0.01,0.02,0.03,0.04,0.05,0.06]},\"pulse_meta\":{\"drives\":2,\"knots\":3,\"labels\":[\"a_1\",\"a_2\"]},\"tail\":[\"AMICODE_PULSE_META drives=2 knots=3 labels=\\\"a_1\\\",\\\"a_2\\\" bounds=-0.2:0.2,-0.2:0.2\",\"AMICODE_ITER iter=1 f=4.0e-01 inf_pr=1.0e-01 inf_du=5.0e-01\",\"AMICODE_ITER iter=2 f=3.5e-01 inf_pr=1.0e-02 inf_du=5.0e-02\",\"AMICODE_PULSE iter=2 dt=0.2 a=0.01,0.02,0.03;0.04,0.05,0.06\"]},\"error\":null}" }, { "name": "run series — failed (result.toml ≠ finished)", @@ -376,7 +376,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"run\":{\"run_id\":\"r20260818-000000Z-4h5i6j\",\"lab\":\"default\",\"status\":\"stalled\",\"iteration\":2,\"fidelity\":null,\"best_f\":0.59,\"last_f\":0.59,\"elapsed_ms\":240735296,\"series\":[{\"iter\":1,\"f\":0.6},{\"iter\":2,\"f\":0.59}],\"pulse\":{\"iter\":2,\"dt\":0.2,\"values\":[0.01,0.02,0.03,0.04,0.05,0.06]},\"pulse_meta\":{\"drives\":2,\"knots\":3,\"labels\":[\"a_1\",\"a_2\"]},\"tail\":[\"AMICODE_PULSE_META drives=2 knots=3 labels=\\\"a_1\\\",\\\"a_2\\\" bounds=-0.2:0.2,-0.2:0.2\",\"AMICODE_ITER iter=1 f=6.0e-01 inf_pr=1.0e-01 inf_du=5.0e-01\",\"AMICODE_ITER iter=2 f=5.9e-01 inf_pr=1.0e-02 inf_du=5.0e-02\",\"AMICODE_PULSE iter=2 dt=0.2 a=0.01,0.02,0.03;0.04,0.05,0.06\"]},\"error\":null}" + "body": "{\"ok\":true,\"run\":{\"run_id\":\"r20260818-000000Z-4h5i6j\",\"lab\":\"default\",\"status\":\"stalled\",\"iteration\":2,\"fidelity\":null,\"best_f\":0.59,\"last_f\":0.59,\"elapsed_ms\":241297411,\"series\":[{\"iter\":1,\"f\":0.6},{\"iter\":2,\"f\":0.59}],\"pulse\":{\"iter\":2,\"dt\":0.2,\"values\":[0.01,0.02,0.03,0.04,0.05,0.06]},\"pulse_meta\":{\"drives\":2,\"knots\":3,\"labels\":[\"a_1\",\"a_2\"]},\"tail\":[\"AMICODE_PULSE_META drives=2 knots=3 labels=\\\"a_1\\\",\\\"a_2\\\" bounds=-0.2:0.2,-0.2:0.2\",\"AMICODE_ITER iter=1 f=6.0e-01 inf_pr=1.0e-01 inf_du=5.0e-01\",\"AMICODE_ITER iter=2 f=5.9e-01 inf_pr=1.0e-02 inf_du=5.0e-02\",\"AMICODE_PULSE iter=2 dt=0.2 a=0.01,0.02,0.03;0.04,0.05,0.06\"]},\"error\":null}" }, { "name": "run series — explicit lab", @@ -409,7 +409,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"papers\":[{\"name\":\"piccolo-trajectory-2023.pdf\",\"size\":28,\"added_ms\":1785974400000,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-a28MnP/.amico/library/piccolo-trajectory-2023.pdf\"},{\"name\":\"rydberg-blockade-2024.pdf\",\"size\":28,\"added_ms\":1785628800000,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-a28MnP/.amico/library/rydberg-blockade-2024.pdf\"}],\"error\":null}" + "body": "{\"ok\":true,\"papers\":[{\"name\":\"piccolo-trajectory-2023.pdf\",\"size\":28,\"added_ms\":1785974400000,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-JlOpLU/.amico/library/piccolo-trajectory-2023.pdf\"},{\"name\":\"rydberg-blockade-2024.pdf\",\"size\":28,\"added_ms\":1785628800000,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-JlOpLU/.amico/library/rydberg-blockade-2024.pdf\"}],\"error\":null}" }, { "name": "library upload — valid PDF (refreshed listing)", @@ -424,7 +424,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"papers\":[{\"name\":\"new paper.pdf\",\"size\":29,\"added_ms\":1787259135961,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-a28MnP/.amico/library/new paper.pdf\"},{\"name\":\"piccolo-trajectory-2023.pdf\",\"size\":28,\"added_ms\":1785974400000,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-a28MnP/.amico/library/piccolo-trajectory-2023.pdf\"},{\"name\":\"rydberg-blockade-2024.pdf\",\"size\":28,\"added_ms\":1785628800000,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-a28MnP/.amico/library/rydberg-blockade-2024.pdf\"}],\"error\":null}" + "body": "{\"ok\":true,\"papers\":[{\"name\":\"new paper.pdf\",\"size\":29,\"added_ms\":1787259698079,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-JlOpLU/.amico/library/new paper.pdf\"},{\"name\":\"piccolo-trajectory-2023.pdf\",\"size\":28,\"added_ms\":1785974400000,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-JlOpLU/.amico/library/piccolo-trajectory-2023.pdf\"},{\"name\":\"rydberg-blockade-2024.pdf\",\"size\":28,\"added_ms\":1785628800000,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-JlOpLU/.amico/library/rydberg-blockade-2024.pdf\"}],\"error\":null}" }, { "name": "library upload — bad_filetype refusal", @@ -464,7 +464,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"papers\":[{\"name\":\"new paper.pdf\",\"size\":29,\"added_ms\":1787259135961,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-a28MnP/.amico/library/new paper.pdf\"},{\"name\":\"piccolo-trajectory-2023.pdf\",\"size\":28,\"added_ms\":1785974400000,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-a28MnP/.amico/library/piccolo-trajectory-2023.pdf\"},{\"name\":\"rydberg-blockade-2024.pdf\",\"size\":28,\"added_ms\":1785628800000,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-a28MnP/.amico/library/rydberg-blockade-2024.pdf\"}],\"error\":null}" + "body": "{\"ok\":true,\"papers\":[{\"name\":\"new paper.pdf\",\"size\":29,\"added_ms\":1787259698079,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-JlOpLU/.amico/library/new paper.pdf\"},{\"name\":\"piccolo-trajectory-2023.pdf\",\"size\":28,\"added_ms\":1785974400000,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-JlOpLU/.amico/library/piccolo-trajectory-2023.pdf\"},{\"name\":\"rydberg-blockade-2024.pdf\",\"size\":28,\"added_ms\":1785628800000,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-JlOpLU/.amico/library/rydberg-blockade-2024.pdf\"}],\"error\":null}" }, { "name": "widget registry — builtins with content hashes", @@ -620,6 +620,278 @@ "contentType": "application/json", "csp": null, "body": "{\"ok\":false,\"dashboard\":null,\"error\":\"bad_body: widget must be a list\"}" + }, + { + "name": "connections — seeded connected + needs-key states", + "request": { + "method": "GET", + "path": "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/amicode/connections" + }, + "status": 200, + "contentType": "application/json", + "csp": null, + "body": "{\"ok\":true,\"connections\":[{\"id\":\"company-compute\",\"state\":\"connected\",\"validated_at\":\"2026-08-10T00:00:00Z\",\"stale\":true,\"identity\":\"aaron\",\"entitlements\":[\"hpc\"],\"icon\":\"\",\"name\":\"Harmoniqs Cloud\"},{\"id\":\"pasqal-cloud\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Pasqal Cloud\"},{\"id\":\"slack\",\"state\":\"connected\",\"validated_at\":\"2026-08-10T00:00:00Z\",\"stale\":true,\"identity\":\"aaron@example\",\"icon\":\"\",\"name\":\"Slack\"},{\"id\":\"github\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"GitHub\"},{\"id\":\"linear\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Linear\"},{\"id\":\"google\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Google\",\"auth_methods\":[\"browser\"]},{\"id\":\"google-drive\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Google Drive\",\"auth_methods\":[\"browser\"]},{\"id\":\"custom-seed1\",\"state\":\"connected\",\"validated_at\":null,\"stale\":true,\"icon\":\"L\",\"name\":\"Lab QPU\"}],\"error\":null}" + }, + { + "name": "connections catalog — configured filtered out", + "request": { + "method": "GET", + "path": "/amicode/connections/catalog" + }, + "status": 200, + "contentType": "application/json", + "csp": null, + "body": "{\"ok\":true,\"catalog\":[{\"id\":\"pasqal-cloud\",\"name\":\"Pasqal Cloud\",\"icon\":\"\",\"authShape\":\"pasqal-credentials\"},{\"id\":\"github\",\"name\":\"GitHub\",\"icon\":\"\",\"authShape\":\"token-only\"},{\"id\":\"linear\",\"name\":\"Linear\",\"icon\":\"\",\"authShape\":\"token-only\"},{\"id\":\"google\",\"name\":\"Google\",\"icon\":\"\",\"authShape\":\"token-only\"},{\"id\":\"google-drive\",\"name\":\"Google Drive\",\"icon\":\"\",\"authShape\":\"token-only\"}],\"error\":null}" + }, + { + "name": "credential submit — empty body refusal", + "request": { + "method": "POST", + "path": "/amicode/connections/credential", + "body": {} + }, + "status": 200, + "contentType": "application/json", + "csp": null, + "body": "{\"ok\":false,\"connection\":null,\"error\":\"unknown_connection: id must be a known connection id\"}" + }, + { + "name": "credential submit — unknown_connection", + "request": { + "method": "POST", + "path": "/amicode/connections/credential", + "body": { + "id": "bogus-connector", + "token": "x" + } + }, + "status": 200, + "contentType": "application/json", + "csp": null, + "body": "{\"ok\":false,\"connection\":null,\"error\":\"unknown_connection: id must be a known connection id\"}" + }, + { + "name": "credential submit — non-http base_url refusal", + "request": { + "method": "POST", + "path": "/amicode/connections/credential", + "body": { + "id": "company-compute", + "base_url": "ftp://not-http", + "token": "t" + } + }, + "status": 200, + "contentType": "application/json", + "csp": null, + "body": "{\"ok\":false,\"connection\":null,\"error\":\"bad_request: base_url must be an http(s) URL\"}" + }, + { + "name": "credential submit — empty token refusal", + "request": { + "method": "POST", + "path": "/amicode/connections/credential", + "body": { + "id": "slack", + "token": " " + } + }, + "status": 200, + "contentType": "application/json", + "csp": null, + "body": "{\"ok\":false,\"connection\":null,\"error\":\"bad_request: non-empty token is required\"}" + }, + { + "name": "disconnect — clears seeded slack credential", + "request": { + "method": "POST", + "path": "/amicode/connections/disconnect", + "body": { + "id": "slack" + } + }, + "status": 200, + "contentType": "application/json", + "csp": null, + "body": "{\"ok\":true,\"connection\":{\"id\":\"slack\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Slack\"},\"error\":null}" + }, + { + "name": "disconnect — unknown id refusal", + "request": { + "method": "POST", + "path": "/amicode/connections/disconnect", + "body": { + "id": "no-such-connector" + } + }, + "status": 200, + "contentType": "application/json", + "csp": null, + "body": "{\"ok\":false,\"connection\":null,\"error\":\"bad_request: body must be JSON {id} with a known connection id\"}" + }, + { + "name": "revalidate — no credential → needs-key (pre-probe)", + "request": { + "method": "POST", + "path": "/amicode/connections/revalidate", + "body": { + "id": "github" + } + }, + "status": 200, + "contentType": "application/json", + "csp": null, + "body": "{\"ok\":true,\"connection\":{\"id\":\"github\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"GitHub\"},\"error\":null}" + }, + { + "name": "revalidate — unknown id refusal", + "request": { + "method": "POST", + "path": "/amicode/connections/revalidate", + "body": { + "id": "githubx" + } + }, + "status": 200, + "contentType": "application/json", + "csp": null, + "body": "{\"ok\":false,\"connection\":null,\"error\":\"bad_request: body must be JSON {id} with a known connection id\"}" + }, + { + "name": "choose-project — no pending selection", + "request": { + "method": "POST", + "path": "/amicode/connections/choose-project", + "body": { + "id": "pasqal-cloud", + "project_id": "p1" + } + }, + "status": 200, + "contentType": "application/json", + "csp": null, + "body": "{\"ok\":false,\"connection\":null,\"error\":\"no_pending_selection: no pending project selection — reconnect and pick a project\"}" + }, + { + "name": "add-custom — missing token refusal", + "request": { + "method": "POST", + "path": "/amicode/connections/add-custom", + "body": { + "name": "half-filled" + } + }, + "status": 200, + "contentType": "application/json", + "csp": null, + "body": "{\"ok\":false,\"connection\":null,\"error\":\"bad_request: name and token are required\"}" + }, + { + "name": "remove — custom connection removed", + "request": { + "method": "POST", + "path": "/amicode/connections/remove", + "body": { + "id": "custom-seed1" + } + }, + "status": 200, + "contentType": "application/json", + "csp": null, + "body": "{\"ok\":true,\"error\":null}" + }, + { + "name": "remove — unknown custom id refusal", + "request": { + "method": "POST", + "path": "/amicode/connections/remove", + "body": { + "id": "custom-gone" + } + }, + "status": 200, + "contentType": "application/json", + "csp": null, + "body": "{\"ok\":false,\"connection\":null,\"error\":\"bad_request: custom connection not found\"}" + }, + { + "name": "connections — post-disconnect state", + "request": { + "method": "GET", + "path": "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/amicode/connections" + }, + "status": 200, + "contentType": "application/json", + "csp": null, + "body": "{\"ok\":true,\"connections\":[{\"id\":\"company-compute\",\"state\":\"connected\",\"validated_at\":\"2026-08-10T00:00:00Z\",\"stale\":true,\"identity\":\"aaron\",\"entitlements\":[\"hpc\"],\"offline\":true,\"icon\":\"\",\"name\":\"Harmoniqs Cloud\"},{\"id\":\"pasqal-cloud\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Pasqal Cloud\"},{\"id\":\"slack\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Slack\"},{\"id\":\"github\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"GitHub\"},{\"id\":\"linear\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Linear\"},{\"id\":\"google\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Google\",\"auth_methods\":[\"browser\"]},{\"id\":\"google-drive\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Google Drive\",\"auth_methods\":[\"browser\"]}],\"error\":null}" + }, + { + "name": "project create — mkdir (git absent, best-effort)", + "request": { + "method": "POST", + "path": "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/amicode/project", + "body": { + "name": "My New Project" + } + }, + "status": 200, + "contentType": "application/json", + "csp": null, + "body": "{\"ok\":true,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-JlOpLU/AmicodeProjects/my-new-project\",\"slug\":\"my-new-project\",\"gitInitialized\":false}" + }, + { + "name": "project create — collision", + "request": { + "method": "POST", + "path": "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/amicode/project", + "body": { + "name": "My New Project" + } + }, + "status": 200, + "contentType": "application/json", + "csp": null, + "body": "{\"ok\":false,\"error\":\"collision\",\"message\":\"A project with this name already exists here.\"}" + }, + { + "name": "project create — empty-name refusal", + "request": { + "method": "POST", + "path": "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/amicode/project", + "body": { + "name": " " + } + }, + "status": 200, + "contentType": "application/json", + "csp": null, + "body": "{\"ok\":false,\"error\":\"empty-name\",\"message\":\"Enter a project name.\"}" + }, + { + "name": "project create — non-absolute parent refusal", + "request": { + "method": "POST", + "path": "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/amicode/project", + "body": { + "name": "Ok", + "parentDir": "relative/path" + } + }, + "status": 200, + "contentType": "application/json", + "csp": null, + "body": "{\"ok\":false,\"error\":\"bad-parent\",\"message\":\"Choose a location for the project.\"}" + }, + { + "name": "projects list — prior + created", + "request": { + "method": "GET", + "path": "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/amicode/projects" + }, + "status": 200, + "contentType": "application/json", + "csp": null, + "body": "{\"ok\":true,\"parentDir\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-JlOpLU/AmicodeProjects\",\"projects\":[{\"slug\":\"my-new-project\",\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-JlOpLU/AmicodeProjects/my-new-project\"},{\"slug\":\"prior-project\",\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-JlOpLU/AmicodeProjects/prior-project\"}]}" } ] } From dd8d8babfb92e9f3eb117960e2f9e1875ee3e59c Mon Sep 17 00:00:00 2001 From: aaron Date: Thu, 20 Aug 2026 17:12:38 -0400 Subject: [PATCH 2/2] =?UTF-8?q?fix(test):=20connections=20fixtures=20?= =?UTF-8?q?=E2=80=94=20seed=20fresh=20validated=5Fat,=20kill=20the=20backg?= =?UTF-8?q?round-revalidation=20race?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CI flake: seeded validated_at (2026-08-10) was ten days old, so the fork's 24h staleness clock read stale:true on every status GET — and a stale CONNECTED entry with a stored credential kicks the fork's background network revalidation (probe → offline-flag cache write). That write raced the later post-disconnect GET: on macOS it landed before the read on both sides; on CI it didn't — offline:true appeared in the fixture but not the port. Fix at the seed: validated_at = one minute before seed-NOW (fresh on both the 24h clock and the 5s mtime slack — credential files pinned to the same instant), so NO background revalidation ever fires. The wall-clock validated_at normalizes to at replay (five-minute window), same discipline as added_ms. Three consecutive stable runs; contract suite 74/74; full suite green. --- .../scripts/amicode_fixture_seed.mjs | 20 +++++++---- .../test/amicode_service_contract.test.ts | 11 ++++++ .../test/fixtures/amicode/golden.json | 34 +++++++++---------- 3 files changed, 41 insertions(+), 24 deletions(-) diff --git a/packages/extension/scripts/amicode_fixture_seed.mjs b/packages/extension/scripts/amicode_fixture_seed.mjs index 6a480209b..2c6e2a1f8 100644 --- a/packages/extension/scripts/amicode_fixture_seed.mjs +++ b/packages/extension/scripts/amicode_fixture_seed.mjs @@ -315,21 +315,27 @@ export function seedAmicodeSandbox(dir) { }); // --- connections (credentials + status cache + custom registry) ------------- - // company-compute + slack CONNECTED (credential file present, cache entry - // fresh); credential mtimes pinned to validated_at so staleness reads false - // (the 5s mtime slack). Tokens are inert seed values — no probe runs. + // company-compute + slack CONNECTED. validated_at is seeded one minute + // before seed-NOW: the 24h staleness clock MUST read fresh, or the fork's + // GET kicks a background network revalidation (probe → cache write) whose + // landing races the later reads — observed as the CI offline-flag flake. + // Credential mtimes pinned to the SAME instant so the 5s edit-slack also + // reads fresh. Tokens are inert seed values — no probe runs, no probe is + // kicked. The wall-clock validated_at is normalized to at replay. + const ccValidatedAt = new Date(Date.now() - 60_000); + const ccValidatedIso = ccValidatedAt.toISOString(); writeJson(join(amico, "cloud.json"), { base_url: "https://solve.example.internal", token: "tok-cc-seed" }); writeJson(join(amico, "slack.json"), { token: "xoxb-seed-token" }); - utimesSync(join(amico, "cloud.json"), new Date("2026-08-10T00:00:00Z"), new Date("2026-08-10T00:00:00Z")); - utimesSync(join(amico, "slack.json"), new Date("2026-08-10T00:00:00Z"), new Date("2026-08-10T00:00:00Z")); + utimesSync(join(amico, "cloud.json"), ccValidatedAt, ccValidatedAt); + utimesSync(join(amico, "slack.json"), ccValidatedAt, ccValidatedAt); writeJson(join(amico, "connections.json"), { "company-compute": { state: "connected", identity: "aaron", entitlements: ["hpc"], - validated_at: "2026-08-10T00:00:00Z", + validated_at: ccValidatedIso, }, - slack: { state: "connected", identity: "aaron@example", validated_at: "2026-08-10T00:00:00Z" }, + slack: { state: "connected", identity: "aaron@example", validated_at: ccValidatedIso }, }); // one custom connection (the remove-fixture target) writeJson(join(dir, "custom-connections.json"), [ diff --git a/packages/extension/test/amicode_service_contract.test.ts b/packages/extension/test/amicode_service_contract.test.ts index 8d192c7af..36702e33a 100644 --- a/packages/extension/test/amicode_service_contract.test.ts +++ b/packages/extension/test/amicode_service_contract.test.ts @@ -76,6 +76,17 @@ describe("amicode service — golden-fixture parity with the fork", () => { ); return { ...obj, papers }; } + // connections: validated_at is seeded one minute before this side's now + // (fresh on the 24h clock — see the seeder); normalize anything fresher + // than five minutes before seed time, covering it and any route-written + // revalidation stamps. + if (obj && typeof obj === "object" && Array.isArray(obj.connections)) { + const fresh = (v: unknown) => typeof v === "string" && Date.parse(v) > seededAt - 5 * 60_000; + const connections = obj.connections.map((c: any) => + fresh(c?.validated_at) ? { ...c, validated_at: "" } : c, + ); + return { ...obj, connections }; + } return obj; }; diff --git a/packages/extension/test/fixtures/amicode/golden.json b/packages/extension/test/fixtures/amicode/golden.json index 7d6abea1d..4fb4bed27 100644 --- a/packages/extension/test/fixtures/amicode/golden.json +++ b/packages/extension/test/fixtures/amicode/golden.json @@ -1,12 +1,12 @@ { - "recordedAt": "2026-08-20T21:01:38.612Z", + "recordedAt": "2026-08-20T21:12:06.674Z", "fork": { "version": "1.18.10", "tag": "v1.18.10-amicode.11" }, - "sandbox": "/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-JlOpLU", - "sandboxReal": "/private/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-JlOpLU", - "seededAt": 1787259697414, + "sandbox": "/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-aHO0Nd", + "sandboxReal": "/private/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-aHO0Nd", + "seededAt": 1787260325503, "entries": [ { "name": "cold read — synthesized identity + stats + remembers", @@ -75,7 +75,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"name\":\"attachable-demo\",\"kind\":\"personal\",\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-JlOpLU/vaults/attachable-demo\"}" + "body": "{\"ok\":true,\"name\":\"attachable-demo\",\"kind\":\"personal\",\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-aHO0Nd/vaults/attachable-demo\"}" }, { "name": "vaults — post-attach (cache bust, new mount)", @@ -189,7 +189,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"found\":true,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-JlOpLU/docs/readme.md\",\"mount\":null,\"kind\":\"file\"}" + "body": "{\"ok\":true,\"found\":true,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-aHO0Nd/docs/readme.md\",\"mount\":null,\"kind\":\"file\"}" }, { "name": "resolve mount-prefixed (tier 3)", @@ -200,7 +200,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"found\":true,\"path\":\"/private/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-JlOpLU/vaults/personal-main/notes/note.md\",\"mount\":\"personal-main\",\"kind\":\"file\"}" + "body": "{\"ok\":true,\"found\":true,\"path\":\"/private/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-aHO0Nd/vaults/personal-main/notes/note.md\",\"mount\":\"personal-main\",\"kind\":\"file\"}" }, { "name": "resolve relative w/ dir part (tier 4 → project dir)", @@ -211,7 +211,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"found\":true,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-JlOpLU/docs/readme.md\",\"mount\":null,\"kind\":\"file\"}" + "body": "{\"ok\":true,\"found\":true,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-aHO0Nd/docs/readme.md\",\"mount\":null,\"kind\":\"file\"}" }, { "name": "resolve bare typed-prefix — miss (tier 5)", @@ -354,7 +354,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"run\":{\"run_id\":\"r20260810-000000Z-3d4e5f\",\"lab\":\"default\",\"status\":\"solving\",\"iteration\":2,\"fidelity\":null,\"best_f\":0.35,\"last_f\":0.35,\"elapsed_ms\":939697411,\"series\":[{\"iter\":1,\"f\":0.4},{\"iter\":2,\"f\":0.35}],\"pulse\":{\"iter\":2,\"dt\":0.2,\"values\":[0.01,0.02,0.03,0.04,0.05,0.06]},\"pulse_meta\":{\"drives\":2,\"knots\":3,\"labels\":[\"a_1\",\"a_2\"]},\"tail\":[\"AMICODE_PULSE_META drives=2 knots=3 labels=\\\"a_1\\\",\\\"a_2\\\" bounds=-0.2:0.2,-0.2:0.2\",\"AMICODE_ITER iter=1 f=4.0e-01 inf_pr=1.0e-01 inf_du=5.0e-01\",\"AMICODE_ITER iter=2 f=3.5e-01 inf_pr=1.0e-02 inf_du=5.0e-02\",\"AMICODE_PULSE iter=2 dt=0.2 a=0.01,0.02,0.03;0.04,0.05,0.06\"]},\"error\":null}" + "body": "{\"ok\":true,\"run\":{\"run_id\":\"r20260810-000000Z-3d4e5f\",\"lab\":\"default\",\"status\":\"solving\",\"iteration\":2,\"fidelity\":null,\"best_f\":0.35,\"last_f\":0.35,\"elapsed_ms\":940325499,\"series\":[{\"iter\":1,\"f\":0.4},{\"iter\":2,\"f\":0.35}],\"pulse\":{\"iter\":2,\"dt\":0.2,\"values\":[0.01,0.02,0.03,0.04,0.05,0.06]},\"pulse_meta\":{\"drives\":2,\"knots\":3,\"labels\":[\"a_1\",\"a_2\"]},\"tail\":[\"AMICODE_PULSE_META drives=2 knots=3 labels=\\\"a_1\\\",\\\"a_2\\\" bounds=-0.2:0.2,-0.2:0.2\",\"AMICODE_ITER iter=1 f=4.0e-01 inf_pr=1.0e-01 inf_du=5.0e-01\",\"AMICODE_ITER iter=2 f=3.5e-01 inf_pr=1.0e-02 inf_du=5.0e-02\",\"AMICODE_PULSE iter=2 dt=0.2 a=0.01,0.02,0.03;0.04,0.05,0.06\"]},\"error\":null}" }, { "name": "run series — failed (result.toml ≠ finished)", @@ -376,7 +376,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"run\":{\"run_id\":\"r20260818-000000Z-4h5i6j\",\"lab\":\"default\",\"status\":\"stalled\",\"iteration\":2,\"fidelity\":null,\"best_f\":0.59,\"last_f\":0.59,\"elapsed_ms\":241297411,\"series\":[{\"iter\":1,\"f\":0.6},{\"iter\":2,\"f\":0.59}],\"pulse\":{\"iter\":2,\"dt\":0.2,\"values\":[0.01,0.02,0.03,0.04,0.05,0.06]},\"pulse_meta\":{\"drives\":2,\"knots\":3,\"labels\":[\"a_1\",\"a_2\"]},\"tail\":[\"AMICODE_PULSE_META drives=2 knots=3 labels=\\\"a_1\\\",\\\"a_2\\\" bounds=-0.2:0.2,-0.2:0.2\",\"AMICODE_ITER iter=1 f=6.0e-01 inf_pr=1.0e-01 inf_du=5.0e-01\",\"AMICODE_ITER iter=2 f=5.9e-01 inf_pr=1.0e-02 inf_du=5.0e-02\",\"AMICODE_PULSE iter=2 dt=0.2 a=0.01,0.02,0.03;0.04,0.05,0.06\"]},\"error\":null}" + "body": "{\"ok\":true,\"run\":{\"run_id\":\"r20260818-000000Z-4h5i6j\",\"lab\":\"default\",\"status\":\"stalled\",\"iteration\":2,\"fidelity\":null,\"best_f\":0.59,\"last_f\":0.59,\"elapsed_ms\":241925499,\"series\":[{\"iter\":1,\"f\":0.6},{\"iter\":2,\"f\":0.59}],\"pulse\":{\"iter\":2,\"dt\":0.2,\"values\":[0.01,0.02,0.03,0.04,0.05,0.06]},\"pulse_meta\":{\"drives\":2,\"knots\":3,\"labels\":[\"a_1\",\"a_2\"]},\"tail\":[\"AMICODE_PULSE_META drives=2 knots=3 labels=\\\"a_1\\\",\\\"a_2\\\" bounds=-0.2:0.2,-0.2:0.2\",\"AMICODE_ITER iter=1 f=6.0e-01 inf_pr=1.0e-01 inf_du=5.0e-01\",\"AMICODE_ITER iter=2 f=5.9e-01 inf_pr=1.0e-02 inf_du=5.0e-02\",\"AMICODE_PULSE iter=2 dt=0.2 a=0.01,0.02,0.03;0.04,0.05,0.06\"]},\"error\":null}" }, { "name": "run series — explicit lab", @@ -409,7 +409,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"papers\":[{\"name\":\"piccolo-trajectory-2023.pdf\",\"size\":28,\"added_ms\":1785974400000,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-JlOpLU/.amico/library/piccolo-trajectory-2023.pdf\"},{\"name\":\"rydberg-blockade-2024.pdf\",\"size\":28,\"added_ms\":1785628800000,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-JlOpLU/.amico/library/rydberg-blockade-2024.pdf\"}],\"error\":null}" + "body": "{\"ok\":true,\"papers\":[{\"name\":\"piccolo-trajectory-2023.pdf\",\"size\":28,\"added_ms\":1785974400000,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-aHO0Nd/.amico/library/piccolo-trajectory-2023.pdf\"},{\"name\":\"rydberg-blockade-2024.pdf\",\"size\":28,\"added_ms\":1785628800000,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-aHO0Nd/.amico/library/rydberg-blockade-2024.pdf\"}],\"error\":null}" }, { "name": "library upload — valid PDF (refreshed listing)", @@ -424,7 +424,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"papers\":[{\"name\":\"new paper.pdf\",\"size\":29,\"added_ms\":1787259698079,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-JlOpLU/.amico/library/new paper.pdf\"},{\"name\":\"piccolo-trajectory-2023.pdf\",\"size\":28,\"added_ms\":1785974400000,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-JlOpLU/.amico/library/piccolo-trajectory-2023.pdf\"},{\"name\":\"rydberg-blockade-2024.pdf\",\"size\":28,\"added_ms\":1785628800000,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-JlOpLU/.amico/library/rydberg-blockade-2024.pdf\"}],\"error\":null}" + "body": "{\"ok\":true,\"papers\":[{\"name\":\"new paper.pdf\",\"size\":29,\"added_ms\":1787260326150,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-aHO0Nd/.amico/library/new paper.pdf\"},{\"name\":\"piccolo-trajectory-2023.pdf\",\"size\":28,\"added_ms\":1785974400000,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-aHO0Nd/.amico/library/piccolo-trajectory-2023.pdf\"},{\"name\":\"rydberg-blockade-2024.pdf\",\"size\":28,\"added_ms\":1785628800000,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-aHO0Nd/.amico/library/rydberg-blockade-2024.pdf\"}],\"error\":null}" }, { "name": "library upload — bad_filetype refusal", @@ -464,7 +464,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"papers\":[{\"name\":\"new paper.pdf\",\"size\":29,\"added_ms\":1787259698079,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-JlOpLU/.amico/library/new paper.pdf\"},{\"name\":\"piccolo-trajectory-2023.pdf\",\"size\":28,\"added_ms\":1785974400000,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-JlOpLU/.amico/library/piccolo-trajectory-2023.pdf\"},{\"name\":\"rydberg-blockade-2024.pdf\",\"size\":28,\"added_ms\":1785628800000,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-JlOpLU/.amico/library/rydberg-blockade-2024.pdf\"}],\"error\":null}" + "body": "{\"ok\":true,\"papers\":[{\"name\":\"new paper.pdf\",\"size\":29,\"added_ms\":1787260326150,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-aHO0Nd/.amico/library/new paper.pdf\"},{\"name\":\"piccolo-trajectory-2023.pdf\",\"size\":28,\"added_ms\":1785974400000,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-aHO0Nd/.amico/library/piccolo-trajectory-2023.pdf\"},{\"name\":\"rydberg-blockade-2024.pdf\",\"size\":28,\"added_ms\":1785628800000,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-aHO0Nd/.amico/library/rydberg-blockade-2024.pdf\"}],\"error\":null}" }, { "name": "widget registry — builtins with content hashes", @@ -630,7 +630,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"connections\":[{\"id\":\"company-compute\",\"state\":\"connected\",\"validated_at\":\"2026-08-10T00:00:00Z\",\"stale\":true,\"identity\":\"aaron\",\"entitlements\":[\"hpc\"],\"icon\":\"\",\"name\":\"Harmoniqs Cloud\"},{\"id\":\"pasqal-cloud\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Pasqal Cloud\"},{\"id\":\"slack\",\"state\":\"connected\",\"validated_at\":\"2026-08-10T00:00:00Z\",\"stale\":true,\"identity\":\"aaron@example\",\"icon\":\"\",\"name\":\"Slack\"},{\"id\":\"github\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"GitHub\"},{\"id\":\"linear\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Linear\"},{\"id\":\"google\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Google\",\"auth_methods\":[\"browser\"]},{\"id\":\"google-drive\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Google Drive\",\"auth_methods\":[\"browser\"]},{\"id\":\"custom-seed1\",\"state\":\"connected\",\"validated_at\":null,\"stale\":true,\"icon\":\"L\",\"name\":\"Lab QPU\"}],\"error\":null}" + "body": "{\"ok\":true,\"connections\":[{\"id\":\"company-compute\",\"state\":\"connected\",\"validated_at\":\"2026-08-20T21:11:05.501Z\",\"stale\":false,\"identity\":\"aaron\",\"entitlements\":[\"hpc\"],\"icon\":\"\",\"name\":\"Harmoniqs Cloud\"},{\"id\":\"pasqal-cloud\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Pasqal Cloud\"},{\"id\":\"slack\",\"state\":\"connected\",\"validated_at\":\"2026-08-20T21:11:05.501Z\",\"stale\":false,\"identity\":\"aaron@example\",\"icon\":\"\",\"name\":\"Slack\"},{\"id\":\"github\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"GitHub\"},{\"id\":\"linear\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Linear\"},{\"id\":\"google\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Google\",\"auth_methods\":[\"browser\"]},{\"id\":\"google-drive\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Google Drive\",\"auth_methods\":[\"browser\"]},{\"id\":\"custom-seed1\",\"state\":\"connected\",\"validated_at\":null,\"stale\":true,\"icon\":\"L\",\"name\":\"Lab QPU\"}],\"error\":null}" }, { "name": "connections catalog — configured filtered out", @@ -823,7 +823,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"connections\":[{\"id\":\"company-compute\",\"state\":\"connected\",\"validated_at\":\"2026-08-10T00:00:00Z\",\"stale\":true,\"identity\":\"aaron\",\"entitlements\":[\"hpc\"],\"offline\":true,\"icon\":\"\",\"name\":\"Harmoniqs Cloud\"},{\"id\":\"pasqal-cloud\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Pasqal Cloud\"},{\"id\":\"slack\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Slack\"},{\"id\":\"github\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"GitHub\"},{\"id\":\"linear\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Linear\"},{\"id\":\"google\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Google\",\"auth_methods\":[\"browser\"]},{\"id\":\"google-drive\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Google Drive\",\"auth_methods\":[\"browser\"]}],\"error\":null}" + "body": "{\"ok\":true,\"connections\":[{\"id\":\"company-compute\",\"state\":\"connected\",\"validated_at\":\"2026-08-20T21:11:05.501Z\",\"stale\":false,\"identity\":\"aaron\",\"entitlements\":[\"hpc\"],\"icon\":\"\",\"name\":\"Harmoniqs Cloud\"},{\"id\":\"pasqal-cloud\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Pasqal Cloud\"},{\"id\":\"slack\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Slack\"},{\"id\":\"github\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"GitHub\"},{\"id\":\"linear\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Linear\"},{\"id\":\"google\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Google\",\"auth_methods\":[\"browser\"]},{\"id\":\"google-drive\",\"state\":\"needs-key\",\"validated_at\":null,\"stale\":false,\"icon\":\"\",\"name\":\"Google Drive\",\"auth_methods\":[\"browser\"]}],\"error\":null}" }, { "name": "project create — mkdir (git absent, best-effort)", @@ -837,7 +837,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-JlOpLU/AmicodeProjects/my-new-project\",\"slug\":\"my-new-project\",\"gitInitialized\":false}" + "body": "{\"ok\":true,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-aHO0Nd/AmicodeProjects/my-new-project\",\"slug\":\"my-new-project\",\"gitInitialized\":false}" }, { "name": "project create — collision", @@ -891,7 +891,7 @@ "status": 200, "contentType": "application/json", "csp": null, - "body": "{\"ok\":true,\"parentDir\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-JlOpLU/AmicodeProjects\",\"projects\":[{\"slug\":\"my-new-project\",\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-JlOpLU/AmicodeProjects/my-new-project\"},{\"slug\":\"prior-project\",\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-JlOpLU/AmicodeProjects/prior-project\"}]}" + "body": "{\"ok\":true,\"parentDir\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-aHO0Nd/AmicodeProjects\",\"projects\":[{\"slug\":\"my-new-project\",\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-aHO0Nd/AmicodeProjects/my-new-project\"},{\"slug\":\"prior-project\",\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-aHO0Nd/AmicodeProjects/prior-project\"}]}" } ] }