firedrill-run 3.0.1 → 3.0.2
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/assembly.json
CHANGED
|
@@ -101,7 +101,11 @@ async function lock(scope, key, signal, work) {
|
|
|
101
101
|
const directory = join(await privateDirectory(), `runner-lock-${identifier(scope.owner)}-${identifier(scope.workspace)}-${identifier(key)}`);
|
|
102
102
|
return withBootstrapProcessLock(directory, signal, work);
|
|
103
103
|
}
|
|
104
|
-
|
|
104
|
+
/** Read only the original signing material and verify its retained public identity.
|
|
105
|
+
* A metadata journal can outlive a session-only process's in-memory key.
|
|
106
|
+
* Reading that journal alone therefore does not make a runner usable.
|
|
107
|
+
*/
|
|
108
|
+
async function privateSigningKey(value) {
|
|
105
109
|
try {
|
|
106
110
|
const encoded = MEMORY_KEYS.get(value.key) ?? (await store(value, value.key)).getPassword();
|
|
107
111
|
if (!encoded || encoded.length > 2048)
|
|
@@ -112,6 +116,15 @@ async function proof(value, payload) {
|
|
|
112
116
|
const publicKey = createPublicKey(key).export({ format: 'jwk' });
|
|
113
117
|
if (!publicKey.x || Buffer.from(publicKey.x, 'base64url').toString('base64') !== value.enrollment.public_key)
|
|
114
118
|
throw new Error();
|
|
119
|
+
return key;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
throw new CliError('runner_key_unavailable', 'This runner key could not be read from the OS credential store. It was not regenerated.');
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
async function proof(value, payload) {
|
|
126
|
+
try {
|
|
127
|
+
const key = await privateSigningKey(value);
|
|
115
128
|
return { payload, signature: sign(null, Buffer.from('firedrill.local-runner-proof.v1\n' + canonical(payload)), key).toString('base64') };
|
|
116
129
|
}
|
|
117
130
|
catch {
|
|
@@ -513,17 +526,29 @@ export async function withNativeExecutor(scope, key, signal, work) {
|
|
|
513
526
|
* identity on this machine. Enrollment stays an explicit, consented step.
|
|
514
527
|
*/
|
|
515
528
|
export async function nativeExecutorKey(scope, requested, signal) {
|
|
529
|
+
const usableKey = async (key) => {
|
|
530
|
+
const retained = await currentLocalRunner(scope, key, signal);
|
|
531
|
+
// Recheck the local journal against the exact validated server receipt.
|
|
532
|
+
// No key is generated, persisted, signed or sent while selecting a device.
|
|
533
|
+
const value = await saved(scope, retained.device_key);
|
|
534
|
+
matches(value, retained);
|
|
535
|
+
signal.throwIfAborted();
|
|
536
|
+
await privateSigningKey(value);
|
|
537
|
+
signal.throwIfAborted();
|
|
538
|
+
return retained.device_key;
|
|
539
|
+
};
|
|
540
|
+
// Explicit requests never fall back to a different runner or enroll a key.
|
|
516
541
|
if (requested !== undefined)
|
|
517
|
-
return (
|
|
542
|
+
return usableKey(identifier(requested));
|
|
518
543
|
const usable = [];
|
|
519
544
|
for (let page = 1; page <= 5; page++) {
|
|
520
545
|
const { directory, candidates } = await localRunnerSelectionPage(scope, page, signal);
|
|
521
546
|
for (const candidate of candidates) {
|
|
522
547
|
try {
|
|
523
|
-
usable.push(
|
|
548
|
+
usable.push(await usableKey(candidate.device_key));
|
|
524
549
|
}
|
|
525
550
|
catch (error) {
|
|
526
|
-
if (!(error instanceof CliError) || !['runner_identity_not_local', 'runner_scope_changed', 'runner_access_removed'].includes(error.code))
|
|
551
|
+
if (!(error instanceof CliError) || !['runner_identity_not_local', 'runner_scope_changed', 'runner_access_removed', 'runner_key_unavailable'].includes(error.code))
|
|
527
552
|
throw error;
|
|
528
553
|
}
|
|
529
554
|
}
|
|
@@ -265,11 +265,17 @@ async function start(args, options, emit) {
|
|
|
265
265
|
}
|
|
266
266
|
const reviewed = setup;
|
|
267
267
|
let children = items ?? await setupItems(scope, { setup: reviewed, signal });
|
|
268
|
-
|
|
268
|
+
const recoveringAdmission = options.request !== undefined && setup.state === 'needs_attention'
|
|
269
|
+
&& setup.approved_at !== null && setup.stop_requested_at === null;
|
|
270
|
+
if ((setup.state === 'reviewed' && setup.approved_at === null && setup.stop_requested_at === null) || recoveringAdmission) {
|
|
269
271
|
// Quoting reserves nothing. Root keeps one estimate per session and replays it for its quote key only,
|
|
270
272
|
// so a later run admits exactly the estimate the human saw. An approval holds only for that key.
|
|
271
273
|
const original = setup.quote_request_key;
|
|
272
274
|
const given = options.quote?.toLowerCase();
|
|
275
|
+
if (recoveringAdmission && (original === null || setup.bundle_digest === null))
|
|
276
|
+
throw new CliError('tool_start_unconfirmed', 'The original approved estimate could not be confirmed. Inspect this same tool session; do not create a replacement request.', 2);
|
|
277
|
+
if (recoveringAdmission && (ids.length || options.file !== undefined || options.seconds !== undefined || options.service !== undefined))
|
|
278
|
+
throw new CliError('arguments_invalid', `Continue this original tool session without changing its selection or duration: ${recovery} --usage-quote ${original} --approve-tool-change.`, 2);
|
|
273
279
|
if (given !== undefined && original !== null && given !== original)
|
|
274
280
|
throw new CliError('idempotency_conflict', 'That quote key is not this setup’s original estimate. Read the original setup before continuing. Nothing was admitted.', 2);
|
|
275
281
|
const quoteKey = original ?? given ?? randomUUID();
|
|
@@ -280,24 +286,37 @@ async function start(args, options, emit) {
|
|
|
280
286
|
notice = `This session's estimate was quoted under ${original}, not the key given, so it needs its own approval. `;
|
|
281
287
|
const quoted = children;
|
|
282
288
|
const quotes = await retryTransient(() => quoteSetup(scope, { setup: reviewed, items: quoted, requestKey: quoteKey, signal }), { signal, attempts: 3 });
|
|
283
|
-
if (
|
|
289
|
+
if (recoveringAdmission && (quotes.quote_key !== original || quotes.bundle_digest !== setup.bundle_digest))
|
|
290
|
+
throw new CliError('idempotency_conflict', 'The retained approved estimate changed. Nothing was retried. Inspect the original tool session.', 2);
|
|
291
|
+
// The server replays an existing approval without replacing its quote.
|
|
292
|
+
// Only a never-approved review must still meet the initial quote deadline.
|
|
293
|
+
if (!recoveringAdmission && !quotes.quotes.every(live))
|
|
284
294
|
throw expiredReview(setup.id);
|
|
285
|
-
let approved = options.approve === true && (
|
|
295
|
+
let approved = options.approve === true && (recoveringAdmission ? given === original
|
|
296
|
+
: !quotes.quotes.some(quote => quote.credit_approval_required) || (original !== null && given === original));
|
|
286
297
|
const interactive = tty(options);
|
|
287
298
|
if (!approved && interactive)
|
|
288
299
|
process.stderr.write(setupText(setup, children) + '\n');
|
|
289
|
-
emit('tool_start_quote', quotes, `${notice}${quoteText(quotes, children)}`);
|
|
300
|
+
emit('tool_start_quote', quotes, `${recoveringAdmission ? 'Continuing the original approved tool session with its retained estimate. Existing tools are not replaced.\n' : notice}${quoteText(quotes, children)}`);
|
|
290
301
|
if (!approved && interactive) {
|
|
291
|
-
approved = await selectOne(
|
|
292
|
-
{
|
|
293
|
-
{ value:
|
|
302
|
+
approved = await selectOne(recoveringAdmission ? 'Continue this original approved tool session?'
|
|
303
|
+
: `Start ${children.length} tool${children.length === 1 ? '' : 's'} for ${duration(setup.duration_seconds)} each?`, [
|
|
304
|
+
{ value: false, label: 'Not now', detail: recoveringAdmission ? 'The original session and existing allocations stay unchanged.' : 'Nothing is reserved or started.' },
|
|
305
|
+
{ value: true, label: recoveringAdmission ? 'Continue original session' : 'Start tools',
|
|
306
|
+
detail: recoveringAdmission ? 'Uses the same approved selection and estimate. Existing tool allocations are retained.'
|
|
307
|
+
: 'Reserves up to the estimate above. The tools run in the cloud, not on this machine.' },
|
|
294
308
|
], signal);
|
|
295
309
|
}
|
|
296
310
|
if (!approved) {
|
|
297
|
-
throw new CliError('tool_change_consent_required',
|
|
311
|
+
throw new CliError('tool_change_consent_required', (recoveringAdmission
|
|
312
|
+
? 'The original approval and any existing allocations were left unchanged. After the human approves continuing this same session: '
|
|
313
|
+
: `${notice}Nothing was reserved or started. Show the human this estimate; after they approve it: `)
|
|
298
314
|
+ `${recovery} --usage-quote ${quoteKey} --approve-tool-change`, 2);
|
|
299
315
|
}
|
|
300
|
-
|
|
316
|
+
// Recovery does not invent another credit approval. The backend verifies
|
|
317
|
+
// and uses its durable original approval_digest and per-child request keys.
|
|
318
|
+
const creditApproval = recoveringAdmission ? false
|
|
319
|
+
: await approveCredits(quotes.quotes, options, interactive, `${recovery} --usage-quote ${quoteKey} --approve-tool-change`);
|
|
301
320
|
try {
|
|
302
321
|
setup = await retryTransient(() => admitSetup(scope, { quotes, signal, creditApproval }), { signal, attempts: 3 });
|
|
303
322
|
}
|
|
@@ -340,6 +359,20 @@ async function start(args, options, emit) {
|
|
|
340
359
|
}
|
|
341
360
|
emit('tool_session', { ...setup, items: children }, setupText(setup, children)
|
|
342
361
|
+ (settled(setup, children) ? '' : `\nFollow it: firedrill tools session ${setup.id}`));
|
|
362
|
+
if (options.follow) {
|
|
363
|
+
// A completed follow is an observation, not proof that admission or every
|
|
364
|
+
// child succeeded. Preserve its full final receipt before the typed error.
|
|
365
|
+
// Normal stopped/expired history and user cancellation are not failures.
|
|
366
|
+
const failed = children.filter(item => item.instance_state === 'failed' || item.instance_state === 'rejected'
|
|
367
|
+
|| item.failure_code !== null && item.admission_state !== 'cancelled'
|
|
368
|
+
&& !['stopped', 'canceled', 'cancelled', 'expired'].includes(item.instance_state ?? ''));
|
|
369
|
+
if (failed.length)
|
|
370
|
+
throw new CliError('tool_start_failed', `${failed.length} tool${failed.length === 1 ? '' : 's'} failed in this original session. Inspect its retained result with firedrill tools session ${setup.id}. Nothing was restarted.`, 1);
|
|
371
|
+
if (setup.stop_requested_at === null && (setup.state === 'needs_attention' || children.some(item => item.admission_state === 'needs_attention')))
|
|
372
|
+
throw new CliError('tool_start_needs_attention', `This original tool session needs attention. Inspect it with firedrill tools session ${setup.id} before explicitly continuing its original request. Nothing was retried.`, 1);
|
|
373
|
+
if (!settled(setup, children))
|
|
374
|
+
throw new CliError('tool_start_pending', `This original tool session is still preparing after the follow window. Read its progress with firedrill tools session ${setup.id}. This is not a startup failure, and nothing was restarted.`, 1);
|
|
375
|
+
}
|
|
343
376
|
}
|
|
344
377
|
/** `tools runnable [--search <text>] [--page <n>]`: saved versions and the lane each service would start in. */
|
|
345
378
|
async function runnable(options, emit) {
|