From 18cc12950fe142921f1b9f8e283de4c0b3b0089a Mon Sep 17 00:00:00 2001 From: amicode-ci Date: Thu, 24 Sep 2026 16:13:27 -0400 Subject: [PATCH 1/5] =?UTF-8?q?Fleet=20Studio=20B2b=20(#1541):=20Grant=20f?= =?UTF-8?q?oundation=20=E2=80=94=20Enroll=20seeds=20lifecycle-admin=20auth?= =?UTF-8?q?ority=20+=20self-owned=20control=20issuance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grant foundation slice of ADR 0034 (D3): the NET-NEW lifecycle-admin authority persistence, the self-owned control fast-path, and the owner-resolvable control grant read #1542 needs. All additive; peer studios stay additive. - @amicode/schema: fleet_lifecycle_authority.ts — the shared on-disk authority store contract (record shape, path, writer, resolver) at ~/.amico/fleet-lifecycle-authority.json, env-overridable. The cross-package seam is a shared on-disk contract (amico-run writes, the extension reads), the same pattern as fleet_roster/fleet_config — never a cross-package import. - amico-run: `amico fleet enroll` (client redeem) records the enroller as this machine's lifecycle-admin authority via an injectable recordLifecycleAuthority seam with a real default — enroll stays byte-identical when defaulted. - extension fleet_lifecycle_authority.ts: the service-side resolver ("who holds authority for machine X"), feeding management-verification and #1545 routing. - extension fleet_control_bootstrap.ts: evaluateControlBootstrap (a NEW predicate mirroring evaluateObserveBootstrap, which refuses control) — self-owned + management-verified authorizes; every other case requires approval (no privilege bleed). management-verified is a DEFINED predicate (enroll-seeded authority + serving peer + held reader token). enableSelfOwnedControl mints an ACTIVE control grant on the explicit enable, no target-side interaction. - fleet_control_lifecycle.ts: findControlGrantByTarget — resolves the controlling machine's own active control grant by targetMachineId, with its token (ADR D2). - AC3 (control-not-auto-restored) is a regression guard on the landed fleet_headless_rehydration.ts — a test asserts it, no re-implementation. Tests: new fleet_control_bootstrap + fleet_enroll_authority suites; extended fleet_control_lifecycle, fleet_headless_rehydration, and amico-run fleet_enroll_verb suites. All touched suites green; typecheck clean; amico-run bundle gate + INVARIANT_STRICT additive gate pass. --- packages/amico-run/src/fleet_enroll_verb.ts | 25 ++ .../amico-run/test/fleet_enroll_verb.test.ts | 50 +++- .../fleet_control_bootstrap.ts | 156 +++++++++++ .../fleet_control_lifecycle.ts | 18 ++ .../fleet_lifecycle_authority.ts | 41 +++ .../test/fleet_control_bootstrap.test.ts | 253 ++++++++++++++++++ .../test/fleet_control_lifecycle.test.ts | 67 +++++ .../test/fleet_enroll_authority.test.ts | 103 +++++++ .../test/fleet_headless_rehydration.test.ts | 47 ++++ .../schema/src/fleet_lifecycle_authority.ts | 148 ++++++++++ packages/schema/src/index.ts | 13 + 11 files changed, 919 insertions(+), 2 deletions(-) create mode 100644 packages/extension/src/amicode_service/fleet_control_bootstrap.ts create mode 100644 packages/extension/src/amicode_service/fleet_lifecycle_authority.ts create mode 100644 packages/extension/test/fleet_control_bootstrap.test.ts create mode 100644 packages/extension/test/fleet_enroll_authority.test.ts create mode 100644 packages/schema/src/fleet_lifecycle_authority.ts diff --git a/packages/amico-run/src/fleet_enroll_verb.ts b/packages/amico-run/src/fleet_enroll_verb.ts index 9d451812..91fc10c5 100644 --- a/packages/amico-run/src/fleet_enroll_verb.ts +++ b/packages/amico-run/src/fleet_enroll_verb.ts @@ -43,12 +43,14 @@ import { randomUUID } from "node:crypto"; import { versionSkewVerdict, writeFleetConfig as schemaWriteFleetConfig, + recordLifecycleAuthority as schemaRecordLifecycleAuthority, fleetTopologyPath, classifyMacModel, classifyLinuxChassis, normalizeDeviceName, isWslKernel, type FleetConfig, + type LifecycleAuthorityRecord, type RosterRow, type RosterHealth, } from "@amicode/schema"; @@ -134,6 +136,13 @@ export interface FleetEnrollDeps { fetchImpl?: typeof fetch; /** The fleet.json writer. Default: the hoisted @amicode/schema writeFleetConfig. */ writeFleetConfig?: (config: FleetConfig, p: string) => void; + /** Seed the lifecycle-admin authority (#1541, ADR 0034 D3): record the + * enroller as THIS machine's authority at client redeem — net-new + * persistence, the same injectable-with-real-default pattern as + * writeFleetConfig/mintFleetToken (enroll stays byte-identical when + * defaulted; a test injects a spy). Default: the shared @amicode/schema + * authority-store writer. */ + recordLifecycleAuthority?: (record: LifecycleAuthorityRecord) => void; /** Where fleet.json lives. Default: fleetTopologyPath(). */ fleetConfigPath?: string; /** Write the join token at 0600. Default: atomic 0600 write. */ @@ -617,6 +626,22 @@ async function enrollAsClient(argv: string[], token: JoinToken, deps: FleetEnrol const now = (deps.now ?? (() => new Date().toISOString()))(); + // ── seed lifecycle-admin authority (#1541, ADR 0034 D3) ── + // Record the enroller (the canonical server) as THIS machine's lifecycle-admin + // authority — the net-new persistence that makes a headless target approvable + // from a UI-bearing authority machine (the target only ever ENFORCES). It runs + // AFTER the pin check + fleet.json commit (a refused enroll returns before this + // and seeds nothing) and is a pure side-addition — the enroll result, roster + // row, and join token are byte-identical to before. The authority's identity + // anchor is the canonical server's stable id (keypair fingerprints are not + // minted at enroll yet — #1477's rollout will thread a real fingerprint here). + (deps.recordLifecycleAuthority ?? ((rec: LifecycleAuthorityRecord) => schemaRecordLifecycleAuthority(rec)))({ + targetMachineId: machineId, + authorityMachineId: canonical.host, + authorityIdentityKey: canonical.host, + recordedAt: now, + }); + // ── register the roster row (#1318 POST /amicode/roster), provisional ── const provisionalRow: RosterRow = { machine_id: machineId, diff --git a/packages/amico-run/test/fleet_enroll_verb.test.ts b/packages/amico-run/test/fleet_enroll_verb.test.ts index eb500338..09646abc 100644 --- a/packages/amico-run/test/fleet_enroll_verb.test.ts +++ b/packages/amico-run/test/fleet_enroll_verb.test.ts @@ -11,7 +11,12 @@ import { describe, it, expect, afterEach } from "vitest"; import * as http from "node:http"; import { hostname as osHostname } from "node:os"; import type { AddressInfo } from "node:net"; -import { parseRosterRow, normalizeDeviceName, type RosterRow } from "@amicode/schema"; +import { + parseRosterRow, + normalizeDeviceName, + type RosterRow, + type LifecycleAuthorityRecord, +} from "@amicode/schema"; import { fleetEnroll, parseJoinToken, @@ -160,6 +165,7 @@ interface Recorder { installerCalls: string[]; transportSets: string[]; engine: { spawns: number }; + authorityRecords: LifecycleAuthorityRecord[]; } function recorder(over: Partial = {}): Recorder { const fleetWrites: { config: unknown; path: string }[] = []; @@ -167,6 +173,7 @@ function recorder(over: Partial = {}): Recorder { const installerCalls: string[] = []; const transportSets: string[] = []; const engine = { spawns: 0 }; + const authorityRecords: LifecycleAuthorityRecord[] = []; const deps: FleetEnrollDeps = { machineId: () => "machine-abc", machineName: () => "workbench", @@ -191,9 +198,12 @@ function recorder(over: Partial = {}): Recorder { // real settings.json read. Individual cases override these seams. commandRunner: () => "", readDeviceSetting: () => undefined, + // Capture the lifecycle-admin authority record (#1541) instead of writing + // the real ~/.amico store — keeps every enroll test hermetic. + recordLifecycleAuthority: (rec) => authorityRecords.push(rec), ...over, }; - return { deps, fleetWrites, joinTokenWrites, installerCalls, transportSets, engine }; + return { deps, fleetWrites, joinTokenWrites, installerCalls, transportSets, engine, authorityRecords }; } const stubs: EnrollStub[] = []; @@ -358,6 +368,42 @@ describe("amico fleet enroll — client redeem happy path (#1319 AC }); }); +describe("amico fleet enroll — client redeem seeds lifecycle-admin authority (#1541 AC1)", () => { + it("records the enroller (canonical.host) as this machine's lifecycle-admin authority — persisted + resolvable", async () => { + const s = await stub({ version: "v1.18.29" }); + const rec = recorder(); + const token = tokenFor(s); // canonical.host = s.host (the enroller / canonical server) + const r = await fleetEnroll(["--join-token-json", JSON.stringify(token)], rec.deps); + + expect(r.code).toBe(0); + expect(rec.authorityRecords).toHaveLength(1); + const a = rec.authorityRecords[0]; + // the target is THIS machine (the one running Enroll); the authority is the enroller. + expect(a.targetMachineId).toBe("machine-abc"); + expect(a.authorityMachineId).toBe(token.canonical.host); + expect(a.authorityIdentityKey).toBe(token.canonical.host); + expect(typeof a.recordedAt).toBe("string"); + }); + + it("a pin-mismatch refusal seeds NO authority (nothing recorded on a refused enroll)", async () => { + const s = await stub({ version: "v2.0.0" }); // major skew → refused before any write + const rec = recorder(); + const r = await fleetEnroll( + ["--join-token-json", JSON.stringify(tokenFor(s, { pin_version: "v1.18.29" }))], + rec.deps, + ); + expect(r.code).not.toBe(0); + expect(rec.authorityRecords).toEqual([]); + }); + + it("the server path (--as-server) records no authority-over-a-target (it establishes the keeper, not a grantee)", async () => { + const rec = recorder(); + const r = await fleetEnroll(["--as-server", "--host", "hub", "--port", "4096", "--ssh-alias", "hub"], rec.deps); + expect(r.code).toBe(0); + expect(rec.authorityRecords).toEqual([]); + }); +}); + describe("amico fleet enroll — pin check refuses a skewed token BEFORE any write (#1319 AC4)", () => { it("rejects a join token whose pin_version disagrees with the host-version probe, writing NOTHING", async () => { const s = await stub({ version: "v2.0.0" }); // host is v2.x; token pins v1.18.29 → major skew diff --git a/packages/extension/src/amicode_service/fleet_control_bootstrap.ts b/packages/extension/src/amicode_service/fleet_control_bootstrap.ts new file mode 100644 index 00000000..79aaace7 --- /dev/null +++ b/packages/extension/src/amicode_service/fleet_control_bootstrap.ts @@ -0,0 +1,156 @@ +// SELF-OWNED CONTROL BOOTSTRAP (#1541, ADR 0034 D3) — the NET-NEW control-scoped +// bootstrap decision. It structurally MIRRORS evaluateObserveBootstrap +// (fleet_observe_bootstrap.ts) but is a SEPARATE predicate: the observe +// bootstrap EXPLICITLY refuses a control outcome ("there is NO control outcome +// here — deliberately NOT implemented"). This is that deliberately-omitted +// control seam, authored per D3. +// +// The decision: a SELF-OWNED peer with VERIFIED MANAGEMENT ACCESS authorizes the +// explicit "Enable control" act; every other case (a shared peer, or a +// self-owned peer without verified management access) requires approval — +// routed, for a shared peer, to the request→approve handshake (slice 5). Unlike +// observe, there is NO targetApproved fast-path here: a shared peer can never +// mint control directly from this decision, so the no-privilege-bleed invariant +// holds (a shared peer NEVER borrows the self-owned fast-path). +// +// "Verified management access" (`managementVerified`) is a DEFINED predicate, +// not the bare boolean the observe path takes on trust — it composes the +// enroll-seeded lifecycle-admin authority + a serving peer + a held reader token +// (the identity/transport/trust triad, ordered as in fleet_headless_rehydration). +// +// On authorize, `enableSelfOwnedControl` mints an ACTIVE `control` grant via the +// existing grant machinery (issueLifecycleGrant) — self-issued, so the grant is +// keyed by the controlling machine (requester) and carries the driven owner as +// its `targetMachineId`, making it owner-resolvable via findControlGrantByTarget. +// Control lands per-session and is never auto-restored (fleet_headless_rehydration). +import { + issueLifecycleGrant, + type LifecycleGrant, + type LifecycleGrantDeps, +} from "./fleet_control_lifecycle"; +import type { PeerOwnership } from "./fleet_observe_bootstrap"; +import type { LifecycleAuthorityRecord } from "./fleet_lifecycle_authority"; + +// ── the control bootstrap decision ─────────────────────────────────────────── + +/** The control bootstrap decision inputs. `managementVerified` is the "verified + * management access" the self-owned fast-path requires (a DEFINED predicate — + * see evaluateManagementVerified). There is no `targetApproved` here: a shared + * peer routes to the request→approve handshake, never a direct control grant. */ +export interface ControlBootstrapRequest { + ownership: PeerOwnership; + managementVerified: boolean; +} + +/** The control bootstrap outcome. `authorize-control` = mint control now (the + * self-owned fast-path); `requires-approval` = hold for the handshake (a shared + * peer, or a self-owned peer without verified management access). */ +export type ControlBootstrapDecision = + | { decision: "authorize-control" } + | { decision: "requires-approval" }; + +/** The self-owned control fast-path, pure: a SELF-OWNED peer with VERIFIED + * MANAGEMENT ACCESS authorizes the explicit enable; every other case requires + * approval. Management access is NOT a substitute for ownership — a shared peer + * can never ride this path (no privilege bleed). Mirrors evaluateObserveBootstrap + * structurally, MINUS the targetApproved branch (control has no direct + * shared-peer grant — that is the request→approve handshake, slice 5). */ +export function evaluateControlBootstrap(req: ControlBootstrapRequest): ControlBootstrapDecision { + if (req.ownership === "self-owned" && req.managementVerified) { + return { decision: "authorize-control" }; + } + return { decision: "requires-approval" }; +} + +// ── management-verified: a DEFINED predicate ───────────────────────────────── + +/** The three established facts that constitute "verified management access" to a + * self-owned peer: an enroll-seeded lifecycle-admin authority (naming self), a + * serving peer (transport up), and a held reader token (bilateral token state). + * This is the identity/transport/trust triad the headless rehydration gate + * orders — surfaced here as an explicit, testable predicate rather than a bare + * boolean set true only in tests. */ +export interface ManagementAccessFacts { + /** SELF holds the enroll-seeded lifecycle-admin authority for the peer. */ + authoritySeededForSelf: boolean; + /** The peer is currently serving (in the serving∧reachable set). */ + peerServing: boolean; + /** This machine holds a reader token for the peer (bilateral token state). */ + readerTokenHeld: boolean; +} + +/** Verified management access holds iff ALL three facts hold — the authority is + * seeded for self, the peer is serving, and we hold its reader token. Any + * missing fact fails closed. */ +export function evaluateManagementVerified(facts: ManagementAccessFacts): boolean { + return facts.authoritySeededForSelf && facts.peerServing && facts.readerTokenHeld; +} + +/** The inputs from which the three management-access facts are established: the + * self machine id, the target peer, the serving-peer + reader-token readers + * (the fleet-peer provider's projections), and the authority resolver (the + * enroll-seeded store). */ +export interface ManagementAccessInputs { + selfMachineId: string; + targetMachineId: string; + getServingPeers: () => Array<{ machineId: string }>; + readPeerToken: (machineId: string) => { ok: boolean }; + resolveAuthority: (targetMachineId: string) => LifecycleAuthorityRecord | undefined; +} + +/** Compose the three facts from the live inputs and decide verified management + * access. The authority must be seeded AND name THIS machine (self) — an + * authority naming a different machine does not verify self. */ +export function establishManagementVerified(inp: ManagementAccessInputs): boolean { + const authority = inp.resolveAuthority(inp.targetMachineId); + const authoritySeededForSelf = + authority !== undefined && + (authority.authorityMachineId === inp.selfMachineId || authority.authorityIdentityKey === inp.selfMachineId); + const peerServing = inp.getServingPeers().some((p) => p.machineId === inp.targetMachineId); + const readerTokenHeld = inp.readPeerToken(inp.targetMachineId).ok; + return evaluateManagementVerified({ authoritySeededForSelf, peerServing, readerTokenHeld }); +} + +// ── the explicit enable → mint an active control grant ─────────────────────── + +export interface EnableControlRequest { + ownership: PeerOwnership; + managementVerified: boolean; + /** The controlling machine (the grant's REQUESTER — self-issued). */ + self: { machineId: string; identityKey: string }; + /** The driven session's owner (the grant's TARGET). */ + target: { machineId: string; identityKey: string }; +} + +export type EnableControlResult = + | { ok: true; grant: LifecycleGrant } + | { ok: false; reason: "requires-approval" } + | { ok: false; reason: "issue-failed"; issueReason: "identity-mismatch" | "target-mismatch" | "revoked" }; + +/** The explicit "Enable control" act on a self-owned peer: evaluate the control + * bootstrap and, on authorize, mint an ACTIVE `control` grant via the existing + * grant machinery — self-issued (requester = the controlling machine), so it is + * owner-resolvable by target via findControlGrantByTarget and carries its token. + * There is NO target-side interaction: issuance is a local mutation of the + * controlling machine's own grant store. A non-authorized decision mints + * nothing (held for approval) — a shared peer can never mint here. */ +export function enableSelfOwnedControl(req: EnableControlRequest, deps: LifecycleGrantDeps = {}): EnableControlResult { + const decision = evaluateControlBootstrap({ ownership: req.ownership, managementVerified: req.managementVerified }); + if (decision.decision !== "authorize-control") { + return { ok: false, reason: "requires-approval" }; + } + const issued = issueLifecycleGrant( + { + requesterMachineId: req.self.machineId, + requesterIdentityKey: req.self.identityKey, + targetMachineId: req.target.machineId, + targetIdentityKey: req.target.identityKey, + scope: "control", + }, + deps, + ); + if (!issued.ok) { + return { ok: false, reason: "issue-failed", issueReason: issued.reason }; + } + return { ok: true, grant: issued.grant }; +} diff --git a/packages/extension/src/amicode_service/fleet_control_lifecycle.ts b/packages/extension/src/amicode_service/fleet_control_lifecycle.ts index a0637407..c622b1a2 100644 --- a/packages/extension/src/amicode_service/fleet_control_lifecycle.ts +++ b/packages/extension/src/amicode_service/fleet_control_lifecycle.ts @@ -443,6 +443,24 @@ export function findGrantByToken( return all.find((g) => g.token === presentedToken && g.state === "active"); } +/** #1541 (ADR 0034 D2): resolve the controlling machine's OWN active `control` + * grant by `targetMachineId` — the owner/target-resolvable read #1542's write + * plane composes as its `grantReader`. The store keys grants by + * `requesterMachineId`, but in the self-owned case the controlling machine is + * the REQUESTER and the driven session's owner is the TARGET, so a + * `requesterMachineId`-keyed lookup on the owner would miss. A LifecycleGrant + * already carries `targetMachineId`, so this scans for the active `control` + * grant whose target IS the owner — and returns it WITH its token (the read + * #1542 presents via its own proxyToPeer, not through ControlGatedResolver). */ +export function findControlGrantByTarget( + targetMachineId: string, + deps: LifecycleGrantDeps = {}, +): LifecycleGrant | undefined { + return readAllLifecycleGrants(deps).find( + (g) => g.targetMachineId === targetMachineId && g.scope === "control" && g.state === "active", + ); +} + /** The composed scope enforcement: given a presented token + request, evaluate * BOTH membership (the token is a valid active grant) AND scope (the route * matrix allows this scope on this route). This is the SAME function called at diff --git a/packages/extension/src/amicode_service/fleet_lifecycle_authority.ts b/packages/extension/src/amicode_service/fleet_lifecycle_authority.ts new file mode 100644 index 00000000..be38555e --- /dev/null +++ b/packages/extension/src/amicode_service/fleet_lifecycle_authority.ts @@ -0,0 +1,41 @@ +// EXTENSION-SIDE lifecycle-admin AUTHORITY resolver (#1541, ADR 0034 D3). +// +// The authority store's on-disk contract lives in @amicode/schema (the shared +// home, so `amico fleet enroll` in @amicode/amico-run and this reader agree on +// ONE shape at ONE path — a shared on-disk contract, NOT a cross-package +// import). This module is the extension's typed door onto that resolver: it +// answers "who holds lifecycle-admin authority for machine X" from the service +// side, which #1545's request routing and the self-owned control fast-path +// (`establishManagementVerified`) consume. +// +// A headless target ENFORCES only — the approval act runs on the UI-bearing +// authority machine this resolver names. The resolver is READ-ONLY; the seeding +// writer is the enroll verb. +import { + resolveLifecycleAuthority as schemaResolve, + readAllLifecycleAuthorities as schemaReadAll, + type LifecycleAuthorityRecord, +} from "@amicode/schema"; + +export type { LifecycleAuthorityRecord }; + +export interface LifecycleAuthorityResolveDeps { + /** Override the store file (test/headless seam). Default: + * $AMICO_FLEET_LIFECYCLE_AUTHORITY_FILE → ~/.amico/fleet-lifecycle-authority.json. */ + authorityStoreFile?: string; +} + +/** Resolve WHO holds lifecycle-admin authority over `targetMachineId`, or + * undefined when none is seeded (the honest "no authority yet" — a machine that + * never enrolled). */ +export function resolveLifecycleAuthority( + targetMachineId: string, + deps: LifecycleAuthorityResolveDeps = {}, +): LifecycleAuthorityRecord | undefined { + return schemaResolve(targetMachineId, deps.authorityStoreFile); +} + +/** Read ALL seeded authority records (fleet-scale, small set). */ +export function readAllLifecycleAuthorities(deps: LifecycleAuthorityResolveDeps = {}): LifecycleAuthorityRecord[] { + return schemaReadAll(deps.authorityStoreFile); +} diff --git a/packages/extension/test/fleet_control_bootstrap.test.ts b/packages/extension/test/fleet_control_bootstrap.test.ts new file mode 100644 index 00000000..e1948e0b --- /dev/null +++ b/packages/extension/test/fleet_control_bootstrap.test.ts @@ -0,0 +1,253 @@ +// fleet_control_bootstrap.test.ts — #1541 (ADR 0034 D3): the SELF-OWNED CONTROL +// fast-path. A NEW control-scoped bootstrap decision, structurally mirroring +// evaluateObserveBootstrap (fleet_observe_bootstrap.ts) — which EXPLICITLY +// refuses a control outcome. This suite pins: +// +// · evaluateControlBootstrap — self-owned + verified-management-access +// AUTHORIZES the explicit enable; every other case requires approval. +// · management-verified is a DEFINED predicate (enroll-seeded authority + a +// serving peer + a held reader token), not a bare boolean. +// · enableSelfOwnedControl mints an ACTIVE `control` grant on the explicit +// enable, with NO target-side interaction, and the grant is owner-resolvable +// + token-bearing (the read #1542 needs). +// · no privilege bleed — a shared peer NEVER borrows the self-owned fast-path. +import { describe, it, expect, beforeEach } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + evaluateControlBootstrap, + evaluateManagementVerified, + establishManagementVerified, + enableSelfOwnedControl, + type ControlBootstrapRequest, +} from "../src/amicode_service/fleet_control_bootstrap"; +import { + findControlGrantByTarget, + readLifecycleGrant, + type LifecycleGrantDeps, +} from "../src/amicode_service/fleet_control_lifecycle"; +import type { LifecycleAuthorityRecord } from "../src/amicode_service/fleet_lifecycle_authority"; + +function tmproot(): string { + return mkdtempSync(join(tmpdir(), "amicode-1541-control-bootstrap-")); +} + +const SELF_ID = "my-macbook"; +const SELF_KEY = "SHA256:self-fingerprint"; +const PEER_ID = "the-studio"; +const PEER_KEY = "SHA256:studio-fingerprint"; + +function makeDeps(root?: string): LifecycleGrantDeps { + const r = root ?? tmproot(); + return { + grantStoreFile: join(r, "lifecycle-grants.json"), + tokenFactory: () => "CONTROL-TOKEN-001", + now: () => "2026-09-24T00:00:00.000Z", + }; +} + +// A seeded authority record naming SELF as the target-peer's lifecycle-admin +// authority (what Enroll persists on the target machine). +function authorityFor(target: string, authority: string): LifecycleAuthorityRecord { + return { + targetMachineId: target, + authorityMachineId: authority, + authorityIdentityKey: authority, + recordedAt: "2026-09-24T00:00:00.000Z", + }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// evaluateControlBootstrap — the self-owned fast-path predicate +// ═══════════════════════════════════════════════════════════════════════════ +describe("evaluateControlBootstrap — self-owned + management-verified authorizes; everything else requires approval", () => { + it("a SELF-OWNED, management-verified peer authorizes the explicit enable", () => { + const req: ControlBootstrapRequest = { ownership: "self-owned", managementVerified: true }; + expect(evaluateControlBootstrap(req).decision).toBe("authorize-control"); + }); + + it("a self-owned peer WITHOUT verified management access requires approval (management access is the gate)", () => { + const req: ControlBootstrapRequest = { ownership: "self-owned", managementVerified: false }; + expect(evaluateControlBootstrap(req).decision).toBe("requires-approval"); + }); + + it("a SHARED peer requires approval — control never auto-establishes for a different operator's machine", () => { + const req: ControlBootstrapRequest = { ownership: "shared", managementVerified: false }; + expect(evaluateControlBootstrap(req).decision).toBe("requires-approval"); + }); + + it("no privilege bleed: a shared peer CANNOT borrow the self-owned fast-path even claiming management access", () => { + const req: ControlBootstrapRequest = { ownership: "shared", managementVerified: true }; + expect(evaluateControlBootstrap(req).decision).toBe("requires-approval"); + }); + + it("there is NO targetApproved fast-path for control (unlike observe) — a shared peer routes to the handshake, never a direct grant", () => { + // control bootstrap only knows two inputs: ownership + managementVerified. + // A shared peer is always requires-approval here (the request→approve + // handshake is slice 5), so it can never mint control from this decision. + expect(evaluateControlBootstrap({ ownership: "shared", managementVerified: true }).decision).toBe( + "requires-approval", + ); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// management-verified — a DEFINED predicate, not a bare boolean +// ═══════════════════════════════════════════════════════════════════════════ +describe("evaluateManagementVerified — the named identity/transport/token check", () => { + it("all three facts true (authority seeded + serving peer + held reader token) → verified", () => { + expect( + evaluateManagementVerified({ authoritySeededForSelf: true, peerServing: true, readerTokenHeld: true }), + ).toBe(true); + }); + + it("missing the enroll-seeded authority → NOT verified", () => { + expect( + evaluateManagementVerified({ authoritySeededForSelf: false, peerServing: true, readerTokenHeld: true }), + ).toBe(false); + }); + + it("the peer is not serving (transport down) → NOT verified", () => { + expect( + evaluateManagementVerified({ authoritySeededForSelf: true, peerServing: false, readerTokenHeld: true }), + ).toBe(false); + }); + + it("no held reader token (bilateral token state absent) → NOT verified", () => { + expect( + evaluateManagementVerified({ authoritySeededForSelf: true, peerServing: true, readerTokenHeld: false }), + ).toBe(false); + }); +}); + +describe("establishManagementVerified — composes the enroll-seeded authority + serving peer + held reader token", () => { + it("verified when SELF holds the enroll-seeded authority for a serving peer we hold a token for", () => { + const verified = establishManagementVerified({ + selfMachineId: SELF_ID, + targetMachineId: PEER_ID, + getServingPeers: () => [{ machineId: PEER_ID }], + readPeerToken: (id) => ({ ok: id === PEER_ID }), + resolveAuthority: (t) => (t === PEER_ID ? authorityFor(PEER_ID, SELF_ID) : undefined), + }); + expect(verified).toBe(true); + }); + + it("NOT verified when no authority is seeded for the peer", () => { + const verified = establishManagementVerified({ + selfMachineId: SELF_ID, + targetMachineId: PEER_ID, + getServingPeers: () => [{ machineId: PEER_ID }], + readPeerToken: () => ({ ok: true }), + resolveAuthority: () => undefined, + }); + expect(verified).toBe(false); + }); + + it("NOT verified when the seeded authority names a DIFFERENT machine (not self)", () => { + const verified = establishManagementVerified({ + selfMachineId: SELF_ID, + targetMachineId: PEER_ID, + getServingPeers: () => [{ machineId: PEER_ID }], + readPeerToken: () => ({ ok: true }), + resolveAuthority: () => authorityFor(PEER_ID, "someone-else"), + }); + expect(verified).toBe(false); + }); + + it("NOT verified when the peer is not in the serving set", () => { + const verified = establishManagementVerified({ + selfMachineId: SELF_ID, + targetMachineId: PEER_ID, + getServingPeers: () => [], + readPeerToken: () => ({ ok: true }), + resolveAuthority: () => authorityFor(PEER_ID, SELF_ID), + }); + expect(verified).toBe(false); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// enableSelfOwnedControl — the explicit enable mints an ACTIVE control grant +// ═══════════════════════════════════════════════════════════════════════════ +describe("enableSelfOwnedControl — a self-owned, management-verified peer mints an active control grant (no target-side interaction)", () => { + let deps: LifecycleGrantDeps; + beforeEach(() => { + deps = makeDeps(); + }); + + it("mints an ACTIVE control grant on the explicit enable — self-issued, keyed by the controlling machine", () => { + const result = enableSelfOwnedControl( + { + ownership: "self-owned", + managementVerified: true, + self: { machineId: SELF_ID, identityKey: SELF_KEY }, + target: { machineId: PEER_ID, identityKey: PEER_KEY }, + }, + deps, + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.grant.scope).toBe("control"); + expect(result.grant.state).toBe("active"); + expect(result.grant.requesterMachineId).toBe(SELF_ID); + expect(result.grant.targetMachineId).toBe(PEER_ID); + expect(result.grant.token).toBe("CONTROL-TOKEN-001"); + + // the grant persisted on the controlling machine, keyed by the requester (self) + const stored = readLifecycleGrant(SELF_ID, deps); + expect(stored!.scope).toBe("control"); + expect(stored!.state).toBe("active"); + }); + + it("the minted grant is owner-resolvable + token-bearing (the read #1542 needs)", () => { + enableSelfOwnedControl( + { + ownership: "self-owned", + managementVerified: true, + self: { machineId: SELF_ID, identityKey: SELF_KEY }, + target: { machineId: PEER_ID, identityKey: PEER_KEY }, + }, + deps, + ); + const found = findControlGrantByTarget(PEER_ID, deps); + expect(found).toBeDefined(); + expect(found!.scope).toBe("control"); + expect(found!.state).toBe("active"); + expect(found!.targetMachineId).toBe(PEER_ID); + expect(found!.token).toBe("CONTROL-TOKEN-001"); + }); + + it("a self-owned peer WITHOUT management access does NOT mint — held for approval, no grant", () => { + const result = enableSelfOwnedControl( + { + ownership: "self-owned", + managementVerified: false, + self: { machineId: SELF_ID, identityKey: SELF_KEY }, + target: { machineId: PEER_ID, identityKey: PEER_KEY }, + }, + deps, + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toBe("requires-approval"); + expect(findControlGrantByTarget(PEER_ID, deps)).toBeUndefined(); + }); + + it("no privilege bleed: a SHARED peer (even management-verified) does NOT mint — no grant survives", () => { + const result = enableSelfOwnedControl( + { + ownership: "shared", + managementVerified: true, + self: { machineId: SELF_ID, identityKey: SELF_KEY }, + target: { machineId: PEER_ID, identityKey: PEER_KEY }, + }, + deps, + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toBe("requires-approval"); + expect(findControlGrantByTarget(PEER_ID, deps)).toBeUndefined(); + }); +}); diff --git a/packages/extension/test/fleet_control_lifecycle.test.ts b/packages/extension/test/fleet_control_lifecycle.test.ts index 66808308..8b6f821d 100644 --- a/packages/extension/test/fleet_control_lifecycle.test.ts +++ b/packages/extension/test/fleet_control_lifecycle.test.ts @@ -675,3 +675,70 @@ describe("AC2+AC3 — the lifecycle credential matrix: only lifecycle-admin and }); } }); + +// ═══════════════════════════════════════════════════════════════════════════ +// #1541 (ADR 0034 D2) — the OWNER/TARGET-resolvable control-grant read. +// +// The grant store keys on `requesterMachineId`, but the write plane (#1542) +// resolves the controlling machine's OWN active `control` grant by +// `targetMachineId === ` — a LifecycleGrant already carries +// `targetMachineId`. findControlGrantByTarget delivers that read (the grant + +// its token) so #1542's grantReader composes correctly. +// ═══════════════════════════════════════════════════════════════════════════ +import { findControlGrantByTarget } from "../src/amicode_service/fleet_control_lifecycle"; + +describe("#1541 — findControlGrantByTarget resolves the controlling machine's own active control grant by targetMachineId", () => { + let deps: LifecycleGrantDeps; + const SELF = "my-macbook"; + const OWNER = "the-studio"; // the driven session's owner (the target of control) + beforeEach(() => { + deps = makeDeps(); + }); + + function issueControl(over?: Partial) { + return issueLifecycleGrant( + { + requesterMachineId: SELF, + requesterIdentityKey: "SHA256:self", + targetMachineId: OWNER, + targetIdentityKey: "SHA256:owner", + scope: "control", + ...over, + }, + deps, + ); + } + + it("returns the active control grant (with its token) resolved by targetMachineId", () => { + issueControl(); + const g = findControlGrantByTarget(OWNER, deps); + expect(g).toBeDefined(); + expect(g!.scope).toBe("control"); + expect(g!.state).toBe("active"); + expect(g!.targetMachineId).toBe(OWNER); + expect(g!.token).toBe("GRANT-TOKEN-001"); + }); + + it("returns undefined for a target with no control grant", () => { + issueControl(); + expect(findControlGrantByTarget("some-other-owner", deps)).toBeUndefined(); + }); + + it("does NOT return an OBSERVE-scoped grant for that target (control-only)", () => { + issueControl({ scope: "observe" }); + expect(findControlGrantByTarget(OWNER, deps)).toBeUndefined(); + }); + + it("does NOT return a revoked control grant (only active)", () => { + issueControl(); + revokeLifecycleGrant(SELF, deps); + acknowledgeRevocation(SELF, deps); + expect(findControlGrantByTarget(OWNER, deps)).toBeUndefined(); + }); + + it("does NOT return a revocation-pending control grant (only active)", () => { + issueControl(); + revokeLifecycleGrant(SELF, deps); + expect(findControlGrantByTarget(OWNER, deps)).toBeUndefined(); + }); +}); diff --git a/packages/extension/test/fleet_enroll_authority.test.ts b/packages/extension/test/fleet_enroll_authority.test.ts new file mode 100644 index 00000000..465d7133 --- /dev/null +++ b/packages/extension/test/fleet_enroll_authority.test.ts @@ -0,0 +1,103 @@ +// fleet_enroll_authority.test.ts — #1541 (ADR 0034 D3): the cross-package +// writer↔resolver contract for the NET-NEW lifecycle-admin authority. +// +// `amico fleet enroll` (in @amicode/amico-run) records the enroller as the +// enrolling machine's lifecycle-admin authority through a persisted, on-disk +// store (the shared @amicode/schema contract — amico-run writes, the extension +// reads, NOT a cross-package import). This suite proves the extension-side +// resolver resolves EXACTLY what the enroll-side writer wrote — the only place +// both halves of the contract can be exercised together (the extension depends +// on amico-run, so an extension test may drive both). +import { describe, it, expect } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + recordLifecycleAuthority as schemaRecordLifecycleAuthority, + type LifecycleAuthorityRecord, +} from "@amicode/schema"; +import { + fleetEnroll, + type FleetEnrollDeps, + type JoinToken, +} from "../../amico-run/src/fleet_enroll_verb.js"; +import { resolveLifecycleAuthority } from "../src/amicode_service/fleet_lifecycle_authority"; + +function tmpStore(): string { + return join(mkdtempSync(join(tmpdir(), "amicode-1541-authority-")), "fleet-lifecycle-authority.json"); +} + +// A fake fetch answering every hub probe (pin-check + roster POST + verify-attach) +// as a healthy, pin-matching host — so the client redeem runs end-to-end with no +// server standing. +const healthyFetch = (async () => ({ + ok: true, + status: 200, + json: async () => ({ healthy: true, version: "v1.18.29" }), +})) as unknown as typeof fetch; + +function enrollDeps(store: string, over: Partial = {}): FleetEnrollDeps { + return { + machineId: () => "headless-target", + machineName: () => "Headless Target", + capabilities: () => [], + clientVersion: () => "v1.18.29", + now: () => "2026-09-24T00:00:00.000Z", + fleetConfigPath: join(tmpdir(), "amicode-1541-nonexistent", "fleet.json"), + writeFleetConfig: () => {}, + setTransport: () => {}, + runInstaller: () => ({ ok: true }), + fetchImpl: healthyFetch, + resolveProbeOrigin: () => ({ ok: true, origin: "http://hub.example:4096" }), + retryDelayMs: [0, 0, 0], + commandRunner: () => "", + readDeviceSetting: () => undefined, + // the writer under test: the REAL shared @amicode/schema writer the enroll + // default uses, pointed at a hermetic tmp store. + recordLifecycleAuthority: (rec: LifecycleAuthorityRecord) => schemaRecordLifecycleAuthority(rec, store), + ...over, + }; +} + +const token: JoinToken = { + canonical: { host: "hub.example", port: 4096, sshAlias: "hub" }, + fleet_token: "FLEET-SECRET", + transport_hint: "ssh", + pin_version: "v1.18.29", +}; + +describe("#1541 AC1/AC4 — Enroll seeds a resolvable lifecycle-admin authority (writer↔resolver contract)", () => { + it("a headless target that ran Enroll yields an authority the extension resolver resolves EXACTLY", async () => { + const store = tmpStore(); + const r = await fleetEnroll(["--join-token-json", JSON.stringify(token)], enrollDeps(store)); + expect(r.code).toBe(0); + + const resolved = resolveLifecycleAuthority("headless-target", { authorityStoreFile: store }); + expect(resolved).toBeDefined(); + expect(resolved!.targetMachineId).toBe("headless-target"); // the machine that ran Enroll (the target) + expect(resolved!.authorityMachineId).toBe("hub.example"); // the enroller / canonical server + expect(resolved!.authorityIdentityKey).toBe("hub.example"); + expect(typeof resolved!.recordedAt).toBe("string"); + }); + + it("a machine that never enrolled has no resolvable authority (honest undefined, the target renders nothing)", () => { + const store = tmpStore(); + expect(resolveLifecycleAuthority("never-enrolled", { authorityStoreFile: store })).toBeUndefined(); + }); + + it("a second peer's authority is resolved independently — the store keys by target machine_id", async () => { + const store = tmpStore(); + await fleetEnroll(["--join-token-json", JSON.stringify(token)], enrollDeps(store)); + await fleetEnroll( + ["--join-token-json", JSON.stringify(token)], + enrollDeps(store, { machineId: () => "second-target" }), + ); + expect(resolveLifecycleAuthority("headless-target", { authorityStoreFile: store })!.authorityMachineId).toBe( + "hub.example", + ); + expect(resolveLifecycleAuthority("second-target", { authorityStoreFile: store })!.authorityMachineId).toBe( + "hub.example", + ); + }); +}); diff --git a/packages/extension/test/fleet_headless_rehydration.test.ts b/packages/extension/test/fleet_headless_rehydration.test.ts index 402fcb60..97151721 100644 --- a/packages/extension/test/fleet_headless_rehydration.test.ts +++ b/packages/extension/test/fleet_headless_rehydration.test.ts @@ -340,6 +340,53 @@ describe("AC2 — identity/transport/trust revalidation precedes Observe; Contro }); }); +// ═══════════════════════════════════════════════════════════════════════════ +// #1541 AC3 REGRESSION GUARD — a control grant is NEVER auto-restored on +// rehydration. This is NOT new work: the rule already lives in +// fleet_headless_rehydration.ts (control-scoped grant → suspended, +// controlSuspended:true). This guard pins the invariant against regression and +// asserts the store grant itself is untouched (rehydration never self-revives +// a control session — control lands suspended until an explicit re-enable). +// ═══════════════════════════════════════════════════════════════════════════ +describe("#1541 AC3 regression guard — control is never auto-restored on rehydration", () => { + let grantDeps: LifecycleGrantDeps; + beforeEach(() => { + grantDeps = makeGrantDeps(); + }); + + it("a persisted active control grant rehydrates SUSPENDED (observe restored, control not), grant untouched", () => { + issueLifecycleGrant( + { + requesterMachineId: PEER_A_ID, + requesterIdentityKey: PEER_A_KEY, + targetMachineId: LOCAL_ID, + targetIdentityKey: LOCAL_KEY, + scope: "control", + }, + grantDeps, + ); + + const result = rehydratePeerRelationships({ + grantDeps, + peerProvider: stubPeerProvider({ + servingPeers: [{ machineId: PEER_A_ID }], + tokens: { [PEER_A_ID]: { baseUrl: "http://studio:43117", token: "tok-a" } }, + }), + }); + + const peer = result.peers.find((p) => p.peerId === PEER_A_ID)!; + expect(peer.state).toBe("suspended"); + expect(peer.controlSuspended).toBe(true); + expect(peer.observeRestored).toBe(true); + + // read-only: the persisted grant is UNCHANGED — never self-revived to a live + // control session by the boot-time rehydration. + const stored = readLifecycleGrant(PEER_A_ID, grantDeps); + expect(stored!.scope).toBe("control"); + expect(stored!.state).toBe("active"); + }); +}); + // ═══════════════════════════════════════════════════════════════════════════ // AC3 — stale bootstrap/reconnect work cannot advance a revoked or // superseded relationship generation diff --git a/packages/schema/src/fleet_lifecycle_authority.ts b/packages/schema/src/fleet_lifecycle_authority.ts new file mode 100644 index 00000000..0a25b582 --- /dev/null +++ b/packages/schema/src/fleet_lifecycle_authority.ts @@ -0,0 +1,148 @@ +// The lifecycle-admin AUTHORITY store (amicode#1541, ADR 0034 D3) — the NET-NEW +// persistence that seeds "who holds lifecycle-admin authority over machine X" at +// `amico fleet enroll`. Before this, `authorityIdentityKey` existed ONLY as an +// injected parameter to the pure `isLifecycleAuthority` predicate — no store, no +// writer, no resolver. This module is that store's on-disk contract. +// +// It lives in @amicode/schema, the repo's home for cross-package shared +// contracts, for the SAME reason fleet_roster.ts and fleet_config.ts do: the +// WRITER is `amico fleet enroll` (in @amicode/amico-run, which cannot import the +// extension), and the RESOLVER is consumed extension-side (#1545 request +// routing). They agree on ONE on-disk shape at ONE path — a shared on-disk +// contract, never a cross-package import. +// +// On-disk file: ~/.amico/fleet-lifecycle-authority.json (0600), keyed by the +// TARGET machine_id (the machine whose authority this row describes): +// { "store_version": 1, +// "authorities": { +// "": { authority_machine_id, authority_identity_key, recorded_at } } } +// +// `lifecycle-admin` authority is NOT a superset of `control` (ADR 0034): it +// names WHO may mint/revoke/re-admit for a machine — the approval act runs on a +// UI-bearing authority machine; a headless target only enforces. This store is +// what makes a headless peer approvable from elsewhere. +import * as fs from "node:fs"; +import * as path from "node:path"; +import { homedir } from "node:os"; + +/** The authority store's schema version — bumped independently of the roster / + * projection / grant contracts (a distinct, amicode-owned artifact). */ +export const LIFECYCLE_AUTHORITY_STORE_VERSION = 1; + +const AUTHORITIES_COLLECTION = "authorities"; + +/** One authority record: machine `targetMachineId`'s lifecycle-admin authority + * is `authorityMachineId` (identified by `authorityIdentityKey`). Recorded at + * Enroll on the machine that ran it. */ +export interface LifecycleAuthorityRecord { + /** The machine whose authority this row describes (the store key). */ + targetMachineId: string; + /** The machine that holds lifecycle-admin authority over the target (the + * enroller / canonical server in the self-owned server-first flow). */ + authorityMachineId: string; + /** The authority's stable identity anchor — the value `isLifecycleAuthority` + * compares a presented `identityKey` against. */ + authorityIdentityKey: string; + /** ISO stamp of when the authority was seeded. */ + recordedAt: string; +} + +/** The authority store path. `$AMICO_FLEET_LIFECYCLE_AUTHORITY_FILE` overrides + * (test/headless seam), else `~/.amico/fleet-lifecycle-authority.json`. */ +export function lifecycleAuthorityStorePath(home: string = homedir()): string { + const env = process.env.AMICO_FLEET_LIFECYCLE_AUTHORITY_FILE; + if (env && env.trim() !== "") return env; + return path.join(home, ".amico", "fleet-lifecycle-authority.json"); +} + +interface AuthorityDoc { + store_version?: number; + [key: string]: unknown; +} + +interface StoredAuthorityRecord { + authority_machine_id: string; + authority_identity_key: string; + recorded_at: string; +} + +/** Tolerant whole-file read — an absent/corrupt/non-object file is {} (never a + * throw), so a resolver degrades to "no authority" rather than crashing. */ +function readDoc(file: string): AuthorityDoc { + if (!fs.existsSync(file)) return {}; + let parsed: unknown; + try { + parsed = JSON.parse(fs.readFileSync(file, "utf8")); + } catch { + return {}; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {}; + return parsed as AuthorityDoc; +} + +function collectionOf(doc: AuthorityDoc): Record { + const c = doc[AUTHORITIES_COLLECTION]; + return typeof c === "object" && c !== null && !Array.isArray(c) ? (c as Record) : {}; +} + +function parseStored(targetMachineId: string, raw: unknown): LifecycleAuthorityRecord | undefined { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined; + const r = raw as Record; + const authorityMachineId = typeof r.authority_machine_id === "string" ? r.authority_machine_id : ""; + const authorityIdentityKey = typeof r.authority_identity_key === "string" ? r.authority_identity_key : ""; + const recordedAt = typeof r.recorded_at === "string" ? r.recorded_at : ""; + if (authorityMachineId === "" || authorityIdentityKey === "") return undefined; + return { targetMachineId, authorityMachineId, authorityIdentityKey, recordedAt }; +} + +/** Persist (upsert) one authority record atomically, 0600, keyed by the target + * machine_id. Every OTHER entry and unknown top-level key is preserved. This is + * the writer `amico fleet enroll` invokes (its default enroll seam). */ +export function recordLifecycleAuthority( + record: LifecycleAuthorityRecord, + p: string = lifecycleAuthorityStorePath(), +): void { + const doc = readDoc(p); + const stored: StoredAuthorityRecord = { + authority_machine_id: record.authorityMachineId, + authority_identity_key: record.authorityIdentityKey, + recorded_at: record.recordedAt, + }; + const collection = { ...collectionOf(doc), [record.targetMachineId]: stored }; + const out: AuthorityDoc = { + ...doc, + store_version: LIFECYCLE_AUTHORITY_STORE_VERSION, + [AUTHORITIES_COLLECTION]: collection, + }; + fs.mkdirSync(path.dirname(p), { recursive: true }); + const tmp = `${p}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(out, null, 2) + "\n", { mode: 0o600 }); + fs.chmodSync(tmp, 0o600); + fs.renameSync(tmp, p); + try { + fs.chmodSync(p, 0o600); + } catch { + // best-effort — the rename landed; a chmod race is not fatal. + } +} + +/** Resolve WHO holds lifecycle-admin authority for a machine, or undefined. The + * read #1545's request routing consumes; the read the enroll writer's output is + * verified against. */ +export function resolveLifecycleAuthority( + targetMachineId: string, + p: string = lifecycleAuthorityStorePath(), +): LifecycleAuthorityRecord | undefined { + return parseStored(targetMachineId, collectionOf(readDoc(p))[targetMachineId]); +} + +/** Read ALL authority records (fleet-scale, small set). */ +export function readAllLifecycleAuthorities(p: string = lifecycleAuthorityStorePath()): LifecycleAuthorityRecord[] { + const collection = collectionOf(readDoc(p)); + const out: LifecycleAuthorityRecord[] = []; + for (const [target, raw] of Object.entries(collection)) { + const rec = parseStored(target, raw); + if (rec) out.push(rec); + } + return out; +} diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index a4ac75af..2665b5b7 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -255,6 +255,19 @@ export { type FleetConfig, } from "./fleet_config.js"; +// The lifecycle-admin AUTHORITY store (amicode#1541, ADR 0034 D3) — the NET-NEW +// persistence seeded at `amico fleet enroll` (writer in @amicode/amico-run) and +// resolved extension-side (#1545 routing). A shared on-disk contract at ONE +// path, the SAME cross-package pattern as fleet_roster / fleet_config. +export { + LIFECYCLE_AUTHORITY_STORE_VERSION, + lifecycleAuthorityStorePath, + recordLifecycleAuthority, + resolveLifecycleAuthority, + readAllLifecycleAuthorities, + type LifecycleAuthorityRecord, +} from "./fleet_lifecycle_authority.js"; + // ajv-formats ships a CJS default export; under NodeNext the default import can // bind the module namespace rather than the callable, so normalize defensively. const addFormats = (typeof addFormatsDefault === "function" From 25e62e689424ff5dc833f3d36159472cf70f0b08 Mon Sep 17 00:00:00 2001 From: amicode-ci Date: Thu, 24 Sep 2026 16:34:42 -0400 Subject: [PATCH 2/5] =?UTF-8?q?Fleet=20Studio=20B2b=20(#1542):=20Observati?= =?UTF-8?q?on=20write=20plane=20=E2=80=94=20control-gated=20remote=20write?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the #1537 observation READ seam for WRITES, with the GET/non-GET decision INVERTED: a NON-GET request (prompt/archive/delete) to a peer-owned session is authorized by the pure evaluateRemoteWriteGate and, when allowed, proxied to the owner; GET is ignored (the read plane owns it); local/unowned/ non-session/amicode writes fall through byte-identical. - ObservationWriteRouter (session_multiplexer.ts): resolve() inverts GET, authorizes via the gate, returns a peer target | named deny | undefined. - D2 grant-key fix: grantReader resolves the controlling machine's OWN active control grant by targetMachineId === owner (findControlGrantByTarget, #1541), NOT a requesterMachineId-keyed lookup that would deny every write. - createObservationWritePlane (new observation_write_plane.ts): its OWN proxyToPeer via HubProxy, presenting the PEER reader token the owner accepts (NOT the control token, which the owner has never seen and would 401; NOT the ControlGatedResolver adapter, which drops the peer credential). Honest boundary documented: a distinct owner-enforced control token is future work. - Dispatch wiring (server.ts): ObservationWritePlane interface + observeWrite seam + attachObservationWritePlane; consult beside observeRead inside if (this.engineProxy) — allowed proxies to the owner, transport-down is the peer-unreachable 503, auth denials (no-control-grant/grant-revoked/ insufficient-scope) are a 403 carrying the gate's real reason, NEVER local. Structural no-op when unattached (byte-identity). - index.ts: attach the write plane in the observation branch, wired from fleetPeers (owner map, serving peers for reachability + credential) + the lifecycle grant deps. Part of #1540. ADR 0034 D2/D4/D5. --- .../extension/src/amicode_service/index.ts | 33 + .../observation_write_plane.ts | 73 +++ .../extension/src/amicode_service/server.ts | 79 +++ .../amicode_service/session_multiplexer.ts | 133 ++++ ...code_service_observe_write_routing.test.ts | 592 ++++++++++++++++++ 5 files changed, 910 insertions(+) create mode 100644 packages/extension/src/amicode_service/observation_write_plane.ts create mode 100644 packages/extension/test/amicode_service_observe_write_routing.test.ts diff --git a/packages/extension/src/amicode_service/index.ts b/packages/extension/src/amicode_service/index.ts index 2ad0a6d3..5c74b314 100644 --- a/packages/extension/src/amicode_service/index.ts +++ b/packages/extension/src/amicode_service/index.ts @@ -46,6 +46,8 @@ import { } from "./session_multiplexer"; import { SseFanInDriver } from "./sse_fanin_driver"; import { createObservationReadPlane } from "./observation_read_plane"; +import { createObservationWritePlane } from "./observation_write_plane"; +import { findControlGrantByTarget } from "./fleet_control_lifecycle"; import { HubCredentialRead, mintRegistry, readHubCredential } from "./hub_credential"; import { buildMergedProjection, buildFleetProjection, type UpstreamMode, type MergedProjection, type FleetProjection } from "./merged_projection"; import { FleetPostureDetector, type FleetPostureTuning } from "./fleet_posture"; @@ -1107,6 +1109,37 @@ export function createAmicodeService( ...(opts.fleet.dataPlaneTimeoutMs !== undefined ? { timeoutMs: opts.fleet.dataPlaneTimeoutMs } : {}), }), ); + // #1542 (Fleet Studio B2b, WRITE seam): BESIDE the read plane, attach the + // observation-only WRITE router. A NON-GET request to a peer-owned session + // is AUTHORIZED by the pure write gate and, when allowed, routed to the + // owner with the credential the owner accepts (the SAME peer reader token + // the read plane sources). The `grantReader` resolves the CONTROLLING + // machine's OWN active `control` grant by `targetMachineId === owner` + // (findControlGrantByTarget, #1541 — the D2 fix): the store keys grants by + // requesterMachineId, so in the self-owned case the naïve owner-keyed + // lookup would miss and every write would wrongly deny. Reads use the SAME + // late-bound `peer` closure for reachability + the transport credential. + server.attachObservationWritePlane( + createObservationWritePlane({ + ownerMap, + localMachineId: fleetPeers.localMachineId, + peer: (machineId) => { + if (!fleetPeers.getServingPeers().some((p) => p.machineId === machineId)) return undefined; + const r = fleetPeers.readPeerToken(machineId); + return r.ok ? { getUrl: () => r.credential.baseUrl, token: r.credential.token } : { getUrl: () => undefined }; + }, + // The D2-corrected grant read: the controlling machine's OWN active + // `control` grant, resolved by target === owner. The transport + // credential presented to the owner remains the peer reader token + // (above) — the owner accepts that for /session CRUD today; a + // distinct owner-enforced control token is a future tightening. + grantReader: (owner) => { + const g = findControlGrantByTarget(owner); + return g ? { scope: g.scope, state: g.state } : undefined; + }, + ...(opts.fleet.dataPlaneTimeoutMs !== undefined ? { timeoutMs: opts.fleet.dataPlaneTimeoutMs } : {}), + }), + ); } } } diff --git a/packages/extension/src/amicode_service/observation_write_plane.ts b/packages/extension/src/amicode_service/observation_write_plane.ts new file mode 100644 index 00000000..5f944dff --- /dev/null +++ b/packages/extension/src/amicode_service/observation_write_plane.ts @@ -0,0 +1,73 @@ +// OBSERVATION WRITE PLANE (#1542, B2b WRITE seam): the mirror of the #1537 +// observation READ plane, with the GET/non-GET decision INVERTED — it routes +// NON-GET requests (prompt / archive / delete) to a PEER-OWNED session and +// IGNORES GET (the read plane owns GET). On a machine running the OBSERVATION +// path (`baseStudioActivates` mounted the fleet routes but NO premium fleet +// plane is attached, so getMode stays "engine"), a peer-owned session's WRITE +// requests fell through to the LOCAL engine — which never held the peer's +// session — or were dropped. This plane AUTHORIZES such a write through the pure +// `evaluateRemoteWriteGate` (active `control` grant + reachable transport, D2- +// corrected grant read at the wiring) and, when allowed, proxies it to the owner +// with the credential the owner accepts. A denied write is the gate's NAMED +// honest deny, NEVER executed locally (the #1382 invariant); every other request +// falls through byte-identical to local. +// +// EMPIRICAL CREDENTIAL BOUNDARY (resolved against live evidence, NOT assumed): +// the control grant is the CLIENT-SIDE authorization gate — it decides whether +// this machine may SEND the write at all. The transport credential presented to +// the owner is the PEER credential the owner accepts (today: the reader token, +// the SAME credential the read plane sources via `fleetPeers.readPeerToken`). +// The observation-only owner accepts the peer reader token for full `/session` +// CRUD — it does NOT enforce a separate control scope on proxied `/session`. A +// self-issued control-grant token the owner has never seen would 401. A distinct +// owner-enforced control token is a future tightening. So this plane presents +// `target.token` (the peer reader token) via its OWN `proxyToPeer` — NOT +// `ControlGatedResolver`/`controlGatedMultiplexAdapter`, whose adapter DROPS the +// peer credential ("a future integration slice will thread it through the +// proxy", control_gated_routing.ts). +// +// Reuse-first: the peer hop rides the battle-tested HubProxy streaming machinery +// (bodies, SSE, timeouts, client-abort, honest 502/503) — byte-identical to the +// read plane. HubProxy attaches `hubUpstreamAuthHeader(token)` ≡ `peerAuthHeader( +// token)` (both `serverAuthHeader(token)`), so a synthetic per-peer credential +// produces exactly the peer-token auth the owner expects. +import * as http from "node:http"; +import { HubProxy } from "./hub_proxy"; +import { HUB_MINT_NAME, type HubCredentialRead } from "./hub_credential"; +import { + ObservationWriteRouter, + type ObservationWriteRouterOpts, + type ObservationWritePeerTarget, +} from "./session_multiplexer"; +import type { ObservationWritePlane } from "./server"; + +/** Build the observation-only WRITE plane: a resolver (path→owner peer, GET + * ignored) that AUTHORIZES through the pure write gate, plus the peer proxy + * presenting the credential the owner accepts (the peer reader token). + * `timeoutMs` bounds the headers wait (SSE bodies ride past it), mirroring the + * read plane / hub / attached proxies. */ +export function createObservationWritePlane( + opts: ObservationWriteRouterOpts & { timeoutMs?: number }, +): ObservationWritePlane { + const router = new ObservationWriteRouter(opts); + return { + resolve: (method, pathname) => router.resolve(method, pathname), + proxyToPeer(req: http.IncomingMessage, res: http.ServerResponse, target: ObservationWritePeerTarget): boolean { + // A synthetic per-peer hub credential → HubProxy attaches + // serverAuthHeader(target.token) === peerAuthHeader(target.token). The + // token is the PEER reader token the owner accepts — NOT the control-grant + // token (see the empirical boundary in the header comment). + const credential = (): HubCredentialRead => ({ + ok: true, + mint: HUB_MINT_NAME, + credential: { baseUrl: target.url, token: target.token }, + }); + const proxy = new HubProxy({ + getUrl: () => target.url, + credential, + ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}), + }); + return proxy.handle(req, res); + }, + }; +} diff --git a/packages/extension/src/amicode_service/server.ts b/packages/extension/src/amicode_service/server.ts index 3fe5dd05..62574397 100644 --- a/packages/extension/src/amicode_service/server.ts +++ b/packages/extension/src/amicode_service/server.ts @@ -31,6 +31,8 @@ import type { ResolvedTarget, ObservationReadResolution, ObservationReadPeerTarget, + ObservationWriteResolution, + ObservationWritePeerTarget, } from "./session_multiplexer"; import type { EventFanInDriver } from "./sse_fanin_driver"; import { BOUND_NONCE_HEADER, BOUND_IDENTITY_HEADER } from "./fleet_bootstrap_headers"; @@ -178,6 +180,28 @@ export interface ObservationReadPlane { proxyToPeer(req: http.IncomingMessage, res: http.ServerResponse, target: ObservationReadPeerTarget): boolean; } +/** #1542 (Fleet Studio B2b, WRITE seam): the OBSERVATION-ONLY per-session + * owner-routing seam for WRITES — the mirror of ObservationReadPlane with the + * GET/non-GET decision INVERTED. Armed ONLY on the observation path (index.ts); + * absent everywhere else, so the dispatch consult is a structural no-op unless + * attached. `dispatch()` consults it INSIDE the engine-proxy branch, BESIDE the + * read consult: a NON-GET request to a PEER-OWNED session resolves to either an + * AUTHORIZED peer target (proxied to the owner with the credential the owner + * accepts) or a NAMED honest deny (never local, the #1382 invariant); every + * other request (GET/HEAD — the read plane owns them, local/unowned writes, + * /amicode/*, non-session paths) resolves `undefined` and falls through + * BYTE-IDENTICAL to the local engine. */ +export interface ObservationWritePlane { + /** Resolve a write to an owner-peer target, a named deny, or undefined to + * fall through byte-identical to local. */ + resolve(method: string, pathname: string): ObservationWriteResolution | undefined; + /** Proxy an AUTHORIZED write to the owner peer with the credential the owner + * accepts (the peer reader token). Returns true when it owns the response; + * false when no upstream is bound (→ the caller answers a named 503, never + * local). */ + proxyToPeer(req: http.IncomingMessage, res: http.ServerResponse, target: ObservationWritePeerTarget): boolean; +} + /** #1261 (AC6): a client's own named hub-down state — distinct from the base * "hub upstream not available" and never the engine's message. */ export const FLEET_HUB_DOWN_ERROR = "fleet-hub-down"; @@ -238,6 +262,11 @@ export class AmicodeServiceServer { * Armed ONLY on the observation path (index.ts); absent everywhere else, so * the dispatch consult is a structural no-op unless it is attached. */ private observeRead?: ObservationReadPlane; + /** #1542 (B2b write seam): the observation-only per-session WRITE router. + * Armed ONLY on the observation path (index.ts), BESIDE observeRead; absent + * everywhere else, so the dispatch consult is a structural no-op unless it is + * attached. */ + private observeWrite?: ObservationWritePlane; readonly password: string; /** #955 (the hub cutover): the auth mode. "credential" (the default) is the * per-boot-mint posture — every non-public-UI request 401s without a @@ -325,6 +354,14 @@ export class AmicodeServiceServer { return this; } + /** #1542 (B2b write seam): arm the observation-only per-session WRITE router. + * Called ONLY by the observation path in index.ts; a boot without it never + * consults the seam (byte-identical). */ + attachObservationWritePlane(plane: ObservationWritePlane): this { + this.observeWrite = plane; + return this; + } + /** #1449 (W1b): register a teardown callback run once on stop() (idempotent * per callback via the caller). The owner-map feed's timer registers here so * it is halted when the service stops. */ @@ -707,6 +744,48 @@ export class AmicodeServiceServer { return; } } + // #1542 (B2b write seam): BESIDE the read consult, on the OBSERVATION + // path a NON-GET request (prompt / archive / delete) for a PEER-OWNED + // session is AUTHORIZED by the pure write gate and, when allowed, proxied + // to the owner peer with the credential the owner accepts (the peer + // reader token). A DENIED resolution is the gate's NAMED honest deny — + // NEVER executed locally (the #1382 invariant): `transport-down` is the + // peer-unreachable 503; the authorization denials (no-control-grant / + // grant-revoked / insufficient-scope) are a 403 carrying the gate's real + // reason. An `undefined` resolution (a GET/HEAD — the read plane owns + // those, a local/unowned write, /amicode/*, a non-session path) falls + // through BYTE-IDENTICAL to the engine proxy below. Absent on + // standalone / armed / client boots (never attached) → a structural + // no-op. Confirmation is NOT this seam's job (#1544): the gate authorizes, + // the plane routes; the delete-confirm UI is a downstream consumer. + if (this.observeWrite) { + const decision = this.observeWrite.resolve(req.method ?? "GET", url.pathname); + if (decision) { + if (decision.kind === "peer") { + if (this.observeWrite.proxyToPeer(req, res, decision)) return; + // authorized but no upstream bound → the peer's OWN honest 503, + // never local. + send({ + status: 503, + body: JSON.stringify({ ok: false, error: FLEET_PEER_UNREACHABLE_ERROR, reason: "peer-unreachable", machine_id: decision.machineId }), + }); + return; + } + // denied → the gate's NAMED deny, never local. + if (decision.reason === "transport-down") { + send({ + status: 503, + body: JSON.stringify({ ok: false, error: FLEET_PEER_UNREACHABLE_ERROR, reason: "transport-down", machine_id: decision.machineId }), + }); + } else { + send({ + status: 403, + body: JSON.stringify({ ok: false, error: "remote-write-denied", reason: decision.reason, machine_id: decision.machineId }), + }); + } + return; + } + } // Streams method/headers/body through to the engine (SSE included); // false = no upstream bound yet → the honest 503 below. if (this.engineProxy.handle(req, res)) return; diff --git a/packages/extension/src/amicode_service/session_multiplexer.ts b/packages/extension/src/amicode_service/session_multiplexer.ts index e7b7f35f..d3b0cb65 100644 --- a/packages/extension/src/amicode_service/session_multiplexer.ts +++ b/packages/extension/src/amicode_service/session_multiplexer.ts @@ -18,6 +18,7 @@ import * as http from "node:http"; import type { SessionOwnerTag } from "./merged_projection"; +import { evaluateRemoteWriteGate, type WriteGrantRead } from "./remote_write_gate"; // ── owner-routing header ───────────────────────────────────────────────────── @@ -272,6 +273,138 @@ export class ObservationReadRouter { } } +// ── observation-only WRITE router (#1542, B2b write seam) ──────────────────── + +/** An AUTHORIZED peer write target — proxy the write to `url` with `token`. + * `token` is the PEER credential the owner accepts (the reader token, the SAME + * the read plane sources), NOT the self-issued control-grant token — see the + * empirical note on ObservationWriteRouter. */ +export type ObservationWritePeerTarget = { kind: "peer"; machineId: string; url: string; token: string }; + +/** The gate's NAMED honest deny — never local. `reason` is the write gate's + * own reason vocabulary: `no-control-grant` | `grant-revoked` (the gate + * COLLAPSES `revocation-pending`→`grant-revoked`) | `insufficient-scope` | + * `transport-down`. NOT `read-only`/`unavailable` (those are ControlGatedTarget + * kinds — a different module). */ +export type ObservationWriteDenied = { kind: "denied"; machineId: string; reason: string }; + +/** The resolution of an observation-mode WRITE: + * - an AUTHORIZED peer target → proxy the write to the owner with the peer + * credential (the reader token); + * - a NAMED deny → the caller answers the gate's honest deny (an auth denial, + * or the peer-unreachable 503 for `transport-down`), NEVER local (#1382); + * - `undefined` (NOT a target) → LOCAL, the fail-safe: a GET/HEAD (the read + * plane owns those — the INVERTED decision), a keyless / non-session path, + * /amicode/*, a local-owned or unowned session, or an owner that is not a + * serving peer. The write falls through BYTE-IDENTICAL to the local engine. */ +export type ObservationWriteResolution = ObservationWritePeerTarget | ObservationWriteDenied; + +/** Options for the ObservationWriteRouter. */ +export interface ObservationWriteRouterOpts { + ownerMap: SessionOwnerMap; + localMachineId: string; + /** Late-bound peer transport by owner machine_id (fleet discipline: read per + * request), used for BOTH reachability and the transport credential. + * `undefined` → the owner is NOT a serving peer (→ local). A present + * transport whose `getUrl()`/`token` is absent is transport-down. */ + peer(machineId: string): PeerTransport | undefined; + /** The D2-corrected grant read: the CONTROLLING machine's OWN active `control` + * grant, resolved by `targetMachineId === ownerMachineId` (composed from + * `findControlGrantByTarget` at the wiring, #1541). The naïve + * `requesterMachineId`-keyed lookup on the owner returns undefined and every + * write wrongly denies — this closure fixes that. Injected so the router is + * unit-testable without a grant store. */ + grantReader(ownerMachineId: string): WriteGrantRead | undefined; +} + +/** The observation-only per-session owner WRITE router (#1542, B2b) — the mirror + * of ObservationReadRouter with the GET/non-GET decision INVERTED. On the + * OBSERVATION machine (no premium fleet plane; getMode "engine"), a NON-GET + * request (prompt / archive / delete) to a PEER-OWNED session must be + * AUTHORIZED by the pure `evaluateRemoteWriteGate` (active `control` grant + + * reachable transport) and, when allowed, ROUTED to the owner peer with the + * credential the owner accepts. A denied write is the gate's NAMED honest deny, + * NEVER executed locally (the #1382 invariant). GET/HEAD are IGNORED here — the + * read plane owns them. + * + * EMPIRICAL CREDENTIAL BOUNDARY (resolved against reality, #1537-style trap): + * the control grant is the CLIENT-SIDE authorization gate — it decides whether + * this machine may SEND the write. The transport credential presented to the + * owner is the PEER credential the owner accepts (today: the reader token, the + * SAME credential the read plane sources via `fleetPeers.readPeerToken`). A + * self-issued control-grant token the owner has never seen would 401 — the + * observation-only owner does NOT yet enforce a separate control scope on + * proxied `/session` (it accepts the peer reader token for full CRUD). A + * distinct owner-enforced control token is a future tightening. So the target + * carries `peer.token` (the reader token), never the grant's token. + * + * Additive & fail-safe: anything that does not resolve to a peer/deny returns + * undefined and the caller falls through byte-identical to today. */ +export class ObservationWriteRouter { + private readonly ownerMap: SessionOwnerMap; + private readonly localMachineId: string; + private readonly peer: (machineId: string) => PeerTransport | undefined; + private readonly grantReader: (ownerMachineId: string) => WriteGrantRead | undefined; + + constructor(opts: ObservationWriteRouterOpts) { + this.ownerMap = opts.ownerMap; + this.localMachineId = opts.localMachineId; + this.peer = opts.peer; + this.grantReader = opts.grantReader; + } + + /** Resolve a write to its owner-peer target, a named deny, or undefined + * (fall through local). + * + * Resolution order (the read router's, with 0 INVERTED): + * 0. GET/HEAD → local (the read plane owns reads — writes NEVER touch GET) + * 1. /amicode/* (the machine's own surface) → local + * 2. no session id extractable from the path → local + * 3. owner unknown (unowned) → local + * 4. owner == localMachineId (local-owned) → local + * 5. owner is not a serving peer → local + * 6. owner is a serving peer → AUTHORIZE via the pure write gate: + * allowed → the peer target (with the peer reader token); + * denied → the gate's NAMED deny (never local). */ + resolve(method: string, pathname: string): ObservationWriteResolution | undefined { + const m = (method || "GET").toUpperCase(); + if (m === "GET" || m === "HEAD") return undefined; // (0) INVERTED: reads never route here + // (1) never proxy the machine's OWN /amicode/* surface (honesty + local). + if (pathname === "/amicode" || pathname.startsWith("/amicode/")) return undefined; + const sessionId = extractSessionIdFromReadPath(pathname); // (2) + if (!sessionId) return undefined; + const owner = this.ownerMap.resolveOwner(sessionId); // (3) + if (!owner) return undefined; + if (owner === this.localMachineId) return undefined; // (4) + const peer = this.peer(owner); // (5) + if (!peer) return undefined; // owner is not a serving peer → local fall-through + // (6) authorize via the pure gate. The gate reads the control grant (D2- + // corrected reader) FIRST, then transport reachability — so it emits the + // honest reason for every denial (no-control-grant / grant-revoked / + // insufficient-scope / transport-down). + const gate = evaluateRemoteWriteGate( + { ownerMachineId: owner, action: m, path: pathname }, + { + localMachineId: this.localMachineId, + grantReader: (id) => this.grantReader(id), + peerReachable: (id) => { + const p = this.peer(id); + return !!(p && p.getUrl() && p.token); + }, + }, + ); + if (!gate.allowed) return { kind: "denied", machineId: owner, reason: gate.reason }; + // Allowed → route to the owner with the PEER credential (reader token), NOT + // the control-grant token (the empirical boundary above). Defensive + // transport re-read: the gate's peerReachable already guaranteed url+token, + // but this narrows the optionals honestly to transport-down if they vanished. + const url = peer.getUrl(); + const token = peer.token; + if (!url || !token) return { kind: "denied", machineId: owner, reason: "transport-down" }; + return { kind: "peer", machineId: owner, url, token }; + } +} + // ── multiplexing proxy ─────────────────────────────────────────────────────── /** The resolved target for one request. The `resolveTarget` return is diff --git a/packages/extension/test/amicode_service_observe_write_routing.test.ts b/packages/extension/test/amicode_service_observe_write_routing.test.ts new file mode 100644 index 00000000..63efbf02 --- /dev/null +++ b/packages/extension/test/amicode_service_observe_write_routing.test.ts @@ -0,0 +1,592 @@ +// amicode_service_observe_write_routing.test.ts — #1542 (Fleet Studio B2b, +// WRITE seam): the mirror of the #1537 observation READ seam, with the +// GET/non-GET decision INVERTED. On the OBSERVATION-ONLY path (the base +// peer-studio routes are mounted but NO premium FleetPlane is attached, so +// getMode stays "engine"), a NON-GET request (prompt / archive / delete) to a +// PEER-OWNED session must be AUTHORIZED by the control gate and, when allowed, +// ROUTED to the owner peer with the credential the owner accepts (the peer +// reader token — see the empirical note below); an unauthorized write is a +// NAMED, honest deny, NEVER executed locally (the #1382 invariant). GET is +// ignored here (the read plane owns it), and local-owned / unowned / non-session +// / /amicode/* writes fall through BYTE-IDENTICAL to the local engine. +// +// EMPIRICAL CREDENTIAL DECISION (resolved against reality, not assumed): +// the control grant is the CLIENT-SIDE authorization gate — it decides whether +// this machine may SEND the write at all. The transport credential presented to +// the owner is the PEER credential the owner accepts (today: the reader token, +// the SAME credential the read plane sources via fleetPeers.readPeerToken). A +// self-issued control-grant token the owner has never seen would 401. A distinct +// owner-enforced control token is a future tightening (the owner does not yet +// enforce a separate control scope on proxied /session). So the write plane +// presents target.token (the peer reader token) via its OWN proxyToPeer, NOT +// ControlGatedResolver/controlGatedMultiplexAdapter (whose adapter DROPS the +// peer credential). +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as http from "node:http"; +import { AddressInfo } from "node:net"; + +import { AmicodeServiceServer } from "../src/amicode_service/server"; +import { createAmicodeService } from "../src/amicode_service"; +import { EngineProxy } from "../src/amicode_service/engine_proxy"; +import { + SessionOwnerMap, + ObservationWriteRouter, + type PeerTransport, +} from "../src/amicode_service/session_multiplexer"; +import { createObservationReadPlane } from "../src/amicode_service/observation_read_plane"; +import { createObservationWritePlane } from "../src/amicode_service/observation_write_plane"; +import type { WriteGrantRead } from "../src/amicode_service/remote_write_gate"; +import { peerAuthHeader } from "../src/amicode_service/merged_projection"; +import { serverAuthHeader } from "../src/server_auth"; + +const PW = "observe-write-1542"; + +// ── a path-aware marker stub (same shape as the read-routing suite): records +// {method, path, auth}, answers a session ARRAY for GET /session (the +// projection fan-out) + a version for /global/health, and a distinct marker +// for every other path. ────────────────────────────────────────────────── +interface Stub { + url: string; + marker: string; + requests: Array<{ method: string; path: string; auth?: string }>; + stop(): Promise; +} +function startStub(marker: string, sessions: Array> = []): Promise { + const requests: Stub["requests"] = []; + const server = http.createServer((req, res) => { + const u = new URL(req.url ?? "/", "http://stub"); + requests.push({ + method: req.method ?? "GET", + path: u.pathname, + auth: typeof req.headers.authorization === "string" ? req.headers.authorization : undefined, + }); + res.writeHead(200, { "content-type": "application/json" }); + if (u.pathname === "/session" && (req.method ?? "GET") === "GET") return void res.end(JSON.stringify(sessions)); + if (u.pathname === "/global/health") return void res.end(JSON.stringify({ version: "stub-1542" })); + res.end(JSON.stringify({ ok: true, marker })); + }); + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const port = (server.address() as AddressInfo).port; + resolve({ + url: `http://127.0.0.1:${port}`, + marker, + requests, + stop: () => new Promise((r) => server.close(() => r())), + }); + }); + }); +} + +let localStub: Stub; +let peerStub: Stub; + +beforeAll(async () => { + localStub = await startStub("LOCAL-1542", [{ id: "ses-local", time: { created: 1, updated: 2 } }]); + peerStub = await startStub("PEER-1542", [{ id: "ses-studio", time: { created: 3, updated: 4 } }]); +}); +afterAll(async () => { + await localStub?.stop(); + await peerStub?.stop(); +}); + +const authed = { Authorization: serverAuthHeader(PW) }; + +// ══════════════════════════════════════════════════════════════════════════════ +// The pure resolver — GET/non-GET INVERTED + the authorize decision (units). +// ══════════════════════════════════════════════════════════════════════════════ +describe("#1542 — ObservationWriteRouter resolution order (GET/non-GET INVERTED)", () => { + const ownerMap = new SessionOwnerMap(); + ownerMap.update([ + { id: "ses-studio", amicode_owner: { owner_machine_id: "studio", owner_name: "studio", is_local: false } }, + { id: "ses-local", amicode_owner: { owner_machine_id: "macbook", owner_name: "macbook", is_local: true } }, + { id: "ses-dark", amicode_owner: { owner_machine_id: "darkpeer", owner_name: "darkpeer", is_local: false } }, + { id: "ses-observe", amicode_owner: { owner_machine_id: "obspeer", owner_name: "obspeer", is_local: false } }, + { id: "ses-revoked", amicode_owner: { owner_machine_id: "revpeer", owner_name: "revpeer", is_local: false } }, + { id: "ses-pending", amicode_owner: { owner_machine_id: "pendpeer", owner_name: "pendpeer", is_local: false } }, + { id: "ses-nogrant", amicode_owner: { owner_machine_id: "ngpeer", owner_name: "ngpeer", is_local: false } }, + { id: "ses-stranger", amicode_owner: { owner_machine_id: "stranger", owner_name: "stranger", is_local: false } }, + ]); + const peer = (id: string): PeerTransport | undefined => { + if (id === "studio") return { getUrl: () => "http://studio.invalid", token: "tok-studio" }; + if (id === "darkpeer") return { getUrl: () => undefined, token: "tok-dark" }; // serving peer, transport down + if (id === "obspeer") return { getUrl: () => "http://obs.invalid", token: "tok-obs" }; + if (id === "revpeer") return { getUrl: () => "http://rev.invalid", token: "tok-rev" }; + if (id === "pendpeer") return { getUrl: () => "http://pend.invalid", token: "tok-pend" }; + if (id === "ngpeer") return { getUrl: () => "http://ng.invalid", token: "tok-ng" }; + return undefined; // "stranger" is not a serving peer + }; + const grantReader = (owner: string): WriteGrantRead | undefined => { + if (owner === "studio") return { scope: "control", state: "active" }; + if (owner === "darkpeer") return { scope: "control", state: "active" }; // active but transport down + if (owner === "obspeer") return { scope: "observe", state: "active" }; // wrong scope + if (owner === "revpeer") return { scope: "control", state: "revoked" }; + if (owner === "pendpeer") return { scope: "control", state: "revocation-pending" }; + return undefined; // ngpeer + stranger: no grant + }; + const r = new ObservationWriteRouter({ ownerMap, localMachineId: "macbook", peer, grantReader }); + + it("a NON-GET for a peer-owned session (active control + reachable) → the peer target with the PEER reader token", () => { + expect(r.resolve("POST", "/api/session/ses-studio/message")).toEqual({ + kind: "peer", + machineId: "studio", + url: "http://studio.invalid", + token: "tok-studio", + }); + expect(r.resolve("DELETE", "/session/ses-studio")).toMatchObject({ kind: "peer", machineId: "studio" }); + expect(r.resolve("PATCH", "/api/session/ses-studio")).toMatchObject({ kind: "peer", machineId: "studio" }); + }); + + it("a GET/HEAD NEVER routes on the write plane (the read plane owns GET — the INVERTED decision)", () => { + expect(r.resolve("GET", "/api/session/ses-studio")).toBeUndefined(); + expect(r.resolve("GET", "/session/ses-studio")).toBeUndefined(); + expect(r.resolve("HEAD", "/api/session/ses-studio")).toBeUndefined(); + }); + + it("local-owned / unowned / not-a-known-peer → undefined (BYTE-IDENTICAL local fall-through)", () => { + expect(r.resolve("POST", "/session/ses-local")).toBeUndefined(); // local-owned + expect(r.resolve("POST", "/api/session/ses-unknown/message")).toBeUndefined(); // unowned + expect(r.resolve("POST", "/api/session/ses-stranger/message")).toBeUndefined(); // owner not a serving peer + }); + + it("/amicode/* and non-session paths → undefined (never a write target)", () => { + expect(r.resolve("POST", "/amicode/fleet/status")).toBeUndefined(); + expect(r.resolve("POST", "/amicode/profile")).toBeUndefined(); + expect(r.resolve("POST", "/session")).toBeUndefined(); // the list, no id + expect(r.resolve("POST", "/session/status")).toBeUndefined(); // the status poll + }); + + it("no control grant → DENIED no-control-grant (never local, never a peer target)", () => { + expect(r.resolve("POST", "/api/session/ses-nogrant/message")).toEqual({ + kind: "denied", + machineId: "ngpeer", + reason: "no-control-grant", + }); + }); + + it("a revoked grant → DENIED grant-revoked", () => { + expect(r.resolve("DELETE", "/api/session/ses-revoked")).toEqual({ + kind: "denied", + machineId: "revpeer", + reason: "grant-revoked", + }); + }); + + it("a revocation-pending grant → DENIED grant-revoked (the gate COLLAPSES pending→revoked)", () => { + expect(r.resolve("POST", "/api/session/ses-pending/message")).toEqual({ + kind: "denied", + machineId: "pendpeer", + reason: "grant-revoked", + }); + }); + + it("an observe-only grant → DENIED insufficient-scope (need control for writes)", () => { + expect(r.resolve("POST", "/api/session/ses-observe/message")).toEqual({ + kind: "denied", + machineId: "obspeer", + reason: "insufficient-scope", + }); + }); + + it("an active control grant but transport down → DENIED transport-down (never local)", () => { + expect(r.resolve("POST", "/api/session/ses-dark/message")).toEqual({ + kind: "denied", + machineId: "darkpeer", + reason: "transport-down", + }); + }); +}); + +// ── an observation-mode server with BOTH planes: local EngineProxy + the read +// plane (#1537) + the write plane (#1542), NO fleet plane (getMode stays +// "engine"). `owners` seeds the SessionOwnerMap; `peer` resolves transport; +// `grantReader` is injected (isolates dispatch wiring from the grant store — +// the real findControlGrantByTarget composition is exercised in the +// production-wiring describe below). ───────────────────────────────────── +function bootObserveRW( + owners: Array<{ id: string; owner: string }>, + peer: (machineId: string) => PeerTransport | undefined, + grantReader: (owner: string) => WriteGrantRead | undefined, + localMachineId = "macbook", +): AmicodeServiceServer { + const ownerMap = new SessionOwnerMap(); + ownerMap.update( + owners.map((o) => ({ + id: o.id, + amicode_owner: { owner_machine_id: o.owner, owner_name: o.owner, is_local: o.owner === localMachineId }, + })), + ); + const server = new AmicodeServiceServer({ password: PW }); + server.attachEngineProxy(new EngineProxy({ getUrl: () => localStub.url })); + server.attachObservationReadPlane(createObservationReadPlane({ ownerMap, localMachineId, peer })); + server.attachObservationWritePlane(createObservationWritePlane({ ownerMap, localMachineId, peer, grantReader })); + return server; +} + +// ══════════════════════════════════════════════════════════════════════════════ +// AC1 — with NO control grant, a remote write is DENIED no-control-grant and is +// NEVER executed locally (nor sent to the peer). +// ══════════════════════════════════════════════════════════════════════════════ +describe("#1542 AC1 — no control grant → remote write denied no-control-grant, never local", () => { + const peer = (id: string): PeerTransport | undefined => + id === "studio" ? { getUrl: () => peerStub.url, token: "tok-studio" } : undefined; + const grantReader = (): WriteGrantRead | undefined => undefined; // no grant anywhere + + for (const [method, path] of [ + ["POST", "/api/session/ses-studio/message"], + ["DELETE", "/api/session/ses-studio"], + ["PATCH", "/session/ses-studio"], + ] as const) { + it(`${method} ${path} → 403 no-control-grant; peer NOT dialed; local NOT dialed`, async () => { + const server = bootObserveRW([{ id: "ses-studio", owner: "studio" }], peer, grantReader); + const origin = (await server.start()).toString().replace(/\/$/, ""); + const localBefore = localStub.requests.length; + const peerBefore = peerStub.requests.length; + try { + const res = await fetch(`${origin}${path}`, { + method, + headers: { ...authed, "content-type": "application/json" }, + body: method === "DELETE" ? undefined : JSON.stringify({ text: "hi" }), + }); + expect(res.status).toBe(403); + const body = (await res.json()) as { ok: boolean; reason?: string }; + expect(body.ok).toBe(false); + expect(body.reason).toBe("no-control-grant"); + expect(peerStub.requests.length).toBe(peerBefore); // the peer was NOT written to + expect(localStub.requests.length).toBe(localBefore); // and NEVER executed locally + } finally { + await server.stop(); + } + }); + } +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// AC2 — with an active control grant + reachable transport, a prompt / archive / +// delete to a peer-owned session ROUTES to the owner with the credential the +// owner accepts (the PEER reader token); the local engine is NOT dialed. +// ══════════════════════════════════════════════════════════════════════════════ +describe("#1542 AC2 — active control + reachable → the write routes to the owner peer with the accepted credential", () => { + const peer = (id: string): PeerTransport | undefined => + id === "studio" ? { getUrl: () => peerStub.url, token: "tok-studio" } : undefined; + const grantReader = (owner: string): WriteGrantRead | undefined => + owner === "studio" ? { scope: "control", state: "active" } : undefined; + + for (const [method, path] of [ + ["POST", "/api/session/ses-studio/message"], // prompt + ["DELETE", "/api/session/ses-studio"], // delete + ["PATCH", "/session/ses-studio"], // archive (a mutation) + ] as const) { + it(`${method} ${path} → dialed on the PEER with peerAuthHeader(reader token); local NOT dialed`, async () => { + const server = bootObserveRW([{ id: "ses-studio", owner: "studio" }], peer, grantReader); + const origin = (await server.start()).toString().replace(/\/$/, ""); + const localBefore = localStub.requests.length; + const peerBefore = peerStub.requests.length; + try { + const res = await fetch(`${origin}${path}`, { + method, + headers: { ...authed, "content-type": "application/json" }, + body: method === "DELETE" ? undefined : JSON.stringify({ text: "hi" }), + }); + expect(res.status).toBe(200); + expect(((await res.json()) as { marker: string }).marker).toBe(peerStub.marker); + expect(peerStub.requests.length).toBe(peerBefore + 1); + const dialed = peerStub.requests.at(-1)!; + expect(dialed.method).toBe(method); // the write verb proxied verbatim + expect(dialed.path).toBe(path); // proxied verbatim + expect(dialed.auth).toBe(peerAuthHeader("tok-studio")); // the PEER reader token the owner accepts + expect(dialed.auth?.includes(PW)).toBe(false); // the local mint is NEVER forwarded outward + expect(localStub.requests.length).toBe(localBefore); // the local engine was NOT dialed + } finally { + await server.stop(); + } + }); + } +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// AC3 — the fail-closed vocabulary: revoked / revocation-pending → grant-revoked; +// wrong scope → insufficient-scope; transport-down → transport-down. Mutation is +// suspended, but the session stays READABLE via the read plane (#1537). None of +// these ever executes locally. +// ══════════════════════════════════════════════════════════════════════════════ +describe("#1542 AC3 — the gate's real reasons; mutation suspended, session still readable", () => { + const peer = (id: string): PeerTransport | undefined => + id === "studio" ? { getUrl: () => peerStub.url, token: "tok-studio" } : undefined; + const peerDown = (id: string): PeerTransport | undefined => + id === "studio" ? { getUrl: () => undefined, token: "tok-studio" } : undefined; // serving, transport down + + async function denyCase( + grantReader: (o: string) => WriteGrantRead | undefined, + peerFn: (id: string) => PeerTransport | undefined, + expectStatus: number, + expectReason: string, + ) { + const server = bootObserveRW([{ id: "ses-studio", owner: "studio" }], peerFn, grantReader); + const origin = (await server.start()).toString().replace(/\/$/, ""); + const localBefore = localStub.requests.length; + try { + const res = await fetch(`${origin}/api/session/ses-studio/message`, { + method: "POST", + headers: { ...authed, "content-type": "application/json" }, + body: JSON.stringify({ text: "hi" }), + }); + expect(res.status).toBe(expectStatus); + const body = (await res.json()) as { ok: boolean; reason?: string }; + expect(body.ok).toBe(false); + expect(body.reason).toBe(expectReason); + expect(localStub.requests.length).toBe(localBefore); // NEVER executed locally + } finally { + await server.stop(); + } + } + + it("a revoked grant → 403 grant-revoked", async () => { + await denyCase((o) => (o === "studio" ? { scope: "control", state: "revoked" } : undefined), peer, 403, "grant-revoked"); + }); + + it("a revocation-pending grant → 403 grant-revoked (the gate collapses pending→revoked)", async () => { + await denyCase((o) => (o === "studio" ? { scope: "control", state: "revocation-pending" } : undefined), peer, 403, "grant-revoked"); + }); + + it("an observe-only grant → 403 insufficient-scope", async () => { + await denyCase((o) => (o === "studio" ? { scope: "observe", state: "active" } : undefined), peer, 403, "insufficient-scope"); + }); + + it("an active control grant but transport down → 503 transport-down (peer-unreachable), never local", async () => { + await denyCase((o) => (o === "studio" ? { scope: "control", state: "active" } : undefined), peerDown, 503, "transport-down"); + }); + + it("mutation suspended (insufficient-scope) yet the session stays READABLE via the read plane", async () => { + // observe-only grant: the WRITE is denied, but a GET still routes to the + // owner peer through the untouched read plane (session readable). + const grantReader = (o: string): WriteGrantRead | undefined => (o === "studio" ? { scope: "observe", state: "active" } : undefined); + const server = bootObserveRW([{ id: "ses-studio", owner: "studio" }], peer, grantReader); + const origin = (await server.start()).toString().replace(/\/$/, ""); + const peerBefore = peerStub.requests.length; + try { + // WRITE denied + const w = await fetch(`${origin}/api/session/ses-studio/message`, { + method: "POST", + headers: { ...authed, "content-type": "application/json" }, + body: JSON.stringify({ text: "hi" }), + }); + expect(w.status).toBe(403); + expect((await w.json() as { reason?: string }).reason).toBe("insufficient-scope"); + // READ still routes to the peer (the read plane owns GET, unaffected by the write gate) + const rr = await fetch(`${origin}/api/session/ses-studio`, { headers: authed }); + expect(rr.status).toBe(200); + expect(((await rr.json()) as { marker: string }).marker).toBe(peerStub.marker); + const dialed = peerStub.requests.slice(peerBefore).find((q) => q.method === "GET" && q.path === "/api/session/ses-studio"); + expect(dialed).toBeDefined(); + expect(dialed!.auth).toBe(peerAuthHeader("tok-studio")); + } finally { + await server.stop(); + } + }); +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// AC4 — local-owned and unowned sessions: writes fall through BYTE-IDENTICAL to +// the local engine (the plane is inert); the peer is NOT dialed. +// ══════════════════════════════════════════════════════════════════════════════ +describe("#1542 AC4 — local-owned / unowned writes fall through byte-identical to the local engine", () => { + const peer = (id: string): PeerTransport | undefined => + id === "studio" ? { getUrl: () => peerStub.url, token: "tok-studio" } : undefined; + const grantReader = (owner: string): WriteGrantRead | undefined => + owner === "studio" ? { scope: "control", state: "active" } : undefined; + + it("POST for a LOCAL-owned session → the local engine, peer NOT dialed", async () => { + const server = bootObserveRW([{ id: "ses-local", owner: "macbook" }], peer, grantReader); + const origin = (await server.start()).toString().replace(/\/$/, ""); + const peerBefore = peerStub.requests.length; + try { + const res = await fetch(`${origin}/api/session/ses-local/message`, { + method: "POST", + headers: { ...authed, "content-type": "application/json" }, + body: JSON.stringify({ text: "hi" }), + }); + expect(((await res.json()) as { marker: string }).marker).toBe(localStub.marker); + expect(peerStub.requests.length).toBe(peerBefore); // peer NOT dialed + } finally { + await server.stop(); + } + }); + + it("POST for an UNOWNED session → the local engine, peer NOT dialed", async () => { + const server = bootObserveRW([], peer, grantReader); + const origin = (await server.start()).toString().replace(/\/$/, ""); + const peerBefore = peerStub.requests.length; + try { + const res = await fetch(`${origin}/api/session/ses-nobody/message`, { + method: "POST", + headers: { ...authed, "content-type": "application/json" }, + body: JSON.stringify({ text: "hi" }), + }); + expect(((await res.json()) as { marker: string }).marker).toBe(localStub.marker); + expect(peerStub.requests.length).toBe(peerBefore); + } finally { + await server.stop(); + } + }); +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// Byte-identity when UNATTACHED — a server with NO write plane consults nothing: +// a POST for a peer-owned session falls through to the local engine (the +// `if (this.observeWrite)` block is a structural no-op). This is the flag-off / +// standalone / armed posture. +// ══════════════════════════════════════════════════════════════════════════════ +describe("#1542 — the write plane is INERT when unattached (structural byte-identity)", () => { + const peer = (id: string): PeerTransport | undefined => + id === "studio" ? { getUrl: () => peerStub.url, token: "tok-studio" } : undefined; + + it("no write plane attached → a peer-owned POST falls through to the local engine, peer NOT dialed", async () => { + // read plane attached (so the seam family is present) but the WRITE plane is NOT. + const ownerMap = new SessionOwnerMap(); + ownerMap.update([{ id: "ses-studio", amicode_owner: { owner_machine_id: "studio", owner_name: "studio", is_local: false } }]); + const server = new AmicodeServiceServer({ password: PW }); + server.attachEngineProxy(new EngineProxy({ getUrl: () => localStub.url })); + server.attachObservationReadPlane(createObservationReadPlane({ ownerMap, localMachineId: "macbook", peer })); + // NO attachObservationWritePlane + const origin = (await server.start()).toString().replace(/\/$/, ""); + const peerBefore = peerStub.requests.length; + try { + const res = await fetch(`${origin}/api/session/ses-studio/message`, { + method: "POST", + headers: { ...authed, "content-type": "application/json" }, + body: JSON.stringify({ text: "hi" }), + }); + expect(((await res.json()) as { marker: string }).marker).toBe(localStub.marker); + expect(peerStub.requests.length).toBe(peerBefore); // peer NOT dialed + } finally { + await server.stop(); + } + }); +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// Production wiring — createAmicodeService on the OBSERVATION-ONLY path wires the +// write plane with the REAL findControlGrantByTarget composition (the D2 fix): a +// control grant resolved by targetMachineId === owner authorizes the write; NO +// grant denies no-control-grant. The read plane and armed path stay unchanged. +// ══════════════════════════════════════════════════════════════════════════════ +describe("#1542 — production wiring (createAmicodeService observation-only path + real grant store)", () => { + let root: string; + const savedHubFile = process.env.AMICO_FLEET_HUB_FILE; + const savedGrantFile = process.env.AMICO_FLEET_LIFECYCLE_GRANT_FILE; + + beforeAll(() => { + root = mkdtempSync(join(tmpdir(), "amicode-1542-wire-")); + process.env.AMICO_FLEET_HUB_FILE = join(root, "hub-cred-absent.json"); + process.env.AMICO_FLEET_LIFECYCLE_GRANT_FILE = join(root, "lifecycle-grants.json"); + }); + afterAll(() => { + if (savedHubFile === undefined) delete process.env.AMICO_FLEET_HUB_FILE; + else process.env.AMICO_FLEET_HUB_FILE = savedHubFile; + if (savedGrantFile === undefined) delete process.env.AMICO_FLEET_LIFECYCLE_GRANT_FILE; + else process.env.AMICO_FLEET_LIFECYCLE_GRANT_FILE = savedGrantFile; + rmSync(root, { recursive: true, force: true }); + }); + + function servingPeerProvider() { + return { + localMachineId: "macbook", + getServingPeers: () => [{ machineId: "studio" }], + getBlockedPeers: () => [] as Array<{ machineId: string; reason: "identity-conflict" }>, + readPeerToken: (id: string) => + id === "studio" + ? ({ ok: true as const, credential: { baseUrl: peerStub.url, token: "tok-studio" } }) + : ({ ok: false as const }), + rosterLookup: (id: string) => ({ name: id }), + }; + } + + async function waitFor(cond: () => boolean, timeoutMs = 4000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (cond()) return true; + await new Promise((r) => setTimeout(r, 25)); + } + return cond(); + } + + it("with an active control grant (target === studio), an observation-only boot routes a peer-owned WRITE to the peer (its reader token); local NOT dialed", async () => { + // Seed the real lifecycle store with an active control grant whose TARGET is + // the owner peer (studio) — the D2-corrected findControlGrantByTarget read. + const { issueLifecycleGrant } = await import("../src/amicode_service/fleet_control_lifecycle"); + const issued = issueLifecycleGrant({ + requesterMachineId: "macbook", + requesterIdentityKey: "id-macbook", + targetMachineId: "studio", + targetIdentityKey: "id-studio", + scope: "control", + }); + expect(issued.ok).toBe(true); + + const svc = createAmicodeService({ + password: PW, + engine: { password: "engine-mint", getUrl: () => localStub.url }, + fleet: { hub: { getUrl: () => undefined }, observationOnly: true, fleetPeers: servingPeerProvider() }, + }); + const origin = (await svc.start()).toString().replace(/\/$/, ""); + try { + await waitFor(() => peerStub.requests.some((r) => r.path === "/session") && peerStub.requests.some((r) => r.path === "/global/health")); + await new Promise((r) => setTimeout(r, 250)); // settle the synchronous ownerMap.update + + const localBefore = localStub.requests.length; + const peerBefore = peerStub.requests.length; + const res = await fetch(`${origin}/api/session/ses-studio/message`, { + method: "POST", + headers: { Authorization: serverAuthHeader("engine-mint"), "content-type": "application/json" }, + body: JSON.stringify({ text: "hi" }), + }); + expect(res.status).toBe(200); + expect(((await res.json()) as { marker: string }).marker).toBe(peerStub.marker); + const dialed = peerStub.requests.slice(peerBefore).find((r) => r.method === "POST" && r.path === "/api/session/ses-studio/message"); + expect(dialed).toBeDefined(); + expect(dialed!.auth).toBe(peerAuthHeader("tok-studio")); // the peer reader token the owner accepts + expect(localStub.requests.slice(localBefore).some((r) => r.path === "/api/session/ses-studio/message")).toBe(false); // local NOT dialed + } finally { + await svc.stop(); + } + }); + + it("with NO grant in the store, the SAME peer-owned WRITE is denied no-control-grant; peer NOT dialed, local NOT dialed", async () => { + // Clear the store (a fresh temp file with no grant). + const freshGrantFile = join(root, "lifecycle-grants-empty.json"); + const saved = process.env.AMICO_FLEET_LIFECYCLE_GRANT_FILE; + process.env.AMICO_FLEET_LIFECYCLE_GRANT_FILE = freshGrantFile; + + const svc = createAmicodeService({ + password: PW, + engine: { password: "engine-mint", getUrl: () => localStub.url }, + fleet: { hub: { getUrl: () => undefined }, observationOnly: true, fleetPeers: servingPeerProvider() }, + }); + const origin = (await svc.start()).toString().replace(/\/$/, ""); + try { + await waitFor(() => peerStub.requests.some((r) => r.path === "/session") && peerStub.requests.some((r) => r.path === "/global/health")); + await new Promise((r) => setTimeout(r, 250)); + + const localBefore = localStub.requests.length; + const peerBefore = peerStub.requests.length; + const res = await fetch(`${origin}/api/session/ses-studio/message`, { + method: "POST", + headers: { Authorization: serverAuthHeader("engine-mint"), "content-type": "application/json" }, + body: JSON.stringify({ text: "hi" }), + }); + expect(res.status).toBe(403); + expect((await res.json() as { reason?: string }).reason).toBe("no-control-grant"); + expect(peerStub.requests.slice(peerBefore).some((r) => r.method === "POST" && r.path === "/api/session/ses-studio/message")).toBe(false); + expect(localStub.requests.slice(localBefore).some((r) => r.path === "/api/session/ses-studio/message")).toBe(false); + } finally { + await svc.stop(); + if (saved === undefined) delete process.env.AMICO_FLEET_LIFECYCLE_GRANT_FILE; + else process.env.AMICO_FLEET_LIFECYCLE_GRANT_FILE = saved; + } + }); +}); + From 2557a7e08da4fb019dea91ed55557628eb561869 Mon Sep 17 00:00:00 2001 From: amicode-ci Date: Thu, 24 Sep 2026 17:34:22 -0400 Subject: [PATCH 3/5] Fleet Studio B2b (#1543): SSE fan-in on the observation /event (ADR 0033 D1-D4) A NEW, separately-armed observation /event interception that reuses SseFanInDriver as a library (ADR 0034 D6 / ADR 0033 Amendment 1): NOT the premium fleet-plane wire, NOT behind AMICO_FLEET_MULTIPLEX, NOT behind the multiplexer. Armed on observation readiness (the driver declines at zero non-local owners = the fleet-of-one byte-identity guard); no focus-snapshot provider on this path so byte-identity holds. Per-peer arms auth as themselves with their own reader token; local arm rides the app credential. - server.ts: observeEvents field + attachObservationEventPlane + a separate /event dispatch consult beside (never replacing) the premium wire. - index.ts: attach a SseFanInDriver in the observation branch, wired from fleetPeers (ownerMap/localMachineId/localEventUrl/peerBaseUrl/peerToken). - +5 tests: fleet-of-one byte-identity oracle (vs no-plane), live peer fan-in, cursor pass-through, inert-when-unattached, production wiring (peer /event dialed with the reader token). Premium server.ts wire + fleetMultiplexEnabled untouched (the #1519 invariant test stays green). Director-run recovery (autodev honest degradation): the dispatched implementer session was interrupted with nothing written; worktree was pristine. Part of #1540. --- .../extension/src/amicode_service/index.ts | 28 ++ .../extension/src/amicode_service/server.ts | 34 +- ...code_service_observe_event_routing.test.ts | 343 ++++++++++++++++++ 3 files changed, 403 insertions(+), 2 deletions(-) create mode 100644 packages/extension/test/amicode_service_observe_event_routing.test.ts diff --git a/packages/extension/src/amicode_service/index.ts b/packages/extension/src/amicode_service/index.ts index 5c74b314..d2c84457 100644 --- a/packages/extension/src/amicode_service/index.ts +++ b/packages/extension/src/amicode_service/index.ts @@ -1140,6 +1140,34 @@ export function createAmicodeService( ...(opts.fleet.dataPlaneTimeoutMs !== undefined ? { timeoutMs: opts.fleet.dataPlaneTimeoutMs } : {}), }), ); + // #1543 (Fleet Studio B2b, SSE fan-in seam): BESIDE the read + write + // planes, attach a NEW, SEPARATELY-ARMED observation `/event` fan-in + // driver (ADR 0034 D6 / ADR 0033 Amendment 1). It REUSES SseFanInDriver + // as a library — it is NOT the premium fleet-plane fan-in wire, NOT + // behind AMICO_FLEET_MULTIPLEX, and NOT behind the multiplexer. Armed on + // OBSERVATION READINESS: the driver DECLINES at zero non-local owners, so + // holding observe on ≥1 reachable session-owning peer (a non-local owner + // in the same ownerMap the read/write planes use) is exactly its takeover + // gate; fleet-of-one stays byte-identical BY the driver. NO focusSnapshot + // is wired here — fleet-of-one byte-identity holds only absent a focus + // provider (D6). Per-peer arms auth AS THEMSELVES with their OWN reader + // token (decision A); the local arm rides the app's incoming credential. + server.attachObservationEventPlane( + new SseFanInDriver({ + ownerMap, + localMachineId: fleetPeers.localMachineId, + localEventUrl: opts.engine?.getUrl ?? ((): string | undefined => undefined), + peerBaseUrl: (machineId) => { + if (!fleetPeers.getServingPeers().some((p) => p.machineId === machineId)) return undefined; + const r = fleetPeers.readPeerToken(machineId); + return r.ok ? r.credential.baseUrl : undefined; + }, + peerToken: (machineId) => { + const r = fleetPeers.readPeerToken(machineId); + return r.ok ? { ok: true, credential: r.credential } : { ok: false, reason: "absent" }; + }, + }), + ); } } } diff --git a/packages/extension/src/amicode_service/server.ts b/packages/extension/src/amicode_service/server.ts index 62574397..083d8b56 100644 --- a/packages/extension/src/amicode_service/server.ts +++ b/packages/extension/src/amicode_service/server.ts @@ -266,8 +266,16 @@ export class AmicodeServiceServer { * Armed ONLY on the observation path (index.ts), BESIDE observeRead; absent * everywhere else, so the dispatch consult is a structural no-op unless it is * attached. */ - private observeWrite?: ObservationWritePlane; - readonly password: string; + private observeWrite?: ObservationWritePlane; + /** #1543 (B2b SSE fan-in seam): the observation-only `/event` fan-in driver. + * A NEW, SEPARATELY-ARMED interception (NOT the premium fleet-plane fan-in + * wire, NOT behind AMICO_FLEET_MULTIPLEX, NOT behind the multiplexer — ADR + * 0033 Amendment 1). Armed ONLY on the observation path (index.ts); absent + * everywhere else, so the dispatch consult is a structural no-op unless it is + * attached. The driver's own zero-non-local-owner decline is the fleet-of-one + * byte-identity guard (observation readiness = ≥1 reachable owner peer). */ + private observeEvents?: EventFanInDriver; + readonly password: string; /** #955 (the hub cutover): the auth mode. "credential" (the default) is the * per-boot-mint posture — every non-public-UI request 401s without a * valid mint. "open" matches the fork hub's DEPLOYED posture on the @@ -362,6 +370,16 @@ export class AmicodeServiceServer { return this; } + /** #1543 (B2b SSE fan-in seam): arm the observation-only `/event` fan-in + * driver. Called ONLY by the observation path in index.ts; a boot without it + * never consults the seam (byte-identical). This is DISTINCT from the premium + * fleet-plane fan-in wire — the two are mutually exclusive by which + * plane a boot attaches (an observation boot has no fleetPlane). */ + attachObservationEventPlane(driver: EventFanInDriver): this { + this.observeEvents = driver; + return this; + } + /** #1449 (W1b): register a teardown callback run once on stop() (idempotent * per callback via the caller). The owner-map feed's timer registers here so * it is halted when the service stops. */ @@ -548,6 +566,18 @@ export class AmicodeServiceServer { // byte-identity). This is the deliberate amendment of the #1448 AC4 // structural guard: the SSE relay is wired ONLY behind the flag. if (url.pathname === "/event" && fleetMultiplexEnabled() && this.fleetPlane?.eventFanIn?.handle(req, res)) return; + // #1543 (Fleet Studio B2b, SSE fan-in on the OBSERVATION path — ADR 0034 D6 + // / ADR 0033 Amendment 1): a SEPARATE, separately-armed `/event` + // interception BESIDE the premium wire above. It is NOT behind + // fleetMultiplexEnabled() and NOT behind the multiplexer — it is armed only + // when the observation path attached `observeEvents` (index.ts), and the + // driver DECLINES (returns false) at zero non-local owners, so a fleet-of- + // one / unattached boot falls through BYTE-IDENTICALLY to the paths below + // (the `?.handle` is a structural no-op when unattached). An observation + // boot has no `fleetPlane`, so the premium line above already short- + // circuited; the two wires never both fire. With ≥1 owned peer the driver + // takes over the response (returns true → we return). + if (url.pathname === "/event" && this.observeEvents?.handle(req, res)) return; // #1262: in fleet CLIENT mode the HOST owns all /amicode/* state. Bypass // the ENTIRE local /amicode/* dispatch (the exact-match route table AND // the catch-all 404 below) so a REGISTERED route (GET /amicode/problems, diff --git a/packages/extension/test/amicode_service_observe_event_routing.test.ts b/packages/extension/test/amicode_service_observe_event_routing.test.ts new file mode 100644 index 00000000..26e7f0d1 --- /dev/null +++ b/packages/extension/test/amicode_service_observe_event_routing.test.ts @@ -0,0 +1,343 @@ +// amicode_service_observe_event_routing.test.ts — #1543 (Fleet Studio B2b, SSE +// fan-in on the OBSERVATION path, ADR 0033 D1–D4 via ADR 0034 D6). A NEW, +// SEPARATELY-ARMED `/event` interception that reuses the SseFanInDriver as a +// library — it is NOT the premium `server.ts` wire (`fleetMultiplexEnabled() && +// fleetPlane.eventFanIn`), NOT behind AMICO_FLEET_MULTIPLEX, and NOT behind the +// multiplexer (ADR 0033 Amendment 1). It is armed on OBSERVATION READINESS: +// holding observe on ≥1 reachable session-owning peer = ≥1 non-local owner in +// the SessionOwnerMap, which is exactly the driver's own zero-owner decline — +// so fleet-of-one is byte-identical BY the driver, and byte-identity holds only +// absent a focus-snapshot provider (none is wired on the observation path). +// +// The route dispatch → the observation `/event` interception → the #1511 +// aggregator → the real downstream `res` is exercised deterministically via an +// INJECTED upstream opener; the live cross-machine delivery is the opt-in E2E. +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as http from "node:http"; +import { AddressInfo } from "node:net"; + +import { AmicodeServiceServer } from "../src/amicode_service/server"; +import { createAmicodeService } from "../src/amicode_service"; +import { EngineProxy } from "../src/amicode_service/engine_proxy"; +import { SseFanInDriver } from "../src/amicode_service/sse_fanin_driver"; +import type { SseFrameSource } from "../src/amicode_service/sse_fanin_aggregator"; +import { SessionOwnerMap } from "../src/amicode_service/session_multiplexer"; +import { peerAuthHeader } from "../src/amicode_service/merged_projection"; +import { serverAuthHeader } from "../src/server_auth"; + +const PW = "observe-event-1543"; +const authed = { Authorization: serverAuthHeader(PW) }; + +// ── an SSE stub: emits a fixed frame list as text/event-stream then ends (so a +// byte-identity read completes). Records path / ?lastEventID / auth. ──────── +interface SseStub { + url: string; + requests: Array<{ path: string; lastEventID: string | null; auth?: string }>; + stop(): Promise; +} +function startSseStub(frames: string[]): Promise { + const requests: SseStub["requests"] = []; + const server = http.createServer((req, res) => { + const u = new URL(req.url ?? "/", "http://stub"); + requests.push({ + path: u.pathname, + lastEventID: u.searchParams.get("lastEventID"), + auth: typeof req.headers.authorization === "string" ? req.headers.authorization : undefined, + }); + if (u.pathname === "/session" && (req.method ?? "GET") === "GET") { + res.writeHead(200, { "content-type": "application/json" }); + return void res.end(JSON.stringify([{ id: "ses-studio", time: { created: 3, updated: 4 } }])); + } + if (u.pathname === "/global/health") { + res.writeHead(200, { "content-type": "application/json" }); + return void res.end(JSON.stringify({ version: "stub-1543" })); + } + res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" }); + for (const f of frames) res.write(f); + res.end(); + }); + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const port = (server.address() as AddressInfo).port; + resolve({ url: `http://127.0.0.1:${port}`, requests, stop: () => new Promise((r) => server.close(() => r())) }); + }); + }); +} + +function sseFrame(...lines: string[]): string { + return lines.join("\n") + "\n\n"; +} + +async function readSseFrames(url: string, headers: Record, opts: { maxFrames: number; timeoutMs: number }): Promise { + const frames: string[] = []; + try { + const res = await fetch(url, { headers, signal: AbortSignal.timeout(opts.timeoutMs) }); + if (!res.ok || res.body === null) return frames; + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buf = ""; + try { + while (frames.length < opts.maxFrames) { + const { value, done } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + let idx: number; + while ((idx = buf.indexOf("\n\n")) >= 0 && frames.length < opts.maxFrames) { + frames.push(buf.slice(0, idx + 2)); + buf = buf.slice(idx + 2); + } + } + } finally { + try { + await reader.cancel(); + } catch { + /* already closed */ + } + } + } catch { + /* timeout / blip — return whatever whole frames we got */ + } + return frames; +} + +/** A controllable frame source (stays OPEN between pushes; a real SSE stream + * never ends on its own) — feeds the aggregator deterministically. */ +interface Ctl { + push(frame: string): void; + end(): void; + source: SseFrameSource; +} +function controllableSource(preload: string[] = []): Ctl { + const queue: string[] = [...preload]; + const waiters: Array<(v: string | null) => void> = []; + let ended = false; + const push = (f: string): void => { + const w = waiters.shift(); + if (w) w(f); + else queue.push(f); + }; + const end = (): void => { + if (ended) return; + ended = true; + let w: ((v: string | null) => void) | undefined; + while ((w = waiters.shift())) w(null); + }; + return { + push, + end, + source: { + next(): Promise { + const f = queue.shift(); + if (f !== undefined) return Promise.resolve(f); + if (ended) return Promise.resolve(null); + return new Promise((resolve) => waiters.push(resolve)); + }, + close(): void { + end(); + }, + }, + }; +} + +// ══════════════════════════════════════════════════════════════════════════════ +// AC1 (WRITTEN FIRST — the fleet-of-one byte-identity guard). With the +// observation event plane ATTACHED but ZERO non-local owners, the driver +// DECLINES and `/event` streams frame-for-frame through the engine proxy — +// identical to a server with NO event plane. The oracle is the no-plane route. +// ══════════════════════════════════════════════════════════════════════════════ +describe("#1543 AC1 — observation /event fleet-of-one byte-identity (driver declines at zero owners)", () => { + const F1 = sseFrame("event: message", 'data: {"a":1}', "id: 1"); + const F2 = sseFrame("event: message", 'data: {"b":2}', "id: 2"); + + it("event plane attached + ZERO non-local owners → /event is frame-for-frame identical to the no-plane engine stream", async () => { + const engine = await startSseStub([F1, F2]); + try { + // Oracle: NO event plane attached. + const bare = new AmicodeServiceServer({ password: PW }); + bare.attachEngineProxy(new EngineProxy({ getUrl: () => engine.url })); + const bareOrigin = (await bare.start()).toString().replace(/\/$/, ""); + const bareBody = await (await fetch(`${bareOrigin}/event`, { headers: authed })).text(); + await bare.stop(); + + // Subject: event plane attached, but the ownerMap holds only a LOCAL owner + // (zero non-local owners → the driver declines → same engine-proxy path). + const ownerMap = new SessionOwnerMap(); + ownerMap.update([{ id: "ses-local", amicode_owner: { owner_machine_id: "macbook", owner_name: "macbook", is_local: true } }]); + const svc = new AmicodeServiceServer({ password: PW }); + svc.attachEngineProxy(new EngineProxy({ getUrl: () => engine.url })); + svc.attachObservationEventPlane( + new SseFanInDriver({ + ownerMap, + localMachineId: "macbook", + localEventUrl: () => engine.url, + peerBaseUrl: () => undefined, + peerToken: () => ({ ok: false, reason: "absent" }), + }), + ); + const origin = (await svc.start()).toString().replace(/\/$/, ""); + const body = await (await fetch(`${origin}/event`, { headers: authed })).text(); + await svc.stop(); + + expect(bareBody).toBe(F1 + F2); // sanity: the oracle is verbatim + expect(body).toBe(bareBody); // byte-identical to the no-plane route + } finally { + await engine.stop(); + } + }); + + it("the opaque ?lastEventID cursor rides through to the engine UNCHANGED when the driver declines", async () => { + const engine = await startSseStub([sseFrame("data: {}", "id: 9")]); + try { + const ownerMap = new SessionOwnerMap(); // zero owners → decline + const svc = new AmicodeServiceServer({ password: PW }); + svc.attachEngineProxy(new EngineProxy({ getUrl: () => engine.url })); + svc.attachObservationEventPlane( + new SseFanInDriver({ ownerMap, localMachineId: "macbook", localEventUrl: () => engine.url, peerBaseUrl: () => undefined, peerToken: () => ({ ok: false, reason: "absent" }) }), + ); + const origin = (await svc.start()).toString().replace(/\/$/, ""); + await (await fetch(`${origin}/event?lastEventID=42`, { headers: authed })).text(); + await svc.stop(); + expect(engine.requests.some((r) => r.path === "/event" && r.lastEventID === "42")).toBe(true); + } finally { + await engine.stop(); + } + }); +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// AC2 — a peer-owned session's events STREAM LIVE into this window's single +// /event (the driver takes over at ≥1 non-local owner; the peer arm's frame is +// fanned in, frame-preserved per ADR 0033 D2). Injected upstream for determinism. +// ══════════════════════════════════════════════════════════════════════════════ +describe("#1543 AC2 — a peer-owned session's events fan in live", () => { + it("≥1 reachable non-local owner → the peer frame's data survives on the single downstream /event", async () => { + const ownerMap = new SessionOwnerMap(); + ownerMap.update([{ id: "ses-studio", amicode_owner: { owner_machine_id: "studio", owner_name: "studio", is_local: false } }]); + const peerCtl = controllableSource([sseFrame("event: message", 'data: {"from":"studio","n":7}', "id: p1")]); + const localCtl = controllableSource(); // local arm stays open, no frames + const seenUpstream: Array<{ namespace: string; authHeader?: string }> = []; + + const svc = new AmicodeServiceServer({ password: PW }); + // No engine proxy needed: the driver takes over the response. + svc.attachObservationEventPlane( + new SseFanInDriver({ + ownerMap, + localMachineId: "macbook", + localEventUrl: () => "http://local.invalid", + peerBaseUrl: (id) => (id === "studio" ? "http://studio.invalid" : undefined), + peerToken: (id) => (id === "studio" ? { ok: true, credential: { baseUrl: "http://studio.invalid", token: "tok-studio" } } : { ok: false, reason: "absent" }), + reconcileMs: 999999, + openUpstream: (r) => { + seenUpstream.push({ namespace: r.namespace, ...(r.authHeader ? { authHeader: r.authHeader } : {}) }); + return r.namespace === "studio" ? peerCtl.source : localCtl.source; + }, + }), + ); + const origin = (await svc.start()).toString().replace(/\/$/, ""); + try { + const frames = await readSseFrames(`${origin}/event`, authed, { maxFrames: 6, timeoutMs: 2500 }); + const joined = frames.join(""); + expect(joined).toContain('"from":"studio"'); // the peer frame's data survived (D2 relay) + // the peer arm authed AS ITSELF with its OWN token (decision A / D2) + const peerArm = seenUpstream.find((u) => u.namespace === "studio"); + expect(peerArm).toBeDefined(); + expect(peerArm!.authHeader).toBe(peerAuthHeader("tok-studio")); + } finally { + peerCtl.end(); + localCtl.end(); + await svc.stop(); + } + }); +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// Inert when UNATTACHED — a server with NO event plane consults nothing; /event +// streams byte-identically through the engine proxy (the `?.handle` no-op). +// ══════════════════════════════════════════════════════════════════════════════ +describe("#1543 — the observation event plane is INERT when unattached (structural byte-identity)", () => { + it("no event plane attached → /event streams verbatim through the engine proxy", async () => { + const F = sseFrame("event: message", 'data: {"z":1}', "id: 5"); + const engine = await startSseStub([F]); + try { + const server = new AmicodeServiceServer({ password: PW }); + server.attachEngineProxy(new EngineProxy({ getUrl: () => engine.url })); + const origin = (await server.start()).toString().replace(/\/$/, ""); + const body = await (await fetch(`${origin}/event`, { headers: authed })).text(); + await server.stop(); + expect(body).toBe(F); + } finally { + await engine.stop(); + } + }); +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// Production wiring — createAmicodeService on the OBSERVATION-ONLY path attaches +// the event plane wired from fleetPeers. With a serving SSE peer owned in the +// projection, GET /event TAKES OVER and opens the peer's /event upstream with +// the peer reader token (proving the real wiring, not a hand-attached driver). +// ══════════════════════════════════════════════════════════════════════════════ +describe("#1543 — production wiring (createAmicodeService observation-only path)", () => { + let root: string; + const savedHubFile = process.env.AMICO_FLEET_HUB_FILE; + let engineStub: SseStub; + let peerStub: SseStub; + + beforeAll(async () => { + root = mkdtempSync(join(tmpdir(), "amicode-1543-wire-")); + process.env.AMICO_FLEET_HUB_FILE = join(root, "hub-cred-absent.json"); + engineStub = await startSseStub([sseFrame("data: {}", "id: local-1")]); + peerStub = await startSseStub([sseFrame("event: message", 'data: {"peer":true}', "id: p9")]); + }); + afterAll(async () => { + if (savedHubFile === undefined) delete process.env.AMICO_FLEET_HUB_FILE; + else process.env.AMICO_FLEET_HUB_FILE = savedHubFile; + await engineStub?.stop(); + await peerStub?.stop(); + rmSync(root, { recursive: true, force: true }); + }); + + function servingPeerProvider() { + return { + localMachineId: "macbook", + getServingPeers: () => [{ machineId: "studio" }], + getBlockedPeers: () => [] as Array<{ machineId: string; reason: "identity-conflict" }>, + readPeerToken: (id: string) => + id === "studio" ? ({ ok: true as const, credential: { baseUrl: peerStub.url, token: "tok-studio" } }) : ({ ok: false as const }), + rosterLookup: (id: string) => ({ name: id }), + }; + } + + async function waitFor(cond: () => boolean, timeoutMs = 4000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (cond()) return true; + await new Promise((r) => setTimeout(r, 25)); + } + return cond(); + } + + it("observation-only boot + a serving owned peer → GET /event opens the peer's /event upstream with the peer reader token", async () => { + const svc = createAmicodeService({ + password: PW, + engine: { password: "engine-mint", getUrl: () => engineStub.url }, + fleet: { hub: { getUrl: () => undefined }, observationOnly: true, fleetPeers: servingPeerProvider() }, + }); + const origin = (await svc.start()).toString().replace(/\/$/, ""); + try { + // let the OwnerMapFeed pull the projection so the peer becomes a non-local owner + await waitFor(() => peerStub.requests.some((r) => r.path === "/session")); + await new Promise((r) => setTimeout(r, 250)); + const peerEventBefore = peerStub.requests.filter((r) => r.path === "/event").length; + await readSseFrames(`${origin}/event`, { Authorization: serverAuthHeader("engine-mint") }, { maxFrames: 4, timeoutMs: 2500 }); + const peerEventReqs = peerStub.requests.filter((r) => r.path === "/event"); + expect(peerEventReqs.length).toBeGreaterThan(peerEventBefore); // the peer arm was opened (takeover) + expect(peerEventReqs.at(-1)!.auth).toBe(peerAuthHeader("tok-studio")); // authed as the peer, its own token + } finally { + await svc.stop(); + } + }); +}); From 9a44601ea5f2dd9e26763c102f38e8cae35013a4 Mon Sep 17 00:00:00 2001 From: amicode-ci Date: Thu, 24 Sep 2026 18:09:38 -0400 Subject: [PATCH 4/5] =?UTF-8?q?Fleet=20Studio=20B2b=20(#1544):=20Control?= =?UTF-8?q?=20UI=20=E2=80=94=20enable,=20driving=20banner,=20fail-closed?= =?UTF-8?q?=20affordances?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit State channel (Data Contract): amicode_control { controlState, reason, eligibility } rides the fleet projection (GET /amicode/fleet/sessions) as a sibling of amicode_owner — the SINGLE carrier, derived from remote_session_state.ts (the SoT, preserving distinct reasons the write gate collapses). All five SoT reasons enumerated: no-control-grant, grant-revoked, revocation-pending, insufficient-scope, transport-down. The app consumes it off DropdownSession. Session surface: - Fail-closed chip: disabled write affordances + visible human-readable reason per state (never a live erroring button). - Enable control (self-owned): dispatches amicode.fleet.enableControl to the VS Code native-modal confirm; on success the projection flips to interactive + the driving banner lights. - Request control (shared): affordance present, dispatches to the #1545 request route (backend not yet built — honest inert placeholder). - Persistent driving banner: Portal to document.body, data-driving-peer hook (assertable), survives intra-session navigation. - Owner-routed remote-delete: built (reuses arm→confirm interaction over the #1542 write plane's owner-routed path), attached to the session timeline. - Archive: no discrete confirm (per D4). - Fleet Manager grant panel: list + enable/disable/revoke affordances; pending-requests stub for #1545. - Sidebar: read-only preserved (control surfaces on session + Fleet Manager only). Director-run recovery (autodev honest degradation): dispatched implementer interrupted with 15 files of uncommitted work — reviewed, gates run, committed. Part of #1540. --- packages/app-bundle/manifest.json | 22 +- .../session/session-fleet-peers.test.ts | 164 +++++++++++++ .../components/session/session-fleet-peers.ts | 166 ++++++++++++- .../session/session-header.test.tsx | 50 ++++ .../src/components/session/session-header.tsx | 230 ++++++++++++++++++ .../pages/session/fleet-manager-tab.test.tsx | 35 +++ .../src/pages/session/fleet-manager-tab.tsx | 105 +++++++- .../src/pages/session/fleet-manager.test.ts | 72 ++++++ .../app/src/pages/session/fleet-manager.ts | 93 +++++++ .../extension/src/amicode_service/index.ts | 39 ++- .../src/amicode_service/merged_projection.ts | 35 ++- .../amicode_service/remote_session_state.ts | 96 ++++++++ .../amicode_service_fleet_data_plane.test.ts | 45 ++++ .../test/merged_projection_control.test.ts | 99 ++++++++ .../test/remote_session_state.test.ts | 96 ++++++++ .../test/sidebar_fleet_section.test.ts | 36 +++ 16 files changed, 1360 insertions(+), 23 deletions(-) create mode 100644 packages/app-bundle/overlay/packages/app/src/components/session/session-header.test.tsx create mode 100644 packages/app-bundle/overlay/packages/app/src/pages/session/fleet-manager-tab.test.tsx create mode 100644 packages/extension/test/merged_projection_control.test.ts diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 064d9338..c0e1fa9c 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -643,9 +643,10 @@ "packages/app/src/components/session/preview-human-write-gate.ts": "4c4b1cf258a06b0012d722add0125ecb77a0f4e463dd95d193f21e5e73541098", "packages/app/src/components/session/session-chats-dropdown.test.ts": "b5f178bea1b52788d6f794d09ccf7ff81f745633ad59e2d438cfc80b96acde55", "packages/app/src/components/session/session-context-tab.tsx": "227243b178b517f067d9ae0ae0eec3c559beeb6681828158b0600a17e98e7f81", - "packages/app/src/components/session/session-fleet-peers.test.ts": "c3e5404ff868b37706a4963c83565de022443ea09e350ee60200b89cc5dba61c", - "packages/app/src/components/session/session-fleet-peers.ts": "1c9e846668adcff0852a66a6961e393fb9e0cc91dc4103017d70cbad269f791c", - "packages/app/src/components/session/session-header.tsx": "c8207d714f01d4fe9f8731b410ff0359e4f9b9f7815967cc654060944af8933e", + "packages/app/src/components/session/session-fleet-peers.test.ts": "e81601e8a5acba4def10f7aa5654c9125c363a2bddfd5a3aa9b72cbcdb9a5a44", + "packages/app/src/components/session/session-fleet-peers.ts": "47bc20deed958a7eead1c445e442d396ad83ca4a7bc2f33ebc04d77cdd5fcb0a", + "packages/app/src/components/session/session-header.test.tsx": "c41252d81ca29491a030c56bbe41906017d44992795fea8d95df543bfc024a95", + "packages/app/src/components/session/session-header.tsx": "ac550119c39bf603a096fc20052f7657e0e0fcb891979c30d4a6c15c75cd46a5", "packages/app/src/components/session/session-new-view.tsx": "9510a4f550a3f0d4791e98e8025666f09d70a60fb66f193e48ee61feddae5a57", "packages/app/src/components/session/session-preview-tab.tsx": "d3a210598d181c0aa29e9f3b5db3cd691169f3ee467001313a3d9a24bee93dbe", "packages/app/src/components/session/session-preview-tabs.test.ts": "773fc0116c5f61302eaa4a09fd5f91e6b26dee978c89e29c21714243ae3a5653", @@ -703,9 +704,10 @@ "packages/app/src/pages/new-session/new-session-view.test.ts": "eb337485bf03facbc59d9eadac37cfeb04f75bf97814df80d3be4d0ec9455047", "packages/app/src/pages/new-session/new-session-view.tsx": "8fa065e3d792f99de6fdb6f0de8c6205fab519159342655fe59fc09ea67691b1", "packages/app/src/pages/session/amicode-session-relay.integration.test.ts": "4991e775d3a6c63a514072ccfa5aad190c586383b77ef41689c853fb271d3bf4", - "packages/app/src/pages/session/fleet-manager-tab.tsx": "45716dc15348534fbc5019ef2de4a8dcb1e7cfc33b8ca577edee29205ce3715a", - "packages/app/src/pages/session/fleet-manager.test.ts": "7f0593266d5fa5be66d6c4f9fe2d6f10d4fe1130258d5aa4a0de8c82e2a6547d", - "packages/app/src/pages/session/fleet-manager.ts": "5ea4b2fba3f187e2ecae8ae8093759c36990bd60c2956eafe9bfd35bc62c361c", + "packages/app/src/pages/session/fleet-manager-tab.test.tsx": "f3bb956e343ccd82acd8bf38c9ad2413d48b24ec9f484c5dfd3939d9e07139b7", + "packages/app/src/pages/session/fleet-manager-tab.tsx": "1e7db225a1f2fa3234e0eb47f314de49185db1f3ebddb5585fd0247184adebaa", + "packages/app/src/pages/session/fleet-manager.test.ts": "b10aa624fba721c66e295a08f5bb862363701e09fe870295028c8d4b39d3b850", + "packages/app/src/pages/session/fleet-manager.ts": "2177198394e525abd793da064dde0a1ba119e87084d2d48cd6f4766451e27f03", "packages/app/src/pages/session/helpers.test.ts": "b6a8bcbb78fe237d3dc347520b596342c9561c45056d1cfa4dd2e15ec34425ed", "packages/app/src/pages/session/helpers.ts": "1b777919c8f3c864b52469e90564f873445f9e7877c87f7290db3578823091cd", "packages/app/src/pages/session/preview-human-write-gate.test.ts": "b657eadc3f2fed1ff7332b17afc6dbe5fb23709a27fd1d36249e5a25788f6bc4", @@ -895,10 +897,10 @@ "packages/ui/src/v2/components/text-shimmer-v2.tsx" ], "exceptions": [], - "extracted_at": "2026-09-24T19:01:26.321Z", + "extracted_at": "2026-09-24T22:07:12.088Z", "per_package": { "packages/app": { - "A": 203, + "A": 205, "M": 156, "D": 0 }, @@ -944,7 +946,7 @@ } }, "counts": { - "overlay_total": 873, + "overlay_total": 875, "deletions": 7, "server_coupled": 0 }, @@ -1586,6 +1588,7 @@ "packages/app/src/components/session/session-context-tab.tsx": "M", "packages/app/src/components/session/session-fleet-peers.test.ts": "A", "packages/app/src/components/session/session-fleet-peers.ts": "A", + "packages/app/src/components/session/session-header.test.tsx": "A", "packages/app/src/components/session/session-header.tsx": "M", "packages/app/src/components/session/session-new-view.tsx": "M", "packages/app/src/components/session/session-preview-tab.tsx": "A", @@ -1644,6 +1647,7 @@ "packages/app/src/pages/new-session/new-session-view.test.ts": "A", "packages/app/src/pages/new-session/new-session-view.tsx": "M", "packages/app/src/pages/session/amicode-session-relay.integration.test.ts": "A", + "packages/app/src/pages/session/fleet-manager-tab.test.tsx": "A", "packages/app/src/pages/session/fleet-manager-tab.tsx": "A", "packages/app/src/pages/session/fleet-manager.test.ts": "A", "packages/app/src/pages/session/fleet-manager.ts": "A", diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/session-fleet-peers.test.ts b/packages/app-bundle/overlay/packages/app/src/components/session/session-fleet-peers.test.ts index 044b33c1..541f2a43 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/session/session-fleet-peers.test.ts +++ b/packages/app-bundle/overlay/packages/app/src/components/session/session-fleet-peers.test.ts @@ -5,7 +5,18 @@ import { deriveSessionBadge, isRemotePeerSession, resolveDropdownOpenAction, + readSessionControl, + isControlHeld, + writeAffordanceEnabled, + failClosedChip, + controlAffordance, + drivingBanner, + drivingBannerFromProjection, + remoteDeleteAction, + findSessionControlInProjection, + CONTROL_CHIP_REASONS, type DropdownSession, + type SessionControlProjection, } from "./session-fleet-peers" // #1525 B1 (read-only): merge PEER sessions from the fleet projection into the @@ -179,3 +190,156 @@ describe("#1537 resolveDropdownOpenAction — owner-routed open of a peer row", } }) }) + +// ── #1544 (slice 4): the app-side control surface ───────────────────────────── +// The state channel `amicode_control` ({ controlState, reason, eligibility }) +// rides the fleet projection beside `amicode_owner` (SoT: the extension's +// remote_session_state). These pure helpers are the data layer the session +// surface consumes: the fail-closed chip, the enable/request affordance, the +// persistent driving banner, and the owner-routed remote-delete gate. Every +// reader is tolerant (defaults to `local`/no-affordance on garbage). +const ctrl = (over: Partial = {}): SessionControlProjection => ({ + controlState: "read-only", + reason: "no-control-grant", + eligibility: "enable-control", + ...over, +}) +const remoteWith = (control: SessionControlProjection, machineId = "jjs-mac-studio"): DropdownSession => + ({ + id: "ses_studio", + directory: "/studio-proj", + time: { created: 1 }, + amicode_owner: { owner_machine_id: machineId, owner_name: "Studio", is_local: false }, + amicode_control: control, + }) as unknown as DropdownSession + +describe("#1544 readSessionControl — tolerant read of amicode_control", () => { + test("absent / malformed → the local no-affordance default (never throws)", () => { + expect(readSessionControl(undefined)).toEqual({ controlState: "local", reason: null, eligibility: "none" }) + expect(readSessionControl({} as DropdownSession)).toEqual({ controlState: "local", reason: null, eligibility: "none" }) + expect(readSessionControl({ amicode_control: { nope: 1 } } as unknown as DropdownSession).controlState).toBe("local") + }) + test("a well-formed projection round-trips", () => { + expect(readSessionControl(remoteWith(ctrl({ controlState: "interactive", reason: null, eligibility: "none" })))).toEqual({ + controlState: "interactive", + reason: null, + eligibility: "none", + }) + }) +}) + +describe("#1544 write affordances gated on control held", () => { + test("local + interactive → control held → writes enabled", () => { + expect(isControlHeld(ctrl({ controlState: "local", reason: null, eligibility: "none" }))).toBe(true) + expect(isControlHeld(ctrl({ controlState: "interactive", reason: null, eligibility: "none" }))).toBe(true) + expect(writeAffordanceEnabled(ctrl({ controlState: "interactive", reason: null, eligibility: "none" }))).toBe(true) + }) + test("read-only + suspended → control NOT held → writes disabled", () => { + expect(isControlHeld(ctrl({ controlState: "read-only" }))).toBe(false) + expect(isControlHeld(ctrl({ controlState: "suspended", reason: "transport-down" }))).toBe(false) + expect(writeAffordanceEnabled(ctrl({ controlState: "read-only" }))).toBe(false) + }) +}) + +describe("#1544 failClosedChip — disabled-with-reason, derived from the SoT reason (never the collapsed gate reason)", () => { + test("null (no chip) when control is held (local / interactive)", () => { + expect(failClosedChip(ctrl({ controlState: "local", reason: null, eligibility: "none" }))).toBeNull() + expect(failClosedChip(ctrl({ controlState: "interactive", reason: null, eligibility: "none" }))).toBeNull() + }) + test("EVERY one of the five SoT reasons yields a distinct, human chip label", () => { + const labels = new Set() + for (const reason of CONTROL_CHIP_REASONS) { + const controlState = reason === "transport-down" || reason === "revocation-pending" ? "suspended" : "read-only" + const chip = failClosedChip(ctrl({ controlState, reason })) + expect(chip, `reason ${reason} must produce a chip`).not.toBeNull() + expect(chip!.reason).toBe(reason) + expect(typeof chip!.label).toBe("string") + expect(chip!.label.length).toBeGreaterThan(0) + labels.add(chip!.label) + } + // revocation-pending and grant-revoked must NOT share a label (the SoT keeps + // them distinct where the write gate collapses them). + expect(failClosedChip(ctrl({ controlState: "suspended", reason: "revocation-pending" }))!.label).not.toBe( + failClosedChip(ctrl({ controlState: "read-only", reason: "grant-revoked" }))!.label, + ) + expect(labels.size).toBe(CONTROL_CHIP_REASONS.length) + }) +}) + +describe("#1544 controlAffordance — enable (self) / request (shared) / none", () => { + test("eligibility enable-control → an Enable affordance, live (not inert)", () => { + const a = controlAffordance(ctrl({ eligibility: "enable-control" })) + expect(a.kind).toBe("enable-control") + expect(a.inert).toBe(false) + expect(a.label.length).toBeGreaterThan(0) + }) + test("eligibility request-control → a Request affordance, INERT (backend is #1545)", () => { + const a = controlAffordance(ctrl({ eligibility: "request-control" })) + expect(a.kind).toBe("request-control") + expect(a.inert).toBe(true) + }) + test("eligibility none → no affordance", () => { + expect(controlAffordance(ctrl({ controlState: "interactive", reason: null, eligibility: "none" })).kind).toBe("none") + }) +}) + +describe("#1544 drivingBanner — persistent, pinned to the peer being driven", () => { + test("interactive (control held over a remote peer) → banner carries the peer machineId", () => { + const session = remoteWith(ctrl({ controlState: "interactive", reason: null, eligibility: "none" }), "jjs-mac-studio") + expect(drivingBanner(session)).toEqual({ machineId: "jjs-mac-studio" }) + }) + test("not interactive (read-only / local) → no banner", () => { + expect(drivingBanner(remoteWith(ctrl({ controlState: "read-only" })))).toBeNull() + const local = { id: "l", directory: "/d", time: { created: 1 } } as DropdownSession + expect(drivingBanner(local)).toBeNull() + }) +}) + +describe("#1544 remoteDeleteAction — owner-routed delete, gated on control (arm→confirm reused, not this gate)", () => { + test("control held → allowed + an OWNER-ROUTED request carrying the owner machineId", () => { + const session = remoteWith(ctrl({ controlState: "interactive", reason: null, eligibility: "none" }), "jjs-mac-studio") + const action = remoteDeleteAction(session) + expect(action.allowed).toBe(true) + expect(action.request).toEqual({ sessionID: "ses_studio", directory: "/studio-proj", ownerMachineId: "jjs-mac-studio" }) + }) + test("control NOT held → disallowed, no request, carries the fail-closed reason (never a live erroring button)", () => { + const session = remoteWith(ctrl({ controlState: "read-only", reason: "no-control-grant" })) + const action = remoteDeleteAction(session) + expect(action.allowed).toBe(false) + expect(action.request).toBeUndefined() + expect(action.reason).toBe("no-control-grant") + }) +}) + +describe("#1544 findSessionControlInProjection — the current session's control off the fleet projection", () => { + const raw = { + sessions: [ + { id: "ses_local", time: { created: 1 }, amicode_owner: { owner_machine_id: "me", owner_name: "Me", is_local: true }, amicode_control: { controlState: "local", reason: null, eligibility: "none" } }, + { id: "ses_studio", time: { created: 2 }, amicode_owner: { owner_machine_id: "studio", owner_name: "Studio", is_local: false }, amicode_control: { controlState: "read-only", reason: "no-control-grant", eligibility: "enable-control" } }, + ], + } + test("finds a remote entry's control by id", () => { + expect(findSessionControlInProjection(raw, "ses_studio")).toEqual({ + controlState: "read-only", + reason: "no-control-grant", + eligibility: "enable-control", + }) + }) + test("unknown id / garbage → the local default (never throws)", () => { + expect(findSessionControlInProjection(raw, "nope")).toEqual({ controlState: "local", reason: null, eligibility: "none" }) + expect(findSessionControlInProjection(undefined, "x")).toEqual({ controlState: "local", reason: null, eligibility: "none" }) + }) + + test("drivingBannerFromProjection lights the banner for a driven (interactive) current session", () => { + const driving = { + sessions: [ + { id: "ses_studio", amicode_owner: { owner_machine_id: "studio", owner_name: "Studio", is_local: false }, amicode_control: { controlState: "interactive", reason: null, eligibility: "none" } }, + ], + } + expect(drivingBannerFromProjection(driving, "ses_studio")).toEqual({ machineId: "studio" }) + // read-only current session → no banner; unknown id → no banner + expect(drivingBannerFromProjection(raw, "ses_studio")).toBeNull() + expect(drivingBannerFromProjection(driving, "nope")).toBeNull() + expect(drivingBannerFromProjection(undefined, "x")).toBeNull() + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/session-fleet-peers.ts b/packages/app-bundle/overlay/packages/app/src/components/session/session-fleet-peers.ts index 61fa83b3..164a006b 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/session/session-fleet-peers.ts +++ b/packages/app-bundle/overlay/packages/app/src/components/session/session-fleet-peers.ts @@ -33,7 +33,171 @@ export interface SessionOwnerTag { /** A dropdown session row — the SDK session shape plus the optional owner * overlay a peer entry carries. */ -export type DropdownSession = Session & { amicode_owner?: SessionOwnerTag } +export type DropdownSession = Session & { + amicode_owner?: SessionOwnerTag + /** #1544 (slice 4): the state channel — the app-visible control shape the + * fleet projection stamps beside `amicode_owner` (see SessionControlProjection). */ + amicode_control?: SessionControlProjection +} + +// ── #1544 (slice 4): the CONTROL STATE CHANNEL (mirror of the extension's +// remote_session_state.SessionControlProjection, carried on GET +// /amicode/fleet/sessions as the `amicode_control` sibling of `amicode_owner`). +// +// This module is the SINGLE consumer of that channel on the session surface: +// the fail-closed chip, the enable/request affordance, the persistent driving +// banner, and the owner-routed remote-delete gate all derive from it. The chip +// reason is the SoT reason (the extension's write gate collapses +// revocation-pending→grant-revoked; this channel keeps them distinct — ADR 0034 +// D4/D5), so the UI never reads the raw gate reason. + +/** EXACTLY the five reasons the SoT emits — mirrors the extension's + * CONTROL_CHIP_REASONS (kept in sync as a literal, the same seam-crossing + * precedent SessionOwnerTag sets). */ +export const CONTROL_CHIP_REASONS = [ + "no-control-grant", + "grant-revoked", + "revocation-pending", + "insufficient-scope", + "transport-down", +] as const + +export type ControlChipReason = (typeof CONTROL_CHIP_REASONS)[number] + +/** Which control affordance should appear. */ +export type ControlEligibility = "enable-control" | "request-control" | "none" + +/** The app-visible control shape (mirror of the extension's projection). */ +export interface SessionControlProjection { + controlState: "local" | "interactive" | "read-only" | "suspended" + reason: ControlChipReason | null + eligibility: ControlEligibility +} + +const CONTROL_STATES = new Set(["local", "interactive", "read-only", "suspended"]) +const CONTROL_REASONS = new Set(CONTROL_CHIP_REASONS) + +/** The default (control-held, no-affordance) projection — a local/unowned or + * malformed session degrades to this, never to a live erroring affordance. */ +const CONTROL_LOCAL_DEFAULT: SessionControlProjection = { controlState: "local", reason: null, eligibility: "none" } + +/** Human, DISTINCT chip labels — one per SoT reason. revocation-pending and + * grant-revoked are deliberately different (the write gate collapses them; the + * chip must not). */ +const CONTROL_CHIP_LABELS: Record = { + "no-control-grant": "Control not enabled", + "grant-revoked": "Control revoked", + "revocation-pending": "Revoking…", + "insufficient-scope": "Observe only", + "transport-down": "Peer unreachable", +} + +/** Tolerant read of the control channel off a session. Absent / malformed → + * the local no-affordance default (never throws). */ +export function readSessionControl( + session: { amicode_control?: unknown } | undefined, +): SessionControlProjection { + const raw = session?.amicode_control + if (!raw || typeof raw !== "object") return CONTROL_LOCAL_DEFAULT + const o = raw as Record + if (typeof o.controlState !== "string" || !CONTROL_STATES.has(o.controlState)) return CONTROL_LOCAL_DEFAULT + const reason = typeof o.reason === "string" && CONTROL_REASONS.has(o.reason) ? (o.reason as ControlChipReason) : null + const eligibility = + o.eligibility === "enable-control" || o.eligibility === "request-control" ? o.eligibility : "none" + return { controlState: o.controlState as SessionControlProjection["controlState"], reason, eligibility } +} + +/** Control is HELD when the session is local or interactively controlled. */ +export function isControlHeld(control: SessionControlProjection): boolean { + return control.controlState === "local" || control.controlState === "interactive" +} + +/** Write affordances (composer→peer, archive, delete) are enabled ONLY under + * held control — otherwise disabled with a reason chip (never a live 500). */ +export function writeAffordanceEnabled(control: SessionControlProjection): boolean { + return isControlHeld(control) +} + +/** The fail-closed chip: disabled-with-reason when control is not held. Null + * when control is held (no chip). The label is derived from the SoT reason. */ +export function failClosedChip(control: SessionControlProjection): { reason: ControlChipReason; label: string } | null { + if (isControlHeld(control) || control.reason === null) return null + return { reason: control.reason, label: CONTROL_CHIP_LABELS[control.reason] } +} + +/** The enable/request affordance derived from eligibility. `request-control` is + * present-but-INERT here — its backend (the request→approve handshake) is + * #1545; `enable-control` is live (the self-owned one-act enable). */ +export function controlAffordance(control: SessionControlProjection): { + kind: ControlEligibility + label: string + inert: boolean +} { + if (control.eligibility === "enable-control") return { kind: "enable-control", label: "Enable control", inert: false } + if (control.eligibility === "request-control") return { kind: "request-control", label: "Request control", inert: true } + return { kind: "none", label: "", inert: true } +} + +/** The persistent driving banner's target: the peer machineId being driven, + * or null when not interactively driving a peer. Read from the owner overlay + * (the state channel carries no machineId — the owner tag does). */ +export function drivingBanner( + session: { amicode_owner?: SessionOwnerTag; amicode_control?: unknown } | undefined, +): { machineId: string } | null { + const control = readSessionControl(session) + if (control.controlState !== "interactive") return null + const machineId = session?.amicode_owner?.owner_machine_id + if (!machineId) return null + return { machineId } +} + +/** The owner-routed remote-delete action, GATED on held control. When allowed, + * the request carries the owner machineId so the caller (and a reviewer) can + * see it is owner-routed — the #1542 write plane resolves the non-GET to the + * peer-owned session by pathname; this descriptor names the owner it targets. + * When control is not held, it is disallowed and carries the fail-closed reason + * (never a live erroring button). */ +export function remoteDeleteAction( + session: { id: string; directory?: string; amicode_owner?: SessionOwnerTag; amicode_control?: unknown } | undefined, +): { allowed: boolean; request?: { sessionID: string; directory: string; ownerMachineId: string }; reason?: ControlChipReason } { + const control = readSessionControl(session) + if (!writeAffordanceEnabled(control) || !session) { + return { allowed: false, ...(control.reason ? { reason: control.reason } : {}) } + } + const ownerMachineId = session.amicode_owner?.owner_machine_id ?? "" + return { + allowed: true, + request: { sessionID: session.id, directory: session.directory ?? "", ownerMachineId }, + } +} + +/** Find a session's control off a raw GET /amicode/fleet/sessions response by + * id — the session surface's read for the CURRENT session's banner/affordance. + * Unknown id / garbage → the local default (never throws). */ +export function findSessionControlInProjection(raw: unknown, sessionId: string): SessionControlProjection { + return readSessionControl(findSessionEntryInProjection(raw, sessionId)) +} + +/** Find a raw projection entry by id (owner + control overlays intact), or + * undefined. Tolerant. */ +function findSessionEntryInProjection( + raw: unknown, + sessionId: string, +): { amicode_owner?: SessionOwnerTag; amicode_control?: unknown } | undefined { + if (!raw || typeof raw !== "object") return undefined + const sessions = (raw as { sessions?: unknown }).sessions + if (!Array.isArray(sessions)) return undefined + const hit = sessions.find((s) => s && typeof s === "object" && (s as { id?: unknown }).id === sessionId) + return hit as { amicode_owner?: SessionOwnerTag; amicode_control?: unknown } | undefined +} + +/** The driving banner for the CURRENT session id, read off the fleet projection. + * Non-null only when that session is interactively driving a remote peer — the + * persistent "driving " banner's data source. Unknown id / not-driving / + * garbage → null (no banner). */ +export function drivingBannerFromProjection(raw: unknown, sessionId: string): { machineId: string } | null { + return drivingBanner(findSessionEntryInProjection(raw, sessionId)) +} /** True when a session is a REMOTE peer session (has an owner overlay whose * `is_local` is explicitly false). Local / unowned sessions are false — diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/session-header.test.tsx b/packages/app-bundle/overlay/packages/app/src/components/session/session-header.test.tsx new file mode 100644 index 00000000..bcad8052 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/session/session-header.test.tsx @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { resolve } from "node:path" + +// #1544 (slice 4): the session-surface control wiring. Following the repo's +// component-source-assertion pattern (vscode-explorer-file-icon.test.tsx): the +// DECISION logic is pure + unit-tested in session-fleet-peers.ts; here we assert +// the SolidJS wiring binds those pure functions to the session surface — the +// persistent driving banner's `data-` hook (so persistence is assertable), the +// native-modal confirm dispatch, the fail-closed reason chip, and the +// owner-routed remote-delete reusing the arm→confirm interaction. +const source = readFileSync(resolve(__dirname, "session-header.tsx"), "utf8") + +describe("#1544 session-header control wiring", () => { + test("the persistent driving banner is pinned with an ASSERTABLE data-driving-peer hook", () => { + expect(source).toContain("data-driving-peer={peer.machineId}") + expect(source).toContain('data-slot="amicode-driving-banner"') + // sourced from the fleet projection (the state channel carrier), not ad hoc + expect(source).toContain("drivingBannerFromProjection(controlProjection.latest") + }) + + test("Enable control dispatches the VS Code native-modal confirm (ADR 0034 D4)", () => { + expect(source).toContain("postAmicode(ENABLE_CONTROL_COMMAND)") + expect(source).toContain('data-action="session-enable-control"') + // the affordance is derived from eligibility (enable vs request vs none) + expect(source).toContain("controlAffordance(") + expect(source).toContain("data-control-affordance={controlAffordanceState().kind}") + }) + + test("a not-held remote row shows a disabled reason chip (never a live erroring button)", () => { + expect(source).toContain("failClosedChip(control())") + expect(source).toContain('data-slot="session-control-chip"') + expect(source).toContain("data-control-reason={c.reason}") + // writes are gated on held control + expect(source).toContain("writeAffordanceEnabled(control())") + }) + + test("the remote-delete affordance is owner-routed AND reuses the arm→confirm interaction", () => { + // owner-routed: the action carries the owner and the write plane routes it + expect(source).toContain("remoteDeleteAction(session)") + expect(source).toContain("action.request.ownerMachineId") + // reused arm→confirm interaction (no second modal), gated on held control + expect(source).toContain('data-action="session-remote-delete"') + expect(source).toContain('data-action="session-remote-delete-confirm"') + expect(source).toContain("armRemoteDelete") + expect(source).toContain("confirmRemoteDelete") + // only shown when control is held + expect(source).toContain("") + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/session-header.tsx b/packages/app-bundle/overlay/packages/app/src/components/session/session-header.tsx index 3591b9db..27034971 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/session/session-header.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/session/session-header.tsx @@ -42,15 +42,31 @@ import { sessionListDirectories, sortedRootSessions } from "@/pages/layout/helpe import { useNavigate } from "@solidjs/router" import type { Session } from "@opencode-ai/sdk/v2/client" import { amicodeGet } from "@/utils/amicode-fetch" +import { postAmicode } from "@/utils/amicode-bridge" import { peerSessionsFromProjection, mergePeerSessions, deriveSessionBadge, isRemotePeerSession, resolveDropdownOpenAction, + readSessionControl, + writeAffordanceEnabled, + failClosedChip, + controlAffordance, + drivingBannerFromProjection, + findSessionControlInProjection, + remoteDeleteAction, type DropdownSession, } from "./session-fleet-peers" +// AMICODE #1544 (slice 4): the app→extension bridge command that opens the VS +// Code NATIVE-MODAL confirm for enabling control of a self-owned peer (ADR 0034 +// D4 — the one net-new confirmation surface). The extension relays it to the +// modal + the #1541 self-owned control issuance; on success the projected state +// flips to `interactive` and the driving banner lights. Present-and-dispatching +// here; the modal + issuance handler is the extension-side seam. +const ENABLE_CONTROL_COMMAND = "amicode.fleet.enableControl" + // AMICODE: the MCP/LSP/Plugins/Vaults status popover is opencode-operator // noise here ("No MCPs configured"). Hidden, not deleted — the trigger slot is // where a solver-health panel (server/Julia env/runs dir) belongs later. @@ -179,6 +195,32 @@ export function SessionHeader() { const terminal = useTerminal() const { params, view } = useSessionLayout() + // #1544 (slice 4): the control state channel for the CURRENT session, read off + // the fleet projection (GET /amicode/fleet/sessions — the SAME carrier the + // Sessions dropdown consumes). It drives the persistent "driving " + // banner and the Enable/Request-control affordance on the SESSION SURFACE + // (control affordances live here + in Fleet Manager, NEVER the read-only + // sidebar — ADR 0034 D7). Tolerant: a 404 / no-fleet resolves to undefined and + // every derived value degrades to "no banner / no affordance". + const [controlProjection] = createResource( + () => server.current, + (conn) => amicodeGet(conn, "/amicode/fleet/sessions").catch(() => undefined), + ) + const drivingPeer = createMemo(() => + params.id ? drivingBannerFromProjection(controlProjection.latest, params.id) : null, + ) + const controlAffordanceState = createMemo(() => + controlAffordance(findSessionControlInProjection(controlProjection.latest, params.id ?? "")), + ) + const enableControl = () => { + // The one net-new confirmation surface: dispatch the VS Code native-modal + // confirm (ADR 0034 D4). The extension relays it to the modal + #1541 + // self-owned issuance; on success the projected state flips to interactive + // and the driving banner lights. Request-control (shared) is inert here — + // its request→approve backend is #1545. + if (controlAffordanceState().kind === "enable-control") postAmicode(ENABLE_CONTROL_COMMAND) + } + const projectDirectory = createMemo(() => decode64(params.dir) ?? "") const project = createMemo(() => { const directory = projectDirectory() @@ -333,6 +375,81 @@ export function SessionHeader() { return ( <> + {/* #1544 (slice 4): the persistent "driving " banner — the + ambient-safety mechanism (control is NEVER silent, ADR 0034 D4). Pinned + to document.body via a Portal so it survives navigation WITHIN the + session (SessionHeader persists across param.id changes); its + `data-driving-peer` hook makes the persistence assertable. */} + + {(peer) => ( + +
+ + Driving {peer.machineId} +
+
+ )} +
+ {/* #1544: the Enable-control (self-owned) / Request-control (shared) + affordance on the session surface. Enable → the native-modal confirm + (ADR 0034 D4). Request → present-but-inert (backend is #1545). */} + + + + + {(mount) => ( @@ -882,6 +999,36 @@ export function SessionChatsDropdown(props: { currentSessionID?: string } = {}) } } + // #1544 (slice 4): owner-routed remote DELETE of a peer session. This is the + // net-new remote-delete affordance the ADR (D4) calls for — there is no + // existing wired remote-delete to reuse (the timeline/dropdown deletes are + // LOCAL SDK calls with no owner routing). It reuses the arm→confirm + // INTERACTION (SessionDropdownRow, mirroring ArchivedSessionDropdownRow), and + // routes by OWNER: the SDK delete is keyed on the session's own id+directory, + // and the #1542 observation WRITE plane intercepts the non-GET to the + // peer-owned session by pathname and proxies it to the owner. The action is + // GATED on held control (remoteDeleteAction) — a fail-closed row shows a + // reason chip instead, never a live erroring button. + async function remoteDeleteSession(session: DropdownSession) { + const action = remoteDeleteAction(session) + if (!action.allowed || !action.request) return + const ctx = getServerCtx() + if (!ctx) return + try { + // Owner-routed: same SDK delete surface; the write plane routes the + // non-GET to action.request.ownerMachineId by pathname. + await (ctx.sdk.client.session.delete as Function)({ + sessionID: action.request.sessionID, + directory: action.request.directory, + }) + } catch (cause) { + showToast({ + title: language.t("session.delete.failed.title"), + description: String(cause), + }) + } + } + async function unarchiveSession(session: Session) { const ctx = getServerCtx() if (!ctx) return @@ -1130,6 +1277,7 @@ export function SessionChatsDropdown(props: { currentSessionID?: string } = {}) isCurrent={session.id === currentSessionID()} onOpen={openSession} onArchive={archiveSession} + onRemoteDelete={remoteDeleteSession} /> )} @@ -1193,6 +1341,7 @@ function SessionDropdownRow(props: { isCurrent: boolean onOpen: (session: Session) => void onArchive: (session: Session) => void + onRemoteDelete?: (session: DropdownSession) => void }) { const language = useLanguage() const title = createMemo(() => sessionTitle(props.session.title) || props.session.id) @@ -1200,6 +1349,35 @@ function SessionDropdownRow(props: { // hides local-only actions (archive is B2). Local/unowned rows are unbadged. const badge = createMemo(() => deriveSessionBadge(props.session as DropdownSession)) const isRemote = createMemo(() => isRemotePeerSession(props.session as DropdownSession)) + // #1544 (slice 4): the control state channel for THIS remote row. When control + // is NOT held, write affordances are DISABLED with a visible reason chip + // (failClosedChip) — never a live erroring button. When held, the owner-routed + // remote-delete affordance appears (arm→confirm, reused interaction). + const control = createMemo(() => readSessionControl(props.session as DropdownSession)) + const chip = createMemo(() => failClosedChip(control())) + const canWrite = createMemo(() => writeAffordanceEnabled(control())) + // arm→confirm state (mirrors ArchivedSessionDropdownRow's interaction). + const [deleteArmed, setDeleteArmed] = createSignal(false) + let deleteResetTimer: ReturnType | undefined + function armRemoteDelete(event: MouseEvent) { + event.preventDefault() + event.stopPropagation() + setDeleteArmed(true) + clearTimeout(deleteResetTimer) + deleteResetTimer = setTimeout(() => setDeleteArmed(false), 3000) + } + function confirmRemoteDelete(event: MouseEvent) { + event.preventDefault() + event.stopPropagation() + clearTimeout(deleteResetTimer) + setDeleteArmed(false) + void props.onRemoteDelete?.(props.session as DropdownSession) + } + function disarmRemoteDelete() { + clearTimeout(deleteResetTimer) + setDeleteArmed(false) + } + onCleanup(() => clearTimeout(deleteResetTimer)) const rowServer = useServer() // #1292 hover prewarm: a hovered row is a click away — pull its first // message page the instant the pointer lands, so the open renders from @@ -1274,6 +1452,58 @@ function SessionDropdownRow(props: { + {/* #1544 (slice 4): a REMOTE peer row's control affordances. Control NOT + held → the write affordance is DISABLED with a visible reason chip + (failClosedChip, derived from the SoT reason) — never a live erroring + button. Control HELD → the owner-routed remote-DELETE, reusing the + arm→confirm interaction (no second modal). */} + +
+ + {(c) => ( + + + {c.label} + + )} + + + + } + aria-label="Delete on peer" + onClick={armRemoteDelete} + /> + + } + > + e.key === "Escape" && disarmRemoteDelete()} + > + Delete + + + +
+
) } diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/fleet-manager-tab.test.tsx b/packages/app-bundle/overlay/packages/app/src/pages/session/fleet-manager-tab.test.tsx new file mode 100644 index 00000000..a7e0af9e --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/pages/session/fleet-manager-tab.test.tsx @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { resolve } from "node:path" + +// #1544 (slice 4): the Fleet Manager grant-management panel wiring. The pure +// decision logic (shapeGrantRows / grantAffordances / shapePendingRequests) is +// unit-tested in fleet-manager.test.ts; here we assert the tab BINDS it — reads +// the sanitized grants off GET /amicode/fleet/grants (never a token), renders +// per-state affordances, and carries the #1545 pending-requests stub. +const source = readFileSync(resolve(__dirname, "fleet-manager-tab.tsx"), "utf8") + +describe("#1544 fleet-manager-tab grant panel wiring", () => { + test("adds a Grants section to the tab", () => { + expect(source).toContain('') + expect(source).toContain('section() === "grants"') + }) + + test("reads the SANITIZED grants off the never-proxied GRANTS_ROUTE", () => { + expect(source).toContain("amicodeGet(server.current, GRANTS_ROUTE)") + expect(source).toContain("shapeGrantRows(") + // never renders a token field + expect(source).not.toContain(".token") + }) + + test("renders per-state grant affordances (enable/disable/revoke)", () => { + expect(source).toContain("data-grant-action={affordance}") + expect(source).toContain("g.affordances") + expect(source).toContain("data-grant-state={g.state}") + }) + + test("carries the #1545 pending-requests stub (honest empty)", () => { + expect(source).toContain("shapePendingRequests(") + expect(source).toContain("PENDING_REQUESTS_BACKEND_ISSUE") + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/fleet-manager-tab.tsx b/packages/app-bundle/overlay/packages/app/src/pages/session/fleet-manager-tab.tsx index 52692334..83eaa539 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/session/fleet-manager-tab.tsx +++ b/packages/app-bundle/overlay/packages/app/src/pages/session/fleet-manager-tab.tsx @@ -27,11 +27,16 @@ import { shapeVersionRows, attachControlFor, performAttachControlWithEffectiveStream, + shapeGrantRows, + shapePendingRequests, + GRANTS_ROUTE, + PENDING_REQUESTS_BACKEND_ISSUE, type RosterRowLike, type DoctorSurfaceLike, + type SanitizedGrantLike, } from "@/pages/session/fleet-manager" -type FleetManagerSection = "devices" | "machine" | "hub" | "versions" +type FleetManagerSection = "devices" | "machine" | "hub" | "versions" | "grants" /** Tolerant roster-response reader → the lawful rows array, else []. Mirrors the * host's own tolerant load: a malformed / error response never throws here. */ @@ -132,6 +137,29 @@ export function FleetManagerContent() { .catch(() => {}) } + // ── #1544 (slice 4): the lifecycle grant-management panel. Reads the + // SANITIZED grants (sanitizeGrantForDisplay — NEVER the token) off + // GET /amicode/fleet/grants, and offers per-state enable/disable/revoke + // affordances. The pending-requests view is stubbed until #1545 wires the + // request→approve backend. Control affordances live HERE + on the session + // surface — NEVER on the read-only sidebar (ADR 0034 D7). + const [grantsRaw] = createResource( + () => server.current, + () => amicodeGet(server.current, GRANTS_ROUTE).catch(() => undefined), + ) + const grantRows = createMemo(() => { + const raw = grantsRaw() + const grants = raw && typeof raw === "object" ? (raw as { grants?: unknown }).grants : undefined + return shapeGrantRows(Array.isArray(grants) ? (grants as SanitizedGrantLike[]) : []) + }) + const pendingRequests = createMemo(() => shapePendingRequests(grantsRaw())) + const runGrantAction = (targetMachineId: string, affordance: string) => { + // The grant-lifecycle mutations (disable/revoke → revocation, re-admit) are + // dispatched to the extension's lifecycle commands (#1541's issuance/revoke + // seam). Present-and-dispatching; the command handler is the backend seam. + postAmicode(`amicode.fleet.grant.${affordance}:${targetMachineId}`) + } + // ── Devices: the local row's inline capabilities edit → POST /amicode/roster ─ const toggleCapability = (tag: string) => { const row = localRow() @@ -192,6 +220,7 @@ export function FleetManagerContent() { + @@ -362,6 +391,80 @@ export function FleetManagerContent() {
+ {/* ── Grants (#1544 slice 4): lifecycle grant management + pending ── */} + +
+
+
Control grants
+ 0} + fallback={
No control grants issued yet.
} + > +
+ + {(g) => ( +
+
+ + {g.targetMachineId} + {g.scope} + + {g.state} + +
+
+ + {(affordance) => ( + + )} + +
+
+ )} +
+
+
+
+ + {/* Pending requests — the shared-peer request→approve view. Stubbed + until #1545 wires the backend (honest empty, never fabricated). */} +
+
Pending requests
+ 0} + fallback={ +
+ No pending control requests. The request→approve handshake for shared peers arrives in a later + release (#{PENDING_REQUESTS_BACKEND_ISSUE}). +
+ } + > +
+ + {(r) => ( +
+ {r.requesterMachineId} + {r.scope} +
+ )} +
+
+
+
+
+
+ {/* ── Versions (absorbs the retired Fleet & Versions panel) ───────── */}
diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/fleet-manager.test.ts b/packages/app-bundle/overlay/packages/app/src/pages/session/fleet-manager.test.ts index d06e09bc..65386b00 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/session/fleet-manager.test.ts +++ b/packages/app-bundle/overlay/packages/app/src/pages/session/fleet-manager.test.ts @@ -18,7 +18,13 @@ import { performAttachControlWithEffectiveStream, ATTACH_ROUTE, DETACH_ROUTE, + shapeGrantRows, + grantAffordances, + shapePendingRequests, + GRANTS_ROUTE, + PENDING_REQUESTS_BACKEND_ISSUE, type RosterRowLike, + type SanitizedGrantLike, } from "./fleet-manager" const row = (over: Partial = {}): RosterRowLike => ({ @@ -309,3 +315,69 @@ describe("performAttachControl (AC2 — the control drives attach AND detach end ]) }) }) + +// ── #1544 (slice 4): the Fleet Manager grant-management panel ───────────────── +// The panel lists the lifecycle grants (from GET /amicode/fleet/grants, the +// SANITIZED sanitizeGrantForDisplay output — NEVER the token) with per-state +// enable/disable/revoke affordances, plus a pending-requests view stubbed for +// #1545. These pure helpers are the panel's decision logic (the tab render is +// untested-by-design wiring around them). +const grant = (over: Partial = {}): SanitizedGrantLike => ({ + requesterMachineId: "my-macbook", + targetMachineId: "mac-studio", + scope: "control", + generation: 1, + state: "active", + issuedAt: "2026-09-24T00:00:00.000Z", + ...over, +}) + +describe("#1544 shapeGrantRows — sanitized grants → panel rows (NEVER a token)", () => { + test("maps a grant to a row carrying identity + scope + state + affordances", () => { + const rows = shapeGrantRows([grant()]) + expect(rows).toHaveLength(1) + expect(rows[0].targetMachineId).toBe("mac-studio") + expect(rows[0].scope).toBe("control") + expect(rows[0].state).toBe("active") + expect(rows[0].affordances.length).toBeGreaterThan(0) + }) + + test("a token is NEVER surfaced — even if a malformed input smuggled one in (defensive strip)", () => { + const dirty = { ...grant(), token: "SECRET-abc123" } as unknown as SanitizedGrantLike + const rows = shapeGrantRows([dirty]) + expect(JSON.stringify(rows)).not.toContain("SECRET") + expect(JSON.stringify(rows)).not.toContain("token") + }) + + test("tolerant: non-array / garbage → [] (never throws)", () => { + expect(shapeGrantRows(undefined as unknown as SanitizedGrantLike[])).toEqual([]) + expect(shapeGrantRows("nope" as unknown as SanitizedGrantLike[])).toEqual([]) + }) + + test("the read surface is GET /amicode/fleet/grants (the never-proxied honesty surface)", () => { + expect(GRANTS_ROUTE).toBe("/amicode/fleet/grants") + }) +}) + +describe("#1544 grantAffordances — per-state enable/disable/revoke", () => { + test("an active grant can be disabled and revoked", () => { + expect(grantAffordances("active")).toEqual(["disable", "revoke"]) + }) + test("a revocation-pending grant can be revoked (finalized), not disabled again", () => { + expect(grantAffordances("revocation-pending")).toEqual(["revoke"]) + }) + test("a revoked grant can be re-admitted", () => { + expect(grantAffordances("revoked")).toEqual(["re-admit"]) + }) +}) + +describe("#1544 shapePendingRequests — the #1545 stub (honest empty)", () => { + test("the pending-requests view is stubbed until #1545 wires the request→approve backend", () => { + expect(PENDING_REQUESTS_BACKEND_ISSUE).toBe(1545) + }) + test("reads pending_requests off the grants response; absent/garbage → [] (honest, never fabricated)", () => { + expect(shapePendingRequests({ ok: true, grants: [], pending_requests: [] })).toEqual([]) + expect(shapePendingRequests(undefined)).toEqual([]) + expect(shapePendingRequests({ pending_requests: "nope" })).toEqual([]) + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/fleet-manager.ts b/packages/app-bundle/overlay/packages/app/src/pages/session/fleet-manager.ts index be5d1f51..e0581adb 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/session/fleet-manager.ts +++ b/packages/app-bundle/overlay/packages/app/src/pages/session/fleet-manager.ts @@ -330,5 +330,98 @@ export async function performAttachControlWithEffectiveStream(input: { return result } +// ── #1544 (slice 4): Fleet Manager grant management ────────────────────────── +// +// The grant-management panel lists the lifecycle grants and offers per-state +// affordances. It reads the SANITIZED grants (sanitizeGrantForDisplay on the +// extension side — the token is NEVER present) off GET /amicode/fleet/grants +// (under /amicode/fleet/* so it inherits the never-proxied local-honesty +// exclusion). The pending-requests view is stubbed until #1545 wires the +// request→approve backend. Control affordances live HERE and on the session +// surface — NEVER on the read-only sidebar (ADR 0034 D7). + +/** The never-proxied read surface for the sanitized grants. */ +export const GRANTS_ROUTE = "/amicode/fleet/grants" + +/** The issue that wires the pending-requests (request→approve) backend. Until + * then the pending-requests view is an honest empty stub. */ +export const PENDING_REQUESTS_BACKEND_ISSUE = 1545 + +/** The sanitized grant shape (mirror of sanitizeGrantForDisplay's output — NO + * token, identity keys truncated on the extension side). */ +export interface SanitizedGrantLike { + requesterMachineId: string + targetMachineId: string + scope: "observe" | "control" | "lifecycle-admin" + generation: number + state: "active" | "revocation-pending" | "revoked" + issuedAt: string + revokedAt?: string + /** requesterIdentityKey may ride through truncated; NEVER a token. */ + requesterIdentityKey?: string +} + +/** The grant-management affordances a grant offers in its current state. */ +export type GrantManagementAffordance = "disable" | "revoke" | "re-admit" + +/** Per-state affordances: active → begin-revocation (disable) or revoke; + * revocation-pending → finalize revoke; revoked → re-admit. */ +export function grantAffordances(state: SanitizedGrantLike["state"]): GrantManagementAffordance[] { + if (state === "active") return ["disable", "revoke"] + if (state === "revocation-pending") return ["revoke"] + return ["re-admit"] +} + +/** One rendered grant row: identity + scope + state + its affordances. The + * token is structurally impossible here (we copy ONLY the safe fields — a + * smuggled `token` on the input is dropped). */ +export interface FleetGrantRow { + requesterMachineId: string + targetMachineId: string + scope: SanitizedGrantLike["scope"] + generation: number + state: SanitizedGrantLike["state"] + issuedAt: string + revokedAt?: string + affordances: GrantManagementAffordance[] +} + +/** Sanitized grants → panel rows. Copies ONLY the safe fields (never a token, + * even if a malformed input carried one). Tolerant: non-array → []. */ +export function shapeGrantRows(grants: SanitizedGrantLike[]): FleetGrantRow[] { + if (!Array.isArray(grants)) return [] + return grants.map((g) => ({ + requesterMachineId: g.requesterMachineId, + targetMachineId: g.targetMachineId, + scope: g.scope, + generation: g.generation, + state: g.state, + issuedAt: g.issuedAt, + ...(g.revokedAt ? { revokedAt: g.revokedAt } : {}), + affordances: grantAffordances(g.state), + })) +} + +/** A pending control request (the shared-peer handshake). Backend is #1545. */ +export interface PendingRequestRow { + requesterMachineId: string + targetMachineId: string + scope: string +} + +/** Read the pending-requests off the grants response. Until #1545 the backend + * always returns [], so this is an honest empty view (never fabricated). */ +export function shapePendingRequests(raw: unknown): PendingRequestRow[] { + if (!raw || typeof raw !== "object") return [] + const pending = (raw as { pending_requests?: unknown }).pending_requests + if (!Array.isArray(pending)) return [] + return pending.flatMap((p) => { + if (!p || typeof p !== "object") return [] + const o = p as Record + if (typeof o.requesterMachineId !== "string" || typeof o.targetMachineId !== "string") return [] + return [{ requesterMachineId: o.requesterMachineId, targetMachineId: o.targetMachineId, scope: typeof o.scope === "string" ? o.scope : "control" }] + }) +} + diff --git a/packages/extension/src/amicode_service/index.ts b/packages/extension/src/amicode_service/index.ts index d2c84457..b765dc0f 100644 --- a/packages/extension/src/amicode_service/index.ts +++ b/packages/extension/src/amicode_service/index.ts @@ -47,9 +47,10 @@ import { import { SseFanInDriver } from "./sse_fanin_driver"; import { createObservationReadPlane } from "./observation_read_plane"; import { createObservationWritePlane } from "./observation_write_plane"; -import { findControlGrantByTarget } from "./fleet_control_lifecycle"; +import { findControlGrantByTarget, readAllLifecycleGrants, sanitizeGrantForDisplay } from "./fleet_control_lifecycle"; import { HubCredentialRead, mintRegistry, readHubCredential } from "./hub_credential"; import { buildMergedProjection, buildFleetProjection, type UpstreamMode, type MergedProjection, type FleetProjection } from "./merged_projection"; +import { buildControlResolver } from "./remote_session_state"; import { FleetPostureDetector, type FleetPostureTuning } from "./fleet_posture"; import { handleFleetWrite, type FleetWriteDeps } from "./fleet_writes"; import { inspectTunnelConfigFile, TUNNEL_GENERATION_HEADER } from "./fleet_tunnel"; @@ -526,12 +527,33 @@ export function registerFleetRoutes(server: AmicodeServiceServer, deps: FleetRou // #1481 (AC3): blocked-identity peers (alias-conflict / key-changed) are // NAMED source states, never silently dropped from the projection. const blockedPeers = deps.fleetPeers.getBlockedPeers?.() ?? []; + // #1544 (slice 4): the STATE CHANNEL. Stamp each entry's app-visible + // `amicode_control` ({ controlState, reason, eligibility }) projected from + // the SoT (remote_session_state) — NOT the raw write-gate reason (which + // collapses revocation-pending→grant-revoked). The grant read is the + // D2-corrected target-keyed control-grant lookup (the controlling machine + // holds a `control` grant whose `targetMachineId` is the owner); any state + // is read so revoked/pending stay DISTINCT for the chip. Reachability = + // the peer is serving with a usable token+URL. `isSelfOwned` is the base + // self-owned fast-path (true) — the shared-peer request handshake that + // would flip it to request-control is #1545. + const reachableIds = new Set(peers.filter((p) => p.token !== undefined && p.getUrl() !== undefined).map((p) => p.machineId)); + const resolveControl = buildControlResolver({ + localMachineId: deps.fleetPeers.localMachineId, + grantReader: (ownerId) => { + const g = readAllLifecycleGrants().find((x) => x.targetMachineId === ownerId && x.scope === "control"); + return g ? { scope: g.scope, state: g.state } : undefined; + }, + peerReachable: (ownerId) => reachableIds.has(ownerId), + isSelfOwned: () => true, + }); projection = await buildFleetProjection({ localMachineId: deps.fleetPeers.localMachineId, local: { getUrl: deps.engine.getUrl, password: deps.engine.password }, peers, blockedPeers, rosterLookup: deps.fleetPeers.rosterLookup, + resolveControl, }); } else { projection = await buildMergedProjection({ @@ -558,6 +580,21 @@ export function registerFleetRoutes(server: AmicodeServiceServer, deps: FleetRou return { body: JSON.stringify(projection) }; }); + // #1544 (slice 4): the Fleet Manager grant-management panel's read surface — + // the lifecycle grants, SANITIZED (sanitizeGrantForDisplay NEVER includes the + // token; identity keys are truncated). Under the /amicode/fleet/* prefix so it + // inherits the never-proxied local-honesty exclusion (grants are this + // machine's own state). The pending-requests view is a #1545 stub (empty). + server.add("GET", "/amicode/fleet/grants", () => { + return { + body: JSON.stringify({ + ok: true, + grants: readAllLifecycleGrants().map(sanitizeGrantForDisplay), + pending_requests: [], + }), + }; + }); + return server; } diff --git a/packages/extension/src/amicode_service/merged_projection.ts b/packages/extension/src/amicode_service/merged_projection.ts index 86fdae48..50fb6f83 100644 --- a/packages/extension/src/amicode_service/merged_projection.ts +++ b/packages/extension/src/amicode_service/merged_projection.ts @@ -21,6 +21,7 @@ import { createHash } from "node:crypto"; import { HubCredentialRead, hubUpstreamAuthHeader } from "./hub_credential"; import { serverAuthHeader } from "../server_auth"; +import type { SessionControlProjection } from "./remote_session_state"; export type SourceTag = string; export type UpstreamMode = "engine" | "fleet"; @@ -72,6 +73,11 @@ export interface FleetProjectionOptions { blockedPeers?: Array<{ machineId: string; reason?: SourceAbsenceReason; detail?: string }>; /** Roster lookup for owner_name/device_type enrichment. */ rosterLookup: (machineId: string) => RosterEntry | undefined; + /** #1544 (slice 4): the OPTIONAL state-channel injector. When present, each + * session entry is stamped with the app-visible `amicode_control` + * ({ controlState, reason, eligibility }) derived from the SoT + * (remote_session_state) for its owner. Absent ⇒ no field (back-compat). */ + resolveControl?: (ownerMachineId: string, isLocal: boolean) => SessionControlProjection; fetchImpl?: typeof fetch; timeoutMs?: number; } @@ -80,8 +86,9 @@ export interface FleetProjectionOptions { export interface FleetProjection { ok: true; mode: "fleet"; - /** Each entry carries `amicode_owner` (the owner tag overlay). */ - sessions: Array & { amicode_owner?: SessionOwnerTag }>; + /** Each entry carries `amicode_owner` (the owner tag overlay) and, when a + * control resolver was supplied, `amicode_control` (the state channel). */ + sessions: Array & { amicode_owner?: SessionOwnerTag; amicode_control?: SessionControlProjection }>; /** Keyed by machine_id (string), not the old "local"|"hub" pair. */ sources: Record; currency: { token: string; sources: string[]; derived_over: "fetched" }; @@ -319,14 +326,18 @@ export function peerAuthHeader(token: string): string { // ── N-peer fleet-wide projection (#1439) ───────────────────────────────────── /** Tag each session entry with its owner machine's identity, joining the - * roster for name/device_type. */ + * roster for name/device_type. When `resolveControl` is supplied (#1544), the + * app-visible `amicode_control` state channel is stamped alongside — one + * resolution per source (state is per-owner, not per-session). */ function tagSessionsWithOwner( entries: Record[], machineId: string, isLocal: boolean, rosterLookup: (id: string) => RosterEntry | undefined, -): Array & { amicode_owner?: SessionOwnerTag }> { + resolveControl?: (ownerMachineId: string, isLocal: boolean) => SessionControlProjection, +): Array & { amicode_owner?: SessionOwnerTag; amicode_control?: SessionControlProjection }> { const roster = rosterLookup(machineId); + const control = resolveControl ? resolveControl(machineId, isLocal) : undefined; return entries.map((e) => ({ ...e, amicode_provenance: machineId, @@ -337,15 +348,16 @@ function tagSessionsWithOwner( ...(typeof e.directory === "string" ? { directory: e.directory } : {}), is_local: isLocal, }, + ...(control ? { amicode_control: control } : {}), })); } /** Merge N sources into one deduplicated list. Later sources (by array order) * win on conflict (same session id in multiple stores). */ function mergeNSources( - taggedSources: Array<{ machineId: string; entries: Array & { amicode_owner?: SessionOwnerTag }> }>, -): Array & { amicode_owner?: SessionOwnerTag }> { - const out: Array & { amicode_owner?: SessionOwnerTag }> = []; + taggedSources: Array<{ machineId: string; entries: Array & { amicode_owner?: SessionOwnerTag; amicode_control?: SessionControlProjection }> }>, +): Array & { amicode_owner?: SessionOwnerTag; amicode_control?: SessionControlProjection }> { + const out: Array & { amicode_owner?: SessionOwnerTag; amicode_control?: SessionControlProjection }> = []; const seen = new Map(); for (const { entries } of taggedSources) { for (const e of entries) { @@ -407,16 +419,17 @@ export async function buildFleetProjection(opts: FleetProjectionOptions): Promis }; } - // Tag each source's sessions with owner info (roster join) - const taggedSources: Array<{ machineId: string; entries: Array & { amicode_owner?: SessionOwnerTag }> }> = []; + // Tag each source's sessions with owner info (roster join) + the #1544 state + // channel (amicode_control), when a resolver was supplied. + const taggedSources: Array<{ machineId: string; entries: Array & { amicode_owner?: SessionOwnerTag; amicode_control?: SessionControlProjection }> }> = []; taggedSources.push({ machineId: opts.localMachineId, - entries: tagSessionsWithOwner(localResult.entries, opts.localMachineId, true, opts.rosterLookup), + entries: tagSessionsWithOwner(localResult.entries, opts.localMachineId, true, opts.rosterLookup, opts.resolveControl), }); for (let i = 0; i < opts.peers.length; i++) { taggedSources.push({ machineId: opts.peers[i].machineId, - entries: tagSessionsWithOwner(peerResults[i].entries, opts.peers[i].machineId, false, opts.rosterLookup), + entries: tagSessionsWithOwner(peerResults[i].entries, opts.peers[i].machineId, false, opts.rosterLookup, opts.resolveControl), }); } diff --git a/packages/extension/src/amicode_service/remote_session_state.ts b/packages/extension/src/amicode_service/remote_session_state.ts index 2f6b3291..d697b414 100644 --- a/packages/extension/src/amicode_service/remote_session_state.ts +++ b/packages/extension/src/amicode_service/remote_session_state.ts @@ -83,3 +83,99 @@ export function resolveRemoteSessionState( // All gates passed → interactive return { kind: "interactive", machineId: ownerMachineId }; } + +// ── #1544 (slice 4) THE STATE CHANNEL DATA CONTRACT ────────────────────────── +// +// The reason/eligibility logic above is a PURE module with (before this slice) +// NO app consumer. This section names the APP-VISIBLE shape and projects it from +// resolveRemoteSessionState — the SINGLE SOURCE OF TRUTH for the fail-closed +// chip. +// +// CARRIER (stated explicitly, per the deliberate-review correction): the shape +// rides the fleet projection the app ALREADY consumes — GET /amicode/fleet/ +// sessions (buildFleetProjection). Each projection session entry is tagged with +// the `amicode_owner` overlay (merged_projection.ts tagSessionsWithOwner); this +// slice adds a SIBLING overlay field `amicode_control: SessionControlProjection` +// on the same entry, derived here. The app reads it off DropdownSession +// (session-fleet-peers.ts). No new endpoint — the control state travels beside +// the owner tag it is scoped to. +// +// WHY NOT THE RAW WRITE-GATE REASON: evaluateRemoteWriteGate COLLAPSES +// `revocation-pending` → `grant-revoked`. This module keeps them distinct, and +// that distinction must reach the UI, so the chip is projected from HERE, never +// from the write gate (ADR 0034 D4/D5). + +/** EXACTLY the reasons the SoT (resolveRemoteSessionState) can emit — no more, + * no fewer. The fail-closed chip's vocabulary. */ +export const CONTROL_CHIP_REASONS = [ + "no-control-grant", + "grant-revoked", + "revocation-pending", + "insufficient-scope", + "transport-down", +] as const; + +export type ControlChipReason = (typeof CONTROL_CHIP_REASONS)[number]; + +/** Whether — and which — control affordance should appear for the session: + * `enable-control` (self-owned, one explicit act), `request-control` (shared, + * routed to the peer's lifecycle-admin authority — backend is #1545), or + * `none` (control already held, or the session is local). */ +export type ControlEligibility = "enable-control" | "request-control" | "none"; + +/** The APP-VISIBLE control shape carried on GET /amicode/fleet/sessions as the + * `amicode_control` sibling of `amicode_owner`. Projected from the SoT. */ +export interface SessionControlProjection { + /** The remote_session_state kind — the state the fail-closed surface honors. */ + controlState: RemoteSessionState["kind"]; + /** The chip reason (distinct from the collapsed write-gate reason), or null + * when control is held / the session is local. */ + reason: ControlChipReason | null; + /** The affordance derived from ownership + current state. */ + eligibility: ControlEligibility; +} + +/** Project the SoT state into the app-visible shape. Pure. + * - controlState mirrors the state kind (the four fail-closed states); + * - reason is the state's reason for the read-only / suspended cases (kept + * DISTINCT — revocation-pending is never collapsed to grant-revoked), else + * null (local / interactive carry no chip); + * - eligibility: control-held (local / interactive) → none; control NOT held + * (read-only / suspended) → enable-control when self-owned, else + * request-control (the shared-peer handshake affordance). */ +export function projectSessionControlState( + state: RemoteSessionState, + opts: { selfOwned: boolean }, +): SessionControlProjection { + if (state.kind === "local") { + return { controlState: "local", reason: null, eligibility: "none" }; + } + if (state.kind === "interactive") { + return { controlState: "interactive", reason: null, eligibility: "none" }; + } + // read-only | suspended — control is NOT held: fail-closed + an affordance to + // acquire it. The reason is carried verbatim (its distinctness is the point). + const reason = state.reason as ControlChipReason; + return { + controlState: state.kind, + reason, + eligibility: opts.selfOwned ? "enable-control" : "request-control", + }; +} + +/** Per-owner resolver for the projection carrier (the route wiring, index.ts). + * Composes resolveRemoteSessionState + projectSessionControlState so index.ts + * only supplies the grant/reachability/ownership deps and stamps the result on + * each fleet-projection entry. `isSelfOwned(peerId)` answers whether this + * operator owns the peer (self-owned fast-path) vs a shared peer. */ +export function buildControlResolver(deps: RemoteSessionDeps & { + isSelfOwned: (peerId: string) => boolean; +}): (ownerMachineId: string, isLocal: boolean) => SessionControlProjection { + return (ownerMachineId, isLocal) => { + if (isLocal || ownerMachineId === deps.localMachineId) { + return { controlState: "local", reason: null, eligibility: "none" }; + } + const state = resolveRemoteSessionState(ownerMachineId, ownerMachineId, deps); + return projectSessionControlState(state, { selfOwned: deps.isSelfOwned(ownerMachineId) }); + }; +} diff --git a/packages/extension/test/amicode_service_fleet_data_plane.test.ts b/packages/extension/test/amicode_service_fleet_data_plane.test.ts index 306114e1..54e3a617 100644 --- a/packages/extension/test/amicode_service_fleet_data_plane.test.ts +++ b/packages/extension/test/amicode_service_fleet_data_plane.test.ts @@ -1401,6 +1401,51 @@ describe("GET /amicode/fleet/sessions — fleet-wide tagged list via the route ( expect(body.sources["the-studio"].present).toBe(true); expect(body.sources["the-mini"].present).toBe(true); }); + + // #1544 (slice 4): the STATE CHANNEL is carried on this endpoint. Every entry + // gets the app-visible `amicode_control` overlay projected from the SoT. The + // exact remote reason depends on the grant store; assert the SHAPE + that the + // local entry is `local` (env-independent). + it("carries the amicode_control state channel on every session entry (#1544 Data Contract)", async () => { + const res = await fetch(`${origin}/amicode/fleet/sessions`, { + headers: { Authorization: `Basic ${engineToken}` }, + }); + const body = (await res.json()) as FleetProjection; + const kinds = new Set(["local", "interactive", "read-only", "suspended"]); + const reasons = new Set([null, "no-control-grant", "grant-revoked", "revocation-pending", "insufficient-scope", "transport-down"]); + for (const s of body.sessions) { + const control = (s as Record).amicode_control as + | { controlState: string; reason: string | null; eligibility: string } + | undefined; + expect(control, `session ${s.id} must carry amicode_control`).toBeDefined(); + expect(kinds.has(control!.controlState)).toBe(true); + expect(reasons.has(control!.reason as never)).toBe(true); + expect(["enable-control", "request-control", "none"]).toContain(control!.eligibility); + } + const local = body.sessions.find((s) => s.id === "ses-local-x") as Record; + expect((local.amicode_control as { controlState: string }).controlState).toBe("local"); + }); + + it("GET /amicode/fleet/grants returns SANITIZED grants (never a token) + the #1545 pending-requests stub", async () => { + const prev = process.env.AMICO_FLEET_LIFECYCLE_GRANT_FILE; + // point the grant reader at a fresh (nonexistent) file → deterministic []. + process.env.AMICO_FLEET_LIFECYCLE_GRANT_FILE = join(root, "grants-empty.json"); + try { + const res = await fetch(`${origin}/amicode/fleet/grants`, { + headers: { Authorization: `Basic ${engineToken}` }, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { ok: boolean; grants: unknown[]; pending_requests: unknown[] }; + expect(body.ok).toBe(true); + expect(body.grants).toEqual([]); + expect(body.pending_requests).toEqual([]); + // and never a token key anywhere in the payload + expect(JSON.stringify(body)).not.toContain("token"); + } finally { + if (prev === undefined) delete process.env.AMICO_FLEET_LIFECYCLE_GRANT_FILE; + else process.env.AMICO_FLEET_LIFECYCLE_GRANT_FILE = prev; + } + }); }); // ══════════════════════════════════════════════════════════════════════════════ diff --git a/packages/extension/test/merged_projection_control.test.ts b/packages/extension/test/merged_projection_control.test.ts new file mode 100644 index 00000000..4b2f54ab --- /dev/null +++ b/packages/extension/test/merged_projection_control.test.ts @@ -0,0 +1,99 @@ +// merged_projection_control.test.ts — #1544 (slice 4): the state channel is +// CARRIED on the fleet projection (GET /amicode/fleet/sessions). buildFleetProjection +// gains an OPTIONAL `resolveControl` injector that stamps the app-visible +// `amicode_control` overlay (the { controlState, reason, eligibility } shape, +// projected from the SoT remote_session_state) beside each entry's `amicode_owner`. +// Back-compat: absent resolver ⇒ NO field (existing callers unchanged). +import { describe, it, expect } from "vitest"; +import { buildFleetProjection, type SessionOwnerTag } from "../src/amicode_service/merged_projection"; +import { buildControlResolver, type SessionControlProjection } from "../src/amicode_service/remote_session_state"; + +/** A fetch stub: every origin serves its own session array + a /global/health + * version stamp. Keyed by origin URL. */ +function fakeFetch(byOrigin: Record>>): typeof fetch { + return (async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input.toString(); + const origin = Object.keys(byOrigin).find((o) => url.startsWith(o)); + if (!origin) return new Response("no", { status: 502 }); + if (url.endsWith("/global/health")) { + return new Response(JSON.stringify({ version: "test-1" }), { status: 200 }); + } + if (url.endsWith("/session")) { + return new Response(JSON.stringify(byOrigin[origin]), { status: 200 }); + } + return new Response("no", { status: 404 }); + }) as unknown as typeof fetch; +} + +const ROSTER: Record = { + "local-mac": { name: "MacBook Pro" }, + "peer-a": { name: "Mac Studio" }, + "peer-b": { name: "Mac Mini" }, +}; + +function opts(resolveControl?: (ownerMachineId: string, isLocal: boolean) => SessionControlProjection) { + return { + localMachineId: "local-mac", + local: { getUrl: () => "http://local", password: "pw" }, + peers: [ + { machineId: "peer-a", getUrl: () => "http://peer-a", token: "tok-a" }, + { machineId: "peer-b", getUrl: () => "http://peer-b", token: "tok-b" }, + ], + rosterLookup: (id: string) => ROSTER[id], + fetchImpl: fakeFetch({ + "http://local": [{ id: "ses-local", time: { created: 1, updated: 1 } }], + "http://peer-a": [{ id: "ses-a", time: { created: 2, updated: 2 } }], + "http://peer-b": [{ id: "ses-b", time: { created: 3, updated: 3 } }], + }), + ...(resolveControl ? { resolveControl } : {}), + }; +} + +describe("#1544 buildFleetProjection carries the control channel (amicode_control)", () => { + it("BACK-COMPAT: no resolveControl ⇒ entries carry amicode_owner but NO amicode_control", async () => { + const p = await buildFleetProjection(opts()); + const a = p.sessions.find((s) => s.id === "ses-a")!; + expect((a.amicode_owner as SessionOwnerTag).owner_machine_id).toBe("peer-a"); + expect((a as Record).amicode_control).toBeUndefined(); + }); + + it("with a resolver, each entry gets amicode_control derived from its owner (SoT projection)", async () => { + const resolveControl = buildControlResolver({ + localMachineId: "local-mac", + // peer-a has an active control grant; peer-b has none + grantReader: (id) => (id === "peer-a" ? { scope: "control", state: "active" } : undefined), + peerReachable: (id) => id === "peer-a", + isSelfOwned: () => true, + }); + const p = await buildFleetProjection(opts(resolveControl)); + + const local = p.sessions.find((s) => s.id === "ses-local")! as Record; + expect(local.amicode_control).toEqual({ controlState: "local", reason: null, eligibility: "none" }); + + const a = p.sessions.find((s) => s.id === "ses-a")! as Record; + expect(a.amicode_control).toEqual({ controlState: "interactive", reason: null, eligibility: "none" }); + + const b = p.sessions.find((s) => s.id === "ses-b")! as Record; + expect(b.amicode_control).toEqual({ + controlState: "read-only", + reason: "no-control-grant", + eligibility: "enable-control", + }); + }); + + it("a revocation-pending peer stays DISTINCT (suspended/revocation-pending) through the carrier", async () => { + const resolveControl = buildControlResolver({ + localMachineId: "local-mac", + grantReader: (id) => (id === "peer-a" ? { scope: "control", state: "revocation-pending" } : undefined), + peerReachable: () => true, + isSelfOwned: () => true, + }); + const p = await buildFleetProjection(opts(resolveControl)); + const a = p.sessions.find((s) => s.id === "ses-a")! as Record; + expect(a.amicode_control).toEqual({ + controlState: "suspended", + reason: "revocation-pending", + eligibility: "enable-control", + }); + }); +}); diff --git a/packages/extension/test/remote_session_state.test.ts b/packages/extension/test/remote_session_state.test.ts index fda7fd0e..d8f772c8 100644 --- a/packages/extension/test/remote_session_state.test.ts +++ b/packages/extension/test/remote_session_state.test.ts @@ -4,8 +4,12 @@ import { describe, it, expect } from "vitest"; import { resolveRemoteSessionState, + projectSessionControlState, + buildControlResolver, + CONTROL_CHIP_REASONS, type RemoteSessionState, type RemoteSessionDeps, + type SessionControlProjection, } from "../src/amicode_service/remote_session_state"; const LOCAL_MACHINE = "local-mac"; @@ -116,3 +120,95 @@ describe("#1484 AC4 — no local fallback for known remote sessions", () => { } }); }); + +// ── #1544 (slice 4) — the state channel Data Contract ───────────────────────── +// `projectSessionControlState` is the SINGLE SOURCE OF TRUTH projector for the +// app-visible control shape { controlState, reason, eligibility } carried on the +// fleet projection (GET /amicode/fleet/sessions). It projects from the +// remote_session_state kind + reason — NOT the raw write-gate reason (the write +// gate COLLAPSES revocation-pending→grant-revoked; this projector must keep them +// distinct, so the fail-closed chip can say which). Eligibility is derived from +// ownership (self vs shared) + whether control is currently held. +describe("#1544 projectSessionControlState — the app-visible { controlState, reason, eligibility }", () => { + it("enumerates EXACTLY the five reasons the SoT emits (no more, no fewer)", () => { + expect([...CONTROL_CHIP_REASONS].sort()).toEqual( + ["grant-revoked", "insufficient-scope", "no-control-grant", "revocation-pending", "transport-down"], + ); + }); + + it("local → controlState local, no reason, no affordance", () => { + const p = projectSessionControlState({ kind: "local" }, { selfOwned: true }); + expect(p).toEqual({ controlState: "local", reason: null, eligibility: "none" } satisfies SessionControlProjection); + }); + + it("interactive (control held) → no chip reason, no enable/request affordance", () => { + const p = projectSessionControlState({ kind: "interactive", machineId: "peer-a" }, { selfOwned: true }); + expect(p).toEqual({ controlState: "interactive", reason: null, eligibility: "none" }); + }); + + it("read-only no-control-grant, self-owned → Enable-control affordance, chip reason preserved", () => { + const p = projectSessionControlState( + { kind: "read-only", machineId: "peer-a", reason: "no-control-grant" }, + { selfOwned: true }, + ); + expect(p).toEqual({ controlState: "read-only", reason: "no-control-grant", eligibility: "enable-control" }); + }); + + it("read-only no-control-grant, SHARED peer → Request-control affordance", () => { + const p = projectSessionControlState( + { kind: "read-only", machineId: "peer-a", reason: "no-control-grant" }, + { selfOwned: false }, + ); + expect(p.eligibility).toBe("request-control"); + expect(p.reason).toBe("no-control-grant"); + }); + + it("read-only insufficient-scope / grant-revoked preserve their distinct reasons", () => { + expect(projectSessionControlState({ kind: "read-only", machineId: "p", reason: "insufficient-scope" }, { selfOwned: true }).reason).toBe("insufficient-scope"); + expect(projectSessionControlState({ kind: "read-only", machineId: "p", reason: "grant-revoked" }, { selfOwned: true }).reason).toBe("grant-revoked"); + }); + + it("suspended revocation-pending stays DISTINCT from grant-revoked (the SoT distinction the write gate collapses)", () => { + const pending = projectSessionControlState( + { kind: "suspended", machineId: "peer-a", reason: "revocation-pending" }, + { selfOwned: true }, + ); + expect(pending.controlState).toBe("suspended"); + expect(pending.reason).toBe("revocation-pending"); + // and it is still eligible to re-enable (self-owned) + expect(pending.eligibility).toBe("enable-control"); + }); + + it("suspended transport-down → suspended state, transport-down reason, eligible to re-enable", () => { + const p = projectSessionControlState( + { kind: "suspended", machineId: "peer-a", reason: "transport-down" }, + { selfOwned: true }, + ); + expect(p).toEqual({ controlState: "suspended", reason: "transport-down", eligibility: "enable-control" }); + }); +}); + +describe("#1544 buildControlResolver — the projection carrier's per-owner resolver (route wiring)", () => { + const resolver = buildControlResolver({ + localMachineId: "local-mac", + grantReader: (id) => (id === "peer-a" ? { scope: "control", state: "active" } : undefined), + peerReachable: (id) => id === "peer-a", + isSelfOwned: () => true, + }); + + it("resolves a local entry to controlState local", () => { + expect(resolver("local-mac", true)).toEqual({ controlState: "local", reason: null, eligibility: "none" }); + }); + + it("resolves a controlled+reachable peer to interactive", () => { + expect(resolver("peer-a", false)).toEqual({ controlState: "interactive", reason: null, eligibility: "none" }); + }); + + it("resolves an ungranted peer to read-only + no-control-grant + enable-control", () => { + expect(resolver("peer-b", false)).toEqual({ + controlState: "read-only", + reason: "no-control-grant", + eligibility: "enable-control", + }); + }); +}); diff --git a/packages/extension/test/sidebar_fleet_section.test.ts b/packages/extension/test/sidebar_fleet_section.test.ts index 119e9b09..568370d8 100644 --- a/packages/extension/test/sidebar_fleet_section.test.ts +++ b/packages/extension/test/sidebar_fleet_section.test.ts @@ -8,6 +8,8 @@ // - renderFleetSection(): the view-model → DOM (rows, posture badge, the // single Manage affordance), and the read-only click contract. import { describe, it, expect, vi } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { buildFleetSectionModel, renderFleetSection, @@ -1078,3 +1080,37 @@ describe("#1484 AC1 — scoped server actions per peer relationship", () => { expect(device!.availableActions).toEqual([]); }); }); + +// ── #1544 (slice 4) AC5 — the sidebar fleet section stays READ-ONLY ─────────── +// Control affordances (Enable/Request control, the driving banner, owner-routed +// remote-delete, the fail-closed chip) live ONLY on the session surface + the +// Fleet Manager tab — NEVER the read-only sidebar (ADR 0034 D7). This is a +// REGRESSION GUARD pinning that invariant: the sidebar module must carry none of +// the #1544 control-UI markers, and its emitted message union must stay +// navigation-only. +describe("#1544 AC5 — sidebar fleet section carries NO control affordance", () => { + const src = readFileSync(resolve(__dirname, "../src/sidebar_fleet_section.ts"), "utf8"); + + it("no #1544 control-UI marker leaked into the sidebar", () => { + for (const marker of [ + "data-driving-peer", + "amicode-driving-banner", + "session-control-chip", + "session-remote-delete", + "session-enable-control", + "enableControl", + "amicode.fleet.enableControl", + ]) { + expect(src, `sidebar must not contain "${marker}" (control lives on the session surface)`).not.toContain(marker); + } + }); + + it("the sidebar's emitted message union stays navigation-only (no control/driving/write message)", () => { + // FleetSectionMessage is the closed union of what the sidebar can emit — it + // is navigation only (open-fleet-manager / troubleshoot / connect / focus). + expect(src).toContain("export type FleetSectionMessage"); + for (const controlKind of ["enable-control", "drive-peer", "remote-delete", "grant-control-write"]) { + expect(src, `sidebar message union must not carry "${controlKind}"`).not.toContain(controlKind); + } + }); +}); From 669500d602c9aadf57f8d8159d8f7f966c14ffea Mon Sep 17 00:00:00 2001 From: amicode-ci Date: Thu, 24 Sep 2026 18:18:21 -0400 Subject: [PATCH 5/5] =?UTF-8?q?Fleet=20Studio=20B2b=20(#1545):=20Shared-pe?= =?UTF-8?q?er=20control=20=E2=80=94=20request=E2=86=92approve=20handshake?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Net-new shared-peer control path (ADR 0034 D3, slice 5): - fleet_control_request.ts: pending-request store (file-based, keyed, state machine: pending → approved|denied), submit/approve/deny operations. Injectable deps, env-overridable path (AMICO_FLEET_CONTROL_REQUEST_FILE). - Server routes: POST /amicode/fleet/control-request (submit), /control-approve (authority-gated, mints control grant), /control-deny (no grant). All under /amicode/fleet/* (never-proxied). - Route matrix: control-approve/deny added to LIFECYCLE_ADMIN_PREFIXES (only lifecycle-admin authority can approve or deny). - GET /amicode/fleet/grants now returns live pending_requests (replaces the #1544 empty stub). - fleet_control_bootstrap.test.ts extended: 3 new tests pin the shared-peer arm (bootstrap → submit → approve → grant, deny path, no-privilege-bleed invariant). - fleet_control_request.test.ts: 26 tests covering the store, submit, approve, deny, fail-closed, already-granted, multiple requesters, and route-matrix enforcement. Invariants: - Shared path NEVER borrows the self-owned fast-path (no privilege bleed) - Fail-closed: no approval → no control grant - A headless target renders nothing; approval is from the authority holder Closes #1545 --- .../fleet_control_lifecycle.ts | 4 + .../amicode_service/fleet_control_request.ts | 298 ++++++++++++++++ .../extension/src/amicode_service/index.ts | 91 ++++- .../test/fleet_control_bootstrap.test.ts | 121 +++++++ .../test/fleet_control_request.test.ts | 326 ++++++++++++++++++ 5 files changed, 838 insertions(+), 2 deletions(-) create mode 100644 packages/extension/src/amicode_service/fleet_control_request.ts create mode 100644 packages/extension/test/fleet_control_request.test.ts diff --git a/packages/extension/src/amicode_service/fleet_control_lifecycle.ts b/packages/extension/src/amicode_service/fleet_control_lifecycle.ts index c622b1a2..70692943 100644 --- a/packages/extension/src/amicode_service/fleet_control_lifecycle.ts +++ b/packages/extension/src/amicode_service/fleet_control_lifecycle.ts @@ -258,6 +258,10 @@ const LIFECYCLE_ADMIN_PREFIXES = [ "/amicode/fleet/revoke", "/amicode/fleet/readmit", "/amicode/fleet/lifecycle", + // #1545: the control-approve/deny routes are lifecycle-admin acts (only the + // authority holder may approve or deny a shared peer's control request). + "/amicode/fleet/control-approve", + "/amicode/fleet/control-deny", ]; /** Evaluate whether a scope is authorized for a given method+pathname. diff --git a/packages/extension/src/amicode_service/fleet_control_request.ts b/packages/extension/src/amicode_service/fleet_control_request.ts new file mode 100644 index 00000000..2b17e677 --- /dev/null +++ b/packages/extension/src/amicode_service/fleet_control_request.ts @@ -0,0 +1,298 @@ +// SHARED-PEER CONTROL REQUEST STORE (#1545, ADR 0034 D3) — the request→approve +// handshake for a shared peer that cannot use the self-owned fast-path. A +// shared peer POSTs a control request routed to the target's lifecycle-admin +// authority holder; the authority holder approves (minting a control grant via +// issueLifecycleGrant) or denies (fail-closed — no grant, no control). +// +// This module owns the pending-request store (file-based, keyed, state machine: +// pending → approved|denied) and the submit/approve/deny operations. It is +// consumed by the server routes (POST /amicode/fleet/control-request, +// /control-approve, /control-deny) and the Fleet Manager's pending-requests +// view (#1544). +// +// The store is DISTINCT from the lifecycle grant store — a request is the +// PRECURSOR to a grant, not a grant itself. On approval, the request transitions +// to "approved" AND issueLifecycleGrant mints the actual control grant. +import { homedir } from "node:os"; +import { join } from "node:path"; +import { readKeyedCollection, upsertKeyedEntry } from "./keyed_store"; +import { + issueLifecycleGrant, + findControlGrantByTarget, + type LifecycleGrant, + type LifecycleGrantDeps, +} from "./fleet_control_lifecycle"; + +// ── Store constants ────────────────────────────────────────────────────────── + +export const CONTROL_REQUEST_STORE_VERSION = 1; +const REQUESTS_COLLECTION = "control_requests"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export type ControlRequestStatus = "pending" | "approved" | "denied"; + +/** A control request: a shared peer asking for control over a target. */ +export interface ControlRequestRecord { + requesterMachineId: string; + requesterIdentityKey: string; + targetMachineId: string; + targetIdentityKey: string; + status: ControlRequestStatus; + requestedAt: string; + resolvedAt?: string; +} + +/** The submit inputs (what the requester sends). */ +export interface ControlRequest { + requesterMachineId: string; + requesterIdentityKey: string; + targetMachineId: string; + targetIdentityKey: string; +} + +/** Dependencies (injectable for testing). */ +export interface ControlRequestDeps { + /** Override the store file. Default: + * $AMICO_FLEET_CONTROL_REQUEST_FILE → ~/.amico/fleet-control-requests.json. */ + requestStoreFile?: string; + /** The lifecycle grant deps (the approval path mints a grant). */ + grantDeps?: LifecycleGrantDeps; + /** ISO clock. Default: now. */ + now?: () => string; +} + +// ── Store path ─────────────────────────────────────────────────────────────── + +export function controlRequestStorePath(deps: ControlRequestDeps = {}): string { + if (deps.requestStoreFile) return deps.requestStoreFile; + const env = process.env.AMICO_FLEET_CONTROL_REQUEST_FILE; + if (env && env.trim() !== "") return env; + return join(homedir(), ".amico", "fleet-control-requests.json"); +} + +// ── Store key: requester+target composite ──────────────────────────────────── + +/** The store key is requester→target, so the same requester can request control + * over different targets, and different requesters can request the same target. */ +function requestKey(requesterMachineId: string, targetMachineId: string): string { + return `${requesterMachineId}::${targetMachineId}`; +} + +// ── On-disk record shape ───────────────────────────────────────────────────── + +interface StoredRequestRecord { + requester_identity_key: string; + target_machine_id: string; + target_identity_key: string; + status: string; + requested_at: string; + resolved_at?: string; +} + +// ── Parse ──────────────────────────────────────────────────────────────────── + +function parseStored(key: string, raw: unknown): ControlRequestRecord | undefined { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined; + const r = raw as Record; + const requesterIdentityKey = typeof r.requester_identity_key === "string" ? r.requester_identity_key : ""; + const targetMachineId = typeof r.target_machine_id === "string" ? r.target_machine_id : ""; + const targetIdentityKey = typeof r.target_identity_key === "string" ? r.target_identity_key : ""; + const status = typeof r.status === "string" ? r.status : ""; + const requestedAt = typeof r.requested_at === "string" ? r.requested_at : ""; + if (!requesterIdentityKey || !targetMachineId || !requestedAt) return undefined; + if (status !== "pending" && status !== "approved" && status !== "denied") return undefined; + + // Extract requester machine id from the composite key + const sep = key.indexOf("::"); + const requesterMachineId = sep >= 0 ? key.slice(0, sep) : key; + + const record: ControlRequestRecord = { + requesterMachineId, + requesterIdentityKey, + targetMachineId, + targetIdentityKey, + status, + requestedAt, + }; + const resolvedAt = typeof r.resolved_at === "string" ? r.resolved_at : undefined; + if (resolvedAt) record.resolvedAt = resolvedAt; + return record; +} + +// ── Reads ──────────────────────────────────────────────────────────────────── + +/** Read one control request by requester+target. */ +export function readControlRequest( + requesterMachineId: string, + targetMachineId: string, + deps: ControlRequestDeps = {}, +): ControlRequestRecord | undefined { + const collection = readKeyedCollection(controlRequestStorePath(deps), REQUESTS_COLLECTION); + return parseStored(requestKey(requesterMachineId, targetMachineId), collection[requestKey(requesterMachineId, targetMachineId)]); +} + +/** Read ALL pending requests. */ +export function readPendingRequests(deps: ControlRequestDeps = {}): ControlRequestRecord[] { + const collection = readKeyedCollection(controlRequestStorePath(deps), REQUESTS_COLLECTION); + const out: ControlRequestRecord[] = []; + for (const [key, raw] of Object.entries(collection)) { + const rec = parseStored(key, raw); + if (rec && rec.status === "pending") out.push(rec); + } + return out; +} + +/** Read ALL requests (any status). */ +export function readAllControlRequests(deps: ControlRequestDeps = {}): ControlRequestRecord[] { + const collection = readKeyedCollection(controlRequestStorePath(deps), REQUESTS_COLLECTION); + const out: ControlRequestRecord[] = []; + for (const [key, raw] of Object.entries(collection)) { + const rec = parseStored(key, raw); + if (rec) out.push(rec); + } + return out; +} + +// ── Writes ─────────────────────────────────────────────────────────────────── + +function writeRequest(record: ControlRequestRecord, deps: ControlRequestDeps): void { + const stored: StoredRequestRecord = { + requester_identity_key: record.requesterIdentityKey, + target_machine_id: record.targetMachineId, + target_identity_key: record.targetIdentityKey, + status: record.status, + requested_at: record.requestedAt, + }; + if (record.resolvedAt) (stored as unknown as Record).resolved_at = record.resolvedAt; + upsertKeyedEntry( + controlRequestStorePath(deps), + REQUESTS_COLLECTION, + requestKey(record.requesterMachineId, record.targetMachineId), + stored, + CONTROL_REQUEST_STORE_VERSION, + ); +} + +// ── Submit ─────────────────────────────────────────────────────────────────── + +export type ControlRequestResult = + | { ok: true; status: "pending" } + | { ok: true; status: "already-granted" } + | { ok: false; reason: string }; + +/** Submit a control request for a shared peer. If the requester already holds + * an active control grant for the target, returns `already-granted` (no new + * request). If a pending request already exists, returns `pending` (idempotent). + * Otherwise, creates a new pending request. */ +export function submitControlRequest( + req: ControlRequest, + deps: ControlRequestDeps = {}, +): ControlRequestResult { + // Check if the requester already has an active control grant for the target + const existingGrant = findControlGrantByTarget(req.targetMachineId, deps.grantDeps); + if (existingGrant && existingGrant.requesterMachineId === req.requesterMachineId) { + return { ok: true, status: "already-granted" }; + } + + // Check if a pending request already exists + const existing = readControlRequest(req.requesterMachineId, req.targetMachineId, deps); + if (existing && existing.status === "pending") { + return { ok: true, status: "pending" }; + } + + // Create a new pending request + const now = (deps.now ?? (() => new Date().toISOString()))(); + const record: ControlRequestRecord = { + requesterMachineId: req.requesterMachineId, + requesterIdentityKey: req.requesterIdentityKey, + targetMachineId: req.targetMachineId, + targetIdentityKey: req.targetIdentityKey, + status: "pending", + requestedAt: now, + }; + writeRequest(record, deps); + return { ok: true, status: "pending" }; +} + +// ── Approve ────────────────────────────────────────────────────────────────── + +export type ApproveControlResult = + | { ok: true; grant: LifecycleGrant; status?: "already-approved" } + | { ok: false; reason: "not-found" | "grant-failed" }; + +/** Approve a pending control request: mint a control grant via + * issueLifecycleGrant, update the request to "approved". If the request is + * already approved, returns the existing grant (idempotent). */ +export function approveControlRequest( + requesterMachineId: string, + targetMachineId: string, + deps: ControlRequestDeps = {}, +): ApproveControlResult { + const existing = readControlRequest(requesterMachineId, targetMachineId, deps); + if (!existing) return { ok: false, reason: "not-found" }; + + // Idempotent on already-approved + if (existing.status === "approved") { + const grant = findControlGrantByTarget(targetMachineId, deps.grantDeps); + if (grant && grant.requesterMachineId === requesterMachineId) { + return { ok: true, grant, status: "already-approved" }; + } + // The request says approved but the grant is gone — re-issue + } + + // Mint the control grant + const grantResult = issueLifecycleGrant( + { + requesterMachineId, + requesterIdentityKey: existing.requesterIdentityKey, + targetMachineId, + targetIdentityKey: existing.targetIdentityKey, + scope: "control", + }, + deps.grantDeps, + ); + if (!grantResult.ok) { + return { ok: false, reason: "grant-failed" }; + } + + // Update request status to approved + const now = (deps.now ?? (() => new Date().toISOString()))(); + const updated: ControlRequestRecord = { + ...existing, + status: "approved", + resolvedAt: now, + }; + writeRequest(updated, deps); + + return { ok: true, grant: grantResult.grant }; +} + +// ── Deny ───────────────────────────────────────────────────────────────────── + +export type DenyControlResult = + | { ok: true } + | { ok: false; reason: "not-found" }; + +/** Deny a pending control request: update to "denied", no grant minted. + * Idempotent on already-denied. */ +export function denyControlRequest( + requesterMachineId: string, + targetMachineId: string, + deps: ControlRequestDeps = {}, +): DenyControlResult { + const existing = readControlRequest(requesterMachineId, targetMachineId, deps); + if (!existing) return { ok: false, reason: "not-found" }; + + // Idempotent on already-denied + if (existing.status === "denied") return { ok: true }; + + const now = (deps.now ?? (() => new Date().toISOString()))(); + const updated: ControlRequestRecord = { + ...existing, + status: "denied", + resolvedAt: now, + }; + writeRequest(updated, deps); + return { ok: true }; +} diff --git a/packages/extension/src/amicode_service/index.ts b/packages/extension/src/amicode_service/index.ts index b765dc0f..fb68f291 100644 --- a/packages/extension/src/amicode_service/index.ts +++ b/packages/extension/src/amicode_service/index.ts @@ -48,6 +48,12 @@ import { SseFanInDriver } from "./sse_fanin_driver"; import { createObservationReadPlane } from "./observation_read_plane"; import { createObservationWritePlane } from "./observation_write_plane"; import { findControlGrantByTarget, readAllLifecycleGrants, sanitizeGrantForDisplay } from "./fleet_control_lifecycle"; +import { + submitControlRequest, + approveControlRequest, + denyControlRequest, + readPendingRequests, +} from "./fleet_control_request"; import { HubCredentialRead, mintRegistry, readHubCredential } from "./hub_credential"; import { buildMergedProjection, buildFleetProjection, type UpstreamMode, type MergedProjection, type FleetProjection } from "./merged_projection"; import { buildControlResolver } from "./remote_session_state"; @@ -584,17 +590,98 @@ export function registerFleetRoutes(server: AmicodeServiceServer, deps: FleetRou // the lifecycle grants, SANITIZED (sanitizeGrantForDisplay NEVER includes the // token; identity keys are truncated). Under the /amicode/fleet/* prefix so it // inherits the never-proxied local-honesty exclusion (grants are this - // machine's own state). The pending-requests view is a #1545 stub (empty). + // machine's own state). The pending-requests view is populated by #1545. server.add("GET", "/amicode/fleet/grants", () => { return { body: JSON.stringify({ ok: true, grants: readAllLifecycleGrants().map(sanitizeGrantForDisplay), - pending_requests: [], + pending_requests: readPendingRequests().map((r) => ({ + requesterMachineId: r.requesterMachineId, + targetMachineId: r.targetMachineId, + status: r.status, + requestedAt: r.requestedAt, + })), + }), + }; + }); + + // #1545 (slice 5): the shared-peer control request→approve handshake routes. + // A shared peer POSTs a control request; the lifecycle-admin authority holder + // approves or denies. These live under /amicode/fleet/* (never-proxied). + server.add("POST", "/amicode/fleet/control-request", ({ body }) => { + let parsed: Record; + try { + parsed = JSON.parse(body); + } catch { + return { status: 400, body: JSON.stringify({ ok: false, reason: "invalid-json" }) }; + } + const requesterMachineId = typeof parsed.requesterMachineId === "string" ? parsed.requesterMachineId : ""; + const requesterIdentityKey = typeof parsed.requesterIdentityKey === "string" ? parsed.requesterIdentityKey : ""; + const targetMachineId = typeof parsed.targetMachineId === "string" ? parsed.targetMachineId : ""; + const targetIdentityKey = typeof parsed.targetIdentityKey === "string" ? parsed.targetIdentityKey : ""; + if (!requesterMachineId || !targetMachineId) { + return { status: 400, body: JSON.stringify({ ok: false, reason: "missing-fields" }) }; + } + const result = submitControlRequest({ + requesterMachineId, + requesterIdentityKey, + targetMachineId, + targetIdentityKey, + }); + return { body: JSON.stringify(result) }; + }); + + server.add("POST", "/amicode/fleet/control-approve", ({ body }) => { + let parsed: Record; + try { + parsed = JSON.parse(body); + } catch { + return { status: 400, body: JSON.stringify({ ok: false, reason: "invalid-json" }) }; + } + const requesterMachineId = typeof parsed.requesterMachineId === "string" ? parsed.requesterMachineId : ""; + const targetMachineId = typeof parsed.targetMachineId === "string" ? parsed.targetMachineId : ""; + if (!requesterMachineId || !targetMachineId) { + return { status: 400, body: JSON.stringify({ ok: false, reason: "missing-fields" }) }; + } + const result = approveControlRequest(requesterMachineId, targetMachineId); + if (!result.ok) { + return { status: 404, body: JSON.stringify(result) }; + } + // Return grant token + metadata (the requester needs the token to present) + return { + body: JSON.stringify({ + ok: true, + status: result.status, + grant: { + scope: result.grant.scope, + state: result.grant.state, + token: result.grant.token, + generation: result.grant.generation, + }, }), }; }); + server.add("POST", "/amicode/fleet/control-deny", ({ body }) => { + let parsed: Record; + try { + parsed = JSON.parse(body); + } catch { + return { status: 400, body: JSON.stringify({ ok: false, reason: "invalid-json" }) }; + } + const requesterMachineId = typeof parsed.requesterMachineId === "string" ? parsed.requesterMachineId : ""; + const targetMachineId = typeof parsed.targetMachineId === "string" ? parsed.targetMachineId : ""; + if (!requesterMachineId || !targetMachineId) { + return { status: 400, body: JSON.stringify({ ok: false, reason: "missing-fields" }) }; + } + const result = denyControlRequest(requesterMachineId, targetMachineId); + if (!result.ok) { + return { status: 404, body: JSON.stringify(result) }; + } + return { body: JSON.stringify({ ok: true }) }; + }); + return server; } diff --git a/packages/extension/test/fleet_control_bootstrap.test.ts b/packages/extension/test/fleet_control_bootstrap.test.ts index e1948e0b..3577bce1 100644 --- a/packages/extension/test/fleet_control_bootstrap.test.ts +++ b/packages/extension/test/fleet_control_bootstrap.test.ts @@ -251,3 +251,124 @@ describe("enableSelfOwnedControl — a self-owned, management-verified peer mint expect(findControlGrantByTarget(PEER_ID, deps)).toBeUndefined(); }); }); + +// ═══════════════════════════════════════════════════════════════════════════ +// #1545 — shared-peer arm: the request→approve handshake path +// +// When evaluateControlBootstrap returns `requires-approval` for a shared peer, +// the caller issues a control request (fleet_control_request.ts). Approval +// (from the lifecycle-admin authority holder) mints a control grant via the +// SAME issueLifecycleGrant machinery the self-owned path uses — but the shared +// peer NEVER reaches enableSelfOwnedControl. This is the no-privilege-bleed +// invariant: two distinct paths, one grant model. +// ═══════════════════════════════════════════════════════════════════════════ +import { + submitControlRequest, + approveControlRequest, + denyControlRequest, + readPendingRequests, + type ControlRequestDeps, +} from "../src/amicode_service/fleet_control_request"; + +describe("#1545 — shared-peer arm: requires-approval → request→approve handshake → control grant", () => { + let deps: ControlRequestDeps; + beforeEach(() => { + const root = tmproot(); + deps = { + requestStoreFile: join(root, "control-requests.json"), + grantDeps: { + grantStoreFile: join(root, "lifecycle-grants.json"), + tokenFactory: () => "SHARED-CONTROL-TOKEN", + now: () => "2026-09-24T12:00:00.000Z", + }, + now: () => "2026-09-24T10:00:00.000Z", + }; + }); + + it("the shared-peer path: bootstrap requires-approval → submit request → approve → control grant", () => { + // Step 1: the bootstrap decision for a shared peer + const decision = evaluateControlBootstrap({ ownership: "shared", managementVerified: false }); + expect(decision.decision).toBe("requires-approval"); + + // Step 2: submit a control request + const submitResult = submitControlRequest( + { + requesterMachineId: SELF_ID, + requesterIdentityKey: SELF_KEY, + targetMachineId: PEER_ID, + targetIdentityKey: PEER_KEY, + }, + deps, + ); + expect(submitResult.ok).toBe(true); + expect(submitResult.status).toBe("pending"); + + // Step 3: the authority holder sees the pending request + const pending = readPendingRequests(deps); + expect(pending).toHaveLength(1); + expect(pending[0].requesterMachineId).toBe(SELF_ID); + + // Step 4: approve → control grant minted + const approveResult = approveControlRequest(SELF_ID, PEER_ID, deps); + expect(approveResult.ok).toBe(true); + if (!approveResult.ok) return; + expect(approveResult.grant.scope).toBe("control"); + expect(approveResult.grant.state).toBe("active"); + expect(approveResult.grant.token).toBe("SHARED-CONTROL-TOKEN"); + + // Step 5: the requester now has a control grant resolvable by target + const grant = findControlGrantByTarget(PEER_ID, deps.grantDeps); + expect(grant).toBeDefined(); + expect(grant!.scope).toBe("control"); + }); + + it("the shared-peer path denied: bootstrap requires-approval → submit → deny → NO grant", () => { + const decision = evaluateControlBootstrap({ ownership: "shared", managementVerified: false }); + expect(decision.decision).toBe("requires-approval"); + + submitControlRequest( + { + requesterMachineId: SELF_ID, + requesterIdentityKey: SELF_KEY, + targetMachineId: PEER_ID, + targetIdentityKey: PEER_KEY, + }, + deps, + ); + + denyControlRequest(SELF_ID, PEER_ID, deps); + const grant = findControlGrantByTarget(PEER_ID, deps.grantDeps); + expect(grant).toBeUndefined(); + }); + + it("no privilege bleed: enableSelfOwnedControl REFUSES a shared peer — only the handshake path works", () => { + // Attempt the self-owned path with shared ownership + const selfOwnedResult = enableSelfOwnedControl( + { + ownership: "shared", + managementVerified: true, + self: { machineId: SELF_ID, identityKey: SELF_KEY }, + target: { machineId: PEER_ID, identityKey: PEER_KEY }, + }, + deps.grantDeps, + ); + expect(selfOwnedResult.ok).toBe(false); + if (selfOwnedResult.ok) return; + expect(selfOwnedResult.reason).toBe("requires-approval"); + + // The handshake path works for the same shared peer + submitControlRequest( + { + requesterMachineId: SELF_ID, + requesterIdentityKey: SELF_KEY, + targetMachineId: PEER_ID, + targetIdentityKey: PEER_KEY, + }, + deps, + ); + const approveResult = approveControlRequest(SELF_ID, PEER_ID, deps); + expect(approveResult.ok).toBe(true); + if (!approveResult.ok) return; + expect(approveResult.grant.scope).toBe("control"); + }); +}); diff --git a/packages/extension/test/fleet_control_request.test.ts b/packages/extension/test/fleet_control_request.test.ts new file mode 100644 index 00000000..ac3d8ce4 --- /dev/null +++ b/packages/extension/test/fleet_control_request.test.ts @@ -0,0 +1,326 @@ +// fleet_control_request.test.ts — #1545 (ADR 0034 D3): the SHARED-PEER +// control request→approve handshake. A shared peer (a different operator's +// machine) can't use the self-owned fast-path — it must request control, +// routed to the target's lifecycle-admin authority holder, who approves or +// denies. This suite pins: +// +// · The pending-request store — file-based, keyed, state machine +// (pending → approved|denied). +// · The request route — POST /amicode/fleet/control-request: records a +// pending request, returns status. Idempotent on already-granted. +// · The approve/deny routes — POST /amicode/fleet/control-approve and +// /control-deny: authority-gated, mint a control grant on approve. +// · Fail-closed — no approval, no control. +// · No privilege bleed — the shared path NEVER borrows the self-owned +// fast-path. +import { describe, it, expect, beforeEach } from "vitest"; +import { mkdtempSync, readFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + type ControlRequest, + type ControlRequestDeps, + type ControlRequestResult, + type ApproveControlResult, + type DenyControlResult, + submitControlRequest, + approveControlRequest, + denyControlRequest, + readPendingRequests, + readControlRequest, + CONTROL_REQUEST_STORE_VERSION, +} from "../src/amicode_service/fleet_control_request"; +import { + readLifecycleGrant, + findControlGrantByTarget, + evaluateRouteMatrix, + type LifecycleGrantDeps, +} from "../src/amicode_service/fleet_control_lifecycle"; + +function tmproot(): string { + return mkdtempSync(join(tmpdir(), "amicode-1545-control-request-")); +} + +const REQUESTER_ID = "shared-macbook"; +const REQUESTER_KEY = "SHA256:shared-fingerprint"; +const TARGET_ID = "the-studio"; +const TARGET_KEY = "SHA256:studio-fingerprint"; +const AUTHORITY_ID = "authority-machine"; +const AUTHORITY_KEY = "SHA256:authority-fingerprint"; + +function makeDeps(root?: string): ControlRequestDeps { + const r = root ?? tmproot(); + return { + requestStoreFile: join(r, "control-requests.json"), + grantDeps: { + grantStoreFile: join(r, "lifecycle-grants.json"), + tokenFactory: () => "CONTROL-TOKEN-FROM-APPROVE", + now: () => "2026-09-24T12:00:00.000Z", + }, + now: () => "2026-09-24T10:00:00.000Z", + }; +} + +function makeRequest(overrides?: Partial): ControlRequest { + return { + requesterMachineId: REQUESTER_ID, + requesterIdentityKey: REQUESTER_KEY, + targetMachineId: TARGET_ID, + targetIdentityKey: TARGET_KEY, + ...overrides, + }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Pending-request store — file-based, keyed, state machine +// ═══════════════════════════════════════════════════════════════════════════ +describe("pending-request store — submit creates a pending request", () => { + let deps: ControlRequestDeps; + beforeEach(() => { + deps = makeDeps(); + }); + + it("submitControlRequest creates a pending request and returns status=pending", () => { + const result = submitControlRequest(makeRequest(), deps); + expect(result.ok).toBe(true); + expect(result.status).toBe("pending"); + }); + + it("the pending request is readable from the store", () => { + submitControlRequest(makeRequest(), deps); + const stored = readControlRequest(REQUESTER_ID, TARGET_ID, deps); + expect(stored).toBeDefined(); + expect(stored!.status).toBe("pending"); + expect(stored!.requesterMachineId).toBe(REQUESTER_ID); + expect(stored!.targetMachineId).toBe(TARGET_ID); + expect(stored!.requestedAt).toBe("2026-09-24T10:00:00.000Z"); + }); + + it("readPendingRequests returns only pending requests", () => { + submitControlRequest(makeRequest(), deps); + const pending = readPendingRequests(deps); + expect(pending).toHaveLength(1); + expect(pending[0].status).toBe("pending"); + expect(pending[0].requesterMachineId).toBe(REQUESTER_ID); + }); + + it("a duplicate submit is idempotent — returns pending, does not create a second request", () => { + submitControlRequest(makeRequest(), deps); + const result = submitControlRequest(makeRequest(), deps); + expect(result.ok).toBe(true); + expect(result.status).toBe("pending"); + const pending = readPendingRequests(deps); + expect(pending).toHaveLength(1); + }); + + it("the store file uses the env-overridable path", () => { + submitControlRequest(makeRequest(), deps); + expect(existsSync(deps.requestStoreFile!)).toBe(true); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Approve — authority-gated, mints a control grant +// ═══════════════════════════════════════════════════════════════════════════ +describe("approveControlRequest — mints a control grant + updates request to approved", () => { + let deps: ControlRequestDeps; + beforeEach(() => { + deps = makeDeps(); + submitControlRequest(makeRequest(), deps); + }); + + it("approving a pending request mints a control grant and returns the token", () => { + const result = approveControlRequest(REQUESTER_ID, TARGET_ID, deps); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.grant).toBeDefined(); + expect(result.grant.scope).toBe("control"); + expect(result.grant.state).toBe("active"); + expect(result.grant.token).toBe("CONTROL-TOKEN-FROM-APPROVE"); + expect(result.grant.requesterMachineId).toBe(REQUESTER_ID); + expect(result.grant.targetMachineId).toBe(TARGET_ID); + }); + + it("approval updates the request status to approved", () => { + approveControlRequest(REQUESTER_ID, TARGET_ID, deps); + const stored = readControlRequest(REQUESTER_ID, TARGET_ID, deps); + expect(stored).toBeDefined(); + expect(stored!.status).toBe("approved"); + }); + + it("the minted grant is readable from the lifecycle grant store", () => { + approveControlRequest(REQUESTER_ID, TARGET_ID, deps); + const grant = readLifecycleGrant(REQUESTER_ID, deps.grantDeps); + expect(grant).toBeDefined(); + expect(grant!.scope).toBe("control"); + expect(grant!.state).toBe("active"); + }); + + it("the minted grant is findable by targetMachineId", () => { + approveControlRequest(REQUESTER_ID, TARGET_ID, deps); + const grant = findControlGrantByTarget(TARGET_ID, deps.grantDeps); + expect(grant).toBeDefined(); + expect(grant!.scope).toBe("control"); + expect(grant!.token).toBe("CONTROL-TOKEN-FROM-APPROVE"); + }); + + it("approving a non-existent request fails with not-found", () => { + const result = approveControlRequest("unknown-peer", TARGET_ID, deps); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toBe("not-found"); + }); + + it("approving an already-approved request is idempotent — returns already-approved", () => { + approveControlRequest(REQUESTER_ID, TARGET_ID, deps); + const result = approveControlRequest(REQUESTER_ID, TARGET_ID, deps); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.status).toBe("already-approved"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Deny — updates request, no grant minted +// ═══════════════════════════════════════════════════════════════════════════ +describe("denyControlRequest — no grant minted, request updated to denied", () => { + let deps: ControlRequestDeps; + beforeEach(() => { + deps = makeDeps(); + submitControlRequest(makeRequest(), deps); + }); + + it("denying a pending request updates status to denied", () => { + const result = denyControlRequest(REQUESTER_ID, TARGET_ID, deps); + expect(result.ok).toBe(true); + const stored = readControlRequest(REQUESTER_ID, TARGET_ID, deps); + expect(stored!.status).toBe("denied"); + }); + + it("deny does NOT mint a grant — the requester has no control", () => { + denyControlRequest(REQUESTER_ID, TARGET_ID, deps); + const grant = readLifecycleGrant(REQUESTER_ID, deps.grantDeps); + expect(grant).toBeUndefined(); + }); + + it("denying a non-existent request fails with not-found", () => { + const result = denyControlRequest("unknown-peer", TARGET_ID, deps); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toBe("not-found"); + }); + + it("denying an already-denied request is idempotent", () => { + denyControlRequest(REQUESTER_ID, TARGET_ID, deps); + const result = denyControlRequest(REQUESTER_ID, TARGET_ID, deps); + expect(result.ok).toBe(true); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Fail-closed — no approval, no control +// ═══════════════════════════════════════════════════════════════════════════ +describe("fail-closed — a pending (unapproved) request leaves the requester without control", () => { + let deps: ControlRequestDeps; + beforeEach(() => { + deps = makeDeps(); + }); + + it("a submitted but unapproved request means NO control grant exists", () => { + submitControlRequest(makeRequest(), deps); + const grant = readLifecycleGrant(REQUESTER_ID, deps.grantDeps); + expect(grant).toBeUndefined(); + }); + + it("a denied request means NO control grant exists", () => { + submitControlRequest(makeRequest(), deps); + denyControlRequest(REQUESTER_ID, TARGET_ID, deps); + const grant = readLifecycleGrant(REQUESTER_ID, deps.grantDeps); + expect(grant).toBeUndefined(); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Already-granted — submit returns already-granted when the requester holds +// an active control grant for the target +// ═══════════════════════════════════════════════════════════════════════════ +describe("already-granted — submit detects an existing active control grant", () => { + let deps: ControlRequestDeps; + beforeEach(() => { + deps = makeDeps(); + }); + + it("returns already-granted when the requester already has a control grant for the target", () => { + // First approve a request + submitControlRequest(makeRequest(), deps); + approveControlRequest(REQUESTER_ID, TARGET_ID, deps); + // Now submit again — should detect the existing grant + const result = submitControlRequest(makeRequest(), deps); + expect(result.ok).toBe(true); + expect(result.status).toBe("already-granted"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Multiple requests — different requesters for same target +// ═══════════════════════════════════════════════════════════════════════════ +describe("multiple requesters — each has an independent request", () => { + let deps: ControlRequestDeps; + const OTHER_REQUESTER = "other-laptop"; + const OTHER_KEY = "SHA256:other-fingerprint"; + beforeEach(() => { + deps = makeDeps(); + }); + + it("two different requesters for the same target each get their own pending request", () => { + submitControlRequest(makeRequest(), deps); + submitControlRequest( + makeRequest({ requesterMachineId: OTHER_REQUESTER, requesterIdentityKey: OTHER_KEY }), + deps, + ); + const pending = readPendingRequests(deps); + expect(pending).toHaveLength(2); + }); + + it("approving one does not affect the other", () => { + submitControlRequest(makeRequest(), deps); + submitControlRequest( + makeRequest({ requesterMachineId: OTHER_REQUESTER, requesterIdentityKey: OTHER_KEY }), + deps, + ); + approveControlRequest(REQUESTER_ID, TARGET_ID, deps); + const pending = readPendingRequests(deps); + expect(pending).toHaveLength(1); + expect(pending[0].requesterMachineId).toBe(OTHER_REQUESTER); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Route matrix enforcement — control-approve/deny are lifecycle-admin only +// ═══════════════════════════════════════════════════════════════════════════ +describe("route matrix — control-approve/deny are lifecycle-admin acts", () => { + it("lifecycle-admin scope: ALLOWED on POST /amicode/fleet/control-approve", () => { + expect(evaluateRouteMatrix("lifecycle-admin", "POST", "/amicode/fleet/control-approve")).toBe(true); + }); + + it("lifecycle-admin scope: ALLOWED on POST /amicode/fleet/control-deny", () => { + expect(evaluateRouteMatrix("lifecycle-admin", "POST", "/amicode/fleet/control-deny")).toBe(true); + }); + + it("control scope: DENIED on POST /amicode/fleet/control-approve (lifecycle-admin only)", () => { + expect(evaluateRouteMatrix("control", "POST", "/amicode/fleet/control-approve")).toBe(false); + }); + + it("control scope: DENIED on POST /amicode/fleet/control-deny (lifecycle-admin only)", () => { + expect(evaluateRouteMatrix("control", "POST", "/amicode/fleet/control-deny")).toBe(false); + }); + + it("observe scope: DENIED on POST /amicode/fleet/control-approve", () => { + expect(evaluateRouteMatrix("observe", "POST", "/amicode/fleet/control-approve")).toBe(false); + }); + + it("observe scope: DENIED on POST /amicode/fleet/control-deny", () => { + expect(evaluateRouteMatrix("observe", "POST", "/amicode/fleet/control-deny")).toBe(false); + }); +});