@consiliency/agent-board-cli 1.8.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 +59 -1
- package/dist/cli.js +891 -15
- 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,
|
|
@@ -1246,6 +1442,193 @@ export function defaultEnrollmentRunner(env = process.env, deps = {}) {
|
|
|
1246
1442
|
now: deps.now,
|
|
1247
1443
|
});
|
|
1248
1444
|
},
|
|
1445
|
+
async device(input) {
|
|
1446
|
+
const descriptor = resolveEnrollmentDescriptor(env);
|
|
1447
|
+
// Fail CLEAN and actionable when the host descriptor carries no device
|
|
1448
|
+
// endpoint — otherwise the shared flow throws a bare `no_device_endpoint`.
|
|
1449
|
+
// The endpoint is optional in resolveEnrollmentDescriptor (it is not required
|
|
1450
|
+
// for the auth-code paths), so a headless operator must set it explicitly.
|
|
1451
|
+
if (!descriptor.issuer.deviceAuthorizationEndpoint) {
|
|
1452
|
+
throw new EnrollmentDescriptorError("missing_device_endpoint", "device enrollment needs a device authorization endpoint (set MESSAGE_BOARD_ENROLL_DEVICE_AUTHORIZATION_ENDPOINT, e.g. https://portal.consiliency.io/api/oauth/device_authorization)");
|
|
1453
|
+
}
|
|
1454
|
+
// Post-token deps are reconstructed EXACTLY as `run` does (rule #1): keystore,
|
|
1455
|
+
// keystoreBackends, boardFetch, enrollEndpoint, sessionUrl, publishableKey. A
|
|
1456
|
+
// dropped sessionUrl/publishableKey breaks the post-enroll board op on a raw-
|
|
1457
|
+
// Supabase board — pinned by the device analogue of the `run` e2e (drop either
|
|
1458
|
+
// and the sessionUrl-override / anon-apikey assertion fails).
|
|
1459
|
+
const enrollEndpoint = deps.enrollEndpoint ?? resolveEnrollEndpoint(env);
|
|
1460
|
+
const keystore = deps.keystore ?? buildEnrollKeystore(descriptor, env, {
|
|
1461
|
+
...(input.intent.kind === "fleet_onboarding" ? { fleetOnboarding: input.intent } : {}),
|
|
1462
|
+
});
|
|
1463
|
+
return runDeviceEnrollment(descriptor, input.intent, {
|
|
1464
|
+
slug: input.slug,
|
|
1465
|
+
agentKind: input.agentKind,
|
|
1466
|
+
requestedScopes: input.requestedScopes,
|
|
1467
|
+
requestedBoardScopes: input.requestedBoardScopes,
|
|
1468
|
+
}, {
|
|
1469
|
+
fetch,
|
|
1470
|
+
enrollEndpoint,
|
|
1471
|
+
keystore,
|
|
1472
|
+
boardFetch,
|
|
1473
|
+
keystoreBackends: keystore.backends,
|
|
1474
|
+
sessionUrl,
|
|
1475
|
+
publishableKey,
|
|
1476
|
+
onDeviceAuthorization: input.onDeviceAuthorization,
|
|
1477
|
+
sleep: deps.sleep,
|
|
1478
|
+
now: deps.now,
|
|
1479
|
+
});
|
|
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
|
+
},
|
|
1249
1632
|
async begin(input) {
|
|
1250
1633
|
// Force OOB: a loopback receiver cannot bridge two separate CLI processes, so
|
|
1251
1634
|
// the two-step path is always out-of-band paste (no redirectUri ⇒ OOB).
|
|
@@ -1259,13 +1642,24 @@ export function defaultEnrollmentRunner(env = process.env, deps = {}) {
|
|
|
1259
1642
|
requestedScopes: input.requestedScopes,
|
|
1260
1643
|
requestedBoardScopes: input.requestedBoardScopes,
|
|
1261
1644
|
redirectMode: "oob",
|
|
1262
|
-
}, store, {
|
|
1645
|
+
}, store, {
|
|
1646
|
+
fetch,
|
|
1647
|
+
clientId: descriptor.issuer.clientId,
|
|
1648
|
+
// #173 — pass the provisioning config so begin refuses a doomed raw-Supabase
|
|
1649
|
+
// enrollment BEFORE the human is asked to approve anything.
|
|
1650
|
+
sessionUrl,
|
|
1651
|
+
publishableKey,
|
|
1652
|
+
now: deps.now,
|
|
1653
|
+
});
|
|
1263
1654
|
},
|
|
1264
1655
|
async complete(input) {
|
|
1265
1656
|
const descriptor = resolveEnrollmentDescriptor(env);
|
|
1266
1657
|
const enrollEndpoint = deps.enrollEndpoint ?? resolveEnrollEndpoint(env);
|
|
1267
|
-
const keystore = deps.keystore ?? buildEnrollKeystore(descriptor, env);
|
|
1268
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
|
+
});
|
|
1269
1663
|
return completeEnrollment({
|
|
1270
1664
|
enrollmentId: input.enrollmentId,
|
|
1271
1665
|
code: input.code,
|
|
@@ -1285,6 +1679,9 @@ export function defaultEnrollmentRunner(env = process.env, deps = {}) {
|
|
|
1285
1679
|
};
|
|
1286
1680
|
}
|
|
1287
1681
|
function intentFromOptions(options) {
|
|
1682
|
+
if (options.board === false) {
|
|
1683
|
+
return { kind: "none" };
|
|
1684
|
+
}
|
|
1288
1685
|
if (options.joinBoard) {
|
|
1289
1686
|
return { kind: "join_board", boardId: options.joinBoard };
|
|
1290
1687
|
}
|
|
@@ -1305,6 +1702,203 @@ function csv(value) {
|
|
|
1305
1702
|
.filter(Boolean)
|
|
1306
1703
|
: undefined;
|
|
1307
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
|
+
}
|
|
1308
1902
|
function addEnrollCommands(program, io, enrollment) {
|
|
1309
1903
|
const enroll = program
|
|
1310
1904
|
.command("enroll")
|
|
@@ -1312,7 +1906,7 @@ function addEnrollCommands(program, io, enrollment) {
|
|
|
1312
1906
|
// One-shot loopback flow. It is a DEFAULT subcommand (isDefault) so bare
|
|
1313
1907
|
// `enroll ...` runs it, while keeping its options off the parent — otherwise a
|
|
1314
1908
|
// parent-level --slug shadows the begin/complete subcommands' own --slug.
|
|
1315
|
-
enroll
|
|
1909
|
+
const runCommand = enroll
|
|
1316
1910
|
.command("run", { isDefault: true })
|
|
1317
1911
|
.description("one-shot enrollment over an RFC 8252 loopback receiver")
|
|
1318
1912
|
.requiredOption("--slug <slug>", "desired agent slug")
|
|
@@ -1324,13 +1918,34 @@ function addEnrollCommands(program, io, enrollment) {
|
|
|
1324
1918
|
.option("--join-board <boardId>", "join-board intent board id")
|
|
1325
1919
|
.option("--requested-scopes <csv>", "requested scopes (comma-separated)")
|
|
1326
1920
|
.option("--requested-board-scopes <csv>", "requested board scopes (comma-separated)")
|
|
1327
|
-
.
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
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)));
|
|
1334
1949
|
enroll
|
|
1335
1950
|
.command("begin")
|
|
1336
1951
|
.description("begin an enrollment; prints the authorization URL (no code_verifier/state)")
|
|
@@ -1343,6 +1958,7 @@ function addEnrollCommands(program, io, enrollment) {
|
|
|
1343
1958
|
.option("--join-board <boardId>", "join-board intent board id")
|
|
1344
1959
|
.option("--requested-scopes <csv>", "requested scopes (comma-separated)")
|
|
1345
1960
|
.option("--requested-board-scopes <csv>", "requested board scopes (comma-separated)")
|
|
1961
|
+
.option("--no-board", "enroll WITHOUT provisioning any board (no create, no join)")
|
|
1346
1962
|
// A6: `begin` is ALWAYS out-of-band (a loopback receiver cannot bridge the two
|
|
1347
1963
|
// separate begin/complete CLI processes), so this flag is a vestigial no-op kept
|
|
1348
1964
|
// only for backward compatibility. Use `enroll run` for the loopback flow.
|
|
@@ -1376,6 +1992,169 @@ function addEnrollCommands(program, io, enrollment) {
|
|
|
1376
1992
|
slug: options.slug,
|
|
1377
1993
|
}));
|
|
1378
1994
|
});
|
|
1995
|
+
// RFC 8628 device authorization — the correct flow for a HEADLESS host. `run`'s
|
|
1996
|
+
// loopback receiver binds the SERVER's localhost (useless when the human's browser
|
|
1997
|
+
// is elsewhere) and `begin`/`complete`'s OOB redirect is not registered for the
|
|
1998
|
+
// Portal client; the device grant prints a user_code + URL the human approves on
|
|
1999
|
+
// ANY device, then polls to completion. Same options as `run` where they apply.
|
|
2000
|
+
const deviceCommand = enroll
|
|
2001
|
+
.command("device")
|
|
2002
|
+
.description("headless enrollment via the RFC 8628 device authorization grant (approve on any device)")
|
|
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")
|
|
2005
|
+
.option("--agent-kind <kind>", "agent kind (default 'agent')")
|
|
2006
|
+
.option("--create-board <slug>", "create-board intent slug")
|
|
2007
|
+
.option("--board-name <name>", "board name for create-board")
|
|
2008
|
+
.option("--board-description <description>", "board description")
|
|
2009
|
+
.option("--visibility <visibility>", "board visibility")
|
|
2010
|
+
.option("--join-board <boardId>", "join-board intent board id")
|
|
2011
|
+
.option("--requested-scopes <csv>", "requested scopes (comma-separated)")
|
|
2012
|
+
.option("--requested-board-scopes <csv>", "requested board scopes (comma-separated)")
|
|
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
|
+
}
|
|
2114
|
+
// `device` is an OPTIONAL member of EnrollmentCommandRunner (added after the
|
|
2115
|
+
// interface shipped). The default runner always provides it; a custom runner
|
|
2116
|
+
// that predates it gets a clean error instead of a `undefined is not a function`.
|
|
2117
|
+
if (!enrollment.device) {
|
|
2118
|
+
throw new Error("device-authorization enrollment is not supported by this runner (EnrollmentCommandRunner.device is not implemented)");
|
|
2119
|
+
}
|
|
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;
|
|
2132
|
+
});
|
|
2133
|
+
}, Boolean(options.fleetOnboarding)));
|
|
2134
|
+
}
|
|
2135
|
+
/**
|
|
2136
|
+
* Render the human-facing device-authorization prompt. Written to be pasted to a
|
|
2137
|
+
* human: the user_code and verification URI are the two things they act on. Never
|
|
2138
|
+
* carries the device_code (the shared flow does not expose it here).
|
|
2139
|
+
*/
|
|
2140
|
+
function renderDevicePrompt(prompt) {
|
|
2141
|
+
const lines = [
|
|
2142
|
+
"",
|
|
2143
|
+
"=== Approve this agent on any device (RFC 8628 device authorization) ===",
|
|
2144
|
+
` 1. Open: ${prompt.verificationUri}`,
|
|
2145
|
+
` 2. Enter code: ${prompt.userCode}`,
|
|
2146
|
+
];
|
|
2147
|
+
if (prompt.verificationUriComplete) {
|
|
2148
|
+
lines.push(` (or open this pre-filled link directly: ${prompt.verificationUriComplete})`);
|
|
2149
|
+
}
|
|
2150
|
+
if (prompt.expiresIn) {
|
|
2151
|
+
lines.push(` This code expires in ${prompt.expiresIn} seconds. Waiting for approval...`);
|
|
2152
|
+
}
|
|
2153
|
+
else {
|
|
2154
|
+
lines.push(" Waiting for approval...");
|
|
2155
|
+
}
|
|
2156
|
+
lines.push("");
|
|
2157
|
+
return `${lines.join("\n")}\n`;
|
|
1379
2158
|
}
|
|
1380
2159
|
/** Read a single-line auth code from a piped stdin (A5). Returns undefined on a TTY. */
|
|
1381
2160
|
async function readCodeFromStdin() {
|
|
@@ -1533,6 +2312,102 @@ function addRoomsCommands(program, client, io) {
|
|
|
1533
2312
|
.description("Revoke an invite link by code")
|
|
1534
2313
|
.requiredOption("--code <code>")
|
|
1535
2314
|
.action((o) => runHandler(io, "agent_board.revoke_invite_link", () => client.revokeInviteLink({ p_code: o.code })));
|
|
2315
|
+
// Joiner-side redeem: an already-enrolled agent redeems an invite AS ITSELF from
|
|
2316
|
+
// its OWN env-resolved credentials (the same resolution every other CLI command
|
|
2317
|
+
// uses) — the clean path for a host whose MCP session carries a different/shared
|
|
2318
|
+
// identity, without hand-rolling an SDK script. The code is a BEARER SECRET.
|
|
2319
|
+
//
|
|
2320
|
+
// A5 (mirrors `enroll complete`, cli.ts): PREFER stdin. A bearer code on argv leaks
|
|
2321
|
+
// via /proc/<pid>/cmdline and the shell's history file, so `--code` is optional and
|
|
2322
|
+
// documented as leaky; when it is absent and stdin is piped, the code is read from
|
|
2323
|
+
// stdin (e.g. a quoted heredoc — the form the rendered join instructions emit, which
|
|
2324
|
+
// keeps the code out of any executable shell string). The command never writes the
|
|
2325
|
+
// code to a log, and neither the success result nor the failure object echoes it.
|
|
2326
|
+
rooms
|
|
2327
|
+
.command("link-redeem")
|
|
2328
|
+
.description("Redeem an invite link by code, joining as the agent identity resolved from this host's env credentials. The code is a BEARER SECRET — prefer piping it on stdin.")
|
|
2329
|
+
.option("--code <code>", "the invite code (prefer stdin; argv leaks via /proc/<pid>/cmdline and shell history)")
|
|
2330
|
+
.action(async (o) => {
|
|
2331
|
+
let code = o.code;
|
|
2332
|
+
if (!code) {
|
|
2333
|
+
code = await readCodeFromStdin();
|
|
2334
|
+
}
|
|
2335
|
+
if (!code) {
|
|
2336
|
+
io.writeStderr("link-redeem needs an invite code: pipe it on stdin (e.g. `agent-board rooms link-redeem <<'MB_INVITE_CODE'` ... `MB_INVITE_CODE`) or pass --code (which leaks the bearer code via /proc and shell history).\n");
|
|
2337
|
+
process.exitCode = 1;
|
|
2338
|
+
return;
|
|
2339
|
+
}
|
|
2340
|
+
return runHandler(io, "agent_board.redeem_invite_link", () => client.redeemInviteLink({ p_code: code }));
|
|
2341
|
+
});
|
|
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
|
+
}));
|
|
1536
2411
|
}
|
|
1537
2412
|
export function createProgram(client, io, enrollment = defaultEnrollmentRunner()) {
|
|
1538
2413
|
const program = new Command()
|
|
@@ -1556,6 +2431,7 @@ export function createProgram(client, io, enrollment = defaultEnrollmentRunner()
|
|
|
1556
2431
|
addWriteCommands(program, client, io);
|
|
1557
2432
|
addReadCommands(program, client, io);
|
|
1558
2433
|
addRoomsCommands(program, client, io);
|
|
2434
|
+
addInviteCommands(program, client, io);
|
|
1559
2435
|
addEnrollCommands(program, io, enrollment);
|
|
1560
2436
|
return program;
|
|
1561
2437
|
}
|