firedrill-run 3.0.1 → 3.0.3

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema": "firedrill.cli-assembly.v1",
3
3
  "package": "firedrill-run",
4
- "version": "3.0.1",
4
+ "version": "3.0.3",
5
5
  "entry": "cli/src/main.js",
6
6
  "helpers": [
7
7
  "cli/runtime/source_snapshot.py",
@@ -2,13 +2,88 @@ import { constants } from 'node:fs';
2
2
  import { lstat, open } from 'node:fs/promises';
3
3
  import { resolve } from 'node:path';
4
4
  import { CliError } from './http.js';
5
+ /** Consume a pipe once without leaving an abort waiting for another byte. */
6
+ async function stdinInput(signal, maximumBytes) {
7
+ signal.throwIfAborted();
8
+ const input = process.stdin;
9
+ if (input.isTTY || input.readableEncoding !== null) {
10
+ throw new CliError('request_stdin_invalid', 'Pipe one UTF-8 JSON object into --file -. Nothing was submitted.', 2);
11
+ }
12
+ const chunks = await new Promise((accept, reject) => {
13
+ const collected = [];
14
+ let size = 0;
15
+ let settled = false;
16
+ const invalid = () => new CliError('request_stdin_invalid', 'Standard input must contain one JSON object within transport capacity. Nothing was submitted.', 2);
17
+ const finish = (error) => {
18
+ if (settled)
19
+ return;
20
+ settled = true;
21
+ input.pause();
22
+ input.removeListener('data', data);
23
+ input.removeListener('end', end);
24
+ input.removeListener('error', failed);
25
+ input.removeListener('close', closed);
26
+ signal.removeEventListener('abort', aborted);
27
+ if (error !== undefined)
28
+ reject(error);
29
+ else
30
+ accept(collected);
31
+ };
32
+ const data = (chunk) => {
33
+ if (!Buffer.isBuffer(chunk)) {
34
+ finish(invalid());
35
+ return;
36
+ }
37
+ size += chunk.length;
38
+ if (size > maximumBytes) {
39
+ finish(invalid());
40
+ return;
41
+ }
42
+ collected.push(Buffer.from(chunk));
43
+ };
44
+ const end = () => finish();
45
+ const failed = () => finish(invalid());
46
+ const closed = () => { if (!input.readableEnded)
47
+ finish(invalid()); };
48
+ const aborted = () => finish(signal.reason ?? new DOMException('The operation was aborted.', 'AbortError'));
49
+ input.on('data', data);
50
+ input.once('end', end);
51
+ input.once('error', failed);
52
+ input.once('close', closed);
53
+ signal.addEventListener('abort', aborted, { once: true });
54
+ // EOF or cancellation can precede listener installation. Never wait for an
55
+ // event that has already happened, including an idle pipe on cancellation.
56
+ if (signal.aborted)
57
+ aborted();
58
+ else if (input.readableEnded)
59
+ end();
60
+ else if (input.destroyed)
61
+ failed();
62
+ else
63
+ input.resume();
64
+ });
65
+ signal.throwIfAborted();
66
+ try {
67
+ const raw = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks));
68
+ const value = JSON.parse(raw);
69
+ if (!value || typeof value !== 'object' || Array.isArray(value))
70
+ throw new Error();
71
+ return raw;
72
+ }
73
+ catch {
74
+ throw new CliError('request_stdin_invalid', 'Standard input is not a UTF-8 JSON object. Nothing was submitted.', 2);
75
+ }
76
+ }
5
77
  /** Read one bounded, unchanged JSON object without rewriting number literals. */
6
78
  export async function requestInput(path, signal, maximumBytes = 8 * 1024 * 1024) {
79
+ signal.throwIfAborted();
7
80
  if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 2 || maximumBytes > 8 * 1024 * 1024) {
8
81
  throw new CliError('request_capacity_invalid', 'Choose a supported request capacity.', 2);
9
82
  }
10
83
  if (!path)
11
84
  return '{}';
85
+ if (path === '-')
86
+ return stdinInput(signal, maximumBytes);
12
87
  const before = await lstat(resolve(path));
13
88
  if (!before.isFile() || before.isSymbolicLink() || before.size > maximumBytes) {
14
89
  throw new CliError('request_file_invalid', 'Choose a regular JSON file within transport capacity.', 2);
@@ -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
- async function proof(value, payload) {
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 (await currentLocalRunner(scope, identifier(requested), signal)).device_key;
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((await currentLocalRunner(scope, candidate.device_key, signal)).device_key);
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
- if (setup.state === 'reviewed' && setup.approved_at === null && setup.stop_requested_at === null) {
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 (!quotes.quotes.every(live))
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 && (!quotes.quotes.some(quote => quote.credit_approval_required) || (original !== null && given === original));
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(`Start ${children.length} tool${children.length === 1 ? '' : 's'} for ${duration(setup.duration_seconds)} each?`, [
292
- { value: false, label: 'Not now', detail: 'Nothing is reserved or started.' },
293
- { value: true, label: 'Start tools', detail: 'Reserves up to the estimate above. The tools run in the cloud, not on this machine.' },
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', `${notice}Nothing was reserved or started. Show the human this estimate; after they approve it: `
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
- const creditApproval = await approveCredits(quotes.quotes, options, interactive, `${recovery} --usage-quote ${quoteKey} --approve-tool-change`);
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "firedrill-run",
3
- "version": "3.0.1",
3
+ "version": "3.0.3",
4
4
  "description": "Local AI agent simulations with cloud-hosted worlds and tools",
5
5
  "type": "module",
6
6
  "bin": {