@consiliency/agent-board-cli 1.10.0 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.d.ts +80 -0
- package/dist/cli.js +243 -49
- 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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1787
|
-
harnessKind: options.harnessKind
|
|
1788
|
-
repoSlug: options.repoSlug
|
|
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:
|
|
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
|
-
|
|
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
|
|
1821
|
-
boardId: options.boardId
|
|
1822
|
-
machineId: options.machineId
|
|
1823
|
-
harnessKind: options.harnessKind
|
|
1824
|
-
harnessLabel: options.harnessLabel
|
|
1825
|
-
repoSlug: options.repoSlug
|
|
1826
|
-
repoFingerprint: options.repoFingerprint
|
|
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
|
|
1830
|
-
localKeyId: options.localKeyId
|
|
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
|
-
|
|
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
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
3
|
+
"version": "1.12.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.
|
|
34
|
+
"@consiliency/agent-board-client": "1.23.0",
|
|
35
35
|
"commander": "^14.0.1"
|
|
36
36
|
},
|
|
37
37
|
"license": "Apache-2.0"
|