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
@@ -35,7 +35,7 @@ import { processMayBeAlive } from "./process-liveness.js";
35
35
  import { assertCodingUpdateStopped, backupCodingCatalog, codingCatalogExists, releaseStaleCodingOwner, removeCodingUpdateGate } from "./coding-update.js";
36
36
  import { installLaunchdService, launchdPlist, stopLaunchdService, writeFileDurably } from "./daemon.js";
37
37
  import { NAME } from "./names.js";
38
- import { orphanKey, updateSafeSchema, V117_RENAMED_COLUMNS, V117_RENAMED_TABLES, V118_MERGED_TABLES, V119_ADDED_COLUMNS, V120_ADDED_COLUMNS, V121_ADDED_COLUMNS, V122_ADDED_COLUMNS } from "./store.js";
38
+ import { orphanKey, updateSafeSchema, V117_RENAMED_COLUMNS, V117_RENAMED_TABLES, V118_MERGED_TABLES, V119_ADDED_COLUMNS, V120_ADDED_COLUMNS, V121_ADDED_COLUMNS, V122_ADDED_COLUMNS, V123_ADDED_COLUMNS } from "./store.js";
39
39
  import { isNewer, REGISTRY } from "./releases.js";
40
40
  import { markNeverIndex } from "./never-index.js";
41
41
  import { readRuntimeUpdateJournal, RUNTIME_PHASES, RUNTIME_UPDATE_STEPS, stagedStartedAt, updaterStartingOf } from "./contracts/update-journal.js";
@@ -400,7 +400,8 @@ const HISTORY_RULES = {
400
400
  // saved before them reads none.
401
401
  // v121: live steering's columns and a run's attempt key read none, and no sends, the same way.
402
402
  // v122: a project's recorded remote reads none the same way.
403
- ...addedColumns(V119_ADDED_COLUMNS, V120_ADDED_COLUMNS, V121_ADDED_COLUMNS, V122_ADDED_COLUMNS),
403
+ // v123: a plan authorization's basis reads the operating mode, and a trigger firing reads not on time and no script ran.
404
+ ...addedColumns(V119_ADDED_COLUMNS, V120_ADDED_COLUMNS, V121_ADDED_COLUMNS, V122_ADDED_COLUMNS, V123_ADDED_COLUMNS),
404
405
  };
405
406
  /** The tables a migration may remove whole (v115's removed features). */
406
407
  export const RETIRED_TABLES = Object.freeze(Object.keys(HISTORY_RULES).filter(name => HISTORY_RULES[name].retired));
@@ -2,7 +2,7 @@
2
2
  * entered here and nowhere else, and never shown again. */
3
3
  import { type Html } from "./html.js";
4
4
  import { type FoundTool, type ProjectTool, type ToolSpec } from "./project-tools.js";
5
- export declare const TOOLS_CSS = ".tools{max-width:780px;min-width:0;overflow-wrap:anywhere}.tools .card{padding:16px 18px;margin:12px 0;min-width:0}.tools .card h2{font-size:1.05rem;margin:0;color:var(--so-ink);font-weight:600}.tools .tool-heading{display:flex;align-items:center;gap:10px}.tools .tool-heading h2{flex:1;min-width:0}.tools .tool-state{font-size:.85rem;flex-shrink:0;color:var(--so-muted)}.tools .tool-state[data-ready=\"false\"]{color:var(--so-warning)}.tools .card>p{margin:6px 0 0}.tools .tool-note{color:var(--so-warning)}.tools code{white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word;font-size:.85rem}.tools form{display:grid;gap:10px;margin:0}.tools label{display:grid;gap:6px}.tools input,.tools select{box-sizing:border-box;width:100%;max-width:100%;min-width:0}.tools button{justify-self:start;white-space:nowrap;min-height:44px;max-width:100%;overflow:hidden;text-overflow:ellipsis}.tools details:not(.card){margin:6px 0 0;padding:0;border:0;border-radius:0;background:none;box-shadow:none}.tools details:not(.card)>summary{min-height:36px;display:flex;align-items:center;font-size:.9rem;color:var(--so-muted);cursor:pointer}.tools details:not(.card)[open]>summary{color:var(--so-ink)}.tools details.card>summary{cursor:pointer;min-height:44px;display:flex;align-items:center;font-weight:600}.tools summary{list-style:none;gap:10px}.tools summary::-webkit-details-marker{display:none}.tools summary::before{content:\"\";flex-shrink:0;width:6px;height:6px;margin:0 2px;border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;transform:rotate(-45deg);transition:transform .15s}.tools details[open]>summary::before{transform:rotate(45deg)}.tools details:not(.card) form{padding:4px 0 10px}.tools .tool-facts{margin:0 0 8px;display:grid;gap:4px;font-size:.9rem}.tools .tool-facts p{margin:0}.tools .tool-controls{display:flex;gap:12px;flex-wrap:wrap;margin-top:12px}.tools .tool-found{display:flex;gap:10px;align-items:flex-start}.tools .tool-found input{flex:0 0 auto;width:18px;height:18px;margin-top:3px}.tools .tool-found span{min-width:0}.tools .problem{padding:10px 12px;margin:8px 0 0}.tools .tool-connect{scroll-margin-top:72px}.tools .connect-wanted{background:var(--so-accent);color:var(--so-on-accent);border-color:var(--so-accent)}.tools .connect-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:8px}.tools .connect-tile{display:grid;grid-template-columns:auto 1fr;column-gap:10px;align-items:center;justify-self:stretch;text-align:left;white-space:normal;padding:10px 12px;border:1px solid var(--so-line);border-radius:10px;background:var(--so-paper);color:var(--so-ink);min-height:60px;font:inherit;cursor:pointer;scroll-margin-top:96px}.tools .connect-tile:hover{border-color:var(--so-input-line);background:var(--so-raised)}.tools .connect-tile:target,.tools .connect-tile:focus-visible{outline:2px solid var(--so-accent);outline-offset:2px}.tools .connect-tile .brand-mark{grid-row:span 2}.tools .connect-tile strong{font-weight:600;line-height:1.3}.tools .connect-tile>span:not(.brand-mark){font-size:.8rem;color:var(--so-muted);line-height:1.35;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.tools .connect-tile[data-state=\"connected\"]>span:not(.brand-mark){color:var(--so-success)}.tools .connect-local{grid-column:1/-1}.tools .connect-local>span:not(.brand-mark){-webkit-line-clamp:unset}.tools .connect-problem{margin:0}.tools .connect-problem:focus{outline:2px solid var(--so-accent);outline-offset:2px}.tools .connect-tile:disabled{cursor:default;opacity:.6}@media(max-width:600px){.tools .connect-grid{grid-template-columns:1fr 1fr}.tools .connect-tile:not(.connect-local)>span:not(.brand-mark){display:none}.tools .connect-local>span:not(.brand-mark){grid-column:2}.tools .connect-tile{min-height:52px;padding:8px 10px}.tools .connect-tile .brand-mark{grid-row:auto}.tools input,.tools select{font-size:16px}.tools .card{padding:14px}}";
5
+ export declare const TOOLS_CSS = ".tools{max-width:780px;min-width:0;overflow-wrap:anywhere}.tools .card{padding:16px 18px;margin:12px 0;min-width:0}.tools .card h2{font-size:1.05rem;margin:0;color:var(--so-ink);font-weight:600}.tools .tool-heading{display:flex;align-items:center;gap:10px}.tools .tool-heading h2{flex:1;min-width:0}.tools .tool-state{font-size:.85rem;flex-shrink:0;color:var(--so-muted)}.tools .tool-state[data-ready=\"false\"]{color:var(--so-warning)}.tools .card>p{margin:6px 0 0}.tools .card>p.tool-credential{margin-top:4px;font-size:.875rem;color:var(--so-muted)}.tools .tool-note{color:var(--so-warning)}.tools code{white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word;font-size:.85rem}.tools form{display:grid;gap:10px;margin:0}.tools label{display:grid;gap:6px}.tools input,.tools select{box-sizing:border-box;width:100%;max-width:100%;min-width:0}.tools button{justify-self:start;white-space:nowrap;min-height:44px;max-width:100%;overflow:hidden;text-overflow:ellipsis}.tools details:not(.card){margin:6px 0 0;padding:0;border:0;border-radius:0;background:none;box-shadow:none}.tools details:not(.card)>summary{min-height:36px;display:flex;align-items:center;font-size:.9rem;color:var(--so-muted);cursor:pointer}.tools details:not(.card)[open]>summary{color:var(--so-ink)}.tools details.card>summary{cursor:pointer;min-height:44px;display:flex;align-items:center;font-weight:600}.tools summary{list-style:none;gap:10px}.tools summary::-webkit-details-marker{display:none}.tools summary::before{content:\"\";flex-shrink:0;width:6px;height:6px;margin:0 2px;border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;transform:rotate(-45deg);transition:transform .15s}.tools details[open]>summary::before{transform:rotate(45deg)}.tools details:not(.card) form{padding:4px 0 10px}.tools .tool-facts{margin:0 0 8px;display:grid;gap:4px;font-size:.9rem}.tools .tool-facts p{margin:0}.tools .tool-controls{display:flex;gap:12px;flex-wrap:wrap;margin-top:12px}.tools .tool-found{display:flex;gap:10px;align-items:flex-start}.tools .tool-found input{flex:0 0 auto;width:18px;height:18px;margin-top:3px}.tools .tool-found span{min-width:0}.tools .problem{padding:10px 12px;margin:8px 0 0}.tools .tool-connect{scroll-margin-top:72px}.tools .connect-wanted{background:var(--so-accent);color:var(--so-on-accent);border-color:var(--so-accent)}.tools .connect-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:8px}.tools .connect-tile{display:grid;grid-template-columns:auto 1fr;column-gap:10px;align-items:center;justify-self:stretch;text-align:left;white-space:normal;padding:10px 12px;border:1px solid var(--so-line);border-radius:10px;background:var(--so-paper);color:var(--so-ink);min-height:60px;font:inherit;cursor:pointer;scroll-margin-top:96px}.tools .connect-tile:hover{border-color:var(--so-input-line);background:var(--so-raised)}.tools .connect-tile:target,.tools .connect-tile:focus-visible{outline:2px solid var(--so-accent);outline-offset:2px}.tools .connect-tile .brand-mark{grid-row:span 2}.tools .connect-tile strong{font-weight:600;line-height:1.3}.tools .connect-tile>span:not(.brand-mark){font-size:.8rem;color:var(--so-muted);line-height:1.35;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.tools .connect-tile[data-state=\"connected\"]>span:not(.brand-mark){color:var(--so-success)}.tools .connect-local{grid-column:1/-1}.tools .connect-local>span:not(.brand-mark){-webkit-line-clamp:unset}.tools .connect-problem{margin:0}.tools .connect-problem:focus{outline:2px solid var(--so-accent);outline-offset:2px}.tools .connect-tile:disabled{cursor:default;opacity:.6}@media(max-width:600px){.tools .connect-grid{grid-template-columns:1fr 1fr}.tools .connect-tile:not(.connect-local)>span:not(.brand-mark){display:none}.tools .connect-local>span:not(.brand-mark){grid-column:2}.tools .connect-tile{min-height:52px;padding:8px 10px}.tools .connect-tile .brand-mark{grid-row:auto}.tools input,.tools select{font-size:16px}.tools .card{padding:14px}}";
6
6
  export type ToolsView = {
7
7
  repo: string;
8
8
  project: string;
@@ -26,6 +26,12 @@ export type ToolsView = {
26
26
  label: string;
27
27
  })[];
28
28
  found: FoundTool[];
29
+ /** Where this computer keeps tool secrets (project-tools.ts toolSecretStorage): said plainly when it's files only. */
30
+ storage?: {
31
+ words: string;
32
+ filesOnly: boolean;
33
+ problem?: string;
34
+ };
29
35
  /** The person's other projects and the one-click services still open there: where a connection here can also go. */
30
36
  others?: {
31
37
  repo: string;
package/dist/tools-ui.js CHANGED
@@ -2,9 +2,10 @@
2
2
  * entered here and nowhere else, and never shown again. */
3
3
  import { html, postForm } from "./html.js";
4
4
  import { brandMarkHtml, TILE_PHONE } from "./brand-mark.js";
5
- import { toolCommandLine, toolStanding } from "./project-tools.js";
5
+ import { signedIn } from "./oauth-run-token.js";
6
+ import { credentialModeOf, toolCommandLine, toolStanding } from "./project-tools.js";
6
7
  const hidden = (data) => Object.entries(data).map(([k, v]) => html `<input type="hidden" name="${k}" value="${v}">`);
7
- export const TOOLS_CSS = `.tools{max-width:780px;min-width:0;overflow-wrap:anywhere}.tools .card{padding:16px 18px;margin:12px 0;min-width:0}.tools .card h2{font-size:1.05rem;margin:0;color:var(--so-ink);font-weight:600}.tools .tool-heading{display:flex;align-items:center;gap:10px}.tools .tool-heading h2{flex:1;min-width:0}.tools .tool-state{font-size:.85rem;flex-shrink:0;color:var(--so-muted)}.tools .tool-state[data-ready="false"]{color:var(--so-warning)}.tools .card>p{margin:6px 0 0}.tools .tool-note{color:var(--so-warning)}.tools code{white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word;font-size:.85rem}.tools form{display:grid;gap:10px;margin:0}.tools label{display:grid;gap:6px}.tools input,.tools select{box-sizing:border-box;width:100%;max-width:100%;min-width:0}.tools button{justify-self:start;white-space:nowrap;min-height:44px;max-width:100%;overflow:hidden;text-overflow:ellipsis}.tools details:not(.card){margin:6px 0 0;padding:0;border:0;border-radius:0;background:none;box-shadow:none}.tools details:not(.card)>summary{min-height:36px;display:flex;align-items:center;font-size:.9rem;color:var(--so-muted);cursor:pointer}.tools details:not(.card)[open]>summary{color:var(--so-ink)}.tools details.card>summary{cursor:pointer;min-height:44px;display:flex;align-items:center;font-weight:600}.tools summary{list-style:none;gap:10px}.tools summary::-webkit-details-marker{display:none}.tools summary::before{content:"";flex-shrink:0;width:6px;height:6px;margin:0 2px;border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;transform:rotate(-45deg);transition:transform .15s}.tools details[open]>summary::before{transform:rotate(45deg)}.tools details:not(.card) form{padding:4px 0 10px}.tools .tool-facts{margin:0 0 8px;display:grid;gap:4px;font-size:.9rem}.tools .tool-facts p{margin:0}.tools .tool-controls{display:flex;gap:12px;flex-wrap:wrap;margin-top:12px}.tools .tool-found{display:flex;gap:10px;align-items:flex-start}.tools .tool-found input{flex:0 0 auto;width:18px;height:18px;margin-top:3px}.tools .tool-found span{min-width:0}.tools .problem{padding:10px 12px;margin:8px 0 0}.tools .tool-connect{scroll-margin-top:72px}.tools .connect-wanted{background:var(--so-accent);color:var(--so-on-accent);border-color:var(--so-accent)}.tools .connect-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:8px}.tools .connect-tile{display:grid;grid-template-columns:auto 1fr;column-gap:10px;align-items:center;justify-self:stretch;text-align:left;white-space:normal;padding:10px 12px;border:1px solid var(--so-line);border-radius:10px;background:var(--so-paper);color:var(--so-ink);min-height:60px;font:inherit;cursor:pointer;scroll-margin-top:96px}.tools .connect-tile:hover{border-color:var(--so-input-line);background:var(--so-raised)}.tools .connect-tile:target,.tools .connect-tile:focus-visible{outline:2px solid var(--so-accent);outline-offset:2px}.tools .connect-tile .brand-mark{grid-row:span 2}.tools .connect-tile strong{font-weight:600;line-height:1.3}.tools .connect-tile>span:not(.brand-mark){font-size:.8rem;color:var(--so-muted);line-height:1.35;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.tools .connect-tile[data-state="connected"]>span:not(.brand-mark){color:var(--so-success)}.tools .connect-local{grid-column:1/-1}.tools .connect-local>span:not(.brand-mark){-webkit-line-clamp:unset}.tools .connect-problem{margin:0}.tools .connect-problem:focus{outline:2px solid var(--so-accent);outline-offset:2px}.tools .connect-tile:disabled{cursor:default;opacity:.6}${TILE_PHONE}{.tools .connect-grid{grid-template-columns:1fr 1fr}.tools .connect-tile:not(.connect-local)>span:not(.brand-mark){display:none}.tools .connect-local>span:not(.brand-mark){grid-column:2}.tools .connect-tile{min-height:52px;padding:8px 10px}.tools .connect-tile .brand-mark{grid-row:auto}.tools input,.tools select{font-size:16px}.tools .card{padding:14px}}`;
8
+ export const TOOLS_CSS = `.tools{max-width:780px;min-width:0;overflow-wrap:anywhere}.tools .card{padding:16px 18px;margin:12px 0;min-width:0}.tools .card h2{font-size:1.05rem;margin:0;color:var(--so-ink);font-weight:600}.tools .tool-heading{display:flex;align-items:center;gap:10px}.tools .tool-heading h2{flex:1;min-width:0}.tools .tool-state{font-size:.85rem;flex-shrink:0;color:var(--so-muted)}.tools .tool-state[data-ready="false"]{color:var(--so-warning)}.tools .card>p{margin:6px 0 0}.tools .card>p.tool-credential{margin-top:4px;font-size:.875rem;color:var(--so-muted)}.tools .tool-note{color:var(--so-warning)}.tools code{white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word;font-size:.85rem}.tools form{display:grid;gap:10px;margin:0}.tools label{display:grid;gap:6px}.tools input,.tools select{box-sizing:border-box;width:100%;max-width:100%;min-width:0}.tools button{justify-self:start;white-space:nowrap;min-height:44px;max-width:100%;overflow:hidden;text-overflow:ellipsis}.tools details:not(.card){margin:6px 0 0;padding:0;border:0;border-radius:0;background:none;box-shadow:none}.tools details:not(.card)>summary{min-height:36px;display:flex;align-items:center;font-size:.9rem;color:var(--so-muted);cursor:pointer}.tools details:not(.card)[open]>summary{color:var(--so-ink)}.tools details.card>summary{cursor:pointer;min-height:44px;display:flex;align-items:center;font-weight:600}.tools summary{list-style:none;gap:10px}.tools summary::-webkit-details-marker{display:none}.tools summary::before{content:"";flex-shrink:0;width:6px;height:6px;margin:0 2px;border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;transform:rotate(-45deg);transition:transform .15s}.tools details[open]>summary::before{transform:rotate(45deg)}.tools details:not(.card) form{padding:4px 0 10px}.tools .tool-facts{margin:0 0 8px;display:grid;gap:4px;font-size:.9rem}.tools .tool-facts p{margin:0}.tools .tool-controls{display:flex;gap:12px;flex-wrap:wrap;margin-top:12px}.tools .tool-found{display:flex;gap:10px;align-items:flex-start}.tools .tool-found input{flex:0 0 auto;width:18px;height:18px;margin-top:3px}.tools .tool-found span{min-width:0}.tools .problem{padding:10px 12px;margin:8px 0 0}.tools .tool-connect{scroll-margin-top:72px}.tools .connect-wanted{background:var(--so-accent);color:var(--so-on-accent);border-color:var(--so-accent)}.tools .connect-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:8px}.tools .connect-tile{display:grid;grid-template-columns:auto 1fr;column-gap:10px;align-items:center;justify-self:stretch;text-align:left;white-space:normal;padding:10px 12px;border:1px solid var(--so-line);border-radius:10px;background:var(--so-paper);color:var(--so-ink);min-height:60px;font:inherit;cursor:pointer;scroll-margin-top:96px}.tools .connect-tile:hover{border-color:var(--so-input-line);background:var(--so-raised)}.tools .connect-tile:target,.tools .connect-tile:focus-visible{outline:2px solid var(--so-accent);outline-offset:2px}.tools .connect-tile .brand-mark{grid-row:span 2}.tools .connect-tile strong{font-weight:600;line-height:1.3}.tools .connect-tile>span:not(.brand-mark){font-size:.8rem;color:var(--so-muted);line-height:1.35;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.tools .connect-tile[data-state="connected"]>span:not(.brand-mark){color:var(--so-success)}.tools .connect-local{grid-column:1/-1}.tools .connect-local>span:not(.brand-mark){-webkit-line-clamp:unset}.tools .connect-problem{margin:0}.tools .connect-problem:focus{outline:2px solid var(--so-accent);outline-offset:2px}.tools .connect-tile:disabled{cursor:default;opacity:.6}${TILE_PHONE}{.tools .connect-grid{grid-template-columns:1fr 1fr}.tools .connect-tile:not(.connect-local)>span:not(.brand-mark){display:none}.tools .connect-local>span:not(.brand-mark){grid-column:2}.tools .connect-tile{min-height:52px;padding:8px 10px}.tools .connect-tile .brand-mark{grid-row:auto}.tools input,.tools select{font-size:16px}.tools .card{padding:14px}}`;
8
9
  /** The project chooser: it opens the chosen project at once; without scripts, its button does. */
9
10
  export function toolsProjectPicker(projects, chosen, keep = {}) {
10
11
  if (projects.length < 2)
@@ -18,19 +19,23 @@ const password = html `<label>Your Toolroll password<input type="password" name=
18
19
  function toolCard(one, base, manage, project, research = null) {
19
20
  const { tool, secretsSet } = one;
20
21
  const standing = toolStanding(tool, secretsSet);
21
- // One line per secret: the disclosure is both where it stands and where it is set.
22
- const secrets = manage ? tool.spec.secrets.map(secret => {
22
+ // How its runs get credentials, in one line; what that means is in Details.
23
+ const mode = credentialModeOf(tool.spec, secretsSet);
24
+ const credential = mode === null ? "" : html `<p class="tool-credential" data-credential="${mode.mode}">${mode.words}</p>`;
25
+ // One line per secret: the disclosure is both where it stands and where it is set. A service connected by signing
26
+ // in gets its tokens only by signing in again (its Connect tile): no token of it is ever pasted here.
27
+ const secrets = manage && !signedIn(tool.spec) ? tool.spec.secrets.map(secret => {
23
28
  const set = secretsSet.includes(secret.name);
24
29
  return html `<details><summary>${set ? `Replace ${secret.name}` : `Set ${secret.name}${secret.optional ? " (optional)" : ""}`}</summary>${postForm("/settings/tools/change", html `<label>${secret.name}<input type="password" name="value" autocomplete="off" required></label>${password}<button>Save</button>`, { hidden: { ...base, action: "secret", name: tool.name, secret: secret.name } })}</details>`;
25
30
  }) : "";
26
31
  const tested = tool.lastTest?.ok ? html `<p>Tested ${tool.lastTest.at.slice(0, 16).replace("T", " ")} UTC: ${tool.lastTest.tools.join(", ") || "it lists no tools"}</p>` : "";
27
- const details = html `<details><summary>Details</summary><div class="tool-facts"><p>Starts: <code>${toolCommandLine(tool.spec)}</code></p><p>From ${tool.source} · added by ${tool.createdBy}</p>${tested}</div></details>`;
32
+ const details = html `<details><summary>Details</summary><div class="tool-facts">${mode?.detail == null ? "" : html `<p>${mode.detail}</p>`}<p>Starts: <code>${toolCommandLine(tool.spec)}</code></p><p>From ${tool.source} · added by ${tool.createdBy}</p>${tested}</div></details>`;
28
33
  const failed = tool.lastTest !== null && !tool.lastTest.ok ? html `<p class="problem" role="status">${tool.lastTest.problem ?? "The last test failed."}</p>` : "";
29
34
  // Connected, but research can't use it (its grant writes): said once, with the one way to fix it.
30
35
  const reconnect = research === null || !manage ? "" : html `<details><summary>Reconnect read-only</summary>${postForm("/settings/tools/connect", html `${password}<button>Reconnect read-only</button>`, { hidden: { ...base, service: tool.name, access: "read" } })}</details>`;
31
36
  const limited = research === null ? "" : html `<p class="tool-note" role="status">${research}</p>${reconnect}`;
32
37
  const controls = manage ? html `<div class="tool-controls">${postForm("/settings/tools/change", html `<button aria-label="Test ${tool.name} in ${project}">Test</button>`, { hidden: { ...base, action: "test", name: tool.name } })}${postForm("/settings/tools/change", html `<button class="secondary">Remove from ${project}</button>`, { hidden: { ...base, action: "remove", name: tool.name } })}</div>` : "";
33
- return html `<article class="card" id="tool-${tool.name}"><div class="tool-heading">${brandMarkHtml(tool.source === "custom" ? null : tool.name, tool.name, standing.ready)}<h2>${tool.name}</h2><span class="tool-state" data-ready="${String(standing.ready)}">${standing.words}</span></div><p>${tool.spec.about}</p>${failed}${limited}${secrets}${details}${controls}</article>`;
38
+ return html `<article class="card" id="tool-${tool.name}"><div class="tool-heading">${brandMarkHtml(tool.source === "custom" ? null : tool.name, tool.name, standing.ready)}<h2>${tool.name}</h2><span class="tool-state" data-ready="${String(standing.ready)}">${standing.words}</span></div><p>${tool.spec.about}</p>${credential}${failed}${limited}${secrets}${details}${controls}</article>`;
34
39
  }
35
40
  /** A service connected here, offered to the person's other projects: each button signs in again for that project (tokens are never copied). */
36
41
  function elsewhere(view, base) {
@@ -44,7 +49,11 @@ export function toolsHtml(view, csrf, canManage, message = {}) {
44
49
  // `shown` is the project this page shows; the server refuses a post whose repo differs from it.
45
50
  const base = { repo: view.repo, shown: view.repo };
46
51
  const to = ` to ${view.project}`;
47
- const intro = html `<p>Builds in ${view.project} use exactly these tools. MCP servers set up elsewhere on this computer aren't used.</p><details><summary>How tools work</summary><ul><li>A tool you add reaches work you approve from now on. Approve a task again to give it a new tool.</li><li>Removing a tool takes it away from every build right away.</li><li>Secrets stay on this computer, are never shown again, and go only to the tool that needs them.</li><li>Reviewers and the lead chat never get tools. Gemini builds don't use tools yet.</li></ul></details>`;
52
+ const keptIn = view.storage === undefined || view.storage.filesOnly ? "Secrets stay on this computer" : view.storage.words.replace(/\.$/, "");
53
+ // No keychain here: said once, plainly, where it can't be missed.
54
+ const storage = view.storage?.problem !== undefined ? html `<p class="problem tool-storage" role="alert">${view.storage.problem}</p>`
55
+ : view.storage?.filesOnly === true ? html `<p class="tool-storage" role="note">${view.storage.words}</p>` : "";
56
+ const intro = html `<p>Builds in ${view.project} use exactly these tools. MCP servers set up elsewhere on this computer aren't used.</p><details><summary>How tools work</summary><ul><li>A tool you add reaches work you approve from now on. Approve a task again to give it a new tool.</li><li>Removing a tool takes it away from every build right away.</li><li>${keptIn}, are never shown again, and go only to the tool that needs them.</li><li>Reviewers and the lead chat never get tools. Gemini builds don't use tools yet.</li></ul></details>`;
48
57
  const cards = view.tools.length === 0 ? html `<p class="meta">No tools yet. Builds in this project get none.</p>` : view.tools.map(one => toolCard(one, base, manage, view.project, view.connections.find(c => c.id === one.tool.name && c.state === "connected")?.research ?? null));
49
58
  const fromList = view.catalog.length === 0 ? "" : postForm("/settings/tools/change", html `<label>Common tools<select name="catalog">${view.catalog.map(one => html `<option value="${one.name}">${one.label}: ${one.about}</option>`)}</select></label>${password}<button>Add${to}</button>`, { hidden: { ...base, action: "add-catalog" } });
50
59
  const custom = html `<details><summary>Add your own</summary>${postForm("/settings/tools/change", html `<label>Name<input name="name" pattern="[a-z0-9][a-z0-9_\\-]{0,39}" maxlength="40" required placeholder="for example: postgres"></label><label>It runs as<select name="transport"><option value="stdio">A program on this computer</option><option value="http">A web address</option></select></label><label>Command or address<input name="target" required placeholder="npx -y some-mcp-server@1.2.3, or https://…"></label><label>Secrets it needs (names, comma separated)<input name="secrets" placeholder="for example: DATABASE_URL"></label><p class="meta">For a web address, the first secret is sent as its sign-in token. Set each secret's value after adding.</p>${password}<button>Add${to}</button>`, { hidden: { ...base, action: "add-custom" } })}</details>`;
@@ -62,5 +71,5 @@ export function toolsHtml(view, csrf, canManage, message = {}) {
62
71
  const add = manage ? html `<details class="card"${view.tools.length === 0 ? html ` open` : ""}><summary>Add a tool</summary>${fromList}${custom}</details>` : "";
63
72
  const found = manage && view.found.length > 0 ? html `<details class="card"><summary>Found on this computer (${view.found.length})</summary><p class="meta">MCP servers your other apps use. Adding one copies its settings, and any secrets it has, into this project.</p>${postForm("/settings/tools/change", html `${view.found.map(one => html `<label class="tool-found"><input type="checkbox" name="import" value="${one.spec.name}"><span><strong>${one.spec.name}</strong> · ${one.source}<br><code>${toolCommandLine(one.spec)}</code>${one.spec.secrets.length === 0 ? "" : html `<br><span class="meta">Brings ${one.spec.secrets.map(s => s.name).join(", ")}</span>`}</span></label>`)}${password}<button>Add selected${to}</button>`, { hidden: { ...base, action: "import" } })}</details>` : "";
64
73
  const note = atButton ? "" : message.problem ? html `<p class="problem" role="alert">${message.problem}</p>` : message.said ? html `<p role="status">${message.said}</p>` : "";
65
- return html `<section class="tools">${note}${intro}${cards}${connect}${add}${found}</section>`;
74
+ return html `<section class="tools">${note}${intro}${storage}${cards}${connect}${add}${found}</section>`;
66
75
  }
@@ -5,7 +5,7 @@ import { leadNameOf } from './lead-identity.js';
5
5
  import { createHash } from 'node:crypto';
6
6
  import { existsSync } from 'node:fs';
7
7
  import { join } from 'node:path';
8
- import { readProjectAccess } from './project-access.js';
8
+ import { accountReachesSql, readProjectAccess } from './project-access.js';
9
9
  import { pauseOnTaskProviders, providerName, signInReason } from './provider-auth.js';
10
10
  import { scopeTermsProblem, stopFactOf } from './store.js';
11
11
  import { assignmentChecksForRun } from './assignment.js';
@@ -19,6 +19,7 @@ import { withFollowUps } from './result-follow-ups.js';
19
19
  import { LANDED_ACTOR, landedReasonOf } from './result-completion.js';
20
20
  import { ASKS, NEED_ASK, NEEDS, askChipOf, failedAttemptSentence, processNeedOf, resultHoldUpSentence } from './needs-you.js';
21
21
  import { LEAD_CLAIM_SQL, LEAD_IDLE_MS } from './lead-voice.js';
22
+ import { grantStandsSql } from './flow-unattended.js';
22
23
  export const WORK_INDEX_PAGE_LIMIT = 40;
23
24
  export const WORK_INDEX_MAX_LIMIT = 100;
24
25
  const EMPTY = { all: 0, 'needs-you': 0, running: 0, completed: 0 };
@@ -134,9 +135,10 @@ const PROJECTION = `WITH RECURSIVE admitted AS MATERIALIZED (
134
135
  UNION ALL SELECT json_extract(leg.value,'$.provider') FROM json_each(CASE WHEN json_valid(sc.approved_route_json) THEN sc.approved_route_json ELSE '{}' END,'$.legs') leg
135
136
  WHERE json_extract(leg.value,'$.phase')='build'))) signed_out,
136
137
  (sc.approved_at IS NOT NULL AND sc.approved_by IS NOT NULL AND sc.approved_digest=sc.digest) approved,
137
- (sc.approval_basis IS NOT 'mode' OR EXISTS(SELECT 1 FROM operating_mode om JOIN approver signer ON signer.name=om.signed_by
138
+ ((sc.approval_basis IS NOT 'mode' AND sc.approval_basis IS NOT 'grant') OR (sc.approval_basis='mode' AND EXISTS(SELECT 1 FROM operating_mode om JOIN approver signer ON signer.name=om.signed_by
138
139
  WHERE om.repo=c.repo AND om.digest=sc.mode_digest AND om.revoked_at IS NULL AND om.absolute_expiry>$now
139
- AND signer.role='approver' AND signer.revoked_at IS NULL)) dispatch_approval,
140
+ AND signer.role='approver' AND signer.revoked_at IS NULL AND ${accountReachesSql("signer", "c.repo")}))
141
+ OR (sc.approval_basis='grant' AND ${grantStandsSql("sc.mode_digest", "$now", "c.repo")})) dispatch_approval,
140
142
  CASE WHEN sc.task_id IS NULL THEN 0 ELSE so_work_scope_valid(json_object(
141
143
  'touches',sc.touches,'acceptance_json',sc.acceptance_json,'budget_microusd',sc.budget_microusd,
142
144
  'goal',CASE WHEN typeof(sc.goal)='text' THEN '' ELSE sc.goal END,
@@ -24,6 +24,7 @@
24
24
  */
25
25
  import { type ExecResult, type RunOptions } from "./exec.js";
26
26
  import type { Store, WorktreeRow } from "./store.js";
27
+ import { type Git } from "./teardown-git.js";
27
28
  export type Runner = (file: string, args: readonly string[], options?: RunOptions) => Promise<ExecResult>;
28
29
  export type PoolOptions = {
29
30
  /** Where worktrees are created. Outside the repo, and outside any sync root. */
@@ -123,6 +124,8 @@ export declare const WORKTREE_TIMEOUT_MS = 60000;
123
124
  * the pool root through its own slashes.
124
125
  */
125
126
  export declare function worktreePath(root: string, repo: string, branch: string): string;
127
+ /** The pool root a checkout at `path` of `repo` was made under (worktreePath's layout), or null for one it didn't make. */
128
+ export declare function poolRootOf(path: string, repo: string): string | null;
126
129
  /** Record a potentially writing subprocess against the original checkout lease. */
127
130
  export declare function recordWorktreeProcess(store: Store, path: string, runner: string, pid: number, leaseEpoch?: string | null): void;
128
131
  /** Read-only occupancy witness shared with stop settlement. */
@@ -281,17 +284,18 @@ export declare class WorktreePool {
281
284
  /**
282
285
  * Whether a let-go checkout could go without losing anything: clean (build output that .gitignore hides is not
283
286
  * work, and neither are the files Toolroll itself wrote in there), and everything its HEAD reaches on a branch, tag
284
- * or remote. Reads only.
287
+ * or remote. Reads only, through `git` when given (teardown's hardened git).
285
288
  */
286
- inspect(path: string): Promise<"clean" | KeptWhy>;
289
+ inspect(path: string, git?: Git): Promise<"clean" | KeptWhy>;
287
290
  /**
288
291
  * Keep storage in check: remove the working copies nobody needs any more. Of `rows`, a checkout goes when it is
289
292
  * let go, nothing holds it, `wanted` says it may go (asked again right before it goes, so a task coming back or a
290
293
  * lease since keeps it) and `inspect` finds it clean, then clean still right before it goes (git refuses an edit
291
294
  * made after that). Only the working copy goes: its branch, and so every commit,
292
- * stays, and a later lease of the same branch makes a new one. At most `max` go per pass.
295
+ * stays, and a later lease of the same branch makes a new one. At most `max` go per pass. Every git command goes
296
+ * through `git` when given: landed cleanup passes its hardened teardown git (teardown-git.ts).
293
297
  */
294
- prune(rows: readonly WorktreeRow[], wanted: (row: WorktreeRow, fresh: boolean) => boolean | Promise<boolean>, max?: number): Promise<{
298
+ prune(rows: readonly WorktreeRow[], wanted: (row: WorktreeRow, fresh: boolean) => boolean | Promise<boolean>, max?: number, git?: Git): Promise<{
295
299
  removed: WorktreeRow[];
296
300
  kept: {
297
301
  path: string;
@@ -342,5 +346,7 @@ export declare class WorktreePool {
342
346
  * hides is not work, but a file somebody dropped in and never staged is.
343
347
  */
344
348
  private isDirty;
349
+ /** All disposal and inspection paths share the hardened teardown boundary. */
350
+ private readonly teardown;
345
351
  private git;
346
352
  }
package/dist/worktree.js CHANGED
@@ -23,15 +23,16 @@
23
23
  * over `.git` internals corrupts both.
24
24
  */
25
25
  import { createHash, randomBytes } from "node:crypto";
26
- import { existsSync, readFileSync, writeFileSync, rmSync, realpathSync, mkdirSync, readdirSync, renameSync } from "node:fs";
26
+ import { existsSync, lstatSync, readFileSync, writeFileSync, rmSync, realpathSync, mkdirSync, readdirSync, renameSync } from "node:fs";
27
27
  import { hostname } from "node:os";
28
- import { dirname, join } from "node:path";
28
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
29
29
  import { run } from "./exec.js";
30
30
  import { currentBootId, provenDeadByBootChange } from "./boot-identity.js";
31
31
  import { HANDOFF_PREFIX, MAILBOX_SUFFIX, looksLikeProtocolFile, readMailbox } from "./evidence.js";
32
32
  import { parseHandoff } from "./decision.js";
33
33
  import { diskBytes } from "./storage.js";
34
34
  import { markNeverIndex } from "./never-index.js";
35
+ import { teardownGit } from "./teardown-git.js";
35
36
  /**
36
37
  * The note a lease leaves in the checkout, naming the process holding it.
37
38
  *
@@ -129,6 +130,31 @@ export function worktreePath(root, repo, branch) {
129
130
  .slice(0, 8);
130
131
  return join(root, safeSegment(basenameOf(repo)), `${safeSegment(branch)}-${digest}`);
131
132
  }
133
+ /** The pool root a checkout at `path` of `repo` was made under (worktreePath's layout), or null for one it didn't make. */
134
+ export function poolRootOf(path, repo) {
135
+ const project = dirname(path);
136
+ return basename(project) === safeSegment(basenameOf(repo)) && /-[0-9a-f]{8}$/.test(basename(path)) ? dirname(project) : null;
137
+ }
138
+ /** Inspect names, not their destinations, before the plane writes into a pool. */
139
+ function safePoolPath(root, target) {
140
+ const base = resolve(root);
141
+ const rel = relative(base, resolve(target));
142
+ if (rel.startsWith("..") || rel.startsWith(sep))
143
+ return false;
144
+ let path = base;
145
+ for (const component of ["", ...rel.split(sep)]) {
146
+ path = join(path, component);
147
+ try {
148
+ if (!lstatSync(path).isDirectory())
149
+ return false;
150
+ }
151
+ catch (error) {
152
+ if (error.code !== "ENOENT")
153
+ return false;
154
+ }
155
+ }
156
+ return true;
157
+ }
132
158
  function normalisePath(path) {
133
159
  return path.replace(/\\/g, "/").replace(/\/+$/, "");
134
160
  }
@@ -210,20 +236,21 @@ export function worktreeProcessOccupancy(path) {
210
236
  * reset or clean a tree saves this first.
211
237
  */
212
238
  export async function saveWorkPatch(runner, path, file, header) {
213
- const status = await runner(GIT, [...READ_ONLY, "status", "--porcelain", "--untracked-files=all"], { cwd: path, timeoutMs: WORKTREE_TIMEOUT_MS });
239
+ const git = teardownGit(runner);
240
+ const status = await git(path, [...READ_ONLY, "status", "--porcelain", "--untracked-files=all", "--ignore-submodules=all"]);
214
241
  if (status.code !== 0)
215
242
  return { ok: false, message: `${path} could not be inspected before its work was saved` };
216
243
  const untracked = status.stdout
217
244
  .split("\n")
218
245
  .filter(line => line.startsWith("?? ") && !line.trimEnd().endsWith(MARKER))
219
246
  .map(line => line.slice(3).trim());
220
- const tracked = await runner(GIT, [...READ_ONLY, "diff", "--binary", "HEAD"], { cwd: path, timeoutMs: WORKTREE_TIMEOUT_MS });
247
+ const tracked = await git(path, [...READ_ONLY, "diff", "--binary", "--ignore-submodules=all", "HEAD"]);
221
248
  if (tracked.code !== 0)
222
249
  return { ok: false, message: `${path}: git diff failed while saving its work (${firstLine(tracked.stderr)})` };
223
250
  const parts = [tracked.stdout];
224
251
  for (const one of untracked) {
225
252
  // --no-index exits 1 when the sides differ; that is the expected answer.
226
- const diff = await runner(GIT, [...READ_ONLY, "diff", "--binary", "--no-index", "--", "/dev/null", one], { cwd: path, timeoutMs: WORKTREE_TIMEOUT_MS });
253
+ const diff = await git(path, [...READ_ONLY, "diff", "--binary", "--no-index", "--ignore-submodules=all", "--", "/dev/null", one]);
227
254
  if (diff.code > 1)
228
255
  return { ok: false, message: `${path}: ${one} could not be captured (${firstLine(diff.stderr)})` };
229
256
  parts.push(diff.stdout);
@@ -304,6 +331,8 @@ export class WorktreePool {
304
331
  }
305
332
  async lease(request) {
306
333
  const target = worktreePath(this.options.root, request.repo, request.branch);
334
+ if (!safePoolPath(this.options.root, target))
335
+ return { ok: false, reason: "git", message: "The checkout pool contains a symlink or an unreadable folder." };
307
336
  if (busy.has(target))
308
337
  return { ok: false, reason: "in-use", message: `${target} is busy; try again in a moment` };
309
338
  const release = reserveCheckout(this.store, target);
@@ -331,6 +360,9 @@ export class WorktreePool {
331
360
  };
332
361
  }
333
362
  const path = worktreePath(this.options.root, request.repo, request.branch);
363
+ const unsafe = () => ({ ok: false, reason: "git", message: "The checkout pool contains a symlink or an unreadable folder." });
364
+ if (!safePoolPath(this.options.root, path))
365
+ return unsafe();
334
366
  const existing = this.store.getWorktree(path);
335
367
  // The machine outranks the database here. A row can say "released" while a
336
368
  // process that never got to write its own ending is still working in the
@@ -408,10 +440,14 @@ export class WorktreePool {
408
440
  const fromBase = request.base !== undefined && !branchExists;
409
441
  // Spotlight leaves the checkouts alone: the marker sits in the pool and the project's folder, never inside a
410
442
  // checkout, where a build's `git add -A` would commit it (never-index.ts).
443
+ if (!safePoolPath(this.options.root, path))
444
+ return unsafe();
411
445
  try {
412
446
  mkdirSync(dirname(path), { recursive: true });
413
447
  }
414
- catch { /* git reports it */ }
448
+ catch {
449
+ return unsafe();
450
+ }
415
451
  markNeverIndex(this.options.root);
416
452
  markNeverIndex(dirname(path));
417
453
  // A new branch is created first, on its own, so its reflog's first entry says Toolroll made it and when.
@@ -426,10 +462,12 @@ export class WorktreePool {
426
462
  return { ok: false, reason: "git", message: firstLine(branch.stderr) };
427
463
  made = oid;
428
464
  }
465
+ if (!safePoolPath(this.options.root, path))
466
+ return unsafe();
429
467
  const add = await this.git(request.repo, ["worktree", "add", path, request.branch]);
430
468
  if (add.code !== 0) {
431
469
  if (made !== null)
432
- await this.git(request.repo, ["update-ref", "-d", `refs/heads/${request.branch}`, made]);
470
+ await this.teardown(request.repo, ["update-ref", "-d", `refs/heads/${request.branch}`, made]);
433
471
  return { ok: false, reason: "git", message: firstLine(add.stderr) };
434
472
  }
435
473
  }
@@ -592,10 +630,10 @@ export class WorktreePool {
592
630
  }
593
631
  /** Back to HEAD, untracked gone, our lease note kept; proven clean after. */
594
632
  async resetTree(path) {
595
- const checkout = await this.runner(GIT, ["checkout", "--", "."], { cwd: path, timeoutMs: WORKTREE_TIMEOUT_MS });
633
+ const checkout = await this.teardown(path, ["checkout", "--", "."]);
596
634
  if (checkout.code !== 0)
597
635
  return { ok: false, message: `${path}: git checkout failed while resetting (${firstLine(checkout.stderr)})` };
598
- const clean = await this.runner(GIT, ["clean", "-fd", "-e", MARKER], { cwd: path, timeoutMs: WORKTREE_TIMEOUT_MS });
636
+ const clean = await this.teardown(path, ["clean", "-fd", "-e", MARKER]);
599
637
  if (clean.code !== 0)
600
638
  return { ok: false, message: `${path}: git clean failed while resetting (${firstLine(clean.stderr)})` };
601
639
  const dirty = await this.isDirty(path);
@@ -683,17 +721,17 @@ export class WorktreePool {
683
721
  const occupied = this.inUse(path);
684
722
  if (occupied.held)
685
723
  return { ok: false, message: `${path} is being used by process ${occupied.by} — leaving it alone` };
686
- const removed = await this.git(existing.repo, ["worktree", "remove", "--force", path]);
724
+ const removed = await this.teardown(existing.repo, ["worktree", "remove", "--force", path]);
687
725
  if (removed.code !== 0)
688
726
  return { ok: false, message: `git worktree remove: ${removed.stderr.trim() || `exit ${removed.code}`}` };
689
727
  // The branch is disposable too; a failure here is bookkeeping, not custody.
690
- await this.git(existing.repo, ["branch", "-D", existing.branch]);
728
+ await this.teardown(existing.repo, ["branch", "-D", existing.branch]);
691
729
  this.store.forgetWorktree(path);
692
730
  void now;
693
731
  return { ok: true };
694
732
  }
695
733
  async orphans(repo) {
696
- const listed = await this.git(repo, ["worktree", "list", "--porcelain"], READ_ONLY);
734
+ const listed = await this.teardown(repo, ["worktree", "list", "--porcelain"]);
697
735
  // A listing that failed is not a listing that came back empty. Treating
698
736
  // it as empty would report every stored row as missing — and anything
699
737
  // acting on that answer would erase real bookkeeping over a git hiccup.
@@ -790,14 +828,14 @@ export class WorktreePool {
790
828
  /**
791
829
  * Whether a let-go checkout could go without losing anything: clean (build output that .gitignore hides is not
792
830
  * work, and neither are the files Toolroll itself wrote in there), and everything its HEAD reaches on a branch, tag
793
- * or remote. Reads only.
831
+ * or remote. Reads only, through `git` when given (teardown's hardened git).
794
832
  */
795
- async inspect(path) {
796
- const dirty = await this.isDirty(path, true);
833
+ async inspect(path, git = this.teardown) {
834
+ const dirty = await this.isDirty(path, true, git);
797
835
  if (dirty !== false)
798
836
  return dirty === null ? "unreadable" : "has changes";
799
837
  // Commits only this checkout's HEAD reaches (a detached HEAD moved on) exist nowhere else.
800
- const own = await this.runner(GIT, [...READ_ONLY, "rev-list", "-1", "HEAD", "--not", "--branches", "--tags", "--remotes"], { cwd: path, timeoutMs: WORKTREE_TIMEOUT_MS });
838
+ const own = await git(path, [...READ_ONLY, "rev-list", "-1", "HEAD", "--not", "--branches", "--tags", "--remotes"]);
801
839
  if (own.code !== 0)
802
840
  return "unreadable";
803
841
  return own.stdout.trim() === "" ? "clean" : "has commits";
@@ -807,9 +845,10 @@ export class WorktreePool {
807
845
  * let go, nothing holds it, `wanted` says it may go (asked again right before it goes, so a task coming back or a
808
846
  * lease since keeps it) and `inspect` finds it clean, then clean still right before it goes (git refuses an edit
809
847
  * made after that). Only the working copy goes: its branch, and so every commit,
810
- * stays, and a later lease of the same branch makes a new one. At most `max` go per pass.
848
+ * stays, and a later lease of the same branch makes a new one. At most `max` go per pass. Every git command goes
849
+ * through `git` when given: landed cleanup passes its hardened teardown git (teardown-git.ts).
811
850
  */
812
- async prune(rows, wanted, max = Infinity) {
851
+ async prune(rows, wanted, max = Infinity, git = this.teardown) {
813
852
  const removed = [];
814
853
  const kept = [];
815
854
  const idle = (row, releasedAt) => row !== null && row.runner === null && row.releasedAt === releasedAt && !this.inUse(row.path).held;
@@ -823,7 +862,7 @@ export class WorktreePool {
823
862
  continue;
824
863
  if (!existsSync(row.path) || !idle(row, row.releasedAt))
825
864
  continue;
826
- const found = await this.inspect(row.path);
865
+ const found = await this.inspect(row.path, git);
827
866
  if (found !== "clean") {
828
867
  kept.push({ path: row.path, why: found });
829
868
  continue;
@@ -843,7 +882,7 @@ export class WorktreePool {
843
882
  const allowed = wanted(now, true);
844
883
  if (!(typeof allowed === "boolean" ? allowed : await allowed))
845
884
  continue;
846
- const gone = await this.removeCleanCopy(row);
885
+ const gone = await this.removeCleanCopy(row, git);
847
886
  if (gone !== "removed") {
848
887
  kept.push({ path: row.path, why: gone });
849
888
  continue;
@@ -867,7 +906,7 @@ export class WorktreePool {
867
906
  const idle = (now) => now !== null && now.runner === null && now.releasedAt !== null && now.releasedAt === row.releasedAt && !this.inUse(row.path).held;
868
907
  if (busy.has(row.path) || !existsSync(row.path) || !idle(this.store.getWorktree(row.path)))
869
908
  return { ok: false, message: `${row.path} is in use` };
870
- const listed = await this.runner(GIT, [...READ_ONLY, "ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z"], { cwd: row.path, timeoutMs: WORKTREE_TIMEOUT_MS });
909
+ const listed = await this.teardown(row.path, [...READ_ONLY, "ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z"]);
871
910
  if (listed.code !== 0)
872
911
  return { ok: false, message: `git couldn't list ${row.path}` };
873
912
  const drop = listed.stdout.split("\0").filter(one => one.endsWith("/")).map(one => one.slice(0, -1))
@@ -949,7 +988,7 @@ export class WorktreePool {
949
988
  catch {
950
989
  return false;
951
990
  }
952
- const gone = await this.git(row.repo, ["worktree", "remove", "--force", row.path]);
991
+ const gone = await this.teardown(row.repo, ["worktree", "remove", "--force", row.path]);
953
992
  if (gone.code !== 0) {
954
993
  rmSync(note, { force: true });
955
994
  return false;
@@ -963,11 +1002,11 @@ export class WorktreePool {
963
1002
  * removed without --force, so git itself refuses an edit made since. Only Toolroll's own files go first, because
964
1003
  * git's check would count them.
965
1004
  */
966
- async removeCleanCopy(row) {
967
- const found = await this.inspect(row.path);
1005
+ async removeCleanCopy(row, git) {
1006
+ const found = await this.inspect(row.path, git);
968
1007
  if (found !== "clean")
969
1008
  return found;
970
- const status = await this.runner(GIT, [...READ_ONLY, "status", "--porcelain", "--untracked-files=all"], { cwd: row.path, timeoutMs: WORKTREE_TIMEOUT_MS });
1009
+ const status = await git(row.path, [...READ_ONLY, "status", "--porcelain", "--untracked-files=all", "--ignore-submodules=all"]);
971
1010
  if (status.code !== 0)
972
1011
  return "unreadable";
973
1012
  for (const line of status.stdout.split("\n")) {
@@ -975,9 +1014,9 @@ export class WorktreePool {
975
1014
  if (name === MARKER || (name !== "" && looksLikeProtocolFile(name)))
976
1015
  rmSync(join(row.path, name), { recursive: true, force: true });
977
1016
  }
978
- const gone = await this.git(row.repo, ["worktree", "remove", row.path]);
1017
+ const gone = await git(row.repo, ["worktree", "remove", row.path]);
979
1018
  if (gone.code !== 0)
980
- return (await this.isDirty(row.path)) === false ? "git refused" : "has changes";
1019
+ return (await this.isDirty(row.path, false, git)) === false ? "git refused" : "has changes";
981
1020
  this.forgetRemoved(row);
982
1021
  return "removed";
983
1022
  }
@@ -995,8 +1034,8 @@ export class WorktreePool {
995
1034
  * `--untracked-files=all` and no `--ignored`: build output that .gitignore
996
1035
  * hides is not work, but a file somebody dropped in and never staged is.
997
1036
  */
998
- async isDirty(path, ignoreOwnFiles = false) {
999
- const status = await this.runner(GIT, [...READ_ONLY, "status", "--porcelain", "--untracked-files=all"], { cwd: path, timeoutMs: WORKTREE_TIMEOUT_MS });
1037
+ async isDirty(path, ignoreOwnFiles = false, git = this.teardown) {
1038
+ const status = await git(path, [...READ_ONLY, "status", "--porcelain", "--untracked-files=all", "--ignore-submodules=all"]);
1000
1039
  if (status.code !== 0)
1001
1040
  return null;
1002
1041
  // Our own lease note is not the operator's work. Without this the marker
@@ -1010,6 +1049,8 @@ export class WorktreePool {
1010
1049
  .filter(line => line.trim() !== "")
1011
1050
  .some(line => !line.trimEnd().endsWith(MARKER) && !(ignoreOwnFiles && line.startsWith("?? ") && looksLikeProtocolFile(line.slice(3).trim())));
1012
1051
  }
1052
+ /** All disposal and inspection paths share the hardened teardown boundary. */
1053
+ teardown = (cwd, args) => teardownGit(this.runner)(cwd, args);
1013
1054
  git(cwd, args, prefix = []) {
1014
1055
  return this.runner(GIT, [...prefix, ...args], { cwd, timeoutMs: WORKTREE_TIMEOUT_MS });
1015
1056
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.9.62",
3
+ "version": "0.9.63",
4
4
  "description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
5
5
  "directories": {
6
6
  "doc": "docs"
@@ -8,6 +8,8 @@
8
8
  "scripts": {
9
9
  "build": "tsc -p tsconfig.build.json && node scripts/browser-build.mjs && node scripts/postbuild.mjs",
10
10
  "test": "vitest run",
11
+ "test:attacks": "vitest run --config test/attacks/vitest.config.ts",
12
+ "check:quick": "node scripts/quick-check.mjs",
11
13
  "dev": "tsx src/cli.ts",
12
14
  "typecheck": "tsc --noEmit && tsc -p tsconfig.browser.json",
13
15
  "certify:provider": "npm run build && node scripts/provider-canary.mjs",