@consiliency/agent-board-cli 1.9.0 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.d.ts +43 -1
- package/dist/cli.js +772 -30
- package/dist/enroll_runtime.d.ts +67 -1
- package/dist/enroll_runtime.js +259 -1
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { Command, Option } from "commander";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { createAgentBoardClient, toOperationFailure, beginEnrollment, completeEnrollment, beginDeviceEnrollment, completeDeviceEnrollment, runEnrollment, runDeviceEnrollment, resolveEnrollmentDescriptor, EnrollmentDescriptorError, EnrollmentCommandError, resolveEnrollEndpoint, resolveEnrollSessionUrl, resolveEnrollPublishableKey, resolveJoinDeployment, buildFleetHarnessTemplate, finalizeFleetHarnessTemplate, normalizePublishableKeyFieldReference, normalizeKeyReference, enrollCommitMarker, buildAgentJoinInstructions, nodeFetchAdapter, buildEnrollKeystore, } from "@consiliency/agent-board-client";
|
|
6
|
+
import { DevicePendingEnrollmentStore, FilePendingEnrollmentStore, FleetDeviceContinuationStore, makeBrowserOpener, prepareFleetRuntimeOutput, publishFinalizedFleetTemplate, } from "./enroll_runtime.js";
|
|
4
7
|
function isRuntimeStatus(value) {
|
|
5
8
|
return Boolean(value && typeof value === "object" && "runtime" in value);
|
|
6
9
|
}
|
|
@@ -13,13 +16,200 @@ function parseJson(value) {
|
|
|
13
16
|
function numberOption(flags, description) {
|
|
14
17
|
return new Option(flags, description).argParser((value) => Number(value));
|
|
15
18
|
}
|
|
16
|
-
|
|
19
|
+
/**
|
|
20
|
+
* A fleet failure whose message this CLI CONSTRUCTED and therefore knows to be free of
|
|
21
|
+
* bearer codes, key material and local key handles. The blanket fleet redaction below
|
|
22
|
+
* exists because a raw error can carry an `os_keychain[<handle>]` descriptor; it must
|
|
23
|
+
* not also flatten the messages we wrote ourselves, or an actionable pre-flight refusal
|
|
24
|
+
* reaches the operator as an unactionable "failed before completion".
|
|
25
|
+
*/
|
|
26
|
+
class FleetSafeMessageError extends Error {
|
|
27
|
+
code;
|
|
28
|
+
constructor(code, message) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.code = code;
|
|
31
|
+
this.name = "FleetSafeMessageError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Raised BEFORE any server call — the enrollment has not committed and the bounded
|
|
36
|
+
* invite use is not spent. The message names the environment variable, never its value.
|
|
37
|
+
*/
|
|
38
|
+
class FleetPreflightError extends FleetSafeMessageError {
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Raised AFTER the server committed the enrollment: identity, signing key, harness
|
|
42
|
+
* registration, repo scope, fleet binding and membership exist and the bounded invite
|
|
43
|
+
* use is SPENT. Reporting such a failure as "before completion" is the
|
|
44
|
+
* Consiliency/message-board#176 shape — it tells the operator to retry an enrollment
|
|
45
|
+
* that already happened, against an invite that may have no uses left.
|
|
46
|
+
*
|
|
47
|
+
* The detail is withheld unless the cause is itself a message we constructed: a raw
|
|
48
|
+
* post-commit error can carry a local key descriptor, which is exactly what the
|
|
49
|
+
* blanket redaction is for.
|
|
50
|
+
*/
|
|
51
|
+
class FleetPostCommitError extends FleetSafeMessageError {
|
|
52
|
+
constructor(committedFacts, cause) {
|
|
53
|
+
// Only the ids that survived. Region-1 facts are deliberately sparse — that region
|
|
54
|
+
// renders what this process KNEW rather than what the response echoed — so a fixed
|
|
55
|
+
// four-field list would print `agentId=,` and read as data while carrying none.
|
|
56
|
+
const committed = renderIds(committedFacts) || "(the receipt carried no id we could verify)";
|
|
57
|
+
const detail = cause instanceof FleetSafeMessageError
|
|
58
|
+
? cause.message
|
|
59
|
+
: `detail withheld (a raw local failure can carry key descriptors)${namedCauseCode(cause)}`;
|
|
60
|
+
super("fleet_onboarding_committed_local_finalization_failed", "fleet enrollment COMMITTED on the server; only local finalization failed. "
|
|
61
|
+
+ "Do NOT treat this as a failed enrollment and do NOT re-run it — the bounded invite use is spent. "
|
|
62
|
+
+ `Committed: ${committed}. Local finalization failure: ${detail}`);
|
|
63
|
+
this.cause = cause;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Name WHICH check failed without letting the cause's free text across the boundary.
|
|
68
|
+
* The enrollment codes are a closed vocabulary we author (`fleet_onboarding_contract_
|
|
69
|
+
* mismatch` et al.), so an operator facing a rollout skew learns something actionable;
|
|
70
|
+
* the shape gate is what keeps it from becoming a message channel. It is a gate on the
|
|
71
|
+
* VALUE, not an `instanceof EnrollError`: a cross-package `instanceof` is silently
|
|
72
|
+
* false when the workspace holds two copies of the client module.
|
|
73
|
+
*/
|
|
74
|
+
function namedCauseCode(cause) {
|
|
75
|
+
if (!cause || typeof cause !== "object")
|
|
76
|
+
return "";
|
|
77
|
+
const code = cause.code;
|
|
78
|
+
// Lower-case snake_case only: our own codes match, and a key descriptor
|
|
79
|
+
// (`os_keychain[handle]`), a path, or an upper-case OS errno does not.
|
|
80
|
+
if (typeof code !== "string" || !/^[a-z][a-z0-9_]{0,63}$/.test(code))
|
|
81
|
+
return "";
|
|
82
|
+
return ` [code ${code}]`;
|
|
83
|
+
}
|
|
84
|
+
function committedFactsFromOutcome(outcome) {
|
|
85
|
+
return {
|
|
86
|
+
agentId: outcome.agentId,
|
|
87
|
+
agentHandle: outcome.agentHandle,
|
|
88
|
+
keyId: outcome.keyId,
|
|
89
|
+
boardId: outcome.onboarding?.boardId ?? "",
|
|
90
|
+
machineId: outcome.onboarding?.machineId ?? "",
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Everything inside `action` runs against a COMMITTED server enrollment. Marks any
|
|
95
|
+
* failure so the redaction below reports it truthfully instead of asserting it happened
|
|
96
|
+
* before completion.
|
|
97
|
+
*/
|
|
98
|
+
async function afterFleetCommit(outcome, action) {
|
|
99
|
+
try {
|
|
100
|
+
return await action();
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
throw error instanceof FleetPostCommitError ? error : new FleetPostCommitError(committedFactsFromOutcome(outcome), error);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Render only the ids that are actually present. An all-empty list (`agentId=,
|
|
108
|
+
* agentHandle=,`) is the shape both messages exist to avoid printing: it reads as
|
|
109
|
+
* evidence while carrying none.
|
|
110
|
+
*/
|
|
111
|
+
function renderIds(facts) {
|
|
112
|
+
// Gated HERE, at the single render point, rather than at each producer. There are two
|
|
113
|
+
// producers of these facts — the client's marker (already normalized) and
|
|
114
|
+
// `committedFactsFromOutcome` below (straight from the outcome, never normalized) —
|
|
115
|
+
// and the type carries a stated invariant that a non-id-shaped value is not rendered.
|
|
116
|
+
// A per-producer gate makes that invariant a thing each new producer must remember;
|
|
117
|
+
// gating the render makes it hold for producers that do not exist yet. Without it a
|
|
118
|
+
// poisoned `agent_id` in an otherwise contract-valid receipt reaches stdout verbatim,
|
|
119
|
+
// newlines and all.
|
|
120
|
+
const printable = (value) => /^[A-Za-z0-9._:@/+=-]{1,128}$/.test(value);
|
|
121
|
+
// `agentId` gets a TYPED gate, not the generic one, and the asymmetry is deliberate.
|
|
122
|
+
//
|
|
123
|
+
// Of the four fields, three are already tied to something this process sent: the
|
|
124
|
+
// receipt check requires `board_id`/`machine_id`/`agent_handle` to EQUAL the
|
|
125
|
+
// authorized tuple, so a contract-valid receipt cannot carry a surprise there — the
|
|
126
|
+
// validation does what region 1 achieves by construction. `agent_id` is the one field
|
|
127
|
+
// the contract constrains only as non-empty, so it is the one place a receipt can
|
|
128
|
+
// carry an arbitrary server-chosen string into an operator's terminal, including a
|
|
129
|
+
// value we ourselves sent (the invite code) echoed back.
|
|
130
|
+
//
|
|
131
|
+
// The schema declares it: `agents.id uuid primary key`. So a non-UUID `agent_id` is
|
|
132
|
+
// off-contract, and rendering it as an agent id would be rendering something this
|
|
133
|
+
// field cannot legitimately be. This is a type check derived from the contract, not a
|
|
134
|
+
// guess at what "looks like an id".
|
|
135
|
+
//
|
|
136
|
+
// Residual, stated rather than implied: an on-path attacker who can forge a
|
|
137
|
+
// contract-valid receipt can also supply a well-formed UUID. No client-side check
|
|
138
|
+
// closes that; it needs a signature over the response.
|
|
139
|
+
const isUuid = (value) => /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(value);
|
|
140
|
+
return [
|
|
141
|
+
["agentId", facts.agentId, isUuid],
|
|
142
|
+
["agentHandle", facts.agentHandle, printable],
|
|
143
|
+
["boardId", facts.boardId, printable],
|
|
144
|
+
["machineId", facts.machineId, printable],
|
|
145
|
+
].filter(([, value, ok]) => value !== "" && ok(value)).map(([key, value]) => `${key}=${value}`).join(", ");
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Raised when the server's answer was OFF-CONTRACT, so whether it committed is unknown.
|
|
149
|
+
* This is the third state, and it exists because both of the other two would be a lie:
|
|
150
|
+
* "COMMITTED" tells the operator not to retry an enrollment that may never have
|
|
151
|
+
* happened and to write off an invite use that may be unspent, and "failed before
|
|
152
|
+
* completion" is the #176 shape. The operator is told what to verify instead of what to
|
|
153
|
+
* assume. As with the post-commit error, the cause's free text is withheld.
|
|
154
|
+
*/
|
|
155
|
+
class FleetIndeterminateError extends FleetSafeMessageError {
|
|
156
|
+
constructor(marker, cause) {
|
|
157
|
+
// Only the ids that actually survived. An all-empty list (`agentId=, agentHandle=`)
|
|
158
|
+
// is the shape this state exists to stop printing — it reads as data while carrying
|
|
159
|
+
// none, and on the off-contract path there is frequently nothing to show.
|
|
160
|
+
const rendered = renderIds(marker.facts);
|
|
161
|
+
const observed = rendered
|
|
162
|
+
? ` What this enrollment asked for, to verify against: ${rendered}.`
|
|
163
|
+
: " Nothing about this enrollment could be corroborated, so there is no id here to verify against.";
|
|
164
|
+
super("fleet_onboarding_commit_indeterminate",
|
|
165
|
+
// Covers both shapes: an off-contract status (201/202/204), and a 200 that carried
|
|
166
|
+
// no /enroll identity at all — a proxy or maintenance page the origin never saw.
|
|
167
|
+
`fleet enrollment status is UNKNOWN: the server answered HTTP ${marker.status ?? "(unknown)"} without a usable /enroll receipt. `
|
|
168
|
+
+ "The enrollment MAY or MAY NOT have committed, and the bounded invite use may or may not be spent. "
|
|
169
|
+
+ "Do NOT re-run it blindly: verify first whether the agent identity now exists on the board, and enroll again only if it does not."
|
|
170
|
+
+ observed);
|
|
171
|
+
this.cause = cause;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* The enrollment call ITSELF (Consiliency/message-board#202). `afterFleetCommit` can only
|
|
176
|
+
* fence what follows the runner, but the runner commits INSIDE its own call: the /enroll
|
|
177
|
+
* POST returns 200 and the contract checks, receipt check and pending-store cleanup all
|
|
178
|
+
* run after it. Such a failure surfaced out of the runner, missed the fence entirely, and
|
|
179
|
+
* was flattened into "failed before completion" — telling the operator to re-run an
|
|
180
|
+
* enrollment that already happened, against an invite whose bounded use is spent.
|
|
181
|
+
*
|
|
182
|
+
* The client marks those errors at the 200-response boundary, where the truth is known;
|
|
183
|
+
* this converts the marker into the CLI's own truthful message. Errors with no marker are
|
|
184
|
+
* genuinely pre-commit and keep the blanket redaction. `runHandler` is unchanged — it
|
|
185
|
+
* still exempts exactly `FleetSafeMessageError` and nothing else.
|
|
186
|
+
*/
|
|
187
|
+
async function throughFleetCommit(call) {
|
|
188
|
+
try {
|
|
189
|
+
return await call();
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
const marker = enrollCommitMarker(error);
|
|
193
|
+
if (!marker)
|
|
194
|
+
throw error;
|
|
195
|
+
throw marker.state === "indeterminate"
|
|
196
|
+
? new FleetIndeterminateError(marker, error)
|
|
197
|
+
: new FleetPostCommitError(marker.facts, error);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
async function runHandler(io, operation, action, redactFleetFailure = false) {
|
|
17
201
|
try {
|
|
18
202
|
const result = await action();
|
|
19
203
|
io.writeStdout(`${JSON.stringify(result, null, 2)}\n`);
|
|
20
204
|
}
|
|
21
205
|
catch (error) {
|
|
22
|
-
|
|
206
|
+
const failure = toOperationFailure(operation, error);
|
|
207
|
+
// Redact only what we did not write. A post-commit failure keeps its truthful
|
|
208
|
+
// message; a pre-flight refusal keeps the variable name the operator must set.
|
|
209
|
+
if (redactFleetFailure && !(error instanceof FleetSafeMessageError)) {
|
|
210
|
+
failure.error.message = "fleet enrollment failed before completion";
|
|
211
|
+
}
|
|
212
|
+
io.writeStdout(`${JSON.stringify(failure, null, 2)}\n`);
|
|
23
213
|
process.exitCode = 1;
|
|
24
214
|
}
|
|
25
215
|
}
|
|
@@ -1212,6 +1402,10 @@ const processWriter = {
|
|
|
1212
1402
|
writeStdout: (message) => process.stdout.write(message),
|
|
1213
1403
|
writeStderr: (message) => process.stderr.write(message),
|
|
1214
1404
|
};
|
|
1405
|
+
function defaultDevicePendingStateDir(env) {
|
|
1406
|
+
const configured = env.XDG_STATE_HOME?.trim();
|
|
1407
|
+
return join(configured || join(homedir(), ".local", "state"), "agent-board", "pending-device-enroll");
|
|
1408
|
+
}
|
|
1215
1409
|
// The default runner drives the live enrollment flow: `run` = one-shot loopback +
|
|
1216
1410
|
// browser opener; `begin`/`complete` = headless OOB two-step bridged by a
|
|
1217
1411
|
// file-backed store (loopback cannot span two CLI processes). Every seam is
|
|
@@ -1227,7 +1421,9 @@ export function defaultEnrollmentRunner(env = process.env, deps = {}) {
|
|
|
1227
1421
|
async run(input) {
|
|
1228
1422
|
const descriptor = resolveEnrollmentDescriptor(env);
|
|
1229
1423
|
const enrollEndpoint = deps.enrollEndpoint ?? resolveEnrollEndpoint(env);
|
|
1230
|
-
const keystore = deps.keystore ?? buildEnrollKeystore(descriptor, env
|
|
1424
|
+
const keystore = deps.keystore ?? buildEnrollKeystore(descriptor, env, {
|
|
1425
|
+
...(input.intent.kind === "fleet_onboarding" ? { fleetOnboarding: input.intent } : {}),
|
|
1426
|
+
});
|
|
1231
1427
|
const openAuthorization = deps.openAuthorization ?? makeBrowserOpener(deps.io ?? processWriter);
|
|
1232
1428
|
return runEnrollment(descriptor, input.intent, {
|
|
1233
1429
|
slug: input.slug,
|
|
@@ -1261,7 +1457,9 @@ export function defaultEnrollmentRunner(env = process.env, deps = {}) {
|
|
|
1261
1457
|
// Supabase board — pinned by the device analogue of the `run` e2e (drop either
|
|
1262
1458
|
// and the sessionUrl-override / anon-apikey assertion fails).
|
|
1263
1459
|
const enrollEndpoint = deps.enrollEndpoint ?? resolveEnrollEndpoint(env);
|
|
1264
|
-
const keystore = deps.keystore ?? buildEnrollKeystore(descriptor, env
|
|
1460
|
+
const keystore = deps.keystore ?? buildEnrollKeystore(descriptor, env, {
|
|
1461
|
+
...(input.intent.kind === "fleet_onboarding" ? { fleetOnboarding: input.intent } : {}),
|
|
1462
|
+
});
|
|
1265
1463
|
return runDeviceEnrollment(descriptor, input.intent, {
|
|
1266
1464
|
slug: input.slug,
|
|
1267
1465
|
agentKind: input.agentKind,
|
|
@@ -1280,6 +1478,157 @@ export function defaultEnrollmentRunner(env = process.env, deps = {}) {
|
|
|
1280
1478
|
now: deps.now,
|
|
1281
1479
|
});
|
|
1282
1480
|
},
|
|
1481
|
+
async deviceBegin(input) {
|
|
1482
|
+
const descriptor = resolveEnrollmentDescriptor(env);
|
|
1483
|
+
if (!descriptor.issuer.deviceAuthorizationEndpoint) {
|
|
1484
|
+
throw new EnrollmentDescriptorError("missing_device_endpoint", "device enrollment needs a device authorization endpoint (set MESSAGE_BOARD_ENROLL_DEVICE_AUTHORIZATION_ENDPOINT)");
|
|
1485
|
+
}
|
|
1486
|
+
const continuation = await beginDeviceEnrollment(descriptor, input.intent, {
|
|
1487
|
+
fetch,
|
|
1488
|
+
enrollEndpoint: deps.enrollEndpoint ?? resolveEnrollEndpoint(env),
|
|
1489
|
+
keystore: deps.keystore ?? buildEnrollKeystore(descriptor, env, {
|
|
1490
|
+
...(input.intent.kind === "fleet_onboarding" ? { fleetOnboarding: input.intent } : {}),
|
|
1491
|
+
}),
|
|
1492
|
+
boardFetch,
|
|
1493
|
+
sessionUrl,
|
|
1494
|
+
publishableKey,
|
|
1495
|
+
onDeviceAuthorization: async () => { },
|
|
1496
|
+
now: deps.now,
|
|
1497
|
+
});
|
|
1498
|
+
const enrollmentId = input.enrollmentId ?? `enr-${randomUUID()}`;
|
|
1499
|
+
const store = deps.deviceStore ?? new DevicePendingEnrollmentStore(defaultDevicePendingStateDir(env));
|
|
1500
|
+
await store.put({
|
|
1501
|
+
flow: "device_code",
|
|
1502
|
+
enrollmentId,
|
|
1503
|
+
deviceCode: continuation.deviceCode,
|
|
1504
|
+
interval: continuation.prompt.interval,
|
|
1505
|
+
expiresAt: continuation.expiresAt,
|
|
1506
|
+
expiresIn: continuation.prompt.expiresIn,
|
|
1507
|
+
userCode: continuation.prompt.userCode,
|
|
1508
|
+
verificationUri: continuation.prompt.verificationUri,
|
|
1509
|
+
verificationUriComplete: continuation.prompt.verificationUriComplete,
|
|
1510
|
+
intent: input.intent,
|
|
1511
|
+
slug: input.slug,
|
|
1512
|
+
agentKind: input.agentKind,
|
|
1513
|
+
requestedScopes: input.requestedScopes,
|
|
1514
|
+
requestedBoardScopes: input.requestedBoardScopes,
|
|
1515
|
+
});
|
|
1516
|
+
return { enrollmentId, prompt: continuation.prompt };
|
|
1517
|
+
},
|
|
1518
|
+
async deviceComplete(input) {
|
|
1519
|
+
const store = deps.deviceStore ?? new DevicePendingEnrollmentStore(defaultDevicePendingStateDir(env));
|
|
1520
|
+
const pending = await store.get(input.enrollmentId);
|
|
1521
|
+
if (!pending) {
|
|
1522
|
+
throw new EnrollmentCommandError("unknown_device_enrollment", "device enrollment is unknown");
|
|
1523
|
+
}
|
|
1524
|
+
// Consiliency/message-board#210 — the READ door for the DEVICE store, symmetric with
|
|
1525
|
+
// `completeEnrollment`'s (enroll_command.ts). Keyed on the intent READ FROM THE
|
|
1526
|
+
// STORE, never on the caller's flag: `enroll device --enrollment-id <id>` without
|
|
1527
|
+
// --fleet-onboarding passes no `expectedFleet`, and every fleet check below is gated
|
|
1528
|
+
// on it, so a fleet pending would be handed to completeDeviceEnrollment and
|
|
1529
|
+
// COMMITTED with no publishable-key pre-flight, no runtime output, no finalization
|
|
1530
|
+
// and no post-commit fence — and then deleted below, destroying the correct resume's
|
|
1531
|
+
// own state.
|
|
1532
|
+
//
|
|
1533
|
+
// The pending is deliberately NOT deleted: the operator's recovery is to re-run the
|
|
1534
|
+
// same resume WITH the flag, and that needs this record. Deleting here would make
|
|
1535
|
+
// the refusal as destructive as the bug.
|
|
1536
|
+
//
|
|
1537
|
+
// This is the missing direction of a biconditional: `expectedFleet` present against
|
|
1538
|
+
// a non-fleet pending already throws below. Fleet-ness of the PENDING decides; the
|
|
1539
|
+
// flag must agree with it in both directions.
|
|
1540
|
+
//
|
|
1541
|
+
// Placement is load-bearing three ways: AFTER the `!pending` throw (an unknown id
|
|
1542
|
+
// keeps `unknown_device_enrollment`), BEFORE the descriptor/keystore/endpoint work
|
|
1543
|
+
// below (no local side effects), and BEFORE any network call (the device
|
|
1544
|
+
// authorization is not spent).
|
|
1545
|
+
if (pending.intent.kind === "fleet_onboarding" && !input.expectedFleet) {
|
|
1546
|
+
throw new FleetPreflightError("fleet_device_resume_requires_fleet_flag", "this pending device enrollment is a fleet onboarding and cannot be resumed without "
|
|
1547
|
+
+ "--fleet-onboarding: that flag is what performs the publishable-key pre-flight, the "
|
|
1548
|
+
+ "runtime output and the fleet finalization. Nothing was committed and the pending "
|
|
1549
|
+
+ "was kept. Re-run the same resume with --fleet-onboarding, the same bounded tuple "
|
|
1550
|
+
+ "(--board-id/--machine-id/--harness-kind/--harness-label/--repo-slug/"
|
|
1551
|
+
+ "--repo-fingerprint/--repo-scope-role/--agent-role/--expected-agent-handle/"
|
|
1552
|
+
+ "--local-key-id), --slug and --runtime-output.");
|
|
1553
|
+
}
|
|
1554
|
+
if (input.expectedFleet) {
|
|
1555
|
+
if (pending.intent.kind !== "fleet_onboarding") {
|
|
1556
|
+
throw new EnrollmentCommandError("fleet_device_intent_mismatch", "device enrollment is not a fleet enrollment");
|
|
1557
|
+
}
|
|
1558
|
+
const { inviteCode: _inviteCode, ...storedIntent } = pending.intent;
|
|
1559
|
+
if (JSON.stringify(storedIntent) !== JSON.stringify(input.expectedFleet.intent)) {
|
|
1560
|
+
throw new EnrollmentCommandError("fleet_device_intent_mismatch", "device enrollment fleet bounds do not match");
|
|
1561
|
+
}
|
|
1562
|
+
if (pending.slug !== input.expectedFleet.slug) {
|
|
1563
|
+
throw new EnrollmentCommandError("fleet_device_slug_mismatch", "device enrollment agent slug does not match");
|
|
1564
|
+
}
|
|
1565
|
+
const currentReference = normalizeKeyReference(resolveEnrollmentDescriptor(env).keyReference);
|
|
1566
|
+
if (`${currentReference.resolver}:${currentReference.handle}` !== input.expectedFleet.keyReference) {
|
|
1567
|
+
throw new EnrollmentCommandError("fleet_device_key_reference_mismatch", "device enrollment key reference does not match current configuration");
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
const descriptor = resolveEnrollmentDescriptor(env);
|
|
1571
|
+
if (!descriptor.issuer.deviceAuthorizationEndpoint) {
|
|
1572
|
+
throw new EnrollmentDescriptorError("missing_device_endpoint", "device enrollment needs a device authorization endpoint (set MESSAGE_BOARD_ENROLL_DEVICE_AUTHORIZATION_ENDPOINT)");
|
|
1573
|
+
}
|
|
1574
|
+
const keystore = deps.keystore ?? buildEnrollKeystore(descriptor, env, {
|
|
1575
|
+
...(pending.intent.kind === "fleet_onboarding" ? { fleetOnboarding: pending.intent } : {}),
|
|
1576
|
+
});
|
|
1577
|
+
const result = await completeDeviceEnrollment(descriptor, pending.intent, {
|
|
1578
|
+
slug: pending.slug,
|
|
1579
|
+
agentKind: pending.agentKind,
|
|
1580
|
+
requestedScopes: pending.requestedScopes,
|
|
1581
|
+
requestedBoardScopes: pending.requestedBoardScopes,
|
|
1582
|
+
}, {
|
|
1583
|
+
deviceCode: pending.deviceCode,
|
|
1584
|
+
expiresAt: pending.expiresAt,
|
|
1585
|
+
prompt: {
|
|
1586
|
+
userCode: pending.userCode,
|
|
1587
|
+
verificationUri: pending.verificationUri,
|
|
1588
|
+
verificationUriComplete: pending.verificationUriComplete,
|
|
1589
|
+
expiresIn: pending.expiresIn,
|
|
1590
|
+
interval: pending.interval,
|
|
1591
|
+
},
|
|
1592
|
+
}, {
|
|
1593
|
+
fetch,
|
|
1594
|
+
enrollEndpoint: deps.enrollEndpoint ?? resolveEnrollEndpoint(env),
|
|
1595
|
+
keystore,
|
|
1596
|
+
boardFetch,
|
|
1597
|
+
keystoreBackends: keystore.backends,
|
|
1598
|
+
sessionUrl,
|
|
1599
|
+
publishableKey,
|
|
1600
|
+
onDeviceAuthorization: async () => { },
|
|
1601
|
+
sleep: deps.sleep,
|
|
1602
|
+
now: deps.now,
|
|
1603
|
+
});
|
|
1604
|
+
// POST-COMMIT and unmarked: `completeDeviceEnrollment` above has returned, so the
|
|
1605
|
+
// enrollment is committed and this delete is local cleanup on top of it.
|
|
1606
|
+
//
|
|
1607
|
+
// An earlier version of this comment said the fleet path DOES reach here, via the
|
|
1608
|
+
// resume that omits `--fleet-onboarding`. That premise died with
|
|
1609
|
+
// Consiliency/message-board#210: the guard at the top of this method refuses a fleet
|
|
1610
|
+
// pending whose caller passed no `expectedFleet`, before any network call, and the
|
|
1611
|
+
// CLI's fleet resume passes `deletePending: false`. Neither CLI branch arrives here
|
|
1612
|
+
// with a fleet enrollment.
|
|
1613
|
+
//
|
|
1614
|
+
// What remains is a DIRECT runner caller that supplies a fleet-guarded
|
|
1615
|
+
// `expectedFleet` and lets `deletePending` default — a caller that has by
|
|
1616
|
+
// construction already done the fleet pre-flight, and that is inside the CLI's fence
|
|
1617
|
+
// only if it is the CLI.
|
|
1618
|
+
//
|
|
1619
|
+
// Marker decision, recorded rather than omitted: NO #202 marker here.
|
|
1620
|
+
// `markEnrollCommitted`/`throughFleetCommit` exist to turn an error raised inside a
|
|
1621
|
+
// committed call into a truthful post-commit message for a caller that HAS a fence.
|
|
1622
|
+
// On the only path that still reaches this line there is no such fence to feed, so a
|
|
1623
|
+
// marker would imply one that does not exist.
|
|
1624
|
+
if (input.deletePending !== false)
|
|
1625
|
+
await store.delete(input.enrollmentId);
|
|
1626
|
+
return result;
|
|
1627
|
+
},
|
|
1628
|
+
async deviceDeletePending(input) {
|
|
1629
|
+
const store = deps.deviceStore ?? new DevicePendingEnrollmentStore(defaultDevicePendingStateDir(env));
|
|
1630
|
+
await store.delete(input.enrollmentId);
|
|
1631
|
+
},
|
|
1283
1632
|
async begin(input) {
|
|
1284
1633
|
// Force OOB: a loopback receiver cannot bridge two separate CLI processes, so
|
|
1285
1634
|
// the two-step path is always out-of-band paste (no redirectUri ⇒ OOB).
|
|
@@ -1306,8 +1655,11 @@ export function defaultEnrollmentRunner(env = process.env, deps = {}) {
|
|
|
1306
1655
|
async complete(input) {
|
|
1307
1656
|
const descriptor = resolveEnrollmentDescriptor(env);
|
|
1308
1657
|
const enrollEndpoint = deps.enrollEndpoint ?? resolveEnrollEndpoint(env);
|
|
1309
|
-
const keystore = deps.keystore ?? buildEnrollKeystore(descriptor, env);
|
|
1310
1658
|
const store = deps.store ?? new FilePendingEnrollmentStore(env);
|
|
1659
|
+
const pending = await store.get(input.enrollmentId);
|
|
1660
|
+
const keystore = deps.keystore ?? buildEnrollKeystore(descriptor, env, {
|
|
1661
|
+
...(pending?.intent.kind === "fleet_onboarding" ? { fleetOnboarding: pending.intent } : {}),
|
|
1662
|
+
});
|
|
1311
1663
|
return completeEnrollment({
|
|
1312
1664
|
enrollmentId: input.enrollmentId,
|
|
1313
1665
|
code: input.code,
|
|
@@ -1350,6 +1702,203 @@ function csv(value) {
|
|
|
1350
1702
|
.filter(Boolean)
|
|
1351
1703
|
: undefined;
|
|
1352
1704
|
}
|
|
1705
|
+
const DEFAULT_PUBLISHABLE_KEY_FIELD_REFERENCE = "MESSAGE_BOARD_ENROLL_PUBLISHABLE_KEY";
|
|
1706
|
+
/**
|
|
1707
|
+
* Resolve the publishable key fleet finalization REQUIRES from this host's environment.
|
|
1708
|
+
* The pre-flight (before any server call) and the post-commit finalization both go
|
|
1709
|
+
* through this one function, so they cannot drift about which variable is read or what
|
|
1710
|
+
* counts as present — the drift is what made a missing variable a post-commit failure.
|
|
1711
|
+
*/
|
|
1712
|
+
function resolveFleetPublishableKey(publishableKeyFieldReference) {
|
|
1713
|
+
const reference = normalizePublishableKeyFieldReference(publishableKeyFieldReference ?? DEFAULT_PUBLISHABLE_KEY_FIELD_REFERENCE);
|
|
1714
|
+
const value = process.env[reference]?.trim() ?? "";
|
|
1715
|
+
if (!value) {
|
|
1716
|
+
throw new FleetPreflightError("fleet_publishable_key_missing", `fleet onboarding requires the publishable key in this host's environment: export ${reference}=<project anon/publishable key>. `
|
|
1717
|
+
+ "The variable name was frozen at mint time by --publishable-key-field-reference.");
|
|
1718
|
+
}
|
|
1719
|
+
return value;
|
|
1720
|
+
}
|
|
1721
|
+
function buildPendingFleetTemplate(intent, publishableKeyFieldReference = DEFAULT_PUBLISHABLE_KEY_FIELD_REFERENCE) {
|
|
1722
|
+
const suffix = `-${intent.harnessKind}`;
|
|
1723
|
+
if (!intent.expectedAgentHandle.endsWith(suffix))
|
|
1724
|
+
throw new Error("fleet_onboarding_expected_handle_invalid");
|
|
1725
|
+
const host = intent.expectedAgentHandle.slice(0, -suffix.length);
|
|
1726
|
+
const deployment = resolveJoinDeployment();
|
|
1727
|
+
if (!deployment.keyReference)
|
|
1728
|
+
throw new Error("fleet_onboarding_key_reference_missing");
|
|
1729
|
+
const ref = normalizeKeyReference(deployment.keyReference);
|
|
1730
|
+
const platform = ref.resolver === "os_keychain" ? "desktop" : ref.resolver === "agent_socket" ? "headless_linux" : null;
|
|
1731
|
+
if (!platform)
|
|
1732
|
+
throw new Error("fleet_onboarding_unsupported_key_reference");
|
|
1733
|
+
const separator = ref.handle.lastIndexOf("#");
|
|
1734
|
+
const pending = buildFleetHarnessTemplate({ host, harnessKind: intent.harnessKind, harnessLabel: intent.harnessLabel, platform,
|
|
1735
|
+
boardId: intent.boardId, machineId: intent.machineId, repoSlug: intent.repoSlug, repoFingerprint: intent.repoFingerprint,
|
|
1736
|
+
repoScopeRole: intent.repoScopeRole, agentRole: intent.agentRole, boardUrl: deployment.boardUrl, sessionUrl: deployment.sessionUrl ?? deployment.boardUrl,
|
|
1737
|
+
publishableKeyFieldReference,
|
|
1738
|
+
...(platform === "headless_linux" ? { agentSocketPath: ref.handle.slice(0, separator), publicKeySelector: separator > 0 ? ref.handle.slice(separator + 1) : "" } : {}),
|
|
1739
|
+
});
|
|
1740
|
+
if (pending.localKeyId !== intent.localKeyId || pending.keyReference !== `${ref.resolver}:${ref.handle}`)
|
|
1741
|
+
throw new Error("fleet_onboarding_local_key_mismatch");
|
|
1742
|
+
return pending;
|
|
1743
|
+
}
|
|
1744
|
+
async function finalizeFleetOutcome(options, intent, result, frozenPending) {
|
|
1745
|
+
if (intent.kind !== "fleet_onboarding")
|
|
1746
|
+
return result;
|
|
1747
|
+
if (!options.runtimeOutput)
|
|
1748
|
+
throw new Error("--fleet-onboarding requires --runtime-output");
|
|
1749
|
+
const outcome = result;
|
|
1750
|
+
// LIVE, unlike its same-named twin in enroll_command.ts (message-board#212). That one
|
|
1751
|
+
// sits after an `enrollAgent` call whose return shape the same function controls, so
|
|
1752
|
+
// nothing can produce the state it tests. This one consumes whatever the INJECTED
|
|
1753
|
+
// `EnrollmentCommandRunner` returned — a seam any `runCli` caller supplies — so a
|
|
1754
|
+
// runner that resolves a fleet enrollment without its receipt reaches here. Verified by
|
|
1755
|
+
// probe, not by reading. Do not delete it alongside the other one.
|
|
1756
|
+
if (!outcome.onboarding)
|
|
1757
|
+
throw new Error("fleet_onboarding_receipt_missing");
|
|
1758
|
+
const ref = normalizeKeyReference(outcome.keyReference);
|
|
1759
|
+
const pending = frozenPending ?? buildPendingFleetTemplate(intent, options.publishableKeyFieldReference);
|
|
1760
|
+
if (pending.localKeyId !== intent.localKeyId || pending.keyReference !== `${ref.resolver}:${ref.handle}`)
|
|
1761
|
+
throw new Error("fleet_onboarding_local_key_mismatch");
|
|
1762
|
+
const fingerprint = ref.publicKeyFingerprint ?? `sha256:${createHash("sha256").update(Buffer.from(outcome.agentPublicKey, "base64url")).digest("base64url")}`;
|
|
1763
|
+
const resolvedPublishableKey = resolveFleetPublishableKey(pending.publishableKeyFieldReference);
|
|
1764
|
+
const finalized = finalizeFleetHarnessTemplate(pending, { ...outcome.onboarding, agentId: outcome.agentId, signingKeyId: outcome.signingKeyId, keyId: outcome.keyId, keyVersion: outcome.keyVersion, agentHandle: outcome.agentHandle }, { keyReference: pending.keyReference, publicKey: outcome.agentPublicKey, publicKeyFingerprint: fingerprint }, resolvedPublishableKey);
|
|
1765
|
+
const published = await publishFinalizedFleetTemplate(options.runtimeOutput, finalized);
|
|
1766
|
+
return { enrollment: fleetSafeOutput(outcome), materialization: { generation: published.generation, digest: published.digest } };
|
|
1767
|
+
}
|
|
1768
|
+
function fleetResumeIntentFromCli(options) {
|
|
1769
|
+
const required = [
|
|
1770
|
+
["--slug", options.slug], ["--board-id", options.boardId], ["--machine-id", options.machineId],
|
|
1771
|
+
["--harness-kind", options.harnessKind], ["--harness-label", options.harnessLabel],
|
|
1772
|
+
["--repo-slug", options.repoSlug], ["--repo-fingerprint", options.repoFingerprint],
|
|
1773
|
+
["--repo-scope-role", options.repoScopeRole], ["--agent-role", options.agentRole],
|
|
1774
|
+
["--expected-agent-handle", options.expectedAgentHandle], ["--local-key-id", options.localKeyId],
|
|
1775
|
+
["--runtime-output", options.runtimeOutput],
|
|
1776
|
+
];
|
|
1777
|
+
const missing = required.filter(([, value]) => !value?.trim()).map(([name]) => name);
|
|
1778
|
+
if (missing.length)
|
|
1779
|
+
throw new Error(`fleet device resume is missing: ${missing.join(", ")}`);
|
|
1780
|
+
const pair = (options.agentRole === "phase_worker" && options.repoScopeRole === "worker")
|
|
1781
|
+
|| (options.agentRole === "reviewer" && options.repoScopeRole === "reviewer");
|
|
1782
|
+
if (!pair)
|
|
1783
|
+
throw new Error("fleet device resume allows only phase_worker/worker or reviewer/reviewer");
|
|
1784
|
+
return {
|
|
1785
|
+
kind: "fleet_onboarding", inviteCode: "",
|
|
1786
|
+
boardId: options.boardId.trim(), machineId: options.machineId.trim(),
|
|
1787
|
+
harnessKind: options.harnessKind.trim().toLowerCase(), harnessLabel: options.harnessLabel.trim(),
|
|
1788
|
+
repoSlug: options.repoSlug.trim(), repoFingerprint: options.repoFingerprint.trim(),
|
|
1789
|
+
repoScopeRole: options.repoScopeRole,
|
|
1790
|
+
agentRole: options.agentRole,
|
|
1791
|
+
expectedAgentHandle: options.expectedAgentHandle.trim(), localKeyId: options.localKeyId.trim(),
|
|
1792
|
+
};
|
|
1793
|
+
}
|
|
1794
|
+
async function enrollmentIntentFromCli(options) {
|
|
1795
|
+
if (!options.fleetOnboarding)
|
|
1796
|
+
return intentFromOptions(options);
|
|
1797
|
+
if (options.createBoard || options.joinBoard || options.board === false) {
|
|
1798
|
+
throw new Error("--fleet-onboarding cannot be combined with --create-board, --join-board, or --no-board");
|
|
1799
|
+
}
|
|
1800
|
+
let inviteCode = options.inviteCode;
|
|
1801
|
+
if (!inviteCode)
|
|
1802
|
+
inviteCode = await readCodeFromStdin();
|
|
1803
|
+
const required = [
|
|
1804
|
+
["invite code (stdin or --invite-code)", inviteCode],
|
|
1805
|
+
["--board-id", options.boardId], ["--machine-id", options.machineId],
|
|
1806
|
+
["--harness-kind", options.harnessKind], ["--harness-label", options.harnessLabel],
|
|
1807
|
+
["--repo-slug", options.repoSlug], ["--repo-fingerprint", options.repoFingerprint],
|
|
1808
|
+
["--repo-scope-role", options.repoScopeRole], ["--agent-role", options.agentRole],
|
|
1809
|
+
["--expected-agent-handle", options.expectedAgentHandle], ["--local-key-id", options.localKeyId],
|
|
1810
|
+
];
|
|
1811
|
+
const missing = required.filter(([, value]) => !value?.trim()).map(([name]) => name);
|
|
1812
|
+
if (missing.length)
|
|
1813
|
+
throw new Error(`--fleet-onboarding is missing: ${missing.join(", ")}`);
|
|
1814
|
+
const pair = (options.agentRole === "phase_worker" && options.repoScopeRole === "worker")
|
|
1815
|
+
|| (options.agentRole === "reviewer" && options.repoScopeRole === "reviewer");
|
|
1816
|
+
if (!pair)
|
|
1817
|
+
throw new Error("--fleet-onboarding allows only phase_worker/worker or reviewer/reviewer");
|
|
1818
|
+
return {
|
|
1819
|
+
kind: "fleet_onboarding",
|
|
1820
|
+
inviteCode: inviteCode.trim(),
|
|
1821
|
+
boardId: options.boardId.trim(),
|
|
1822
|
+
machineId: options.machineId.trim(),
|
|
1823
|
+
harnessKind: options.harnessKind.trim().toLowerCase(),
|
|
1824
|
+
harnessLabel: options.harnessLabel.trim(),
|
|
1825
|
+
repoSlug: options.repoSlug.trim(),
|
|
1826
|
+
repoFingerprint: options.repoFingerprint.trim(),
|
|
1827
|
+
repoScopeRole: options.repoScopeRole,
|
|
1828
|
+
agentRole: options.agentRole,
|
|
1829
|
+
expectedAgentHandle: options.expectedAgentHandle.trim(),
|
|
1830
|
+
localKeyId: options.localKeyId.trim(),
|
|
1831
|
+
};
|
|
1832
|
+
}
|
|
1833
|
+
/**
|
|
1834
|
+
* Every field DECLARED on `FleetOnboardingReceipt` (including the nine it inherits from
|
|
1835
|
+
* `FleetOnboardingBounds`) except `publicKeyFingerprint`, which is deliberately withheld.
|
|
1836
|
+
*
|
|
1837
|
+
* Consiliency/message-board#224 — this list exists because the receipt used to be rebuilt
|
|
1838
|
+
* as `const { publicKeyFingerprint: _f, ...receipt } = outcome.onboarding`, a DENYLIST OF
|
|
1839
|
+
* ONE: every undeclared property spread straight to stdout. The outcome being shaped here
|
|
1840
|
+
* arrives from an INJECTED `EnrollmentCommandRunner`, so it is not a value this code
|
|
1841
|
+
* produced, and `FleetOnboardingIntent` extends the same bounds while adding `inviteCode`
|
|
1842
|
+
* — an intent-shaped receipt therefore leaked the bounded BEARER SECRET. An allowlist
|
|
1843
|
+
* cannot leak a field nobody listed; a denylist can only stop what someone anticipated.
|
|
1844
|
+
*
|
|
1845
|
+
* Kept honest by `enroll_commit_sites.test.ts`, which derives the declared field set from
|
|
1846
|
+
* `enrollment_types.ts` and fails if this list and the type disagree in EITHER direction —
|
|
1847
|
+
* so a field added to the receipt cannot be silently dropped here, which is the regression
|
|
1848
|
+
* an allowlist invites.
|
|
1849
|
+
*/
|
|
1850
|
+
const FLEET_RECEIPT_FIELDS = [
|
|
1851
|
+
"boardId", "machineId", "harnessKind", "harnessLabel", "repoSlug", "repoFingerprint",
|
|
1852
|
+
"repoScopeRole", "agentRole", "expectedAgentHandle",
|
|
1853
|
+
"contractVersion", "harnessRegistrationId", "repoScopeId", "fleetMachineAgentId",
|
|
1854
|
+
"bindingSigningKeyId", "membershipRole", "membershipState",
|
|
1855
|
+
"identityDisposition", "membershipDisposition", "harnessDisposition",
|
|
1856
|
+
"repoScopeDisposition", "bindingDisposition",
|
|
1857
|
+
];
|
|
1858
|
+
/** The field withheld from output even though the type declares it. */
|
|
1859
|
+
const FLEET_RECEIPT_WITHHELD = "publicKeyFingerprint";
|
|
1860
|
+
function fleetSafeOutput(result) {
|
|
1861
|
+
if (!result || typeof result !== "object")
|
|
1862
|
+
return result;
|
|
1863
|
+
const outcome = result;
|
|
1864
|
+
if (!outcome.onboarding)
|
|
1865
|
+
return result;
|
|
1866
|
+
const source = outcome.onboarding;
|
|
1867
|
+
const receipt = {};
|
|
1868
|
+
// Copy BY NAME. A property absent from the source stays absent rather than becoming
|
|
1869
|
+
// an explicit `undefined`, so a partial receipt shapes to a partial receipt.
|
|
1870
|
+
for (const field of FLEET_RECEIPT_FIELDS) {
|
|
1871
|
+
if (field in source)
|
|
1872
|
+
receipt[field] = source[field];
|
|
1873
|
+
}
|
|
1874
|
+
return {
|
|
1875
|
+
agentId: outcome.agentId,
|
|
1876
|
+
authUserId: outcome.authUserId,
|
|
1877
|
+
providerId: outcome.providerId,
|
|
1878
|
+
signingKeyId: outcome.signingKeyId,
|
|
1879
|
+
keyId: outcome.keyId,
|
|
1880
|
+
keyVersion: outcome.keyVersion,
|
|
1881
|
+
agentHandle: outcome.agentHandle,
|
|
1882
|
+
onboarding: receipt,
|
|
1883
|
+
};
|
|
1884
|
+
}
|
|
1885
|
+
function addFleetOptions(command) {
|
|
1886
|
+
return command
|
|
1887
|
+
.option("--fleet-onboarding", "atomically enroll and bind the creator-authorized fleet tuple")
|
|
1888
|
+
.option("--invite-code <code>", "fleet bearer code escape hatch (WARNING: argv and shell history expose it; prefer stdin)")
|
|
1889
|
+
.option("--board-id <id>", "bounded board id")
|
|
1890
|
+
.option("--machine-id <id>", "bounded fleet machine id")
|
|
1891
|
+
.option("--harness-kind <kind>", "bounded harness kind")
|
|
1892
|
+
.option("--harness-label <label>", "bounded harness label")
|
|
1893
|
+
.option("--repo-slug <slug>", "bounded repo slug")
|
|
1894
|
+
.option("--repo-fingerprint <fingerprint>", "bounded repo fingerprint")
|
|
1895
|
+
.option("--repo-scope-role <role>", "worker or reviewer")
|
|
1896
|
+
.option("--agent-role <role>", "phase_worker or reviewer")
|
|
1897
|
+
.option("--expected-agent-handle <handle>", "exact server handle authorized by the creator")
|
|
1898
|
+
.option("--local-key-id <id>", "deterministic descriptor-only local key id")
|
|
1899
|
+
.option("--publishable-key-field-reference <ref>", "publishable-key environment field frozen into fleet finalization")
|
|
1900
|
+
.option("--runtime-output <path>", "required fleet runtime generation destination");
|
|
1901
|
+
}
|
|
1353
1902
|
function addEnrollCommands(program, io, enrollment) {
|
|
1354
1903
|
const enroll = program
|
|
1355
1904
|
.command("enroll")
|
|
@@ -1357,7 +1906,7 @@ function addEnrollCommands(program, io, enrollment) {
|
|
|
1357
1906
|
// One-shot loopback flow. It is a DEFAULT subcommand (isDefault) so bare
|
|
1358
1907
|
// `enroll ...` runs it, while keeping its options off the parent — otherwise a
|
|
1359
1908
|
// parent-level --slug shadows the begin/complete subcommands' own --slug.
|
|
1360
|
-
enroll
|
|
1909
|
+
const runCommand = enroll
|
|
1361
1910
|
.command("run", { isDefault: true })
|
|
1362
1911
|
.description("one-shot enrollment over an RFC 8252 loopback receiver")
|
|
1363
1912
|
.requiredOption("--slug <slug>", "desired agent slug")
|
|
@@ -1369,14 +1918,34 @@ function addEnrollCommands(program, io, enrollment) {
|
|
|
1369
1918
|
.option("--join-board <boardId>", "join-board intent board id")
|
|
1370
1919
|
.option("--requested-scopes <csv>", "requested scopes (comma-separated)")
|
|
1371
1920
|
.option("--requested-board-scopes <csv>", "requested board scopes (comma-separated)")
|
|
1372
|
-
.option("--no-board", "enroll WITHOUT provisioning any board (no create, no join)")
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1921
|
+
.option("--no-board", "enroll WITHOUT provisioning any board (no create, no join)");
|
|
1922
|
+
addFleetOptions(runCommand)
|
|
1923
|
+
.action((options) => runHandler(io, "agent_board.enroll", async () => {
|
|
1924
|
+
if (options.fleetOnboarding) {
|
|
1925
|
+
// BOTH local prerequisites of fleet finalization are checked here, before the
|
|
1926
|
+
// server is asked to commit anything. The publishable key used to be read only
|
|
1927
|
+
// inside finalizeFleetOutcome, after `enrollment.run` had already created the
|
|
1928
|
+
// identity and spent the bounded invite use (message-board#176).
|
|
1929
|
+
await prepareFleetRuntimeOutput(options.runtimeOutput ?? "");
|
|
1930
|
+
resolveFleetPublishableKey(options.publishableKeyFieldReference);
|
|
1931
|
+
}
|
|
1932
|
+
return enrollmentIntentFromCli(options).then(async (intent) => {
|
|
1933
|
+
const call = () => enrollment.run({
|
|
1934
|
+
slug: options.slug,
|
|
1935
|
+
agentKind: options.agentKind,
|
|
1936
|
+
intent,
|
|
1937
|
+
requestedScopes: csv(options.requestedScopes),
|
|
1938
|
+
requestedBoardScopes: csv(options.requestedBoardScopes),
|
|
1939
|
+
});
|
|
1940
|
+
if (intent.kind !== "fleet_onboarding")
|
|
1941
|
+
return finalizeFleetOutcome(options, intent, await call());
|
|
1942
|
+
// #202 — the runner commits inside this call, so the fence must start here,
|
|
1943
|
+
// not after it. Fleet-only: the post-commit message is about a fleet
|
|
1944
|
+
// enrollment, and a non-fleet failure is not redacted in the first place.
|
|
1945
|
+
const result = await throughFleetCommit(call);
|
|
1946
|
+
return afterFleetCommit(result, () => finalizeFleetOutcome(options, intent, result));
|
|
1947
|
+
});
|
|
1948
|
+
}, Boolean(options.fleetOnboarding)));
|
|
1380
1949
|
enroll
|
|
1381
1950
|
.command("begin")
|
|
1382
1951
|
.description("begin an enrollment; prints the authorization URL (no code_verifier/state)")
|
|
@@ -1428,10 +1997,11 @@ function addEnrollCommands(program, io, enrollment) {
|
|
|
1428
1997
|
// is elsewhere) and `begin`/`complete`'s OOB redirect is not registered for the
|
|
1429
1998
|
// Portal client; the device grant prints a user_code + URL the human approves on
|
|
1430
1999
|
// ANY device, then polls to completion. Same options as `run` where they apply.
|
|
1431
|
-
enroll
|
|
2000
|
+
const deviceCommand = enroll
|
|
1432
2001
|
.command("device")
|
|
1433
2002
|
.description("headless enrollment via the RFC 8628 device authorization grant (approve on any device)")
|
|
1434
|
-
.
|
|
2003
|
+
.option("--slug <slug>", "desired agent slug (required unless resuming a non-fleet enrollment)")
|
|
2004
|
+
.option("--enrollment-id <id>", "resume the device authorization returned by a prior fleet begin")
|
|
1435
2005
|
.option("--agent-kind <kind>", "agent kind (default 'agent')")
|
|
1436
2006
|
.option("--create-board <slug>", "create-board intent slug")
|
|
1437
2007
|
.option("--board-name <name>", "board name for create-board")
|
|
@@ -1440,25 +2010,127 @@ function addEnrollCommands(program, io, enrollment) {
|
|
|
1440
2010
|
.option("--join-board <boardId>", "join-board intent board id")
|
|
1441
2011
|
.option("--requested-scopes <csv>", "requested scopes (comma-separated)")
|
|
1442
2012
|
.option("--requested-board-scopes <csv>", "requested board scopes (comma-separated)")
|
|
1443
|
-
.option("--no-board", "enroll WITHOUT provisioning any board (no create, no join)")
|
|
1444
|
-
|
|
2013
|
+
.option("--no-board", "enroll WITHOUT provisioning any board (no create, no join)");
|
|
2014
|
+
addFleetOptions(deviceCommand)
|
|
2015
|
+
.action((options) => runHandler(io, "agent_board.enroll_device", async () => {
|
|
2016
|
+
if (options.enrollmentId) {
|
|
2017
|
+
if (!enrollment.deviceComplete) {
|
|
2018
|
+
throw new Error("device resume is not supported by this runner (EnrollmentCommandRunner.deviceComplete is not implemented)");
|
|
2019
|
+
}
|
|
2020
|
+
if (!options.fleetOnboarding) {
|
|
2021
|
+
// #210, second half — shaped on the OUTCOME, not on the flag. `fleetSafeOutput`
|
|
2022
|
+
// is a no-op for a non-fleet outcome by construction (it returns `result`
|
|
2023
|
+
// unchanged when `onboarding` is absent), so this costs the supported non-fleet
|
|
2024
|
+
// resume nothing. It closes the leak because this is the ONE enroll path whose
|
|
2025
|
+
// result reaches `runHandler` without passing through `finalizeFleetOutcome`:
|
|
2026
|
+
// a fleet outcome arriving here — from the INJECTED `EnrollmentCommandRunner`
|
|
2027
|
+
// seam, which this file already treats as a reachable population — printed
|
|
2028
|
+
// `keyReference` and `agentPublicKey` verbatim.
|
|
2029
|
+
//
|
|
2030
|
+
// `runHandler`'s `redactFleetFailure` argument below stays
|
|
2031
|
+
// `Boolean(options.fleetOnboarding)`. Do NOT make it outcome-keyed to match:
|
|
2032
|
+
// it governs FAILURE text, and turning it on here would blank the guard's
|
|
2033
|
+
// refusal message into "fleet enrollment failed before completion".
|
|
2034
|
+
return fleetSafeOutput(await enrollment.deviceComplete({ enrollmentId: options.enrollmentId }));
|
|
2035
|
+
}
|
|
2036
|
+
if (!enrollment.deviceDeletePending) {
|
|
2037
|
+
throw new Error("fleet device resume is not supported by this runner (EnrollmentCommandRunner.deviceDeletePending is not implemented)");
|
|
2038
|
+
}
|
|
2039
|
+
const store = new FleetDeviceContinuationStore(defaultDevicePendingStateDir(process.env));
|
|
2040
|
+
const continuation = await store.get(options.enrollmentId);
|
|
2041
|
+
if (!continuation)
|
|
2042
|
+
throw new Error("fleet_device_continuation_unknown");
|
|
2043
|
+
const resumeIntent = fleetResumeIntentFromCli(options);
|
|
2044
|
+
const candidate = buildPendingFleetTemplate(resumeIntent, options.publishableKeyFieldReference);
|
|
2045
|
+
const pending = continuation.pendingTemplate;
|
|
2046
|
+
if (options.slug.trim() !== pending.agentSlug || options.runtimeOutput !== continuation.runtimeOutput
|
|
2047
|
+
|| continuation.keyReference !== pending.keyReference
|
|
2048
|
+
|| continuation.publishableKeyFieldReference !== pending.publishableKeyFieldReference
|
|
2049
|
+
|| JSON.stringify(candidate) !== JSON.stringify(pending)) {
|
|
2050
|
+
throw new Error("fleet_device_continuation_mismatch");
|
|
2051
|
+
}
|
|
2052
|
+
await prepareFleetRuntimeOutput(options.runtimeOutput);
|
|
2053
|
+
const expectedIntent = { kind: "fleet_onboarding", ...pending.enrollment };
|
|
2054
|
+
let outcome = continuation.outcome;
|
|
2055
|
+
const committedThisLeg = !outcome;
|
|
2056
|
+
if (committedThisLeg) {
|
|
2057
|
+
// Nothing has committed yet on this leg — check the second local
|
|
2058
|
+
// prerequisite before spending the device authorization.
|
|
2059
|
+
resolveFleetPublishableKey(pending.publishableKeyFieldReference);
|
|
2060
|
+
// #202 — same boundary as `run`: deviceComplete commits inside the call.
|
|
2061
|
+
outcome = await throughFleetCommit(() => enrollment.deviceComplete({
|
|
2062
|
+
enrollmentId: options.enrollmentId,
|
|
2063
|
+
expectedFleet: { intent: expectedIntent, keyReference: pending.keyReference, slug: pending.agentSlug },
|
|
2064
|
+
deletePending: false,
|
|
2065
|
+
}));
|
|
2066
|
+
}
|
|
2067
|
+
return afterFleetCommit(outcome, async () => {
|
|
2068
|
+
// The write that MAKES this recoverable is itself post-commit: mkdir,
|
|
2069
|
+
// O_EXCL create, write, fsync and rename can each fail. Outside the fence
|
|
2070
|
+
// its failure read as "failed before completion", and because the outcome
|
|
2071
|
+
// was never persisted a retry would re-enter deviceComplete against a
|
|
2072
|
+
// device authorization that is already consumed.
|
|
2073
|
+
if (committedThisLeg)
|
|
2074
|
+
await store.put({ ...continuation, outcome: outcome });
|
|
2075
|
+
const result = await finalizeFleetOutcome(options, { ...expectedIntent, inviteCode: "" }, outcome, pending);
|
|
2076
|
+
await enrollment.deviceDeletePending({ enrollmentId: options.enrollmentId });
|
|
2077
|
+
await store.delete(options.enrollmentId);
|
|
2078
|
+
return result;
|
|
2079
|
+
});
|
|
2080
|
+
}
|
|
2081
|
+
if (!options.slug)
|
|
2082
|
+
throw new Error("enroll device requires --slug when --enrollment-id is absent");
|
|
2083
|
+
if (options.fleetOnboarding) {
|
|
2084
|
+
if (!enrollment.deviceBegin) {
|
|
2085
|
+
throw new Error("fleet device begin is not supported by this runner (EnrollmentCommandRunner.deviceBegin is not implemented)");
|
|
2086
|
+
}
|
|
2087
|
+
const intent = await enrollmentIntentFromCli(options);
|
|
2088
|
+
if (intent.kind !== "fleet_onboarding")
|
|
2089
|
+
throw new Error("fleet_device_intent_missing");
|
|
2090
|
+
const pending = buildPendingFleetTemplate(intent, options.publishableKeyFieldReference);
|
|
2091
|
+
if (!options.runtimeOutput)
|
|
2092
|
+
throw new Error("--fleet-onboarding requires --runtime-output");
|
|
2093
|
+
await prepareFleetRuntimeOutput(options.runtimeOutput);
|
|
2094
|
+
// Before a human approval is spent, and before any continuation state exists
|
|
2095
|
+
// to clean up.
|
|
2096
|
+
resolveFleetPublishableKey(pending.publishableKeyFieldReference);
|
|
2097
|
+
const enrollmentId = `enr-${randomUUID()}`;
|
|
2098
|
+
const store = new FleetDeviceContinuationStore(defaultDevicePendingStateDir(process.env));
|
|
2099
|
+
await store.put({ version: "fleet_device_continuation.v1", enrollmentId,
|
|
2100
|
+
runtimeOutput: options.runtimeOutput, publishableKeyFieldReference: pending.publishableKeyFieldReference,
|
|
2101
|
+
keyReference: pending.keyReference, pendingTemplate: pending });
|
|
2102
|
+
try {
|
|
2103
|
+
const begun = await enrollment.deviceBegin({ enrollmentId, slug: options.slug, agentKind: options.agentKind,
|
|
2104
|
+
intent, requestedScopes: csv(options.requestedScopes), requestedBoardScopes: csv(options.requestedBoardScopes) });
|
|
2105
|
+
if (begun.enrollmentId !== enrollmentId)
|
|
2106
|
+
throw new Error("fleet_device_enrollment_id_mismatch");
|
|
2107
|
+
return begun;
|
|
2108
|
+
}
|
|
2109
|
+
catch (error) {
|
|
2110
|
+
await store.delete(enrollmentId);
|
|
2111
|
+
throw error;
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
1445
2114
|
// `device` is an OPTIONAL member of EnrollmentCommandRunner (added after the
|
|
1446
2115
|
// interface shipped). The default runner always provides it; a custom runner
|
|
1447
2116
|
// that predates it gets a clean error instead of a `undefined is not a function`.
|
|
1448
2117
|
if (!enrollment.device) {
|
|
1449
2118
|
throw new Error("device-authorization enrollment is not supported by this runner (EnrollmentCommandRunner.device is not implemented)");
|
|
1450
2119
|
}
|
|
1451
|
-
return
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
2120
|
+
return enrollmentIntentFromCli(options).then(async (intent) => {
|
|
2121
|
+
const result = await enrollment.device({
|
|
2122
|
+
slug: options.slug,
|
|
2123
|
+
agentKind: options.agentKind,
|
|
2124
|
+
intent,
|
|
2125
|
+
requestedScopes: csv(options.requestedScopes),
|
|
2126
|
+
requestedBoardScopes: csv(options.requestedBoardScopes),
|
|
2127
|
+
// Print the human-facing prompt PROMINENTLY to stderr (stdout is reserved
|
|
2128
|
+
// for the final JSON outcome). The device_code secret is never surfaced.
|
|
2129
|
+
onDeviceAuthorization: (prompt) => io.writeStderr(renderDevicePrompt(prompt)),
|
|
2130
|
+
});
|
|
2131
|
+
return result;
|
|
1460
2132
|
});
|
|
1461
|
-
}));
|
|
2133
|
+
}, Boolean(options.fleetOnboarding)));
|
|
1462
2134
|
}
|
|
1463
2135
|
/**
|
|
1464
2136
|
* Render the human-facing device-authorization prompt. Written to be pasted to a
|
|
@@ -1668,6 +2340,75 @@ function addRoomsCommands(program, client, io) {
|
|
|
1668
2340
|
return runHandler(io, "agent_board.redeem_invite_link", () => client.redeemInviteLink({ p_code: code }));
|
|
1669
2341
|
});
|
|
1670
2342
|
}
|
|
2343
|
+
function addInviteCommands(program, client, io) {
|
|
2344
|
+
const invite = program.command("invite").description("create bounded fleet onboarding invites");
|
|
2345
|
+
invite
|
|
2346
|
+
.command("create-fleet")
|
|
2347
|
+
.description("create a member-only invite with immutable machine/harness/repo/role/handle bounds")
|
|
2348
|
+
.requiredOption("--board-id <id>")
|
|
2349
|
+
.requiredOption("--machine-id <id>")
|
|
2350
|
+
.requiredOption("--harness-kind <kind>")
|
|
2351
|
+
.requiredOption("--harness-label <label>")
|
|
2352
|
+
.requiredOption("--repo-slug <slug>")
|
|
2353
|
+
.requiredOption("--repo-fingerprint <fingerprint>")
|
|
2354
|
+
.requiredOption("--repo-scope-role <role>", "worker or reviewer")
|
|
2355
|
+
.requiredOption("--agent-role <role>", "phase_worker or reviewer")
|
|
2356
|
+
.requiredOption("--expected-agent-handle <handle>")
|
|
2357
|
+
.addOption(numberOption("--max-uses <n>", "maximum atomic onboardings (default 1)"))
|
|
2358
|
+
.option("--expires <iso>")
|
|
2359
|
+
.option("--created-by-agent-id <id>")
|
|
2360
|
+
.option("--with-instructions", "render the bounded atomic onboarding instructions")
|
|
2361
|
+
.option("--host <slug>", "normalized host slug (required with --with-instructions)")
|
|
2362
|
+
.option("--platform <platform>", "desktop or headless_linux (required with --with-instructions)")
|
|
2363
|
+
.option("--publishable-key-field-reference <ref>", "public key field reference (required with --with-instructions)")
|
|
2364
|
+
.action((o) => runHandler(io, "agent_board.create_fleet_onboarding_invite", async () => {
|
|
2365
|
+
if (!client.createFleetOnboardingInvite) {
|
|
2366
|
+
throw new Error("this client does not support bounded fleet onboarding invites");
|
|
2367
|
+
}
|
|
2368
|
+
const pair = (o.agentRole === "phase_worker" && o.repoScopeRole === "worker")
|
|
2369
|
+
|| (o.agentRole === "reviewer" && o.repoScopeRole === "reviewer");
|
|
2370
|
+
if (!pair)
|
|
2371
|
+
throw new Error("fleet invite allows only phase_worker/worker or reviewer/reviewer");
|
|
2372
|
+
let instructionTemplate;
|
|
2373
|
+
let instructionDeployment;
|
|
2374
|
+
if (o.withInstructions) {
|
|
2375
|
+
if (!o.host || !o.platform || !o.publishableKeyFieldReference)
|
|
2376
|
+
throw new Error("--with-instructions requires --host, --platform, and --publishable-key-field-reference");
|
|
2377
|
+
if (o.platform !== "desktop" && o.platform !== "headless_linux")
|
|
2378
|
+
throw new Error("--platform must be desktop or headless_linux");
|
|
2379
|
+
instructionDeployment = resolveJoinDeployment();
|
|
2380
|
+
instructionTemplate = buildFleetHarnessTemplate({ host: o.host, harnessKind: o.harnessKind, harnessLabel: o.harnessLabel,
|
|
2381
|
+
platform: o.platform, boardId: o.boardId, machineId: o.machineId, repoSlug: o.repoSlug, repoFingerprint: o.repoFingerprint,
|
|
2382
|
+
repoScopeRole: o.repoScopeRole, agentRole: o.agentRole, boardUrl: instructionDeployment.boardUrl,
|
|
2383
|
+
sessionUrl: instructionDeployment.sessionUrl ?? instructionDeployment.boardUrl,
|
|
2384
|
+
publishableKeyFieldReference: o.publishableKeyFieldReference,
|
|
2385
|
+
...(o.platform === "headless_linux" ? { agentSocketPath: process.env.SSH_AUTH_SOCK, publicKeySelector: process.env.AGENT_BOARD_PUBLIC_KEY_SELECTOR } : {}),
|
|
2386
|
+
});
|
|
2387
|
+
const instructionBounds = ["boardId", "machineId", "harnessKind", "harnessLabel", "repoSlug", "repoFingerprint", "repoScopeRole", "agentRole", "expectedAgentHandle"];
|
|
2388
|
+
if (instructionBounds.some((field) => instructionTemplate.enrollment[field] !== o[field])) {
|
|
2389
|
+
throw new Error("fleet instruction options do not match the normalized bounded template");
|
|
2390
|
+
}
|
|
2391
|
+
}
|
|
2392
|
+
const result = await client.createFleetOnboardingInvite({
|
|
2393
|
+
boardId: o.boardId,
|
|
2394
|
+
machineId: o.machineId,
|
|
2395
|
+
harnessKind: o.harnessKind,
|
|
2396
|
+
harnessLabel: o.harnessLabel,
|
|
2397
|
+
repoSlug: o.repoSlug,
|
|
2398
|
+
repoFingerprint: o.repoFingerprint,
|
|
2399
|
+
repoScopeRole: o.repoScopeRole,
|
|
2400
|
+
agentRole: o.agentRole,
|
|
2401
|
+
expectedAgentHandle: o.expectedAgentHandle,
|
|
2402
|
+
maxUses: o.maxUses,
|
|
2403
|
+
expiresAt: o.expires,
|
|
2404
|
+
createdByAgentId: o.createdByAgentId,
|
|
2405
|
+
});
|
|
2406
|
+
if (!o.withInstructions)
|
|
2407
|
+
return result;
|
|
2408
|
+
const branded = result.data;
|
|
2409
|
+
return { result, instructions: buildAgentJoinInstructions({ invite: branded, deployment: { ...instructionDeployment, fleetHarnessTemplate: instructionTemplate, fleetOnboardingInvite: branded } }) };
|
|
2410
|
+
}));
|
|
2411
|
+
}
|
|
1671
2412
|
export function createProgram(client, io, enrollment = defaultEnrollmentRunner()) {
|
|
1672
2413
|
const program = new Command()
|
|
1673
2414
|
.name("agent-board")
|
|
@@ -1690,6 +2431,7 @@ export function createProgram(client, io, enrollment = defaultEnrollmentRunner()
|
|
|
1690
2431
|
addWriteCommands(program, client, io);
|
|
1691
2432
|
addReadCommands(program, client, io);
|
|
1692
2433
|
addRoomsCommands(program, client, io);
|
|
2434
|
+
addInviteCommands(program, client, io);
|
|
1693
2435
|
addEnrollCommands(program, io, enrollment);
|
|
1694
2436
|
return program;
|
|
1695
2437
|
}
|