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