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