diff --git a/packages/extension/src/amicode_service/fleet_staging.ts b/packages/extension/src/amicode_service/fleet_staging.ts index 081b6cde..643f1b8e 100644 --- a/packages/extension/src/amicode_service/fleet_staging.ts +++ b/packages/extension/src/amicode_service/fleet_staging.ts @@ -190,6 +190,21 @@ export function stageFleetDataPlane(opts: StageFleetDataPlaneOptions = {}): Flee return { staged: true, receipt }; } +/** + * #1524: the honest staging receipt for the OBSERVATION-ONLY base peer-studio + * path. The base observation authority (index.ts `baseStudioActivates`) is + * entitlement-FREE — it mounts the read/observation routes for a verified + * serving peer without ever consulting the premium staging gate. So on the + * observation-only path we do NOT call `stageFleetDataPlane` (AC5: no premium + * plane stages even if an entitlement is resolvable); instead this returns a + * receipt that honestly reports `entitlement: "absent", staged: false` — the + * SAME semantics the #1478 base-activation surface reports, never a forged + * present/staged flag. + */ +export function observationOnlyStagingReceipt(now: () => string = () => new Date().toISOString()): FleetStagingReceipt { + return emptyReceipt("absent", false, now()); +} + /** Convenience for logging/boot lines: a one-line staging summary. */ export function fleetStagingSummary(result: FleetStagingResult): string { if (result.receipt.entitlement === "absent") return "fleet staging: entitlement absent — zero fleet surfaces"; diff --git a/packages/extension/src/amicode_service/index.ts b/packages/extension/src/amicode_service/index.ts index 8b367c48..d3b05667 100644 --- a/packages/extension/src/amicode_service/index.ts +++ b/packages/extension/src/amicode_service/index.ts @@ -50,7 +50,7 @@ import { buildMergedProjection, buildFleetProjection, type UpstreamMode, type Me import { FleetPostureDetector, type FleetPostureTuning } from "./fleet_posture"; import { handleFleetWrite, type FleetWriteDeps } from "./fleet_writes"; import { inspectTunnelConfigFile, TUNNEL_GENERATION_HEADER } from "./fleet_tunnel"; -import { stageFleetDataPlane, type FleetStagingReceipt } from "./fleet_staging"; +import { stageFleetDataPlane, observationOnlyStagingReceipt, type FleetStagingReceipt } from "./fleet_staging"; import { resolveFleetProgram, type FleetProgramReceipt } from "./fleet_program"; import { createProject, listProjects } from "./project"; import { rehydratePeerRelationships, type RehydrationResult } from "./fleet_headless_rehydration"; @@ -670,6 +670,16 @@ export function createAmicodeService( * engine). Suppresses the standalone→engine mode flip and switches the * no-upstream 503 to the client's own honest hub-down state. */ client?: boolean; + /** #1524 (base peer-observation decoupled from hub-activation): mount the + * base peer-studio OBSERVATION routes (baseStudioActivates) WITHOUT the + * premium data plane. When true, createAmicodeService BYPASSES + * stageFleetDataPlane / the premium plane entirely (AC5 — no premium plane + * stages even if an entitlement is resolvable) and consults ONLY + * baseStudioActivates(opts.fleet) to decide whether to mount the routes. + * The wiring sets this for an UNARMED machine carrying a fleet-peer + * provider (no hub config). Undefined/false → today's exact staging path + * (byte-identical for entitled/armed machines, H3). */ + observationOnly?: boolean; /** The data-driven routing mode; default "fleet" (a staged plane with * no getter runs fleet). */ getMode?: () => UpstreamMode; @@ -755,12 +765,22 @@ export function createAmicodeService( // entitlement → this block never arms anything → zero fleet surfaces, // byte-identical. if (opts.fleet !== undefined) { - const staging = stageFleetDataPlane({ - entitlements: opts.fleet.entitlements, - entitlementConfigDir: opts.fleet.entitlementConfigDir, - overlaySource: opts.fleet.overlaySource, - }); - if (staging.staged) { + // #1524: OBSERVATION-ONLY base peer-studio decouples base peer-observation + // route mounting from hub-activation/entitlement. When set, BYPASS the + // premium staging gate entirely — the base observation authority + // (baseStudioActivates) is entitlement-free (AC5: no premium plane stages + // even if an entitlement is resolvable) — and ride an honest absent/ + // not-staged receipt (no forgery). Undefined/false → today's EXACT staging + // path, so an entitled/armed machine is byte-identical (H3). + const observationOnly = opts.fleet.observationOnly === true; + const staging = observationOnly + ? { staged: false as const, receipt: observationOnlyStagingReceipt() } + : stageFleetDataPlane({ + entitlements: opts.fleet.entitlements, + entitlementConfigDir: opts.fleet.entitlementConfigDir, + overlaySource: opts.fleet.overlaySource, + }); + if (!observationOnly && staging.staged) { fleetMultiplexerArmed = true; // #1131: the staged fleet program (amicissimo#418) — resolved through // the same entitlement gate inputs; its receipt rides the fleet status @@ -984,6 +1004,17 @@ export function createAmicodeService( // through the N-peer projection. AC3 (service/engine accept-set parity) // is owned by #1485. // + // #1524: this same branch is now the OBSERVATION-ONLY path (observationOnly + // true, hub-activation absent). The projection reads roster + peer tokens + // LATE per request, so a peer that changes state AFTER boot is reflected on + // the next request — PROVIDED the routes were mounted at boot. + // #1524 follow-up: the route-MOUNT decision (baseStudioActivates) is + // boot-time — a machine that boots with ZERO serving peers stays 404 until + // restart even if a peer comes online later (issue AC4). Moving the mount + // decision to per-request would need the routes always-mounted-but- + // conditionally-404, which would risk the AC2/H3 byte-identity guard; left + // as a deliberate follow-up rather than forced here. + // // #1487 (AC1): HEADLESS PEER REHYDRATION — on base-activation, run // rehydration to restore persisted peer relationships into named recovery // states. The result is a read-only snapshot of the rehydration outcome, diff --git a/packages/extension/src/amicode_service_wiring.ts b/packages/extension/src/amicode_service_wiring.ts index dd3ae327..77193ee0 100644 --- a/packages/extension/src/amicode_service_wiring.ts +++ b/packages/extension/src/amicode_service_wiring.ts @@ -100,6 +100,12 @@ export interface AmicodeServiceWiringOptions { * N-peer projection (index.ts:474). Never assigned when localMachineId is * absent — the legacy 2-source projection stays byte-identical. */ fleetPeers?: FleetPeerProvider; + /** #1524: mount the base peer-studio OBSERVATION routes without the premium + * data plane, decoupled from hub-activation. Set by the assembly below for + * an UNARMED machine (no hub config) that carries a fleet-peer provider — + * createAmicodeService then consults ONLY baseStudioActivates to mount the + * read/observation surface (no FleetPlane, hub proxy, or multiplex). */ + observationOnly?: boolean; }; /** #398 (slice 4e): the fleet activation — config/env-driven (see * fleet_activation.ts). A resolved snapshot OR a late-bound resolver @@ -302,6 +308,23 @@ export async function startAmicodeService( }, } : {}), }; + } else if (opts.localMachineId !== undefined && opts.localMachineId.trim() !== "") { + // #1524: OBSERVATION-ONLY base peer-studio. Activation is NOT armed (no + // hub config), yet this machine may hold a roster of serving peers + + // reader tokens (the live bug: /amicode/fleet/sessions 404'd on a VALID + // roster because opts.fleet was built ONLY inside the armed block above). + // Decouple base peer-observation route mounting from hub-activation: build + // a MINIMAL observation-only fleet — the fleet-peer provider (#1446), a + // NULL hub (no upstream), observationOnly:true — and pass it. + // createAmicodeService then BYPASSES the premium plane and consults ONLY + // baseStudioActivates: ≥1 serving peer → the observation routes mount; + // ZERO serving peers → nothing mounts → byte-identical (H3). NO FleetPlane + // / hub proxy / multiplex is attached on this path. + fleet = { + fleetPeers: buildFleetPeerProvider({ localMachineId: opts.localMachineId }), + hub: { getUrl: () => undefined }, + observationOnly: true, + }; } const service = createAmicodeService({ engine: opts.engine, @@ -344,15 +367,17 @@ export async function startAmicodeService( // input is a NAMED outcome (which reason), never a silent no-op. const fleetInput = fleet ?? opts.fleet; const fleetNote = - fleetInput !== undefined - ? `; ${fleetStagingSummary( - stageFleetDataPlane({ - entitlements: fleetInput.entitlements, - entitlementConfigDir: fleetInput.entitlementConfigDir, - overlaySource: fleetInput.overlaySource, - }), - )}` - : ""; + fleetInput === undefined + ? "" + : fleetInput.observationOnly === true + ? "; fleet observation-only (base peer-studio; hub-activation absent — no premium plane)" + : `; ${fleetStagingSummary( + stageFleetDataPlane({ + entitlements: fleetInput.entitlements, + entitlementConfigDir: fleetInput.entitlementConfigDir, + overlaySource: fleetInput.overlaySource, + }), + )}`; log.appendLine( `[amicode-service] listening on ${url.toString()} (${service.routeCount} routes; auth: ${authNote})${engineNote}${shelfNote}${activationNote}${transportNote}${fleetNote}`, ); 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 4b952a59..306114e1 100644 --- a/packages/extension/test/amicode_service_fleet_data_plane.test.ts +++ b/packages/extension/test/amicode_service_fleet_data_plane.test.ts @@ -21,6 +21,7 @@ import { createAmicodeService } from "../src/amicode_service"; import { startAmicodeService } from "../src/amicode_service_wiring"; import type { AmicodeServiceBoot } from "../src/amicode_service_wiring"; import type { FleetActivation } from "../src/fleet_activation"; +import { resolveFleetActivation } from "../src/fleet_activation"; import { serverAuthToken, serverAuthHeader } from "../src/server_auth"; import { fleetHubFile, @@ -2097,3 +2098,327 @@ describe("#1478 base peer-studio activation — the real wiring seam (AC1 produc } }); }); + +// ══════════════════════════════════════════════════════════════════════════════ +// #1524 — OBSERVATION-ONLY base peer-studio WITHOUT hub-activation. The base +// peer-observation routes (/amicode/fleet/status + /amicode/fleet/sessions) must +// mount whenever a fleet-peer provider resolves ≥1 serving peer, INDEPENDENT of +// hub-activation (amicode.fleetHubUrl/fleetTunnelAlias unset → activation NOT +// armed). Root cause: startAmicodeService built opts.fleet ONLY inside the armed +// block, and baseStudioActivates (#1478) was nested inside the premium staging +// gate — so an unarmed machine with a valid peer roster + reader tokens could +// not observe its peers at all (fleet/sessions 404'd on a correct roster). +// +// Fix seam: an explicit `observationOnly` fleet signal. When set, +// createAmicodeService BYPASSES stageFleetDataPlane / the premium plane entirely +// (AC5 — no premium plane even if an entitlement is resolvable) and consults ONLY +// baseStudioActivates to mount the observation routes. observationOnly undefined +// → today's exact path (byte-identical, H3). The wiring builds this minimal fleet +// (fleetPeers + null hub + observationOnly) when activation is unarmed but a +// localMachineId is present; zero serving peers → nothing mounts (AC2/H3). +// ══════════════════════════════════════════════════════════════════════════════ + +describe("#1524 observation-only base peer-studio — routes mount without hub-activation (createAmicodeService seam)", () => { + let root: string; + let dist: string; + let overlaySource: string; + let localEngine: MockOrigin; + let studioPeer: MockOrigin; + let engineToken: string; + let savedHubFile: string | undefined; + + const ROSTER_1524: Record = { + "jjs-macbook-pro": { name: "JJ's MacBook Pro", device_type: "laptop" }, + "jjs-mac-studio": { name: "JJ's Mac Studio", device_type: "desktop" }, + }; + + // A serving-peer provider: one reachable serving peer beyond self, with a + // valid reader token — the live-machine shape from the bug report. + function servingPeerProvider() { + return { + localMachineId: "jjs-macbook-pro", + getServingPeers: () => [{ machineId: "jjs-mac-studio" }], + readPeerToken: (id: string) => + id === "jjs-mac-studio" + ? { ok: true as const, credential: { baseUrl: studioPeer.url, token: "tok-studio-1524" } } + : { ok: false as const }, + rosterLookup: (id: string) => ROSTER_1524[id], + }; + } + + // A provider with ZERO serving peers beyond self (the fleet-of-one / no-peer + // shape — the AC2 byte-identity case). + function noPeerProvider() { + return { + localMachineId: "jjs-macbook-pro", + getServingPeers: () => [] as Array<{ machineId: string }>, + readPeerToken: () => ({ ok: false as const }), + rosterLookup: (id: string) => ROSTER_1524[id], + }; + } + + function bootObservationOnly(fleet: Parameters[0]["fleet"]) { + return createAmicodeService({ + password: "service-own-mint", + engine: { password: "engine-mint-password", getUrl: () => localEngine.url }, + shelf: { distRoot: dist }, + fleet, + }); + } + + beforeAll(async () => { + root = mkdtempSync(join(tmpdir(), "amicode-1524-")); + dist = buildMockDist(root); + overlaySource = join(root, "overlay-source"); + writeDataPlaneManifest(overlaySource); // lawful overlay — present + entitled in AC5, still must NOT stage + localEngine = await startMockEngine([{ id: "ses-1524-local", title: "local", time: { created: 1, updated: 2 } }]); + studioPeer = await startMockPeer( + [{ id: "ses-1524-studio", title: "studio", time: { created: 3, updated: 4 } }], + "tok-studio-1524", + ); + engineToken = serverAuthToken("engine-mint-password"); + savedHubFile = process.env.AMICO_FLEET_HUB_FILE; + process.env.AMICO_FLEET_HUB_FILE = join(root, "hub-cred-absent.json"); + }); + + afterAll(async () => { + await localEngine.stop(); + await studioPeer.stop(); + if (savedHubFile === undefined) delete process.env.AMICO_FLEET_HUB_FILE; + else process.env.AMICO_FLEET_HUB_FILE = savedHubFile; + rmSync(root, { recursive: true, force: true }); + }); + + it("AC1: observationOnly + a serving peer + NO hub-activation mounts /amicode/fleet/sessions AND /amicode/fleet/status (200, not 404)", async () => { + const svc = bootObservationOnly({ + // NO entitlement, NO hub — the unarmed base-peer shape + hub: { getUrl: () => undefined }, + observationOnly: true, + fleetPeers: servingPeerProvider(), + }); + const o = (await svc.start()).toString().replace(/\/$/, ""); + try { + const status = await fetch(`${o}/amicode/fleet/status`, { headers: { Authorization: `Basic ${engineToken}` } }); + expect(status.status).toBe(200); + const sbody = (await status.json()) as { ok: boolean; mode: string }; + expect(sbody.ok).toBe(true); + expect(sbody.mode).toBe("engine"); // a base peer has no hub → honest engine routing + + const sessions = await fetch(`${o}/amicode/fleet/sessions`, { headers: { Authorization: `Basic ${engineToken}` } }); + expect(sessions.status).toBe(200); + const body = (await sessions.json()) as FleetProjection; + expect(body.sources["jjs-mac-studio"].present).toBe(true); + expect(body.sources["jjs-macbook-pro"].present).toBe(true); + } finally { + await svc.stop(); + } + }); + + it("AC2: observationOnly with ZERO serving peers is byte-identical to a base service (fleet routes 404, H3)", async () => { + const base = createAmicodeService({ + password: "service-own-mint", + engine: { password: "engine-mint-password", getUrl: () => localEngine.url }, + shelf: { distRoot: dist }, + }); + const obs = bootObservationOnly({ + hub: { getUrl: () => undefined }, + observationOnly: true, + fleetPeers: noPeerProvider(), + }); + const baseO = (await base.start()).toString().replace(/\/$/, ""); + const obsO = (await obs.start()).toString().replace(/\/$/, ""); + const paths: Array<{ p: string; auth?: boolean; accept?: string }> = [ + { p: "/", accept: "text/html" }, + { p: "/assets/app.js" }, + { p: "/amicode/profile", auth: true }, + { p: "/session", auth: true }, + { p: "/amicode/nope", auth: true }, + { p: "/amicode/fleet/sessions", auth: true }, + { p: "/amicode/fleet/status", auth: true }, + ]; + async function cap(origin: string) { + const out: Array<{ p: string; status: number; body: string }> = []; + for (const r of paths) { + const res = await fetch(`${origin}${r.p}`, { + headers: { + ...(r.accept ? { Accept: r.accept } : {}), + ...(r.auth ? { Authorization: `Basic ${engineToken}` } : {}), + }, + }); + out.push({ p: r.p, status: res.status, body: await res.text() }); + } + return out; + } + try { + const baseCap = await cap(baseO); + const obsCap = await cap(obsO); + // H3: the observation-only-with-no-peers boot is byte-identical to base. + expect(obsCap).toEqual(baseCap); + // and explicitly: the fleet routes 404 with the base no-route shape. + const s = obsCap.find((x) => x.p === "/amicode/fleet/sessions")!; + expect(s.status).toBe(404); + expect(JSON.parse(s.body)).toEqual({ ok: false, error: "no route: GET /amicode/fleet/sessions" }); + } finally { + await base.stop(); + await obs.stop(); + } + }); + + it("AC3: with a serving peer + valid reader token, /amicode/fleet/sessions returns the peer's session tagged amicode_owner (is_local:false)", async () => { + const svc = bootObservationOnly({ + hub: { getUrl: () => undefined }, + observationOnly: true, + fleetPeers: servingPeerProvider(), + }); + const o = (await svc.start()).toString().replace(/\/$/, ""); + try { + const res = await fetch(`${o}/amicode/fleet/sessions`, { headers: { Authorization: `Basic ${engineToken}` } }); + expect(res.status).toBe(200); + const body = (await res.json()) as FleetProjection; + const studioSes = body.sessions.find((s) => s.id === "ses-1524-studio") as + | (Record & { amicode_owner?: SessionOwnerTag }) + | undefined; + expect(studioSes).toBeDefined(); + expect(studioSes!.amicode_owner).toMatchObject({ + owner_machine_id: "jjs-mac-studio", + owner_name: "JJ's Mac Studio", + is_local: false, + }); + } finally { + await svc.stop(); + } + }); + + it("AC5: observationOnly stages NO premium plane even with a resolvable entitlement + lawful overlay (receipt absent/not-staged, mode engine)", async () => { + const svc = bootObservationOnly({ + // an entitlement IS resolvable AND the overlay IS lawful — a premium boot + // WOULD stage. observationOnly must bypass staging entirely (no forgery, + // no premium control authority): the multiplex/hub-proxy path is untouched. + entitlements: ["amicissimo"], + overlaySource, + hub: { getUrl: () => undefined }, + observationOnly: true, + getMode: () => "fleet", // even a fleet getMode must not confer premium routing + fleetPeers: servingPeerProvider(), + }); + const o = (await svc.start()).toString().replace(/\/$/, ""); + try { + const status = await fetch(`${o}/amicode/fleet/status`, { headers: { Authorization: `Basic ${engineToken}` } }); + expect(status.status).toBe(200); + const sbody = (await status.json()) as { mode: string; staging: { entitlement: string; staged: boolean } }; + // NO premium plane staged despite the resolvable entitlement… + expect(sbody.staging.entitlement).toBe("absent"); + expect(sbody.staging.staged).toBe(false); + // …and the routing mode is the base engine posture, never premium fleet routing. + expect(sbody.mode).toBe("engine"); + } finally { + await svc.stop(); + } + }); +}); + +describe("#1524 observation-only base peer-studio — the real wiring seam (unarmed activation)", () => { + let root: string; + let dist: string; + let localEngine: MockOrigin; + let studioPeer: MockOrigin; + let engineToken: string; + const savedEnv: Record = {}; + + beforeAll(async () => { + root = mkdtempSync(join(tmpdir(), "amicode-1524-wire-")); + dist = buildMockDist(root); + localEngine = await startMockEngine([{ id: "ses-1524w-local", title: "local", time: { created: 1, updated: 2 } }]); + studioPeer = await startMockPeer( + [{ id: "ses-1524w-studio", title: "studio", time: { created: 3, updated: 4 } }], + "tok-studio-1524w", + ); + const rosterFile = join(root, "roster.json"); + writeFileSync( + rosterFile, + JSON.stringify({ + schema_version: 1, + rows: [ + w0RosterRow({ id: "jjs-macbook-pro", name: "MacBook Pro", serving: true, reachable: true, device_type: "laptop" }), + w0RosterRow({ id: "jjs-mac-studio", name: "Mac Studio", serving: true, reachable: true, device_type: "desktop" }), + ], + }), + ); + const peerStoreFile = join(root, "peer-tokens.json"); + writePeerToken("jjs-mac-studio", { baseUrl: studioPeer.url, token: "tok-studio-1524w" }, { storeFile: peerStoreFile }); + for (const k of ["AMICO_FLEET_ROSTER_FILE", "AMICO_FLEET_PEER_TOKEN_FILE", "AMICO_FLEET_HUB_FILE"]) savedEnv[k] = process.env[k]; + process.env.AMICO_FLEET_ROSTER_FILE = rosterFile; + process.env.AMICO_FLEET_PEER_TOKEN_FILE = peerStoreFile; + process.env.AMICO_FLEET_HUB_FILE = join(root, "hub-cred-absent.json"); + engineToken = serverAuthToken("engine-mint-password"); + }); + + afterAll(async () => { + await localEngine.stop(); + await studioPeer.stop(); + for (const [k, v] of Object.entries(savedEnv)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + rmSync(root, { recursive: true, force: true }); + }); + + it("AC1 (production): an UNARMED activation + localMachineId + a serving-peer roster mounts the base observation routes (200, not 404)", async () => { + // the EXACT live scenario: valid roster + reader tokens, NO amicode.fleetHubUrl + // / fleetTunnelAlias → resolveFleetActivation is not armed. + const boot = await startAmicodeService(w2Sink(), { + engine: { password: "engine-mint-password", getUrl: () => localEngine.url }, + appDistRoot: dist, + fleetActivation: () => resolveFleetActivation({ config: {}, env: {} }), // unarmed — no hub config + localMachineId: "jjs-macbook-pro", + }); + expect(boot).toBeDefined(); + if (!boot) return; + try { + const status = await fetch(`${boot.url}/amicode/fleet/status`, { headers: { Authorization: `Basic ${engineToken}` } }); + expect(status.status).toBe(200); + const sbody = (await status.json()) as { staging: { entitlement: string; staged: boolean } }; + // no premium forgery through the REAL wiring — the receipt reads absent + expect(sbody.staging.entitlement).toBe("absent"); + expect(sbody.staging.staged).toBe(false); + + const sessions = await fetch(`${boot.url}/amicode/fleet/sessions`, { headers: { Authorization: `Basic ${engineToken}` } }); + expect(sessions.status).toBe(200); + const body = (await sessions.json()) as FleetProjection; + expect(body.sources["jjs-mac-studio"].present).toBe(true); + expect(body.sessions.some((s) => s.id === "ses-1524w-studio")).toBe(true); + } finally { + await boot.service.stop(); + } + }); + + it("AC2 (production): an UNARMED activation + localMachineId but NO serving peers keeps the routes 404 (byte-identical, H3)", async () => { + // a roster whose only serving∧reachable row is self → getServingPeers() === [] + // → baseStudioActivates false → nothing mounts → the base no-route 404. + const rosterFile = join(root, "roster-empty.json"); + writeFileSync( + rosterFile, + JSON.stringify({ + schema_version: 1, + rows: [w0RosterRow({ id: "jjs-macbook-pro", name: "MacBook Pro", serving: true, reachable: true })], + }), + ); + process.env.AMICO_FLEET_ROSTER_FILE = rosterFile; + const boot = await startAmicodeService(w2Sink(), { + engine: { password: "engine-mint-password", getUrl: () => localEngine.url }, + appDistRoot: dist, + fleetActivation: () => resolveFleetActivation({ config: {}, env: {} }), + localMachineId: "jjs-macbook-pro", + }); + expect(boot).toBeDefined(); + if (!boot) return; + try { + const status = await fetch(`${boot.url}/amicode/fleet/status`, { headers: { Authorization: `Basic ${engineToken}` } }); + expect(status.status).toBe(404); + const sessions = await fetch(`${boot.url}/amicode/fleet/sessions`, { headers: { Authorization: `Basic ${engineToken}` } }); + expect(sessions.status).toBe(404); + } finally { + await boot.service.stop(); + } + }); +});