From 1f4dbed361b7ba90ff7f40782fa799fbc745de97 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Mon, 14 Sep 2026 00:26:46 +0200 Subject: [PATCH 1/5] feat: #1106 the verb refreshes the stable projection cache + additive machine fields (P3b-2 commit 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @amicode/schema: fleetProjectionCachePath() + FLEET_PROJECTION_CACHE_RELPATH — the ONE definition of the consumers' cache convention (/.amico/ops/fleet/projection.json) - amico fleet status --projection: after the reader VALIDATES the published projection, it refreshes the stable cache (atomic tmp+rename; a rejected contract never clobbers it; the bootstrap exception (75) leaves it untouched) and the success JSON carries additive machine fields (role, canonical, cache_path) for script consumers - tests hermetic: grantedWorld defaults the cache into the tmp dir --- .../amico-run/src/fleet_projection_verb.ts | 60 ++++++++++++- .../test/fleet_projection_verb.test.ts | 86 ++++++++++++++++++- packages/schema/src/fleet_projection.ts | 17 ++++ packages/schema/src/index.ts | 2 + packages/schema/test/fleet_projection.test.ts | 20 +++++ 5 files changed, 180 insertions(+), 5 deletions(-) diff --git a/packages/amico-run/src/fleet_projection_verb.ts b/packages/amico-run/src/fleet_projection_verb.ts index 66b76dd7f..a2937aab2 100644 --- a/packages/amico-run/src/fleet_projection_verb.ts +++ b/packages/amico-run/src/fleet_projection_verb.ts @@ -39,6 +39,7 @@ import { freshnessBetween, readProjection, renderFleetStatus, + fleetProjectionCachePath, type FleetProjection, } from "@amicode/schema"; import { PREMIUM_CODE, readCodes } from "./premium.js"; @@ -72,6 +73,13 @@ export interface FleetProjectionDeps { readFile?: (p: string) => string | null; /** THE invocation seam (injectable): the publisher subprocess call. */ runPublisher?: (inv: PublisherInvocation) => PublisherResult; + /** #1106 (P3b-2): where the validated projection is cached for the + * consumers. Default: the stable convention path + * (`~/.amico/ops/fleet/projection.json` — the live-layout precedent). */ + cachePath?: string; + /** #1106: the cache write (injectable). Default: mkdir -p + atomic + * tmp+rename, mirroring the extension's writeFleetConfig discipline. */ + writeCache?: (p: string, content: string) => void; } function flagValue(argv: string[], name: string): string | undefined { @@ -135,14 +143,30 @@ function bootstrap(reason: "entitlement" | "checkout", rendered: string, extra: } /** A section's carried value, with the base default applied when the section - * is absent (mode absent = standalone, posture absent = ok — the projection - * contract's additive-optional discipline; the base default is applied, not - * invented: the reader's render states it in provenance). */ + * is absent (mode absent = standalone, posture absent = ok — the projection + * contract's additive-optional discipline; the base default is applied, not + * invented: the reader's render states it in provenance). */ function scalarOrBase(proj: FleetProjection, section: string, base: string): unknown { const s = proj.sections?.[section]; return s?.value === undefined ? base : s.value; } +/** A section's carried value when it is an object (topology), else undefined — + * absent stays absent, never an invented {} (#1106 machine fields). */ +function objectValue(proj: FleetProjection, section: string): Record | undefined { + const v = proj.sections?.[section]?.value; + if (v === null || typeof v !== "object" || Array.isArray(v)) return undefined; + return v as Record; +} + +/** A named object field of a carried value (topology.canonical), tolerantly. */ +function objectField(obj: Record | undefined, field: string): Record | undefined { + if (obj === undefined) return undefined; + const v = obj[field]; + if (v === null || typeof v !== "object" || Array.isArray(v)) return undefined; + return v as Record; +} + /** `amico fleet status --projection` — resolve the checkout (the premium * ladder), gate on the entitlement, invoke the publisher at the subprocess * seam, read the result through the ONE fleet projection reader, and print @@ -217,8 +241,10 @@ export function fleetProjectionStatus(argv: string[], deps: FleetProjectionDeps } let proj: FleetProjection; + let published: string; try { proj = readProjection(inv.outPath); + published = fs.readFileSync(inv.outPath, "utf8"); } catch (e) { // The reader's LOUD rejection surfaces verbatim — a versioned contract // refuses both directions, naming both versions (invariant 5). @@ -229,6 +255,28 @@ export function fleetProjectionStatus(argv: string[], deps: FleetProjectionDeps return fail([message], { checkout, out_path: inv.outPath }); } + // ── the stable projection-cache refresh (#1106, P3b-2) ── + // ONLY a projection the reader validated reaches the cache — a rejected + // contract version never clobbers the consumers' artifact. The cached + // bytes are the publisher's own output, verbatim. + const cachePath = deps.cachePath ?? fleetProjectionCachePath(); + const writeCache = + deps.writeCache ?? + ((p: string, content: string) => { + fs.mkdirSync(path.dirname(p), { recursive: true }); + const tmpFile = `${p}.tmp`; + fs.writeFileSync(tmpFile, content); + fs.renameSync(tmpFile, p); + }); + writeCache(cachePath, published); + + // ── the additive machine fields for script consumers (#1106) ── + // The installer (and any bash consumer) reads `role` + `canonical` from + // this JSON line instead of grepping the raw fleet.json; absent topology + // renders absent, never invented. + const topology = objectValue(proj, "topology"); + const canonical = objectField(topology, "canonical"); + const verdict = previous === null ? null : freshnessBetween(previous, proj); const advisory = verdict === null ? "" : freshnessAdvisory(verdict); const fresh = proj.freshness ?? {}; @@ -241,6 +289,9 @@ export function fleetProjectionStatus(argv: string[], deps: FleetProjectionDeps checkout, mode: scalarOrBase(proj, "mode", "standalone"), posture: scalarOrBase(proj, "posture", "ok"), + ...(topology === undefined ? {} : { role: topology.role }), + ...(canonical === undefined ? {} : { canonical }), + cache_path: cachePath, publisher: proj.publisher ?? {}, sections: proj.sections ?? {}, freshness: { @@ -250,7 +301,8 @@ export function fleetProjectionStatus(argv: string[], deps: FleetProjectionDeps }, summary: renderFleetStatus(proj, previous), note: "read through the ONE fleet projection reader (@amicode/schema fleet_projection, contract v" - + String(proj.contract_version) + ") — amicissimo parses and publishes, amicode consumes (spec §3 D1); provenance renders beside the data, never merged", + + String(proj.contract_version) + ") — amicissimo parses and publishes, amicode consumes (spec §3 D1); provenance renders beside the data, never merged; the validated projection is cached for the P3b-2 consumers at " + + cachePath, }, code: 0, }; diff --git a/packages/amico-run/test/fleet_projection_verb.test.ts b/packages/amico-run/test/fleet_projection_verb.test.ts index ee4153ff4..104db8b40 100644 --- a/packages/amico-run/test/fleet_projection_verb.test.ts +++ b/packages/amico-run/test/fleet_projection_verb.test.ts @@ -25,6 +25,9 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { fleetVerb } from "../src/fleet_verb.js"; import { fleetProjectionStatus, FLEET_BOOTSTRAP_EXIT, type FleetProjectionDeps } from "../src/fleet_projection_verb.js"; +import { fleetProjectionCachePath } from "@amicode/schema"; + +const E1 = "44444444-4444-4444-8444-444444444444"; // The committed fixture projection — a full document shaped on amicissimo's // Python publisher fixtures (client role → fleet mode, health + locks present). @@ -38,7 +41,9 @@ afterEach(() => rmSync(tmp, { recursive: true, force: true })); /** A hermetic world: an entitlements file carrying the `amicissimo` code, a * checkout dir, and a runPublisher that copies the fixture projection to the - * outPath the verb handed it — the real publisher's #414 contract, faked. */ + * outPath the verb handed it — the real publisher's #414 contract, faked. + * The #1106 cache defaults INTO THE TMP DIR — a suite run never touches the + * machine's real ~/.amico/ops/fleet/projection.json. */ function grantedWorld(over: Partial = {}, fixture: string = FIXTURE.pathname) { const entitlements = join(tmp, "entitlements.toml"); writeFileSync(entitlements, 'codes = ["amicissimo"]\n'); @@ -47,6 +52,7 @@ function grantedWorld(over: Partial = {}, fixture: string = const deps: FleetProjectionDeps = { readFile: (p) => (p === entitlements ? 'codes = ["amicissimo"]' : fixtureFileSafe(p, fixture)), checkDir: (p) => p === checkout, + cachePath: join(tmp, "hermetic-cache.json"), runPublisher: (inv) => { calls.push(inv); writeFileSync(inv.outPath, readFileSync(fixture, "utf8")); @@ -204,6 +210,84 @@ describe("the bootstrap exception (no entitlement / no checkout)", () => { }); }); +// ── the stable projection-cache convention (#1106, P3b-2) ────────────────────── + +describe("the stable projection-cache convention (#1106)", () => { + it("a successful status refreshes the cache at the known path with the published bytes", () => { + const w = grantedWorld(); + const cachePath = join(tmp, "ops", "fleet", "projection.json"); + const writes: Array<{ p: string; content: string }> = []; + const deps: FleetProjectionDeps = { ...w.deps, cachePath, writeCache: (p, content) => writes.push({ p, content }) }; + const r = run(["--checkout", w.checkout, "--config", w.entitlements], deps); + expect(r.code).toBe(0); + expect(writes).toHaveLength(1); + expect(writes[0].p).toBe(cachePath); + expect(writes[0].content).toBe(readFileSync(FIXTURE.pathname, "utf8")); // the published bytes, verbatim + }); + + it("the default cachePath is the live-layout convention (~/.amico/ops/fleet/projection.json), never guessed per-call", () => { + const w = grantedWorld(); + const writes: Array<{ p: string; content: string }> = []; + const { cachePath: _omit, ...rest } = w.deps; // hermetic default stays out — assert the convention path + const deps: FleetProjectionDeps = { ...rest, writeCache: (p, content) => writes.push({ p, content }) }; + const r = run(["--checkout", w.checkout, "--config", w.entitlements], deps); + expect(r.code).toBe(0); + expect(writes[0].p).toBe(fleetProjectionCachePath()); + }); + + it("only a projection the reader VALIDATED lands in the cache — a rejected contract version never clobbers it", () => { + const stale = join(tmp, "stale-projection.json"); + writeFileSync(stale, JSON.stringify({ schema_version: 1, contract_version: 2, sections: {} })); + const w = grantedWorld({}, stale); + const writes: Array<{ p: string; content: string }> = []; + const deps: FleetProjectionDeps = { ...w.deps, cachePath: join(tmp, "cache.json"), writeCache: (p, content) => writes.push({ p, content }) }; + const r = run(["--checkout", w.checkout, "--config", w.entitlements], deps); + expect(r.code).toBe(64); // the loud rejection + expect(writes).toHaveLength(0); // and the cache was never touched + }); + + it("the bootstrap exception (75) leaves the cache untouched — base-standalone is stated, not cached", () => { + const writes: Array<{ p: string; content: string }> = []; + const deps: FleetProjectionDeps = { + readFile: () => null, // no entitlements + checkDir: () => true, + writeCache: (p, content) => writes.push({ p, content }), + }; + const r = run(["--config", join(tmp, "entitlements.toml")], deps); + expect(r.code).toBe(FLEET_BOOTSTRAP_EXIT); + expect(writes).toHaveLength(0); + }); + + it("the success JSON carries additive machine fields for script consumers: role + canonical + cache_path", () => { + const w = grantedWorld(); + const cachePath = join(tmp, "cache.json"); + const deps: FleetProjectionDeps = { ...w.deps, cachePath, writeCache: () => {} }; + const r = run(["--checkout", w.checkout, "--config", w.entitlements], deps); + expect(r.code).toBe(0); + expect(r.json.role).toBe("client"); + expect(r.json.canonical).toMatchObject({ host: "hq-hub-01.example.internal", port: 4096, sshAlias: "hq-hub-01" }); + expect(r.json.cache_path).toBe(cachePath); + }); + + it("a projection without a topology section still succeeds and caches — the machine fields render absent, never invented", () => { + const bare = join(tmp, "bare-projection.json"); + writeFileSync(bare, JSON.stringify({ + schema_version: 1, + contract_version: 1, + publisher: { identity: "test", published_at: "2026-09-13T12:00:00Z" }, + freshness: { counter: 1, hub_epoch: E1 }, + sections: { mode: { value: "standalone" } }, + })); + const w = grantedWorld({}, bare); + const cachePath = join(tmp, "cache.json"); + const deps: FleetProjectionDeps = { ...w.deps, cachePath, writeCache: () => {} }; + const r = run(["--checkout", w.checkout, "--config", w.entitlements], deps); + expect(r.code).toBe(0); + expect(r.json.role).toBeUndefined(); + expect(r.json.canonical).toBeUndefined(); + }); +}); + // ── the router: --projection routes within `amico fleet status` ──────────────── describe("the fleet verb router", () => { diff --git a/packages/schema/src/fleet_projection.ts b/packages/schema/src/fleet_projection.ts index 036cd1b34..a9a5c0d3b 100644 --- a/packages/schema/src/fleet_projection.ts +++ b/packages/schema/src/fleet_projection.ts @@ -39,9 +39,26 @@ // established). Fleet-class IMPLEMENTATION stays amicissimo overlay content // (spec R1); a reader is a consumer. import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; export const FLEET_CONTRACT_VERSION = 1; +/** The stable projection-cache convention's path fragment (amicode#1106, fleet + * rearchitect P3b-2): the verb refreshes a published, contract-validated + * projection at `/.amico/ops/fleet/projection.json` — the live-layout + * precedent (beside fleet.json, which only amicissimo's ONE parser reads) — + * and every amicode consumer (extension, installer, guard) reads THAT + * artifact, never the raw file. Scripts without a TS runtime compose it from + * $HOME with this exact fragment. */ +export const FLEET_PROJECTION_CACHE_RELPATH = join(".amico", "ops", "fleet", "projection.json"); + +/** The cache path under a given home (default: the process home). ONE + * definition, consumed by the verb (writer) and the extension (reader). */ +export function fleetProjectionCachePath(home: string = homedir()): string { + return join(home, FLEET_PROJECTION_CACHE_RELPATH); +} + export const SUPPORTED_PROJECTION_SCHEMA_VERSIONS: readonly number[] = [1]; export const MODE_VOCABULARY = ["standalone", "fleet"] as const; diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index 404778af3..8074daf74 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -182,6 +182,8 @@ export { freshnessBetween, freshnessAdvisory, renderFleetStatus, + fleetProjectionCachePath, + FLEET_PROJECTION_CACHE_RELPATH, type FleetFreshness, type FleetProjection, type FleetProvenance, diff --git a/packages/schema/test/fleet_projection.test.ts b/packages/schema/test/fleet_projection.test.ts index 9780a9809..a17f070c3 100644 --- a/packages/schema/test/fleet_projection.test.ts +++ b/packages/schema/test/fleet_projection.test.ts @@ -26,6 +26,8 @@ import { describe, it, expect } from "vitest"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; +import { homedir } from "node:os"; +import { join } from "node:path"; import { FLEET_CONTRACT_VERSION, SUPPORTED_PROJECTION_SCHEMA_VERSIONS, @@ -36,6 +38,8 @@ import { freshnessBetween, freshnessAdvisory, renderFleetStatus, + fleetProjectionCachePath, + FLEET_PROJECTION_CACHE_RELPATH, type FleetProjection, } from "../src/fleet_projection.js"; @@ -270,3 +274,19 @@ describe("the reader never computes age from a wall clock (D1 source guard)", () expect(src).not.toMatch(/\bDate\b|\bnow\b|performance\.now|process\.hrtime/); }); }); + +// ── the stable projection-cache convention (#1106, fleet rearchitect P3b-2) ─── + +describe("the stable projection-cache path convention (#1106)", () => { + it("fleetProjectionCachePath resolves the live-layout precedent: /.amico/ops/fleet/projection.json", () => { + expect(fleetProjectionCachePath("/home/tester")).toBe("/home/tester/.amico/ops/fleet/projection.json"); + }); + + it("the default home is the process home — the ONE path every consumer (verb, extension, guard) reads", () => { + expect(fleetProjectionCachePath()).toBe(join(homedir(), ".amico", "ops", "fleet", "projection.json")); + }); + + it("the relpath constant is the documented convention (scripts and the extension compose it from $HOME)", () => { + expect(FLEET_PROJECTION_CACHE_RELPATH).toBe(join(".amico", "ops", "fleet", "projection.json")); + }); +}); From 88725f6b9f67d76b95142685d4b52c25db3d6e77 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Mon, 14 Sep 2026 00:28:46 +0200 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20#1106=20fleet=5Ftopology.ts=20?= =?UTF-8?q?=E2=80=94=20the=20extension's=20projection-cache=20read=20throu?= =?UTF-8?q?gh=20the=20ONE=20reader=20(P3b-2=20commit=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - readFleetTopology: the cached projection (~/.amico/ops/fleet/projection.json) read through @amicode/schema's reader verbatim — contract validation + base defaults + D1 epoch-bound freshness; absent/broken are NAMED rendered states (refresh pointer / the reader's loud rejection), never silent fallthrough to raw files - readFleetTopologyWithRefresh: the verb seam (amico fleet status --projection — the CLI is the only door); refresh on absent/broken/unknown-freshness; exit 75 and CLI-absent produce the IDENTICAL stated base-standalone bootstrap; a verb failure is surfaced as the honest non-bootstrap state it is --- packages/extension/src/fleet_topology.ts | 261 +++++++++++++++ .../extension/test/fleet_topology.test.ts | 304 ++++++++++++++++++ 2 files changed, 565 insertions(+) create mode 100644 packages/extension/src/fleet_topology.ts create mode 100644 packages/extension/test/fleet_topology.test.ts diff --git a/packages/extension/src/fleet_topology.ts b/packages/extension/src/fleet_topology.ts new file mode 100644 index 000000000..28ac27fbf --- /dev/null +++ b/packages/extension/src/fleet_topology.ts @@ -0,0 +1,261 @@ +// fleet_topology.ts — the extension's fleet-topology read (#1106, fleet +// rearchitect P3b-2; spec spec-20260913-114814 countermeasure row 1, §3 D1). +// +// THE ONE READ PATH: this machine's fleet topology is consumed from the +// verb-refreshed projection cache (`~/.amico/ops/fleet/projection.json` — +// the stable convention path, refreshed by `amico fleet status --projection`), +// read through @amicode/schema's fleet_projection reader VERBATIM — contract +// validation, base-default discipline, and D1 epoch-bound freshness all come +// from the reader. This module NEVER parses the raw `fleet.json` — amicissimo +// parses and publishes, amicode consumes (the spec's ONE-parser countermeasure; +// the raw file stays writable by the mode-machine flows in fleet_fallback.ts, +// which is a writer, never a parser). +// +// Absent / broken projections are RENDERED STATES, not error dumps and never a +// silent fallthrough to raw-file reading: `absent` carries the refresh pointer, +// `broken` carries the reader's own loud rejection verbatim. The refresh goes +// through the VERB (`amico fleet status --projection`) — the CLI is the only +// door; this module never invokes the Python publisher directly. Exit 75 is the +// bootstrap exception: base-standalone STATED with the grant pointer (never a +// mode write, spec invariant 7); a CLI-absent machine gets the IDENTICAL branch; +// a verb failure is a distinct honest state, never mislabeled as bootstrap. +// +// D1 freshness: the carried counter + hub-epoch render verbatim; the verdict +// exists only against a previous projection (freshnessBetween — the schema's +// ONE comparison). "unknown" (cross-epoch or rewind) forces a refetch through +// the verb AND surfaces — never a false-fresh badge, never a wall-clock age +// (this module holds no clock). +import { spawnSync } from "node:child_process"; +import { + FleetContractVersionError, + freshnessAdvisory, + freshnessBetween, + fleetProjectionCachePath, + readProjection, + type FleetFreshness, + type FleetProjection, +} from "@amicode/schema"; + +/** The documented cache-path fragment — the same string the bash consumers + * (guard, installer) compose from `$HOME`. The full default path comes from + * `@amicode/schema`'s `fleetProjectionCachePath()` — ONE definition. */ +export const FLEET_TOPOLOGY_CACHE_DEFAULT_HINT = ".amico/ops/fleet/projection.json"; + +/** The refresh verb — the CLI door every consumer uses. */ +export const FLEET_TOPOLOGY_REFRESH_COMMAND = "amico fleet status --projection"; + +/** A verb run: `code` null = the CLI itself was absent (ENOENT — the + * CLI-absent bootstrap branch); 75 = the bootstrap exception; 0 = the cache + * was refreshed; anything else = a verb failure (never bootstrap). */ +export interface VerbRunResult { + code: number | null; + stdout: string; + stderr: string; +} + +export interface FleetCanonical { + host?: string; + port?: number; + sshAlias?: string; +} + +/** The lawful read: topology + mode/posture + provenance + the carried + * freshness fields. `role` is the topology section's value VERBATIM (an + * out-of-vocabulary value surfaces, never remapped); a projection with no + * topology section is the base default (role = standalone) — honestly + * carried by the publisher, not invented here. */ +export interface FleetTopologyOk { + kind: "ok"; + role: string; + canonical?: FleetCanonical; + previousBinary?: string; + previousPort?: number; + mode: string; + posture: string; + /** The reader's freshness verdict, ONLY when a previous projection was + * supplied (freshnessBetween is the schema's one comparison — null + * previous = no verdict, carried fields only). */ + verdict?: FleetFreshness; + /** The surfaced advisory for the verdict ("" for fresh — no badge noise). */ + advisory?: string; + freshness: { counter?: unknown; hubEpoch?: unknown }; + /** The section's provenance source — metadata BESIDE the value, rendered. */ + provenanceSource: string; + /** The raw lawful projection (for freshnessBetween at the next read). */ + projection: FleetProjection; +} + +/** The cache artifact is absent — a RENDERED state: standalone-adjacent (the + * base default is the reader's discipline) but surfaced with the refresh + * pointer so a client machine is never silently mistaken for one. */ +export interface FleetTopologyAbsent { + kind: "absent"; + /** The honest statement + the refresh pointer (the verb, the CLI door). */ + detail: string; +} + +/** The cache artifact is present but failed the contract read — the reader's + * LOUD rejection carried verbatim (both versions named for a version + * mismatch), never an error dump, never a raw-file fallthrough. */ +export interface FleetTopologyBroken { + kind: "broken"; + detail: string; +} + +export type FleetTopologyState = FleetTopologyOk | FleetTopologyAbsent | FleetTopologyBroken; + +export interface ReadFleetTopologyOpts { + /** The cache artifact path (default: the stable convention path). */ + cachePath?: string; + /** The previous lawful projection — supplies the D1 freshness verdict. */ + previous?: FleetProjection | null; +} + +export function readFleetTopology(opts: ReadFleetTopologyOpts = {}): FleetTopologyState { + const cachePath = opts.cachePath ?? fleetProjectionCachePath(); + let proj: FleetProjection; + try { + proj = readProjection(cachePath); + } catch (e) { + if ((e as NodeJS.ErrnoException).code === "ENOENT") { + return { + kind: "absent", + detail: + `fleet projection absent at ${cachePath} — refresh it with \`${FLEET_TOPOLOGY_REFRESH_COMMAND}\` ` + + `(the CLI is the only door; the cache convention is /${FLEET_TOPOLOGY_CACHE_DEFAULT_HINT}; ` + + `the base default is standalone, stated here — never a silent raw-file read)`, + }; + } + const detail = + e instanceof FleetContractVersionError + ? `${e.message} (the cached projection at ${cachePath} speaks a contract this extension does not — refresh via \`${FLEET_TOPOLOGY_REFRESH_COMMAND}\` or re-clone)` + : `fleet projection at ${cachePath} failed the contract read: ${(e as Error).message} — refresh via \`${FLEET_TOPOLOGY_REFRESH_COMMAND}\``; + return { kind: "broken", detail }; + } + + const sections = proj.sections ?? {}; + const topologyValue = objectValue(proj, "topology"); + const modeSection = sections.mode; + const postureSection = sections.posture; + const mode = modeSection?.value === undefined ? "standalone" : String(modeSection.value); + const posture = postureSection?.value === undefined ? "ok" : String(postureSection.value); + + const verdict = opts.previous === undefined || opts.previous === null ? undefined : freshnessBetween(opts.previous, proj); + const advisory = verdict === undefined ? undefined : freshnessAdvisory(verdict) || undefined; + + const fresh = (proj.freshness ?? {}) as { counter?: unknown; hub_epoch?: unknown }; + return { + kind: "ok", + role: topologyValue === undefined ? "standalone" : String(topologyValue.role ?? "standalone"), + ...(canonicalOf(topologyValue) === undefined ? {} : { canonical: canonicalOf(topologyValue) }), + ...(topologyValue?.previousBinary === undefined ? {} : { previousBinary: String(topologyValue.previousBinary) }), + ...(topologyValue?.previousPort === undefined ? {} : { previousPort: Number(topologyValue.previousPort) }), + mode, + posture, + ...(verdict === undefined ? {} : { verdict, ...(advisory === undefined ? {} : { advisory }) }), + freshness: { counter: fresh.counter, hubEpoch: fresh.hub_epoch }, + provenanceSource: + topologyValue === undefined + ? String(sections.mode?.provenance?.source ?? "unknown") + : String(sections.topology?.provenance?.source ?? "unknown"), + projection: proj, + }; +} + +/** The refresh decision — the guard-parity read used at the live spawn + * decision points: read the cache; when absent or broken (or when D1 says + * unknown freshness vs the previous) refresh ONCE through the verb and + * re-read. The bootstrap exception (exit 75) and a CLI-absent verb produce + * the IDENTICAL stated base-standalone branch; a verb failure is surfaced as + * the honest non-bootstrap state it is. */ +export interface FleetTopologyDecision { + state: FleetTopologyState; + /** The verb refreshed the cache and the re-read succeeded. */ + refreshed: boolean; + /** Non-null ONLY for the bootstrap exception family (75 / CLI-absent). */ + bootstrap: null | { reason: "verb-75" | "cli-absent"; stated: string }; +} + +export interface ReadWithRefreshOpts extends ReadFleetTopologyOpts { + /** The verb seam (injectable; default: spawnSync `amico fleet status + * --projection` from PATH with a bounded timeout). */ + runVerb?: () => VerbRunResult; +} + +export function readFleetTopologyWithRefresh(opts: ReadWithRefreshOpts = {}): FleetTopologyDecision { + const first = readFleetTopology(opts); + const verdictUnknown = first.kind === "ok" && first.verdict === "unknown"; + if (first.kind === "ok" && !verdictUnknown) { + return { state: first, refreshed: false, bootstrap: null }; + } + + const runVerb = opts.runVerb ?? defaultRunVerb; + const result = runVerb(); + + if (result.code === 75) { + return { + state: first, + refreshed: false, + bootstrap: { + reason: "verb-75", + stated: + `base-standalone (bootstrap exception — the verb exited 75): this install does not hold the fleet-authority ` + + `grant, so no projection can be published. The mode field is untouched (spec invariant 7). ` + + `Base-standalone stated with the pointer: see the verb's rendered output for the grant path.\n${result.stdout.trim()}`, + }, + }; + } + if (result.code === null) { + return { + state: first, + refreshed: false, + bootstrap: { + reason: "cli-absent", + stated: + `base-standalone (bootstrap exception — the \`amico\` CLI is absent, so \`${FLEET_TOPOLOGY_REFRESH_COMMAND}\` ` + + `cannot run): identical to the exit-75 branch. The mode field is untouched (spec invariant 7).`, + }, + }; + } + if (result.code !== 0) { + const detail = + first.kind === "broken" + ? `${first.detail}\n and the refresh failed too (\`amico fleet status --projection\` exited ${result.code}): ${result.stderr.trim() || result.stdout.trim() || "(no output)"}` + : `fleet topology unavailable: the cache is ${first.kind === "absent" ? "absent" : "unreadable"} and the refresh (\`${FLEET_TOPOLOGY_REFRESH_COMMAND}\`) exited ${result.code}: ${result.stderr.trim() || result.stdout.trim() || "(no output)"}`; + return { state: { kind: "broken", detail }, refreshed: false, bootstrap: null }; + } + + const second = readFleetTopology(opts); + return { state: second, refreshed: second.kind === "ok", bootstrap: null }; +} + +/** The default verb seam: the CLI from PATH, bounded. `code` null = ENOENT. */ +function defaultRunVerb(): VerbRunResult { + const r = spawnSync("amico", ["fleet", "status", "--projection"], { encoding: "utf8", timeout: 60_000 }); + if (r.error) { + const code = (r.error as NodeJS.ErrnoException).code; + if (code === "ENOENT") return { code: null, stdout: "", stderr: "ENOENT" }; + return { code: -1, stdout: r.stdout ?? "", stderr: `${code ?? "error"}: ${r.error.message}` }; + } + return { code: r.status ?? -1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" }; +} + +// ── internals ───────────────────────────────────────────────────────────────── + +function objectValue(proj: FleetProjection, section: string): Record | undefined { + const v = proj.sections?.[section]?.value; + if (v === null || typeof v !== "object" || Array.isArray(v)) return undefined; + return v as Record; +} + +function canonicalOf(topology: Record | undefined): FleetCanonical | undefined { + if (topology === undefined) return undefined; + const c = topology.canonical; + if (c === null || typeof c !== "object" || Array.isArray(c)) return undefined; + const rec = c as Record; + const canonical: FleetCanonical = {}; + if (typeof rec.host === "string") canonical.host = rec.host; + if (typeof rec.port === "number") canonical.port = rec.port; + if (typeof rec.sshAlias === "string") canonical.sshAlias = rec.sshAlias; + return Object.keys(canonical).length > 0 ? canonical : undefined; +} diff --git a/packages/extension/test/fleet_topology.test.ts b/packages/extension/test/fleet_topology.test.ts new file mode 100644 index 000000000..0e8596c4b --- /dev/null +++ b/packages/extension/test/fleet_topology.test.ts @@ -0,0 +1,304 @@ +// fleet_topology.test.ts — the extension's fleet-topology read (#1106, fleet +// rearchitect P3b-2, spec spec-20260913-114814 row 1 / D1): the extension +// consumes the ONE fleet projection reader (@amicode/schema fleet_projection) +// over the verb-refreshed cache artifact (/.amico/ops/fleet/projection.json), +// NEVER the raw fleet.json — amicissimo parses and publishes, amicode consumes. +// +// The properties this suite defends: +// 1. THE READER IS THE GATE. Contract validation + base-default discipline +// come from @amicode/schema verbatim — this module adds no second format. +// 2. ABSENT / BROKEN ARE RENDERED STATES. A missing cache, a future +// contract_version, a corrupt artifact — each returns a NAMED state with +// an honest message + pointer, never a silent fallthrough to raw files, +// never an error dump, never an invented topology. +// 3. THE REFRESH GOES THROUGH THE VERB (the CLI is the only door). Absent +// or broken cache → `amico fleet status --projection` via an injectable +// seam; exit 75 = the bootstrap exception (base-standalone stated with +// the pointer); CLI-absent behaves identically; a verb FAILURE is a +// distinct honest state, never mislabeled bootstrap. +// 4. FRESHNESS SURFACES (D1). The carried counter/epoch render; a previous +// projection yields the verdict + advisory (unknown = force refetch + +// surface — the refresh fires). +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + readFleetTopology, + readFleetTopologyWithRefresh, + FLEET_TOPOLOGY_CACHE_DEFAULT_HINT, + type VerbRunResult, +} from "../src/fleet_topology"; + +let tmp: string; +let cachePath: string; +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "fleet-topology-")); + cachePath = join(tmp, "projection.json"); +}); +afterEach(() => rmSync(tmp, { recursive: true, force: true })); + +const EPOCH_A = "44444444-4444-4444-8444-444444444444"; +const EPOCH_B = "55555555-5555-4555-8555-555555555555"; + +function writeProjection(over: Record = {}, p: string = cachePath): void { + writeFileSync(p, JSON.stringify({ + schema_version: 1, + contract_version: 1, + publisher: { identity: "fleet_authority", published_at: "2026-09-13T12:00:00Z" }, + freshness: { counter: 7, hub_epoch: EPOCH_A }, + sections: { + mode: { value: "fleet", provenance: { source: "fleet.json", parsed_from: "role='client' (vocabulary mapping)" } }, + posture: { value: "ok", provenance: { source: "fleet-status.json", parsed_from: "base default (posture absent = ok)" } }, + topology: { + value: { + role: "client", + canonical: { host: "hq-hub-01.example.internal", port: 4096, sshAlias: "hq-hub-01" }, + previousBinary: "/home/example/.amico/server/bin/opencode", + previousPort: 4096, + }, + provenance: { source: "fleet.json", parsed_from: "topology schema v1 fields: role, canonical, previousBinary, previousPort" }, + }, + }, + ...over, + }, null, 2)); +} + +/** The mode-only projection: a machine with NO fleet.json — the publisher + * carries mode=standalone and no topology section (the base default, + * honestly carried, never invented). */ +function writeStandaloneProjection(p: string = cachePath): void { + writeFileSync(p, JSON.stringify({ + schema_version: 1, + contract_version: 1, + publisher: { identity: "fleet_authority", published_at: "2026-09-13T12:00:00Z" }, + freshness: { counter: 3, hub_epoch: EPOCH_A }, + sections: { mode: { value: "standalone", provenance: { source: "base default (mode absent = standalone)" } } }, + }, null, 2)); +} + +// ── the read: through the reader, states for everything else ────────────────── + +describe("readFleetTopology — the reader-consumed cache read", () => { + it("a lawful projection with topology → ok: role + canonical verbatim, provenance + freshness surfaced", () => { + writeProjection(); + const st = readFleetTopology({ cachePath }); + expect(st.kind).toBe("ok"); + if (st.kind !== "ok") return; + expect(st.role).toBe("client"); + expect(st.canonical).toMatchObject({ host: "hq-hub-01.example.internal", port: 4096, sshAlias: "hq-hub-01" }); + expect(st.mode).toBe("fleet"); + expect(st.posture).toBe("ok"); + expect(st.freshness.counter).toBe(7); + expect(String(st.freshness.hubEpoch)).toBe(EPOCH_A); + expect(st.provenanceSource).toBe("fleet.json"); // provenance renders, metadata beside the value + }); + + it("a projection without a topology section → ok with the base default role (standalone), honestly", () => { + writeStandaloneProjection(); + const st = readFleetTopology({ cachePath }); + expect(st.kind).toBe("ok"); + if (st.kind !== "ok") return; + expect(st.role).toBe("standalone"); + expect(st.canonical).toBeUndefined(); + expect(st.mode).toBe("standalone"); + }); + + it("absent cache → the NAMED absent state with the bootstrap pointer — never a crash, never silent standalone invention", () => { + const st = readFleetTopology({ cachePath }); + expect(st.kind).toBe("absent"); + if (st.kind !== "absent") return; + expect(st.detail).toMatch(/projection/i); + expect(st.detail).toContain(FLEET_TOPOLOGY_CACHE_DEFAULT_HINT); // the pointer: refresh via the verb + expect(st.detail).toMatch(/amico fleet status --projection/); + }); + + it("a future contract_version → the broken state carrying the reader's LOUD rejection (both versions named)", () => { + writeProjection({ contract_version: 2 }); + const st = readFleetTopology({ cachePath }); + expect(st.kind).toBe("broken"); + if (st.kind !== "broken") return; + expect(st.detail).toContain("v2"); + expect(st.detail).toContain("v1"); + expect(st.detail).toMatch(/refusing loudly/i); + }); + + it("a corrupt artifact (not JSON) → broken, honest, never a crash", () => { + writeFileSync(cachePath, "{ this is not json"); + const st = readFleetTopology({ cachePath }); + expect(st.kind).toBe("broken"); + if (st.kind !== "broken") return; + expect(st.detail.length).toBeGreaterThan(0); + }); + + it("an out-of-vocabulary role surfaces verbatim — surfaced, never silently remapped", () => { + writeProjection({ + sections: { + topology: { value: { role: "space-station" }, provenance: { source: "fleet.json" } }, + }, + }); + const st = readFleetTopology({ cachePath }); + expect(st.kind).toBe("ok"); + if (st.kind !== "ok") return; + expect(st.role).toBe("space-station"); + }); +}); + +// ── D1 freshness surfacing ───────────────────────────────────────────────────── + +describe("readFleetTopology — freshness surfaces (spec §3 D1)", () => { + it("no previous → no verdict, carried fields only (never a wall-clock age)", () => { + writeProjection(); + const st = readFleetTopology({ cachePath }); + expect(st.kind).toBe("ok"); + if (st.kind !== "ok") return; + expect(st.verdict).toBeUndefined(); + expect(JSON.stringify(st)).not.toMatch(/Date|now\(/i); // no local clock in the state + }); + + it("same counter vs the previous → the stale verdict + advisory", () => { + writeProjection(); + const first = readFleetTopology({ cachePath }); + if (first.kind !== "ok") throw new Error("fixture"); + const second = readFleetTopology({ cachePath, previous: first.projection }); + if (second.kind !== "ok") throw new Error("fixture"); + expect(second.verdict).toBe("stale"); + expect(second.advisory).toMatch(/nothing new was published/); + }); + + it("cross-epoch vs the previous → unknown + the force-refetch advisory (never a false-fresh badge)", () => { + writeProjection(); + const first = readFleetTopology({ cachePath }); + if (first.kind !== "ok") throw new Error("fixture"); + writeProjection({ freshness: { counter: 1, hub_epoch: EPOCH_B } }); + const second = readFleetTopology({ cachePath, previous: first.projection }); + if (second.kind !== "ok") throw new Error("fixture"); + expect(second.verdict).toBe("unknown"); + expect(second.advisory).toMatch(/force refetch/i); + }); +}); + +// ── the refresh seam: the CLI is the only door ──────────────────────────────── + +describe("readFleetTopologyWithRefresh — the verb seam (#1106)", () => { + it("an ok cache NEVER invokes the verb (the fast path — the guard-equivalent read stays cheap)", () => { + writeProjection(); + let invoked = 0; + const d = readFleetTopologyWithRefresh({ + cachePath, + runVerb: () => { + invoked += 1; + return { code: 0, stdout: "", stderr: "" }; + }, + }); + expect(invoked).toBe(0); + expect(d.state.kind).toBe("ok"); + expect(d.refreshed).toBe(false); + }); + + it("absent cache → refresh via the verb → re-read ok (the bootstrap path for already-enrolled machines)", () => { + let invoked = 0; + const d = readFleetTopologyWithRefresh({ + cachePath, + runVerb: () => { + invoked += 1; + writeProjection(); + return { code: 0, stdout: "{}", stderr: "" }; + }, + }); + expect(invoked).toBe(1); + expect(d.refreshed).toBe(true); + expect(d.state.kind).toBe("ok"); + if (d.state.kind !== "ok") return; + expect(d.state.role).toBe("client"); + }); + + it("broken cache → refresh too (a rejected artifact is repaired through the verb, never read raw)", () => { + writeProjection({ contract_version: 99 }); + let invoked = 0; + const d = readFleetTopologyWithRefresh({ + cachePath, + runVerb: () => { + invoked += 1; + writeProjection(); + return { code: 0, stdout: "{}", stderr: "" }; + }, + }); + expect(invoked).toBe(1); + expect(d.state.kind).toBe("ok"); + }); + + it("verb exit 75 → the bootstrap exception: base-standalone STATED with the pointer, cache untouched, not a mode write", () => { + writeProjection({ contract_version: 99 }); // broken cache + verb that cannot fix it + let invoked = 0; + const d = readFleetTopologyWithRefresh({ + cachePath, + runVerb: () => { + invoked += 1; + return { code: 75, stdout: "base-standalone (bootstrap exception) — grant path: ~/.amico/amicode/entitlements.toml", stderr: "" }; + }, + }); + expect(invoked).toBe(1); + expect(d.bootstrap).not.toBeNull(); + if (d.bootstrap === null) return; + expect(d.bootstrap.reason).toBe("verb-75"); + expect(d.bootstrap.stated).toMatch(/base-standalone/); + expect(d.bootstrap.stated).toMatch(/pointer|grant|checkout|entitlement/i); + expect(d.state.kind).toBe("broken"); // honest: the cache is still broken + }); + + it("CLI absent → the IDENTICAL bootstrap branch (stated base-standalone + pointer)", () => { + const absent: VerbRunResult = { code: null, stdout: "", stderr: "ENOENT" }; + const d = readFleetTopologyWithRefresh({ cachePath, runVerb: () => absent }); + expect(d.bootstrap).not.toBeNull(); + if (d.bootstrap === null) return; + expect(d.bootstrap.reason).toBe("cli-absent"); + expect(d.bootstrap.stated).toMatch(/base-standalone/); + expect(d.bootstrap.stated).toMatch(/amico fleet status --projection|CLI/i); + }); + + it("a verb FAILURE is NOT bootstrap — a distinct honest state, never mislabeled, never silent", () => { + writeProjection({ contract_version: 99 }); + const d = readFleetTopologyWithRefresh({ + cachePath, + runVerb: () => ({ code: 64, stdout: "", stderr: "publisher exploded" }), + }); + expect(d.bootstrap).toBeNull(); + expect(d.state.kind).toBe("broken"); + if (d.state.kind !== "broken") return; + expect(d.state.detail).toMatch(/publisher exploded|failed/i); + }); + + it("an unknown-freshness read vs the previous forces the refetch (D1: unknown → refresh + surface)", () => { + writeProjection(); + const first = readFleetTopology({ cachePath }); + if (first.kind !== "ok") throw new Error("fixture"); + writeProjection({ freshness: { counter: 1, hub_epoch: EPOCH_B } }); + let invoked = 0; + const d = readFleetTopologyWithRefresh({ + cachePath, + previous: first.projection, + runVerb: () => { + invoked += 1; + writeProjection({ freshness: { counter: 9, hub_epoch: EPOCH_B } }); + return { code: 0, stdout: "{}", stderr: "" }; + }, + }); + expect(invoked).toBe(1); // unknown freshness → the forced refetch fired + expect(d.refreshed).toBe(true); + expect(d.state.kind).toBe("ok"); + }); +}); + +// ── the cache-path convention ────────────────────────────────────────────────── + +describe("the cache-path convention", () => { + it("the default cachePath is the stable live-layout path (/.amico/ops/fleet/projection.json)", () => { + mkdirSync(join(tmp, "ops", "fleet"), { recursive: true }); + writeProjection({}, join(tmp, "ops", "fleet", "projection.json")); + // The default path derives from the process home — assert through the + // exported hint (the documented convention string) rather than mutating + // the real HOME: the hint IS the contract the scripts compose from $HOME. + expect(FLEET_TOPOLOGY_CACHE_DEFAULT_HINT).toBe(".amico/ops/fleet/projection.json"); + }); +}); From 4a0c155299f61d7217a828cf087a8346f06fc7b6 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Mon, 14 Sep 2026 00:36:14 +0200 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20#1106=20the=20extension=20consumes?= =?UTF-8?q?=20the=20projection=20=E2=80=94=20all=20call=20sites=20switch,?= =?UTF-8?q?=20freshness=20surfaces=20(P3b-2=20commit=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fleet_fallback.ts is now a WRITER only (the raw readers are GONE — the read path is fleet_topology's alone); migrateLegacyFallback/goStandalone paths injectable - fleet_health.ts: the four checks consume the FleetTopologyState — absent renders base-default standalone WITH the refresh pointer, broken is a rendered FAIL carrying the reader's rejection + the fix, the D1 verdict surfaces in the detail - extension.ts: isFleetClientGuard routes through readFleetTopologyWithRefresh (verb runner PATH-augmented with the launcher dir, the same resolution the server spawn uses); the status bar renders role + freshness badge + advisory; Go Standalone refreshes the projection cache through the verb after the write (the coherence rule); restart-hub + restart-server read the projection topology - terminal.ts: the AMICO_FLEET_STANDALONE hint flows through fleet_topology — the inline raw parse is gone; the legacy marker stays an existence probe - fleet_topology gains fleetConfigOf (hub flows) + verbRunnerWithPaths - new source-scan guard test: the extension module set parses no raw fleet config --- packages/extension/src/extension.ts | 133 ++++++++++++++---- packages/extension/src/fleet_fallback.ts | 88 +++++------- packages/extension/src/fleet_health.ts | 110 ++++++++++----- packages/extension/src/fleet_topology.ts | 44 +++++- packages/extension/src/terminal.ts | 14 +- .../extension/test/fleet_fallback.test.ts | 83 ++++++++--- packages/extension/test/fleet_health.test.ts | 122 +++++++++++++--- .../test/fleet_topology_single_parser.test.ts | 59 ++++++++ 8 files changed, 491 insertions(+), 162 deletions(-) create mode 100644 packages/extension/test/fleet_topology_single_parser.test.ts diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 05755f33d..4d417c9c8 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -66,7 +66,14 @@ import { } from "./substrate/julia_setup"; import { probeCommand, formatHealthReport, probeOpencodeTui, type HealthResult } from "./healthcheck"; import { fleetHealthReport, FLEET_GUARD_REL } from "./fleet_health"; -import { isFleetClient, getFleetRole, goStandalone, readFleetConfig, migrateLegacyFallback } from "./fleet_fallback"; +import { goStandalone, migrateLegacyFallback } from "./fleet_fallback"; +import { + readFleetTopology, + readFleetTopologyWithRefresh, + fleetConfigOf, + verbRunnerWithPaths, + type VerbRunResult, +} from "./fleet_topology"; import { resolveHubTarget, restartHub } from "./hub_ops"; import { registerAmicodeTerminal } from "./terminal"; import { amicodeServiceDisposal, startAmicodeService, frameOriginUrl } from "./amicode_service_wiring"; @@ -116,15 +123,47 @@ let fleetClientPoll: ReturnType | undefined; const DEVICE_POLL_MS = 2500; // mirror the RunsManager cadence -/** Fleet client detection — reads role from ~/.amico/ops/fleet/fleet.json. - * A fleet client (role="client" in fleet.json, guard installed) must NOT spawn - * a local server; it rides the tunnel. This check prevents the "opencode failed - * to start within 30s" storm when the guard correctly `exit 1`s. (#338) */ -function isFleetClientGuard(binary: string | undefined): boolean { +/** #1106: the fleet-projection verb runner — set once in activate() once the + * amico-run launcher dir is known (PATH-augmented exactly like the server + * spawn's PATH, so `amico` resolves in the extension host and an enrolled + * machine never misroutes to the CLI-absent branch). Undefined until then → + * the fleet_topology default (PATH-only). */ +let fleetVerbRunner: (() => VerbRunResult) | undefined; + +/** Fleet client detection — #1106 (P3b-2): the role comes from the + * verb-refreshed projection cache through @amicode/schema's reader + * (fleet_topology.ts), NEVER the raw fleet.json — amicissimo parses and + * publishes, amicode consumes. A fleet client (role="client", guard + * installed) must NOT spawn a local server; it rides the tunnel. This check + * prevents the "opencode failed to start within 30s" storm when the guard + * correctly `exit 1`s. (#338; the raw-read removal is #1106.) + * + * The projection read's rendered states surface to the caller's log: the + * bootstrap exception (exit 75 / CLI-absent — base-standalone STATED with + * the pointer, the mode field untouched), a broken cache (the reader's loud + * rejection + the refresh pointer), and the D1 freshness verdict each render + * — never a silent fallthrough, never a raw-file read. The GUARD remains the + * enforcement (it fails closed on a broken verb); this check is the UX layer. */ +function isFleetClientGuard(binary: string | undefined, log: (line: string) => void = () => {}): boolean { if (process.platform !== "darwin") return false; if (!binary || !binary.endsWith("amico-opencode-fleet-guard")) return false; - // Role from fleet.json — "client" means ride the tunnel, anything else means spawn locally - return isFleetClient(); + const decision = readFleetTopologyWithRefresh({ runVerb: fleetVerbRunner }); + if (decision.state.kind === "ok" && decision.state.verdict !== undefined) { + log(`[fleet] projection freshness: ${decision.state.verdict}${decision.state.advisory === undefined ? "" : ` — ${decision.state.advisory}`}`); + } + if (decision.bootstrap !== null) { + log(`[fleet] ${decision.bootstrap.stated}`); + return false; // the base-standalone floor: spawn locally (stated, not silent) + } + if (decision.state.kind === "broken") { + log(`[fleet] ${decision.state.detail}`); + return false; // honest degraded spawn — the guard still enforces client refusal + } + if (decision.state.kind === "absent") { + log(`[fleet] ${decision.state.detail}`); + return false; + } + return decision.state.role === "client"; } /** #398 (slice 4e): the fleet activation config, read from the workspace @@ -486,6 +525,9 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // 3. opencode project bootstrap const amicoRunBinDir = resolveAmicoRunBinDir(ctx.extensionPath); + // #1106: the fleet-projection refresh resolves `amico` the same way the + // server spawn does — PATH prepended with the launcher dir. + fleetVerbRunner = verbRunnerWithPaths(amicoRunBinDir === undefined ? [] : [amicoRunBinDir]); // Configured skill-index overrides (spec-20260704-113005 §3) — an empty/unset // array falls through to the module defaults (undefined → the `??` default). const cfgArr = (key: string): string[] | undefined => { @@ -652,13 +694,14 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { }); ctx.subscriptions.push({ dispose: () => unregisterBugReport() }); - // Fleet client: guard would `exit 1` on this host (role=client in fleet.json) — - // don't spawn and storm "opencode failed to start within 30s". - // Ride the tunnel instead; "Go Standalone" switches to local mode permanently. - const fleetClient = isFleetClientGuard(binary); + // Fleet client: guard would `exit 1` on this host (role=client in the + // projection topology) — don't spawn and storm "opencode failed to start + // within 30s". Ride the tunnel instead; "Go Standalone" switches to local + // mode permanently. (#338; the projection read is #1106.) + const fleetClient = isFleetClientGuard(binary, (line) => opencodeChannel.appendLine(line)); if (binary !== undefined && fleetClient) { - const fleetCfg = readFleetConfig(); - const fleetPort = fleetCfg?.canonical?.port ?? 4096; + const topology = readFleetTopology(); + const fleetPort = topology.kind === "ok" ? (topology.canonical?.port ?? 4096) : 4096; opencodeChannel.appendLine(`[fleet] client mode — guard ${binary} would refuse on ${os.hostname()} — riding tunnel 127.0.0.1:${fleetPort}`); opencodeChannel.appendLine(`[fleet] hint: canonical offline? Palette → Amicode: Fleet — Go Standalone`); // Distiller still arms on the client (uses vendored binary directly, not the guard) @@ -1452,29 +1495,41 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { registerOpencodeUpdater(ctx, opencodeChannel); // Fleet mode — "Go Standalone" per CONTEXT.md (#338). - // Config: ~/.amico/ops/fleet/fleet.json (no file = standalone). + // Config: ~/.amico/ops/fleet/fleet.json (no file = standalone) — the WRITER's + // destination; every READ flows through the projection cache (#1106). // Migrate legacy fallback.json on activation. migrateLegacyFallback(); const fleetStatusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 99); ctx.subscriptions.push(fleetStatusItem); + /** #1106: the status bar renders the projection topology — role, canonical + * target, and the D1 freshness verdict SURFACE here (stale/unknown get a + * badge; unknown means force-refetch, and a fresh read with no previous + * renders no badge — no noise). */ + let lastFleetProjection: ReturnType | undefined; const refreshFleetStatus = (): void => { - const role = getFleetRole(); - if (role === "client") { - const cfg = readFleetConfig(); - fleetStatusItem.text = "$(cloud) Fleet: client"; - fleetStatusItem.tooltip = `Fleet client → ${cfg?.canonical?.host ?? "unknown"}:${cfg?.canonical?.port ?? 4096}`; + const previous = lastFleetProjection !== undefined && lastFleetProjection.kind === "ok" ? lastFleetProjection.projection : null; + const state = readFleetTopology({ previous }); + lastFleetProjection = state; + if (state.kind === "ok" && state.role === "client") { + const badge = state.verdict === undefined || state.verdict === "fresh" ? "" : ` (${state.verdict})`; + fleetStatusItem.text = `$(cloud) Fleet: client${badge}`; + fleetStatusItem.tooltip = `Fleet client → ${state.canonical?.host ?? "unknown"}:${state.canonical?.port ?? 4096}` + + (state.advisory === undefined ? "" : `\n${state.advisory}`); fleetStatusItem.command = "amicode.fleet.goStandalone"; fleetStatusItem.show(); } else { + if (state.kind === "broken") { + opencodeChannel.appendLine(`[fleet] ${state.detail}`); + } fleetStatusItem.hide(); } }; refreshFleetStatus(); const runFleetGoStandalone = async (): Promise => { - const role = getFleetRole(); - if (role === "standalone") { + const topology = readFleetTopology(); + if (topology.kind === "ok" && topology.role === "standalone") { void vscode.window.showInformationMessage("Amicode: already in standalone mode (local server)."); return; } @@ -1489,6 +1544,23 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { const prevBinary = cfg.get("opencodeBinary", ""); const prevPort = cfg.get("opencodePort", 0); goStandalone({ previousBinary: prevBinary, previousPort: prevPort }); + // #1106: the write changed the file amicissimo's ONE parser reads — refresh + // the projection cache through the verb NOW so the guard + status bar + health + // checks see role=standalone immediately (the coherence rule: every fleet.json + // writer is followed by a cache refresh). A failed refresh is stated, never + // silent: until it lands, the consumers' cache still shows the old role. + try { + const refreshed = readFleetTopologyWithRefresh({ runVerb: fleetVerbRunner }); + if (refreshed.state.kind === "ok" && refreshed.state.role === "standalone") { + opencodeChannel.appendLine("[fleet] go standalone: projection cache refreshed (role=standalone)"); + } else if (refreshed.bootstrap !== null) { + opencodeChannel.appendLine(`[fleet] go standalone: projection refresh bootstrapped — ${refreshed.bootstrap.stated}`); + } else { + opencodeChannel.appendLine(`[fleet] go standalone: projection refresh did not confirm standalone — ${refreshed.state.kind === "broken" ? refreshed.state.detail : `role=${refreshed.state.kind === "ok" ? refreshed.state.role : "unknown"}`}`); + } + } catch (e) { + opencodeChannel.appendLine(`[fleet] go standalone: projection refresh failed — ${(e as Error).message}`); + } try { // Clear the fleet guard override → vendored binary, ephemeral port await cfg.update("opencodeBinary", "", vscode.ConfigurationTarget.Global); @@ -1579,13 +1651,20 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // from the CLIENT's extension host, which is not hosted on the hub, so the // restart can never kill its own runtime — the 2026-08-30 self-host trap. const runRestartHub = async (): Promise => { - if (getFleetRole() !== "client") { + // #1106: the client check + hub target come from the projection topology + // (the ONE read path) — a broken/absent projection renders honestly here. + const topology = readFleetTopology(); + if (topology.kind === "broken") { + void vscode.window.showErrorMessage(`Amicode: fleet projection is broken — ${topology.detail}`); + return; + } + if (topology.kind !== "ok" || topology.role !== "client") { void vscode.window.showInformationMessage( "Amicode: this machine is not a fleet client — use 'Amicode: Restart opencode server' for the local server.", ); return; } - const target = resolveHubTarget(readFleetConfig()); + const target = resolveHubTarget(fleetConfigOf(topology)); if (!target) { void vscode.window.showErrorMessage("Amicode: fleet config has no canonical sshAlias — run 'Amicode: Fleet Repair'."); return; @@ -2048,9 +2127,9 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // clears the "Something went wrong" state instead of being a no-op. opencodeChannel.appendLine(`[boot] restart requested`); // Fleet client: no local server to restart — just re-probe the tunnel - if (binary !== undefined && isFleetClientGuard(binary)) { - const fleetCfgRestart = readFleetConfig(); - const restartPort = fleetCfgRestart?.canonical?.port ?? 4096; + if (binary !== undefined && isFleetClientGuard(binary, (line) => opencodeChannel.appendLine(line))) { + const topologyRestart = readFleetTopology(); + const restartPort = topologyRestart.kind === "ok" ? (topologyRestart.canonical?.port ?? 4096) : 4096; opencodeChannel.appendLine(`[fleet] client restart — re-probing tunnel 127.0.0.1:${restartPort}`); statusBar?.setServerReady(false); opencodeReadyUrl = undefined; diff --git a/packages/extension/src/fleet_fallback.ts b/packages/extension/src/fleet_fallback.ts index ce4cbc360..b16e232f4 100644 --- a/packages/extension/src/fleet_fallback.ts +++ b/packages/extension/src/fleet_fallback.ts @@ -1,14 +1,28 @@ -// Fleet configuration — the single source of truth for fleet role and topology. -// Config file: ~/.amico/ops/fleet/fleet.json +// Fleet configuration — the WRITER for the raw fleet.json (#1106, fleet +// rearchitect P3b-2, spec spec-20260913-114814 row 1). +// +// On-disk file: ~/.amico/ops/fleet/fleet.json // { "role": "standalone"|"server"|"client", "canonical": { "host": "...", "port": 4096, "sshAlias": "..." } } -// No file = standalone (safe zero-config default). +// No file = standalone (safe zero-config default). +// +// As of #1106 this module is a WRITER, never a parser: amicissimo's fleet +// authority owns the ONE parser for this file (behind the `amico fleet` CLI), +// and every amicode-side READ goes through the verb-refreshed projection cache +// (~/.amico/ops/fleet/projection.json) via fleet_topology.ts + @amicode/ +// schema's reader. The write side stays exactly where it was because the mode +// flows (Go Standalone, legacy migration) mutate the machine-local membership +// record the ONE parser reads — the on-disk shape below is the parser's +// contract and must round-trip byte-for-parseable. // -// "Go Standalone" (CONTEXT.md): the user-invoked mode switch from client to standalone. -// The machine leaves the fleet and serves itself permanently. Not an escape hatch — -// a first-class choice. Re-enrollment (joining a fleet) is a separate flow. +// "Go Standalone" (CONTEXT.md): the user-invoked mode switch from client to +// standalone. The machine leaves the fleet and serves itself permanently. Not +// an escape hatch — a first-class choice. Re-enrollment (joining a fleet) is a +// separate flow. Every writer here is followed at the CALL-SITE by a +// projection-cache refresh through the verb (`amico fleet status +// --projection`), so the consumers' cache stays coherent with the raw file. // -// Legacy: the old fallback.json marker is migrated to fleet.json on first read. -// (harmoniqs/amicode#338) +// Legacy: the old fallback.json marker is migrated to fleet.json on activation +// (harmoniqs/amicode#338). import * as fs from "node:fs"; import * as path from "node:path"; @@ -31,32 +45,6 @@ export interface FleetConfig { previousPort?: number; } -/** Read fleet config from disk. No file = null (treated as standalone by callers). */ -export function readFleetConfig( - p: string = FLEET_CONFIG_PATH, - read: (path: string) => string = (pp) => fs.readFileSync(pp, "utf8"), -): FleetConfig | null { - try { - const raw = read(p); - const j = JSON.parse(raw) as FleetConfig; - if (j && typeof j.role === "string") return j; - return null; - } catch { - return null; - } -} - -/** Get the effective fleet role. No config = standalone. */ -export function getFleetRole(p: string = FLEET_CONFIG_PATH, read?: (path: string) => string): "standalone" | "server" | "client" { - const cfg = readFleetConfig(p, read); - return cfg?.role ?? "standalone"; -} - -/** Is this machine a fleet client? (role = "client" in fleet.json) */ -export function isFleetClient(p: string = FLEET_CONFIG_PATH, read?: (path: string) => string): boolean { - return getFleetRole(p, read) === "client"; -} - /** Write fleet config atomically (tmp + rename). */ export function writeFleetConfig(config: FleetConfig, p: string = FLEET_CONFIG_PATH): void { fs.mkdirSync(path.dirname(p), { recursive: true }); @@ -66,8 +54,9 @@ export function writeFleetConfig(config: FleetConfig, p: string = FLEET_CONFIG_P } /** Go Standalone: write role=standalone to fleet.json. Preserves previous settings for - * potential re-enrollment. Removes legacy fallback.json if present. */ -export function goStandalone(opts: { previousBinary?: string; previousPort?: number; path?: string } = {}): FleetConfig { + * potential re-enrollment. Removes legacy fallback.json if present. Paths + * injectable for tests. */ +export function goStandalone(opts: { previousBinary?: string; previousPort?: number; path?: string; legacyPath?: string } = {}): FleetConfig { const p = opts.path ?? FLEET_CONFIG_PATH; const config: FleetConfig = { role: "standalone", @@ -76,7 +65,7 @@ export function goStandalone(opts: { previousBinary?: string; previousPort?: num }; writeFleetConfig(config, p); // Remove legacy fallback.json if present - try { fs.unlinkSync(LEGACY_FALLBACK_PATH); } catch {} + try { fs.unlinkSync(opts.legacyPath ?? LEGACY_FALLBACK_PATH); } catch {} return config; } @@ -91,26 +80,19 @@ export function removeFleetConfig(p: string = FLEET_CONFIG_PATH): void { } catch {} } -/** Get the canonical server port from fleet config (default 4096). */ -export function getCanonicalPort(p: string = FLEET_CONFIG_PATH, read?: (path: string) => string): number { - const cfg = readFleetConfig(p, read); - return cfg?.canonical?.port ?? 4096; -} - // ── Legacy compatibility ──────────────────────────────────────────────────── // The old fallback.json marker is treated as role=standalone for the guard. -// Extension code that formerly called isFallbackActive now calls isFleetClient -// (inverted logic: old fallback=active meant "allow spawn"; new client=true -// means "refuse spawn"). This section provides the migration bridge. +// Extension code that formerly called isFallbackActive now reads the +// projection topology via fleet_topology.ts; this section is only the +// one-time marker migration (an existence probe + a write — never a parse). /** Migrate legacy fallback.json → fleet.json if fallback.json exists but fleet.json doesn't. - * Called once at extension activation. */ -export function migrateLegacyFallback(): void { - if (fs.existsSync(LEGACY_FALLBACK_PATH) && !fs.existsSync(FLEET_CONFIG_PATH)) { + * Called once at extension activation. Paths injectable for tests. */ +export function migrateLegacyFallback(opts: { legacyPath?: string; configPath?: string } = {}): void { + const legacyPath = opts.legacyPath ?? LEGACY_FALLBACK_PATH; + const configPath = opts.configPath ?? FLEET_CONFIG_PATH; + if (fs.existsSync(legacyPath) && !fs.existsSync(configPath)) { // Legacy fallback was active = the machine was in standalone mode - goStandalone(); + goStandalone({ path: configPath, legacyPath }); } } - -// Re-export the config path for fleet_health.ts and other consumers -export { FLEET_CONFIG_PATH as FALLBACK_PATH }; // backward compat for any remaining import diff --git a/packages/extension/src/fleet_health.ts b/packages/extension/src/fleet_health.ts index ab785216c..f2805b216 100644 --- a/packages/extension/src/fleet_health.ts +++ b/packages/extension/src/fleet_health.ts @@ -1,19 +1,25 @@ // Fleet health — pure, testable checks that prevent the 2026-08-07 / 2026-08-09 // silent-fork regressions (ADR 0005, #279, #324, #338) from recurring. // -// Every check is synchronous + injectable (no direct fs/exec) so it is -// unit-testable and never blocks activation. The extension's `amicode.healthcheck` -// and activation warning call these; `tools/fleet/install.sh --check` is the CLI twin. +// Every check is synchronous + injectable (no direct fs/exec beyond the +// injectable defaults) so it is unit-testable and never blocks activation. The +// extension's `amicode.healthcheck` and activation warning call these; +// `tools/fleet/install.sh --check` is the CLI twin. // -// Fleet role is determined by ~/.amico/ops/fleet/fleet.json (no file = standalone). -// A fleet client must NEVER spawn a local opencode server. The guard -// `tools/fleet/amico-opencode-fleet-guard` enforces it by reading fleet.json and -// `exit 1` when role = "client". +// #1106 (fleet rearchitect P3b-2): fleet role + canonical port come from the +// projection-cache topology state (fleet_topology.ts — @amicode/schema's +// reader over ~/.amico/ops/fleet/projection.json), NEVER the raw fleet.json. +// Absent projection renders the base-default standalone WITH the refresh +// pointer; a broken projection is a RENDERED fail state carrying the reader's +// rejection + the refresh fix — neither is ever a silent raw-file fallthrough. +// A fleet client must NEVER spawn a local opencode server; the guard +// `tools/fleet/amico-opencode-fleet-guard` enforces it by reading the +// projection cache and `exit 1` when role = "client". import * as fs from "node:fs"; import * as path from "node:path"; import { homedir } from "node:os"; -import { readFleetConfig, type FleetConfig } from "./fleet_fallback"; +import { readFleetTopology, type FleetTopologyState } from "./fleet_topology"; export const FLEET_GUARD_REL = "tools/fleet/amico-opencode-fleet-guard"; export const FLEET_GUARD_INSTALL = path.join(homedir(), ".local", "bin", "amico-opencode-fleet-guard"); @@ -65,17 +71,24 @@ export function checkFleetGuard( return { name: "Fleet guard", ok: true, detail: `installed and in sync (${installedGuardPath})` }; } -/** Settings check: amicode.opencodeBinary must point at the guard and opencodePort must match fleet config. */ +/** The canonical port the settings/tunnel checks compare against — from the + * projection topology (base default 4096 when the topology carries none). */ +function fleetPort(topology: FleetTopologyState | undefined): number { + if (topology === undefined || topology.kind !== "ok") return 4096; + return topology.canonical?.port ?? 4096; +} + +/** Settings check: amicode.opencodeBinary must point at the guard and opencodePort must match the projection's canonical port. */ export function checkFleetSettings( configuredBinary: string, configuredPort: number, - opts: { platform?: string; fleetConfig?: FleetConfig | null } = {}, + opts: { platform?: string; topology?: FleetTopologyState } = {}, ): FleetCheck { if ((opts.platform ?? process.platform) !== "darwin") { return { name: "Fleet settings", ok: true, detail: "skipped (not darwin)" }; } - const cfg = opts.fleetConfig ?? readFleetConfig(); - const wantPort = cfg?.canonical?.port ?? 4096; + const topology = opts.topology ?? readFleetTopology(); + const wantPort = fleetPort(topology); const wantBinary = FLEET_GUARD_INSTALL; // Empty binary = vendored default → on a fleet client this would spawn a fork, so flag it. if (!configuredBinary || configuredBinary.trim() === "") { @@ -111,7 +124,7 @@ export function checkFleetSettings( /** Tunnel plist check: ServerAliveInterval 15, CountMax 2, TCPKeepAlive yes, correct port forward. */ export function checkFleetTunnel( plistContent: string | null, - opts: { platform?: string; fleetConfig?: FleetConfig | null } = {}, + opts: { platform?: string; topology?: FleetTopologyState } = {}, ): FleetCheck { if ((opts.platform ?? process.platform) !== "darwin") { return { name: "Fleet tunnel", ok: true, detail: "skipped (not darwin)" }; @@ -124,8 +137,8 @@ export function checkFleetTunnel( fix: "bash tools/fleet/install.sh (installs tunnel plist)", }; } - const cfg = opts.fleetConfig ?? readFleetConfig(); - const port = cfg?.canonical?.port ?? 4096; + const topology = opts.topology ?? readFleetTopology(); + const port = fleetPort(topology); const portForward = `127.0.0.1:${port}:127.0.0.1:${port}`; const issues: string[] = []; @@ -151,29 +164,58 @@ export function checkFleetTunnel( return { name: "Fleet tunnel", ok: true, detail: `ServerAlive 15/2 + TCPKeepAlive, ${port} forward` }; } -/** Fleet role check — surfaces the current mode. */ +/** Fleet role check — surfaces the current mode from the projection topology. + * Every state renders honestly (#1106): ok → the role + canonical target + + * the D1 freshness verdict when present; absent → the base-default + * standalone WITH the refresh pointer; broken → a rendered fail carrying + * the reader's rejection + the refresh fix. */ export function checkFleetRole( - opts: { read?: (p: string) => string; platform?: string } = {}, + opts: { topology?: FleetTopologyState; platform?: string } = {}, ): FleetCheck { if ((opts.platform ?? process.platform) !== "darwin") { return { name: "Fleet role", ok: true, detail: "skipped (not darwin)" }; } - const cfg = readFleetConfig(undefined, opts.read); - const role = cfg?.role ?? "standalone"; + const topology = opts.topology ?? readFleetTopology(); + + if (topology.kind === "absent") { + return { + name: "Fleet role", + ok: true, + detail: `standalone (no fleet projection — base default; ${topology.detail})`, + }; + } + if (topology.kind === "broken") { + return { + name: "Fleet role", + ok: false, + detail: topology.detail, + fix: "refresh the projection: `amico fleet status --projection` (the CLI is the only door)", + }; + } + const role = topology.role; + const freshness = topology.verdict === undefined + ? "" + : ` — freshness: ${topology.verdict}${topology.advisory === undefined ? "" : ` (${topology.advisory})`}`; if (role === "standalone") { - return { name: "Fleet role", ok: true, detail: "standalone (local server, no fleet)" }; + return { name: "Fleet role", ok: true, detail: `standalone (local server, no fleet)${freshness}` }; } if (role === "server") { - return { name: "Fleet role", ok: true, detail: `server (canonical for fleet, host: ${cfg?.canonical?.host ?? "unknown"})` }; + return { name: "Fleet role", ok: true, detail: `server (canonical for fleet, host: ${topology.canonical?.host ?? "unknown"})${freshness}` }; } if (role === "client") { - return { name: "Fleet role", ok: true, detail: `client → ${cfg?.canonical?.host ?? "unknown"}:${cfg?.canonical?.port ?? 4096} via ${cfg?.canonical?.sshAlias ?? "ssh"}` }; + return { + name: "Fleet role", + ok: true, + detail: `client → ${topology.canonical?.host ?? "unknown"}:${topology.canonical?.port ?? 4096} via ${topology.canonical?.sshAlias ?? "ssh"}${freshness}`, + }; } - return { name: "Fleet role", ok: false, detail: `unknown role: ${role}`, fix: "check ~/.amico/ops/fleet/fleet.json" }; + return { name: "Fleet role", ok: false, detail: `unknown role: ${role}`, fix: "refresh the projection: `amico fleet status --projection`" }; } /** Aggregate helper — returns all fleet checks (role + guard + settings + tunnel). - * When role is standalone, guard/settings/tunnel checks are skipped (not relevant). */ + * When role is standalone (or the projection is absent — the same base + * default), guard/settings/tunnel checks are skipped (not relevant); a + * broken projection surfaces ONLY the role check's rendered fail. */ export function fleetHealthReport(args: { repoGuardPath: string; installedGuardPath?: string; @@ -183,21 +225,25 @@ export function fleetHealthReport(args: { read?: (p: string) => string; isExecutable?: (p: string) => boolean; platform?: string; + /** #1106: the projection topology state (injectable; default: readFleetTopology()). */ + topology?: FleetTopologyState; }): FleetCheck[] { - const cfg = readFleetConfig(undefined, args.read); - const role = cfg?.role ?? "standalone"; + const topology = args.topology ?? readFleetTopology(); + const role = topology.kind === "ok" ? topology.role : "standalone"; - // Standalone: fleet checks are irrelevant — just surface the role. - if (role === "standalone" && (args.platform ?? process.platform) === "darwin") { + // Standalone (or absent projection — the base default): fleet checks are + // irrelevant — just surface the role. A BROKEN projection also returns only + // the role check, as its rendered fail. + if ((role === "standalone" || topology.kind !== "ok") && (args.platform ?? process.platform) === "darwin") { return [ - checkFleetRole({ read: args.read, platform: args.platform }), + checkFleetRole({ topology, platform: args.platform }), ]; } return [ - checkFleetRole({ read: args.read, platform: args.platform }), + checkFleetRole({ topology, platform: args.platform }), checkFleetGuard(args.repoGuardPath, args.installedGuardPath, { read: args.read, isExecutable: args.isExecutable, platform: args.platform }), - checkFleetSettings(args.configuredBinary, args.configuredPort, { platform: args.platform, fleetConfig: cfg }), - checkFleetTunnel(args.plistContent, { platform: args.platform, fleetConfig: cfg }), + checkFleetSettings(args.configuredBinary, args.configuredPort, { platform: args.platform, topology }), + checkFleetTunnel(args.plistContent, { platform: args.platform, topology }), ]; } diff --git a/packages/extension/src/fleet_topology.ts b/packages/extension/src/fleet_topology.ts index 28ac27fbf..b5878e685 100644 --- a/packages/extension/src/fleet_topology.ts +++ b/packages/extension/src/fleet_topology.ts @@ -6,10 +6,10 @@ // the stable convention path, refreshed by `amico fleet status --projection`), // read through @amicode/schema's fleet_projection reader VERBATIM — contract // validation, base-default discipline, and D1 epoch-bound freshness all come -// from the reader. This module NEVER parses the raw `fleet.json` — amicissimo -// parses and publishes, amicode consumes (the spec's ONE-parser countermeasure; -// the raw file stays writable by the mode-machine flows in fleet_fallback.ts, -// which is a writer, never a parser). +// from the reader. This module NEVER parses the machine-local fleet config +// file — amicissimo parses and publishes, amicode consumes (the spec's +// ONE-parser countermeasure; that file stays writable by the mode flows in +// fleet_fallback.ts, which is a writer, never a parser). // // Absent / broken projections are RENDERED STATES, not error dumps and never a // silent fallthrough to raw-file reading: `absent` carries the refresh pointer, @@ -240,6 +240,42 @@ function defaultRunVerb(): VerbRunResult { return { code: r.status ?? -1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" }; } +/** A verb runner with extra PATH entries (the extension host's `amico` may + * not be on the ambient PATH — the amico-run launcher dir is prepended the + * same way the server spawn's PATH is, so an enrolled machine never gets + * misrouted to the CLI-absent branch). */ +export function verbRunnerWithPaths(extraPaths: string[], timeoutMs = 30_000): () => VerbRunResult { + return () => { + const env = { ...process.env }; + const base = env.PATH ?? ""; + env.PATH = [...extraPaths, base].filter((p) => p !== "").join(":"); + const r = spawnSync("amico", ["fleet", "status", "--projection"], { env, encoding: "utf8", timeout: timeoutMs }); + if (r.error) { + const code = (r.error as NodeJS.ErrnoException).code; + if (code === "ENOENT") return { code: null, stdout: "", stderr: "ENOENT" }; + return { code: -1, stdout: r.stdout ?? "", stderr: `${code ?? "error"}: ${r.error.message}` }; + } + return { code: r.status ?? -1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" }; + }; +} + +/** The ok state as the `FleetConfig` shape the hub flows consume + * (hub_ops.resolveHubTarget) — the topology section's value verbatim; every + * non-ok state is null (the caller renders, never a silent default). An + * out-of-vocabulary role is surfaced elsewhere and returns null here — it is + * never silently remapped into the closed role vocabulary. */ +export function fleetConfigOf(state: FleetTopologyState): import("./fleet_fallback").FleetConfig | null { + if (state.kind !== "ok") return null; + const role = state.role; + if (role !== "standalone" && role !== "server" && role !== "client") return null; + return { + role, + ...(state.canonical === undefined ? {} : { canonical: state.canonical }), + ...(state.previousBinary === undefined ? {} : { previousBinary: state.previousBinary }), + ...(state.previousPort === undefined ? {} : { previousPort: state.previousPort }), + }; +} + // ── internals ───────────────────────────────────────────────────────────────── function objectValue(proj: FleetProjection, section: string): Record | undefined { diff --git a/packages/extension/src/terminal.ts b/packages/extension/src/terminal.ts index 4bece9f81..aa7f2ee7c 100644 --- a/packages/extension/src/terminal.ts +++ b/packages/extension/src/terminal.ts @@ -24,6 +24,7 @@ import * as path from "node:path"; import * as fs from "node:fs"; import * as os from "node:os"; import { managedPathEntries } from "./opencode_updater_wiring"; +import { readFleetTopology } from "./fleet_topology"; export interface AmicodeTerminalDeps { extensionPath: string; @@ -92,14 +93,13 @@ export function registerAmicodeTerminal(ctx: vscode.ExtensionContext, deps: Amic if (sessionDb) env.OPENCODE_DB = sessionDb; if (configDirOverride) env.OPENCODE_CONFIG_DIR = configDirOverride; - // Carry fleet standalone hint as env for shell scripts that check it + // Carry fleet standalone hint as env for shell scripts that check it. + // #1106: the role comes from the projection cache through the ONE reader + // (fleet_topology), never a raw fleet-config parse; the legacy fallback + // marker stays an existence probe (probed, never parsed). try { - const fleetJson = path.join(os.homedir(), ".amico", "ops", "fleet", "fleet.json"); - if (fs.existsSync(fleetJson)) { - const cfg = JSON.parse(fs.readFileSync(fleetJson, "utf8")); - if (cfg?.role === "standalone") env.AMICO_FLEET_STANDALONE = "1"; - } - // Legacy fallback.json — also treat as standalone hint + const topology = readFleetTopology(); + if (topology.kind === "ok" && topology.role === "standalone") env.AMICO_FLEET_STANDALONE = "1"; const fallback = path.join(os.homedir(), ".amico", "ops", "fleet", "fallback.json"); if (fs.existsSync(fallback)) env.AMICO_FLEET_STANDALONE = "1"; } catch {} diff --git a/packages/extension/test/fleet_fallback.test.ts b/packages/extension/test/fleet_fallback.test.ts index e28d24a53..296ba2d91 100644 --- a/packages/extension/test/fleet_fallback.test.ts +++ b/packages/extension/test/fleet_fallback.test.ts @@ -1,10 +1,18 @@ +// fleet_fallback.test.ts — #1106 (P3b-2): fleet_fallback is now a WRITER for +// the raw fleet.json (the file amicissimo's ONE parser reads, behind the CLI) +// plus the legacy-marker migration — its raw-file READERS are GONE (the read +// path lives in fleet_topology.ts over the verb-refreshed projection cache; +// the extension never parses fleet.json). These tests pin the writer contract +// exactly, because a malformed write would corrupt the one parser's input: +// the on-disk shape { role, canonical, previous* } must round-trip. import { describe, it, expect, beforeEach, afterEach } from "vitest"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { goStandalone, readFleetConfig, getFleetRole, writeFleetConfig, isFleetClient, removeFleetConfig } from "../src/fleet_fallback"; +import { readFileSync } from "node:fs"; +import { goStandalone, writeFleetConfig, removeFleetConfig, migrateLegacyFallback, FLEET_CONFIG_PATH, FleetConfig } from "../src/fleet_fallback"; -describe("fleet_fallback (fleet config)", () => { +describe("fleet_fallback (the writer — reads go through fleet_topology now)", () => { let tmp: string; let p: string; beforeEach(() => { @@ -15,40 +23,69 @@ describe("fleet_fallback (fleet config)", () => { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} }); - it("goStandalone writes role=standalone", () => { + it("goStandalone writes role=standalone in the raw file (amicissimo's parser reads this shape)", () => { const cfg = goStandalone({ path: p }); expect(cfg.role).toBe("standalone"); - const read = readFleetConfig(p); - expect(read?.role).toBe("standalone"); + const onDisk = JSON.parse(readFileSync(p, "utf8")) as FleetConfig; + expect(onDisk.role).toBe("standalone"); }); - it("getFleetRole returns standalone when no file", () => { - expect(getFleetRole(p)).toBe("standalone"); - }); - - it("getFleetRole reads role from config", () => { + it("writeFleetConfig writes a parseable client topology the ONE parser can publish", () => { writeFleetConfig({ role: "client", canonical: { host: "test-host", port: 4096, sshAlias: "test" } }, p); - expect(getFleetRole(p)).toBe("client"); - }); - - it("isFleetClient returns true only for client role", () => { - expect(isFleetClient(p)).toBe(false); - writeFleetConfig({ role: "client", canonical: { host: "x", port: 4096 } }, p); - expect(isFleetClient(p)).toBe(true); - writeFleetConfig({ role: "server" }, p); - expect(isFleetClient(p)).toBe(false); + const onDisk = JSON.parse(readFileSync(p, "utf8")) as FleetConfig; + expect(onDisk.role).toBe("client"); + expect(onDisk.canonical).toMatchObject({ host: "test-host", port: 4096, sshAlias: "test" }); }); - it("removeFleetConfig deletes the file", () => { + it("removeFleetConfig deletes the file (no file = the parser's standalone base default)", () => { writeFleetConfig({ role: "client" }, p); - expect(readFleetConfig(p)).not.toBe(null); + expect(fs.existsSync(p)).toBe(true); removeFleetConfig(p); - expect(readFleetConfig(p)).toBe(null); + expect(fs.existsSync(p)).toBe(false); }); - it("goStandalone preserves previous settings", () => { + it("goStandalone preserves previous settings for re-enrollment", () => { const cfg = goStandalone({ path: p, previousBinary: "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/old/bin", previousPort: 4096 }); expect(cfg.previousBinary).toBe("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/old/bin"); expect(cfg.previousPort).toBe(4096); }); + + it("migrateLegacyFallback: a legacy fallback.json with no fleet.json → role=standalone written (existence probe + write, never a parse)", () => { + const legacyDir = path.join(tmp, "ops", "fleet"); + fs.mkdirSync(legacyDir, { recursive: true }); + const legacyPath = path.join(legacyDir, "fallback.json"); + fs.writeFileSync(legacyPath, "anything — the marker is probed, not parsed"); + const configPath = path.join(legacyDir, "fleet.json"); + migrateLegacyFallback({ legacyPath, configPath }); + expect(JSON.parse(readFileSync(configPath, "utf8")).role).toBe("standalone"); + expect(fs.existsSync(legacyPath)).toBe(false); // consumed + }); + + it("migrateLegacyFallback: an existing fleet.json wins — the legacy marker is left alone", () => { + const legacyDir = path.join(tmp, "ops", "fleet"); + fs.mkdirSync(legacyDir, { recursive: true }); + const legacyPath = path.join(legacyDir, "fallback.json"); + fs.writeFileSync(legacyPath, "marker"); + const configPath = path.join(legacyDir, "fleet.json"); + writeFleetConfig({ role: "client" }, configPath); + migrateLegacyFallback({ legacyPath, configPath }); + expect(JSON.parse(readFileSync(configPath, "utf8")).role).toBe("client"); // untouched + expect(fs.existsSync(legacyPath)).toBe(true); + }); +}); + +describe("fleet_fallback module discipline (#1106: writer, never a parser)", () => { + it("the module contains NO JSON.parse — a writer never reads topology back", () => { + const src = readFileSync(path.join(__dirname, "..", "src", "fleet_fallback.ts"), "utf8"); + expect(src).not.toMatch(/JSON\.parse/); + }); + + it("the module exports no raw-file reader — the read path is fleet_topology's alone", () => { + const src = readFileSync(path.join(__dirname, "..", "src", "fleet_fallback.ts"), "utf8"); + expect(src).not.toMatch(/export function (readFleetConfig|getFleetRole|isFleetClient|getCanonicalPort)/); + }); + + it("FLEET_CONFIG_PATH still points at the live-layout raw file (the writer's destination — unchanged, the parser's input)", () => { + expect(FLEET_CONFIG_PATH).toBe(path.join(os.homedir(), ".amico", "ops", "fleet", "fleet.json")); + }); }); diff --git a/packages/extension/test/fleet_health.test.ts b/packages/extension/test/fleet_health.test.ts index d1b8223ac..103dc3a53 100644 --- a/packages/extension/test/fleet_health.test.ts +++ b/packages/extension/test/fleet_health.test.ts @@ -1,10 +1,42 @@ import { describe, it, expect } from "vitest"; import { checkFleetGuard, checkFleetSettings, checkFleetTunnel, checkFleetRole, fleetHealthReport, FLEET_GUARD_INSTALL } from "../src/fleet_health"; +import type { FleetTopologyState } from "../src/fleet_topology"; const REPO = "/repo/tools/fleet/amico-opencode-fleet-guard"; const INSTALLED = FLEET_GUARD_INSTALL; const guardContent = "#!/bin/bash\nexit 1\n"; +/** #1106 (P3b-2): the health checks consume the projection-cache topology + * state — never the raw fleet.json. These builders are the same states + * readFleetTopology returns; the checks must render each honestly. */ +const okClient: FleetTopologyState = { + kind: "ok", + role: "client", + canonical: { host: "test", port: 4096, sshAlias: "test" }, + mode: "fleet", + posture: "ok", + freshness: { counter: 7, hubEpoch: "44444444-4444-4444-8444-444444444444" }, + provenanceSource: "fleet.json", + projection: { schema_version: 1, contract_version: 1, sections: {} }, +}; +const okStandalone: FleetTopologyState = { + kind: "ok", + role: "standalone", + mode: "standalone", + posture: "ok", + freshness: {}, + provenanceSource: "base default (mode absent = standalone)", + projection: { schema_version: 1, contract_version: 1, sections: {} }, +}; +const absent: FleetTopologyState = { + kind: "absent", + detail: "fleet projection absent at /tmp/projection.json — refresh it with `amico fleet status --projection`", +}; +const broken: FleetTopologyState = { + kind: "broken", + detail: "projection carries contract v2; this consumer speaks v1 — refusing loudly", +}; + describe("fleet_health", () => { it("guard: ok when in sync and executable (darwin)", () => { const c = checkFleetGuard(REPO, INSTALLED, { @@ -43,19 +75,19 @@ describe("fleet_health", () => { }); it("settings: fails when binary not set", () => { - const c = checkFleetSettings("", 4096, { platform: "darwin", fleetConfig: { role: "client", canonical: { port: 4096 } } }); + const c = checkFleetSettings("", 4096, { platform: "darwin", topology: okClient }); expect(c.ok).toBe(false); expect(c.detail).toMatch(/not set/); }); - it("settings: fails when port wrong", () => { - const c = checkFleetSettings(INSTALLED, 43117, { platform: "darwin", fleetConfig: { role: "client", canonical: { port: 4096 } } }); + it("settings: fails when port wrong (the projection's canonical port is the truth)", () => { + const c = checkFleetSettings(INSTALLED, 43117, { platform: "darwin", topology: okClient }); expect(c.ok).toBe(false); expect(c.detail).toMatch(/43117/); }); it("settings: ok when guard + matching port", () => { - const c = checkFleetSettings(INSTALLED, 4096, { platform: "darwin", fleetConfig: { role: "client", canonical: { port: 4096 } } }); + const c = checkFleetSettings(INSTALLED, 4096, { platform: "darwin", topology: okClient }); expect(c.ok).toBe(true); }); @@ -65,21 +97,21 @@ describe("fleet_health", () => { }); it("tunnel: fails when missing", () => { - const c = checkFleetTunnel(null, { platform: "darwin" }); + const c = checkFleetTunnel(null, { platform: "darwin", topology: okClient }); expect(c.ok).toBe(false); expect(c.detail).toMatch(/missing/); }); it("tunnel: fails when stale 30/3", () => { const stale = `ServerAliveInterval=30ServerAliveCountMax=3`; - const c = checkFleetTunnel(stale, { platform: "darwin" }); + const c = checkFleetTunnel(stale, { platform: "darwin", topology: okClient }); expect(c.ok).toBe(false); expect(c.detail).toMatch(/30|stale/); }); it("tunnel: ok when hardened", () => { const good = `ServerAliveInterval=15 ServerAliveCountMax=2 TCPKeepAlive=yes 127.0.0.1:4096:127.0.0.1:4096`; - const c = checkFleetTunnel(good, { platform: "darwin" }); + const c = checkFleetTunnel(good, { platform: "darwin", topology: okClient }); expect(c.ok).toBe(true); }); @@ -88,14 +120,42 @@ describe("fleet_health", () => { expect(c.ok).toBe(true); }); - it("role: returns standalone when no config", () => { - const c = checkFleetRole({ platform: "darwin", read: () => { throw new Error("no file"); } }); + it("role: ok-state standalone renders standalone", () => { + const c = checkFleetRole({ platform: "darwin", topology: okStandalone }); + expect(c.ok).toBe(true); + expect(c.detail).toMatch(/standalone/); + }); + + it("role: absent projection renders the base default standalone — with the refresh pointer, not silent", () => { + const c = checkFleetRole({ platform: "darwin", topology: absent }); expect(c.ok).toBe(true); expect(c.detail).toMatch(/standalone/); + expect(c.detail).toMatch(/amico fleet status --projection/); // the pointer surfaces + }); + + it("role: a broken projection is a RENDERED fail state — the reader's rejection + the refresh fix, never silent standalone", () => { + const c = checkFleetRole({ platform: "darwin", topology: broken }); + expect(c.ok).toBe(false); + expect(c.detail).toMatch(/refusing loudly/); + expect(c.fix).toMatch(/amico fleet status --projection/); + }); + + it("role: the D1 freshness verdict surfaces in the detail (stale says what it is)", () => { + const c = checkFleetRole({ + platform: "darwin", + topology: { ...okClient, verdict: "stale", advisory: "stale — same counter as the previous fetch; nothing new was published" }, + }); + expect(c.ok).toBe(true); + expect(c.detail).toMatch(/stale/); + }); + + it("role: client renders the canonical target from the projection", () => { + const c = checkFleetRole({ platform: "darwin", topology: okClient }); + expect(c.ok).toBe(true); + expect(c.detail).toMatch(/test:4096/); }); - it("aggregate report: standalone skips guard/settings/tunnel", () => { - // No fleet.json = standalone → only the role check returned + it("aggregate report: standalone (ok state) skips guard/settings/tunnel", () => { const r = fleetHealthReport({ repoGuardPath: REPO, configuredBinary: "", // would fail in client mode, but standalone skips @@ -104,25 +164,55 @@ describe("fleet_health", () => { read: () => { throw new Error("no file"); }, isExecutable: () => true, platform: "darwin", + topology: okStandalone, }); expect(r).toHaveLength(1); expect(r[0].name).toBe("Fleet role"); expect(r[0].detail).toMatch(/standalone/); }); + it("aggregate report: absent projection → the standalone floor (only the role check), identical to the ok-standalone shape", () => { + const r = fleetHealthReport({ + repoGuardPath: REPO, + configuredBinary: "", + configuredPort: 0, + plistContent: null, + read: () => { throw new Error("no file"); }, + isExecutable: () => true, + platform: "darwin", + topology: absent, + }); + expect(r).toHaveLength(1); + expect(r[0].name).toBe("Fleet role"); + expect(r[0].detail).toMatch(/standalone/); + }); + + it("aggregate report: a broken projection surfaces as the role check's rendered fail, never a silent skip", () => { + const r = fleetHealthReport({ + repoGuardPath: REPO, + configuredBinary: INSTALLED, + configuredPort: 4096, + plistContent: `ServerAliveInterval=15 ServerAliveCountMax=2 TCPKeepAlive=yes 127.0.0.1:4096:127.0.0.1:4096`, + read: () => guardContent, + isExecutable: () => true, + platform: "darwin", + topology: broken, + }); + expect(r).toHaveLength(1); + expect(r[0].ok).toBe(false); + expect(r[0].detail).toMatch(/refusing loudly/); + }); + it("aggregate report: client mode returns role + guard + settings + tunnel", () => { - const clientConfig = JSON.stringify({ role: "client", canonical: { host: "test", port: 4096, sshAlias: "test" } }); const r = fleetHealthReport({ repoGuardPath: REPO, configuredBinary: INSTALLED, configuredPort: 4096, plistContent: `ServerAliveInterval=15 ServerAliveCountMax=2 TCPKeepAlive=yes 127.0.0.1:4096:127.0.0.1:4096`, - read: (p) => { - if (p.includes("fleet.json")) return clientConfig; - return guardContent; - }, + read: () => guardContent, isExecutable: () => true, platform: "darwin", + topology: okClient, }); expect(r).toHaveLength(4); expect(r.every(c => c.ok)).toBe(true); diff --git a/packages/extension/test/fleet_topology_single_parser.test.ts b/packages/extension/test/fleet_topology_single_parser.test.ts new file mode 100644 index 000000000..24b175ff2 --- /dev/null +++ b/packages/extension/test/fleet_topology_single_parser.test.ts @@ -0,0 +1,59 @@ +// fleet_topology_single_parser.test.ts — #1106's grep-style guard test (fleet +// rearchitect P3b-2, spec spec-20260913-114814 row 1 / §8 F1): the extension +// module set contains NO raw fleet.json parsing anymore — the ONE topology +// parser is amicissimo's, behind the `amico fleet` CLI; every amicode-side +// consumer reads the verb-refreshed projection artifact instead. This is the +// single-parser-guard precedent applied as a source scan: it asserts absence +// of the raw-parse idiom at every site the recon identified (the extension's +// typed read in fleet_fallback, the inline parse in terminal, the health +// checks, the activation flows in extension.ts) — a reintroduced raw parser +// fails here even if no behavioral test catches it. +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const src = (...p: string[]) => join(__dirname, "..", "src", ...p); + +function read(...p: string[]): string { + return readFileSync(src(...p), "utf8"); +} + +describe("the extension module set parses no raw fleet.json (#1106, F1)", () => { + it("fleet_fallback is a writer: no JSON.parse, no exported raw-file reader", () => { + const s = read("fleet_fallback.ts"); + expect(s).not.toMatch(/JSON\.parse/); + expect(s).not.toMatch(/export function (readFleetConfig|getFleetRole|isFleetClient|getCanonicalPort)/); + }); + + it("terminal carries no fleet.json reference at all — the standalone hint flows through fleet_topology", () => { + const s = read("terminal.ts"); + expect(s).not.toMatch(/fleet\.json/); + }); + + it("fleet_health consumes the projection topology state, never readFleetConfig", () => { + const s = read("fleet_health.ts"); + expect(s).not.toMatch(/readFleetConfig/); + expect(s).not.toMatch(/readFileSync[^;]*fleet\.json/); + expect(s).toMatch(/fleet_topology/); // the consumer import is present + }); + + it("extension.ts: no raw fleet.json readFileSync; the fleet decisions route through fleet_topology", () => { + const s = read("extension.ts"); + expect(s).not.toMatch(/readFileSync[^;]*fleet\.json/); + expect(s).not.toMatch(/from "\.\/fleet_fallback"[^;]*\b(readFleetConfig|getFleetRole|isFleetClient)\b/); + expect(s).toMatch(/readFleetTopologyWithRefresh/); // the decision path is the projection read + }); + + it("fleet_topology itself holds no raw-file path and no wall clock — reader + verb only", () => { + const s = read("fleet_topology.ts"); + expect(s).not.toMatch(/fleet\.json/); + expect(s).not.toMatch(/\bnew Date\b|Date\.now/); + expect(s).toMatch(/fleetProjectionCachePath|readProjection/); + }); + + it("the reader seam is @amicode/schema — consumed verbatim, never re-defined locally", () => { + const s = read("fleet_topology.ts"); + expect(s).toMatch(/from "@amicode\/schema"/); + expect(s).not.toMatch(/function readProjection/); // no shadow of the reader + }); +}); From 1fc445c95a27919637d563901fa571682f83e8ac Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Mon, 14 Sep 2026 00:42:31 +0200 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20#1106=20the=20installer=20+=20guard?= =?UTF-8?q?=20consume=20the=20projection=20=E2=80=94=20the=20raw=20grep=20?= =?UTF-8?q?reads=20are=20gone=20(P3b-2=20commit=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - guard: reads the verb-refreshed projection cache (~/.amico/ops/fleet/projection.json), pinned to contract v1 (an artifact speaking another contract is unusable, never guessed from); absent/unusable cache refreshes ONCE through the verb (CLI ladder: PATH → the known dev-checkout launchers); exit 75 and CLI-absent are the IDENTICAL stated base-standalone bootstrap (grant pointer, mode untouched); a verb FAILURE fails closed with the repair path — a client never silently forks on an unreadable topology - installer: shells the verb (machine-parseable JSON — the additive role/canonical fields); exit 75 + CLI-absent → the identical base-standalone bootstrap branch (exit 0, the solo floor untouched); non-75 verb failure or unparseable output dies honestly; the verb run keeps the projection cache warm for the other consumers - assert_fleet_guard.sh (CI gate) now enforces the #1106 convention: projection cache reference + verb door + contract pin in the guard; verb + no-FLEET_CONFIG-grep in the installer; fleet_topology consumer import in fleet_health - both copies (tools/fleet + the VSIX's packaged copy) byte-identical; script behavior driven through real bash execs with fabricated HOME/PATH + a fake amico (18 cases) --- .../extension/scripts/assert_fleet_guard.sh | 34 +- .../test/fleet_scripts_projection.test.ts | 307 ++++++++++++++++++ .../tools/fleet/amico-opencode-fleet-guard | 98 +++++- packages/extension/tools/fleet/install.sh | 77 ++++- tools/fleet/amico-opencode-fleet-guard | 98 +++++- tools/fleet/install.sh | 77 ++++- 6 files changed, 630 insertions(+), 61 deletions(-) create mode 100644 packages/extension/test/fleet_scripts_projection.test.ts diff --git a/packages/extension/scripts/assert_fleet_guard.sh b/packages/extension/scripts/assert_fleet_guard.sh index 6ee8f5d68..44f4a56f2 100755 --- a/packages/extension/scripts/assert_fleet_guard.sh +++ b/packages/extension/scripts/assert_fleet_guard.sh @@ -1,6 +1,10 @@ #!/usr/bin/env bash -# CI gate — fleet guard + tunnel template must be sane. -# Fails if the guard would not prevent the silent-fork on a fleet client. +# CI gate — fleet guard + installer + tunnel template must be sane. +# Fails if the guard would not prevent the silent-fork on a fleet client, and +# (amicode#1106, fleet rearchitect P3b-2) if any bash consumer still parses the +# raw machine-local fleet config: the guard + installer read the PROJECTION +# (the verb-refreshed cache / the verb's machine-parseable output) — the ONE +# parser is amicissimo's, behind the `amico fleet` CLI. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" GUARD="$ROOT/tools/fleet/amico-opencode-fleet-guard" @@ -12,15 +16,27 @@ ok() { echo "[fleet-gate] ok $*"; } [[ -f "$GUARD" ]] || fail "guard missing at $GUARD (fleet hardening not merged?)" [[ -x "$GUARD" ]] || fail "guard not executable — chmod +x $GUARD" -grep -q 'fleet.json' "$GUARD" || fail "guard does not reference fleet.json config" +grep -q 'projection.json' "$GUARD" || fail "guard does not reference the projection cache (the #1106 read path)" +grep -q 'fleet status --projection' "$GUARD" || fail "guard does not shell the fleet-authority verb (the CLI is the only door)" +grep -q 'contract_version' "$GUARD" || fail "guard missing the contract-version pin (must not trust an artifact it cannot speak)" grep -q 'exit 1' "$GUARD" || fail "guard missing client exit 1 (would not prevent fork)" grep -q 'FROZEN.*\.amico/server/bin/opencode' "$GUARD" || fail "guard missing frozen binary path" -grep -q 'FLEET_CONFIG' "$GUARD" || fail "guard missing FLEET_CONFIG variable" -ok "guard $GUARD" +grep -q 'PROJECTION' "$GUARD" || fail "guard missing PROJECTION variable" +if grep -qE '"role"[[:space:]]*:.*fleet\.json|fleet\.json.*"role"' "$GUARD" 2>/dev/null; then + fail "guard parses the raw fleet config for its role (the #1106 anti-goal — the projection cache is the only read)" +fi +ok "guard $GUARD (projection cache + verb door + contract pin)" [[ -f "$INSTALL" ]] || fail "installer missing at $INSTALL" [[ -x "$INSTALL" ]] || fail "installer not executable — chmod +x $INSTALL" -ok "installer $INSTALL" +grep -q 'fleet status --projection' "$INSTALL" || fail "installer does not shell the fleet-authority verb (#1106 — machine-parseable output, never a raw grep)" +if grep -qE 'grep.*"role".*FLEET_CONFIG|FLEET_CONFIG.*grep' "$INSTALL" 2>/dev/null; then + fail "installer greps the raw fleet config for its role (the #1106 anti-goal)" +fi +if grep -q 'FLEET_CONFIG=' "$INSTALL" 2>/dev/null; then + fail "installer still names a FLEET_CONFIG raw path (the #1106 anti-goal — the verb is the only door)" +fi +ok "installer $INSTALL (verb output + bootstrap exception branches)" [[ -f "$PLIST" ]] || fail "tunnel plist template missing at $PLIST" grep -q "ServerAliveInterval=15" "$PLIST" || fail "plist ServerAliveInterval 15 missing" @@ -29,9 +45,9 @@ grep -q "TCPKeepAlive=yes" "$PLIST" || fail "plist TCPKeepAlive yes missing" grep -q "127.0.0.1:4096:127.0.0.1:4096" "$PLIST" || fail "plist LocalForward 4096 missing" ok "tunnel plist $PLIST" -# Guard + tunnel template are the source of truth for the installer; ensure the -# installer itself is consistent (it references both). -grep -q "FLEET_GUARD_REL\|FLEET_CONFIG_PATH\|fleet.json" "$ROOT/packages/extension/src/fleet_health.ts" || fail "fleet_health.ts missing fleet config reference" +# The extension's health checks consume the projection topology state +# (fleet_topology.ts) — the consumer import must be present (#1106). +grep -q "fleet_topology" "$ROOT/packages/extension/src/fleet_health.ts" || fail "fleet_health.ts missing the fleet_topology consumer import (#1106)" # Packaged copy must stay in sync with repo root (the VSIX ships the packaged copy). PKG_GUARD="$ROOT/packages/extension/tools/fleet/amico-opencode-fleet-guard" diff --git a/packages/extension/test/fleet_scripts_projection.test.ts b/packages/extension/test/fleet_scripts_projection.test.ts new file mode 100644 index 000000000..276f80bfe --- /dev/null +++ b/packages/extension/test/fleet_scripts_projection.test.ts @@ -0,0 +1,307 @@ +// fleet_scripts_projection.test.ts — #1106 (P3b-2, spec §8 F1's installer + +// guard legs): the two bash consumers of fleet topology read the PROJECTION +// (the verb-refreshed cache / the verb's machine-parseable output) — never the +// raw fleet config. These tests exec the REAL scripts with a fabricated +// $HOME and a fake `amico` CLI on the child PATH, replaying the same +// absent / broken / bootstrap / client / standalone topology cases the +// extension's fleet_topology tests drive — the triple-consumer replay's +// script legs. +// +// The fake `amico` faithfully emulates the verb contract (#1106): exit 0 → +// print the JSON line AND refresh the cache at $HOME/.amico/ops/fleet/ +// projection.json; exit 75 → bootstrap, cache untouched; any other exit → +// failure, cache untouched. +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { spawnSync } from "node:child_process"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const REPO = join(__dirname, "..", "..", ".."); +const GUARD = join(REPO, "tools", "fleet", "amico-opencode-fleet-guard"); +const INSTALL = join(REPO, "tools", "fleet", "install.sh"); + +let tmp: string; +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "fleet-scripts-")); +}); +afterEach(() => rmSync(tmp, { recursive: true, force: true })); + +const EPOCH_A = "44444444-4444-4444-8444-444444444444"; + +/** A full lawful client projection — the committed fixture's shape. */ +const CLIENT_PROJECTION = JSON.stringify({ + schema_version: 1, + contract_version: 1, + publisher: { identity: "fleet_authority", published_at: "2026-09-13T12:00:00Z" }, + freshness: { counter: 4, hub_epoch: EPOCH_A }, + sections: { + mode: { value: "fleet", provenance: { source: "fleet config", parsed_from: "role='client' (vocabulary mapping)" } }, + posture: { value: "ok", provenance: { source: "fleet-status.json" } }, + topology: { + value: { + role: "client", + canonical: { host: "hq-hub-01.example.internal", port: 4096, sshAlias: "hq-hub-01" }, + }, + provenance: { source: "fleet config", parsed_from: "topology schema v1 fields" }, + }, + }, +}, null, 2); + +/** A lawful standalone projection — no topology section (the base default). */ +const STANDALONE_PROJECTION = JSON.stringify({ + schema_version: 1, + contract_version: 1, + publisher: { identity: "fleet_authority", published_at: "2026-09-13T12:00:00Z" }, + freshness: { counter: 1, hub_epoch: EPOCH_A }, + sections: { mode: { value: "standalone", provenance: { source: "base default" } } }, +}, null, 2); + +/** A future-contract projection — unusable to a v1 consumer. */ +const FUTURE_PROJECTION = JSON.stringify({ + schema_version: 1, + contract_version: 2, + sections: {}, +}, null, 2); + +/** A server-role projection (the hub machine itself spawns). */ +const SERVER_PROJECTION = JSON.stringify({ + schema_version: 1, + contract_version: 1, + publisher: { identity: "fleet_authority", published_at: "2026-09-13T12:00:00Z" }, + freshness: { counter: 2, hub_epoch: EPOCH_A }, + sections: { + mode: { value: "fleet", provenance: { source: "fleet config", parsed_from: "role='server' (vocabulary mapping)" } }, + topology: { + value: { role: "server", canonical: { host: "hq-hub-01", port: 4096, sshAlias: "hq-hub-01" } }, + provenance: { source: "fleet config" }, + }, + }, +}, null, 2); + +/** The verb's machine-parseable JSON stdout (commit 1's additive fields). */ +function verbJson(role?: string, canonical?: Record): string { + return JSON.stringify({ + verb: "fleet", subcommand: "status", projection: true, ok: true, + mode: role === "client" ? "fleet" : "standalone", + posture: "ok", + ...(role === undefined ? {} : { role }), + ...(canonical === undefined ? {} : { canonical }), + cache_path: `${tmp}/.amico/ops/fleet/projection.json`, + }); +} + +/** The fake `amico` — emulates the #1106 verb contract on the child PATH. */ +function fakeAmico(behavior: { code: number; stdout: string; cacheContent?: string }): void { + const bin = join(tmp, "fakebin"); + mkdirSync(bin, { recursive: true }); + const lines = ["#!/usr/bin/env bash", "set -u"]; + if (behavior.cacheContent !== undefined) { + lines.push( + `mkdir -p "$HOME/.amico/ops/fleet"`, + `cat > "$HOME/.amico/ops/fleet/projection.json" << 'AMICO_FAKE_EOF'`, + behavior.cacheContent, + "AMICO_FAKE_EOF", + ); + } + lines.push(`cat << 'AMICO_STDOUT_EOF'`, behavior.stdout, "AMICO_STDOUT_EOF", `exit ${behavior.code}`); + writeFileSync(join(bin, "amico"), lines.join("\n") + "\n"); + chmodSync(join(bin, "amico"), 0o755); +} + +function writeCache(content: string): void { + const dir = join(tmp, ".amico", "ops", "fleet"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "projection.json"), content); +} + +/** A fake frozen opencode binary the guard can exec. */ +function fakeFrozenBinary(): void { + const dir = join(tmp, ".amico", "server", "bin"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "opencode"), "#!/usr/bin/env bash\necho FROZEN-EXEC \"$@\"\n"); + chmodSync(join(dir, "opencode"), 0o755); +} + +function runScript(script: string, args: string[], opts: { path?: string } = {}): { code: number; out: string } { + const env: Record = { + HOME: tmp, + PATH: opts.path ?? `${join(tmp, "fakebin")}:/usr/bin:/bin`, + }; + // The guard/installer must never find the REAL machine's amico or node-installed fleet state. + delete env.AMICISSIMO_ROOT; + const r = spawnSync("bash", [script, ...args], { env, encoding: "utf8", timeout: 60_000 }); + return { code: r.status ?? -1, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; +} + +/** A launcher-less fake repo — the CLI-absent branch is only reachable when + * the repo's own launchers are absent too (the dev checkout ships them). */ +function fakeRepoInstall(): string { + const repo = join(tmp, "fake-repo"); + mkdirSync(join(repo, "tools", "fleet"), { recursive: true }); + for (const f of ["install.sh", "amico-opencode-fleet-guard", "co.harmoniqs.amico-tunnel.plist"]) { + writeFileSync(join(repo, "tools", "fleet", f), readFileSync(join(REPO, "tools", "fleet", f), "utf8")); + } + return join(repo, "tools", "fleet", "install.sh"); +} + +// ── the guard: projection cache first, verb refresh on absent/unusable ───────── + +describe("the guard reads the projection cache (never the raw file)", () => { + it("client projection → exit 1, refusing to spawn (the silent-fork countermeasure holds)", () => { + writeCache(CLIENT_PROJECTION); + const r = runScript(GUARD, []); + expect(r.code).toBe(1); + expect(r.out).toMatch(/refusing to spawn/); + }); + + it("server projection → spawns (execs the frozen binary)", () => { + writeCache(SERVER_PROJECTION); + fakeFrozenBinary(); + const r = runScript(GUARD, []); + expect(r.code).toBe(0); + expect(r.out).toMatch(/FROZEN-EXEC/); + }); + + it("standalone projection (no topology section) → spawns — the base default is honestly carried", () => { + writeCache(STANDALONE_PROJECTION); + fakeFrozenBinary(); + const r = runScript(GUARD, []); + expect(r.code).toBe(0); + expect(r.out).toMatch(/FROZEN-EXEC/); + }); + + it("stale-but-lawful projection (same counter re-published) still refuses on client — staleness never flips the role", () => { + writeCache(CLIENT_PROJECTION); + fakeFrozenBinary(); + const r = runScript(GUARD, []); + expect(r.code).toBe(1); + }); + + it("absent cache + verb exit 0 (refreshes the cache) → the refreshed role refuses on client", () => { + fakeAmico({ code: 0, stdout: verbJson("client", { host: "hq", port: 4096, sshAlias: "hq" }), cacheContent: CLIENT_PROJECTION }); + const r = runScript(GUARD, []); + expect(existsSync(join(tmp, ".amico", "ops", "fleet", "projection.json"))).toBe(true); // the verb refreshed it + expect(r.code).toBe(1); + expect(r.out).toMatch(/refusing to spawn/); + }); + + it("absent cache + verb exit 75 → the bootstrap exception: base-standalone STATED with the grant pointer, then spawns", () => { + fakeAmico({ code: 75, stdout: "fleet status: base-standalone (bootstrap exception) — grant path: ~/.amico/amicode/entitlements.toml" }); + fakeFrozenBinary(); + const r = runScript(GUARD, []); + expect(r.code).toBe(0); + expect(r.out).toMatch(/FROZEN-EXEC/); // base-standalone spawns locally + expect(r.out).toMatch(/base-standalone \(bootstrap exception/); + expect(r.out).toMatch(/entitlements\.toml|amicissimo/); // the pointer + }); + + it("absent cache + CLI absent → the IDENTICAL bootstrap branch (stated + pointer, then spawns)", () => { + fakeFrozenBinary(); + const r = runScript(GUARD, [], { path: "/usr/bin:/bin" }); // no fakebin — no amico anywhere + expect(r.code).toBe(0); + expect(r.out).toMatch(/FROZEN-EXEC/); + expect(r.out).toMatch(/base-standalone \(bootstrap exception/); + expect(r.out).toMatch(/amico CLI is absent/); + }); + + it("unusable cache (future contract) + verb failure → FAILS CLOSED with the honest repair message — never a silent fork", () => { + writeCache(FUTURE_PROJECTION); + fakeAmico({ code: 64, stdout: "the publisher failed" }); + fakeFrozenBinary(); + const r = runScript(GUARD, []); + expect(r.code).toBe(1); // fail closed — a client must never fork on an unreadable topology + expect(r.out).not.toMatch(/FROZEN-EXEC/); + expect(r.out).toMatch(/cannot be trusted/); + expect(r.out).toMatch(/amico fleet status --projection/); + }); + + it("verb exit 0 but the cache it wrote is unusable → fails closed (never trusts an unparseable artifact)", () => { + fakeAmico({ code: 0, stdout: "{}", cacheContent: "{ corrupt" }); + fakeFrozenBinary(); + const r = runScript(GUARD, []); + expect(r.code).toBe(1); + expect(r.out).not.toMatch(/FROZEN-EXEC/); + }); + + it("no projection, no CLI, no opencode binary → the honest no-binary failure (unchanged base behavior)", () => { + const r = runScript(GUARD, [], { path: "/usr/bin:/bin" }); + expect(r.code).toBe(1); + expect(r.out).toMatch(/no opencode binary found/); + }); +}); + +// ── the installer: the verb's machine-parseable output ───────────────────────── + +describe("the installer consumes the verb (never greps the raw file)", () => { + // The installer's settings section needs node; reuse the ambient PATH but + // with the fake amico FIRST (it shadows any real one). + const installEnv = (): { path: string } => ({ + path: `${join(tmp, "fakebin")}:${process.env.PATH ?? ""}`, + }); + + it("verb exit 0 + role standalone → the standalone branch: checks skipped, exit 0", () => { + fakeAmico({ code: 0, stdout: verbJson() }); // no role field → the base default + const r = runScript(INSTALL, ["--check"], installEnv()); + expect(r.code).toBe(0); + expect(r.out).toMatch(/standalone/); + expect(r.out).toMatch(/fleet checks skipped|nothing to install/); + }); + + it("verb exit 0 + role client → the fleet branch with the parsed port (proceeds past the topology gate)", () => { + fakeAmico({ code: 0, stdout: verbJson("client", { host: "hq", port: 4096, sshAlias: "hq" }) }); + const r = runScript(INSTALL, ["--check"], installEnv()); + expect(r.out).toMatch(/fleet role: client \(port: 4096\)/); + // --check then fails on the missing installed guard — the TOPOLOGY branch was taken + expect(r.code).toBe(1); + expect(r.out).toMatch(/guard not installed/); + }); + + it("verb exit 75 → the bootstrap exception: base-standalone STATED with the pointer, exit 0 (identical to CLI-absent)", () => { + fakeAmico({ code: 75, stdout: "fleet status: base-standalone (bootstrap exception)" }); + const r = runScript(INSTALL, ["--check"], installEnv()); + expect(r.code).toBe(0); + expect(r.out).toMatch(/base-standalone/); + expect(r.out).toMatch(/entitlements\.toml|amicissimo|grant/i); + }); + + it("CLI absent → the IDENTICAL bootstrap branch (stated, exit 0 — the solo floor is untouched)", () => { + const r = runScript(fakeRepoInstall(), ["--check"], { path: "/usr/bin:/bin" }); + expect(r.code).toBe(0); + expect(r.out).toMatch(/base-standalone/); + expect(r.out).toMatch(/amico CLI is absent/); + }); + + it("verb failure (non-75) → dies honestly, never a silent raw-file fallthrough", () => { + fakeAmico({ code: 64, stdout: "publisher exploded" }); + const r = runScript(INSTALL, ["--check"], installEnv()); + expect(r.code).toBe(1); + expect(r.out).toMatch(/fleet-authority verb failed|refusing to guess/); + expect(r.out).not.toMatch(/standalone mode — nothing to install/); // not the standalone branch + }); + + it("the verb's stdout is unparseable JSON → dies honestly (machine-parseable is a contract)", () => { + fakeAmico({ code: 0, stdout: "this is not json" }); + const r = runScript(INSTALL, ["--check"], installEnv()); + expect(r.code).toBe(1); + expect(r.out).toMatch(/parse|machine|verb/i); + }); + + it("the verb run leaves the projection cache refreshed (the consumers' cache convention is kept warm by the installer)", () => { + fakeAmico({ code: 0, stdout: verbJson("client", { host: "hq", port: 4096, sshAlias: "hq" }), cacheContent: CLIENT_PROJECTION }); + runScript(INSTALL, ["--check"], installEnv()); + expect(readFileSync(join(tmp, ".amico", "ops", "fleet", "projection.json"), "utf8").trim()).toBe(CLIENT_PROJECTION); + }); +}); + +// ── both copies ship byte-identical (the VSIX's packaged copy is the installer users run) ── + +describe("the packaged fleet scripts stay in sync with the repo copies", () => { + it("guard + installer: packages/extension/tools/fleet copies are byte-identical", () => { + for (const f of ["amico-opencode-fleet-guard", "install.sh"]) { + const pkg = join(REPO, "packages", "extension", "tools", "fleet", f); + expect(existsSync(pkg)).toBe(true); + expect(readFileSync(pkg, "utf8")).toBe(readFileSync(join(REPO, "tools", "fleet", f), "utf8")); + } + }); +}); diff --git a/packages/extension/tools/fleet/amico-opencode-fleet-guard b/packages/extension/tools/fleet/amico-opencode-fleet-guard index 4658bb0a5..3c8d7b95e 100755 --- a/packages/extension/tools/fleet/amico-opencode-fleet-guard +++ b/packages/extension/tools/fleet/amico-opencode-fleet-guard @@ -1,30 +1,93 @@ #!/bin/bash # amico-opencode-fleet-guard — fleet guard for the Amicode extension. -# Reads fleet role from ~/.amico/ops/fleet/fleet.json. On a machine whose role -# is "client", exit 1 immediately: the extension's health probe then rides the +# Reads the fleet role from the verb-refreshed PROJECTION CACHE +# (~/.amico/ops/fleet/projection.json), refreshed by +# `amico fleet status --projection` — amicissimo's parser (behind the CLI) is +# the ONLY reader of the machine-local fleet config; this guard never reads +# that raw file (#1106, fleet rearchitect P3b-2; the silent-fork counter- +# measures remain #279/#324/#338). On a machine whose projection role is +# "client", exit 1 immediately: the extension's health probe then rides the # SSH tunnel to the canonical server, or fails closed if the tunnel is down — -# a client must NEVER spawn a local fork. (harmoniqs/amicode#279, #324, #338) +# a client must NEVER spawn a local fork. # -# Fleet config: ~/.amico/ops/fleet/fleet.json -# { "role": "standalone"|"server"|"client", "canonical": { "host": "...", "port": 4096, "sshAlias": "..." } } -# No file = standalone (safe default — spawns locally like pre-fleet Amicode). +# The projection cache is pinned to contract v1: an artifact speaking another +# contract (or corrupt bytes) is UNUSABLE, never guessed from. An absent or +# unusable cache is refreshed ONCE through the verb — the CLI is the only +# door. The verb's bootstrap exception (exit 75) and a CLI-absent machine get +# the IDENTICAL honest branch: base-standalone STATED with the grant pointer, +# then the base floor's local spawn (stated, never silent). A verb FAILURE +# (any other exit) fails CLOSED — an unreadable topology must never silently +# fork a client, and the repair path is stated. +# +# Cache convention: /.amico/ops/fleet/projection.json — the same +# artifact the extension (fleet_topology.ts) and the installer consume. # # Install to ~/.local/bin on every host and point amicode.opencodeBinary at it: # cp tools/fleet/amico-opencode-fleet-guard ~/.local/bin/amico-opencode-fleet-guard # # then in settings.json (scope: machine): "amicode.opencodeBinary": "$HOME/.local/bin/amico-opencode-fleet-guard" # -# Go Standalone (CONTEXT.md): sets role to "standalone" in fleet.json (or deletes -# it). The guard then allows a local spawn — no tunnel required. +# Go Standalone (CONTEXT.md): the mode flow writes role=standalone to the +# machine-local config (amicissimo's parser input) and refreshes the +# projection cache through the verb; the guard then reads role=standalone +# and allows a local spawn — no tunnel required. + +PROJECTION="$HOME/.amico/ops/fleet/projection.json" + +# The role from the projection cache: usable (contract v1) + the topology +# role key, or an unusable artifact (exit 1). An EMPTY role from a usable +# artifact is the lawful standalone base default (no topology section). +projection_role() { + [ -f "$PROJECTION" ] || return 1 + grep -Eq '"contract_version"[[:space:]]*:[[:space:]]*1([^0-9]|$)' "$PROJECTION" 2>/dev/null || return 1 + grep -o '"role"[[:space:]]*:[[:space:]]*"[^"]*"' "$PROJECTION" 2>/dev/null | head -1 | sed 's/.*"role"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' + return 0 +} -FLEET_CONFIG="$HOME/.amico/ops/fleet/fleet.json" +# The CLI door: `amico` on PATH, else the known dev-checkout launchers. +amico_cli() { + if command -v amico >/dev/null 2>&1; then command -v amico; return 0; fi + for root in "$HOME/harmoniqs/amicode" "$HOME/armonia/repos/amicode"; do + for cand in "$root/packages/extension/bin/launcher/amico" "$root/packages/amico-run/launcher/amico"; do + if [ -x "$cand" ]; then printf '%s' "$cand"; return 0; fi + done + done + return 1 +} -# Determine role from fleet.json. No file or parse failure = standalone. -ROLE="standalone" -if [ -f "$FLEET_CONFIG" ]; then - # Extract role with lightweight JSON parsing (no jq dependency) - PARSED_ROLE="$(grep -o '"role"[[:space:]]*:[[:space:]]*"[^"]*"' "$FLEET_CONFIG" 2>/dev/null | head -1 | sed 's/.*"role"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')" - if [ -n "$PARSED_ROLE" ]; then - ROLE="$PARSED_ROLE" +ROLE_USABLE=0 +ROLE="$(projection_role)" && ROLE_USABLE=1 +if [ "$ROLE_USABLE" -ne 1 ]; then + ROLE="" + # Absent/unusable cache → refresh ONCE through the verb (the CLI is the only door). + if AMICO_BIN="$(amico_cli)"; then + VERB_CODE=0 + "$AMICO_BIN" fleet status --projection >/dev/null 2>&1 || VERB_CODE=$? + if [ "$VERB_CODE" -eq 0 ]; then + ROLE="$(projection_role)" && ROLE_USABLE=1 + if [ "$ROLE_USABLE" -ne 1 ]; then + echo "[amico-fleet-guard] the refreshed fleet projection at $PROJECTION is unusable (contract not v1, or corrupt bytes) — refusing to guess the topology; repair: amico fleet status --projection" >&2 + exit 1 + fi + elif [ "$VERB_CODE" -eq 75 ]; then + # The bootstrap exception — base-standalone STATED with the pointer, then + # the base floor's local spawn. The mode field is untouched (spec + # invariant 7): stating base-standalone is a floor report, not a write. + echo "[amico-fleet-guard] base-standalone (bootstrap exception — the fleet-authority verb exited 75): this install holds no fleet grant" >&2 + echo "[amico-fleet-guard] grant path: repo access to harmoniqs/amicissimo + the \`amicissimo\` code in ~/.amico/amicode/entitlements.toml + the amicissimo checkout (AMICISSIMO_ROOT)" >&2 + echo "[amico-fleet-guard] spawning the base-standalone local server — stated, never silent" >&2 + ROLE="" + else + # A verb failure is NOT bootstrap — fail closed, state the repair. + echo "[amico-fleet-guard] the fleet-authority verb failed (exit $VERB_CODE) — the fleet topology cannot be trusted, and a client must never silently fork" >&2 + echo "[amico-fleet-guard] repair: amico fleet status --projection (see its output), or 'Amicode: Fleet — Go Standalone'" >&2 + exit 1 + fi + else + # CLI absent → the IDENTICAL bootstrap branch (stated base-standalone + pointer). + echo "[amico-fleet-guard] base-standalone (bootstrap exception — the amico CLI is absent, so the fleet-authority verb cannot run): this install holds no fleet grant" >&2 + echo "[amico-fleet-guard] grant path: repo access to harmoniqs/amicissimo + the \`amicissimo\` code in ~/.amico/amicode/entitlements.toml + the amicissimo checkout (AMICISSIMO_ROOT)" >&2 + echo "[amico-fleet-guard] spawning the base-standalone local server — stated, never silent" >&2 + ROLE="" fi fi @@ -35,7 +98,8 @@ if [ "$ROLE" = "client" ]; then exit 1 fi -# Standalone or server: resolve opencode binary — frozen first, then VSIX, then dev checkout. +# Standalone or server (or the stated base-standalone bootstrap): resolve +# opencode binary — frozen first, then VSIX, then dev checkout. FROZEN="$HOME/.amico/server/bin/opencode" VSIX_BIN="$(ls -dt "$HOME"/.vscode/extensions/harmoniqs.amicode-*/vendor/opencode/darwin-arm64/opencode 2>/dev/null | head -1)" DEV_BIN="$(ls -dt "$HOME"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/harmoniqs/amicode/packages/extension/vendor/opencode/darwin-arm64/opencode "$HOME"/armonia/repos/amicode/packages/extension/vendor/opencode/darwin-arm64/opencode 2>/dev/null | head -1)" diff --git a/packages/extension/tools/fleet/install.sh b/packages/extension/tools/fleet/install.sh index 34fb88eb3..ee3686038 100755 --- a/packages/extension/tools/fleet/install.sh +++ b/packages/extension/tools/fleet/install.sh @@ -1,6 +1,14 @@ #!/usr/bin/env bash # Fleet installer — idempotent, safe to re-run. -# Reads fleet topology from ~/.amico/ops/fleet/fleet.json (no file = standalone, skip). +# Reads fleet topology through the ONE parser (#1106, fleet rearchitect +# P3b-2): it shells the fleet-authority verb (`amico fleet status +# --projection` — machine-parseable JSON output; the run also refreshes the +# projection cache at ~/.amico/ops/fleet/projection.json that the extension +# and the guard consume) and NEVER greps the raw machine-local fleet config — +# amicissimo's parser, behind the CLI, is its only reader. +# Exit 75 from the verb = the bootstrap exception → the honest base-standalone +# branch (stated + the grant pointer), IDENTICAL to a CLI-absent machine; +# other non-zero exits die honestly — never a silent raw-file fallthrough. # Installs/updates the fleet guard + tunnel on this host and fixes machine-scoped settings. # Usage: # bash tools/fleet/install.sh # install/repair (writes files, reloads launchd) @@ -12,24 +20,75 @@ GUARD_DST="$HOME/.local/bin/amico-opencode-fleet-guard" PLIST_SRC="$REPO_ROOT/tools/fleet/co.harmoniqs.amico-tunnel.plist" PLIST_DST="$HOME/Library/LaunchAgents/co.harmoniqs.amico-tunnel.plist" SETTINGS="$HOME/Library/Application Support/Code/User/settings.json" -FLEET_CONFIG="$HOME/.amico/ops/fleet/fleet.json" CHECK=0 if [[ "${1:-}" == "--check" ]]; then CHECK=1; fi die() { echo "[fleet] $*" >&2; exit 1; } say() { echo "[fleet] $*"; } -# --- read fleet config --- +# --- read fleet topology through the ONE parser: shell the verb (#1106) --- +# CLI resolution: PATH first, then the repo's own launchers (the dev checkout +# runs this script before anything is on PATH). +AMICO_CLI="" +if command -v amico >/dev/null 2>&1; then + AMICO_CLI="amico" +elif [[ -x "$REPO_ROOT/packages/extension/bin/launcher/amico" ]]; then + AMICO_CLI="$REPO_ROOT/packages/extension/bin/launcher/amico" +elif [[ -x "$REPO_ROOT/packages/amico-run/launcher/amico" ]]; then + AMICO_CLI="$REPO_ROOT/packages/amico-run/launcher/amico" +fi + +bootstrap_base_standalone() { + # The bootstrap exception — base-standalone STATED with the pointer (the + # mode field is untouched: a floor report, never a mode write). Identical for + # the CLI-absent and exit-75 branches; exit 0 (the base product is whole). + if [[ $CHECK -eq 1 ]]; then + say "base-standalone (bootstrap exception — $1): fleet checks skipped (the base product is whole standalone)" + else + say "base-standalone (bootstrap exception — $1): nothing to install" + fi + say " grant path: repo access to harmoniqs/amicissimo + the \`amicissimo\` code in ~/.amico/amicode/entitlements.toml" + say " then: 'Amicode: Fleet — Enroll' (re-run this installer to light the fleet surfaces)" +} + ROLE="standalone" FLEET_PORT=4096 SSH_ALIAS="" -if [[ -f "$FLEET_CONFIG" ]]; then - ROLE="$(grep -o '"role"[[:space:]]*:[[:space:]]*"[^"]*"' "$FLEET_CONFIG" 2>/dev/null | head -1 | sed 's/.*"role"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' || echo standalone)" - PORT_PARSED="$(grep -o '"port"[[:space:]]*:[[:space:]]*[0-9]*' "$FLEET_CONFIG" 2>/dev/null | head -1 | sed 's/.*:[[:space:]]*//' || echo 4096)" - if [[ -n "$PORT_PARSED" ]]; then FLEET_PORT="$PORT_PARSED"; fi - SSH_ALIAS="$(grep -o '"sshAlias"[[:space:]]*:[[:space:]]*"[^"]*"' "$FLEET_CONFIG" 2>/dev/null | head -1 | sed 's/.*"sshAlias"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' || true)" +if [[ -z "$AMICO_CLI" ]]; then + bootstrap_base_standalone "the amico CLI is absent, so the fleet-authority verb cannot run" + exit 0 +fi + +VERB_CODE=0 +VERB_OUT="$("$AMICO_CLI" fleet status --projection 2>/dev/null)" || VERB_CODE=$? +if [[ "$VERB_CODE" -eq 75 ]]; then + bootstrap_base_standalone "the fleet-authority verb exited 75 (no fleet grant)" + exit 0 +fi +if [[ "$VERB_CODE" -ne 0 ]]; then + die "the fleet-authority verb failed (exit $VERB_CODE) — run \`$AMICO_CLI fleet status --projection\` for the detail; refusing to guess the topology (never a silent raw-file read)" fi +# The verb's machine-parseable output: the additive `role` + `canonical` fields +# (contract v1). Unparseable stdout is a broken contract — die honestly. +VERB_FILE="$(mktemp)" +trap 'rm -f "$VERB_FILE"' EXIT +printf '%s' "$VERB_OUT" > "$VERB_FILE" +PARSED="$(node -e ' + const fs = require("fs"); + let j = {}; + try { j = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); } catch (e) { process.exit(64); } + if (j.ok !== true) process.exit(64); + const role = typeof j.role === "string" ? j.role : "standalone"; + const port = j.canonical && Number.isFinite(j.canonical.port) ? j.canonical.port : 4096; + const alias = j.canonical && typeof j.canonical.sshAlias === "string" ? j.canonical.sshAlias : ""; + process.stdout.write(role + "\n" + port + "\n" + alias); +' "$VERB_FILE")" || die "the fleet-authority verb's output is not machine-parseable JSON (\`$AMICO_CLI fleet status --projection\` should print one JSON line with ok/role/canonical) — refusing to guess the topology" +ROLE="$(printf '%s\n' "$PARSED" | sed -n 1p)" +PORT_PARSED="$(printf '%s\n' "$PARSED" | sed -n 2p)" +SSH_ALIAS="$(printf '%s\n' "$PARSED" | sed -n 3p)" +if [[ "$PORT_PARSED" =~ ^[0-9]+$ ]]; then FLEET_PORT="$PORT_PARSED"; fi + # Standalone: nothing to install — the guard/tunnel are irrelevant. if [[ "$ROLE" == "standalone" ]]; then if [[ $CHECK -eq 1 ]]; then @@ -118,7 +177,7 @@ if [[ "$(uname -s)" == "Darwin" ]]; then say "ok tunnel $PLIST_DST (15/2 + TCPKeepAlive, port $FLEET_PORT, alias ${SSH_ALIAS:-unspecified})" else if [[ -z "$SSH_ALIAS" ]]; then - die "no sshAlias in $FLEET_CONFIG — refusing to install a tunnel that cannot resolve its host (add canonical.sshAlias)" + die "no sshAlias in the fleet topology (the projection carries no canonical.sshAlias) — refusing to install a tunnel that cannot resolve its host (add canonical.sshAlias and re-enroll)" fi mkdir -p "$(dirname "$PLIST_DST")" sed -e "s/FLEET_SSH_ALIAS/${SSH_ALIAS}/g" -e "s/127\.0\.0\.1:4096:127\.0\.0\.1:4096/127.0.0.1:${FLEET_PORT}:127.0.0.1:${FLEET_PORT}/g" "$PLIST_SRC" > "$PLIST_DST" diff --git a/tools/fleet/amico-opencode-fleet-guard b/tools/fleet/amico-opencode-fleet-guard index 4658bb0a5..3c8d7b95e 100755 --- a/tools/fleet/amico-opencode-fleet-guard +++ b/tools/fleet/amico-opencode-fleet-guard @@ -1,30 +1,93 @@ #!/bin/bash # amico-opencode-fleet-guard — fleet guard for the Amicode extension. -# Reads fleet role from ~/.amico/ops/fleet/fleet.json. On a machine whose role -# is "client", exit 1 immediately: the extension's health probe then rides the +# Reads the fleet role from the verb-refreshed PROJECTION CACHE +# (~/.amico/ops/fleet/projection.json), refreshed by +# `amico fleet status --projection` — amicissimo's parser (behind the CLI) is +# the ONLY reader of the machine-local fleet config; this guard never reads +# that raw file (#1106, fleet rearchitect P3b-2; the silent-fork counter- +# measures remain #279/#324/#338). On a machine whose projection role is +# "client", exit 1 immediately: the extension's health probe then rides the # SSH tunnel to the canonical server, or fails closed if the tunnel is down — -# a client must NEVER spawn a local fork. (harmoniqs/amicode#279, #324, #338) +# a client must NEVER spawn a local fork. # -# Fleet config: ~/.amico/ops/fleet/fleet.json -# { "role": "standalone"|"server"|"client", "canonical": { "host": "...", "port": 4096, "sshAlias": "..." } } -# No file = standalone (safe default — spawns locally like pre-fleet Amicode). +# The projection cache is pinned to contract v1: an artifact speaking another +# contract (or corrupt bytes) is UNUSABLE, never guessed from. An absent or +# unusable cache is refreshed ONCE through the verb — the CLI is the only +# door. The verb's bootstrap exception (exit 75) and a CLI-absent machine get +# the IDENTICAL honest branch: base-standalone STATED with the grant pointer, +# then the base floor's local spawn (stated, never silent). A verb FAILURE +# (any other exit) fails CLOSED — an unreadable topology must never silently +# fork a client, and the repair path is stated. +# +# Cache convention: /.amico/ops/fleet/projection.json — the same +# artifact the extension (fleet_topology.ts) and the installer consume. # # Install to ~/.local/bin on every host and point amicode.opencodeBinary at it: # cp tools/fleet/amico-opencode-fleet-guard ~/.local/bin/amico-opencode-fleet-guard # # then in settings.json (scope: machine): "amicode.opencodeBinary": "$HOME/.local/bin/amico-opencode-fleet-guard" # -# Go Standalone (CONTEXT.md): sets role to "standalone" in fleet.json (or deletes -# it). The guard then allows a local spawn — no tunnel required. +# Go Standalone (CONTEXT.md): the mode flow writes role=standalone to the +# machine-local config (amicissimo's parser input) and refreshes the +# projection cache through the verb; the guard then reads role=standalone +# and allows a local spawn — no tunnel required. + +PROJECTION="$HOME/.amico/ops/fleet/projection.json" + +# The role from the projection cache: usable (contract v1) + the topology +# role key, or an unusable artifact (exit 1). An EMPTY role from a usable +# artifact is the lawful standalone base default (no topology section). +projection_role() { + [ -f "$PROJECTION" ] || return 1 + grep -Eq '"contract_version"[[:space:]]*:[[:space:]]*1([^0-9]|$)' "$PROJECTION" 2>/dev/null || return 1 + grep -o '"role"[[:space:]]*:[[:space:]]*"[^"]*"' "$PROJECTION" 2>/dev/null | head -1 | sed 's/.*"role"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' + return 0 +} -FLEET_CONFIG="$HOME/.amico/ops/fleet/fleet.json" +# The CLI door: `amico` on PATH, else the known dev-checkout launchers. +amico_cli() { + if command -v amico >/dev/null 2>&1; then command -v amico; return 0; fi + for root in "$HOME/harmoniqs/amicode" "$HOME/armonia/repos/amicode"; do + for cand in "$root/packages/extension/bin/launcher/amico" "$root/packages/amico-run/launcher/amico"; do + if [ -x "$cand" ]; then printf '%s' "$cand"; return 0; fi + done + done + return 1 +} -# Determine role from fleet.json. No file or parse failure = standalone. -ROLE="standalone" -if [ -f "$FLEET_CONFIG" ]; then - # Extract role with lightweight JSON parsing (no jq dependency) - PARSED_ROLE="$(grep -o '"role"[[:space:]]*:[[:space:]]*"[^"]*"' "$FLEET_CONFIG" 2>/dev/null | head -1 | sed 's/.*"role"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')" - if [ -n "$PARSED_ROLE" ]; then - ROLE="$PARSED_ROLE" +ROLE_USABLE=0 +ROLE="$(projection_role)" && ROLE_USABLE=1 +if [ "$ROLE_USABLE" -ne 1 ]; then + ROLE="" + # Absent/unusable cache → refresh ONCE through the verb (the CLI is the only door). + if AMICO_BIN="$(amico_cli)"; then + VERB_CODE=0 + "$AMICO_BIN" fleet status --projection >/dev/null 2>&1 || VERB_CODE=$? + if [ "$VERB_CODE" -eq 0 ]; then + ROLE="$(projection_role)" && ROLE_USABLE=1 + if [ "$ROLE_USABLE" -ne 1 ]; then + echo "[amico-fleet-guard] the refreshed fleet projection at $PROJECTION is unusable (contract not v1, or corrupt bytes) — refusing to guess the topology; repair: amico fleet status --projection" >&2 + exit 1 + fi + elif [ "$VERB_CODE" -eq 75 ]; then + # The bootstrap exception — base-standalone STATED with the pointer, then + # the base floor's local spawn. The mode field is untouched (spec + # invariant 7): stating base-standalone is a floor report, not a write. + echo "[amico-fleet-guard] base-standalone (bootstrap exception — the fleet-authority verb exited 75): this install holds no fleet grant" >&2 + echo "[amico-fleet-guard] grant path: repo access to harmoniqs/amicissimo + the \`amicissimo\` code in ~/.amico/amicode/entitlements.toml + the amicissimo checkout (AMICISSIMO_ROOT)" >&2 + echo "[amico-fleet-guard] spawning the base-standalone local server — stated, never silent" >&2 + ROLE="" + else + # A verb failure is NOT bootstrap — fail closed, state the repair. + echo "[amico-fleet-guard] the fleet-authority verb failed (exit $VERB_CODE) — the fleet topology cannot be trusted, and a client must never silently fork" >&2 + echo "[amico-fleet-guard] repair: amico fleet status --projection (see its output), or 'Amicode: Fleet — Go Standalone'" >&2 + exit 1 + fi + else + # CLI absent → the IDENTICAL bootstrap branch (stated base-standalone + pointer). + echo "[amico-fleet-guard] base-standalone (bootstrap exception — the amico CLI is absent, so the fleet-authority verb cannot run): this install holds no fleet grant" >&2 + echo "[amico-fleet-guard] grant path: repo access to harmoniqs/amicissimo + the \`amicissimo\` code in ~/.amico/amicode/entitlements.toml + the amicissimo checkout (AMICISSIMO_ROOT)" >&2 + echo "[amico-fleet-guard] spawning the base-standalone local server — stated, never silent" >&2 + ROLE="" fi fi @@ -35,7 +98,8 @@ if [ "$ROLE" = "client" ]; then exit 1 fi -# Standalone or server: resolve opencode binary — frozen first, then VSIX, then dev checkout. +# Standalone or server (or the stated base-standalone bootstrap): resolve +# opencode binary — frozen first, then VSIX, then dev checkout. FROZEN="$HOME/.amico/server/bin/opencode" VSIX_BIN="$(ls -dt "$HOME"/.vscode/extensions/harmoniqs.amicode-*/vendor/opencode/darwin-arm64/opencode 2>/dev/null | head -1)" DEV_BIN="$(ls -dt "$HOME"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/harmoniqs/amicode/packages/extension/vendor/opencode/darwin-arm64/opencode "$HOME"/armonia/repos/amicode/packages/extension/vendor/opencode/darwin-arm64/opencode 2>/dev/null | head -1)" diff --git a/tools/fleet/install.sh b/tools/fleet/install.sh index 34fb88eb3..ee3686038 100755 --- a/tools/fleet/install.sh +++ b/tools/fleet/install.sh @@ -1,6 +1,14 @@ #!/usr/bin/env bash # Fleet installer — idempotent, safe to re-run. -# Reads fleet topology from ~/.amico/ops/fleet/fleet.json (no file = standalone, skip). +# Reads fleet topology through the ONE parser (#1106, fleet rearchitect +# P3b-2): it shells the fleet-authority verb (`amico fleet status +# --projection` — machine-parseable JSON output; the run also refreshes the +# projection cache at ~/.amico/ops/fleet/projection.json that the extension +# and the guard consume) and NEVER greps the raw machine-local fleet config — +# amicissimo's parser, behind the CLI, is its only reader. +# Exit 75 from the verb = the bootstrap exception → the honest base-standalone +# branch (stated + the grant pointer), IDENTICAL to a CLI-absent machine; +# other non-zero exits die honestly — never a silent raw-file fallthrough. # Installs/updates the fleet guard + tunnel on this host and fixes machine-scoped settings. # Usage: # bash tools/fleet/install.sh # install/repair (writes files, reloads launchd) @@ -12,24 +20,75 @@ GUARD_DST="$HOME/.local/bin/amico-opencode-fleet-guard" PLIST_SRC="$REPO_ROOT/tools/fleet/co.harmoniqs.amico-tunnel.plist" PLIST_DST="$HOME/Library/LaunchAgents/co.harmoniqs.amico-tunnel.plist" SETTINGS="$HOME/Library/Application Support/Code/User/settings.json" -FLEET_CONFIG="$HOME/.amico/ops/fleet/fleet.json" CHECK=0 if [[ "${1:-}" == "--check" ]]; then CHECK=1; fi die() { echo "[fleet] $*" >&2; exit 1; } say() { echo "[fleet] $*"; } -# --- read fleet config --- +# --- read fleet topology through the ONE parser: shell the verb (#1106) --- +# CLI resolution: PATH first, then the repo's own launchers (the dev checkout +# runs this script before anything is on PATH). +AMICO_CLI="" +if command -v amico >/dev/null 2>&1; then + AMICO_CLI="amico" +elif [[ -x "$REPO_ROOT/packages/extension/bin/launcher/amico" ]]; then + AMICO_CLI="$REPO_ROOT/packages/extension/bin/launcher/amico" +elif [[ -x "$REPO_ROOT/packages/amico-run/launcher/amico" ]]; then + AMICO_CLI="$REPO_ROOT/packages/amico-run/launcher/amico" +fi + +bootstrap_base_standalone() { + # The bootstrap exception — base-standalone STATED with the pointer (the + # mode field is untouched: a floor report, never a mode write). Identical for + # the CLI-absent and exit-75 branches; exit 0 (the base product is whole). + if [[ $CHECK -eq 1 ]]; then + say "base-standalone (bootstrap exception — $1): fleet checks skipped (the base product is whole standalone)" + else + say "base-standalone (bootstrap exception — $1): nothing to install" + fi + say " grant path: repo access to harmoniqs/amicissimo + the \`amicissimo\` code in ~/.amico/amicode/entitlements.toml" + say " then: 'Amicode: Fleet — Enroll' (re-run this installer to light the fleet surfaces)" +} + ROLE="standalone" FLEET_PORT=4096 SSH_ALIAS="" -if [[ -f "$FLEET_CONFIG" ]]; then - ROLE="$(grep -o '"role"[[:space:]]*:[[:space:]]*"[^"]*"' "$FLEET_CONFIG" 2>/dev/null | head -1 | sed 's/.*"role"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' || echo standalone)" - PORT_PARSED="$(grep -o '"port"[[:space:]]*:[[:space:]]*[0-9]*' "$FLEET_CONFIG" 2>/dev/null | head -1 | sed 's/.*:[[:space:]]*//' || echo 4096)" - if [[ -n "$PORT_PARSED" ]]; then FLEET_PORT="$PORT_PARSED"; fi - SSH_ALIAS="$(grep -o '"sshAlias"[[:space:]]*:[[:space:]]*"[^"]*"' "$FLEET_CONFIG" 2>/dev/null | head -1 | sed 's/.*"sshAlias"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' || true)" +if [[ -z "$AMICO_CLI" ]]; then + bootstrap_base_standalone "the amico CLI is absent, so the fleet-authority verb cannot run" + exit 0 +fi + +VERB_CODE=0 +VERB_OUT="$("$AMICO_CLI" fleet status --projection 2>/dev/null)" || VERB_CODE=$? +if [[ "$VERB_CODE" -eq 75 ]]; then + bootstrap_base_standalone "the fleet-authority verb exited 75 (no fleet grant)" + exit 0 +fi +if [[ "$VERB_CODE" -ne 0 ]]; then + die "the fleet-authority verb failed (exit $VERB_CODE) — run \`$AMICO_CLI fleet status --projection\` for the detail; refusing to guess the topology (never a silent raw-file read)" fi +# The verb's machine-parseable output: the additive `role` + `canonical` fields +# (contract v1). Unparseable stdout is a broken contract — die honestly. +VERB_FILE="$(mktemp)" +trap 'rm -f "$VERB_FILE"' EXIT +printf '%s' "$VERB_OUT" > "$VERB_FILE" +PARSED="$(node -e ' + const fs = require("fs"); + let j = {}; + try { j = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); } catch (e) { process.exit(64); } + if (j.ok !== true) process.exit(64); + const role = typeof j.role === "string" ? j.role : "standalone"; + const port = j.canonical && Number.isFinite(j.canonical.port) ? j.canonical.port : 4096; + const alias = j.canonical && typeof j.canonical.sshAlias === "string" ? j.canonical.sshAlias : ""; + process.stdout.write(role + "\n" + port + "\n" + alias); +' "$VERB_FILE")" || die "the fleet-authority verb's output is not machine-parseable JSON (\`$AMICO_CLI fleet status --projection\` should print one JSON line with ok/role/canonical) — refusing to guess the topology" +ROLE="$(printf '%s\n' "$PARSED" | sed -n 1p)" +PORT_PARSED="$(printf '%s\n' "$PARSED" | sed -n 2p)" +SSH_ALIAS="$(printf '%s\n' "$PARSED" | sed -n 3p)" +if [[ "$PORT_PARSED" =~ ^[0-9]+$ ]]; then FLEET_PORT="$PORT_PARSED"; fi + # Standalone: nothing to install — the guard/tunnel are irrelevant. if [[ "$ROLE" == "standalone" ]]; then if [[ $CHECK -eq 1 ]]; then @@ -118,7 +177,7 @@ if [[ "$(uname -s)" == "Darwin" ]]; then say "ok tunnel $PLIST_DST (15/2 + TCPKeepAlive, port $FLEET_PORT, alias ${SSH_ALIAS:-unspecified})" else if [[ -z "$SSH_ALIAS" ]]; then - die "no sshAlias in $FLEET_CONFIG — refusing to install a tunnel that cannot resolve its host (add canonical.sshAlias)" + die "no sshAlias in the fleet topology (the projection carries no canonical.sshAlias) — refusing to install a tunnel that cannot resolve its host (add canonical.sshAlias and re-enroll)" fi mkdir -p "$(dirname "$PLIST_DST")" sed -e "s/FLEET_SSH_ALIAS/${SSH_ALIAS}/g" -e "s/127\.0\.0\.1:4096:127\.0\.0\.1:4096/127.0.0.1:${FLEET_PORT}:127.0.0.1:${FLEET_PORT}/g" "$PLIST_SRC" > "$PLIST_DST" From 8589e79b20e1b874e5f3877f2eed1f88c5f5f1d9 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Mon, 14 Sep 2026 00:50:04 +0200 Subject: [PATCH 5/5] feat: #1106 the plugin's fleet role read goes to the projection; the F1 triple-consumer replay + n_fleet_topology_parsers==1 (P3b-2 commit 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - opencode-plugin/stack_state.ts: readFleetRole reads the projection cache's topology section (AMICO_FLEET_PROJECTION override replaces AMICO_FLEET_CONFIG) — the plugin is a projection consumer, dependency-free, silent-on-optional (absent/broken → no fleet section, byte-identical to the standalone base; never a raw-file fallback); the health read (fleet-status.json) is untouched — row 6's slice owns that - stack_state tests: projection fixtures (server/client/absent/mode-only/corrupt) - new fleet_f1_triple_consumer_replay.test.ts — spec §8 F1: the SAME absent/ broken/stale topology cases through all THREE consumers (extension unit seam + real bash guard/installer execs with fabricated HOME/PATH + a fake amico), and the counter: ZERO raw fleet-config parsers on the amicode side (source-scanned across src, the plugin, and both script copies) + the ONE parser pinned behind the CLI door (the verb's python3 -m fleet_authority invocation + @amicode/schema reader) == n_fleet_topology_parsers == 1 --- .../extension/opencode-plugin/stack_state.ts | 43 ++- .../fleet_f1_triple_consumer_replay.test.ts | 259 ++++++++++++++++++ packages/extension/test/stack_state.test.ts | 56 +++- 3 files changed, 332 insertions(+), 26 deletions(-) create mode 100644 packages/extension/test/fleet_f1_triple_consumer_replay.test.ts diff --git a/packages/extension/opencode-plugin/stack_state.ts b/packages/extension/opencode-plugin/stack_state.ts index 74ccb53a7..da9264a67 100644 --- a/packages/extension/opencode-plugin/stack_state.ts +++ b/packages/extension/opencode-plugin/stack_state.ts @@ -308,11 +308,20 @@ function buildLiveRunsBlock(): string { // ── Fleet state ────────────────────────────────────────────────────────────── -function fleetConfigFile(override?: string): string { +// #1106 (fleet rearchitect P3b-2): the fleet role comes from the projection +// cache — /.amico/ops/fleet/projection.json, refreshed by +// `amico fleet status --projection`. The machine-local fleet config file has +// exactly ONE parser (amicissimo's fleet authority, behind the CLI); this +// plugin is a projection consumer (it runs in opencode's Bun runtime and +// must stay dependency-free, so it reads the cached artifact +// shape-tolerantly — contract validation is the extension's reader's job, +// and the plugin never falls back to the raw file). + +function fleetProjectionFile(override?: string): string { if (override) return override; - const env = process.env.AMICO_FLEET_CONFIG; + const env = process.env.AMICO_FLEET_PROJECTION; if (env && env.trim() !== "") return env.trim(); - return path.join(os.homedir(), ".amico", "ops", "fleet", "fleet.json"); + return path.join(os.homedir(), ".amico", "ops", "fleet", "projection.json"); } function fleetStatusFile(override?: string): string { @@ -322,12 +331,21 @@ function fleetStatusFile(override?: string): string { return path.join(os.homedir(), ".amico", "ops", "fleet-status.json"); } -/** Fleet role from fleet.json — "server" | "client" | "standalone". - * No file = null (a standalone machine has no fleet to report). */ -function readFleetRole(configPath?: string): string | null { +/** Fleet role from the projection cache's topology section — + * "server" | "client" | "standalone" (verbatim; anything else surfaces as + * itself). Absent/unreadable projection, or one with no topology section, + * = null (the base default — a standalone machine has nothing to report). */ +function readFleetRoleFromProjection(projectionPath?: string): string | null { try { - const parsed = JSON.parse(fs.readFileSync(fleetConfigFile(configPath), "utf8")) as Record; - return typeof parsed.role === "string" && parsed.role !== "" ? parsed.role : null; + const parsed = JSON.parse(fs.readFileSync(fleetProjectionFile(projectionPath), "utf8")) as Record; + const sections = parsed.sections; + if (typeof sections !== "object" || sections === null) return null; + const topology = (sections as Record).topology; + if (typeof topology !== "object" || topology === null) return null; + const value = (topology as Record).value; + if (typeof value !== "object" || value === null) return null; + const role = (value as Record).role; + return typeof role === "string" && role !== "" ? role : null; } catch { return null; } @@ -371,9 +389,10 @@ function readFleetStatus(statusPath?: string): FleetStatusSummary | undefined { /** Lean fleet line + on-demand pointers (the reader's choice: detail loads * from fleet-status.json / the fleet skill only when relevant). Absent - * fleet.json (standalone or no fleet tooling) → "" — nothing to say. */ -function buildFleetSection(opts: { configPath?: string; statusPath?: string } = {}): string { - const role = readFleetRole(opts.configPath); + * projection (standalone or no fleet tooling — or a projection with no + * topology section) → "" — nothing to say. */ +function buildFleetSection(opts: { projectionPath?: string; statusPath?: string } = {}): string { + const role = readFleetRoleFromProjection(opts.projectionPath); if (role === null) return ""; const roleText = @@ -382,7 +401,7 @@ function buildFleetSection(opts: { configPath?: string; statusPath?: string } = : role === "client" ? "**client** — rides the tunnel to the canonical server" : `**${role}**`; - const lines = [`## Fleet (live)`, `Role: ${roleText} (\`~/.amico/ops/fleet/fleet.json\`).`]; + const lines = [`## Fleet (live)`, `Role: ${roleText} (from the fleet projection at \`~/.amico/ops/fleet/projection.json\`).`]; const status = readFleetStatus(opts.statusPath); if (status) { diff --git a/packages/extension/test/fleet_f1_triple_consumer_replay.test.ts b/packages/extension/test/fleet_f1_triple_consumer_replay.test.ts new file mode 100644 index 000000000..94be84b45 --- /dev/null +++ b/packages/extension/test/fleet_f1_triple_consumer_replay.test.ts @@ -0,0 +1,259 @@ +// fleet_f1_triple_consumer_replay.test.ts — spec spec-20260913-114814 §8 F1 +// (amicode#1106, fleet rearchitect P3b-2): the SAME stale / broken / absent +// topology cases fed to ALL THREE consumers — the extension (fleet_topology's +// projection read), the installer (the verb's machine-parseable output), the +// guard (the projection cache) — each through its real surface (unit seam for +// the extension; real bash execs with fabricated HOME/PATH for the scripts). +// Plus the F1 counter: `n_fleet_topology_parsers == 1` — ZERO raw fleet +// config parsers survive on the amicode side (source-scanned), and the ONE +// parser is amicissimo's, pinned behind the `amico fleet` CLI door (the verb's +// publisher invocation + the @amicode/schema reader). +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { spawnSync } from "node:child_process"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { readFleetTopology, readFleetTopologyWithRefresh, type VerbRunResult } from "../src/fleet_topology"; + +const REPO = join(__dirname, "..", "..", ".."); +const GUARD = join(REPO, "tools", "fleet", "amico-opencode-fleet-guard"); +const INSTALL = join(REPO, "tools", "fleet", "install.sh"); + +let tmp: string; +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "fleet-f1-")); +}); +afterEach(() => rmSync(tmp, { recursive: true, force: true })); + +const EPOCH_A = "44444444-4444-4444-8444-444444444444"; +const EPOCH_B = "55555555-5555-4555-8555-555555555555"; + +// ── the SHARED fixture set — one topology truth, three consumers ─────────────── + +/** The client projection (a re-publish with the same counter = the STALE case). */ +const CLIENT_PROJECTION = (epoch: string = EPOCH_A, counter: number = 4) => JSON.stringify({ + schema_version: 1, + contract_version: 1, + publisher: { identity: "fleet_authority", published_at: "2026-09-13T12:00:00Z" }, + freshness: { counter, hub_epoch: epoch }, + sections: { + mode: { value: "fleet", provenance: { source: "fleet config", parsed_from: "role='client' (vocabulary mapping)" } }, + posture: { value: "ok" }, + topology: { + value: { role: "client", canonical: { host: "hq-hub-01.example.internal", port: 4096, sshAlias: "hq-hub-01" } }, + provenance: { source: "fleet config" }, + }, + }, +}, null, 2); + +/** The BROKEN case: a projection speaking a contract this consumer does not. */ +const FUTURE_CONTRACT_PROJECTION = JSON.stringify({ + schema_version: 1, + contract_version: 2, + sections: { topology: { value: { role: "client" } } }, +}, null, 2); + +function writeCache(content: string): void { + const dir = join(tmp, ".amico", "ops", "fleet"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "projection.json"), content); +} + +function fakeAmico(behavior: { code: number; stdout: string; cacheContent?: string }): void { + const bin = join(tmp, "fakebin"); + mkdirSync(bin, { recursive: true }); + const lines = ["#!/usr/bin/env bash", "set -u"]; + if (behavior.cacheContent !== undefined) { + lines.push(`mkdir -p "$HOME/.amico/ops/fleet"`, `cat > "$HOME/.amico/ops/fleet/projection.json" << 'AMICO_FAKE_EOF'`, behavior.cacheContent, "AMICO_FAKE_EOF"); + } + lines.push("cat << 'AMICO_STDOUT_EOF'", behavior.stdout, "AMICO_STDOUT_EOF", `exit ${behavior.code}`); + writeFileSync(join(bin, "amico"), lines.join("\n") + "\n"); + chmodSync(join(bin, "amico"), 0o755); +} + +function fakeFrozenBinary(): void { + const dir = join(tmp, ".amico", "server", "bin"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "opencode"), "#!/usr/bin/env bash\necho FROZEN-EXEC\n"); + chmodSync(join(dir, "opencode"), 0o755); +} + +const guardEnvPath = (): string => `${join(tmp, "fakebin")}:/usr/bin:/bin`; + +/** A launcher-less fake repo — the installer's CLI-absent branch is only + * reachable when the repo's own launchers are absent too (a dev checkout + * ships them). */ +function fakeRepoInstall(): string { + const repo = join(tmp, "fake-repo"); + mkdirSync(join(repo, "tools", "fleet"), { recursive: true }); + for (const f of ["install.sh", "amico-opencode-fleet-guard", "co.harmoniqs.amico-tunnel.plist"]) { + writeFileSync(join(repo, "tools", "fleet", f), readFileSync(join(REPO, "tools", "fleet", f), "utf8")); + } + return join(repo, "tools", "fleet", "install.sh"); +} + +function runBash(script: string, args: string[], pathOverride?: string): { code: number; out: string } { + const r = spawnSync("bash", [script, ...args], { + env: { HOME: tmp, PATH: pathOverride ?? guardEnvPath() }, + encoding: "utf8", + timeout: 60_000, + }); + return { code: r.status ?? -1, out: `${r.stdout ?? ""}${r.stderr ?? ""}` }; +} + +// ── the replay: one case, three consumers, consistent outcomes ──────────────── + +describe("F1: the triple-consumer replay — the SAME topology cases through all three", () => { + it("ABSENT topology: all three consumers land on the honest base-standalone bootstrap — stated, never silent, never raw", () => { + // The extension: absent cache + verb 75 → the bootstrap exception, spawn-allowed floor + const ext = readFleetTopologyWithRefresh({ + cachePath: join(tmp, "absent-projection.json"), + runVerb: () => ({ code: 75, stdout: "base-standalone (bootstrap exception)", stderr: "" }) as VerbRunResult, + }); + expect(ext.state.kind).toBe("absent"); + expect(ext.bootstrap).not.toBeNull(); + if (ext.bootstrap !== null) { + expect(ext.bootstrap.reason).toBe("verb-75"); + expect(ext.bootstrap.stated).toMatch(/base-standalone/); + } + + // The guard: absent cache + verb 75 → stated + spawn (execs the frozen binary) + fakeAmico({ code: 75, stdout: "base-standalone (bootstrap exception)" }); + fakeFrozenBinary(); + const guard = runBash(GUARD, []); + expect(guard.code).toBe(0); + expect(guard.out).toMatch(/base-standalone \(bootstrap exception/); + expect(guard.out).toMatch(/FROZEN-EXEC/); // the floor's local spawn + expect(guard.out).toMatch(/stated, never silent/); + + // The installer: CLI absent (a launcher-less install surface) → the IDENTICAL stated branch, exit 0 + const installer = runBash(fakeRepoInstall(), ["--check"], "/usr/bin:/bin"); + expect(installer.code).toBe(0); + expect(installer.out).toMatch(/base-standalone \(bootstrap exception/); + expect(installer.out).toMatch(/amico CLI is absent/); + }); + + it("ABSENT topology that the verb REPAIRS to client: all three consumers land on client (the enrolled-machine bootstrap)", () => { + // The extension: absent → refresh (verb 0, writes the cache) → ok/client + const cachePath = join(tmp, ".amico", "ops", "fleet", "projection.json"); + const ext = readFleetTopologyWithRefresh({ + cachePath, + runVerb: () => { + writeCache(CLIENT_PROJECTION()); + return { code: 0, stdout: "{}", stderr: "" } as VerbRunResult; + }, + }); + expect(ext.refreshed).toBe(true); + if (ext.state.kind !== "ok") throw new Error("expected ok"); + expect(ext.state.role).toBe("client"); + + // The guard: absent → refresh → refuses to spawn + fakeAmico({ code: 0, stdout: "{}", cacheContent: CLIENT_PROJECTION() }); + const guard = runBash(GUARD, []); + expect(guard.code).toBe(1); + expect(guard.out).toMatch(/refusing to spawn/); + + // The installer: verb 0 with role client → the fleet branch + fakeAmico({ code: 0, stdout: JSON.stringify({ ok: true, role: "client", canonical: { host: "hq", port: 4096, sshAlias: "hq" } }) }); + const installer = runBash(INSTALL, ["--check"], `${join(tmp, "fakebin")}:${process.env.PATH ?? ""}`); + expect(installer.out).toMatch(/fleet role: client \(port: 4096\)/); + }); + + it("BROKEN topology (future contract): every consumer refuses loudly — the extension surfaces both versions, the guard fails closed, the installer dies", () => { + // The extension: the reader's loud rejection, both versions named + writeCache(FUTURE_CONTRACT_PROJECTION); + const ext = readFleetTopology({ cachePath: join(tmp, ".amico", "ops", "fleet", "projection.json") }); + expect(ext.kind).toBe("broken"); + if (ext.kind !== "broken") return; + expect(ext.detail).toContain("v2"); + expect(ext.detail).toContain("v1"); + + // The guard: unusable cache (contract not v1) + verb failure → fails closed + fakeAmico({ code: 64, stdout: "projection carries contract v2; this consumer speaks v1 — refusing loudly" }); + fakeFrozenBinary(); + const guard = runBash(GUARD, []); + expect(guard.code).toBe(1); + expect(guard.out).not.toMatch(/FROZEN-EXEC/); // never a silent fork on an unreadable topology + + // The installer: the verb's reader rejected the publisher's artifact → exit 64 → dies honestly + const installer = runBash(INSTALL, ["--check"], `${join(tmp, "fakebin")}:${process.env.PATH ?? ""}`); + expect(installer.code).toBe(1); + expect(installer.out).not.toMatch(/standalone mode — nothing to install/); // never silently "standalone" + }); + + it("STALE topology (same counter re-published): the extension surfaces the verdict, the guard still refuses on client, the installer still takes the fleet branch", () => { + // The extension: two reads of the same counter → the stale verdict surfaces + const cachePath = join(tmp, "projection.json"); + writeFileSync(cachePath, CLIENT_PROJECTION()); + const first = readFleetTopology({ cachePath }); + const second = readFleetTopology({ cachePath, previous: first.kind === "ok" ? first.projection : null }); + if (second.kind !== "ok") throw new Error("expected ok"); + expect(second.verdict).toBe("stale"); + expect(second.advisory).toMatch(/nothing new was published/); + + // The guard: staleness never flips the role — client still refuses + writeCache(CLIENT_PROJECTION()); + const guard = runBash(GUARD, []); + expect(guard.code).toBe(1); + expect(guard.out).toMatch(/refusing to spawn/); + + // The installer: the verb's output still carries role=client → the fleet branch + fakeAmico({ code: 0, stdout: JSON.stringify({ ok: true, role: "client", canonical: { host: "hq", port: 4096, sshAlias: "hq" } }) }); + const installer = runBash(INSTALL, ["--check"], `${join(tmp, "fakebin")}:${process.env.PATH ?? ""}`); + expect(installer.out).toMatch(/fleet role: client \(port: 4096\)/); + }); +}); + +// ── the F1 counter: n_fleet_topology_parsers == 1 ────────────────────────────── + +describe("F1: n_fleet_topology_parsers == 1 (the one parser is amicissimo's, behind the CLI)", () => { + const scanFiles = (dir: string): string[] => + readdirSync(dir, { withFileTypes: true }).flatMap((e) => + e.isDirectory() ? scanFiles(join(dir, e.name)) : e.name.endsWith(".ts") ? [join(dir, e.name)] : [], + ); + + it("ZERO raw fleet-config parsers survive on the amicode side (the extension src + the plugin)", () => { + const surfaces = [ + ...scanFiles(join(REPO, "packages", "extension", "src")), + join(REPO, "packages", "extension", "opencode-plugin", "stack_state.ts"), + ]; + const rawParses: string[] = []; + for (const f of surfaces) { + const src = readFileSync(f, "utf8"); + // the raw-parse idiom: a read of the fleet config followed by a parse + if (/readFileSync\s*\([^)]*fleet\.json/.test(src) || /fleet\.json[\s\S]{0,120}readFileSync/.test(src)) rawParses.push(f); + // the plugin must not even name the raw file (it reads the projection) + if (f.endsWith("stack_state.ts") && /fleet\.json/.test(src)) rawParses.push(f); + } + expect(rawParses).toEqual([]); + }); + + it("ZERO raw fleet-config parsers in the bash consumers (repo copies AND the VSIX's packaged copies)", () => { + const scripts = [ + join(REPO, "tools", "fleet", "install.sh"), + join(REPO, "tools", "fleet", "amico-opencode-fleet-guard"), + join(REPO, "packages", "extension", "tools", "fleet", "install.sh"), + join(REPO, "packages", "extension", "tools", "fleet", "amico-opencode-fleet-guard"), + ]; + for (const s of scripts) { + const src = readFileSync(s, "utf8"); + expect(src, `${s} names a raw config path`).not.toMatch(/FLEET_CONFIG=/); + expect(src, `${s} greps a role from the raw config`).not.toMatch(/grep[^#]*"role"[^#]*fleet\.json/); + } + }); + + it("the ONE parser is amicissimo's, behind the CLI door: the verb pins the publisher invocation + reads through @amicode/schema", () => { + const verb = readFileSync(join(REPO, "packages", "amico-run", "src", "fleet_projection_verb.ts"), "utf8"); + expect(verb).toMatch(/"-m",\s*"fleet_authority",\s*"publish"/); // the pinned publisher door + expect(verb).toMatch(/from "@amicode\/schema"/); // the ONE reader, consumed never re-defined + // the cache convention is written by the verb and read by the consumers + expect(verb).toMatch(/fleetProjectionCachePath/); + }); + + it("the composed counter: amicode-side parsers (0) + the CLI door's parser (1) == n_fleet_topology_parsers == 1", () => { + const amicodeSide: number = 0; // proven by the two scans above (this test composes the fixture's counter) + const cliDoor: number = 1; // amicissimo's fleet_authority, pinned behind `amico fleet status --projection` + const n_fleet_topology_parsers = amicodeSide + cliDoor; + expect(n_fleet_topology_parsers).toBe(1); + }); +}); diff --git a/packages/extension/test/stack_state.test.ts b/packages/extension/test/stack_state.test.ts index 321f9ef46..90c174701 100644 --- a/packages/extension/test/stack_state.test.ts +++ b/packages/extension/test/stack_state.test.ts @@ -69,15 +69,32 @@ function mkProblemCard( // ── Fleet section ──────────────────────────────────────────────────────────── describe("buildFleetSection (lean fleet line + pointers)", () => { - it("no fleet.json (standalone machine) → no section", () => { + it("no projection (standalone machine — the base default) → no section", () => { const dir = mkTmp("fleet-"); - const s = fleetSectionWith({ configPath: path.join(dir, "absent.json") }); + const s = fleetSectionWith({ projectionPath: path.join(dir, "absent.json") }); expect(s).toBe(""); }); + it("a projection with no topology section (mode-only standalone) → no section", () => { + const dir = mkTmp("fleet-"); + const proj = path.join(dir, "projection.json"); + fs.writeFileSync(proj, JSON.stringify({ + schema_version: 1, + contract_version: 1, + sections: { mode: { value: "standalone" } }, + })); + expect(fleetSectionWith({ projectionPath: proj })).toBe(""); + }); it("server role with live status renders role, devices, freshness", () => { const dir = mkTmp("fleet-"); - const cfg = path.join(dir, "fleet.json"); - fs.writeFileSync(cfg, JSON.stringify({ role: "server", canonical: { host: "127.0.0.1", port: 4096 } })); + const proj = path.join(dir, "projection.json"); + fs.writeFileSync(proj, JSON.stringify({ + schema_version: 1, + contract_version: 1, + sections: { + mode: { value: "fleet" }, + topology: { value: { role: "server", canonical: { host: "127.0.0.1", port: 4096 } } }, + }, + })); const status = path.join(dir, "fleet-status.json"); fs.writeFileSync( status, @@ -90,9 +107,10 @@ describe("buildFleetSection (lean fleet line + pointers)", () => { ], }), ); - const s = fleetSectionWith({ configPath: cfg, statusPath: status }); + const s = fleetSectionWith({ projectionPath: proj, statusPath: status }); expect(s).toContain("## Fleet (live)"); expect(s).toContain("**server** — this machine is the canonical Amicode server"); + expect(s).toContain("projection.json"); // the #1106 read path is named in the rendered line expect(s).toContain("Devices: 2/3 reachable (mini, macbook, erlich)"); expect(s).toContain("refreshed 0 min ago"); expect(s).toContain("fleet-status.json"); @@ -100,18 +118,28 @@ describe("buildFleetSection (lean fleet line + pointers)", () => { }); it("unreadable status degrades to 'status unknown', not an error", () => { const dir = mkTmp("fleet-"); - const cfg = path.join(dir, "fleet.json"); - fs.writeFileSync(cfg, JSON.stringify({ role: "client" })); - const s = fleetSectionWith({ configPath: cfg, statusPath: path.join(dir, "nope.json") }); + const proj = path.join(dir, "projection.json"); + fs.writeFileSync(proj, JSON.stringify({ + schema_version: 1, + contract_version: 1, + sections: { topology: { value: { role: "client" } } }, + })); + const s = fleetSectionWith({ projectionPath: proj, statusPath: path.join(dir, "nope.json") }); expect(s).toContain("**client** — rides the tunnel to the canonical server"); expect(s).toContain("status unknown"); }); + it("a corrupt/unreadable projection → no section (silent-on-optional, the plugin's discipline — never a raw-file fallback)", () => { + const dir = mkTmp("fleet-"); + const proj = path.join(dir, "projection.json"); + fs.writeFileSync(proj, "{ corrupt"); + expect(fleetSectionWith({ projectionPath: proj })).toBe(""); + }); }); // buildFleetSection is module-private; reach it through buildStackStateBlock's -// seams for these unit cases (config + status stubbed, everything else empty). -function fleetSectionWith(opts: { configPath?: string; statusPath?: string }): string { - const stubs = stubAllSeams({ fleetConfig: opts.configPath, fleetStatus: opts.statusPath }); +// seams for these unit cases (projection + status stubbed, everything else empty). +function fleetSectionWith(opts: { projectionPath?: string; statusPath?: string }): string { + const stubs = stubAllSeams({ fleetProjection: opts.projectionPath, fleetStatus: opts.statusPath }); try { const block = buildStackStateBlock() ?? ""; const m = block.match(/## Fleet \(live\)[\s\S]*?(?=\n\n## |\n*$)/); @@ -725,7 +753,7 @@ describe("recent problems derived from problem-card frontmatter (KNOWLEDGE.md zo interface SeamOpts { vaultsRoot?: string; - fleetConfig?: string; + fleetProjection?: string; fleetStatus?: string; runsDir?: string; /** Prebuilt fixture vault flavor for the golden-text cases. */ @@ -734,7 +762,7 @@ interface SeamOpts { const SEAM_KEYS = [ "AMICO_VAULTS_ROOT", - "AMICO_FLEET_CONFIG", + "AMICO_FLEET_PROJECTION", "AMICO_FLEET_STATUS", "AMICODE_OPS_DIR", "AMICODE_CONNECTIONS_FILE", @@ -795,7 +823,7 @@ function stubAllSeams(opts: SeamOpts): Record { const runs = path.join(mkTmp("runs-"), "none"); const fleetDir = mkTmp("fleetdir-"); process.env.AMICO_VAULTS_ROOT = root; - process.env.AMICO_FLEET_CONFIG = opts.fleetConfig ?? path.join(fleetDir, "absent-fleet.json"); + process.env.AMICO_FLEET_PROJECTION = opts.fleetProjection ?? path.join(fleetDir, "absent-projection.json"); process.env.AMICO_FLEET_STATUS = opts.fleetStatus ?? path.join(fleetDir, "absent-status.json"); process.env.AMICODE_OPS_DIR = ops; // no solver-mode.json → piccolo/ready → no section process.env.AMICODE_CONNECTIONS_FILE = path.join(conn, "absent.json"); // not connected