Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions packages/amico-run/src/fleet_enroll_verb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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,
Expand Down
50 changes: 48 additions & 2 deletions packages/amico-run/test/fleet_enroll_verb.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -160,13 +165,15 @@ interface Recorder {
installerCalls: string[];
transportSets: string[];
engine: { spawns: number };
authorityRecords: LifecycleAuthorityRecord[];
}
function recorder(over: Partial<FleetEnrollDeps> = {}): Recorder {
const fleetWrites: { config: unknown; path: string }[] = [];
const joinTokenWrites: { path: string; token: JoinToken }[] = [];
const installerCalls: string[] = [];
const transportSets: string[] = [];
const engine = { spawns: 0 };
const authorityRecords: LifecycleAuthorityRecord[] = [];
const deps: FleetEnrollDeps = {
machineId: () => "machine-abc",
machineName: () => "workbench",
Expand All @@ -191,9 +198,12 @@ function recorder(over: Partial<FleetEnrollDeps> = {}): 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[] = [];
Expand Down Expand Up @@ -358,6 +368,42 @@ describe("amico fleet enroll <join-token> — 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
Expand Down
22 changes: 13 additions & 9 deletions packages/app-bundle/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
},
Expand Down Expand Up @@ -944,7 +946,7 @@
}
},
"counts": {
"overlay_total": 873,
"overlay_total": 875,
"deletions": 7,
"server_coupled": 0
},
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading