@consiliency/agent-board-cli 1.13.0 → 1.15.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.
Files changed (2) hide show
  1. package/dist/cli.js +471 -95
  2. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -34,6 +34,27 @@ class FleetSafeMessageError extends Error {
34
34
  /**
35
35
  * Raised BEFORE any server call — the enrollment has not committed and the bounded
36
36
  * invite use is not spent. The message names the environment variable, never its value.
37
+ *
38
+ * A NOTE ON THIS CLASS'S SHAPE, left for whoever meets it next (#402).
39
+ *
40
+ * The name encodes a GLOBAL fact — "this enrollment has not committed anywhere" — while a
41
+ * throw site only ever knows something LOCAL: that this function, on this invocation, has
42
+ * not called the server yet. A helper cannot verify the claim its own class makes. That gap
43
+ * is not a hypothetical: it is precisely how the device-resume defect happened, where every
44
+ * check on the way back into a resume was locally pre-call and globally post-commit, and
45
+ * told operators to re-run an enrollment that had already succeeded.
46
+ *
47
+ * This was not redesigned, deliberately. The non-resume sites do happen to honour the claim
48
+ * — `run` and device `begin` are one-shot, so local and global coincide there — and a
49
+ * rename or a split would touch every call site for a property two of three paths already
50
+ * satisfy. What changed instead is that no MESSAGE asserts the global fact unless its
51
+ * caller can vouch for it: the assurance sentence is passed in by the caller, and the
52
+ * resume path passes one that makes no commit-status claim at all. The class keeps its
53
+ * name; the text stopped over-claiming.
54
+ *
55
+ * If a fourth path is ever added, the question to ask is not "is this before the server
56
+ * call" but "can a commit have happened on ANY earlier leg of this enrollment" — and if the
57
+ * answer is yes or unknown, it must not say the invite is unspent.
37
58
  */
38
59
  class FleetPreflightError extends FleetSafeMessageError {
39
60
  }
@@ -118,15 +139,30 @@ function throughPresentationGate(action) {
118
139
  throw asFleetSafeMessage(error);
119
140
  }
120
141
  }
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}]`;
142
+ /**
143
+ * WITHDRAWN (#402). Returns "" for every input, and is kept as the single named place that
144
+ * decision is recorded rather than deleted into a diff nobody will read.
145
+ *
146
+ * It used to put a CAUSE's `code` into a message this CLI prints, behind a shape gate. Two
147
+ * successive bounds were defended here and BOTH rested on a claim about what a credential
148
+ * can look like: first a lowercase snake_case class (whose alphabet is a subset of
149
+ * base64url's, so a fleet invite code qualifies), then a 42-character cap derived from that
150
+ * code's invariant 43 — which covered exactly one credential and was then treated as
151
+ * covering the channel. The OAuth `device_code` on this same flow is constrained only to be
152
+ * non-empty, so a short one passes the cap.
153
+ *
154
+ * There is no third bound. A code we did not write is not printed, which is the rule the
155
+ * `--harness-kind`, `--local-key-id` and `--publishable-key-field-reference` echoes all
156
+ * reached by the same road.
157
+ *
158
+ * THE COST, stated because it is real: a post-commit or commit-unknown message no longer
159
+ * carries `[code <cause>]`, so an operator debugging one of those loses the one-word
160
+ * discriminator. Everything else in both messages is unchanged, and both still name the
161
+ * state and the action. Restoring it needs a bound that holds for EVERY credential able to
162
+ * reach here, not one derived from a single issuer.
163
+ */
164
+ function namedCauseCode(_cause) {
165
+ return "";
130
166
  }
131
167
  function committedFactsFromOutcome(outcome) {
132
168
  return {
@@ -137,6 +173,50 @@ function committedFactsFromOutcome(outcome) {
137
173
  machineId: outcome.onboarding?.machineId ?? "",
138
174
  };
139
175
  }
176
+ /**
177
+ * THE RESUME'S DEFAULT ANSWER IS "UNKNOWN", NOT "IT DID NOT HAPPEN" (#402 round 3).
178
+ *
179
+ * `runHandler`'s blanket redaction replaces an unrecognised fleet failure with "fleet
180
+ * enrollment failed before completion". On a ONE-SHOT command (`enroll run`, device
181
+ * `begin`) that sentence is true by construction: no leg preceded this one. On a device
182
+ * RESUME it is a claim about a MULTI-LEG flow whose earlier legs are recorded in a local
183
+ * file that can fail to be written or fail to be read — and measured, not reasoned:
184
+ *
185
+ * leg 1 commits, its `store.put` fails, the operator re-runs the identical command
186
+ * → "fleet enrollment failed before completion"
187
+ *
188
+ * That is the message-board#176 shape verbatim, in the case most likely to produce a
189
+ * duplicate agent, because a failed local write is exactly when an operator retries.
190
+ *
191
+ * The round-2 fence cannot reach it: the fence arms off a STORED outcome, and this is the
192
+ * path where storing it is what failed. Nor can any marker written before the commit —
193
+ * `store.get` itself can fail (an unsafe state directory), and then no local record is
194
+ * readable at all. Both variants were driven and both printed the sentence.
195
+ *
196
+ * So the resume's default is unconditional: when this CLI cannot PROVE the enrollment
197
+ * uncommitted, it says it does not know. The deliberate cost is that a first resume whose
198
+ * genuine pre-commit failure is unrecognised now also reads "unknown" — the CLI cannot
199
+ * distinguish that from "committed, and the record of it was lost", and inventing the
200
+ * distinction is what produced the lie.
201
+ *
202
+ * The DETAIL IS WITHHELD, on the `FleetPostCommitError` discipline: this converts errors
203
+ * the redaction was protecting the operator from, so only a fixed sentence and a
204
+ * shape-gated `code` cross. Anything already safe — a pre-flight refusal, the fence's own
205
+ * post-commit or indeterminate message — passes through untouched.
206
+ */
207
+ async function throughResumeCommitUnknown(action) {
208
+ try {
209
+ return await action();
210
+ }
211
+ catch (error) {
212
+ if (error instanceof FleetSafeMessageError)
213
+ throw error;
214
+ throw new FleetSafeMessageError("fleet_device_resume_failed_commit_unknown", "this fleet device resume failed, and whether the enrollment COMMITTED is UNKNOWN: an earlier leg of this "
215
+ + "enrollment may have committed on the server without this host recording it. The bounded invite use may or "
216
+ + "may not be spent. Do NOT re-run this blindly: verify whether the agent identity now exists on the board, "
217
+ + `and enroll again only if it does not.${namedCauseCode(error)}`);
218
+ }
219
+ }
140
220
  /**
141
221
  * Everything inside `action` runs against a COMMITTED server enrollment. Marks any
142
222
  * failure so the redaction below reports it truthfully instead of asserting it happened
@@ -184,11 +264,30 @@ function renderIds(facts) {
184
264
  // contract-valid receipt can also supply a well-formed UUID. No client-side check
185
265
  // closes that; it needs a signature over the response.
186
266
  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);
267
+ // THREE typed gates now, not one (Consiliency/message-board#402). `boardId` and
268
+ // `machineId` are OPERATOR-SUPPLIED on the indeterminate path — `localFacts` takes them
269
+ // from the intent, deliberately, so the message reports what the enrollment ASKED FOR —
270
+ // and the generic `printable` alphabet is a superset of base64url, which is what
271
+ // `create_invite_link` mints. A bounded invite code pasted into `--board-id` was therefore
272
+ // echoed here, on a path that needs no injected runner: an off-contract server answer is
273
+ // all it takes.
274
+ //
275
+ // The fix is the same argument the `agentId` gate already makes, extended to the two
276
+ // fields it fits: `boards.id` and `fleet_machines.id` are `uuid primary key`, so a
277
+ // non-UUID value in either is off-contract and rendering it would be rendering something
278
+ // the field cannot legitimately be. A type check derived from the schema, not a guess at
279
+ // what a credential looks like — that guess has been wrong twice on this branch.
280
+ //
281
+ // RESIDUAL, stated rather than papered over: `agentHandle` is a slug with no type to
282
+ // derive, so it keeps the generic gate. Reaching it with a code requires the code in BOTH
283
+ // `--harness-kind` and `--expected-agent-handle` (as `<host>-<code>`), because the argv
284
+ // census refuses a single-flag paste — the handle must end with `-<harness-kind>` — before
285
+ // any server call. Narrow, contrived, and real; recorded rather than closed.
187
286
  return [
188
287
  ["agentId", facts.agentId, isUuid],
189
288
  ["agentHandle", facts.agentHandle, printable],
190
- ["boardId", facts.boardId, printable],
191
- ["machineId", facts.machineId, printable],
289
+ ["boardId", facts.boardId, isUuid],
290
+ ["machineId", facts.machineId, isUuid],
192
291
  ].filter(([, value, ok]) => value !== "" && ok(value)).map(([key, value]) => `${key}=${value}`).join(", ");
193
292
  }
194
293
  /**
@@ -293,9 +392,17 @@ async function runHandler(io, operation, action, redactFleetFailure = false) {
293
392
  catch (error) {
294
393
  const failure = toOperationFailure(operation, error);
295
394
  // 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.
395
+ // message; a pre-flight refusal keeps the flag the operator must set.
396
+ //
397
+ // BOTH FIELDS, not just the message (Consiliency/message-board#402). `toOperationFailure`
398
+ // copies any string `Error.code` onto the envelope, and this gate used to rewrite
399
+ // `message` alone — so a value placed in `code` reached stdout through a field the
400
+ // redaction never touched. That is the #214 shape exactly: a field-level gate that was
401
+ // one field narrower than the class it was defending. The two are rewritten together
402
+ // because they are one decision — whether this error's text is ours to print.
297
403
  if (redactFleetFailure && !(error instanceof FleetSafeMessageError)) {
298
404
  failure.error.message = "fleet enrollment failed before completion";
405
+ failure.error.code = "unknown";
299
406
  }
300
407
  writeFailureEnvelope(io, failure);
301
408
  process.exitCode = 1;
@@ -1633,8 +1740,9 @@ export function defaultEnrollmentRunner(env = process.env, deps = {}) {
1633
1740
  if (pending.intent.kind === "fleet_onboarding" && !input.expectedFleet) {
1634
1741
  throw new FleetPreflightError("fleet_device_resume_requires_fleet_flag", "this pending device enrollment is a fleet onboarding and cannot be resumed without "
1635
1742
  + "--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 "
1743
+ + "runtime output and the fleet finalization. THIS command stopped before any server call and "
1744
+ + "spent nothing itself; whether an EARLIER leg committed cannot be told from here, and the "
1745
+ + "pending was kept. Re-run the same resume with --fleet-onboarding, the same bounded tuple "
1638
1746
  + "(--board-id/--machine-id/--harness-kind/--harness-label/--repo-slug/"
1639
1747
  + "--repo-fingerprint/--repo-scope-role/--agent-role/--expected-agent-handle/"
1640
1748
  + "--local-key-id), --slug and --runtime-output.");
@@ -1801,23 +1909,124 @@ function resolveFleetPublishableKey(publishableKeyFieldReference) {
1801
1909
  const reference = normalizePublishableKeyFieldReference(publishableKeyFieldReference ?? DEFAULT_PUBLISHABLE_KEY_FIELD_REFERENCE);
1802
1910
  const value = process.env[reference]?.trim() ?? "";
1803
1911
  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>. `
1912
+ throw new FleetPreflightError("fleet_publishable_key_missing",
1913
+ // ONLY OUR OWN LITERAL. Two shape gates were tried here and BOTH rested on a false
1914
+ // claim about what a credential can look like (Consiliency/message-board#402).
1915
+ //
1916
+ // The first was `normalizePublishableKeyFieldReference`'s own POSIX name rule, which a
1917
+ // lowercase token satisfies. The second was SCREAMING_SNAKE_CASE, defended on the
1918
+ // ground that "this system's invite codes are UUIDs" — checked against codes that
1919
+ // happened to be at hand rather than against the ISSUER. Derived properly:
1920
+ // `create_fleet_onboarding_invite` delegates to `create_invite_link`, whose code is
1921
+ // `rtrim(translate(encode(gen_random_bytes(32),'base64'),'+/','-_'),'=')` — base64url
1922
+ // over `A-Za-z0-9-_`. `[A-Z0-9_]` is a SUBSET of that alphabet, so an uppercase
1923
+ // underscored fleet invite code is a reachable value, not a theoretical residual.
1924
+ //
1925
+ // No third shape gate. A value the operator supplied is not echoed, full stop, which
1926
+ // is the same rule every other flag on this path already follows. The refusal stays
1927
+ // actionable — it is not redacted, it names the flag and the code, and the variable
1928
+ // name is in the fleet onboarding instructions this command was invoked from, which is
1929
+ // where the operator got it. The DEFAULT is still named because it is this file's own
1930
+ // literal and nothing operator-supplied reaches it.
1931
+ (reference === DEFAULT_PUBLISHABLE_KEY_FIELD_REFERENCE
1932
+ ? `fleet onboarding requires the publishable key in this host's environment: export ${DEFAULT_PUBLISHABLE_KEY_FIELD_REFERENCE}=<project anon/publishable key>. `
1933
+ : "fleet onboarding requires the publishable key in this host's environment, in the variable named by this "
1934
+ + "enrollment's --publishable-key-field-reference. That value is not echoed back: it is operator-supplied, "
1935
+ + "and a bearer code pasted into the wrong flag would be echoed with it. Read the name from the fleet "
1936
+ + "onboarding instructions this command was invoked from. ")
1805
1937
  + "The variable name was frozen at mint time by --publishable-key-field-reference.");
1806
1938
  }
1807
1939
  return value;
1808
1940
  }
1941
+ /**
1942
+ * THE EXPECTED-HANDLE RULE, in one place (Consiliency/message-board#401).
1943
+ *
1944
+ * Stated once and consumed twice: by the argv-layer census below, which reports it
1945
+ * ALONGSIDE the other unmet preconditions, and by `buildPendingFleetTemplate`, which is
1946
+ * the boundary that actually depends on it (`host` is the handle minus this suffix). A
1947
+ * second copy of the rule is the drift this repo keeps paying for, and the two copies
1948
+ * that would exist here are the two that must agree.
1949
+ *
1950
+ * Returns the operator-facing sentence, or `undefined` when the rule holds.
1951
+ */
1952
+ function expectedHandleSuffixFailure(expectedAgentHandle, harnessKind) {
1953
+ if (expectedAgentHandle.endsWith(`-${harnessKind}`))
1954
+ return undefined;
1955
+ // A TEMPLATE, never the operator's own value (Consiliency/message-board#402).
1956
+ //
1957
+ // An earlier revision echoed `--harness-kind` through a shape gate. A shape gate is not
1958
+ // a secrecy gate: this deployment's invite codes are UUIDs, and a UUID is lowercase
1959
+ // hexadecimal and hyphens — it passes any plausible "looks like a harness kind" test.
1960
+ // A bearer code pasted into the wrong flag, which is precisely the operator error this
1961
+ // message exists to catch, would then have been printed by the diagnostic.
1962
+ //
1963
+ // There is no closed vocabulary of harness kinds to draw from (checked, not assumed —
1964
+ // the column is free text), so nothing operator-supplied is echoed at all. The rule is
1965
+ // fully actionable stated as a template plus an example this file writes.
1966
+ return '--expected-agent-handle must end with "-" followed by your --harness-kind '
1967
+ + '(so --harness-kind codex requires a handle like "myhost-codex"): the fleet template derives this '
1968
+ + "host's slug from the part of the handle BEFORE that suffix, so a handle that does not carry it "
1969
+ + "names no host. Neither value is echoed here — a bearer code pasted into the wrong flag would be "
1970
+ + "echoed with it.";
1971
+ }
1972
+ /**
1973
+ * THE LOCAL-KEY PIN, stated once for the two sites that check it (#401).
1974
+ *
1975
+ * BOTH halves are reported in one pass, and NOTHING derived from operator input is printed
1976
+ * (#402). The earlier revision echoed the derived `localKeyId` as the value to use, which
1977
+ * looked safe because the library composes it — but it composes it as
1978
+ * `agent-board/<host>/<harness-kind>` from the operator's own `--expected-agent-handle` and
1979
+ * `--harness-kind`, so a bearer code pasted into either flag came back out inside it. The
1980
+ * rule is stated as a template instead, which is strictly more useful anyway: it tells the
1981
+ * operator how the value is built rather than only what it is this once.
1982
+ *
1983
+ * The key reference is not printed for the older reason: its handle is the local key
1984
+ * descriptor the blanket fleet redaction exists to keep out of a terminal.
1985
+ */
1986
+ function localKeyMismatchFailure(pending, intent, ref) {
1987
+ const mismatched = [];
1988
+ if (pending.localKeyId !== intent.localKeyId)
1989
+ mismatched.push("--local-key-id");
1990
+ if (pending.keyReference !== `${ref.resolver}:${ref.handle}`)
1991
+ mismatched.push("this host's configured keystore key reference");
1992
+ if (!mismatched.length)
1993
+ return undefined;
1994
+ return `the fleet template this enrollment derives does not agree with ${mismatched.join(" and ")}. `
1995
+ + '--local-key-id must be exactly "agent-board/<host>/<harness-kind>", where <host> is your '
1996
+ + '--expected-agent-handle with its trailing "-<harness-kind>" removed. Neither the derived value nor '
1997
+ + "the key reference is echoed: both are composed from operator-supplied flags, and its handle is a "
1998
+ + "local key descriptor.";
1999
+ }
1809
2000
  function buildPendingFleetTemplate(intent, publishableKeyFieldReference = DEFAULT_PUBLISHABLE_KEY_FIELD_REFERENCE) {
1810
2001
  const suffix = `-${intent.harnessKind}`;
1811
- if (!intent.expectedAgentHandle.endsWith(suffix))
1812
- throw new Error("fleet_onboarding_expected_handle_invalid");
2002
+ // #401 — every throw in this function is a FleetSafeMessageError rather than a plain
2003
+ // `Error`, and the base class rather than `FleetPreflightError`: this function is called
2004
+ // pre-commit by both fleet paths AND post-commit through `finalizeFleetOutcomeInner`'s
2005
+ // `frozenPending ??` fallback, so the pre-flight class's stated contract ("the bounded
2006
+ // invite use is not spent") would be a claim this site cannot make. The redaction
2007
+ // exemption is `instanceof FleetSafeMessageError`, so the base class is equally exempt;
2008
+ // post-commit, `afterFleetCommit` still rewrites the whole thing into the COMMITTED
2009
+ // message and carries this text as its detail, which is the truthful framing.
2010
+ const suffixFailure = expectedHandleSuffixFailure(intent.expectedAgentHandle, intent.harnessKind);
2011
+ if (suffixFailure)
2012
+ throw new FleetSafeMessageError("fleet_onboarding_expected_handle_invalid", suffixFailure);
1813
2013
  const host = intent.expectedAgentHandle.slice(0, -suffix.length);
1814
2014
  const deployment = resolveJoinDeployment();
1815
- if (!deployment.keyReference)
1816
- throw new Error("fleet_onboarding_key_reference_missing");
2015
+ if (!deployment.keyReference) {
2016
+ throw new FleetSafeMessageError("fleet_onboarding_key_reference_missing", "fleet onboarding needs this host's keystore key reference: export "
2017
+ + "MESSAGE_BOARD_ENROLL_KEY_REFERENCE=<resolver>:<handle>. The variable NAME is named here; "
2018
+ + "its value is a local key handle and is never printed.");
2019
+ }
1817
2020
  const ref = normalizeKeyReference(deployment.keyReference);
1818
2021
  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");
2022
+ if (!platform) {
2023
+ // The SUPPORTED resolvers are a closed vocabulary this file writes; the configured one
2024
+ // is not, and it is the left half of a reference whose right half is a key handle. So
2025
+ // the message names what is accepted and stays silent about what was found.
2026
+ throw new FleetSafeMessageError("fleet_onboarding_unsupported_key_reference", "fleet onboarding supports only an os_keychain (desktop) or agent_socket (headless linux) key "
2027
+ + "reference; MESSAGE_BOARD_ENROLL_KEY_REFERENCE names neither. The configured resolver is not "
2028
+ + "printed because it is half of a local key descriptor.");
2029
+ }
1821
2030
  const separator = ref.handle.lastIndexOf("#");
1822
2031
  const pending = buildFleetHarnessTemplate({ host, harnessKind: intent.harnessKind, harnessLabel: intent.harnessLabel, platform,
1823
2032
  boardId: intent.boardId, machineId: intent.machineId, repoSlug: intent.repoSlug, repoFingerprint: intent.repoFingerprint,
@@ -1825,8 +2034,9 @@ function buildPendingFleetTemplate(intent, publishableKeyFieldReference = DEFAUL
1825
2034
  publishableKeyFieldReference,
1826
2035
  ...(platform === "headless_linux" ? { agentSocketPath: ref.handle.slice(0, separator), publicKeySelector: separator > 0 ? ref.handle.slice(separator + 1) : "" } : {}),
1827
2036
  });
1828
- if (pending.localKeyId !== intent.localKeyId || pending.keyReference !== `${ref.resolver}:${ref.handle}`)
1829
- throw new Error("fleet_onboarding_local_key_mismatch");
2037
+ const mismatch = localKeyMismatchFailure(pending, intent, ref);
2038
+ if (mismatch)
2039
+ throw new FleetSafeMessageError("fleet_onboarding_local_key_mismatch", mismatch);
1830
2040
  return pending;
1831
2041
  }
1832
2042
  async function finalizeFleetOutcome(options, intent, result, frozenPending) {
@@ -1848,8 +2058,13 @@ async function finalizeFleetOutcome(options, intent, result, frozenPending) {
1848
2058
  async function finalizeFleetOutcomeInner(options, intent, result, frozenPending) {
1849
2059
  if (intent.kind !== "fleet_onboarding")
1850
2060
  return result;
1851
- if (!options.runtimeOutput)
1852
- throw new Error("--fleet-onboarding requires --runtime-output");
2061
+ // Base class, not `FleetPreflightError`: this whole function runs against a COMMITTED
2062
+ // enrollment on both fleet paths, so the pre-flight class's "the invite use is not spent"
2063
+ // would be false here. `afterFleetCommit` rewrites it into the COMMITTED message anyway;
2064
+ // the classification only decides whether this sentence survives as its detail.
2065
+ if (!options.runtimeOutput) {
2066
+ throw new FleetSafeMessageError("fleet_onboarding_runtime_output_missing", "--fleet-onboarding requires --runtime-output");
2067
+ }
1853
2068
  const outcome = result;
1854
2069
  // LIVE, unlike its same-named twin in enroll_command.ts (message-board#212). That one
1855
2070
  // sits after an `enrollAgent` call whose return shape the same function controls, so
@@ -1861,8 +2076,9 @@ async function finalizeFleetOutcomeInner(options, intent, result, frozenPending)
1861
2076
  throw new Error("fleet_onboarding_receipt_missing");
1862
2077
  const ref = normalizeKeyReference(outcome.keyReference);
1863
2078
  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");
2079
+ const mismatch = localKeyMismatchFailure(pending, intent, ref);
2080
+ if (mismatch)
2081
+ throw new FleetSafeMessageError("fleet_onboarding_local_key_mismatch", mismatch);
1866
2082
  const fingerprint = ref.publicKeyFingerprint ?? `sha256:${createHash("sha256").update(Buffer.from(outcome.agentPublicKey, "base64url")).digest("base64url")}`;
1867
2083
  const resolvedPublishableKey = resolveFleetPublishableKey(pending.publishableKeyFieldReference);
1868
2084
  // Hoisted rather than inlined into the call: wrapping the call in an arrow drops the
@@ -1919,6 +2135,67 @@ function fleetTupleValue(field, value) {
1919
2135
  return disposition.normalize(value);
1920
2136
  return value;
1921
2137
  }
2138
+ /** The role pair rule, stated once for the two builders and the census that reports it. */
2139
+ function fleetRolePairHolds(options) {
2140
+ return (options.agentRole === "phase_worker" && options.repoScopeRole === "worker")
2141
+ || (options.agentRole === "reviewer" && options.repoScopeRole === "reviewer");
2142
+ }
2143
+ /**
2144
+ * EVERY unmet argv-layer precondition, in ONE pass (Consiliency/message-board#401).
2145
+ *
2146
+ * The command's input is a SINGLE-USE bounded invite. The previous shape reported the
2147
+ * first unmet precondition and stopped, so an operator learned of `--local-key-id` only
2148
+ * after every other flag was already right, and of the expected-handle rule only after
2149
+ * that — each discovery costing a run made with a consumable secret in hand.
2150
+ *
2151
+ * WHAT THIS PASS COVERS, stated precisely because the honest scope is narrower than "all
2152
+ * preconditions": the checks whose whole input is argv. The suffix rule is folded in from
2153
+ * `buildPendingFleetTemplate` because it needs only two flags. The rules that need this
2154
+ * HOST — the keystore key reference, the derived `--local-key-id` equality, the publishable
2155
+ * key, the runtime-output directory — cannot be decided here and are still sequential;
2156
+ * each of those now names its own rule instead, which is the other half of the fix.
2157
+ *
2158
+ * Normalization comes from `FLEET_TUPLE_DISPOSITION` via `fleetTupleValue`, never
2159
+ * re-derived here: a second copy of "trim, and lowercase the harness kind" would be free
2160
+ * to disagree with the copy that decides what actually crosses.
2161
+ */
2162
+ function fleetArgvPreconditionFailures(required, options) {
2163
+ const failures = [];
2164
+ const missing = required.filter(([, value]) => !value?.trim()).map(([name]) => name);
2165
+ if (missing.length)
2166
+ failures.push(`missing: ${missing.join(", ")}`);
2167
+ // Both-present guard on each derived rule: a flag that is absent is already named above,
2168
+ // and reporting it a second time as a broken rule reads as two defects where there is one.
2169
+ if (options.agentRole?.trim() && options.repoScopeRole?.trim() && !fleetRolePairHolds(options)) {
2170
+ // The supplied values are not echoed: the rule is a closed vocabulary this file writes,
2171
+ // and the operand is unbounded operator input.
2172
+ failures.push("--agent-role/--repo-scope-role must be phase_worker/worker or reviewer/reviewer");
2173
+ }
2174
+ if (options.harnessKind?.trim() && options.expectedAgentHandle?.trim()) {
2175
+ const suffix = expectedHandleSuffixFailure(fleetTupleValue("expectedAgentHandle", options.expectedAgentHandle), fleetTupleValue("harnessKind", options.harnessKind));
2176
+ if (suffix)
2177
+ failures.push(suffix);
2178
+ }
2179
+ return failures;
2180
+ }
2181
+ /**
2182
+ * The closing sentence is the CALLER's, not this function's (Consiliency/message-board#402).
2183
+ *
2184
+ * A world-state assurance belongs only where it is unconditionally true. `run` and device
2185
+ * `begin` are one-shot: reaching this refusal means no server call happened on any leg, so
2186
+ * they say so. A device RESUME says nothing about commit status at all — it may be the
2187
+ * second leg of an enrollment the server already committed, and the fence around it (or, if
2188
+ * `store.put` failed and no outcome was recorded, the absence of any local evidence) is what
2189
+ * decides that. An earlier revision shared one sentence between them, and the resume's
2190
+ * committed envelope then carried "spent nothing itself" as the detail of a message whose
2191
+ * framing said the invite use was spent. Both halves were literally true of their own
2192
+ * subject and the pair was unreadable.
2193
+ */
2194
+ function renderFleetPreconditionFailures(subject, failures, closing) {
2195
+ return `${subject}: ${failures.length} unmet precondition${failures.length === 1 ? "" : "s"} — `
2196
+ + failures.map((failure, index) => `(${index + 1}) ${failure}`).join(" ")
2197
+ + ` ${closing}`;
2198
+ }
1922
2199
  function fleetResumeIntentFromCli(options) {
1923
2200
  const required = [
1924
2201
  ["--slug", options.slug], ["--board-id", options.boardId], ["--machine-id", options.machineId],
@@ -1928,13 +2205,12 @@ function fleetResumeIntentFromCli(options) {
1928
2205
  ["--expected-agent-handle", options.expectedAgentHandle], ["--local-key-id", options.localKeyId],
1929
2206
  ["--runtime-output", options.runtimeOutput],
1930
2207
  ];
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");
2208
+ const failures = fleetArgvPreconditionFailures(required, options);
2209
+ if (failures.length) {
2210
+ throw new FleetPreflightError("fleet_device_resume_preconditions_unmet",
2211
+ // No commit-status claim: a resume can be the second leg of a committed enrollment.
2212
+ renderFleetPreconditionFailures("fleet device resume", failures, "Fix all of the above in one edit and re-run."));
2213
+ }
1938
2214
  // Synchronous, so the presentation gate can convert a refusal into its own message here
1939
2215
  // rather than letting the blanket fleet redaction flatten it (#254).
1940
2216
  return throughPresentationGate(() => ({
@@ -1951,27 +2227,37 @@ function fleetResumeIntentFromCli(options) {
1951
2227
  async function enrollmentIntentFromCli(options) {
1952
2228
  if (!options.fleetOnboarding)
1953
2229
  return intentFromOptions(options);
2230
+ // Stays BEFORE the stdin read, deliberately: moving it after would drain a piped invite
2231
+ // code on a command that is going to refuse anyway, for no diagnostic gain.
1954
2232
  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");
2233
+ throw new FleetPreflightError("fleet_onboarding_conflicting_intent_flags", "--fleet-onboarding cannot be combined with --create-board, --join-board, or --no-board: a fleet "
2234
+ + "invite already names the board this enrollment joins. This command stopped before any server call and spent nothing itself.");
1956
2235
  }
1957
2236
  let inviteCode = options.inviteCode;
1958
2237
  if (!inviteCode)
1959
2238
  inviteCode = await readCodeFromStdin();
1960
2239
  const required = [
1961
2240
  ["invite code (stdin or --invite-code)", inviteCode],
2241
+ // #402 — the flag an operator is most likely to omit, and the one that used to escape
2242
+ // this collector entirely. `fleetResumeIntentFromCli` has always required it; the two
2243
+ // builders are symmetric again.
2244
+ ["--slug", options.slug],
1962
2245
  ["--board-id", options.boardId], ["--machine-id", options.machineId],
1963
2246
  ["--harness-kind", options.harnessKind], ["--harness-label", options.harnessLabel],
1964
2247
  ["--repo-slug", options.repoSlug], ["--repo-fingerprint", options.repoFingerprint],
1965
2248
  ["--repo-scope-role", options.repoScopeRole], ["--agent-role", options.agentRole],
1966
2249
  ["--expected-agent-handle", options.expectedAgentHandle], ["--local-key-id", options.localKeyId],
2250
+ // #401 — every fleet caller of this builder requires it (`run` finalizes into it,
2251
+ // `device` begin refuses without it), and it was the one required flag whose absence
2252
+ // reached the operator as a redacted `invalid_runtime_output` from the filesystem layer.
2253
+ ["--runtime-output", options.runtimeOutput],
1967
2254
  ];
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");
2255
+ const failures = fleetArgvPreconditionFailures(required, options);
2256
+ if (failures.length) {
2257
+ throw new FleetPreflightError("fleet_onboarding_preconditions_unmet",
2258
+ // One-shot callers only (`run`, device `begin`), so the assurance is unconditional.
2259
+ renderFleetPreconditionFailures("--fleet-onboarding", failures, "This command stopped before any server call and spent nothing itself; fix all of the above in one edit and re-run."));
2260
+ }
1975
2261
  // The stdin read above is the only `await` on this path, and it is already done — so the
1976
2262
  // gate wraps a purely SYNCHRONOUS construction and cannot miss a refusal that would
1977
2263
  // otherwise surface as an unconverted promise rejection.
@@ -2087,7 +2373,14 @@ function addEnrollCommands(program, io, enrollment) {
2087
2373
  const runCommand = enroll
2088
2374
  .command("run", { isDefault: true })
2089
2375
  .description("one-shot enrollment over an RFC 8252 loopback receiver")
2090
- .requiredOption("--slug <slug>", "desired agent slug")
2376
+ // NOT `requiredOption` (Consiliency/message-board#402). Commander enforces that one
2377
+ // BEFORE any action runs: it prints its own usage line to the process's stderr and
2378
+ // exits, so `--slug` never reached the one-pass collector and the operator got a usage
2379
+ // string instead of the JSON envelope every other failure on this command produces —
2380
+ // for the flag they are most likely to omit. The requirement is not dropped; it moved
2381
+ // to the collector (fleet) and to an explicit guard (non-fleet), both of which answer
2382
+ // in the envelope.
2383
+ .option("--slug <slug>", "desired agent slug")
2091
2384
  .option("--agent-kind <kind>", "agent kind (default 'agent')")
2092
2385
  .option("--create-board <slug>", "create-board intent slug")
2093
2386
  .option("--board-name <name>", "board name for create-board")
@@ -2099,6 +2392,12 @@ function addEnrollCommands(program, io, enrollment) {
2099
2392
  .option("--no-board", "enroll WITHOUT provisioning any board (no create, no join)");
2100
2393
  addFleetOptions(runCommand)
2101
2394
  .action((options) => runHandler(io, "agent_board.enroll", async () => {
2395
+ // The non-fleet half of what `requiredOption` used to do. The fleet half is the
2396
+ // collector's; this arm keeps its own guard because `enrollmentIntentFromCli`
2397
+ // returns before the collector when `--fleet-onboarding` is absent.
2398
+ if (!options.fleetOnboarding && !options.slug?.trim()) {
2399
+ throw new FleetPreflightError("enroll_run_slug_required", "enroll run requires --slug");
2400
+ }
2102
2401
  // ORDER IS THE CONTRACT on this path (#254). The intent is built and the whole
2103
2402
  // pending template is constructed BEFORE the filesystem is touched and before the
2104
2403
  // server is asked to commit anything — which is the order `enroll device` (begin
@@ -2204,7 +2503,15 @@ function addEnrollCommands(program, io, enrollment) {
2204
2503
  .action((options) => runHandler(io, "agent_board.enroll_device", async () => {
2205
2504
  if (options.enrollmentId) {
2206
2505
  if (!enrollment.deviceComplete) {
2207
- throw new Error("device resume is not supported by this runner (EnrollmentCommandRunner.deviceComplete is not implemented)");
2506
+ // #402 — a SAFE message rather than a plain `Error`. These two fire BEFORE the
2507
+ // continuation is read, so they sit outside `throughResumeCommitUnknown` and were
2508
+ // redacted to "fleet enrollment failed before completion" — an unqualified claim
2509
+ // on a resume, where an earlier leg may have committed. Classified here instead
2510
+ // of moved inside the wrapper, so the integrator facing them keeps the one thing
2511
+ // that makes them actionable: the name of the method that is missing.
2512
+ throw new FleetSafeMessageError("fleet_device_resume_runner_incomplete", "device resume is not supported by this runner (EnrollmentCommandRunner.deviceComplete is not "
2513
+ + "implemented). This says nothing about whether an earlier leg of this enrollment committed: "
2514
+ + "nothing was read from this host's continuation before the refusal.");
2208
2515
  }
2209
2516
  if (!options.fleetOnboarding) {
2210
2517
  // #210, second half — shaped on the OUTCOME, not on the flag. `fleetSafeOutput`
@@ -2223,57 +2530,112 @@ function addEnrollCommands(program, io, enrollment) {
2223
2530
  return fleetSafeOutput(await enrollment.deviceComplete({ enrollmentId: options.enrollmentId }));
2224
2531
  }
2225
2532
  if (!enrollment.deviceDeletePending) {
2226
- throw new Error("fleet device resume is not supported by this runner (EnrollmentCommandRunner.deviceDeletePending is not implemented)");
2533
+ throw new FleetSafeMessageError("fleet_device_resume_runner_incomplete", "fleet device resume is not supported by this runner (EnrollmentCommandRunner.deviceDeletePending is "
2534
+ + "not implemented). This says nothing about whether an earlier leg of this enrollment committed: "
2535
+ + "nothing was read from this host's continuation before the refusal.");
2227
2536
  }
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;
2537
+ // Starts at the STORE, not after it: `store.get` can fail on its own (an unsafe
2538
+ // state directory), and that failure was redacted to "failed before completion"
2539
+ // with no continuation readable — the variant no marker could ever have fenced.
2540
+ //
2541
+ // One wrapper covers both branches because the armed one cannot reach it:
2542
+ // `afterFleetCommit` turns every failure under it into a `FleetPostCommitError`,
2543
+ // which this converter passes through by class.
2544
+ return throughResumeCommitUnknown(async () => {
2545
+ const store = new FleetDeviceContinuationStore(defaultDevicePendingStateDir(process.env));
2546
+ const continuation = await store.get(options.enrollmentId);
2547
+ if (!continuation) {
2548
+ throw new FleetPreflightError("fleet_device_continuation_unknown", "no fleet device continuation is stored on this host for that --enrollment-id. A fleet resume needs the "
2549
+ + "state written by the matching `enroll device --fleet-onboarding` begin, on this same machine and user "
2550
+ + "account; a completed resume DELETES it. So this says nothing about whether an enrollment committed: "
2551
+ + "a begin that never ran here and a resume that already finished both look like this. Verify on the board "
2552
+ + "before enrolling again. The id itself is not echoed.");
2553
+ }
2554
+ // THE RESUME FENCE (Consiliency/message-board#402).
2555
+ //
2556
+ // A stored `outcome` is the evidence that the server ALREADY COMMITTED this
2557
+ // enrollment: the tail below persists it, inside `afterFleetCommit`, only after
2558
+ // `deviceComplete` returned one. Everything from here to that tail — the argv
2559
+ // census, the template build, the continuation comparison, the runtime-output
2560
+ // preparation — used to run outside any fence, so an operator retrying a resume
2561
+ // after a committed-but-unfinalized leg (the realistic retry: a dropped flag, a
2562
+ // corrected `--runtime-output`) was told the enrollment had not happened. That is
2563
+ // the #176 shape the whole classification exists to prevent, and its cost is a
2564
+ // second enrollment against a second invite.
2565
+ //
2566
+ // Reusing `afterFleetCommit` rather than composing a second truth: one function
2567
+ // decides what a post-commit failure says. Nesting is safe — the tail's own
2568
+ // `afterFleetCommit` produces a `FleetPostCommitError`, which this one rethrows
2569
+ // unchanged — and `throughFleetCommit` cannot collide with it, because that runs
2570
+ // only when `committedThisLeg`, which is exactly when this fence is NOT armed.
2571
+ //
2572
+ // The fence is necessary but NOT sufficient, which is why every message on this
2573
+ // path also had its commit-status claim removed unconditionally: if leg 1 commits
2574
+ // and the `store.put` that records it FAILS (cli.test.ts pins that case), the
2575
+ // server has committed and no `outcome` is stored, so nothing here can arm.
2576
+ const resumeAfterCommit = continuation.outcome;
2577
+ const finishResume = async () => {
2578
+ const resumeIntent = fleetResumeIntentFromCli(options);
2579
+ // Same gate as `run` (#254). A refusal raised INSIDE the template build — an
2580
+ // interior prohibited character reaching `atom()`, say — is not a message this
2581
+ // file wrote, so without the gate the blanket fleet redaction flattens it to
2582
+ // "failed before completion" on this path while `run` prints the named field.
2583
+ // Pre-commit either way; the difference is whether the operator can act on it.
2584
+ const candidate = throughPresentationGate(() => buildPendingFleetTemplate(resumeIntent, options.publishableKeyFieldReference));
2585
+ const pending = continuation.pendingTemplate;
2586
+ // #401 — ALL differing comparisons, named together, from a closed set of literals
2587
+ // this file writes. Only the NAME of each comparison crosses; no operand does,
2588
+ // because two of the five are a key reference and a whole template.
2589
+ const divergent = [
2590
+ ["--slug", options.slug.trim() !== pending.agentSlug],
2591
+ ["--runtime-output", options.runtimeOutput !== continuation.runtimeOutput],
2592
+ ["the keystore key reference", continuation.keyReference !== pending.keyReference],
2593
+ ["--publishable-key-field-reference", continuation.publishableKeyFieldReference !== pending.publishableKeyFieldReference],
2594
+ ["the bounded tuple (--board-id/--machine-id/--harness-kind/--harness-label/--repo-slug/--repo-fingerprint/--repo-scope-role/--agent-role/--expected-agent-handle/--local-key-id)",
2595
+ JSON.stringify(candidate) !== JSON.stringify(pending)],
2596
+ ].filter(([, differs]) => differs).map(([name]) => name);
2597
+ if (divergent.length) {
2598
+ throw new FleetPreflightError("fleet_device_continuation_mismatch", `this resume does not match the fleet device begin it claims to continue — ${divergent.join(", ")} `
2599
+ + `${divergent.length === 1 ? "differs" : "differ"} from the stored continuation. Re-run the resume with the `
2600
+ + "exact arguments of the begin. The continuation was kept.");
2601
+ }
2602
+ await prepareFleetRuntimeOutput(options.runtimeOutput);
2603
+ const expectedIntent = { kind: "fleet_onboarding", ...pending.enrollment };
2604
+ let outcome = continuation.outcome;
2605
+ const committedThisLeg = !outcome;
2606
+ if (committedThisLeg) {
2607
+ // Nothing has committed yet on this leg — check the second local
2608
+ // prerequisite before spending the device authorization.
2609
+ resolveFleetPublishableKey(pending.publishableKeyFieldReference);
2610
+ // #202 — same boundary as `run`: deviceComplete commits inside the call.
2611
+ outcome = await throughFleetCommit(() => enrollment.deviceComplete({
2612
+ enrollmentId: options.enrollmentId,
2613
+ expectedFleet: { intent: expectedIntent, keyReference: pending.keyReference, slug: pending.agentSlug },
2614
+ deletePending: false,
2615
+ }));
2616
+ }
2617
+ return afterFleetCommit(outcome, async () => {
2618
+ // The write that MAKES this recoverable is itself post-commit: mkdir,
2619
+ // O_EXCL create, write, fsync and rename can each fail. Outside the fence
2620
+ // its failure read as "failed before completion", and because the outcome
2621
+ // was never persisted a retry would re-enter deviceComplete against a
2622
+ // device authorization that is already consumed.
2623
+ if (committedThisLeg)
2624
+ await store.put({ ...continuation, outcome: outcome });
2625
+ const result = await finalizeFleetOutcome(options, { ...expectedIntent, inviteCode: "" }, outcome, pending);
2626
+ await enrollment.deviceDeletePending({ enrollmentId: options.enrollmentId });
2627
+ await store.delete(options.enrollmentId);
2628
+ return result;
2629
+ });
2630
+ };
2631
+ return resumeAfterCommit ? afterFleetCommit(resumeAfterCommit, finishResume) : finishResume();
2273
2632
  });
2274
2633
  }
2275
- if (!options.slug)
2276
- throw new Error("enroll device requires --slug when --enrollment-id is absent");
2634
+ // Non-fleet only (#402). A fleet begin gets `--slug` from the collector below,
2635
+ // alongside every other unmet precondition, instead of one refusal ahead of it.
2636
+ if (!options.fleetOnboarding && !options.slug) {
2637
+ throw new FleetPreflightError("enroll_device_slug_required", "enroll device requires --slug when --enrollment-id is absent");
2638
+ }
2277
2639
  if (options.fleetOnboarding) {
2278
2640
  if (!enrollment.deviceBegin) {
2279
2641
  throw new Error("fleet device begin is not supported by this runner (EnrollmentCommandRunner.deviceBegin is not implemented)");
@@ -2282,8 +2644,11 @@ function addEnrollCommands(program, io, enrollment) {
2282
2644
  if (intent.kind !== "fleet_onboarding")
2283
2645
  throw new Error("fleet_device_intent_missing");
2284
2646
  const pending = throughPresentationGate(() => buildPendingFleetTemplate(intent, options.publishableKeyFieldReference));
2285
- if (!options.runtimeOutput)
2286
- throw new Error("--fleet-onboarding requires --runtime-output");
2647
+ // Unreachable today — the argv census above now names `--runtime-output` — and kept
2648
+ // as the boundary's own guard rather than as the operator's first notice of it.
2649
+ if (!options.runtimeOutput) {
2650
+ throw new FleetPreflightError("fleet_onboarding_runtime_output_missing", "--fleet-onboarding requires --runtime-output");
2651
+ }
2287
2652
  await prepareFleetRuntimeOutput(options.runtimeOutput);
2288
2653
  // Before a human approval is spent, and before any continuation state exists
2289
2654
  // to clean up.
@@ -2298,7 +2663,18 @@ function addEnrollCommands(program, io, enrollment) {
2298
2663
  intent, requestedScopes: csv(options.requestedScopes), requestedBoardScopes: csv(options.requestedBoardScopes) });
2299
2664
  if (begun.enrollmentId !== enrollmentId)
2300
2665
  throw new Error("fleet_device_enrollment_id_mismatch");
2301
- return begun;
2666
+ // SHAPED, not passed through (#402). `runHandler` serializes whatever this
2667
+ // returns, and the runner is an injection seam that received the whole intent —
2668
+ // `inviteCode` included — so any extra property it put on its result reached
2669
+ // stdout. The same reasoning `fleetSafeOutput` already applies to an outcome;
2670
+ // this was the one enroll result that had no such waist.
2671
+ return { enrollmentId: begun.enrollmentId, prompt: {
2672
+ userCode: begun.prompt.userCode,
2673
+ verificationUri: begun.prompt.verificationUri,
2674
+ ...(begun.prompt.verificationUriComplete === undefined ? {} : { verificationUriComplete: begun.prompt.verificationUriComplete }),
2675
+ ...(begun.prompt.expiresIn === undefined ? {} : { expiresIn: begun.prompt.expiresIn }),
2676
+ ...(begun.prompt.interval === undefined ? {} : { interval: begun.prompt.interval }),
2677
+ } };
2302
2678
  }
2303
2679
  catch (error) {
2304
2680
  await store.delete(enrollmentId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@consiliency/agent-board-cli",
3
- "version": "1.13.0",
3
+ "version": "1.15.0",
4
4
  "repository": "https://github.com/Consiliency/message-board.git",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -31,7 +31,7 @@
31
31
  "registry": "https://registry.npmjs.org"
32
32
  },
33
33
  "dependencies": {
34
- "@consiliency/agent-board-client": "1.24.0",
34
+ "@consiliency/agent-board-client": "1.25.0",
35
35
  "commander": "^14.0.1"
36
36
  },
37
37
  "license": "Apache-2.0"