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.
- package/CHANGELOG.md +29 -0
- package/README.md +51 -3
- package/dist/agent-fence.d.ts +155 -10
- package/dist/agent-fence.js +473 -38
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +21 -21
- package/dist/browser-workspace.d.ts +28 -0
- package/dist/chat-actions.js +2 -2
- package/dist/check-levels.js +3 -0
- package/dist/claim.js +1 -1
- package/dist/cli-help.d.ts +1 -1
- package/dist/cli-help.js +1 -0
- package/dist/cli.js +3 -0
- package/dist/contracts/cli.d.ts +8 -0
- package/dist/contracts/cli.js +3 -1
- package/dist/contracts/console-api.d.ts +19 -1
- package/dist/contracts/console-api.js +2 -1
- package/dist/contracts/integration-metadata.d.ts +2 -1
- package/dist/contracts/integration-metadata.js +2 -1
- package/dist/desktop-update.d.ts +1 -1
- package/dist/dispatch.js +7 -4
- package/dist/doctor.d.ts +53 -0
- package/dist/doctor.js +427 -0
- package/dist/evidence-pack.d.ts +1 -1
- package/dist/evidence-pack.js +2 -2
- package/dist/exec.d.ts +3 -0
- package/dist/exec.js +5 -4
- package/dist/flow-code.d.ts +2 -2
- package/dist/flow-code.js +4 -15
- package/dist/flow-engine.js +11 -6
- package/dist/flow-triggers.d.ts +8 -4
- package/dist/flow-triggers.js +45 -25
- package/dist/flow-unattended-words.d.ts +28 -0
- package/dist/flow-unattended-words.js +50 -0
- package/dist/flow-unattended.d.ts +227 -0
- package/dist/flow-unattended.js +776 -0
- package/dist/flows-cli.d.ts +28 -1
- package/dist/flows-cli.js +55 -10
- package/dist/flows-ui.d.ts +2 -1
- package/dist/flows-ui.js +4 -1
- package/dist/google-mail.d.ts +1 -0
- package/dist/google-mail.js +61 -18
- package/dist/integrations-ui.d.ts +2 -1
- package/dist/integrations-ui.js +6 -3
- package/dist/integrations.d.ts +17 -3
- package/dist/integrations.js +38 -18
- package/dist/invoke.js +300 -168
- package/dist/landed-cleanup.d.ts +3 -2
- package/dist/landed-cleanup.js +12 -5
- package/dist/lead-tools.js +11 -3
- package/dist/mcp-connect.d.ts +8 -7
- package/dist/mcp-connect.js +49 -51
- package/dist/oauth-run-token.d.ts +51 -0
- package/dist/oauth-run-token.js +127 -0
- package/dist/operate.js +7 -1
- package/dist/plan-auto.d.ts +11 -0
- package/dist/plan-auto.js +62 -11
- package/dist/process-recovery-eligibility.d.ts +1 -1
- package/dist/project-access.d.ts +3 -0
- package/dist/project-access.js +3 -0
- package/dist/project-delete.d.ts +3 -1
- package/dist/project-delete.js +10 -4
- package/dist/project-file.d.ts +3 -0
- package/dist/project-file.js +16 -0
- package/dist/project-tools.d.ts +99 -25
- package/dist/project-tools.js +394 -98
- package/dist/provider-home.d.ts +22 -0
- package/dist/provider-home.js +99 -0
- package/dist/provider.d.ts +17 -3
- package/dist/provider.js +34 -10
- package/dist/remote-main.d.ts +4 -0
- package/dist/remote-main.js +3 -3
- package/dist/scope.d.ts +1 -1
- package/dist/serve.js +11 -3
- package/dist/server/flows.js +27 -5
- package/dist/server/route-table.js +3 -0
- package/dist/server/runtime.d.ts +1 -1
- package/dist/server/settings.js +11 -3
- package/dist/storage-sweep-child.js +3 -1
- package/dist/storage-sweep-process.d.ts +12 -5
- package/dist/storage-sweep-process.js +23 -6
- package/dist/store.d.ts +38 -6
- package/dist/store.js +148 -15
- package/dist/subagent-tools.d.ts +1 -1
- package/dist/surface.js +6 -4
- package/dist/teardown-git.d.ts +14 -0
- package/dist/teardown-git.js +119 -0
- package/dist/tool-broker.d.ts +37 -0
- package/dist/tool-broker.js +369 -0
- package/dist/tool-launcher.js +5 -4
- package/dist/tool-secret-store.d.ts +170 -0
- package/dist/tool-secret-store.js +834 -0
- package/dist/toolroll-update.js +3 -2
- package/dist/tools-ui.d.ts +7 -1
- package/dist/tools-ui.js +17 -8
- package/dist/work-index.js +5 -3
- package/dist/worktree.d.ts +10 -4
- package/dist/worktree.js +70 -29
- package/package.json +3 -1
- package/scripts/provider-canary.mjs +210 -31
package/dist/toolroll-update.js
CHANGED
|
@@ -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
|
-
|
|
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));
|
package/dist/tools-ui.d.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
22
|
-
const
|
|
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"
|
|
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
|
|
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
|
}
|
package/dist/work-index.js
CHANGED
|
@@ -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))
|
|
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,
|
package/dist/worktree.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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 {
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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",
|