@consiliency/agent-board-cli 1.9.0 → 1.11.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.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import { Command, Option } from "commander";
2
- import { createAgentBoardClient, toOperationFailure, beginEnrollment, completeEnrollment, runEnrollment, runDeviceEnrollment, resolveEnrollmentDescriptor, EnrollmentDescriptorError, resolveEnrollEndpoint, resolveEnrollSessionUrl, resolveEnrollPublishableKey, resolveJoinDeployment, nodeFetchAdapter, buildEnrollKeystore, } from "@consiliency/agent-board-client";
3
- import { FilePendingEnrollmentStore, makeBrowserOpener } from "./enroll_runtime.js";
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, normalizeFleetTupleValue, normalizeKeyReference, enrollCommitMarker, assertPresentationSafe, FLEET_TEMPLATE_FAILURE_MESSAGE, PROHIBITED_PRESENTATION_REMOVE_CLASS, 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,288 @@ function parseJson(value) {
13
16
  function numberOption(flags, description) {
14
17
  return new Option(flags, description).argParser((value) => Number(value));
15
18
  }
16
- async function runHandler(io, operation, action) {
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
+ /**
75
+ * A FLEET-TEMPLATE FAILURE is safe to print, and the post-commit fence cannot know that
76
+ * without being told (#214).
77
+ *
78
+ * The fence withholds a cause's text because a raw local failure can carry an
79
+ * `os_keychain[<handle>]` descriptor. The fleet-template module carries no such thing: its
80
+ * `fail()` composes every message from an authored `[a-z0-9_]+` code plus, in the
81
+ * presentation case, a field path already constrained to a plain identifier at the throw
82
+ * site — and it ENFORCES that shape on itself before throwing, so the guarantee is a
83
+ * property of the producer rather than a claim about it. Withholding these inverts the
84
+ * trade this whole change makes: refuse rather than rewrite is only better than a silent
85
+ * rewrite if the refusal is READABLE, and a failed command with no actionable reason after
86
+ * the server committed is the #176 shape.
87
+ *
88
+ * THE CLASS, NOT THE LINE. An earlier revision matched only `*_presentation_invalid:<path>`
89
+ * — the GENERIC content rule — and so redacted `invalid_receipt_key_id`, which is the
90
+ * refusal #214 is actually about and the one a reader would test by hand. `exact()` fires
91
+ * before the content sweep, so none of the legibility applied on the originating path.
92
+ * Matching the producer's whole vocabulary removes the category of that bug instead of
93
+ * adding a second special case beside the first.
94
+ *
95
+ * Trust is CHECKED at the boundary, not assumed from where the error came: same discipline
96
+ * as `namedCauseCode` below — a gate on the VALUE, never a cross-package `instanceof`,
97
+ * which is silently false when the workspace holds two copies of the client module.
98
+ * Anything else — an fs error, a keystore descriptor, an error from a module that is not
99
+ * this one — keeps the blanket redaction, which the #202 raw-cause test pins.
100
+ */
101
+ // Imported, never restated: the producer enforces this shape on itself before throwing,
102
+ // so a second copy here could only ever disagree with it — and the copy that decides
103
+ // whether text crosses a security boundary is the one that must not drift.
104
+ const FLEET_TEMPLATE_FAILURE = FLEET_TEMPLATE_FAILURE_MESSAGE;
105
+ function asFleetSafeMessage(error) {
106
+ if (!(error instanceof Error) || !FLEET_TEMPLATE_FAILURE.test(error.message))
107
+ return error;
108
+ return new FleetSafeMessageError("fleet_onboarding_template_refused", `${error.message} — the fleet template refused this enrollment's local finalization. `
109
+ + "Nothing was rewritten and nothing was written to this host. A `*_presentation_invalid:<field>` "
110
+ + "code means the named field carries a bidi or zero-width character; report it to whoever operates the board.");
111
+ }
112
+ /** Run `action`, letting only a fleet-template failure cross the fence as its own message. */
113
+ function throughPresentationGate(action) {
114
+ try {
115
+ return action();
116
+ }
117
+ catch (error) {
118
+ throw asFleetSafeMessage(error);
119
+ }
120
+ }
121
+ function namedCauseCode(cause) {
122
+ if (!cause || typeof cause !== "object")
123
+ return "";
124
+ const code = cause.code;
125
+ // Lower-case snake_case only: our own codes match, and a key descriptor
126
+ // (`os_keychain[handle]`), a path, or an upper-case OS errno does not.
127
+ if (typeof code !== "string" || !/^[a-z][a-z0-9_]{0,63}$/.test(code))
128
+ return "";
129
+ return ` [code ${code}]`;
130
+ }
131
+ function committedFactsFromOutcome(outcome) {
132
+ return {
133
+ agentId: outcome.agentId,
134
+ agentHandle: outcome.agentHandle,
135
+ keyId: outcome.keyId,
136
+ boardId: outcome.onboarding?.boardId ?? "",
137
+ machineId: outcome.onboarding?.machineId ?? "",
138
+ };
139
+ }
140
+ /**
141
+ * Everything inside `action` runs against a COMMITTED server enrollment. Marks any
142
+ * failure so the redaction below reports it truthfully instead of asserting it happened
143
+ * before completion.
144
+ */
145
+ async function afterFleetCommit(outcome, action) {
146
+ try {
147
+ return await action();
148
+ }
149
+ catch (error) {
150
+ throw error instanceof FleetPostCommitError ? error : new FleetPostCommitError(committedFactsFromOutcome(outcome), error);
151
+ }
152
+ }
153
+ /**
154
+ * Render only the ids that are actually present. An all-empty list (`agentId=,
155
+ * agentHandle=,`) is the shape both messages exist to avoid printing: it reads as
156
+ * evidence while carrying none.
157
+ */
158
+ function renderIds(facts) {
159
+ // Gated HERE, at the single render point, rather than at each producer. There are two
160
+ // producers of these facts — the client's marker (already normalized) and
161
+ // `committedFactsFromOutcome` below (straight from the outcome, never normalized) —
162
+ // and the type carries a stated invariant that a non-id-shaped value is not rendered.
163
+ // A per-producer gate makes that invariant a thing each new producer must remember;
164
+ // gating the render makes it hold for producers that do not exist yet. Without it a
165
+ // poisoned `agent_id` in an otherwise contract-valid receipt reaches stdout verbatim,
166
+ // newlines and all.
167
+ const printable = (value) => /^[A-Za-z0-9._:@/+=-]{1,128}$/.test(value);
168
+ // `agentId` gets a TYPED gate, not the generic one, and the asymmetry is deliberate.
169
+ //
170
+ // Of the four fields, three are already tied to something this process sent: the
171
+ // receipt check requires `board_id`/`machine_id`/`agent_handle` to EQUAL the
172
+ // authorized tuple, so a contract-valid receipt cannot carry a surprise there — the
173
+ // validation does what region 1 achieves by construction. `agent_id` is the one field
174
+ // the contract constrains only as non-empty, so it is the one place a receipt can
175
+ // carry an arbitrary server-chosen string into an operator's terminal, including a
176
+ // value we ourselves sent (the invite code) echoed back.
177
+ //
178
+ // The schema declares it: `agents.id uuid primary key`. So a non-UUID `agent_id` is
179
+ // off-contract, and rendering it as an agent id would be rendering something this
180
+ // field cannot legitimately be. This is a type check derived from the contract, not a
181
+ // guess at what "looks like an id".
182
+ //
183
+ // Residual, stated rather than implied: an on-path attacker who can forge a
184
+ // contract-valid receipt can also supply a well-formed UUID. No client-side check
185
+ // closes that; it needs a signature over the response.
186
+ 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);
187
+ return [
188
+ ["agentId", facts.agentId, isUuid],
189
+ ["agentHandle", facts.agentHandle, printable],
190
+ ["boardId", facts.boardId, printable],
191
+ ["machineId", facts.machineId, printable],
192
+ ].filter(([, value, ok]) => value !== "" && ok(value)).map(([key, value]) => `${key}=${value}`).join(", ");
193
+ }
194
+ /**
195
+ * Raised when the server's answer was OFF-CONTRACT, so whether it committed is unknown.
196
+ * This is the third state, and it exists because both of the other two would be a lie:
197
+ * "COMMITTED" tells the operator not to retry an enrollment that may never have
198
+ * happened and to write off an invite use that may be unspent, and "failed before
199
+ * completion" is the #176 shape. The operator is told what to verify instead of what to
200
+ * assume. As with the post-commit error, the cause's free text is withheld.
201
+ */
202
+ class FleetIndeterminateError extends FleetSafeMessageError {
203
+ constructor(marker, cause) {
204
+ // Only the ids that actually survived. An all-empty list (`agentId=, agentHandle=`)
205
+ // is the shape this state exists to stop printing — it reads as data while carrying
206
+ // none, and on the off-contract path there is frequently nothing to show.
207
+ const rendered = renderIds(marker.facts);
208
+ const observed = rendered
209
+ ? ` What this enrollment asked for, to verify against: ${rendered}.`
210
+ : " Nothing about this enrollment could be corroborated, so there is no id here to verify against.";
211
+ super("fleet_onboarding_commit_indeterminate",
212
+ // Covers both shapes: an off-contract status (201/202/204), and a 200 that carried
213
+ // no /enroll identity at all — a proxy or maintenance page the origin never saw.
214
+ `fleet enrollment status is UNKNOWN: the server answered HTTP ${marker.status ?? "(unknown)"} without a usable /enroll receipt. `
215
+ + "The enrollment MAY or MAY NOT have committed, and the bounded invite use may or may not be spent. "
216
+ + "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."
217
+ + observed);
218
+ this.cause = cause;
219
+ }
220
+ }
221
+ /**
222
+ * The enrollment call ITSELF (Consiliency/message-board#202). `afterFleetCommit` can only
223
+ * fence what follows the runner, but the runner commits INSIDE its own call: the /enroll
224
+ * POST returns 200 and the contract checks, receipt check and pending-store cleanup all
225
+ * run after it. Such a failure surfaced out of the runner, missed the fence entirely, and
226
+ * was flattened into "failed before completion" — telling the operator to re-run an
227
+ * enrollment that already happened, against an invite whose bounded use is spent.
228
+ *
229
+ * The client marks those errors at the 200-response boundary, where the truth is known;
230
+ * this converts the marker into the CLI's own truthful message. Errors with no marker are
231
+ * genuinely pre-commit and keep the blanket redaction. `runHandler` is unchanged — it
232
+ * still exempts exactly `FleetSafeMessageError` and nothing else.
233
+ */
234
+ async function throughFleetCommit(call) {
235
+ try {
236
+ return await call();
237
+ }
238
+ catch (error) {
239
+ const marker = enrollCommitMarker(error);
240
+ if (!marker)
241
+ throw error;
242
+ throw marker.state === "indeterminate"
243
+ ? new FleetIndeterminateError(marker, error)
244
+ : new FleetPostCommitError(marker.facts, error);
245
+ }
246
+ }
247
+ /**
248
+ * THE LAST PASS OVER A FAILURE ENVELOPE BEFORE A HUMAN READS IT (#214).
249
+ *
250
+ * The class this whole change chased was never "receipt values" and never "server
251
+ * messages": it is ANYTHING THAT BECOMES OPERATOR-VISIBLE TEXT, and each review round
252
+ * found that class one sink wider. The field-level version of this failed twice in the
253
+ * same envelope — `boundServerText` cleans the server's `message`, and `error.code` is
254
+ * emitted beside it untouched, so a response carrying `code: "denied<RLO>allowed"` still
255
+ * put a bidi override in stdout.
256
+ *
257
+ * So this gate is on the ENVELOPE, at the one point where it becomes visible, rather than
258
+ * on the fields. That is what makes the NEXT field free instead of another round: a field
259
+ * added to the failure shape later is covered the day it is added, which is exactly how
260
+ * `code` came to be missed.
261
+ *
262
+ * REMOVE, not refuse — the same verb and the same reason as `boundServerText`: this is a
263
+ * diagnostic being rendered, nothing here is carried onward, and refusing to print a
264
+ * failure envelope would delete the operator's only information about the failure.
265
+ *
266
+ * TWO THINGS IT MUST NOT DO, both asserted rather than intended:
267
+ * - It must not weaken the fleet fence. It runs on the SERIALIZED text, after the
268
+ * redaction and the presentation gate have decided what the envelope says; it is a
269
+ * final pass, never a replacement for either.
270
+ * - It must leave `fleet_harness_template_*_presentation_invalid:<path>` intact. That
271
+ * message is ASCII by construction (the path alphabet is gated at the throw site), so
272
+ * the strip is a no-op on it — stripping it would undo the round that made a refusal
273
+ * readable.
274
+ *
275
+ * Applied AFTER `JSON.stringify`, which is safe and deliberate: `JSON.stringify` escapes
276
+ * C0 controls to `\uXXXX` itself, so the only members that reach the text are the raw
277
+ * presentation characters, and removing those from inside string literals cannot make the
278
+ * document invalid — no member of the class can occur in a structural position.
279
+ *
280
+ * Own compiled instance from the shared class SOURCE (`text_safety.ts`), REMOVE verb: the
281
+ * same class `clean()` uses for rendered prose, because this is rendered prose.
282
+ */
283
+ const OPERATOR_VISIBLE_PROHIBITED = new RegExp(`[${PROHIBITED_PRESENTATION_REMOVE_CLASS}]`, "gu");
284
+ function writeFailureEnvelope(io, failure, indent = 2) {
285
+ const serialized = indent === 0 ? JSON.stringify(failure) : JSON.stringify(failure, null, indent);
286
+ io.writeStdout(`${serialized.replace(OPERATOR_VISIBLE_PROHIBITED, "")}\n`);
287
+ }
288
+ async function runHandler(io, operation, action, redactFleetFailure = false) {
17
289
  try {
18
290
  const result = await action();
19
291
  io.writeStdout(`${JSON.stringify(result, null, 2)}\n`);
20
292
  }
21
293
  catch (error) {
22
- io.writeStdout(`${JSON.stringify(toOperationFailure(operation, error), null, 2)}\n`);
294
+ const failure = toOperationFailure(operation, error);
295
+ // Redact only what we did not write. A post-commit failure keeps its truthful
296
+ // message; a pre-flight refusal keeps the variable name the operator must set.
297
+ if (redactFleetFailure && !(error instanceof FleetSafeMessageError)) {
298
+ failure.error.message = "fleet enrollment failed before completion";
299
+ }
300
+ writeFailureEnvelope(io, failure);
23
301
  process.exitCode = 1;
24
302
  }
25
303
  }
@@ -28,7 +306,7 @@ async function runJsonlHandler(io, operation, action) {
28
306
  io.writeStdout(`${JSON.stringify(await action())}\n`);
29
307
  }
30
308
  catch (error) {
31
- io.writeStdout(`${JSON.stringify(toOperationFailure(operation, error))}\n`);
309
+ writeFailureEnvelope(io, toOperationFailure(operation, error), 0);
32
310
  process.exitCode = 1;
33
311
  }
34
312
  }
@@ -1212,6 +1490,10 @@ const processWriter = {
1212
1490
  writeStdout: (message) => process.stdout.write(message),
1213
1491
  writeStderr: (message) => process.stderr.write(message),
1214
1492
  };
1493
+ function defaultDevicePendingStateDir(env) {
1494
+ const configured = env.XDG_STATE_HOME?.trim();
1495
+ return join(configured || join(homedir(), ".local", "state"), "agent-board", "pending-device-enroll");
1496
+ }
1215
1497
  // The default runner drives the live enrollment flow: `run` = one-shot loopback +
1216
1498
  // browser opener; `begin`/`complete` = headless OOB two-step bridged by a
1217
1499
  // file-backed store (loopback cannot span two CLI processes). Every seam is
@@ -1227,7 +1509,9 @@ export function defaultEnrollmentRunner(env = process.env, deps = {}) {
1227
1509
  async run(input) {
1228
1510
  const descriptor = resolveEnrollmentDescriptor(env);
1229
1511
  const enrollEndpoint = deps.enrollEndpoint ?? resolveEnrollEndpoint(env);
1230
- const keystore = deps.keystore ?? buildEnrollKeystore(descriptor, env);
1512
+ const keystore = deps.keystore ?? buildEnrollKeystore(descriptor, env, {
1513
+ ...(input.intent.kind === "fleet_onboarding" ? { fleetOnboarding: input.intent } : {}),
1514
+ });
1231
1515
  const openAuthorization = deps.openAuthorization ?? makeBrowserOpener(deps.io ?? processWriter);
1232
1516
  return runEnrollment(descriptor, input.intent, {
1233
1517
  slug: input.slug,
@@ -1261,7 +1545,9 @@ export function defaultEnrollmentRunner(env = process.env, deps = {}) {
1261
1545
  // Supabase board — pinned by the device analogue of the `run` e2e (drop either
1262
1546
  // and the sessionUrl-override / anon-apikey assertion fails).
1263
1547
  const enrollEndpoint = deps.enrollEndpoint ?? resolveEnrollEndpoint(env);
1264
- const keystore = deps.keystore ?? buildEnrollKeystore(descriptor, env);
1548
+ const keystore = deps.keystore ?? buildEnrollKeystore(descriptor, env, {
1549
+ ...(input.intent.kind === "fleet_onboarding" ? { fleetOnboarding: input.intent } : {}),
1550
+ });
1265
1551
  return runDeviceEnrollment(descriptor, input.intent, {
1266
1552
  slug: input.slug,
1267
1553
  agentKind: input.agentKind,
@@ -1280,6 +1566,157 @@ export function defaultEnrollmentRunner(env = process.env, deps = {}) {
1280
1566
  now: deps.now,
1281
1567
  });
1282
1568
  },
1569
+ async deviceBegin(input) {
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 continuation = await beginDeviceEnrollment(descriptor, input.intent, {
1575
+ fetch,
1576
+ enrollEndpoint: deps.enrollEndpoint ?? resolveEnrollEndpoint(env),
1577
+ keystore: deps.keystore ?? buildEnrollKeystore(descriptor, env, {
1578
+ ...(input.intent.kind === "fleet_onboarding" ? { fleetOnboarding: input.intent } : {}),
1579
+ }),
1580
+ boardFetch,
1581
+ sessionUrl,
1582
+ publishableKey,
1583
+ onDeviceAuthorization: async () => { },
1584
+ now: deps.now,
1585
+ });
1586
+ const enrollmentId = input.enrollmentId ?? `enr-${randomUUID()}`;
1587
+ const store = deps.deviceStore ?? new DevicePendingEnrollmentStore(defaultDevicePendingStateDir(env));
1588
+ await store.put({
1589
+ flow: "device_code",
1590
+ enrollmentId,
1591
+ deviceCode: continuation.deviceCode,
1592
+ interval: continuation.prompt.interval,
1593
+ expiresAt: continuation.expiresAt,
1594
+ expiresIn: continuation.prompt.expiresIn,
1595
+ userCode: continuation.prompt.userCode,
1596
+ verificationUri: continuation.prompt.verificationUri,
1597
+ verificationUriComplete: continuation.prompt.verificationUriComplete,
1598
+ intent: input.intent,
1599
+ slug: input.slug,
1600
+ agentKind: input.agentKind,
1601
+ requestedScopes: input.requestedScopes,
1602
+ requestedBoardScopes: input.requestedBoardScopes,
1603
+ });
1604
+ return { enrollmentId, prompt: continuation.prompt };
1605
+ },
1606
+ async deviceComplete(input) {
1607
+ const store = deps.deviceStore ?? new DevicePendingEnrollmentStore(defaultDevicePendingStateDir(env));
1608
+ const pending = await store.get(input.enrollmentId);
1609
+ if (!pending) {
1610
+ throw new EnrollmentCommandError("unknown_device_enrollment", "device enrollment is unknown");
1611
+ }
1612
+ // Consiliency/message-board#210 — the READ door for the DEVICE store, symmetric with
1613
+ // `completeEnrollment`'s (enroll_command.ts). Keyed on the intent READ FROM THE
1614
+ // STORE, never on the caller's flag: `enroll device --enrollment-id <id>` without
1615
+ // --fleet-onboarding passes no `expectedFleet`, and every fleet check below is gated
1616
+ // on it, so a fleet pending would be handed to completeDeviceEnrollment and
1617
+ // COMMITTED with no publishable-key pre-flight, no runtime output, no finalization
1618
+ // and no post-commit fence — and then deleted below, destroying the correct resume's
1619
+ // own state.
1620
+ //
1621
+ // The pending is deliberately NOT deleted: the operator's recovery is to re-run the
1622
+ // same resume WITH the flag, and that needs this record. Deleting here would make
1623
+ // the refusal as destructive as the bug.
1624
+ //
1625
+ // This is the missing direction of a biconditional: `expectedFleet` present against
1626
+ // a non-fleet pending already throws below. Fleet-ness of the PENDING decides; the
1627
+ // flag must agree with it in both directions.
1628
+ //
1629
+ // Placement is load-bearing three ways: AFTER the `!pending` throw (an unknown id
1630
+ // keeps `unknown_device_enrollment`), BEFORE the descriptor/keystore/endpoint work
1631
+ // below (no local side effects), and BEFORE any network call (the device
1632
+ // authorization is not spent).
1633
+ if (pending.intent.kind === "fleet_onboarding" && !input.expectedFleet) {
1634
+ throw new FleetPreflightError("fleet_device_resume_requires_fleet_flag", "this pending device enrollment is a fleet onboarding and cannot be resumed without "
1635
+ + "--fleet-onboarding: that flag is what performs the publishable-key pre-flight, the "
1636
+ + "runtime output and the fleet finalization. Nothing was committed and the pending "
1637
+ + "was kept. Re-run the same resume with --fleet-onboarding, the same bounded tuple "
1638
+ + "(--board-id/--machine-id/--harness-kind/--harness-label/--repo-slug/"
1639
+ + "--repo-fingerprint/--repo-scope-role/--agent-role/--expected-agent-handle/"
1640
+ + "--local-key-id), --slug and --runtime-output.");
1641
+ }
1642
+ if (input.expectedFleet) {
1643
+ if (pending.intent.kind !== "fleet_onboarding") {
1644
+ throw new EnrollmentCommandError("fleet_device_intent_mismatch", "device enrollment is not a fleet enrollment");
1645
+ }
1646
+ const { inviteCode: _inviteCode, ...storedIntent } = pending.intent;
1647
+ if (JSON.stringify(storedIntent) !== JSON.stringify(input.expectedFleet.intent)) {
1648
+ throw new EnrollmentCommandError("fleet_device_intent_mismatch", "device enrollment fleet bounds do not match");
1649
+ }
1650
+ if (pending.slug !== input.expectedFleet.slug) {
1651
+ throw new EnrollmentCommandError("fleet_device_slug_mismatch", "device enrollment agent slug does not match");
1652
+ }
1653
+ const currentReference = normalizeKeyReference(resolveEnrollmentDescriptor(env).keyReference);
1654
+ if (`${currentReference.resolver}:${currentReference.handle}` !== input.expectedFleet.keyReference) {
1655
+ throw new EnrollmentCommandError("fleet_device_key_reference_mismatch", "device enrollment key reference does not match current configuration");
1656
+ }
1657
+ }
1658
+ const descriptor = resolveEnrollmentDescriptor(env);
1659
+ if (!descriptor.issuer.deviceAuthorizationEndpoint) {
1660
+ throw new EnrollmentDescriptorError("missing_device_endpoint", "device enrollment needs a device authorization endpoint (set MESSAGE_BOARD_ENROLL_DEVICE_AUTHORIZATION_ENDPOINT)");
1661
+ }
1662
+ const keystore = deps.keystore ?? buildEnrollKeystore(descriptor, env, {
1663
+ ...(pending.intent.kind === "fleet_onboarding" ? { fleetOnboarding: pending.intent } : {}),
1664
+ });
1665
+ const result = await completeDeviceEnrollment(descriptor, pending.intent, {
1666
+ slug: pending.slug,
1667
+ agentKind: pending.agentKind,
1668
+ requestedScopes: pending.requestedScopes,
1669
+ requestedBoardScopes: pending.requestedBoardScopes,
1670
+ }, {
1671
+ deviceCode: pending.deviceCode,
1672
+ expiresAt: pending.expiresAt,
1673
+ prompt: {
1674
+ userCode: pending.userCode,
1675
+ verificationUri: pending.verificationUri,
1676
+ verificationUriComplete: pending.verificationUriComplete,
1677
+ expiresIn: pending.expiresIn,
1678
+ interval: pending.interval,
1679
+ },
1680
+ }, {
1681
+ fetch,
1682
+ enrollEndpoint: deps.enrollEndpoint ?? resolveEnrollEndpoint(env),
1683
+ keystore,
1684
+ boardFetch,
1685
+ keystoreBackends: keystore.backends,
1686
+ sessionUrl,
1687
+ publishableKey,
1688
+ onDeviceAuthorization: async () => { },
1689
+ sleep: deps.sleep,
1690
+ now: deps.now,
1691
+ });
1692
+ // POST-COMMIT and unmarked: `completeDeviceEnrollment` above has returned, so the
1693
+ // enrollment is committed and this delete is local cleanup on top of it.
1694
+ //
1695
+ // An earlier version of this comment said the fleet path DOES reach here, via the
1696
+ // resume that omits `--fleet-onboarding`. That premise died with
1697
+ // Consiliency/message-board#210: the guard at the top of this method refuses a fleet
1698
+ // pending whose caller passed no `expectedFleet`, before any network call, and the
1699
+ // CLI's fleet resume passes `deletePending: false`. Neither CLI branch arrives here
1700
+ // with a fleet enrollment.
1701
+ //
1702
+ // What remains is a DIRECT runner caller that supplies a fleet-guarded
1703
+ // `expectedFleet` and lets `deletePending` default — a caller that has by
1704
+ // construction already done the fleet pre-flight, and that is inside the CLI's fence
1705
+ // only if it is the CLI.
1706
+ //
1707
+ // Marker decision, recorded rather than omitted: NO #202 marker here.
1708
+ // `markEnrollCommitted`/`throughFleetCommit` exist to turn an error raised inside a
1709
+ // committed call into a truthful post-commit message for a caller that HAS a fence.
1710
+ // On the only path that still reaches this line there is no such fence to feed, so a
1711
+ // marker would imply one that does not exist.
1712
+ if (input.deletePending !== false)
1713
+ await store.delete(input.enrollmentId);
1714
+ return result;
1715
+ },
1716
+ async deviceDeletePending(input) {
1717
+ const store = deps.deviceStore ?? new DevicePendingEnrollmentStore(defaultDevicePendingStateDir(env));
1718
+ await store.delete(input.enrollmentId);
1719
+ },
1283
1720
  async begin(input) {
1284
1721
  // Force OOB: a loopback receiver cannot bridge two separate CLI processes, so
1285
1722
  // the two-step path is always out-of-band paste (no redirectUri ⇒ OOB).
@@ -1306,8 +1743,11 @@ export function defaultEnrollmentRunner(env = process.env, deps = {}) {
1306
1743
  async complete(input) {
1307
1744
  const descriptor = resolveEnrollmentDescriptor(env);
1308
1745
  const enrollEndpoint = deps.enrollEndpoint ?? resolveEnrollEndpoint(env);
1309
- const keystore = deps.keystore ?? buildEnrollKeystore(descriptor, env);
1310
1746
  const store = deps.store ?? new FilePendingEnrollmentStore(env);
1747
+ const pending = await store.get(input.enrollmentId);
1748
+ const keystore = deps.keystore ?? buildEnrollKeystore(descriptor, env, {
1749
+ ...(pending?.intent.kind === "fleet_onboarding" ? { fleetOnboarding: pending.intent } : {}),
1750
+ });
1311
1751
  return completeEnrollment({
1312
1752
  enrollmentId: input.enrollmentId,
1313
1753
  code: input.code,
@@ -1350,6 +1790,293 @@ function csv(value) {
1350
1790
  .filter(Boolean)
1351
1791
  : undefined;
1352
1792
  }
1793
+ const DEFAULT_PUBLISHABLE_KEY_FIELD_REFERENCE = "MESSAGE_BOARD_ENROLL_PUBLISHABLE_KEY";
1794
+ /**
1795
+ * Resolve the publishable key fleet finalization REQUIRES from this host's environment.
1796
+ * The pre-flight (before any server call) and the post-commit finalization both go
1797
+ * through this one function, so they cannot drift about which variable is read or what
1798
+ * counts as present — the drift is what made a missing variable a post-commit failure.
1799
+ */
1800
+ function resolveFleetPublishableKey(publishableKeyFieldReference) {
1801
+ const reference = normalizePublishableKeyFieldReference(publishableKeyFieldReference ?? DEFAULT_PUBLISHABLE_KEY_FIELD_REFERENCE);
1802
+ const value = process.env[reference]?.trim() ?? "";
1803
+ if (!value) {
1804
+ throw new FleetPreflightError("fleet_publishable_key_missing", `fleet onboarding requires the publishable key in this host's environment: export ${reference}=<project anon/publishable key>. `
1805
+ + "The variable name was frozen at mint time by --publishable-key-field-reference.");
1806
+ }
1807
+ return value;
1808
+ }
1809
+ function buildPendingFleetTemplate(intent, publishableKeyFieldReference = DEFAULT_PUBLISHABLE_KEY_FIELD_REFERENCE) {
1810
+ const suffix = `-${intent.harnessKind}`;
1811
+ if (!intent.expectedAgentHandle.endsWith(suffix))
1812
+ throw new Error("fleet_onboarding_expected_handle_invalid");
1813
+ const host = intent.expectedAgentHandle.slice(0, -suffix.length);
1814
+ const deployment = resolveJoinDeployment();
1815
+ if (!deployment.keyReference)
1816
+ throw new Error("fleet_onboarding_key_reference_missing");
1817
+ const ref = normalizeKeyReference(deployment.keyReference);
1818
+ const platform = ref.resolver === "os_keychain" ? "desktop" : ref.resolver === "agent_socket" ? "headless_linux" : null;
1819
+ if (!platform)
1820
+ throw new Error("fleet_onboarding_unsupported_key_reference");
1821
+ const separator = ref.handle.lastIndexOf("#");
1822
+ const pending = buildFleetHarnessTemplate({ host, harnessKind: intent.harnessKind, harnessLabel: intent.harnessLabel, platform,
1823
+ boardId: intent.boardId, machineId: intent.machineId, repoSlug: intent.repoSlug, repoFingerprint: intent.repoFingerprint,
1824
+ repoScopeRole: intent.repoScopeRole, agentRole: intent.agentRole, boardUrl: deployment.boardUrl, sessionUrl: deployment.sessionUrl ?? deployment.boardUrl,
1825
+ publishableKeyFieldReference,
1826
+ ...(platform === "headless_linux" ? { agentSocketPath: ref.handle.slice(0, separator), publicKeySelector: separator > 0 ? ref.handle.slice(separator + 1) : "" } : {}),
1827
+ });
1828
+ if (pending.localKeyId !== intent.localKeyId || pending.keyReference !== `${ref.resolver}:${ref.handle}`)
1829
+ throw new Error("fleet_onboarding_local_key_mismatch");
1830
+ return pending;
1831
+ }
1832
+ async function finalizeFleetOutcome(options, intent, result, frozenPending) {
1833
+ if (intent.kind !== "fleet_onboarding")
1834
+ return result;
1835
+ // ONE gate over the whole local-finalization phase, rather than one per client call.
1836
+ // Two consequences, both wanted: every fleet-template refusal raised anywhere in here
1837
+ // becomes readable, and every OTHER failure in here (the fs publication error the #202
1838
+ // test drives, a keystore descriptor, a normalizeKeyReference throw) passes through the
1839
+ // same gate and is REJECTED by it — which makes that pre-existing test the discriminating
1840
+ // negative pin for this gate rather than a case that never reaches it.
1841
+ try {
1842
+ return await finalizeFleetOutcomeInner(options, intent, result, frozenPending);
1843
+ }
1844
+ catch (error) {
1845
+ throw asFleetSafeMessage(error);
1846
+ }
1847
+ }
1848
+ async function finalizeFleetOutcomeInner(options, intent, result, frozenPending) {
1849
+ if (intent.kind !== "fleet_onboarding")
1850
+ return result;
1851
+ if (!options.runtimeOutput)
1852
+ throw new Error("--fleet-onboarding requires --runtime-output");
1853
+ const outcome = result;
1854
+ // LIVE, unlike its same-named twin in enroll_command.ts (message-board#212). That one
1855
+ // sits after an `enrollAgent` call whose return shape the same function controls, so
1856
+ // nothing can produce the state it tests. This one consumes whatever the INJECTED
1857
+ // `EnrollmentCommandRunner` returned — a seam any `runCli` caller supplies — so a
1858
+ // runner that resolves a fleet enrollment without its receipt reaches here. Verified by
1859
+ // probe, not by reading. Do not delete it alongside the other one.
1860
+ if (!outcome.onboarding)
1861
+ throw new Error("fleet_onboarding_receipt_missing");
1862
+ const ref = normalizeKeyReference(outcome.keyReference);
1863
+ const pending = frozenPending ?? buildPendingFleetTemplate(intent, options.publishableKeyFieldReference);
1864
+ if (pending.localKeyId !== intent.localKeyId || pending.keyReference !== `${ref.resolver}:${ref.handle}`)
1865
+ throw new Error("fleet_onboarding_local_key_mismatch");
1866
+ const fingerprint = ref.publicKeyFingerprint ?? `sha256:${createHash("sha256").update(Buffer.from(outcome.agentPublicKey, "base64url")).digest("base64url")}`;
1867
+ const resolvedPublishableKey = resolveFleetPublishableKey(pending.publishableKeyFieldReference);
1868
+ // Hoisted rather than inlined into the call: wrapping the call in an arrow drops the
1869
+ // object literal's contextual typing, which turns every spread field optional. The cast
1870
+ // restates what the `!outcome.onboarding` guard above already established.
1871
+ const receipt = { ...outcome.onboarding, agentId: outcome.agentId,
1872
+ signingKeyId: outcome.signingKeyId, keyId: outcome.keyId, keyVersion: outcome.keyVersion,
1873
+ agentHandle: outcome.agentHandle };
1874
+ const finalized = finalizeFleetHarnessTemplate(pending, receipt, { keyReference: pending.keyReference, publicKey: outcome.agentPublicKey, publicKeyFingerprint: fingerprint }, resolvedPublishableKey);
1875
+ // Shaped (and therefore content-checked) BEFORE anything is written to disk.
1876
+ const enrollment = fleetSafeOutput(outcome);
1877
+ const published = await publishFinalizedFleetTemplate(options.runtimeOutput, finalized);
1878
+ return { enrollment, materialization: { generation: published.generation, digest: published.digest } };
1879
+ }
1880
+ export const FLEET_TUPLE_DISPOSITION = Object.freeze({
1881
+ // Labels are the library's own, so an early refusal here carries the identical code the
1882
+ // later `buildFleetHarnessTemplate` boundary would have produced. `cli.test.ts` derives
1883
+ // that equality rather than reciting it.
1884
+ boardId: { kind: "gated", flag: "--board-id", label: "board_id", wire: "refused" },
1885
+ machineId: { kind: "gated", flag: "--machine-id", label: "machine_id", wire: "refused" },
1886
+ harnessLabel: { kind: "gated", flag: "--harness-label", label: "harness_label", wire: "refused" },
1887
+ repoSlug: { kind: "gated", flag: "--repo-slug", label: "repo_slug", wire: "refused" },
1888
+ repoFingerprint: { kind: "gated", flag: "--repo-fingerprint", label: "repo_fingerprint", wire: "refused" },
1889
+ // `atom()` trims AND lowercases this field, and that normalization is deliberate and
1890
+ // pinned (fleet_onboarding.test.ts, "atom() normalizes an edge-placed U+FEFF"). Dropping
1891
+ // the fold here is not a no-op: `buildPendingFleetTemplate` requires the expected handle
1892
+ // to end with `-<harnessKind>`, so `--harness-kind CODEX` would stop working.
1893
+ harnessKind: { kind: "normalized", flag: "--harness-kind", why: "atom()-gated: folded, and the folded value is what crosses", wire: "clean",
1894
+ normalize: (value) => value.trim().toLowerCase() },
1895
+ // Consumed by the `endsWith` suffix check and by `host = handle.slice(...)`, which goes
1896
+ // through `atom()`; the template's handle is DERIVED, never this string. Removing the
1897
+ // trim turns a trailing space into `fleet_onboarding_expected_handle_invalid`.
1898
+ expectedAgentHandle: { kind: "normalized", flag: "--expected-agent-handle", why: "the template handle is derived from atoms; this string only has to agree with it", wire: "clean",
1899
+ normalize: (value) => value.trim() },
1900
+ // Pinned by EQUALITY against the derived `agent-board/<host>/<harness>` — strictly
1901
+ // stronger than a class check, and the derived value is the one that crosses.
1902
+ localKeyId: { kind: "normalized", flag: "--local-key-id", why: "equality-pinned against a derived key id", wire: "clean",
1903
+ normalize: (value) => value.trim() },
1904
+ // A bearer secret the server validates; carried into no template field. Its stdin ingress
1905
+ // is already trimmed once, where a trailing newline is a stream terminator rather than a
1906
+ // typed character.
1907
+ inviteCode: { kind: "normalized", flag: "--invite-code", why: "server-validated bearer secret; reaches no template field", wire: "verbatim",
1908
+ normalize: (value) => value.trim() },
1909
+ repoScopeRole: { kind: "vocabulary", flag: "--repo-scope-role", why: "worker | reviewer, checked by the pair rule below", wire: "refused" },
1910
+ agentRole: { kind: "vocabulary", flag: "--agent-role", why: "phase_worker | reviewer, checked by the pair rule below", wire: "refused" },
1911
+ kind: { kind: "vocabulary", why: "a literal this file writes; never operator-supplied", wire: "refused" },
1912
+ });
1913
+ /** Apply one field's dispositioned verb. The only place an intent field is normalized. */
1914
+ function fleetTupleValue(field, value) {
1915
+ const disposition = FLEET_TUPLE_DISPOSITION[field];
1916
+ if (disposition.kind === "gated")
1917
+ return normalizeFleetTupleValue(value, disposition.label);
1918
+ if (disposition.kind === "normalized")
1919
+ return disposition.normalize(value);
1920
+ return value;
1921
+ }
1922
+ function fleetResumeIntentFromCli(options) {
1923
+ const required = [
1924
+ ["--slug", options.slug], ["--board-id", options.boardId], ["--machine-id", options.machineId],
1925
+ ["--harness-kind", options.harnessKind], ["--harness-label", options.harnessLabel],
1926
+ ["--repo-slug", options.repoSlug], ["--repo-fingerprint", options.repoFingerprint],
1927
+ ["--repo-scope-role", options.repoScopeRole], ["--agent-role", options.agentRole],
1928
+ ["--expected-agent-handle", options.expectedAgentHandle], ["--local-key-id", options.localKeyId],
1929
+ ["--runtime-output", options.runtimeOutput],
1930
+ ];
1931
+ const missing = required.filter(([, value]) => !value?.trim()).map(([name]) => name);
1932
+ if (missing.length)
1933
+ throw new Error(`fleet device resume is missing: ${missing.join(", ")}`);
1934
+ const pair = (options.agentRole === "phase_worker" && options.repoScopeRole === "worker")
1935
+ || (options.agentRole === "reviewer" && options.repoScopeRole === "reviewer");
1936
+ if (!pair)
1937
+ throw new Error("fleet device resume allows only phase_worker/worker or reviewer/reviewer");
1938
+ // Synchronous, so the presentation gate can convert a refusal into its own message here
1939
+ // rather than letting the blanket fleet redaction flatten it (#254).
1940
+ return throughPresentationGate(() => ({
1941
+ kind: "fleet_onboarding", inviteCode: "",
1942
+ boardId: fleetTupleValue("boardId", options.boardId), machineId: fleetTupleValue("machineId", options.machineId),
1943
+ harnessKind: fleetTupleValue("harnessKind", options.harnessKind), harnessLabel: fleetTupleValue("harnessLabel", options.harnessLabel),
1944
+ repoSlug: fleetTupleValue("repoSlug", options.repoSlug), repoFingerprint: fleetTupleValue("repoFingerprint", options.repoFingerprint),
1945
+ repoScopeRole: options.repoScopeRole,
1946
+ agentRole: options.agentRole,
1947
+ expectedAgentHandle: fleetTupleValue("expectedAgentHandle", options.expectedAgentHandle),
1948
+ localKeyId: fleetTupleValue("localKeyId", options.localKeyId),
1949
+ }));
1950
+ }
1951
+ async function enrollmentIntentFromCli(options) {
1952
+ if (!options.fleetOnboarding)
1953
+ return intentFromOptions(options);
1954
+ if (options.createBoard || options.joinBoard || options.board === false) {
1955
+ throw new Error("--fleet-onboarding cannot be combined with --create-board, --join-board, or --no-board");
1956
+ }
1957
+ let inviteCode = options.inviteCode;
1958
+ if (!inviteCode)
1959
+ inviteCode = await readCodeFromStdin();
1960
+ const required = [
1961
+ ["invite code (stdin or --invite-code)", inviteCode],
1962
+ ["--board-id", options.boardId], ["--machine-id", options.machineId],
1963
+ ["--harness-kind", options.harnessKind], ["--harness-label", options.harnessLabel],
1964
+ ["--repo-slug", options.repoSlug], ["--repo-fingerprint", options.repoFingerprint],
1965
+ ["--repo-scope-role", options.repoScopeRole], ["--agent-role", options.agentRole],
1966
+ ["--expected-agent-handle", options.expectedAgentHandle], ["--local-key-id", options.localKeyId],
1967
+ ];
1968
+ const missing = required.filter(([, value]) => !value?.trim()).map(([name]) => name);
1969
+ if (missing.length)
1970
+ throw new Error(`--fleet-onboarding is missing: ${missing.join(", ")}`);
1971
+ const pair = (options.agentRole === "phase_worker" && options.repoScopeRole === "worker")
1972
+ || (options.agentRole === "reviewer" && options.repoScopeRole === "reviewer");
1973
+ if (!pair)
1974
+ throw new Error("--fleet-onboarding allows only phase_worker/worker or reviewer/reviewer");
1975
+ // The stdin read above is the only `await` on this path, and it is already done — so the
1976
+ // gate wraps a purely SYNCHRONOUS construction and cannot miss a refusal that would
1977
+ // otherwise surface as an unconverted promise rejection.
1978
+ return throughPresentationGate(() => ({
1979
+ kind: "fleet_onboarding",
1980
+ inviteCode: fleetTupleValue("inviteCode", inviteCode),
1981
+ boardId: fleetTupleValue("boardId", options.boardId),
1982
+ machineId: fleetTupleValue("machineId", options.machineId),
1983
+ harnessKind: fleetTupleValue("harnessKind", options.harnessKind),
1984
+ harnessLabel: fleetTupleValue("harnessLabel", options.harnessLabel),
1985
+ repoSlug: fleetTupleValue("repoSlug", options.repoSlug),
1986
+ repoFingerprint: fleetTupleValue("repoFingerprint", options.repoFingerprint),
1987
+ repoScopeRole: options.repoScopeRole,
1988
+ agentRole: options.agentRole,
1989
+ expectedAgentHandle: fleetTupleValue("expectedAgentHandle", options.expectedAgentHandle),
1990
+ localKeyId: fleetTupleValue("localKeyId", options.localKeyId),
1991
+ }));
1992
+ }
1993
+ /**
1994
+ * Every field DECLARED on `FleetOnboardingReceipt` (including the nine it inherits from
1995
+ * `FleetOnboardingBounds`) except `publicKeyFingerprint`, which is deliberately withheld.
1996
+ *
1997
+ * Consiliency/message-board#224 — this list exists because the receipt used to be rebuilt
1998
+ * as `const { publicKeyFingerprint: _f, ...receipt } = outcome.onboarding`, a DENYLIST OF
1999
+ * ONE: every undeclared property spread straight to stdout. The outcome being shaped here
2000
+ * arrives from an INJECTED `EnrollmentCommandRunner`, so it is not a value this code
2001
+ * produced, and `FleetOnboardingIntent` extends the same bounds while adding `inviteCode`
2002
+ * — an intent-shaped receipt therefore leaked the bounded BEARER SECRET. An allowlist
2003
+ * cannot leak a field nobody listed; a denylist can only stop what someone anticipated.
2004
+ *
2005
+ * Kept honest by `enroll_commit_sites.test.ts`, which derives the declared field set from
2006
+ * `enrollment_types.ts` and fails if this list and the type disagree in EITHER direction —
2007
+ * so a field added to the receipt cannot be silently dropped here, which is the regression
2008
+ * an allowlist invites.
2009
+ */
2010
+ const FLEET_RECEIPT_FIELDS = [
2011
+ "boardId", "machineId", "harnessKind", "harnessLabel", "repoSlug", "repoFingerprint",
2012
+ "repoScopeRole", "agentRole", "expectedAgentHandle",
2013
+ "contractVersion", "harnessRegistrationId", "repoScopeId", "fleetMachineAgentId",
2014
+ "bindingSigningKeyId", "membershipRole", "membershipState",
2015
+ "identityDisposition", "membershipDisposition", "harnessDisposition",
2016
+ "repoScopeDisposition", "bindingDisposition",
2017
+ ];
2018
+ /** The field withheld from output even though the type declares it. */
2019
+ const FLEET_RECEIPT_WITHHELD = "publicKeyFingerprint";
2020
+ function fleetSafeOutput(result) {
2021
+ if (!result || typeof result !== "object")
2022
+ return result;
2023
+ const outcome = result;
2024
+ if (!outcome.onboarding)
2025
+ return result;
2026
+ const source = outcome.onboarding;
2027
+ const receipt = {};
2028
+ // Copy BY NAME. A property absent from the source stays absent rather than becoming
2029
+ // an explicit `undefined`, so a partial receipt shapes to a partial receipt.
2030
+ for (const field of FLEET_RECEIPT_FIELDS) {
2031
+ if (field in source)
2032
+ receipt[field] = source[field];
2033
+ }
2034
+ const shaped = {
2035
+ agentId: outcome.agentId,
2036
+ authUserId: outcome.authUserId,
2037
+ providerId: outcome.providerId,
2038
+ signingKeyId: outcome.signingKeyId,
2039
+ keyId: outcome.keyId,
2040
+ keyVersion: outcome.keyVersion,
2041
+ agentHandle: outcome.agentHandle,
2042
+ onboarding: receipt,
2043
+ };
2044
+ // #214, second round. This allowlist bounds WHICH fields reach the operator's terminal;
2045
+ // it never bounded their CONTENT, and `JSON.stringify` (how runHandler prints this)
2046
+ // escapes C0 controls while passing bidi overrides and zero-width characters through
2047
+ // verbatim. A poisoned-but-truthy id therefore printed as a different id than the one
2048
+ // enrolled, at the moment the operator decides the enrollment is correct.
2049
+ //
2050
+ // The check lives HERE, in the shaping function, rather than at the fleet finalize call
2051
+ // site, because there are TWO routes to a fleet-shaped stdout and only one of them
2052
+ // passes through `finalizeFleetOutcome`: `enroll device --enrollment-id` returns this
2053
+ // directly from the injected runner (#210). Placing it at the call site would have
2054
+ // gated one route and left the other — the same one-consumer scoping error that let
2055
+ // this sink exist. The invariant is now a property of the shaper: everything it returns
2056
+ // is presentation-safe or nothing is returned at all.
2057
+ //
2058
+ // Two of these fields (`authUserId`, `providerId`) are NOT in the fleet receipt, so the
2059
+ // client-side receipt rule cannot reach them; their construction site is `enroll.ts`.
2060
+ throughPresentationGate(() => assertPresentationSafe(shaped, "fleet_enrollment_output"));
2061
+ return shaped;
2062
+ }
2063
+ function addFleetOptions(command) {
2064
+ return command
2065
+ .option("--fleet-onboarding", "atomically enroll and bind the creator-authorized fleet tuple")
2066
+ .option("--invite-code <code>", "fleet bearer code escape hatch (WARNING: argv and shell history expose it; prefer stdin)")
2067
+ .option("--board-id <id>", "bounded board id")
2068
+ .option("--machine-id <id>", "bounded fleet machine id")
2069
+ .option("--harness-kind <kind>", "bounded harness kind")
2070
+ .option("--harness-label <label>", "bounded harness label")
2071
+ .option("--repo-slug <slug>", "bounded repo slug")
2072
+ .option("--repo-fingerprint <fingerprint>", "bounded repo fingerprint")
2073
+ .option("--repo-scope-role <role>", "worker or reviewer")
2074
+ .option("--agent-role <role>", "phase_worker or reviewer")
2075
+ .option("--expected-agent-handle <handle>", "exact server handle authorized by the creator")
2076
+ .option("--local-key-id <id>", "deterministic descriptor-only local key id")
2077
+ .option("--publishable-key-field-reference <ref>", "publishable-key environment field frozen into fleet finalization")
2078
+ .option("--runtime-output <path>", "required fleet runtime generation destination");
2079
+ }
1353
2080
  function addEnrollCommands(program, io, enrollment) {
1354
2081
  const enroll = program
1355
2082
  .command("enroll")
@@ -1357,7 +2084,7 @@ function addEnrollCommands(program, io, enrollment) {
1357
2084
  // One-shot loopback flow. It is a DEFAULT subcommand (isDefault) so bare
1358
2085
  // `enroll ...` runs it, while keeping its options off the parent — otherwise a
1359
2086
  // parent-level --slug shadows the begin/complete subcommands' own --slug.
1360
- enroll
2087
+ const runCommand = enroll
1361
2088
  .command("run", { isDefault: true })
1362
2089
  .description("one-shot enrollment over an RFC 8252 loopback receiver")
1363
2090
  .requiredOption("--slug <slug>", "desired agent slug")
@@ -1369,14 +2096,45 @@ function addEnrollCommands(program, io, enrollment) {
1369
2096
  .option("--join-board <boardId>", "join-board intent board id")
1370
2097
  .option("--requested-scopes <csv>", "requested scopes (comma-separated)")
1371
2098
  .option("--requested-board-scopes <csv>", "requested board scopes (comma-separated)")
1372
- .option("--no-board", "enroll WITHOUT provisioning any board (no create, no join)")
1373
- .action((options) => runHandler(io, "agent_board.enroll", () => enrollment.run({
1374
- slug: options.slug,
1375
- agentKind: options.agentKind,
1376
- intent: intentFromOptions(options),
1377
- requestedScopes: csv(options.requestedScopes),
1378
- requestedBoardScopes: csv(options.requestedBoardScopes),
1379
- })));
2099
+ .option("--no-board", "enroll WITHOUT provisioning any board (no create, no join)");
2100
+ addFleetOptions(runCommand)
2101
+ .action((options) => runHandler(io, "agent_board.enroll", async () => {
2102
+ // ORDER IS THE CONTRACT on this path (#254). The intent is built and the whole
2103
+ // pending template is constructed BEFORE the filesystem is touched and before the
2104
+ // server is asked to commit anything — which is the order `enroll device` (begin
2105
+ // and resume) has always used, and which this path alone did not.
2106
+ //
2107
+ // Two claims depend on it, and neither was true before. The operator-facing
2108
+ // refusal says nothing was written to this host: `prepareFleetRuntimeOutput`
2109
+ // mkdirs the runtime root, so running it first made that false. And a value the
2110
+ // template REFUSES — an interior prohibited character in the expected handle, say
2111
+ // — was previously sent to the server and only refused afterwards, spending the
2112
+ // bounded invite use on an enrollment this host could never finalize (#176).
2113
+ const intent = await enrollmentIntentFromCli(options);
2114
+ const call = () => enrollment.run({
2115
+ slug: options.slug,
2116
+ agentKind: options.agentKind,
2117
+ intent,
2118
+ requestedScopes: csv(options.requestedScopes),
2119
+ requestedBoardScopes: csv(options.requestedBoardScopes),
2120
+ });
2121
+ if (intent.kind !== "fleet_onboarding")
2122
+ return finalizeFleetOutcome(options, intent, await call());
2123
+ const pending = throughPresentationGate(() => buildPendingFleetTemplate(intent, options.publishableKeyFieldReference));
2124
+ // BOTH local prerequisites of fleet finalization, checked before the server is
2125
+ // asked to commit anything. The publishable key used to be read only inside
2126
+ // finalizeFleetOutcome, after `enrollment.run` had already created the identity
2127
+ // and spent the bounded invite use (message-board#176).
2128
+ await prepareFleetRuntimeOutput(options.runtimeOutput ?? "");
2129
+ resolveFleetPublishableKey(options.publishableKeyFieldReference);
2130
+ // #202 — the runner commits inside this call, so the fence must start here,
2131
+ // not after it. Fleet-only: the post-commit message is about a fleet
2132
+ // enrollment, and a non-fleet failure is not redacted in the first place.
2133
+ const result = await throughFleetCommit(call);
2134
+ // The frozen template is the one the pre-flight validated, so finalization cannot
2135
+ // silently rebuild a different one from the same intent.
2136
+ return afterFleetCommit(result, () => finalizeFleetOutcome(options, intent, result, pending));
2137
+ }, Boolean(options.fleetOnboarding)));
1380
2138
  enroll
1381
2139
  .command("begin")
1382
2140
  .description("begin an enrollment; prints the authorization URL (no code_verifier/state)")
@@ -1428,10 +2186,11 @@ function addEnrollCommands(program, io, enrollment) {
1428
2186
  // is elsewhere) and `begin`/`complete`'s OOB redirect is not registered for the
1429
2187
  // Portal client; the device grant prints a user_code + URL the human approves on
1430
2188
  // ANY device, then polls to completion. Same options as `run` where they apply.
1431
- enroll
2189
+ const deviceCommand = enroll
1432
2190
  .command("device")
1433
2191
  .description("headless enrollment via the RFC 8628 device authorization grant (approve on any device)")
1434
- .requiredOption("--slug <slug>", "desired agent slug")
2192
+ .option("--slug <slug>", "desired agent slug (required unless resuming a non-fleet enrollment)")
2193
+ .option("--enrollment-id <id>", "resume the device authorization returned by a prior fleet begin")
1435
2194
  .option("--agent-kind <kind>", "agent kind (default 'agent')")
1436
2195
  .option("--create-board <slug>", "create-board intent slug")
1437
2196
  .option("--board-name <name>", "board name for create-board")
@@ -1440,25 +2199,132 @@ function addEnrollCommands(program, io, enrollment) {
1440
2199
  .option("--join-board <boardId>", "join-board intent board id")
1441
2200
  .option("--requested-scopes <csv>", "requested scopes (comma-separated)")
1442
2201
  .option("--requested-board-scopes <csv>", "requested board scopes (comma-separated)")
1443
- .option("--no-board", "enroll WITHOUT provisioning any board (no create, no join)")
1444
- .action((options) => runHandler(io, "agent_board.enroll_device", () => {
2202
+ .option("--no-board", "enroll WITHOUT provisioning any board (no create, no join)");
2203
+ addFleetOptions(deviceCommand)
2204
+ .action((options) => runHandler(io, "agent_board.enroll_device", async () => {
2205
+ if (options.enrollmentId) {
2206
+ if (!enrollment.deviceComplete) {
2207
+ throw new Error("device resume is not supported by this runner (EnrollmentCommandRunner.deviceComplete is not implemented)");
2208
+ }
2209
+ if (!options.fleetOnboarding) {
2210
+ // #210, second half — shaped on the OUTCOME, not on the flag. `fleetSafeOutput`
2211
+ // is a no-op for a non-fleet outcome by construction (it returns `result`
2212
+ // unchanged when `onboarding` is absent), so this costs the supported non-fleet
2213
+ // resume nothing. It closes the leak because this is the ONE enroll path whose
2214
+ // result reaches `runHandler` without passing through `finalizeFleetOutcome`:
2215
+ // a fleet outcome arriving here — from the INJECTED `EnrollmentCommandRunner`
2216
+ // seam, which this file already treats as a reachable population — printed
2217
+ // `keyReference` and `agentPublicKey` verbatim.
2218
+ //
2219
+ // `runHandler`'s `redactFleetFailure` argument below stays
2220
+ // `Boolean(options.fleetOnboarding)`. Do NOT make it outcome-keyed to match:
2221
+ // it governs FAILURE text, and turning it on here would blank the guard's
2222
+ // refusal message into "fleet enrollment failed before completion".
2223
+ return fleetSafeOutput(await enrollment.deviceComplete({ enrollmentId: options.enrollmentId }));
2224
+ }
2225
+ if (!enrollment.deviceDeletePending) {
2226
+ throw new Error("fleet device resume is not supported by this runner (EnrollmentCommandRunner.deviceDeletePending is not implemented)");
2227
+ }
2228
+ const store = new FleetDeviceContinuationStore(defaultDevicePendingStateDir(process.env));
2229
+ const continuation = await store.get(options.enrollmentId);
2230
+ if (!continuation)
2231
+ throw new Error("fleet_device_continuation_unknown");
2232
+ const resumeIntent = fleetResumeIntentFromCli(options);
2233
+ // Same gate as `run` (#254). A refusal raised INSIDE the template build — an
2234
+ // interior prohibited character reaching `atom()`, say — is not a message this
2235
+ // file wrote, so without the gate the blanket fleet redaction flattens it to
2236
+ // "failed before completion" on this path while `run` prints the named field.
2237
+ // Pre-commit either way; the difference is whether the operator can act on it.
2238
+ const candidate = throughPresentationGate(() => buildPendingFleetTemplate(resumeIntent, options.publishableKeyFieldReference));
2239
+ const pending = continuation.pendingTemplate;
2240
+ if (options.slug.trim() !== pending.agentSlug || options.runtimeOutput !== continuation.runtimeOutput
2241
+ || continuation.keyReference !== pending.keyReference
2242
+ || continuation.publishableKeyFieldReference !== pending.publishableKeyFieldReference
2243
+ || JSON.stringify(candidate) !== JSON.stringify(pending)) {
2244
+ throw new Error("fleet_device_continuation_mismatch");
2245
+ }
2246
+ await prepareFleetRuntimeOutput(options.runtimeOutput);
2247
+ const expectedIntent = { kind: "fleet_onboarding", ...pending.enrollment };
2248
+ let outcome = continuation.outcome;
2249
+ const committedThisLeg = !outcome;
2250
+ if (committedThisLeg) {
2251
+ // Nothing has committed yet on this leg — check the second local
2252
+ // prerequisite before spending the device authorization.
2253
+ resolveFleetPublishableKey(pending.publishableKeyFieldReference);
2254
+ // #202 — same boundary as `run`: deviceComplete commits inside the call.
2255
+ outcome = await throughFleetCommit(() => enrollment.deviceComplete({
2256
+ enrollmentId: options.enrollmentId,
2257
+ expectedFleet: { intent: expectedIntent, keyReference: pending.keyReference, slug: pending.agentSlug },
2258
+ deletePending: false,
2259
+ }));
2260
+ }
2261
+ return afterFleetCommit(outcome, async () => {
2262
+ // The write that MAKES this recoverable is itself post-commit: mkdir,
2263
+ // O_EXCL create, write, fsync and rename can each fail. Outside the fence
2264
+ // its failure read as "failed before completion", and because the outcome
2265
+ // was never persisted a retry would re-enter deviceComplete against a
2266
+ // device authorization that is already consumed.
2267
+ if (committedThisLeg)
2268
+ await store.put({ ...continuation, outcome: outcome });
2269
+ const result = await finalizeFleetOutcome(options, { ...expectedIntent, inviteCode: "" }, outcome, pending);
2270
+ await enrollment.deviceDeletePending({ enrollmentId: options.enrollmentId });
2271
+ await store.delete(options.enrollmentId);
2272
+ return result;
2273
+ });
2274
+ }
2275
+ if (!options.slug)
2276
+ throw new Error("enroll device requires --slug when --enrollment-id is absent");
2277
+ if (options.fleetOnboarding) {
2278
+ if (!enrollment.deviceBegin) {
2279
+ throw new Error("fleet device begin is not supported by this runner (EnrollmentCommandRunner.deviceBegin is not implemented)");
2280
+ }
2281
+ const intent = await enrollmentIntentFromCli(options);
2282
+ if (intent.kind !== "fleet_onboarding")
2283
+ throw new Error("fleet_device_intent_missing");
2284
+ const pending = throughPresentationGate(() => buildPendingFleetTemplate(intent, options.publishableKeyFieldReference));
2285
+ if (!options.runtimeOutput)
2286
+ throw new Error("--fleet-onboarding requires --runtime-output");
2287
+ await prepareFleetRuntimeOutput(options.runtimeOutput);
2288
+ // Before a human approval is spent, and before any continuation state exists
2289
+ // to clean up.
2290
+ resolveFleetPublishableKey(pending.publishableKeyFieldReference);
2291
+ const enrollmentId = `enr-${randomUUID()}`;
2292
+ const store = new FleetDeviceContinuationStore(defaultDevicePendingStateDir(process.env));
2293
+ await store.put({ version: "fleet_device_continuation.v1", enrollmentId,
2294
+ runtimeOutput: options.runtimeOutput, publishableKeyFieldReference: pending.publishableKeyFieldReference,
2295
+ keyReference: pending.keyReference, pendingTemplate: pending });
2296
+ try {
2297
+ const begun = await enrollment.deviceBegin({ enrollmentId, slug: options.slug, agentKind: options.agentKind,
2298
+ intent, requestedScopes: csv(options.requestedScopes), requestedBoardScopes: csv(options.requestedBoardScopes) });
2299
+ if (begun.enrollmentId !== enrollmentId)
2300
+ throw new Error("fleet_device_enrollment_id_mismatch");
2301
+ return begun;
2302
+ }
2303
+ catch (error) {
2304
+ await store.delete(enrollmentId);
2305
+ throw error;
2306
+ }
2307
+ }
1445
2308
  // `device` is an OPTIONAL member of EnrollmentCommandRunner (added after the
1446
2309
  // interface shipped). The default runner always provides it; a custom runner
1447
2310
  // that predates it gets a clean error instead of a `undefined is not a function`.
1448
2311
  if (!enrollment.device) {
1449
2312
  throw new Error("device-authorization enrollment is not supported by this runner (EnrollmentCommandRunner.device is not implemented)");
1450
2313
  }
1451
- return enrollment.device({
1452
- slug: options.slug,
1453
- agentKind: options.agentKind,
1454
- intent: intentFromOptions(options),
1455
- requestedScopes: csv(options.requestedScopes),
1456
- requestedBoardScopes: csv(options.requestedBoardScopes),
1457
- // Print the human-facing prompt PROMINENTLY to stderr (stdout is reserved
1458
- // for the final JSON outcome). The device_code secret is never surfaced.
1459
- onDeviceAuthorization: (prompt) => io.writeStderr(renderDevicePrompt(prompt)),
2314
+ return enrollmentIntentFromCli(options).then(async (intent) => {
2315
+ const result = await enrollment.device({
2316
+ slug: options.slug,
2317
+ agentKind: options.agentKind,
2318
+ intent,
2319
+ requestedScopes: csv(options.requestedScopes),
2320
+ requestedBoardScopes: csv(options.requestedBoardScopes),
2321
+ // Print the human-facing prompt PROMINENTLY to stderr (stdout is reserved
2322
+ // for the final JSON outcome). The device_code secret is never surfaced.
2323
+ onDeviceAuthorization: (prompt) => io.writeStderr(renderDevicePrompt(prompt)),
2324
+ });
2325
+ return result;
1460
2326
  });
1461
- }));
2327
+ }, Boolean(options.fleetOnboarding)));
1462
2328
  }
1463
2329
  /**
1464
2330
  * Render the human-facing device-authorization prompt. Written to be pasted to a
@@ -1631,7 +2497,7 @@ function addRoomsCommands(program, client, io) {
1631
2497
  io.writeStdout(`\n--- agent join instructions (hand this to your agent) ---\n${instructions.renderedText}\n`);
1632
2498
  }
1633
2499
  catch (error) {
1634
- io.writeStdout(`${JSON.stringify(toOperationFailure("agent_board.create_invite_link", error), null, 2)}\n`);
2500
+ writeFailureEnvelope(io, toOperationFailure("agent_board.create_invite_link", error));
1635
2501
  process.exitCode = 1;
1636
2502
  }
1637
2503
  });
@@ -1668,6 +2534,75 @@ function addRoomsCommands(program, client, io) {
1668
2534
  return runHandler(io, "agent_board.redeem_invite_link", () => client.redeemInviteLink({ p_code: code }));
1669
2535
  });
1670
2536
  }
2537
+ function addInviteCommands(program, client, io) {
2538
+ const invite = program.command("invite").description("create bounded fleet onboarding invites");
2539
+ invite
2540
+ .command("create-fleet")
2541
+ .description("create a member-only invite with immutable machine/harness/repo/role/handle bounds")
2542
+ .requiredOption("--board-id <id>")
2543
+ .requiredOption("--machine-id <id>")
2544
+ .requiredOption("--harness-kind <kind>")
2545
+ .requiredOption("--harness-label <label>")
2546
+ .requiredOption("--repo-slug <slug>")
2547
+ .requiredOption("--repo-fingerprint <fingerprint>")
2548
+ .requiredOption("--repo-scope-role <role>", "worker or reviewer")
2549
+ .requiredOption("--agent-role <role>", "phase_worker or reviewer")
2550
+ .requiredOption("--expected-agent-handle <handle>")
2551
+ .addOption(numberOption("--max-uses <n>", "maximum atomic onboardings (default 1)"))
2552
+ .option("--expires <iso>")
2553
+ .option("--created-by-agent-id <id>")
2554
+ .option("--with-instructions", "render the bounded atomic onboarding instructions")
2555
+ .option("--host <slug>", "normalized host slug (required with --with-instructions)")
2556
+ .option("--platform <platform>", "desktop or headless_linux (required with --with-instructions)")
2557
+ .option("--publishable-key-field-reference <ref>", "public key field reference (required with --with-instructions)")
2558
+ .action((o) => runHandler(io, "agent_board.create_fleet_onboarding_invite", async () => {
2559
+ if (!client.createFleetOnboardingInvite) {
2560
+ throw new Error("this client does not support bounded fleet onboarding invites");
2561
+ }
2562
+ const pair = (o.agentRole === "phase_worker" && o.repoScopeRole === "worker")
2563
+ || (o.agentRole === "reviewer" && o.repoScopeRole === "reviewer");
2564
+ if (!pair)
2565
+ throw new Error("fleet invite allows only phase_worker/worker or reviewer/reviewer");
2566
+ let instructionTemplate;
2567
+ let instructionDeployment;
2568
+ if (o.withInstructions) {
2569
+ if (!o.host || !o.platform || !o.publishableKeyFieldReference)
2570
+ throw new Error("--with-instructions requires --host, --platform, and --publishable-key-field-reference");
2571
+ if (o.platform !== "desktop" && o.platform !== "headless_linux")
2572
+ throw new Error("--platform must be desktop or headless_linux");
2573
+ instructionDeployment = resolveJoinDeployment();
2574
+ instructionTemplate = buildFleetHarnessTemplate({ host: o.host, harnessKind: o.harnessKind, harnessLabel: o.harnessLabel,
2575
+ platform: o.platform, boardId: o.boardId, machineId: o.machineId, repoSlug: o.repoSlug, repoFingerprint: o.repoFingerprint,
2576
+ repoScopeRole: o.repoScopeRole, agentRole: o.agentRole, boardUrl: instructionDeployment.boardUrl,
2577
+ sessionUrl: instructionDeployment.sessionUrl ?? instructionDeployment.boardUrl,
2578
+ publishableKeyFieldReference: o.publishableKeyFieldReference,
2579
+ ...(o.platform === "headless_linux" ? { agentSocketPath: process.env.SSH_AUTH_SOCK, publicKeySelector: process.env.AGENT_BOARD_PUBLIC_KEY_SELECTOR } : {}),
2580
+ });
2581
+ const instructionBounds = ["boardId", "machineId", "harnessKind", "harnessLabel", "repoSlug", "repoFingerprint", "repoScopeRole", "agentRole", "expectedAgentHandle"];
2582
+ if (instructionBounds.some((field) => instructionTemplate.enrollment[field] !== o[field])) {
2583
+ throw new Error("fleet instruction options do not match the normalized bounded template");
2584
+ }
2585
+ }
2586
+ const result = await client.createFleetOnboardingInvite({
2587
+ boardId: o.boardId,
2588
+ machineId: o.machineId,
2589
+ harnessKind: o.harnessKind,
2590
+ harnessLabel: o.harnessLabel,
2591
+ repoSlug: o.repoSlug,
2592
+ repoFingerprint: o.repoFingerprint,
2593
+ repoScopeRole: o.repoScopeRole,
2594
+ agentRole: o.agentRole,
2595
+ expectedAgentHandle: o.expectedAgentHandle,
2596
+ maxUses: o.maxUses,
2597
+ expiresAt: o.expires,
2598
+ createdByAgentId: o.createdByAgentId,
2599
+ });
2600
+ if (!o.withInstructions)
2601
+ return result;
2602
+ const branded = result.data;
2603
+ return { result, instructions: buildAgentJoinInstructions({ invite: branded, deployment: { ...instructionDeployment, fleetHarnessTemplate: instructionTemplate, fleetOnboardingInvite: branded } }) };
2604
+ }));
2605
+ }
1671
2606
  export function createProgram(client, io, enrollment = defaultEnrollmentRunner()) {
1672
2607
  const program = new Command()
1673
2608
  .name("agent-board")
@@ -1690,6 +2625,7 @@ export function createProgram(client, io, enrollment = defaultEnrollmentRunner()
1690
2625
  addWriteCommands(program, client, io);
1691
2626
  addReadCommands(program, client, io);
1692
2627
  addRoomsCommands(program, client, io);
2628
+ addInviteCommands(program, client, io);
1693
2629
  addEnrollCommands(program, io, enrollment);
1694
2630
  return program;
1695
2631
  }