@consiliency/agent-board-cli 1.10.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.
Files changed (3) hide show
  1. package/dist/cli.d.ts +80 -0
  2. package/dist/cli.js +243 -49
  3. package/package.json +2 -2
package/dist/cli.d.ts CHANGED
@@ -221,6 +221,86 @@ export interface DefaultEnrollmentRunnerDeps {
221
221
  };
222
222
  }
223
223
  export declare function defaultEnrollmentRunner(env?: NodeJS.ProcessEnv, deps?: DefaultEnrollmentRunnerDeps): EnrollmentCommandRunner;
224
+ /**
225
+ * How each field of a `FleetOnboardingIntent` is turned from an argv string into the value
226
+ * that goes on the wire and into the template (Consiliency/message-board#254).
227
+ *
228
+ * This map exists because the previous shape — nine literal `.trim()` calls repeated at two
229
+ * call sites — is the completeness failure CLAUDE.md rule 1 targets: #254 itself enumerated
230
+ * six of the nine, and got one of the six wrong. The map is TOTAL over the intent's string
231
+ * fields by construction, so a field added to `FleetOnboardingIntent` does not compile until
232
+ * someone dispositions it, and both call sites read their verb from here rather than
233
+ * restating it.
234
+ *
235
+ * Three dispositions, because there are three real behaviours and collapsing them is what
236
+ * hid the defect:
237
+ *
238
+ * - `gated` — the library REFUSES a prohibited character in this value rather than
239
+ * rewriting it, because the value is carried byte-exact into a template that runs on
240
+ * ANOTHER machine. A bare `.trim()` here deleted an edge-placed member of the refuse
241
+ * classes before `required()` could see it, so the library's raw-operand ordering (#215)
242
+ * was unreachable from the CLI. The verb is the library's own, imported.
243
+ * - `normalized` — the value is NOT carried verbatim: it is folded, or it is pinned by
244
+ * equality against a value derived from folded atoms. Trimming is right here, and
245
+ * removing it would regress an ordinary trailing space into a hard failure for no
246
+ * byte-exactness gain. Each one's claim is asserted positively in `cli.test.ts`, on the
247
+ * intent the runner RECEIVES — `expectedAgentHandle` and `inviteCode` are copied verbatim
248
+ * into the /enroll body, so "the output is clean" would not be evidence about them.
249
+ * - `vocabulary` — a closed set. A poisoned value cannot match a member, so it is refused
250
+ * by the existing pair check rather than by a presentation class.
251
+ *
252
+ * Each entry also states what a PROHIBITED character in that field does to the /enroll
253
+ * request — `wire`, which is the property `cli.test.ts` measures. It is stated per field
254
+ * rather than as one blanket claim because the blanket claim is false: an invite code is a
255
+ * bearer secret the server matches, so the refuse class is not policed on it.
256
+ *
257
+ * `verbatim` means exactly that — THE REFUSE CLASS IS NOT POLICED ON THIS FIELD — and not
258
+ * "untouched". Edges are still trimmed here as on every field (AC-6), so a prohibited
259
+ * character placed at an edge is erased and only an INTERIOR one survives to the wire. The
260
+ * label is worth stating precisely because the looser reading ("it travels as the operator
261
+ * typed it") stood in this comment and in three places in the plan, and was false at the
262
+ * edges in all four. `cli.test.ts` asserts the arm per PLACEMENT, which is the form that
263
+ * cannot be read two ways.
264
+ *
265
+ * Asserting a field's real arm is what makes an exemption evidence instead of prose.
266
+ */
267
+ type FleetTupleDisposition = {
268
+ readonly kind: "gated";
269
+ readonly flag: string;
270
+ readonly label: string;
271
+ readonly wire: "refused";
272
+ } | {
273
+ readonly kind: "normalized";
274
+ readonly flag: string;
275
+ readonly why: string;
276
+ readonly wire: "clean" | "verbatim";
277
+ readonly normalize: (value: string) => string;
278
+ } | {
279
+ readonly kind: "vocabulary";
280
+ readonly flag?: string;
281
+ readonly why: string;
282
+ readonly wire: "refused";
283
+ };
284
+ /**
285
+ * The string-valued keys of `T` — the operator the totality claim above rests on, and the
286
+ * reason it is named and exported rather than written inline.
287
+ *
288
+ * `NonNullable` is load-bearing. `-?` strips optionality from the PRODUCED property, but
289
+ * the conditional tests the original indexed access, and for `field?: string` that is
290
+ * `string | undefined`, which does not extend `string`. Written without `NonNullable` this
291
+ * operator answers `never` for every optional member — so an optional string field added to
292
+ * the intent would vanish from the disposition map, from the census, and from the wire
293
+ * matrix, WITHOUT a compile error. Fail-open, and invisible until someone adds such a field.
294
+ *
295
+ * Latent rather than live today (`FleetOnboardingIntent` has no optional string members),
296
+ * which is exactly why it is pinned by a type-level probe in `cli.test.ts` — a guard whose
297
+ * failure mode is silence has to be tested against a shape that would trigger it.
298
+ */
299
+ export type StringFieldsOf<T> = {
300
+ [K in keyof T]-?: NonNullable<T[K]> extends string ? K : never;
301
+ }[keyof T];
302
+ type FleetTupleField = StringFieldsOf<FleetOnboardingIntent>;
303
+ export declare const FLEET_TUPLE_DISPOSITION: Readonly<Record<FleetTupleField, FleetTupleDisposition>>;
224
304
  export declare function createProgram(client: ClientLike, io: CliIo, enrollment?: EnrollmentCommandRunner): Command;
225
305
  export declare function runCli(argv: string[], io?: CliIo, client?: ClientLike, enrollment?: EnrollmentCommandRunner): Promise<void>;
226
306
  export {};
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@ import { Command, Option } from "commander";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { createAgentBoardClient, toOperationFailure, beginEnrollment, completeEnrollment, beginDeviceEnrollment, completeDeviceEnrollment, runEnrollment, runDeviceEnrollment, resolveEnrollmentDescriptor, EnrollmentDescriptorError, EnrollmentCommandError, resolveEnrollEndpoint, resolveEnrollSessionUrl, resolveEnrollPublishableKey, resolveJoinDeployment, buildFleetHarnessTemplate, finalizeFleetHarnessTemplate, normalizePublishableKeyFieldReference, normalizeKeyReference, enrollCommitMarker, buildAgentJoinInstructions, nodeFetchAdapter, buildEnrollKeystore, } from "@consiliency/agent-board-client";
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
6
  import { DevicePendingEnrollmentStore, FilePendingEnrollmentStore, FleetDeviceContinuationStore, makeBrowserOpener, prepareFleetRuntimeOutput, publishFinalizedFleetTemplate, } from "./enroll_runtime.js";
7
7
  function isRuntimeStatus(value) {
8
8
  return Boolean(value && typeof value === "object" && "runtime" in value);
@@ -71,6 +71,53 @@ class FleetPostCommitError extends FleetSafeMessageError {
71
71
  * VALUE, not an `instanceof EnrollError`: a cross-package `instanceof` is silently
72
72
  * false when the workspace holds two copies of the client module.
73
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
+ }
74
121
  function namedCauseCode(cause) {
75
122
  if (!cause || typeof cause !== "object")
76
123
  return "";
@@ -197,6 +244,47 @@ async function throughFleetCommit(call) {
197
244
  : new FleetPostCommitError(marker.facts, error);
198
245
  }
199
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
+ }
200
288
  async function runHandler(io, operation, action, redactFleetFailure = false) {
201
289
  try {
202
290
  const result = await action();
@@ -209,7 +297,7 @@ async function runHandler(io, operation, action, redactFleetFailure = false) {
209
297
  if (redactFleetFailure && !(error instanceof FleetSafeMessageError)) {
210
298
  failure.error.message = "fleet enrollment failed before completion";
211
299
  }
212
- io.writeStdout(`${JSON.stringify(failure, null, 2)}\n`);
300
+ writeFailureEnvelope(io, failure);
213
301
  process.exitCode = 1;
214
302
  }
215
303
  }
@@ -218,7 +306,7 @@ async function runJsonlHandler(io, operation, action) {
218
306
  io.writeStdout(`${JSON.stringify(await action())}\n`);
219
307
  }
220
308
  catch (error) {
221
- io.writeStdout(`${JSON.stringify(toOperationFailure(operation, error))}\n`);
309
+ writeFailureEnvelope(io, toOperationFailure(operation, error), 0);
222
310
  process.exitCode = 1;
223
311
  }
224
312
  }
@@ -1742,6 +1830,22 @@ function buildPendingFleetTemplate(intent, publishableKeyFieldReference = DEFAUL
1742
1830
  return pending;
1743
1831
  }
1744
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) {
1745
1849
  if (intent.kind !== "fleet_onboarding")
1746
1850
  return result;
1747
1851
  if (!options.runtimeOutput)
@@ -1761,9 +1865,59 @@ async function finalizeFleetOutcome(options, intent, result, frozenPending) {
1761
1865
  throw new Error("fleet_onboarding_local_key_mismatch");
1762
1866
  const fingerprint = ref.publicKeyFingerprint ?? `sha256:${createHash("sha256").update(Buffer.from(outcome.agentPublicKey, "base64url")).digest("base64url")}`;
1763
1867
  const resolvedPublishableKey = resolveFleetPublishableKey(pending.publishableKeyFieldReference);
1764
- const finalized = finalizeFleetHarnessTemplate(pending, { ...outcome.onboarding, agentId: outcome.agentId, signingKeyId: outcome.signingKeyId, keyId: outcome.keyId, keyVersion: outcome.keyVersion, agentHandle: outcome.agentHandle }, { keyReference: pending.keyReference, publicKey: outcome.agentPublicKey, publicKeyFingerprint: fingerprint }, resolvedPublishableKey);
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);
1765
1877
  const published = await publishFinalizedFleetTemplate(options.runtimeOutput, finalized);
1766
- return { enrollment: fleetSafeOutput(outcome), materialization: { generation: published.generation, digest: published.digest } };
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;
1767
1921
  }
1768
1922
  function fleetResumeIntentFromCli(options) {
1769
1923
  const required = [
@@ -1781,15 +1935,18 @@ function fleetResumeIntentFromCli(options) {
1781
1935
  || (options.agentRole === "reviewer" && options.repoScopeRole === "reviewer");
1782
1936
  if (!pair)
1783
1937
  throw new Error("fleet device resume allows only phase_worker/worker or reviewer/reviewer");
1784
- return {
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(() => ({
1785
1941
  kind: "fleet_onboarding", inviteCode: "",
1786
- boardId: options.boardId.trim(), machineId: options.machineId.trim(),
1787
- harnessKind: options.harnessKind.trim().toLowerCase(), harnessLabel: options.harnessLabel.trim(),
1788
- repoSlug: options.repoSlug.trim(), repoFingerprint: options.repoFingerprint.trim(),
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),
1789
1945
  repoScopeRole: options.repoScopeRole,
1790
1946
  agentRole: options.agentRole,
1791
- expectedAgentHandle: options.expectedAgentHandle.trim(), localKeyId: options.localKeyId.trim(),
1792
- };
1947
+ expectedAgentHandle: fleetTupleValue("expectedAgentHandle", options.expectedAgentHandle),
1948
+ localKeyId: fleetTupleValue("localKeyId", options.localKeyId),
1949
+ }));
1793
1950
  }
1794
1951
  async function enrollmentIntentFromCli(options) {
1795
1952
  if (!options.fleetOnboarding)
@@ -1815,20 +1972,23 @@ async function enrollmentIntentFromCli(options) {
1815
1972
  || (options.agentRole === "reviewer" && options.repoScopeRole === "reviewer");
1816
1973
  if (!pair)
1817
1974
  throw new Error("--fleet-onboarding allows only phase_worker/worker or reviewer/reviewer");
1818
- return {
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(() => ({
1819
1979
  kind: "fleet_onboarding",
1820
- inviteCode: inviteCode.trim(),
1821
- boardId: options.boardId.trim(),
1822
- machineId: options.machineId.trim(),
1823
- harnessKind: options.harnessKind.trim().toLowerCase(),
1824
- harnessLabel: options.harnessLabel.trim(),
1825
- repoSlug: options.repoSlug.trim(),
1826
- repoFingerprint: options.repoFingerprint.trim(),
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),
1827
1987
  repoScopeRole: options.repoScopeRole,
1828
1988
  agentRole: options.agentRole,
1829
- expectedAgentHandle: options.expectedAgentHandle.trim(),
1830
- localKeyId: options.localKeyId.trim(),
1831
- };
1989
+ expectedAgentHandle: fleetTupleValue("expectedAgentHandle", options.expectedAgentHandle),
1990
+ localKeyId: fleetTupleValue("localKeyId", options.localKeyId),
1991
+ }));
1832
1992
  }
1833
1993
  /**
1834
1994
  * Every field DECLARED on `FleetOnboardingReceipt` (including the nine it inherits from
@@ -1871,7 +2031,7 @@ function fleetSafeOutput(result) {
1871
2031
  if (field in source)
1872
2032
  receipt[field] = source[field];
1873
2033
  }
1874
- return {
2034
+ const shaped = {
1875
2035
  agentId: outcome.agentId,
1876
2036
  authUserId: outcome.authUserId,
1877
2037
  providerId: outcome.providerId,
@@ -1881,6 +2041,24 @@ function fleetSafeOutput(result) {
1881
2041
  agentHandle: outcome.agentHandle,
1882
2042
  onboarding: receipt,
1883
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;
1884
2062
  }
1885
2063
  function addFleetOptions(command) {
1886
2064
  return command
@@ -1921,30 +2099,41 @@ function addEnrollCommands(program, io, enrollment) {
1921
2099
  .option("--no-board", "enroll WITHOUT provisioning any board (no create, no join)");
1922
2100
  addFleetOptions(runCommand)
1923
2101
  .action((options) => runHandler(io, "agent_board.enroll", async () => {
1924
- if (options.fleetOnboarding) {
1925
- // BOTH local prerequisites of fleet finalization are checked here, before the
1926
- // server is asked to commit anything. The publishable key used to be read only
1927
- // inside finalizeFleetOutcome, after `enrollment.run` had already created the
1928
- // identity and spent the bounded invite use (message-board#176).
1929
- await prepareFleetRuntimeOutput(options.runtimeOutput ?? "");
1930
- resolveFleetPublishableKey(options.publishableKeyFieldReference);
1931
- }
1932
- return enrollmentIntentFromCli(options).then(async (intent) => {
1933
- const call = () => enrollment.run({
1934
- slug: options.slug,
1935
- agentKind: options.agentKind,
1936
- intent,
1937
- requestedScopes: csv(options.requestedScopes),
1938
- requestedBoardScopes: csv(options.requestedBoardScopes),
1939
- });
1940
- if (intent.kind !== "fleet_onboarding")
1941
- return finalizeFleetOutcome(options, intent, await call());
1942
- // #202 — the runner commits inside this call, so the fence must start here,
1943
- // not after it. Fleet-only: the post-commit message is about a fleet
1944
- // enrollment, and a non-fleet failure is not redacted in the first place.
1945
- const result = await throughFleetCommit(call);
1946
- return afterFleetCommit(result, () => finalizeFleetOutcome(options, intent, result));
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),
1947
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));
1948
2137
  }, Boolean(options.fleetOnboarding)));
1949
2138
  enroll
1950
2139
  .command("begin")
@@ -2041,7 +2230,12 @@ function addEnrollCommands(program, io, enrollment) {
2041
2230
  if (!continuation)
2042
2231
  throw new Error("fleet_device_continuation_unknown");
2043
2232
  const resumeIntent = fleetResumeIntentFromCli(options);
2044
- const candidate = buildPendingFleetTemplate(resumeIntent, options.publishableKeyFieldReference);
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));
2045
2239
  const pending = continuation.pendingTemplate;
2046
2240
  if (options.slug.trim() !== pending.agentSlug || options.runtimeOutput !== continuation.runtimeOutput
2047
2241
  || continuation.keyReference !== pending.keyReference
@@ -2087,7 +2281,7 @@ function addEnrollCommands(program, io, enrollment) {
2087
2281
  const intent = await enrollmentIntentFromCli(options);
2088
2282
  if (intent.kind !== "fleet_onboarding")
2089
2283
  throw new Error("fleet_device_intent_missing");
2090
- const pending = buildPendingFleetTemplate(intent, options.publishableKeyFieldReference);
2284
+ const pending = throughPresentationGate(() => buildPendingFleetTemplate(intent, options.publishableKeyFieldReference));
2091
2285
  if (!options.runtimeOutput)
2092
2286
  throw new Error("--fleet-onboarding requires --runtime-output");
2093
2287
  await prepareFleetRuntimeOutput(options.runtimeOutput);
@@ -2303,7 +2497,7 @@ function addRoomsCommands(program, client, io) {
2303
2497
  io.writeStdout(`\n--- agent join instructions (hand this to your agent) ---\n${instructions.renderedText}\n`);
2304
2498
  }
2305
2499
  catch (error) {
2306
- io.writeStdout(`${JSON.stringify(toOperationFailure("agent_board.create_invite_link", error), null, 2)}\n`);
2500
+ writeFailureEnvelope(io, toOperationFailure("agent_board.create_invite_link", error));
2307
2501
  process.exitCode = 1;
2308
2502
  }
2309
2503
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@consiliency/agent-board-cli",
3
- "version": "1.10.0",
3
+ "version": "1.11.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.16.0",
34
+ "@consiliency/agent-board-client": "1.19.0",
35
35
  "commander": "^14.0.1"
36
36
  },
37
37
  "license": "Apache-2.0"