toolroll 0.9.62 → 0.9.63

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.
Files changed (100) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +51 -3
  3. package/dist/agent-fence.d.ts +155 -10
  4. package/dist/agent-fence.js +473 -38
  5. package/dist/browser/workspace.css +1 -1
  6. package/dist/browser/workspace.js +21 -21
  7. package/dist/browser-workspace.d.ts +28 -0
  8. package/dist/chat-actions.js +2 -2
  9. package/dist/check-levels.js +3 -0
  10. package/dist/claim.js +1 -1
  11. package/dist/cli-help.d.ts +1 -1
  12. package/dist/cli-help.js +1 -0
  13. package/dist/cli.js +3 -0
  14. package/dist/contracts/cli.d.ts +8 -0
  15. package/dist/contracts/cli.js +3 -1
  16. package/dist/contracts/console-api.d.ts +19 -1
  17. package/dist/contracts/console-api.js +2 -1
  18. package/dist/contracts/integration-metadata.d.ts +2 -1
  19. package/dist/contracts/integration-metadata.js +2 -1
  20. package/dist/desktop-update.d.ts +1 -1
  21. package/dist/dispatch.js +7 -4
  22. package/dist/doctor.d.ts +53 -0
  23. package/dist/doctor.js +427 -0
  24. package/dist/evidence-pack.d.ts +1 -1
  25. package/dist/evidence-pack.js +2 -2
  26. package/dist/exec.d.ts +3 -0
  27. package/dist/exec.js +5 -4
  28. package/dist/flow-code.d.ts +2 -2
  29. package/dist/flow-code.js +4 -15
  30. package/dist/flow-engine.js +11 -6
  31. package/dist/flow-triggers.d.ts +8 -4
  32. package/dist/flow-triggers.js +45 -25
  33. package/dist/flow-unattended-words.d.ts +28 -0
  34. package/dist/flow-unattended-words.js +50 -0
  35. package/dist/flow-unattended.d.ts +227 -0
  36. package/dist/flow-unattended.js +776 -0
  37. package/dist/flows-cli.d.ts +28 -1
  38. package/dist/flows-cli.js +55 -10
  39. package/dist/flows-ui.d.ts +2 -1
  40. package/dist/flows-ui.js +4 -1
  41. package/dist/google-mail.d.ts +1 -0
  42. package/dist/google-mail.js +61 -18
  43. package/dist/integrations-ui.d.ts +2 -1
  44. package/dist/integrations-ui.js +6 -3
  45. package/dist/integrations.d.ts +17 -3
  46. package/dist/integrations.js +38 -18
  47. package/dist/invoke.js +300 -168
  48. package/dist/landed-cleanup.d.ts +3 -2
  49. package/dist/landed-cleanup.js +12 -5
  50. package/dist/lead-tools.js +11 -3
  51. package/dist/mcp-connect.d.ts +8 -7
  52. package/dist/mcp-connect.js +49 -51
  53. package/dist/oauth-run-token.d.ts +51 -0
  54. package/dist/oauth-run-token.js +127 -0
  55. package/dist/operate.js +7 -1
  56. package/dist/plan-auto.d.ts +11 -0
  57. package/dist/plan-auto.js +62 -11
  58. package/dist/process-recovery-eligibility.d.ts +1 -1
  59. package/dist/project-access.d.ts +3 -0
  60. package/dist/project-access.js +3 -0
  61. package/dist/project-delete.d.ts +3 -1
  62. package/dist/project-delete.js +10 -4
  63. package/dist/project-file.d.ts +3 -0
  64. package/dist/project-file.js +16 -0
  65. package/dist/project-tools.d.ts +99 -25
  66. package/dist/project-tools.js +394 -98
  67. package/dist/provider-home.d.ts +22 -0
  68. package/dist/provider-home.js +99 -0
  69. package/dist/provider.d.ts +17 -3
  70. package/dist/provider.js +34 -10
  71. package/dist/remote-main.d.ts +4 -0
  72. package/dist/remote-main.js +3 -3
  73. package/dist/scope.d.ts +1 -1
  74. package/dist/serve.js +11 -3
  75. package/dist/server/flows.js +27 -5
  76. package/dist/server/route-table.js +3 -0
  77. package/dist/server/runtime.d.ts +1 -1
  78. package/dist/server/settings.js +11 -3
  79. package/dist/storage-sweep-child.js +3 -1
  80. package/dist/storage-sweep-process.d.ts +12 -5
  81. package/dist/storage-sweep-process.js +23 -6
  82. package/dist/store.d.ts +38 -6
  83. package/dist/store.js +148 -15
  84. package/dist/subagent-tools.d.ts +1 -1
  85. package/dist/surface.js +6 -4
  86. package/dist/teardown-git.d.ts +14 -0
  87. package/dist/teardown-git.js +119 -0
  88. package/dist/tool-broker.d.ts +37 -0
  89. package/dist/tool-broker.js +369 -0
  90. package/dist/tool-launcher.js +5 -4
  91. package/dist/tool-secret-store.d.ts +170 -0
  92. package/dist/tool-secret-store.js +834 -0
  93. package/dist/toolroll-update.js +3 -2
  94. package/dist/tools-ui.d.ts +7 -1
  95. package/dist/tools-ui.js +17 -8
  96. package/dist/work-index.js +5 -3
  97. package/dist/worktree.d.ts +10 -4
  98. package/dist/worktree.js +70 -29
  99. package/package.json +3 -1
  100. package/scripts/provider-canary.mjs +210 -31
@@ -269,6 +269,33 @@ export declare const FLOWS_DESCRIPTORS: readonly [{
269
269
  readonly takesValue: true;
270
270
  readonly meaning: "the script's name";
271
271
  }];
272
+ }, {
273
+ readonly action: 'unattended';
274
+ readonly synopsis: "let a flow's own schedule run without approving each card (on: the owner signs, 1 to 7 days; off: any approver revokes)";
275
+ readonly mutation: 'identity-idempotent';
276
+ readonly audience: 'operator';
277
+ readonly positionals: readonly ['flow', 'on|off'];
278
+ readonly flags: readonly [...{
279
+ name: string;
280
+ takesValue: boolean;
281
+ meaning: string;
282
+ }[], {
283
+ readonly name: 'days';
284
+ readonly takesValue: true;
285
+ readonly meaning: "how long it lasts: 1 to 7 days (default 1)";
286
+ }, {
287
+ readonly name: 'budget';
288
+ readonly takesValue: true;
289
+ readonly meaning: 'optional dollar cap per attempt, like 5 or 2.50';
290
+ }, {
291
+ readonly name: 'runs';
292
+ readonly takesValue: true;
293
+ readonly meaning: "optional limit on runs a day (UTC), 1 to 50";
294
+ }, {
295
+ readonly name: 'include';
296
+ readonly takesValue: true;
297
+ readonly meaning: 'also cover these script or plane-review schedules (trigger ids, like 12,14); plain schedules are covered already';
298
+ }];
272
299
  }, {
273
300
  readonly action: 'archive';
274
301
  readonly synopsis: 'archive a flow: its cards stop moving and its triggers stop; previews until --yes';
@@ -284,7 +311,7 @@ export declare const FLOWS_DESCRIPTORS: readonly [{
284
311
  meaning: string;
285
312
  }];
286
313
  }];
287
- /** `--file` is a value here (a path in the project), where elsewhere it is a switch. */
314
+ /** `--file` is a value here (a path in the project), where elsewhere it is a switch; so are unattended's `--budget`, `--runs` and `--include`. */
288
315
  export declare const FLOWS_VALUE_FLAGS: ReadonlySet<string>;
289
316
  export type FlowsCliContext = {
290
317
  store: Store;
package/dist/flows-cli.js CHANGED
@@ -18,6 +18,8 @@ import { describeStandingOrder } from './flow-schedule.js';
18
18
  import { STARTER_FLOWS, starterFlowOf, starterOf, startersFor, starterTerms, switchOnStarter } from './flow-starters.js';
19
19
  import { addFlowTriggerTo, checkFlowTriggerNow, describeTrigger, removeFlowTrigger, setFlowTriggerOn, triggerConfigOf, validateTriggerConfig } from './flow-triggers.js';
20
20
  import { deciderOf, FLOW_KIND_WORDS, FLOW_TEMPLATES, flowDigest, flowFromSteps, flowTerms, stepsFor } from './flows.js';
21
+ import { prepareUnattendedCandidates, activeUnattendedGrant, parseUnattendedAsk, revokeUnattended, signUnattended, unattendedViewOf, unattendedWords, untilWords, UNATTENDED_MAX_RUNS } from './flow-unattended.js';
22
+ import { MODE_MAX_DAYS } from './modes.js';
21
23
  import { verifyApproverByPassword } from './principal.js';
22
24
  import { projectName } from './project.js';
23
25
  const EXIT = { ok: 0, failed: 1, usage: 2, refused: 3 };
@@ -62,10 +64,15 @@ export const FLOWS_DESCRIPTORS = [
62
64
  { action: 'import', synopsis: 'make a flow from a flow file or a gist/GitHub https address; previews in plain words until --yes; triggers arrive off and scripts wait for approval', mutation: 'unkeyed', positionals: ['file'], flags: [...write, yes, repo,
63
65
  { name: 'param', takesValue: true, meaning: 'a value the file asks for, as name=value (repeat for more)' }] },
64
66
  { action: 'script approve', synopsis: 'approve a script an imported flow brought, so it runs', mutation: 'identity-idempotent', flags: [...write, repo, { name: 'name', takesValue: true, meaning: "the script's name" }] },
67
+ { action: 'unattended', synopsis: `let a flow's own schedule run without approving each card (on: the owner signs, 1 to ${MODE_MAX_DAYS} days; off: any approver revokes)`, mutation: 'identity-idempotent', audience: 'operator', positionals: ['flow', 'on|off'], flags: [...write,
68
+ { name: 'days', takesValue: true, meaning: `how long it lasts: 1 to ${MODE_MAX_DAYS} days (default 1)` },
69
+ { name: 'budget', takesValue: true, meaning: 'optional dollar cap per attempt, like 5 or 2.50' },
70
+ { name: 'runs', takesValue: true, meaning: `optional limit on runs a day (UTC), 1 to ${UNATTENDED_MAX_RUNS}` },
71
+ { name: 'include', takesValue: true, meaning: 'also cover these script or plane-review schedules (trigger ids, like 12,14); plain schedules are covered already' }] },
65
72
  { action: 'archive', synopsis: 'archive a flow: its cards stop moving and its triggers stop; previews until --yes', mutation: 'identity-idempotent', positionals: ['flow'], flags: [...write, yes] },
66
73
  ];
67
- /** `--file` is a value here (a path in the project), where elsewhere it is a switch. */
68
- export const FLOWS_VALUE_FLAGS = new Set(['file', 'param']);
74
+ /** `--file` is a value here (a path in the project), where elsewhere it is a switch; so are unattended's `--budget`, `--runs` and `--include`. */
75
+ export const FLOWS_VALUE_FLAGS = new Set(['file', 'param', 'budget', 'runs', 'include']);
69
76
  export async function runFlowsCommand(positional, flags, context) {
70
77
  const [first, second] = positional;
71
78
  const action = first === 'trigger' || first === 'script' || first === 'card' ? `${first} ${second ?? ''}`.trim() : first ?? 'list';
@@ -104,7 +111,7 @@ export async function runFlowsCommand(positional, flags, context) {
104
111
  const flow = flowOf(args[0], context.projects);
105
112
  if (flow === null)
106
113
  return fail('unknown-flow', 'No such flow in your projects. toolroll flows list names them.');
107
- const shown = describeFlow(store, flow);
114
+ const shown = describeFlow(store, flow, context.clock());
108
115
  return ok({ flow: shown }, showLines(shown));
109
116
  }
110
117
  if (action === 'export') {
@@ -197,7 +204,7 @@ export async function runFlowsCommand(positional, flags, context) {
197
204
  if (!switched.ok)
198
205
  return refuse(project, 'refused', switched.said);
199
206
  record(project, 'accepted', `flow #${switched.flow}`);
200
- return ok({ applied: true, flow: describeFlow(store, store.getFlow(switched.flow)) }, [`${switched.said} Flow #${switched.flow}.`]);
207
+ return ok({ applied: true, flow: describeFlow(store, store.getFlow(switched.flow), context.clock()) }, [`${switched.said} Flow #${switched.flow}.`]);
201
208
  }
202
209
  const template = templateId === null ? null : FLOW_TEMPLATES.find(one => one.id === templateId) ?? null;
203
210
  if (templateId !== null && template === null)
@@ -250,7 +257,7 @@ export async function runFlowsCommand(positional, flags, context) {
250
257
  return refuse(project, 'invalid-trigger', error instanceof Error ? error.message : 'Its trigger isn\'t valid.');
251
258
  }
252
259
  record(project, 'accepted', `flow #${id}`);
253
- return ok({ applied: true, flow: describeFlow(store, store.getFlow(id)) }, [`Created ${flowName}, flow #${id}. Add cards with toolroll flows card add ${id} --title "…".`]);
260
+ return ok({ applied: true, flow: describeFlow(store, store.getFlow(id), context.clock()) }, [`Created ${flowName}, flow #${id}. Add cards with toolroll flows card add ${id} --title "…".`]);
254
261
  }
255
262
  if (action === 'script save') {
256
263
  const given = text('repo');
@@ -328,7 +335,7 @@ export async function runFlowsCommand(positional, flags, context) {
328
335
  return refuse(project, 'invalid-file', error instanceof Error ? error.message : 'That flow couldn\'t be imported.');
329
336
  }
330
337
  record(project, 'accepted', `flow #${made.id}`);
331
- return ok({ applied: true, flow: describeFlow(store, store.getFlow(made.id)), said: made.said }, [`${made.said} Flow #${made.id}.`]);
338
+ return ok({ applied: true, flow: describeFlow(store, store.getFlow(made.id), context.clock()), said: made.said }, [`${made.said} Flow #${made.id}.`]);
332
339
  }
333
340
  if (action === 'script approve') {
334
341
  const given = text('repo'), name = text('name');
@@ -352,6 +359,33 @@ export async function runFlowsCommand(positional, flags, context) {
352
359
  if (flow === null)
353
360
  return refuse(null, 'unknown-flow', 'No such flow in your projects. toolroll flows list names them.');
354
361
  const definition = flowDefinitionOf(flow);
362
+ if (action === 'unattended') {
363
+ const flow = flowOf(args[0], reachable);
364
+ if (flow === null)
365
+ return refuse(null, 'unknown-flow', 'No such flow in your projects. toolroll flows list names them.');
366
+ const turn = args[1];
367
+ if (turn !== 'on' && turn !== 'off')
368
+ return fail('usage', `Use flows unattended ${flow.id} on --days N, or flows unattended ${flow.id} off.`, EXIT.usage);
369
+ if (turn === 'off') {
370
+ // Lowering authority is one step for any approver on the project.
371
+ if (!revokeUnattended(store, flow.id, who, now))
372
+ return refuse(flow.repo, 'not-on', `${flow.name} isn't running on its own. Nothing was changed.`);
373
+ record(flow.repo, 'accepted', `flow #${flow.id} off`);
374
+ return ok({ applied: true, unattended: null }, [`${flow.name} no longer runs on its own. Its next cards wait for you; running work is untouched.`]);
375
+ }
376
+ const asked = parseUnattendedAsk({ days: text('days') ?? '1', budget: text('budget') ?? '', runs: text('runs') ?? '', include: text('include') ?? '' });
377
+ if (!asked.ok)
378
+ return fail('usage', asked.message, EXIT.usage);
379
+ const signed = await signUnattended(store, { flow: flow.id, signer: who, ask: asked.ask }, now);
380
+ if (!signed.ok)
381
+ return refuse(flow.repo, 'refused', `${signed.message} Nothing was changed.`);
382
+ record(flow.repo, 'accepted', `flow #${flow.id} on until ${signed.grant.absoluteExpiry}`);
383
+ return ok({ applied: true, unattended: unattendedJson(store, flow, now) }, [
384
+ `${flow.name} runs on its own until ${untilWords(signed.grant.absoluteExpiry)}. Signed by ${who}:`,
385
+ ...unattendedWords(signed.grant.terms).map(line => ` ${line}`),
386
+ `Revoke: toolroll flows unattended ${flow.id} off`,
387
+ ]);
388
+ }
355
389
  if (action === 'archive') {
356
390
  const cards = store.flowCards(flow.id, false).length, triggers = store.flowTriggers(flow.id).filter(one => one.state === 'active').length;
357
391
  if (!flags.has('yes'))
@@ -395,7 +429,7 @@ export async function runFlowsCommand(positional, flags, context) {
395
429
  return refuse(flow.repo, 'stale', 'Someone else changed this flow. Look again, then make yours.');
396
430
  record(flow.repo, 'accepted', `flow #${flow.id}`);
397
431
  settle(flow.repo);
398
- return ok({ applied: true, flow: describeFlow(store, store.getFlow(flow.id)) }, [`Saved ${name}.`]);
432
+ return ok({ applied: true, flow: describeFlow(store, store.getFlow(flow.id), context.clock()) }, [`Saved ${name}.`]);
399
433
  }
400
434
  if (action === 'card add') {
401
435
  const title = text('title');
@@ -471,6 +505,7 @@ export async function runFlowsCommand(positional, flags, context) {
471
505
  const made = addFlowTriggerTo(store, flow, input.value, who, now, context.configDir);
472
506
  if (!made.ok)
473
507
  return refuse(flow.repo, 'invalid-trigger', made.message);
508
+ await prepareUnattendedCandidates(store, flow.id);
474
509
  record(flow.repo, 'accepted', `flow #${flow.id} trigger #${made.id}`);
475
510
  settle(flow.repo);
476
511
  // A webhook's address (and GitHub's signing secret) is shown this once, never stored readable.
@@ -490,9 +525,9 @@ export async function runFlowsCommand(positional, flags, context) {
490
525
  return checked.ok ? ok({ applied: true, triggerId: trigger.id, said: checked.said }, [checked.said]) : fail('check-failed', checked.said);
491
526
  }
492
527
  if (action === 'trigger remove')
493
- removeFlowTrigger(store, trigger, now, context.configDir);
528
+ removeFlowTrigger(store, trigger, now, context.configDir, who);
494
529
  else
495
- setFlowTriggerOn(store, trigger, action !== 'trigger pause', now);
530
+ setFlowTriggerOn(store, trigger, action !== 'trigger pause', now, who);
496
531
  record(flow.repo, 'accepted', `flow #${flow.id} trigger #${trigger.id}`);
497
532
  settle(flow.repo);
498
533
  const said = action === 'trigger remove' ? 'Trigger removed.' : action === 'trigger pause' ? 'Trigger paused.' : 'Trigger on again.';
@@ -524,7 +559,7 @@ function zoneOf(stage, flow) {
524
559
  function cardOf(definition, card) {
525
560
  return { id: card.id, title: card.title, zone: card.stage, zoneTitle: definition?.stages.find(one => one.id === card.stage)?.title ?? card.stage, state: card.state, waiting: card.waiting, task: card.task ?? card.primaryTask, updatedAt: card.updatedAt };
526
561
  }
527
- function describeFlow(store, flow) {
562
+ function describeFlow(store, flow, now) {
528
563
  const definition = flowDefinitionOf(flow);
529
564
  return {
530
565
  id: flow.id, name: flow.name, repo: flow.repo, project: projectName(flow.repo), owner: flow.owner, revision: flow.revision, readable: definition !== null,
@@ -537,14 +572,24 @@ function describeFlow(store, flow) {
537
572
  ...(config?.kind === 'schedule' && config.order !== undefined ? { order: describeStandingOrder(config.order, flow.repo, config.schedule) } : {}) };
538
573
  }),
539
574
  cards: store.flowCards(flow.id, true).slice(0, 10).map(card => cardOf(definition, card)),
575
+ unattended: unattendedJson(store, flow, now),
540
576
  };
541
577
  }
578
+ /** Whether a flow runs on its own, and until when: null when it doesn't. */
579
+ function unattendedJson(store, flow, now) {
580
+ const live = activeUnattendedGrant(store, flow.id, now) === null ? null : unattendedViewOf(store, flow, { name: flow.owner, approver: true }, now).active;
581
+ return live === null ? null : { until: live.until, signedBy: live.signedBy, budgetMicrousd: live.budgetMicrousd, dailyRunCap: live.dailyRunCap, runsToday: live.runsToday, covers: live.covers };
582
+ }
542
583
  function showLines(flow) {
543
584
  const title = (id) => id === null ? null : flow.zones.find(one => one.id === id)?.title ?? id;
544
585
  if (!flow.readable)
545
586
  return [`#${flow.id} ${flow.name} · ${flow.project}`, 'This flow\'s drawing can\'t be read. Save it again from the editor.'];
546
587
  return [
547
588
  `#${flow.id} ${flow.name} · ${flow.project} · owner ${flow.owner}`,
589
+ flow.unattended === null
590
+ ? `Runs on its own: no · turn on: toolroll flows unattended ${flow.id} on --days N`
591
+ : `Runs on its own until ${untilWords(flow.unattended.until)} · revoke: toolroll flows unattended ${flow.id} off`,
592
+ ...(flow.unattended === null ? [] : [` Covers: ${flow.unattended.covers.join('; ')}`]),
548
593
  '', 'Zones',
549
594
  ...flow.zones.flatMap((zone, index) => [
550
595
  ` ${index + 1}. ${zone.title} (${zone.id}) — ${zone.does}${zone.id === flow.start ? ' · start' : ''}`,
@@ -29,7 +29,8 @@ export declare function flowView(store: Store, flow: FlowRow, viewer: {
29
29
  repos: readonly string[];
30
30
  startTrigger?: number | null;
31
31
  sortReady?: boolean;
32
- toolHome?: string;
32
+ toolHome?: string | undefined;
33
+ now?: Date;
33
34
  }): BrowserFlowView;
34
35
  /** The page the canvas replaces when scripts can't run: each zone and its cards, in order. */
35
36
  export declare function flowFallbackHtml(view: BrowserFlowView): Html;
package/dist/flows-ui.js CHANGED
@@ -16,6 +16,7 @@ import { labelOf, nameOf } from "./subagent-admin.js";
16
16
  import { callWords, receiptWords } from "./subagent-tools.js";
17
17
  import { undoFor } from "./subagent-week.js";
18
18
  import { flowCardTaskLine } from "./flow-card-task.js";
19
+ import { unattendedViewOf } from "./flow-unattended.js";
19
20
  import { readFlowSecrets } from "./flow-secrets.js";
20
21
  import { html, joinHtml, postForm } from "./html.js";
21
22
  const projectName = (repo) => repo.split(/[\\/]/).filter(Boolean).pop() ?? repo;
@@ -211,6 +212,7 @@ export function flowView(store, flow, viewer, selectedCard, setup = { dir: null,
211
212
  selectedCard,
212
213
  live: flowFingerprint(store, flow.id),
213
214
  canEdit: viewer.approver,
215
+ unattended: unattendedViewOf(store, flow, viewer, setup.now ?? now),
214
216
  approvers: store.listApprovers().map(one => one.name).filter(name => store.accountCanAccess(name, flow.repo)),
215
217
  projects: [flow.repo, ...setup.repos.filter(one => one !== flow.repo)].map(path => ({ path, name: projectName(path) })),
216
218
  kinds: FLOW_STAGE_KINDS.map(kind => ({ kind, label: FLOW_KIND_WORDS[kind].label, about: FLOW_KIND_WORDS[kind].about })),
@@ -219,7 +221,8 @@ export function flowView(store, flow, viewer, selectedCard, setup = { dir: null,
219
221
  }
220
222
  /** The page the canvas replaces when scripts can't run: each zone and its cards, in order. */
221
223
  export function flowFallbackHtml(view) {
222
- return html `<section class="flows flow-fallback"><p class="meta">${view.flow.project}</p><ol>${view.stages.map(stage => {
224
+ const own = view.unattended.active;
225
+ return html `<section class="flows flow-fallback"><p class="meta">${view.flow.project}</p>${own !== null && html `<p data-flow-unattended>Runs on its own until ${own.untilWords}</p>`}<ol>${view.stages.map(stage => {
223
226
  const cards = view.cards.filter(card => card.stage === stage.id && card.state === "active");
224
227
  return html `<li><strong>${stage.title}</strong> · ${FLOW_KIND_WORDS[stage.kind].label}${cards.length > 0 && html `<ul>${cards.map(card => html `<li>${card.title}${card.waiting !== null && ` — ${card.waiting}`}</li>`)}</ul>`}</li>`;
225
228
  })}</ol></section>`;
@@ -9,6 +9,7 @@ export type GoogleMail = {
9
9
  refreshToken: string | null;
10
10
  address: string | null;
11
11
  };
12
+ /** The saved client and connection; null when there's none. A keychain that refuses throws (SecretStoreError). */
12
13
  export declare function readGoogleMail(dir: string | null): GoogleMail | null;
13
14
  /** Connected: a refresh token and the address it belongs to. */
14
15
  export declare function googleConnected(dir: string | null): {
@@ -7,27 +7,31 @@
7
7
  * and pastes its ID and secret into Settings → Email. Connecting sends them
8
8
  * to Google's consent screen; Google sends them back here with a one-time
9
9
  * code (checked against a state and a PKCE verifier made for that visit),
10
- * which becomes a refresh token. The client secret and refresh token live
11
- * in a 0600 file beside the database, never in a column, a log or a page.
10
+ * which becomes a refresh token. The client secret and refresh token are
11
+ * kept by Toolroll (tool-secret-store.ts: the keychain, or an owner-only file
12
+ * beside the database where there's none), never in a column, a log or a page.
12
13
  * Mail itself goes over IMAP and SMTP with XOAUTH2, so reading and sending
13
14
  * are the same code as for any other mail server.
14
15
  */
15
16
  import { createHash, randomBytes } from "node:crypto";
16
- import { chmodSync, existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
17
- import { join } from "node:path";
18
- const FILE = "google-mail.json";
17
+ import { SecretStoreError, readHeldSecret, writeHeldSecret } from "./tool-secret-store.js";
18
+ const HELD = { name: "google-mail", file: ["google-mail.json"] };
19
19
  export const GOOGLE_AUTH = "https://accounts.google.com/o/oauth2/v2/auth";
20
20
  export const GOOGLE_TOKEN = "https://oauth2.googleapis.com/token";
21
21
  const GOOGLE_REVOKE = "https://oauth2.googleapis.com/revoke";
22
22
  /** Full mail access is what IMAP and SMTP sign-in needs; "email" says which address it is. */
23
23
  export const GOOGLE_SCOPE = "https://mail.google.com/ openid email";
24
24
  export const GOOGLE_CALLBACK = "/settings/google/callback";
25
+ /** The saved client and connection; null when there's none. A keychain that refuses throws (SecretStoreError). */
25
26
  export function readGoogleMail(dir) {
26
- if (dir === null || !existsSync(join(dir, FILE)))
27
+ if (dir === null)
28
+ return null;
29
+ const text = readHeldSecret(dir, HELD.name, HELD.file);
30
+ if (text === null)
27
31
  return null;
28
32
  try {
29
- const raw = JSON.parse(readFileSync(join(dir, FILE), "utf8"));
30
- if (typeof raw["clientId"] !== "string" || typeof raw["clientSecret"] !== "string")
33
+ const raw = JSON.parse(text);
34
+ if (raw === null || typeof raw["clientId"] !== "string" || typeof raw["clientSecret"] !== "string")
31
35
  return null;
32
36
  return { clientId: raw["clientId"], clientSecret: raw["clientSecret"], refreshToken: typeof raw["refreshToken"] === "string" ? raw["refreshToken"] : null, address: typeof raw["address"] === "string" ? raw["address"] : null };
33
37
  }
@@ -37,23 +41,39 @@ export function readGoogleMail(dir) {
37
41
  }
38
42
  /** Connected: a refresh token and the address it belongs to. */
39
43
  export function googleConnected(dir) {
40
- const saved = readGoogleMail(dir);
44
+ let saved;
45
+ try {
46
+ saved = readGoogleMail(dir);
47
+ }
48
+ catch {
49
+ saved = null;
50
+ }
41
51
  return saved?.refreshToken && saved.address ? { address: saved.address } : null;
42
52
  }
43
53
  function write(dir, value) {
44
- writeFileSync(join(dir, FILE), JSON.stringify(value), { mode: 0o600 });
45
- chmodSync(join(dir, FILE), 0o600);
54
+ writeHeldSecret(dir, HELD.name, HELD.file, JSON.stringify(value));
46
55
  }
47
56
  /** Save the OAuth client from Settings; an empty secret keeps the saved one. A new client forgets the old connection. */
48
57
  export function saveGoogleClient(dir, input) {
49
58
  const clientId = typeof input.clientId === "string" ? input.clientId.trim() : "";
50
59
  if (!/^[0-9]{6,20}-[a-z0-9]{8,64}\.apps\.googleusercontent\.com$/.test(clientId))
51
60
  return { ok: false, message: "That doesn't look like a Google OAuth client ID. It ends in .apps.googleusercontent.com." };
52
- const before = readGoogleMail(dir);
61
+ let before;
62
+ try {
63
+ before = readGoogleMail(dir);
64
+ }
65
+ catch (error) {
66
+ return { ok: false, message: error instanceof SecretStoreError ? error.message : "The saved client couldn't be read." };
67
+ }
53
68
  const clientSecret = typeof input.clientSecret === "string" && input.clientSecret.trim() !== "" ? input.clientSecret.trim() : before?.clientId === clientId ? before.clientSecret : "";
54
69
  if (!/^[A-Za-z0-9_-]{16,80}$/.test(clientSecret))
55
70
  return { ok: false, message: "Paste the client secret from the same OAuth client." };
56
- write(dir, { clientId, clientSecret, refreshToken: before?.clientId === clientId ? before.refreshToken : null, address: before?.clientId === clientId ? before.address : null });
71
+ try {
72
+ write(dir, { clientId, clientSecret, refreshToken: before?.clientId === clientId ? before.refreshToken : null, address: before?.clientId === clientId ? before.address : null });
73
+ }
74
+ catch (error) {
75
+ return { ok: false, message: error instanceof SecretStoreError ? error.message : "The client couldn't be saved." };
76
+ }
57
77
  return { ok: true };
58
78
  }
59
79
  /** Where to send the person to consent, and what to remember to check their return against. */
@@ -89,7 +109,13 @@ function addressOf(idToken) {
89
109
  }
90
110
  /** The one-time code from Google's redirect, exchanged for a refresh token and saved. */
91
111
  export async function finishGoogleConsent(dir, visit, code, fetcher) {
92
- const client = readGoogleMail(dir);
112
+ let client;
113
+ try {
114
+ client = readGoogleMail(dir);
115
+ }
116
+ catch (error) {
117
+ return { ok: false, message: error instanceof SecretStoreError ? error.message : "The saved client couldn't be read." };
118
+ }
93
119
  if (client === null)
94
120
  return { ok: false, message: "Save the Google OAuth client first." };
95
121
  let answer;
@@ -107,14 +133,25 @@ export async function finishGoogleConsent(dir, visit, code, fetcher) {
107
133
  const address = addressOf(answer.body["id_token"]);
108
134
  if (address === null)
109
135
  return { ok: false, message: "Google didn't say which address this is. Connect again." };
110
- write(dir, { ...client, refreshToken: refresh, address });
136
+ try {
137
+ write(dir, { ...client, refreshToken: refresh, address });
138
+ }
139
+ catch (error) {
140
+ return { ok: false, message: error instanceof SecretStoreError ? error.message : "The connection couldn't be saved." };
141
+ }
111
142
  cached.delete(client.clientId);
112
143
  return { ok: true, address };
113
144
  }
114
145
  const cached = new Map();
115
146
  /** A fresh access token for IMAP and SMTP (kept in memory until a minute before it ends). */
116
147
  export async function googleAccessToken(dir, fetcher, now = Date.now()) {
117
- const saved = readGoogleMail(dir);
148
+ let saved;
149
+ try {
150
+ saved = readGoogleMail(dir);
151
+ }
152
+ catch (error) {
153
+ return { ok: false, said: error instanceof SecretStoreError ? error.message : "The Google connection couldn't be read.", permanent: false };
154
+ }
118
155
  if (saved?.refreshToken == null || saved.address === null)
119
156
  return { ok: false, said: "The Google account isn't connected. Connect it in Settings → Email.", permanent: true };
120
157
  const hit = cached.get(saved.clientId);
@@ -138,7 +175,13 @@ export async function googleAccessToken(dir, fetcher, now = Date.now()) {
138
175
  }
139
176
  /** Disconnect: Google forgets the grant (best effort), and the file goes. */
140
177
  export async function disconnectGoogle(dir, fetcher) {
141
- const saved = readGoogleMail(dir);
178
+ let saved;
179
+ try {
180
+ saved = readGoogleMail(dir);
181
+ }
182
+ catch {
183
+ saved = null;
184
+ }
142
185
  if (saved?.refreshToken) {
143
186
  try {
144
187
  await fetcher(`${GOOGLE_REVOKE}?${new URLSearchParams({ token: saved.refreshToken })}`, { method: "POST", signal: AbortSignal.timeout(10_000) });
@@ -147,5 +190,5 @@ export async function disconnectGoogle(dir, fetcher) {
147
190
  }
148
191
  if (saved !== null)
149
192
  cached.delete(saved.clientId);
150
- rmSync(join(dir, FILE), { force: true });
193
+ writeHeldSecret(dir, HELD.name, HELD.file, null);
151
194
  }
@@ -5,10 +5,11 @@
5
5
  * render never waits on one (see integrations.ts).
6
6
  */
7
7
  import { type Html } from "./html.js";
8
- import { type Integration } from "./integrations.js";
8
+ import { type Integration, type SecretsKept } from "./integrations.js";
9
9
  export declare const INTEGRATIONS_CSS: string;
10
10
  export declare function integrationsHtml(list: readonly Integration[], csrf: string, notice: {
11
11
  said?: string | null;
12
12
  problem?: string | null;
13
13
  checking?: boolean;
14
+ secrets?: SecretsKept;
14
15
  }): Html;
@@ -5,7 +5,7 @@
5
5
  * render never waits on one (see integrations.ts).
6
6
  */
7
7
  import { html, joinHtml, postForm } from "./html.js";
8
- import { STATE_WORDS } from "./integrations.js";
8
+ import { STATE_WORDS, secretsNote } from "./integrations.js";
9
9
  import { whenUtc } from "./when-html.js";
10
10
  import { brandMarkHtml } from "./brand-mark.js";
11
11
  const when = (at) => whenUtc(at);
@@ -49,7 +49,7 @@ function row(one, csrf) {
49
49
  const badge = !one.checked && one.state === "connected"
50
50
  ? html `<span class="integration-state integration-state--checking"><i aria-hidden="true"></i>Checking</span>`
51
51
  : html `<span class="integration-state integration-state--${one.state}"><i aria-hidden="true"></i>${STATE_WORDS[one.state]}</span>`;
52
- const facts = [one.account, one.detail].filter(Boolean).join(" · ");
52
+ const facts = [one.account, one.detail, one.credentials].filter(Boolean).join(" · ");
53
53
  const command = one.action.kind !== "test" ? one.action.command : null;
54
54
  const fix = one.action.kind === "fix" ? html `<p class="integration-fix" role="status">${one.action.words}</p>` : "";
55
55
  const run = command === null || (one.action.kind === "fix" && one.action.words.includes(command)) ? "" : html `<p class="meta">Run <code>${command}</code> on the computer running Toolroll.</p>`;
@@ -69,5 +69,8 @@ export function integrationsHtml(list, csrf, notice) {
69
69
  const rows = list.filter(one => one.group === group);
70
70
  return rows.length === 0 ? "" : html `<h2>${title}</h2>${rows.map(one => row(one, csrf))}`;
71
71
  });
72
- return html `<section class="integrations">${note}${summary}${sections}</section>`;
72
+ // No keychain here, or Toolroll's folder for secrets can't be used: said once, plainly.
73
+ const said = secretsNote(list, notice.secrets);
74
+ const kept = said === null ? "" : said.problem ? html `<p class="problem integration-secrets" role="alert">${said.words}</p>` : html `<p class="meta integration-secrets" role="note">${said.words}</p>`;
75
+ return html `<section class="integrations">${note}${summary}${kept}${sections}</section>`;
73
76
  }
@@ -38,6 +38,8 @@ export type Integration = {
38
38
  action: IntegrationAction;
39
39
  /** A project's own MCP tool: it shows a letter, never a logo matched by name. */
40
40
  custom?: true;
41
+ /** A project's MCP tool: how its runs get credentials ("Short-lived token per run", "Key held by Toolroll"). */
42
+ credentials?: string;
41
43
  };
42
44
  export declare const STATE_WORDS: Record<IntegrationState, string>;
43
45
  export type ProbeResult = {
@@ -69,7 +71,7 @@ export type IntegrationIo = {
69
71
  /** The same sign-in check Settings → AI providers makes. */
70
72
  checkConnection?: ConnectionChecker;
71
73
  /** Where project tool secrets live. */
72
- toolHome?: string;
74
+ toolHome?: string | undefined;
73
75
  /** Whether a mail server answers at host:port (tests replace it). */
74
76
  reach?: (host: string, port: number, secure: boolean) => Promise<void>;
75
77
  clock?: () => Date;
@@ -97,8 +99,20 @@ export declare function integrationsNow(io: IntegrationIo): Integration[];
97
99
  export declare function checkIntegrations(io: IntegrationIo, keys?: readonly string[]): Promise<Integration[]>;
98
100
  /** The one line `status` and `onboard` add when something is Broken; null when nothing is. */
99
101
  export declare function integrationsBrokenLine(list: readonly Integration[]): string | null;
100
- /** The CLI's lines: one per integration, then the fix under anything Broken. */
101
- export declare function renderIntegrations(list: readonly Integration[]): string[];
102
+ /** Where secrets rest (project-tools.ts toolSecretStorage), as Settings and the CLI take it. */
103
+ export type SecretsKept = {
104
+ words: string;
105
+ filesOnly: boolean;
106
+ problem?: string;
107
+ };
108
+ /** The one line about where secrets rest, or null: a folder that can't be used always; files only once something set up holds secrets. */
109
+ export declare function secretsNote(list: readonly Integration[], secrets: SecretsKept | null | undefined): {
110
+ words: string;
111
+ problem: boolean;
112
+ } | null;
113
+ /** The CLI's lines: one per integration, then the fix under anything Broken; last, where secrets are kept when that is
114
+ * files only, or why they can't be used (secretsNote). */
115
+ export declare function renderIntegrations(list: readonly Integration[], secrets?: SecretsKept | null): string[];
102
116
  export type IntegrationMonitor = {
103
117
  /** The list now, without waiting; starts a background check when the last one is older than the cache. */
104
118
  list(): Integration[];
@@ -17,7 +17,6 @@
17
17
  */
18
18
  import { connect as tcpConnect } from "node:net";
19
19
  import { connect as tlsConnect } from "node:tls";
20
- import { homedir } from "node:os";
21
20
  import { basename } from "node:path";
22
21
  import { ALL_CREDENTIAL_ENV } from "./provider.js";
23
22
  import { providerName, signInCommand } from "./provider-auth.js";
@@ -29,7 +28,7 @@ import { effectivePrimary } from "./webhooks.js";
29
28
  import { readEmailSettings } from "./email-settings.js";
30
29
  import { googleConnected } from "./google-mail.js";
31
30
  import { LINEAR_URL, readHooksBase, readLinearKey } from "./flow-triggers.js";
32
- import { projectToolsOf, readToolSecrets, missingSecrets, testToolOf } from "./project-tools.js";
31
+ import { credentialModeOf, projectToolsOf, readToolSecrets, missingSecrets, secretsSetFor, testToolOf } from "./project-tools.js";
33
32
  import { origin, readMonitoring } from "./monitoring-settings.js";
34
33
  import { targetOf } from "./monitoring.js";
35
34
  export const STATE_WORDS = { connected: "Connected", "not-set-up": "Not set up", broken: "Broken" };
@@ -375,15 +374,23 @@ function targets(io) {
375
374
  probe: email === null ? null : check => probeEmail(check, { host: email.host, port: email.port, secure: email.secure }, email.imap),
376
375
  });
377
376
  // Each project's MCP tools: the server starts and lists its tools.
378
- const toolHome = io.toolHome ?? homedir();
377
+ const toolHome = io.toolHome;
379
378
  let anyTool = false;
380
379
  for (const repo of io.repos) {
381
380
  for (const tool of projectToolsOf(store, repo)) {
382
381
  anyTool = true;
383
382
  const href = `/settings/tools?repo=${encodeURIComponent(repo)}#tool-${encodeURIComponent(tool.name)}`;
384
383
  const test = tool.lastTest;
384
+ // Which secrets are set, by name only: no value is read to say how runs get them.
385
+ let credentials;
386
+ try {
387
+ credentials = credentialModeOf(tool.spec, secretsSetFor(repo, tool.spec, toolHome))?.words;
388
+ }
389
+ catch {
390
+ credentials = undefined;
391
+ }
385
392
  list.push({
386
- key: `mcp:${repo}:${tool.name}`, group: "tools", name: tool.name, configured: true, account: basename(repo), ...(tool.source === "custom" ? { custom: true } : {}),
393
+ key: `mcp:${repo}:${tool.name}`, group: "tools", name: tool.name, configured: true, account: basename(repo), ...(tool.source === "custom" ? { custom: true } : {}), ...(credentials === undefined ? {} : { credentials }),
387
394
  setup: place(href), fix: place(href), usedBy: [`Tasks in ${basename(repo)}`],
388
395
  activity: test === null ? NO_ACTIVITY : test.ok ? { okAt: test.at, error: null, errorAt: null, failing: false } : { okAt: null, error: test.problem, errorAt: test.at, failing: false },
389
396
  probe: async (check) => {
@@ -432,7 +439,7 @@ function targets(io) {
432
439
  }
433
440
  // ---------------------------------------------------------------- the list
434
441
  function combine(target, row) {
435
- const base = { key: target.key, group: target.group, name: target.name, usedBy: target.usedBy, ...(target.custom ? { custom: true } : {}) };
442
+ const base = { key: target.key, group: target.group, name: target.name, usedBy: target.usedBy, ...(target.custom ? { custom: true } : {}), ...(target.credentials === undefined ? {} : { credentials: target.credentials }) };
436
443
  const checked = target.probe === null || row !== undefined;
437
444
  const lastSuccessAt = later(row?.okAt ?? null, target.activity.okAt);
438
445
  const checkError = row?.error ?? null, checkErrorAt = row?.errorAt ?? null;
@@ -482,20 +489,33 @@ export function integrationsBrokenLine(list) {
482
489
  return `Broken integration${broken.length === 1 ? "" : "s"}: ${names}. See \`toolroll integrations\` or Settings → Integrations.`;
483
490
  }
484
491
  const whenText = (at) => (at === null ? "never" : `${at.slice(0, 16).replace("T", " ")} UTC`);
485
- /** The CLI's lines: one per integration, then the fix under anything Broken. */
486
- export function renderIntegrations(list) {
492
+ /** Integrations whose secrets Toolroll holds: project tools, the Linear key, a Google mail connection. */
493
+ const holdsSecrets = (one) => one.group === "tools" || one.key === "linear" || one.key === "email";
494
+ /** The one line about where secrets rest, or null: a folder that can't be used always; files only once something set up holds secrets. */
495
+ export function secretsNote(list, secrets) {
496
+ if (secrets === null || secrets === undefined)
497
+ return null;
498
+ if (secrets.problem !== undefined)
499
+ return { words: secrets.problem, problem: true };
500
+ return secrets.filesOnly && list.some(one => holdsSecrets(one) && one.state !== "not-set-up") ? { words: secrets.words, problem: false } : null;
501
+ }
502
+ /** The CLI's lines: one per integration, then the fix under anything Broken; last, where secrets are kept when that is
503
+ * files only, or why they can't be used (secretsNote). */
504
+ export function renderIntegrations(list, secrets = null) {
487
505
  const width = Math.min(24, Math.max(...list.map(one => one.name.length)));
488
- return list.flatMap(one => {
489
- const facts = [one.account, one.detail].filter(Boolean).join(" · ");
490
- const lines = [`${one.name.padEnd(width)} ${STATE_WORDS[one.state].padEnd(10)}${facts === "" ? "" : ` ${facts}`}`];
491
- if (one.state !== "not-set-up")
492
- lines.push(`${"".padEnd(width)} last success ${whenText(one.lastSuccessAt)}${one.lastError === null ? "" : ` · last error ${whenText(one.lastErrorAt)}: ${one.lastError}`}`);
493
- if (one.action.kind === "fix")
494
- lines.push(`${"".padEnd(width)} Fix: ${one.action.words}${one.action.command === null || one.action.words.includes(one.action.command) ? "" : ` (run ${one.action.command})`}`);
495
- if (one.action.kind === "setup")
496
- lines.push(`${"".padEnd(width)} Set up: ${one.action.command !== null ? `run ${one.action.command}` : `Settings (${one.action.href ?? "/settings"})`}`);
497
- return lines;
498
- });
506
+ const said = secretsNote(list, secrets);
507
+ const note = said === null ? [] : [said.words];
508
+ return [...list.flatMap(one => {
509
+ const facts = [one.account, one.detail, one.credentials].filter(Boolean).join(" · ");
510
+ const lines = [`${one.name.padEnd(width)} ${STATE_WORDS[one.state].padEnd(10)}${facts === "" ? "" : ` ${facts}`}`];
511
+ if (one.state !== "not-set-up")
512
+ lines.push(`${"".padEnd(width)} last success ${whenText(one.lastSuccessAt)}${one.lastError === null ? "" : ` · last error ${whenText(one.lastErrorAt)}: ${one.lastError}`}`);
513
+ if (one.action.kind === "fix")
514
+ lines.push(`${"".padEnd(width)} Fix: ${one.action.words}${one.action.command === null || one.action.words.includes(one.action.command) ? "" : ` (run ${one.action.command})`}`);
515
+ if (one.action.kind === "setup")
516
+ lines.push(`${"".padEnd(width)} Set up: ${one.action.command !== null ? `run ${one.action.command}` : `Settings (${one.action.href ?? "/settings"})`}`);
517
+ return lines;
518
+ }), ...note];
499
519
  }
500
520
  /** Checks in the background with a short cache, as the first-run suggestions are: a render never waits on a check. */
501
521
  export function createIntegrationMonitor(io, options = {}) {