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
package/dist/store.d.ts CHANGED
@@ -110,6 +110,10 @@ export declare const LEAD_FOLLOW_MESSAGE = "Automatic crew update: inspect the s
110
110
  * nothing set up, such as no `gh` sign-in). `ok_at` and `error`/`error_at` keep the last success and the last
111
111
  * failure across checks, so a row can say both. `account` is a display name only, never a credential. */
112
112
  export declare const INTEGRATIONS_SCHEMA = "\nCREATE TABLE IF NOT EXISTS integration_check (\n key TEXT PRIMARY KEY,\n outcome TEXT NOT NULL CHECK (outcome IN ('ok', 'failed', 'absent')),\n account TEXT,\n detail TEXT,\n problem TEXT,\n checked_at TEXT NOT NULL,\n ok_at TEXT,\n error TEXT,\n error_at TEXT\n);\n";
113
+ /** Tool secrets (no version bump): which sealing key Toolroll made for the secrets it keeps in the keychain, as a
114
+ * digest of the key, never the key. A key found beside them that isn't this one was put there by something else and is
115
+ * refused (tool-secret-store.ts). */
116
+ export declare const TOOL_SECRET_KEY_SCHEMA = "\nCREATE TABLE IF NOT EXISTS tool_secret_key (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n digest TEXT NOT NULL CHECK (length(digest) = 64),\n made_at TEXT NOT NULL\n);\n-- Where secrets were kept before Toolroll's folder, decided once at this database's first start (null: none) and never\n-- probed again, so a folder a build makes later under an old name is never migrated in.\nCREATE TABLE IF NOT EXISTS tool_secret_legacy (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n dir TEXT,\n decided_at TEXT NOT NULL\n);\n";
113
117
  export type BackupSettings = {
114
118
  enabled: boolean;
115
119
  everyHours: number;
@@ -166,7 +170,7 @@ export type SealedLedgerEntry = LedgerEntry & {
166
170
  hash: string;
167
171
  } | null;
168
172
  };
169
- export declare const SCHEMA_VERSION = 122;
173
+ export declare const SCHEMA_VERSION = 123;
170
174
  /** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
171
175
  export declare const V118_MERGED_TABLES: Readonly<Record<string, CredentialKind>>;
172
176
  /**
@@ -227,7 +231,8 @@ export declare const V115_DROPPED_TABLES: readonly string[];
227
231
  * three nullable task_steer columns (the running build's steering folder), checked the same way. v121 only adds
228
232
  * new tables and the columns in V121_ADDED_COLUMNS (null, or no sends), checked the same way. v122 only adds a new
229
233
  * table (branch_base_move) and the nullable column in V122_ADDED_COLUMNS (a project's recorded remote), checked the
230
- * same way.
234
+ * same way. v123 only adds new tables (unattended flows, tool secrets) and the columns in V123_ADDED_COLUMNS ('mode',
235
+ * 0 or null), checked the same way.
231
236
  */
232
237
  export declare const UPDATE_SAFE_MIGRATIONS: readonly number[];
233
238
  /** v119: the columns it adds, by table, with the value every row saved before it reads (toolroll-update's HISTORY_RULES). */
@@ -263,6 +268,13 @@ export declare const V122_ADDED_COLUMNS: Readonly<Record<string, Readonly<Record
263
268
  definition: string;
264
269
  saved: string;
265
270
  }>>>>;
271
+ /** v123: the columns it adds, with the value every row saved before it reads: which authority armed a plan's
272
+ * unchanged-plan approval (saved rows read the operating mode), whether a trigger's firing was the clock's own (saved
273
+ * firings read not), and what code an on-time script firing a grant signed for ran (saved firings read none). */
274
+ export declare const V123_ADDED_COLUMNS: Readonly<Record<string, Readonly<Record<string, {
275
+ definition: string;
276
+ saved: string;
277
+ }>>>>;
266
278
  /** Whether a database settled at `version` reaches this build's schema through update-safe migrations alone. */
267
279
  export declare function updateSafeSchema(version: number | null): boolean;
268
280
  export type OAuthClientRow = {
@@ -1770,6 +1782,18 @@ export declare class Store {
1770
1782
  /** The database's own file (null in memory): where the agent fence begins. */
1771
1783
  databaseFile(): string | null;
1772
1784
  close(): void;
1785
+ /** The digest of the sealing key Toolroll made for its tool secrets; null before it made one (or in an older database). */
1786
+ toolSecretKeyDigest(): string | null;
1787
+ /**
1788
+ * The folder secrets were kept in before Toolroll's own, as decided at this database's first start of a command that
1789
+ * can start builds (`record`): `probe` runs until then, and its answer (null: none) is kept for every later start.
1790
+ * Other commands never write, so a preview stays read-only and a busy database doesn't stop them.
1791
+ */
1792
+ toolSecretLegacyDir(probe: () => string | null, now: Date, options: {
1793
+ record: boolean;
1794
+ }): string | null;
1795
+ /** Record a new sealing key's digest only if the one recorded is still `expected` (null: none yet). */
1796
+ swapToolSecretKeyDigest(expected: string | null, next: string, now: Date): boolean;
1773
1797
  /**
1774
1798
  * Creating a task also creates its overlay reference, because a task with no
1775
1799
  * `TaskRef` cannot be claimed, held, or scheduled — it would be work the
@@ -2164,7 +2188,7 @@ export declare class Store {
2164
2188
  * exactly what the signed digest was bound to. Returns false when the
2165
2189
  * scope vanished mid-ceremony. */
2166
2190
  sealScopeApproval(taskId: string, by: string, now: Date, mutation?: Mutation, basis?: {
2167
- kind: "mode" | "chat";
2191
+ kind: "mode" | "chat" | "grant";
2168
2192
  modeDigest: string;
2169
2193
  }, approverKind?: ApproverKind): boolean;
2170
2194
  /** Whether this task's scope carries a LIVE seal: approved, and the
@@ -2219,6 +2243,10 @@ export declare class Store {
2219
2243
  * ceremony. Reconciliation drops mode-basis intents to waiting-human;
2220
2244
  * `firing` rows are untouched — an issued call is one-shot (E1). */
2221
2245
  revokeMode(repo: string, by: string, reason: string, now: Date): boolean;
2246
+ /** A flow's "runs on its own" grant ended (revoked, expired, renewed): the undispatched work it sealed falls back
2247
+ * to a person, as a mode's does, and plans it armed lose their pre-authorization. Running and finished work keeps
2248
+ * its history. */
2249
+ withdrawGrantApprovals(digest: string, now: Date): void;
2222
2250
  /** F2/R-REVOKE: the ONE reconciliation road. newMode null = no live
2223
2251
  * automerge authority (revocation, expiry, or a notify posture):
2224
2252
  * mode-basis pending/claimed intents drop to waiting-human, leases
@@ -4412,6 +4440,10 @@ export declare class Store {
4412
4440
  * other class is observational.
4413
4441
  */
4414
4442
  stampTerminalClass(id: number, authMode: "subscription" | "api-key", terminalClass: TerminalClass): void;
4443
+ /** A launch refused before any model turn (an installed CLI too old for a
4444
+ * build's flags): the start stamp goes, its version and evidence stay. Only
4445
+ * while the run is open, so a settled run's account never changes. */
4446
+ withdrawProviderStart(id: number): void;
4415
4447
  /** The race-refusal stamp (Phase 3 C5): the version that was REFUSED,
4416
4448
  * recorded without provider_started_at — no provider process started. */
4417
4449
  stampProviderVersion(id: number, providerVersion: string): void;
@@ -5684,7 +5716,7 @@ export declare class Store {
5684
5716
  }, now: Date): number;
5685
5717
  /** A person approves a held script (one an imported flow brought): from now on it runs. False when it wasn't held. */
5686
5718
  approveFlowScript(repo: string, name: string): boolean;
5687
- removeFlowScript(repo: string, name: string): boolean;
5719
+ removeFlowScript(repo: string, name: string, by?: string, now?: Date): boolean;
5688
5720
  /** Claim a card's visit to a step zone. False when another pass holds it or it already finished. */
5689
5721
  claimFlowStep(step: {
5690
5722
  card: number;
@@ -5764,7 +5796,7 @@ export declare class Store {
5764
5796
  lastOutcome?: string;
5765
5797
  failures?: number;
5766
5798
  hookHash?: string | null;
5767
- }, now: Date): void;
5799
+ }, now: Date, by?: string): void;
5768
5800
  /** Whether a trigger already handled this outside thing (an issue, a run, a schedule slot). */
5769
5801
  flowTriggerSaw(trigger: number, key: string): boolean;
5770
5802
  /** The card one outside thing made (v89: a chat message, so replies in its thread find it). */
@@ -5775,7 +5807,7 @@ export declare class Store {
5775
5807
  * Record what a trigger did with one outside thing: the card it made, or why it made none, and (a standing order's
5776
5808
  * firing) the task it filed. False when it was already recorded.
5777
5809
  */
5778
- recordFlowTriggerEvent(trigger: number, key: string, card: number | null, note: string | null, now: Date, task?: string | null): boolean;
5810
+ recordFlowTriggerEvent(trigger: number, key: string, card: number | null, note: string | null, now: Date, task?: string | null, onTime?: boolean, ranScript?: string | null): boolean;
5779
5811
  /** The last card a trigger made, if any. */
5780
5812
  lastFlowTriggerCard(trigger: number): number | null;
5781
5813
  /**
package/dist/store.js CHANGED
@@ -74,9 +74,10 @@ import { isProviderId } from "./provider.js";
74
74
  import { authenticate as runnerAuthenticate } from "./runner.js";
75
75
  import { stampNewProjectLevel } from "./check-levels.js";
76
76
  import { parseCheckProgressSnapshot } from "./check-progress.js";
77
- import { normalizeProjectAccess, projectAccessAllows, readProjectAccess } from "./project-access.js";
77
+ import { accountReachesSql, normalizeProjectAccess, projectAccessAllows, readProjectAccess } from "./project-access.js";
78
78
  import { LEDGER_COLUMNS, LEDGER_SCHEMA, LEDGER_TABLE, LEDGER_V99_TABLE, installLedgerTriggers } from "./action-ledger.js";
79
79
  import { PLAN_AUTO_SCHEMA } from "./plan-auto.js";
80
+ import { endUnattendedGrants, FLOW_UNATTENDED_SCHEMA, grantStandsSql } from "./flow-unattended.js";
80
81
  import { RECIPE_SCHEMA } from "./recipes.js";
81
82
  import { POLICY_SCHEMA, agentRefusal, approvalRefusal, policyParts, readPolicy, underCeiling, sessionCeilingRefusal } from "./policy.js";
82
83
  import { PROVIDER_AUTH_SCHEMA } from "./provider-auth.js";
@@ -475,6 +476,23 @@ CREATE TABLE IF NOT EXISTS integration_check (
475
476
  error_at TEXT
476
477
  );
477
478
  `;
479
+ /** Tool secrets (no version bump): which sealing key Toolroll made for the secrets it keeps in the keychain, as a
480
+ * digest of the key, never the key. A key found beside them that isn't this one was put there by something else and is
481
+ * refused (tool-secret-store.ts). */
482
+ export const TOOL_SECRET_KEY_SCHEMA = `
483
+ CREATE TABLE IF NOT EXISTS tool_secret_key (
484
+ id INTEGER PRIMARY KEY CHECK (id = 1),
485
+ digest TEXT NOT NULL CHECK (length(digest) = 64),
486
+ made_at TEXT NOT NULL
487
+ );
488
+ -- Where secrets were kept before Toolroll's folder, decided once at this database's first start (null: none) and never
489
+ -- probed again, so a folder a build makes later under an old name is never migrated in.
490
+ CREATE TABLE IF NOT EXISTS tool_secret_legacy (
491
+ id INTEGER PRIMARY KEY CHECK (id = 1),
492
+ dir TEXT,
493
+ decided_at TEXT NOT NULL
494
+ );
495
+ `;
478
496
  /** Sprint 8: database backups. One settings row (off, or every so many hours, keeping the newest N in a folder; no
479
497
  * folder means `backups` beside the database), every backup's outcome, and a lease the console's backup loop holds
480
498
  * and renews while it runs: one process backs up at a time, and a restore sees that the console is running. */
@@ -624,7 +642,10 @@ const REGISTERED_AT_FILING = "again, as it was filed";
624
642
  // v122 adds, and changes no saved row: the remote URL an owner's project add recorded (project.remote_url), the only
625
643
  // remote whose main automatic completion trusts (remote-main.ts; saved projects read none until it is recorded), and
626
644
  // fresh base's record of a task branch moved to current main before an attempt (branch_base_move).
627
- export const SCHEMA_VERSION = 122;
645
+ // v123 adds, and changes no saved row: flows that run on their own (flow_script_provenance, flow_unattended_grant,
646
+ // flow_unattended_run, plan_authorization.basis, flow_trigger_event.on_time and ran_script) and the tool-secret
647
+ // sealing key's record (tool_secret_key, tool_secret_legacy).
648
+ export const SCHEMA_VERSION = 123;
628
649
  /** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
629
650
  export const V118_MERGED_TABLES = Object.freeze({ api_token: "person", coordinator_credential: "coordinator", lead_credential: "lead" });
630
651
  /** The foreign keys that named a merged table, exactly as their DDL wrote them; each names `credential(id)` instead. */
@@ -751,9 +772,10 @@ const V115_DROPPED_REFERENCES = /\s+REFERENCES (?:contestant|contest|tournament_
751
772
  * three nullable task_steer columns (the running build's steering folder), checked the same way. v121 only adds
752
773
  * new tables and the columns in V121_ADDED_COLUMNS (null, or no sends), checked the same way. v122 only adds a new
753
774
  * table (branch_base_move) and the nullable column in V122_ADDED_COLUMNS (a project's recorded remote), checked the
754
- * same way.
775
+ * same way. v123 only adds new tables (unattended flows, tool secrets) and the columns in V123_ADDED_COLUMNS ('mode',
776
+ * 0 or null), checked the same way.
755
777
  */
756
- export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122]);
778
+ export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123]);
757
779
  /** v119: the columns it adds, by table, with the value every row saved before it reads (toolroll-update's HISTORY_RULES). */
758
780
  export const V119_ADDED_COLUMNS = Object.freeze({
759
781
  task_ref: { planning_generation: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" } },
@@ -804,6 +826,16 @@ export const V121_ADDED_COLUMNS = Object.freeze({
804
826
  export const V122_ADDED_COLUMNS = Object.freeze({
805
827
  project: { remote_url: { definition: "TEXT", saved: "NULL" } },
806
828
  });
829
+ /** v123: the columns it adds, with the value every row saved before it reads: which authority armed a plan's
830
+ * unchanged-plan approval (saved rows read the operating mode), whether a trigger's firing was the clock's own (saved
831
+ * firings read not), and what code an on-time script firing a grant signed for ran (saved firings read none). */
832
+ export const V123_ADDED_COLUMNS = Object.freeze({
833
+ plan_authorization: { basis: { definition: "TEXT NOT NULL DEFAULT 'mode' CHECK (basis IN ('mode', 'grant'))", saved: "'mode'" } },
834
+ flow_trigger_event: {
835
+ on_time: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" },
836
+ ran_script: { definition: "TEXT", saved: "NULL" },
837
+ },
838
+ });
807
839
  /** Whether a database settled at `version` reaches this build's schema through update-safe migrations alone. */
808
840
  export function updateSafeSchema(version) {
809
841
  if (version === null || !Number.isSafeInteger(version) || version <= 0 || version > SCHEMA_VERSION)
@@ -3527,6 +3559,8 @@ function initializeStore(db, file) {
3527
3559
  db.exec(LEDGER_CHAIN_SCHEMA);
3528
3560
  db.exec(MONITORING_SCHEMA);
3529
3561
  db.exec(INTEGRATIONS_SCHEMA);
3562
+ // v123: the tool-secret sealing key's record and where secrets were kept before (tool-secret-store.ts): new tables.
3563
+ db.exec(TOOL_SECRET_KEY_SCHEMA);
3530
3564
  db.exec(LEAD_ASK_SCHEMA);
3531
3565
  // Decisions in the chat app (no version bump: additive only): result, plan and merge buttons, and Request changes.
3532
3566
  db.exec(CHAT_DECIDE_SCHEMA);
@@ -3613,6 +3647,16 @@ function initializeStore(db, file) {
3613
3647
  // v115: the task each standing-order firing filed, kept on the firing itself (a card's task changes as it moves).
3614
3648
  addColumn(db, "flow_trigger_event", "task", "TEXT");
3615
3649
  db.exec(PLAN_AUTO_SCHEMA);
3650
+ // v123: a flow that runs on its own (flow-unattended.ts): its grants and daily runs (new tables), and which authority
3651
+ // armed a plan's unchanged-plan approval (plan_authorization.basis, V123_ADDED_COLUMNS: saved rows read the mode).
3652
+ db.exec(FLOW_UNATTENDED_SCHEMA);
3653
+ // v123: whether a trigger's firing was the clock's own (flow_trigger_event.on_time, V123_ADDED_COLUMNS: a schedule
3654
+ // due, not Run now; only those can run on their own), and what code an on-time script firing a grant signed for ran
3655
+ // (flow_trigger_event.ran_script, V123_ADDED_COLUMNS: flow-unattended.ts scriptRanDigestOf; older firings read null).
3656
+ for (const [table, columns] of Object.entries(V123_ADDED_COLUMNS)) {
3657
+ for (const [column, { definition }] of Object.entries(columns))
3658
+ addColumn(db, table, column, definition);
3659
+ }
3616
3660
  // v119: planning generations, and why a question closed unanswered (additive only; saved rows read the defaults).
3617
3661
  for (const [table, columns] of Object.entries(V119_ADDED_COLUMNS)) {
3618
3662
  for (const [column, { definition }] of Object.entries(columns))
@@ -4818,6 +4862,36 @@ export class Store {
4818
4862
  close() {
4819
4863
  this.db.close();
4820
4864
  }
4865
+ /** The digest of the sealing key Toolroll made for its tool secrets; null before it made one (or in an older database). */
4866
+ toolSecretKeyDigest() {
4867
+ if (!tableExists(this.db, "tool_secret_key"))
4868
+ return null;
4869
+ const row = this.db.prepare("SELECT digest FROM tool_secret_key WHERE id = 1").get();
4870
+ return row === undefined ? null : String(row["digest"]);
4871
+ }
4872
+ /**
4873
+ * The folder secrets were kept in before Toolroll's own, as decided at this database's first start of a command that
4874
+ * can start builds (`record`): `probe` runs until then, and its answer (null: none) is kept for every later start.
4875
+ * Other commands never write, so a preview stays read-only and a busy database doesn't stop them.
4876
+ */
4877
+ toolSecretLegacyDir(probe, now, options) {
4878
+ const read = () => tableExists(this.db, "tool_secret_legacy") ? this.db.prepare("SELECT dir FROM tool_secret_legacy WHERE id = 1").get() : undefined;
4879
+ let row = read();
4880
+ if (row === undefined) {
4881
+ if (!options.record)
4882
+ return probe();
4883
+ this.db.prepare("INSERT OR IGNORE INTO tool_secret_legacy (id, dir, decided_at) VALUES (1, ?, ?)").run(probe(), now.toISOString());
4884
+ row = read();
4885
+ }
4886
+ return row?.["dir"] === null || row?.["dir"] === undefined ? null : String(row["dir"]);
4887
+ }
4888
+ /** Record a new sealing key's digest only if the one recorded is still `expected` (null: none yet). */
4889
+ swapToolSecretKeyDigest(expected, next, now) {
4890
+ const changed = expected === null
4891
+ ? this.db.prepare("INSERT OR IGNORE INTO tool_secret_key (id, digest, made_at) VALUES (1, ?, ?)").run(next, now.toISOString()).changes
4892
+ : this.db.prepare("UPDATE tool_secret_key SET digest = ?, made_at = ? WHERE id = 1 AND digest = ?").run(next, now.toISOString(), expected).changes;
4893
+ return Number(changed) === 1;
4894
+ }
4821
4895
  // ---- the built-in task store -------------------------------------------
4822
4896
  /**
4823
4897
  * Creating a task also creates its overlay reference, because a task with no
@@ -6344,7 +6418,9 @@ export class Store {
6344
6418
  ? `Approved by ${currentActor()?.lead === true ? "the lead" : lifecycleWords(by, 40)}. A connected worker can take it next.`
6345
6419
  : basis.kind === "chat"
6346
6420
  ? `Approved by ${lifecycleWords(by, 40)} in chat. A connected worker can take it next.`
6347
- : "Approved automatically under the operating mode. A connected worker can take it next.",
6421
+ : basis.kind === "grant"
6422
+ ? "Approved automatically: its flow runs on its own. A connected worker can take it next."
6423
+ : "Approved automatically under the operating mode. A connected worker can take it next.",
6348
6424
  link: chatControlHref("task", taskId),
6349
6425
  }, now);
6350
6426
  }
@@ -6375,16 +6451,19 @@ export class Store {
6375
6451
  * scopes answer true: this belt only ever REMOVES mode authority.
6376
6452
  */
6377
6453
  modeApprovalLive(taskRef, now) {
6454
+ // A flow's "runs on its own" grant (flow-unattended.ts) is held to the same belt.
6378
6455
  const dead = this.db
6379
6456
  .prepare(`SELECT 1 AS dead FROM task_scope
6380
6457
  JOIN task_ref ON task_ref.id = ? AND task_ref.backend = 'built-in' AND task_scope.task_id = task_ref.external_id
6381
- WHERE task_scope.approval_basis = 'mode' AND task_scope.approved_at IS NOT NULL
6458
+ WHERE task_scope.approved_at IS NOT NULL
6459
+ AND ((task_scope.approval_basis = 'mode'
6382
6460
  AND NOT EXISTS (SELECT 1 FROM operating_mode om
6383
6461
  JOIN approver signer ON signer.name = om.signed_by
6384
6462
  WHERE om.repo = task_ref.repo AND om.revoked_at IS NULL
6385
6463
  AND om.absolute_expiry > ? AND om.digest = task_scope.mode_digest
6386
- AND signer.revoked_at IS NULL AND signer.role = 'approver')`)
6387
- .get(taskRef, now.toISOString());
6464
+ AND signer.revoked_at IS NULL AND signer.role = 'approver' AND ${accountReachesSql("signer", "task_ref.repo")}))
6465
+ OR (task_scope.approval_basis = 'grant' AND NOT ${grantStandsSql("task_scope.mode_digest", "?", "task_ref.repo")}))`)
6466
+ .get(taskRef, now.toISOString(), now.toISOString());
6388
6467
  return dead === undefined;
6389
6468
  }
6390
6469
  // ---- approvers ----------------------------------------------------------
@@ -6511,6 +6590,29 @@ export class Store {
6511
6590
  return true;
6512
6591
  });
6513
6592
  }
6593
+ /** A flow's "runs on its own" grant ended (revoked, expired, renewed): the undispatched work it sealed falls back
6594
+ * to a person, as a mode's does, and plans it armed lose their pre-authorization. Running and finished work keeps
6595
+ * its history. */
6596
+ withdrawGrantApprovals(digest, now) {
6597
+ const demotable = `WHERE approval_basis = 'grant' AND approved_at IS NOT NULL AND mode_digest = ?
6598
+ AND EXISTS (SELECT 1 FROM task_ref JOIN task ON task.id = task_ref.external_id
6599
+ WHERE task_ref.backend = 'built-in' AND task_ref.external_id = task_scope.task_id AND task.state = 'queued')
6600
+ AND NOT EXISTS (SELECT 1 FROM claim JOIN task_ref tr ON tr.id = claim.task_ref
6601
+ WHERE tr.backend = 'built-in' AND tr.external_id = task_scope.task_id
6602
+ AND claim.released_at IS NULL AND claim.expires_at > ?)`;
6603
+ const demoted = this.db.prepare(`SELECT task_id FROM task_scope ${demotable}`).all(digest, now.toISOString()).map(row => String(row["task_id"]));
6604
+ this.db
6605
+ .prepare(`UPDATE task_scope SET approved_at = NULL, approved_by = NULL, approved_digest = NULL,
6606
+ approved_profile_json = NULL, approval_basis = 'password', mode_digest = NULL ${demotable}`)
6607
+ .run(digest, now.toISOString());
6608
+ this.db.prepare("DELETE FROM plan_authorization WHERE basis = 'grant' AND mode_digest = ?").run(digest);
6609
+ for (const taskId of demoted) {
6610
+ const ref = this.lookupRef(taskId);
6611
+ if (ref === null)
6612
+ continue;
6613
+ this.noteLifecycle({ taskRef: ref.id, kind: "approval-withdrawn", identity: `t${ref.id}`, subject: "Approval needs renewing", body: "Its flow no longer runs on its own. Approve it before it runs.", link: chatControlHref("approval", taskId) }, now);
6614
+ }
6615
+ }
6514
6616
  /** F2/R-REVOKE: the ONE reconciliation road. newMode null = no live
6515
6617
  * automerge authority (revocation, expiry, or a notify posture):
6516
6618
  * mode-basis pending/claimed intents drop to waiting-human, leases
@@ -6735,6 +6837,7 @@ export class Store {
6735
6837
  for (const row of this.db.prepare("SELECT repo FROM operating_mode WHERE signed_by = ? AND revoked_at IS NULL").all(name)) {
6736
6838
  this.revokeMode(String(row["repo"]), by, "operator", now);
6737
6839
  }
6840
+ endUnattendedGrants(this, { signer: name }, by, "access changed", now);
6738
6841
  this.revokeDerivedAuthority(name, by, now);
6739
6842
  for (const repo of [...new Set([...(account.projects ?? []), ...(access ?? [])])]) {
6740
6843
  this.recordAction({ at: now.toISOString(), actor: by, repo, taskId: null, runId: null,
@@ -6762,6 +6865,10 @@ export class Store {
6762
6865
  ON CONFLICT(repo) DO UPDATE SET not_requester = excluded.not_requester, protect_project = excluded.protect_project, protected_paths = excluded.protected_paths, updated_by = excluded.updated_by, updated_at = excluded.updated_at`)
6763
6866
  .run(repo, next.notRequester ? 1 : 0, next.protectProject ? 1 : 0, JSON.stringify(next.protectedPaths), by, now.toISOString());
6764
6867
  this.recordPolicy(by, repo, "approval rules changed", rulesWords(before), rulesWords(next), now);
6868
+ // Protected work is always a person's: a flow here (or building here) stops running on its own, and its queued
6869
+ // approvals are withdrawn (flow-unattended.ts). The dispatch belts check the rules again for any race.
6870
+ if (isProtectedWork(next, []))
6871
+ endUnattendedGrants(this, { repo }, by, "project protected", now);
6765
6872
  });
6766
6873
  }
6767
6874
  /** Who filed a task, when anyone knows. */
@@ -12325,6 +12432,12 @@ export class Store {
12325
12432
  .prepare("UPDATE run SET auth_mode = COALESCE(auth_mode, ?), terminal_class = ? WHERE id = ? AND terminal_class IS NULL")
12326
12433
  .run(authMode, terminalClass, id);
12327
12434
  }
12435
+ /** A launch refused before any model turn (an installed CLI too old for a
12436
+ * build's flags): the start stamp goes, its version and evidence stay. Only
12437
+ * while the run is open, so a settled run's account never changes. */
12438
+ withdrawProviderStart(id) {
12439
+ this.db.prepare("UPDATE run SET provider_started_at = NULL WHERE id = ? AND outcome IS NULL").run(id);
12440
+ }
12328
12441
  /** The race-refusal stamp (Phase 3 C5): the version that was REFUSED,
12329
12442
  * recorded without provider_started_at — no provider process started. */
12330
12443
  stampProviderVersion(id, providerVersion) {
@@ -14928,6 +15041,8 @@ export class Store {
14928
15041
  const version = (current?.version ?? Number(this.db.prepare("SELECT COALESCE(MAX(version), 0) AS v FROM flow_script WHERE repo = ? AND name = ?").get(script.repo, script.name)?.["v"] ?? 0)) + 1;
14929
15042
  if (current !== null)
14930
15043
  this.db.prepare("UPDATE flow_script SET state = 'removed' WHERE id = ?").run(current.id);
15044
+ // A "runs on its own" grant signed this script's code: a new version ends it (flow-unattended.ts).
15045
+ endUnattendedGrants(this, { script: { repo: script.repo, name: script.name } }, script.by, "script changed", now);
14931
15046
  this.db.prepare(`INSERT INTO flow_script (repo, name, about, body, timeout_minutes, version, digest, saved_by, saved_at, state, language, file, held)
14932
15047
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)`).run(script.repo, script.name, script.about, script.body, script.timeoutMinutes, version, script.digest, script.by, now.toISOString(), script.language ?? "shell", script.file ?? null, script.held ?? null);
14933
15048
  return version;
@@ -14938,9 +15053,14 @@ export class Store {
14938
15053
  const { changes } = this.db.prepare("UPDATE flow_script SET held = NULL WHERE repo = ? AND name = ? AND state = 'active' AND held IS NOT NULL").run(repo, name);
14939
15054
  return Number(changes) === 1;
14940
15055
  }
14941
- removeFlowScript(repo, name) {
14942
- const { changes } = this.db.prepare("UPDATE flow_script SET state = 'removed' WHERE repo = ? AND name = ? AND state = 'active'").run(repo, name);
14943
- return Number(changes) === 1;
15056
+ removeFlowScript(repo, name, by = "flow", now = new Date()) {
15057
+ return this.transact(() => {
15058
+ const { changes } = this.db.prepare("UPDATE flow_script SET state = 'removed' WHERE repo = ? AND name = ? AND state = 'active'").run(repo, name);
15059
+ // A grant that signed this script's code covers nothing without it: it ends (flow-unattended.ts).
15060
+ if (Number(changes) === 1)
15061
+ endUnattendedGrants(this, { script: { repo, name } }, by, "script changed", now);
15062
+ return Number(changes) === 1;
15063
+ });
14944
15064
  }
14945
15065
  /** Claim a card's visit to a step zone. False when another pass holds it or it already finished. */
14946
15066
  claimFlowStep(step, now) {
@@ -15029,6 +15149,13 @@ export class Store {
15029
15149
  // ---- flow triggers (v82) -----------------------------------------------------
15030
15150
  addFlowTrigger(trigger, now) {
15031
15151
  const stamp = now.toISOString();
15152
+ // A new schedule is one the flow's "runs on its own" grant never read: it ends (flow-unattended.ts).
15153
+ if (trigger.kind === "schedule")
15154
+ return this.transact(() => {
15155
+ endUnattendedGrants(this, { flow: trigger.flow }, trigger.by, "schedule changed", now);
15156
+ return Number(this.db.prepare("INSERT INTO flow_trigger (flow, kind, config_json, state, hook_hash, cursor, next_at, created_by, created_at, updated_at) VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?, ?)")
15157
+ .run(trigger.flow, trigger.kind, trigger.configJson, trigger.hookHash, trigger.cursor, trigger.nextAt, trigger.by, stamp, stamp).lastInsertRowid);
15158
+ });
15032
15159
  return Number(this.db.prepare("INSERT INTO flow_trigger (flow, kind, config_json, state, hook_hash, cursor, next_at, created_by, created_at, updated_at) VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?, ?)")
15033
15160
  .run(trigger.flow, trigger.kind, trigger.configJson, trigger.hookHash, trigger.cursor, trigger.nextAt, trigger.by, stamp, stamp).lastInsertRowid);
15034
15161
  }
@@ -15049,11 +15176,17 @@ export class Store {
15049
15176
  const row = this.db.prepare("SELECT t.* FROM flow_trigger t JOIN flow f ON f.id = t.flow WHERE t.hook_hash = ? AND t.state = 'active' AND f.state = 'active'").get(hash);
15050
15177
  return row === undefined ? null : readFlowTriggerRow(row);
15051
15178
  }
15052
- updateFlowTrigger(id, change, now) {
15179
+ updateFlowTrigger(id, change, now, by = "a person") {
15053
15180
  const trigger = this.getFlowTrigger(id);
15054
15181
  if (trigger === null)
15055
15182
  return;
15056
- this.db.prepare("UPDATE flow_trigger SET state = ?, cursor = ?, next_at = ?, last_at = ?, last_outcome = ?, failures = ?, hook_hash = ?, updated_at = ? WHERE id = ?").run(change.state ?? trigger.state, change.cursor === undefined ? trigger.cursor : change.cursor, change.nextAt === undefined ? trigger.nextAt : change.nextAt, change.lastAt ?? trigger.lastAt, change.lastOutcome ?? trigger.lastOutcome, change.failures ?? trigger.failures, change.hookHash === undefined ? trigger.hookHash : change.hookHash, now.toISOString(), id);
15183
+ // Pausing, resuming or removing a schedule ends the flow's "runs on its own" grant (flow-unattended.ts).
15184
+ const write = () => this.db.prepare("UPDATE flow_trigger SET state = ?, cursor = ?, next_at = ?, last_at = ?, last_outcome = ?, failures = ?, hook_hash = ?, updated_at = ? WHERE id = ?").run(change.state ?? trigger.state, change.cursor === undefined ? trigger.cursor : change.cursor, change.nextAt === undefined ? trigger.nextAt : change.nextAt, change.lastAt ?? trigger.lastAt, change.lastOutcome ?? trigger.lastOutcome, change.failures ?? trigger.failures, change.hookHash === undefined ? trigger.hookHash : change.hookHash, now.toISOString(), id);
15185
+ if (trigger.kind !== "schedule" || change.state === undefined || change.state === trigger.state) {
15186
+ write();
15187
+ return;
15188
+ }
15189
+ this.transact(() => { endUnattendedGrants(this, { flow: trigger.flow }, by, "schedule changed", now); write(); });
15057
15190
  }
15058
15191
  /** Whether a trigger already handled this outside thing (an issue, a run, a schedule slot). */
15059
15192
  flowTriggerSaw(trigger, key) {
@@ -15074,8 +15207,8 @@ export class Store {
15074
15207
  * Record what a trigger did with one outside thing: the card it made, or why it made none, and (a standing order's
15075
15208
  * firing) the task it filed. False when it was already recorded.
15076
15209
  */
15077
- recordFlowTriggerEvent(trigger, key, card, note, now, task = null) {
15078
- const { changes } = this.db.prepare("INSERT OR IGNORE INTO flow_trigger_event (trigger, key, card, note, at, task) VALUES (?, ?, ?, ?, ?, ?)").run(trigger, key, card, note, now.toISOString(), task);
15210
+ recordFlowTriggerEvent(trigger, key, card, note, now, task = null, onTime = false, ranScript = null) {
15211
+ const { changes } = this.db.prepare("INSERT OR IGNORE INTO flow_trigger_event (trigger, key, card, note, at, task, on_time, ran_script) VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run(trigger, key, card, note, now.toISOString(), task, onTime ? 1 : 0, ranScript);
15079
15212
  return Number(changes) === 1;
15080
15213
  }
15081
15214
  /** The last card a trigger made, if any. */
@@ -12,7 +12,7 @@ export type ToolLister = (spec: ToolSpec, values: Record<string, string>) => Pro
12
12
  export type ToolIo = {
13
13
  callTool?: ToolCaller;
14
14
  listTools?: ToolLister;
15
- toolHome?: string;
15
+ toolHome?: string | undefined;
16
16
  };
17
17
  /** Actions whose names say they only read start as "do it"; everything else asks first until its manager says otherwise. */
18
18
  export declare const READ_VERB: RegExp;
package/dist/surface.js CHANGED
@@ -116,8 +116,9 @@ const GUIDE_ROWS = [
116
116
  ...KNOWLEDGE_DESCRIPTORS.map(spec => ({ invocation: `knowledge ${spec.action}`, synopsis: spec.synopsis, audience: "audience" in spec ? spec.audience : "agent", agentMayInvoke: !("audience" in spec), mutation: spec.mutation, flags: spec.flags, ...(spec.takesQuery ? { positionals: [{ name: "query", required: true, meaning: "search text or source file for impact" }] } : {}) })),
117
117
  ...MEMORY_DESCRIPTORS.map(spec => ({ invocation: `memory ${spec.action}`, synopsis: spec.synopsis, audience: "agent", agentMayInvoke: true, mutation: spec.mutation, flags: spec.flags, ...(spec.takesQuery ? { positionals: [{ name: "query", required: true, meaning: "search text, a decision id, or the decision sentence" }] } : {}) })),
118
118
  // Flow writes carry an approver's credential: an agent runs them only as the person asked, after showing the preview.
119
- ...FLOWS_DESCRIPTORS.map(spec => ({ invocation: `flows ${spec.action}`, synopsis: spec.synopsis, audience: "agent", agentMayInvoke: true, mutation: spec.mutation, flags: spec.flags,
120
- ...("positionals" in spec ? { positionals: spec.positionals.map(name => ({ name, required: true, meaning: name === "flow" ? "a flow id from flows list" : name === "card" ? "a card id from flows show" : "a trigger id from flows show, or for trigger add its settings as JSON, a JSON file, or -" })) } : {}) })),
119
+ // Letting a flow run on its own raises authority: the owner's own password ceremony, never an agent's act.
120
+ ...FLOWS_DESCRIPTORS.map(spec => ({ invocation: `flows ${spec.action}`, synopsis: spec.synopsis, audience: "audience" in spec ? spec.audience : "agent", agentMayInvoke: !("audience" in spec), mutation: spec.mutation, flags: spec.flags,
121
+ ...("positionals" in spec ? { positionals: spec.positionals.map(name => ({ name, required: true, meaning: name === "flow" ? "a flow id from flows list" : name === "card" ? "a card id from flows show" : name === "on|off" ? "on signs the grant, off revokes it" : "a trigger id from flows show, or for trigger add its settings as JSON, a JSON file, or -" })) } : {}) })),
121
122
  ...MODELS_DESCRIPTORS.map(spec => ({ invocation: `models ${spec.action}`, synopsis: spec.synopsis, audience: "agent", agentMayInvoke: true, mutation: spec.mutation, flags: spec.flags, ...(spec.takesQuery ? { positionals: [{ name: "target", required: true, meaning: "the CLI to update, or on/off" }] } : {}) })),
122
123
  // ---- the queue (agent surface) ----
123
124
  { invocation: "status", synopsis: "running work, queued reasons, results to review, the latest release check and plan windows", audience: "agent", agentMayInvoke: true, mutation: "none", flags: [jsonFlag, dbFlag] },
@@ -411,6 +412,7 @@ const GUIDE_ROWS = [
411
412
  operator("up", "console + worker + browser, one command"),
412
413
  operator("onboard", "add this repository, report signed-in agents, install or --remove the operator skill (--yes), offer the starter flows (--starter <ids>), and print the handoff"),
413
414
  operator("daemon", "install the loop under the OS service manager"),
415
+ { ...operatorRead("doctor", "check that Toolroll's service holds no key, token or password a build could read; names what it finds and the steps to fix it, never a value, and changes nothing"), flags: [jsonFlag] },
414
416
  ];
415
417
  /**
416
418
  * Whether each row may run on a central server for a person signed in with their own API token (`runOperateAs` in
@@ -429,7 +431,7 @@ const REMOTE_NO = [
429
431
  // Workers, the loop and the services that run here.
430
432
  "claim", "heartbeat", "worker release", "reap", "tick", "build", "reconcile",
431
433
  "runner register", "runner retire", "runner bind", "runner capacity", "coordinator mint", "coordinator revoke", "mcp", "outbox deliver", "peek",
432
- "serve", "serve check-public", "watch", "up", "onboard", "daemon", "bridge", "setup show", "setup clear",
434
+ "serve", "serve check-public", "watch", "up", "onboard", "daemon", "doctor", "bridge", "setup show", "setup clear",
433
435
  // Provider keys and model tools on this machine.
434
436
  "providers", "keys status", "keys set", "keys clear", "keys verify", "keys auth", "models check", "models update", "models watch",
435
437
  // Publication and release.
@@ -443,7 +445,7 @@ const REMOTE_NO = [
443
445
  ];
444
446
  const REMOTE_STEP_UP = [
445
447
  "proposals", "decide", "task regate", "task approve", "task accept", "task merge", "run settle", "knowledge apply", "memory apply",
446
- "flows card approve", "flows card send-back", "flows script approve",
448
+ "flows card approve", "flows card send-back", "flows script approve", "flows unattended",
447
449
  "approver list", "approver add", "people list", "people invite", "people projects", "people revoke",
448
450
  // A person's own API tokens: a password step-up on the server's machine, or the console. Never with a token.
449
451
  "tokens create", "tokens list", "tokens revoke", "tokens rotate",
@@ -0,0 +1,14 @@
1
+ import { type ExecResult } from "./exec.js";
2
+ import { type Runner } from "./worktree.js";
3
+ export type Git = (cwd: string, args: readonly string[]) => Promise<ExecResult>;
4
+ /** Fixed on every teardown command, ahead of its own arguments. */
5
+ export declare const TEARDOWN_FLAGS: readonly string[];
6
+ /** Exit code of a refused command (git's own fatal code, so no caller reads it as "not found" or "no"). */
7
+ export declare const REFUSED_CODE = 128;
8
+ /** The command-running setting a `git config --list --show-scope -z` listing sets, or null. Only local and worktree
9
+ * scope count: teardown commands never read system or global config. */
10
+ export declare function commandSetting(listing: string): string | null;
11
+ /** Why a teardown command was refused, or null when `result` isn't a refusal. */
12
+ export declare function refusal(result: ExecResult): string | null;
13
+ /** The one way teardown runs git, including the target checkout immediately before removal. */
14
+ export declare function teardownGit(runner?: Runner): Git;
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Git for teardown (landed-cleanup.ts and the checkout removal it drives in worktree.ts), run by Toolroll's own
3
+ * process inside the shared repository a build can write to. A build can plant hooks in .git/hooks and settings in
4
+ * .git/config (or a worktree's config.worktree), so every teardown command runs hardened: an allowlisted environment
5
+ * with no system or global config (remote-main.ts's), no hooks, no fsmonitor, no ext transport, no prompts and no
6
+ * automatic maintenance.
7
+ *
8
+ * Settings that name a command, and includes (which could bring them in from a file nobody looked at), are not
9
+ * overridden but refused: before each command the repository's own config is read with includes off, and if it sets
10
+ * any of them the command never runs. The refusal is a failed git result, so every caller's existing doubt applies and
11
+ * nothing is removed.
12
+ */
13
+ import { existsSync, readdirSync } from "node:fs";
14
+ import { join, resolve } from "node:path";
15
+ import { run } from "./exec.js";
16
+ import { ENV, ENV_ALLOWLIST } from "./remote-main.js";
17
+ import { WORKTREE_TIMEOUT_MS } from "./worktree.js";
18
+ /** Fixed on every teardown command, ahead of its own arguments. */
19
+ export const TEARDOWN_FLAGS = [
20
+ "--no-optional-locks", "-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "-c", "protocol.ext.allow=never",
21
+ "-c", "credential.helper=", "-c", "gc.auto=0", "-c", "maintenance.auto=false", "-c", "submodule.recurse=false",
22
+ ];
23
+ /** Exit code of a refused command (git's own fatal code, so no caller reads it as "not found" or "no"). */
24
+ export const REFUSED_CODE = 128;
25
+ const REFUSED_PREFIX = "Toolroll won't run git here: ";
26
+ /** Config keys that run a command, bring in unread config, or redirect inspection away from the checked path.
27
+ * `*` is one subsection, any name. */
28
+ const COMMAND_KEYS = [
29
+ "core.sshcommand", "core.gitproxy", "core.askpass", "core.pager", "core.editor", "core.alternaterefscommand",
30
+ "core.worktree", "gpg.ssh.defaultkeycommand", "trailer.*.command", "trailer.*.cmd", "submodule.*.update", "sequence.editor", "diff.external", "credential.helper", "credential.*.helper", "filter.*.clean", "filter.*.smudge", "filter.*.process",
31
+ "diff.*.command", "diff.*.textconv", "merge.*.driver", "gpg.program", "gpg.*.program", "uploadpack.packobjectshook",
32
+ "remote.*.uploadpack", "remote.*.receivepack", "remote.*.vcs", "pager.*", "include.path", "includeif.*.path",
33
+ ].map(key => new RegExp(`^${key.replace(/\./g, "\\.").replace(/\*/g, ".+")}$`));
34
+ /** core.fsmonitor as a boolean is git's own daemon; anything else is a command to run. */
35
+ const BOOLEAN = /^(|true|false|yes|no|on|off|-?\d+)$/i;
36
+ /** The command-running setting a `git config --list --show-scope -z` listing sets, or null. Only local and worktree
37
+ * scope count: teardown commands never read system or global config. */
38
+ export function commandSetting(listing) {
39
+ const fields = listing.split("\0");
40
+ for (let at = 0; at + 1 < fields.length; at += 2) {
41
+ const scope = fields[at];
42
+ const entry = fields[at + 1];
43
+ if (scope !== "local" && scope !== "worktree")
44
+ continue;
45
+ const newline = entry.indexOf("\n");
46
+ const key = (newline < 0 ? entry : entry.slice(0, newline)).toLowerCase();
47
+ const value = newline < 0 ? "" : entry.slice(newline + 1);
48
+ if (key === "core.fsmonitor" ? !BOOLEAN.test(value.trim()) : COMMAND_KEYS.some(pattern => pattern.test(key)))
49
+ return key;
50
+ }
51
+ return null;
52
+ }
53
+ /** Why a teardown command was refused, or null when `result` isn't a refusal. */
54
+ export function refusal(result) {
55
+ return result.code === REFUSED_CODE && result.stderr.startsWith(REFUSED_PREFIX) ? result.stderr.slice(REFUSED_PREFIX.length).trim() : null;
56
+ }
57
+ const refused = (why) => ({ code: REFUSED_CODE, stdout: "", stderr: `${REFUSED_PREFIX}${why}\n`, timedOut: false, notFound: false });
58
+ /** Refuse nested repositories without asking Git to enter them or evaluate their config.
59
+ * Includes ignored/untracked repositories and bare repositories; never follows symlinks. */
60
+ function nestedRepository(path) {
61
+ const pending = [path];
62
+ while (pending.length > 0) {
63
+ const dir = pending.pop();
64
+ const entries = readdirSync(dir, { withFileTypes: true });
65
+ if (dir !== path && (entries.some(entry => entry.name === ".git") ||
66
+ ["HEAD", "objects", "config"].every(name => entries.some(entry => entry.name === name))))
67
+ return true;
68
+ for (const entry of entries)
69
+ if (entry.isDirectory() && entry.name !== ".git")
70
+ pending.push(join(dir, entry.name));
71
+ }
72
+ return false;
73
+ }
74
+ /** The one way teardown runs git, including the target checkout immediately before removal. */
75
+ export function teardownGit(runner = run) {
76
+ const options = (cwd) => ({ cwd, envAllowlist: ENV_ALLOWLIST, env: ENV, timeoutMs: WORKTREE_TIMEOUT_MS, maxBuffer: 512 * 1024 * 1024 });
77
+ const configProblem = async (cwd) => {
78
+ const config = await runner("git", ["--no-optional-locks", "config", "--list", "--no-includes", "--show-scope", "-z"], options(cwd));
79
+ if (config.code !== 0)
80
+ return refused("its config couldn't be read");
81
+ const key = commandSetting(config.stdout);
82
+ return key === null ? null : refused(`the project's git config sets ${key}, which can run a command`);
83
+ };
84
+ return async (cwd, args) => {
85
+ const problem = await configProblem(cwd);
86
+ if (problem !== null)
87
+ return problem;
88
+ const removal = args[0] === "worktree" && args[1] === "remove";
89
+ const inspection = args.includes("status") || args.includes("diff");
90
+ const target = removal ? resolve(cwd, args.at(-1)) : cwd;
91
+ if (removal) {
92
+ const targetProblem = await configProblem(target);
93
+ if (targetProblem !== null)
94
+ return targetProblem;
95
+ }
96
+ if (removal || inspection || args.includes("checkout") || args.includes("clean")) {
97
+ try {
98
+ if (existsSync(target) && nestedRepository(target))
99
+ return refused("the checkout contains a nested repository");
100
+ }
101
+ catch {
102
+ return refused("the checkout couldn't be inspected for nested repositories");
103
+ }
104
+ // ls-files reads index entries only; it cannot run submodule filters. Even an uninitialized
105
+ // gitlink is kept, so worktree remove's internal status can never traverse it.
106
+ const index = await runner("git", [...TEARDOWN_FLAGS, "ls-files", "--stage", "-z"], options(target));
107
+ if (index.code !== 0)
108
+ return refused("the checkout's index couldn't be read");
109
+ if (index.stdout.split("\0").some(entry => entry.startsWith("160000 ")))
110
+ return refused("the checkout contains a gitlink");
111
+ }
112
+ const safeArgs = [...args];
113
+ if (inspection && !safeArgs.includes("--ignore-submodules=all")) {
114
+ const command = safeArgs.findIndex(arg => arg === "status" || arg === "diff");
115
+ safeArgs.splice(command + 1, 0, "--ignore-submodules=all");
116
+ }
117
+ return runner("git", [...TEARDOWN_FLAGS, ...safeArgs], options(cwd));
118
+ };
119
+ }