toolroll 0.9.62 → 0.9.65
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 +80 -0
- package/README.md +66 -8
- package/dist/acp-agents.d.ts +43 -0
- package/dist/acp-agents.js +62 -0
- package/dist/acp-attest.d.ts +44 -0
- package/dist/acp-attest.js +269 -0
- package/dist/acp-check.d.ts +28 -0
- package/dist/acp-check.js +410 -0
- package/dist/acp-client.d.ts +163 -0
- package/dist/acp-client.js +738 -0
- package/dist/acp-inspect.d.ts +23 -0
- package/dist/acp-inspect.js +109 -0
- package/dist/agent-board.d.ts +19 -6
- package/dist/agent-board.js +30 -9
- package/dist/agent-fence.d.ts +203 -10
- package/dist/agent-fence.js +623 -41
- package/dist/agent-pulse.js +9 -2
- package/dist/agent-state-words.d.ts +19 -0
- package/dist/agent-state-words.js +20 -0
- package/dist/agent-state.d.ts +65 -0
- package/dist/agent-state.js +229 -0
- package/dist/agentconfig.js +36 -13
- package/dist/agents-board.d.ts +34 -2
- package/dist/agents-board.js +110 -1
- package/dist/approval-rules-ui.js +2 -2
- package/dist/assignment-delivery.d.ts +14 -0
- package/dist/assignment-delivery.js +13 -0
- package/dist/assignment.js +13 -0
- package/dist/attest.js +7 -0
- package/dist/backup-ui.js +2 -2
- package/dist/browser/THIRD_PARTY_NOTICES.txt +48 -0
- package/dist/browser/terminal.css +37 -0
- package/dist/browser/terminal.js +117 -0
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +42 -38
- package/dist/browser-shell.js +6 -3
- package/dist/browser-workspace.d.ts +263 -1
- package/dist/browser-workspace.js +60 -0
- package/dist/builder.d.ts +6 -1
- package/dist/builder.js +34 -13
- package/dist/chat-actions.d.ts +13 -1
- package/dist/chat-actions.js +199 -17
- package/dist/chat-ask.d.ts +4 -0
- package/dist/chat-ask.js +14 -0
- package/dist/chat-channel.js +20 -0
- package/dist/chat-continuity.js +5 -4
- package/dist/chat-decide.d.ts +0 -14
- package/dist/chat-decide.js +4 -1
- package/dist/chat-delivery.d.ts +9 -0
- package/dist/chat-delivery.js +45 -6
- package/dist/chat-messages.d.ts +8 -0
- package/dist/chat-messages.js +36 -4
- package/dist/check-levels-ui.js +2 -2
- package/dist/check-levels.js +3 -0
- package/dist/checkout-proof.d.ts +28 -0
- package/dist/checkout-proof.js +62 -0
- package/dist/claim.d.ts +12 -0
- package/dist/claim.js +20 -1
- package/dist/cli-help.d.ts +1 -1
- package/dist/cli-help.js +5 -1
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +24 -2
- package/dist/coding-ui.js +6 -4
- package/dist/console-instance.d.ts +124 -0
- package/dist/console-instance.js +337 -0
- package/dist/contracts/chat-actions.d.ts +16 -0
- package/dist/contracts/chat-actions.js +4 -0
- package/dist/contracts/chat-content.d.ts +19 -0
- package/dist/contracts/chat-content.js +6 -0
- package/dist/contracts/cli.d.ts +8 -0
- package/dist/contracts/cli.js +3 -1
- package/dist/contracts/console-api.d.ts +30 -4
- package/dist/contracts/console-api.js +13 -4
- package/dist/contracts/flow-card.d.ts +4 -0
- package/dist/contracts/flow-card.js +3 -0
- package/dist/contracts/flow.d.ts +46 -0
- package/dist/contracts/flow.js +11 -2
- package/dist/contracts/integration-metadata.d.ts +2 -1
- package/dist/contracts/integration-metadata.js +2 -1
- package/dist/contracts/lead-tools.d.ts +92 -0
- package/dist/contracts/lead-tools.js +13 -0
- package/dist/contracts/provider.d.ts +11 -1
- package/dist/contracts/provider.js +1 -1
- package/dist/contracts/route.d.ts +76 -1
- package/dist/contracts/route.js +1 -1
- package/dist/contracts/store-json.d.ts +2 -0
- package/dist/control-setup.d.ts +1 -1
- package/dist/control-ui.js +2 -2
- package/dist/converse.d.ts +16 -0
- package/dist/converse.js +32 -4
- package/dist/demo-chat.js +4 -4
- package/dist/demo.js +2 -2
- package/dist/desktop-update-gate.js +6 -0
- package/dist/discord-chat.js +4 -1
- package/dist/dispatch.js +7 -4
- package/dist/doctor.d.ts +53 -0
- package/dist/doctor.js +427 -0
- package/dist/egress-bridge.d.ts +17 -0
- package/dist/egress-bridge.js +111 -0
- package/dist/egress.d.ts +119 -0
- package/dist/egress.js +423 -0
- package/dist/evidence-pack.d.ts +1 -1
- package/dist/evidence-pack.js +2 -2
- package/dist/exec.d.ts +81 -0
- package/dist/exec.js +368 -9
- package/dist/export-ui.js +2 -2
- package/dist/flow-code.d.ts +2 -2
- package/dist/flow-code.js +4 -15
- package/dist/flow-engine.js +35 -11
- package/dist/flow-gallery-ui.js +2 -2
- package/dist/flow-gallery.js +27 -8
- package/dist/flow-share.js +16 -5
- package/dist/flow-triggers.d.ts +84 -11
- package/dist/flow-triggers.js +554 -87
- 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 +63 -12
- package/dist/flows-ui.d.ts +2 -3
- package/dist/flows-ui.js +56 -5
- package/dist/flows.d.ts +4 -0
- package/dist/flows.js +9 -1
- package/dist/google-mail.d.ts +1 -0
- package/dist/google-mail.js +61 -18
- package/dist/html.d.ts +9 -0
- package/dist/html.js +11 -0
- 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.d.ts +17 -0
- package/dist/invoke.js +510 -182
- package/dist/keys.d.ts +18 -14
- package/dist/keys.js +5 -0
- package/dist/landed-cleanup.d.ts +3 -2
- package/dist/landed-cleanup.js +12 -5
- package/dist/lead-capabilities.js +3 -2
- package/dist/lead-contract.js +2 -0
- package/dist/lead-doors.d.ts +2 -0
- package/dist/lead-doors.js +7 -0
- package/dist/lead-follow.d.ts +2 -0
- package/dist/lead-follow.js +58 -2
- package/dist/lead-progress.js +1 -0
- package/dist/lead-queue-runtime.d.ts +46 -0
- package/dist/lead-queue-runtime.js +191 -0
- package/dist/lead-queue.d.ts +104 -0
- package/dist/lead-queue.js +187 -0
- package/dist/lead-sweep.d.ts +67 -0
- package/dist/lead-sweep.js +99 -0
- package/dist/lead-tools.d.ts +22 -0
- package/dist/lead-tools.js +163 -4
- package/dist/lead.d.ts +13 -5
- package/dist/lead.js +89 -29
- package/dist/limits-ui.d.ts +8 -1
- package/dist/limits-ui.js +26 -0
- package/dist/live-bus.d.ts +8 -0
- package/dist/live-bus.js +40 -4
- package/dist/live-detail.d.ts +27 -0
- package/dist/live-detail.js +57 -0
- package/dist/live-notes.d.ts +20 -0
- package/dist/live-notes.js +47 -0
- package/dist/live-records.d.ts +140 -0
- package/dist/live-records.js +935 -0
- package/dist/live-terminal.d.ts +258 -0
- package/dist/live-terminal.js +649 -0
- package/dist/live.d.ts +38 -27
- package/dist/live.js +142 -124
- package/dist/mcp-connect.d.ts +8 -7
- package/dist/mcp-connect.js +49 -51
- package/dist/mcp-oauth.d.ts +6 -3
- package/dist/mcp-oauth.js +8 -4
- package/dist/memory-pass.js +2 -2
- package/dist/mobile-viewport.d.ts +5 -1
- package/dist/mobile-viewport.js +18 -2
- package/dist/model-catalog.js +4 -0
- package/dist/monitoring-ui.js +2 -2
- package/dist/needs-you.d.ts +1 -1
- package/dist/needs-you.js +11 -4
- package/dist/oauth-run-token.d.ts +51 -0
- package/dist/oauth-run-token.js +127 -0
- package/dist/operate.d.ts +2 -0
- package/dist/operate.js +282 -18
- package/dist/passwordless.d.ts +23 -0
- package/dist/passwordless.js +26 -0
- package/dist/peek-cli.d.ts +12 -4
- package/dist/peek-cli.js +19 -11
- package/dist/phase-routing.js +10 -3
- package/dist/plan-auto.d.ts +11 -0
- package/dist/plan-auto.js +62 -11
- package/dist/planner-source.d.ts +3 -0
- package/dist/planner-source.js +14 -0
- package/dist/planner.d.ts +2 -1
- package/dist/planner.js +7 -1
- package/dist/policy-ui.js +2 -2
- package/dist/policy.d.ts +1 -20
- package/dist/policy.js +29 -2
- 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-ui.d.ts +12 -0
- package/dist/project-delete-ui.js +41 -9
- package/dist/project-delete.d.ts +3 -1
- package/dist/project-delete.js +12 -6
- package/dist/project-file.d.ts +3 -0
- package/dist/project-file.js +16 -0
- package/dist/project-memory.d.ts +3 -1
- package/dist/project-memory.js +57 -25
- package/dist/project-tools.d.ts +103 -27
- package/dist/project-tools.js +399 -100
- package/dist/provider-auth.d.ts +4 -3
- package/dist/provider-auth.js +5 -4
- package/dist/provider-connection.d.ts +2 -1
- package/dist/provider-connection.js +5 -0
- package/dist/provider-home.d.ts +22 -0
- package/dist/provider-home.js +99 -0
- package/dist/provider.d.ts +42 -5
- package/dist/provider.js +97 -19
- package/dist/pull-request-ui.js +2 -2
- package/dist/push.js +1 -1
- package/dist/quick-start.d.ts +24 -0
- package/dist/quick-start.js +127 -0
- package/dist/release.d.ts +3 -0
- package/dist/release.js +14 -8
- package/dist/remote-main.d.ts +4 -0
- package/dist/remote-main.js +3 -3
- package/dist/request-budget-ui.js +2 -2
- package/dist/result-review.d.ts +1 -1
- package/dist/result-review.js +9 -3
- package/dist/retention-ui.js +2 -2
- package/dist/retention.js +10 -1
- package/dist/run-efficiency.d.ts +73 -0
- package/dist/run-efficiency.js +228 -0
- package/dist/runner.d.ts +5 -1
- package/dist/runner.js +22 -1
- package/dist/scope.d.ts +171 -6
- package/dist/scope.js +251 -31
- package/dist/scout-net.d.ts +4 -1
- package/dist/scout-net.js +37 -12
- package/dist/scout.d.ts +20 -6
- package/dist/scout.js +27 -3
- package/dist/serve.js +258 -43
- package/dist/server/chat.d.ts +1 -1
- package/dist/server/chat.js +122 -31
- package/dist/server/chrome.d.ts +6 -1
- package/dist/server/chrome.js +18 -7
- package/dist/server/console.css +52 -38
- package/dist/server/flows.js +65 -13
- package/dist/server/guards.d.ts +1 -0
- package/dist/server/guards.js +23 -9
- package/dist/server/http.d.ts +7 -0
- package/dist/server/http.js +3 -2
- package/dist/server/live.d.ts +34 -1
- package/dist/server/live.js +39 -17
- package/dist/server/pages-assets.js +5 -0
- package/dist/server/pages.js +90 -13
- package/dist/server/people-tokens.d.ts +3 -0
- package/dist/server/people-tokens.js +55 -17
- package/dist/server/remote-hooks.js +12 -6
- package/dist/server/remote.d.ts +2 -1
- package/dist/server/remote.js +14 -5
- package/dist/server/render-chat.d.ts +12 -3
- package/dist/server/render-chat.js +47 -16
- package/dist/server/render-pages.d.ts +4 -1
- package/dist/server/render-pages.js +5 -4
- package/dist/server/render-people.d.ts +3 -2
- package/dist/server/render-people.js +3 -2
- package/dist/server/render-results.d.ts +18 -2
- package/dist/server/render-results.js +37 -28
- package/dist/server/render-settings.js +6 -5
- package/dist/server/render-tasks.d.ts +35 -10
- package/dist/server/render-tasks.js +110 -27
- package/dist/server/request-context.d.ts +2 -0
- package/dist/server/request-context.js +2 -1
- package/dist/server/route-table.d.ts +3 -0
- package/dist/server/route-table.js +49 -5
- package/dist/server/runtime.d.ts +34 -3
- package/dist/server/session.d.ts +25 -1
- package/dist/server/session.js +18 -4
- package/dist/server/settings.js +132 -13
- package/dist/server/sweeps.d.ts +4 -0
- package/dist/server/sweeps.js +58 -0
- package/dist/server/task-views.d.ts +2 -1
- package/dist/server/task-views.js +96 -6
- package/dist/server/tasks.js +320 -74
- package/dist/server/terminals.d.ts +31 -0
- package/dist/server/terminals.js +365 -0
- package/dist/setup-guide.js +3 -2
- package/dist/slack-chat.js +4 -1
- package/dist/spend-ui.js +3 -3
- package/dist/spend.js +2 -1
- package/dist/steer-capabilities.d.ts +32 -0
- package/dist/steer-capabilities.js +60 -0
- package/dist/steer-controls.d.ts +14 -0
- package/dist/steer-controls.js +36 -0
- package/dist/steer-status.js +2 -2
- 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/storage-ui.js +4 -4
- package/dist/store-baseline.d.ts +1 -1
- package/dist/store-baseline.js +1 -1
- package/dist/store.d.ts +466 -19
- package/dist/store.js +1185 -63
- package/dist/subagent-desk.js +1 -1
- package/dist/subagent-tools.d.ts +1 -1
- package/dist/summary.js +3 -0
- package/dist/surface.js +6 -4
- package/dist/task-source.d.ts +58 -0
- package/dist/task-source.js +93 -0
- package/dist/team-leads.js +1 -1
- package/dist/teams-chat.js +4 -1
- package/dist/teardown-git.d.ts +14 -0
- package/dist/teardown-git.js +119 -0
- package/dist/telegram-lead.d.ts +3 -0
- package/dist/telegram-lead.js +44 -7
- package/dist/telegram.d.ts +11 -2
- package/dist/telegram.js +51 -17
- package/dist/terminal-catalog.d.ts +11 -0
- package/dist/terminal-catalog.js +129 -0
- package/dist/text-limits.d.ts +2 -0
- package/dist/text-limits.js +2 -0
- package/dist/tool-broker.d.ts +39 -0
- package/dist/tool-broker.js +370 -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-ui.js +2 -2
- package/dist/toolroll-update.js +14 -3
- package/dist/tools-ui.d.ts +7 -1
- package/dist/tools-ui.js +19 -10
- package/dist/webhook-limits.d.ts +23 -0
- package/dist/webhook-limits.js +30 -0
- package/dist/webhook-readers.d.ts +56 -0
- package/dist/webhook-readers.js +138 -0
- package/dist/webhook-signing.d.ts +27 -0
- package/dist/webhook-signing.js +119 -0
- package/dist/work-index.d.ts +29 -0
- package/dist/work-index.js +21 -5
- package/dist/workspace-revision.js +3 -1
- package/dist/worktree.d.ts +10 -4
- package/dist/worktree.js +70 -29
- package/package.json +13 -2
- package/scripts/provider-canary.mjs +210 -31
package/dist/project-tools.js
CHANGED
|
@@ -9,20 +9,29 @@
|
|
|
9
9
|
* leaves every build at once. Reviewers and the lead chat never get tools.
|
|
10
10
|
*
|
|
11
11
|
* Secret values (API keys, tokens, any env value) never enter the
|
|
12
|
-
* database, a chat, a card or a log: they live in
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* database, a chat, a card or a log: they live in the operating system's
|
|
13
|
+
* keychain (tool-secret-store.ts) or, where there is none, in one 0600 file
|
|
14
|
+
* per tool in `tool-secrets` beside the database (files kept before under
|
|
15
|
+
* ~/.toolroll/tool-secrets/, or an older name's, move in on first use).
|
|
16
|
+
* They are written only from the console's password-gated screen, read when a
|
|
17
|
+
* run starts, and reach only that run's tool broker (tool-broker.ts), which
|
|
18
|
+
* gives them to the project's server processes and sign-in headers — never to
|
|
19
|
+
* the agent. A tool connected by signing in gives a run only an access token
|
|
20
|
+
* of its own (oauth-run-token.ts).
|
|
16
21
|
*/
|
|
17
22
|
import { toolRefusal } from "./policy.js";
|
|
18
23
|
import { createHash } from "node:crypto";
|
|
19
24
|
import { spawn } from "node:child_process";
|
|
20
|
-
import { chmodSync, existsSync,
|
|
25
|
+
import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
21
26
|
import { homedir, tmpdir } from "node:os";
|
|
22
|
-
import { isAbsolute, join } from "node:path";
|
|
27
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
23
28
|
import { fileURLToPath } from "node:url";
|
|
24
29
|
import { scanForSecrets } from "./evidence.js";
|
|
25
30
|
import { namedPath } from "./names.js";
|
|
31
|
+
import { startToolBroker } from "./tool-broker.js";
|
|
32
|
+
import { isAcpProvider } from "./acp-agents.js";
|
|
33
|
+
import { SecretStoreError, destroyOwnFile, prepareSecretFolder, registerSecretFolder, pathIn, readOwnFile, readSealed, removeValue, secretBackend, secretFolder, secretStorage, serviceOf, withSecretLock, withSecretLockAsync, writeOwnFile, writeSealed } from "./tool-secret-store.js";
|
|
34
|
+
import { ACCESS, GRANTED, REFRESH, mintRunToken, mintsRunTokens, oneAtATime, runScope, scopeList, signedIn } from "./oauth-run-token.js";
|
|
26
35
|
import { HEADER_NAME, SECRET_NAME, TOOL_NAME as NAME, TOOL_SPEC_LIMITS as LIMITS, readToolSpec } from "./contracts/integration-metadata.js";
|
|
27
36
|
/** Env names a tool may never claim: the process's own plumbing, every
|
|
28
37
|
* provider credential, and anything that changes how a program or its
|
|
@@ -176,60 +185,191 @@ export function toolCommandLine(spec) {
|
|
|
176
185
|
return [spec.command, ...spec.args].map(part => /^[A-Za-z0-9@%+=:,./_-]+$/.test(part ?? "") ? part : JSON.stringify(part)).join(" ");
|
|
177
186
|
}
|
|
178
187
|
// ---- secrets -----------------------------------------------------------------
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
return
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
188
|
+
/**
|
|
189
|
+
* Where tool secrets were kept before (an existing ~/.toolroll/tool-secrets, or an older name's), read once when
|
|
190
|
+
* Toolroll starts so their files move in on first use. Never where new secrets go.
|
|
191
|
+
*/
|
|
192
|
+
export function legacyToolSecretsDir(home = homedir()) {
|
|
193
|
+
const found = namedPath(home, ["tool-secrets"], { dot: true });
|
|
194
|
+
return existsSync(found) ? found : null;
|
|
195
|
+
}
|
|
196
|
+
/** Commands that can start builds or serve the console: Toolroll's folder for secrets is made before any build runs. */
|
|
197
|
+
export const SECRETS_FIRST = new Set(["serve", "up", "daemon", "watch", "worker", "tick", "build", "run", "reconcile", "bridge"]);
|
|
198
|
+
/**
|
|
199
|
+
* Toolroll's folder for the secrets it holds, beside `databaseFile`, checked against the sealing key the database
|
|
200
|
+
* recorded: made now when `eager` (a command that can start builds), else the first time a secret is used.
|
|
201
|
+
* A folder that can't be used is said in Settings and `toolroll integrations`; every secret is refused until then.
|
|
202
|
+
*/
|
|
203
|
+
export function startToolSecrets(store, databaseFile, clock, options) {
|
|
204
|
+
const anchor = { read: () => store.toolSecretKeyDigest(), swap: (expected, next) => store.swapToolSecretKeyDigest(expected, next, clock()) };
|
|
205
|
+
// The old folder is found once, at the first start of a command that can start builds (so before any build ran), then
|
|
206
|
+
// read from the record.
|
|
207
|
+
const state = dirname(databaseFile), legacy = store.toolSecretLegacyDir(() => legacyToolSecretsDir(), clock(), { record: options.eager });
|
|
208
|
+
if (options.eager)
|
|
209
|
+
prepareSecretFolder(state, anchor, { standing: true, legacy });
|
|
210
|
+
else
|
|
211
|
+
registerSecretFolder(state, anchor, { legacy });
|
|
212
|
+
}
|
|
213
|
+
/** Toolroll's folder for secrets (tool-secret-store.ts): beside the database Toolroll started with, or a test's `state`. */
|
|
214
|
+
export function toolSecretsDir(state) {
|
|
215
|
+
return secretFolder(state).dir;
|
|
216
|
+
}
|
|
217
|
+
const repoHash = (repo) => createHash("sha256").update(repo).digest("hex").slice(0, 16);
|
|
218
|
+
const toolName = (tool) => { if (!NAME.test(tool))
|
|
219
|
+
throw new Error("Choose a tool by its name."); return tool; };
|
|
220
|
+
/** A tool's own file in the folder: its secrets where there's no keychain, or the names of those set. */
|
|
221
|
+
const toolFile = (folder, repo, tool, suffix, make = false) => pathIn(folder.dir, [repoHash(repo), `${toolName(tool)}${suffix}`], { make });
|
|
222
|
+
/** The same tool's file where secrets were kept before Toolroll's folder; null when there was none. */
|
|
223
|
+
const legacyFile = (folder, repo, tool) => folder.legacy === null ? null : pathIn(folder.legacy, [repoHash(repo), `${toolName(tool)}.json`]);
|
|
224
|
+
/** A tool's keychain item: this Toolroll's folder and the project, hashed, and the tool's name. Never a secret. */
|
|
225
|
+
const secretItem = (folder, repo, tool) => ({ service: serviceOf(folder), account: `${repoHash(repo)}.${toolName(tool)}` });
|
|
226
|
+
const secretValues = (parsed) => parsed === null || typeof parsed !== "object" || Array.isArray(parsed) ? {}
|
|
227
|
+
: Object.fromEntries(Object.entries(parsed).filter((entry) => SECRET_NAME.test(entry[0]) && typeof entry[1] === "string"));
|
|
228
|
+
const parsedValues = (text) => {
|
|
191
229
|
try {
|
|
192
|
-
|
|
193
|
-
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
194
|
-
return {};
|
|
195
|
-
return Object.fromEntries(Object.entries(parsed).filter((entry) => SECRET_NAME.test(entry[0]) && typeof entry[1] === "string"));
|
|
230
|
+
return secretValues(JSON.parse(text));
|
|
196
231
|
}
|
|
197
232
|
catch {
|
|
198
|
-
|
|
233
|
+
throw new SecretStoreError("A tool's stored secrets are damaged.");
|
|
199
234
|
}
|
|
235
|
+
};
|
|
236
|
+
/** A secrets file (owner-only, never through a link); null when there is none. */
|
|
237
|
+
function readSecretFile(file) {
|
|
238
|
+
const text = readOwnFile(file);
|
|
239
|
+
return text === null ? null : parsedValues(text);
|
|
200
240
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
241
|
+
/** What the current store holds for a tool: its keychain item, or (no keychain) its file in Toolroll's folder; null when nothing. */
|
|
242
|
+
function readStored(backend, folder, repo, tool) {
|
|
243
|
+
if (backend === null)
|
|
244
|
+
return readSecretFile(toolFile(folder, repo, tool, ".json"));
|
|
245
|
+
const text = readSealed(backend, folder, secretItem(folder, repo, tool));
|
|
246
|
+
return text === null ? null : parsedValues(text);
|
|
247
|
+
}
|
|
248
|
+
function writeStored(backend, folder, repo, tool, values, options = {}) {
|
|
249
|
+
if (backend !== null) {
|
|
250
|
+
const item = secretItem(folder, repo, tool);
|
|
251
|
+
if (Object.keys(values).length === 0) {
|
|
252
|
+
removeValue(backend, item);
|
|
253
|
+
return "ok";
|
|
254
|
+
}
|
|
255
|
+
return writeSealed(backend, folder, item, JSON.stringify(values), options);
|
|
256
|
+
}
|
|
257
|
+
const file = toolFile(folder, repo, tool, ".json", true);
|
|
258
|
+
if (Object.keys(values).length === 0)
|
|
259
|
+
destroyOwnFile(file);
|
|
260
|
+
else
|
|
261
|
+
writeOwnFile(file, JSON.stringify(values));
|
|
262
|
+
return "ok";
|
|
263
|
+
}
|
|
264
|
+
/** One change to a tool's secrets at a time, across Toolroll's processes, re-entrant within one. */
|
|
265
|
+
const withToolLock = (folder, repo, tool, work) => withSecretLock(folder, `${repoHash(repo)}.${toolName(tool)}`, work);
|
|
266
|
+
/** The names of a tool's secrets that have a value, kept beside them so a page never reads a value to say so. */
|
|
267
|
+
function writeNames(folder, repo, tool, names) {
|
|
268
|
+
const file = toolFile(folder, repo, tool, ".names.json", true);
|
|
269
|
+
if (names.length === 0)
|
|
208
270
|
rmSync(file, { force: true });
|
|
271
|
+
else
|
|
272
|
+
writeOwnFile(file, JSON.stringify([...names].sort()));
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* A tool's secrets moved into the current store from where they were kept before — the old home folder, or (now that
|
|
276
|
+
* there is a keychain) its file in Toolroll's folder — the first time they are used: stored, read back and compared,
|
|
277
|
+
* and only then is the file destroyed. What the store already holds (an earlier move, or a change since) wins over
|
|
278
|
+
* the file. A failure leaves the file as it was, and says so.
|
|
279
|
+
*/
|
|
280
|
+
function moveIn(backend, folder, repo, tool) {
|
|
281
|
+
const sources = [legacyFile(folder, repo, tool), backend === null ? null : toolFile(folder, repo, tool, ".json")].filter((one) => one !== null && existsSync(one));
|
|
282
|
+
if (sources.length === 0)
|
|
209
283
|
return;
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
284
|
+
withToolLock(folder, repo, tool, () => {
|
|
285
|
+
for (const file of sources) {
|
|
286
|
+
// Damaged, a link, or not plainly Toolroll's: kept as it is, and never used.
|
|
287
|
+
let values;
|
|
288
|
+
try {
|
|
289
|
+
values = readSecretFile(file);
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
values = null;
|
|
293
|
+
}
|
|
294
|
+
if (values === null)
|
|
295
|
+
continue;
|
|
296
|
+
if (Object.keys(values).length > 0 && readStored(backend, folder, repo, tool) === null) {
|
|
297
|
+
if (writeStored(backend, folder, repo, tool, values, { create: true }) === "ok") {
|
|
298
|
+
let back = null;
|
|
299
|
+
try {
|
|
300
|
+
back = readStored(backend, folder, repo, tool);
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
back = null;
|
|
304
|
+
}
|
|
305
|
+
if (back === null || JSON.stringify(back) !== JSON.stringify(values)) {
|
|
306
|
+
try {
|
|
307
|
+
if (backend !== null)
|
|
308
|
+
removeValue(backend, secretItem(folder, repo, tool));
|
|
309
|
+
}
|
|
310
|
+
catch { /* the file stays the copy that counts */ }
|
|
311
|
+
throw new SecretStoreError("A tool's secrets couldn't be moved to the keychain; they stay in their file until they can.");
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
const kept = readStored(backend, folder, repo, tool) ?? {};
|
|
316
|
+
writeNames(folder, repo, tool, Object.keys(kept));
|
|
317
|
+
destroyOwnFile(file);
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
/** A tool's secret values. Read only to test it, change it or start a run: a page reads which are set (secretsSetFor). */
|
|
322
|
+
export function readToolSecrets(repo, tool, state) {
|
|
323
|
+
const folder = secretFolder(state);
|
|
324
|
+
const backend = secretBackend();
|
|
325
|
+
moveIn(backend, folder, repo, tool);
|
|
326
|
+
return readStored(backend, folder, repo, tool) ?? {};
|
|
213
327
|
}
|
|
214
328
|
/** Set (or, with an empty value, clear) one secret of one tool. */
|
|
215
|
-
export function setToolSecret(repo, tool, name, value,
|
|
216
|
-
|
|
217
|
-
throw new Error("Choose one of the tool's secrets.");
|
|
218
|
-
if (value.length > 8_192 || /[\u0000\r\n]/.test(value))
|
|
219
|
-
throw new Error("A secret is one line, up to 8,192 characters.");
|
|
220
|
-
const values = readToolSecrets(repo, tool, home);
|
|
221
|
-
if (value === "")
|
|
222
|
-
delete values[name];
|
|
223
|
-
else
|
|
224
|
-
values[name] = value;
|
|
225
|
-
writeSecrets(repo, tool, values, home);
|
|
329
|
+
export function setToolSecret(repo, tool, name, value, state) {
|
|
330
|
+
setToolSecrets(repo, tool, { [name]: value }, state);
|
|
226
331
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
332
|
+
/** Set (or, with empty values, clear) some secrets of one tool, in one change. */
|
|
333
|
+
export function setToolSecrets(repo, tool, values, state) {
|
|
334
|
+
for (const [name, value] of Object.entries(values)) {
|
|
335
|
+
if (!SECRET_NAME.test(name) || RESERVED_ENV.has(name))
|
|
336
|
+
throw new Error("Choose one of the tool's secrets.");
|
|
337
|
+
if (value.length > 8_192 || /[\u0000\r\n]/.test(value))
|
|
338
|
+
throw new Error("A secret is one line, up to 8,192 characters.");
|
|
339
|
+
}
|
|
340
|
+
const folder = secretFolder(state);
|
|
341
|
+
withToolLock(folder, repo, tool, () => {
|
|
342
|
+
const current = readToolSecrets(repo, tool, state);
|
|
343
|
+
for (const [name, value] of Object.entries(values)) {
|
|
344
|
+
if (value === "")
|
|
345
|
+
delete current[name];
|
|
346
|
+
else
|
|
347
|
+
current[name] = value;
|
|
348
|
+
}
|
|
349
|
+
writeStored(secretBackend(), folder, repo, tool, current);
|
|
350
|
+
writeNames(folder, repo, tool, Object.keys(current));
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
export function clearToolSecrets(repo, tool, state) {
|
|
354
|
+
const folder = secretFolder(state);
|
|
355
|
+
withToolLock(folder, repo, tool, () => {
|
|
356
|
+
const backend = secretBackend();
|
|
357
|
+
if (backend !== null)
|
|
358
|
+
removeValue(backend, secretItem(folder, repo, tool));
|
|
359
|
+
for (const file of [toolFile(folder, repo, tool, ".json"), legacyFile(folder, repo, tool)])
|
|
360
|
+
if (file !== null && existsSync(file))
|
|
361
|
+
destroyOwnFile(file);
|
|
362
|
+
rmSync(toolFile(folder, repo, tool, ".names.json"), { force: true });
|
|
363
|
+
});
|
|
230
364
|
}
|
|
231
|
-
|
|
232
|
-
|
|
365
|
+
/**
|
|
366
|
+
* One renewal of a sign-in at a time, across Toolroll's processes (a lock in its folder) and within one: a service
|
|
367
|
+
* that rotates its refresh token on every use would otherwise see the same one twice, and whichever answer was saved
|
|
368
|
+
* last would be the newest only by luck.
|
|
369
|
+
*/
|
|
370
|
+
export function renewingSignIn(repo, tool, state, work) {
|
|
371
|
+
const folder = secretFolder(state);
|
|
372
|
+
return oneAtATime(`${folder.root}\0${repo}\0${tool}`, () => withSecretLockAsync(folder, `${repoHash(repo)}.${toolName(tool)}.renew`, work));
|
|
233
373
|
}
|
|
234
374
|
/** Required secrets with no value yet: the tool is left out of builds until they are set. */
|
|
235
375
|
export function missingSecrets(spec, values) {
|
|
@@ -540,26 +680,28 @@ function safeLine(text) {
|
|
|
540
680
|
}
|
|
541
681
|
const toml = (value) => `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
542
682
|
const tomlList = (values) => `[${values.map(toml).join(",")}]`;
|
|
543
|
-
/**
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
servers[spec.name] = { type: "stdio", command: spec.command, args: spec.args, env: tool.values };
|
|
550
|
-
}
|
|
551
|
-
else {
|
|
552
|
-
const headers = {};
|
|
553
|
-
if (spec.bearer !== null && tool.values[spec.bearer])
|
|
554
|
-
headers["Authorization"] = `Bearer ${tool.values[spec.bearer]}`;
|
|
555
|
-
for (const [header, secret] of Object.entries(spec.headerSecrets))
|
|
556
|
-
if (tool.values[secret])
|
|
557
|
-
headers[header] = tool.values[secret];
|
|
558
|
-
servers[spec.name] = { type: "http", url: spec.url, headers };
|
|
559
|
-
}
|
|
560
|
-
}
|
|
683
|
+
/** The launch's broker, holding its tools' values; the agent gets only its address and token. */
|
|
684
|
+
function brokerFor(launch, options) {
|
|
685
|
+
return startToolBroker(launch.tools.map(tool => ({ spec: tool.spec, values: tool.values, ...(options.enabledTools?.[tool.spec.name] === undefined ? {} : { actions: options.enabledTools[tool.spec.name] }) })), options.broker);
|
|
686
|
+
}
|
|
687
|
+
/** A private 0700 folder for one launch's files, and its cleanup with the broker's. */
|
|
688
|
+
function launchFolder(broker) {
|
|
561
689
|
const dir = mkdtempSync(join(tmpdir(), "so-tools-"));
|
|
562
690
|
chmodSync(dir, 0o700);
|
|
691
|
+
return { dir, cleanup: () => { broker?.close(); rmSync(dir, { recursive: true, force: true }); } };
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* Claude: only these servers, in a private 0600 file removed after the run
|
|
695
|
+
* (the provider adds `--strict-mcp-config`). Each is the broker's address
|
|
696
|
+
* with this launch's token: Claude and its shell share one sandbox, so the
|
|
697
|
+
* file holds nothing a tool signs in with.
|
|
698
|
+
*/
|
|
699
|
+
export async function claudeToolArgs(launch, options = {}) {
|
|
700
|
+
const broker = launch.tools.length === 0 ? null : await brokerFor(launch, options);
|
|
701
|
+
const servers = {};
|
|
702
|
+
for (const tool of launch.tools)
|
|
703
|
+
servers[tool.spec.name] = { type: "http", url: broker.url(tool.spec.name), headers: { Authorization: `Bearer ${broker.token}` } };
|
|
704
|
+
const { dir, cleanup } = launchFolder(broker);
|
|
563
705
|
const file = join(dir, "mcp.json");
|
|
564
706
|
writeFileSync(file, JSON.stringify({ mcpServers: servers }), { mode: 0o600 });
|
|
565
707
|
return {
|
|
@@ -567,32 +709,38 @@ export function claudeToolArgs(launch) {
|
|
|
567
709
|
// A first `npx` download can take longer than the default start-up wait.
|
|
568
710
|
env: launch.tools.length > 0 ? { MCP_TIMEOUT: "60000" } : {},
|
|
569
711
|
omitEnv: [],
|
|
570
|
-
cleanup
|
|
712
|
+
cleanup,
|
|
713
|
+
privateDir: dir,
|
|
714
|
+
...(broker === null ? {} : { serverPids: broker.pids, brokerPort: broker.port }),
|
|
571
715
|
};
|
|
572
716
|
}
|
|
573
717
|
/** Where the tool launcher lives beside this module, built. */
|
|
574
718
|
export function toolLauncherPath() {
|
|
575
719
|
return fileURLToPath(new URL("./tool-launcher.js", import.meta.url));
|
|
576
720
|
}
|
|
721
|
+
/** The secret name a launch file gives the broker's token. */
|
|
722
|
+
const BROKER_TOKEN = "TOOLROLL_TOOL_TOKEN";
|
|
577
723
|
/**
|
|
578
|
-
* Codex: each tool is named by `-c` and started through the launcher
|
|
579
|
-
* operator's config.toml, rules, apps,
|
|
580
|
-
* (the provider's
|
|
581
|
-
*
|
|
582
|
-
* its
|
|
583
|
-
*
|
|
724
|
+
* Codex: each tool is named by `-c` and started through the launcher, which
|
|
725
|
+
* relays it to the launch's broker. The operator's config.toml, rules, apps,
|
|
726
|
+
* plugins and hooks are already off (the provider's
|
|
727
|
+
* CODEX_BUILD_ISOLATION_ARGV), and nothing of config.toml is carried. Codex
|
|
728
|
+
* gives its own environment to the agent's shell whatever its policy says,
|
|
729
|
+
* so nothing rides it: the launch files name the broker and its token, and
|
|
730
|
+
* the folder holding them is fenced from the agent.
|
|
584
731
|
*/
|
|
585
|
-
export function codexToolArgs(launch, options = {}) {
|
|
732
|
+
export async function codexToolArgs(launch, options = {}) {
|
|
586
733
|
const argv = [];
|
|
587
734
|
if (launch.tools.length === 0)
|
|
588
735
|
return { argv, env: {}, omitEnv: [], cleanup: () => undefined };
|
|
589
|
-
const
|
|
590
|
-
|
|
736
|
+
const broker = await brokerFor(launch, options);
|
|
737
|
+
const { dir, cleanup } = launchFolder(broker);
|
|
591
738
|
const launcher = options.launcher ?? toolLauncherPath();
|
|
592
739
|
const node = options.node ?? process.execPath;
|
|
593
740
|
for (const tool of launch.tools) {
|
|
594
741
|
const file = join(dir, `${tool.spec.name}.json`);
|
|
595
|
-
|
|
742
|
+
const relay = { ...tool.spec, transport: "http", command: null, args: [], url: broker.url(tool.spec.name), secrets: [{ name: BROKER_TOKEN, optional: false }], bearer: BROKER_TOKEN, headerSecrets: {} };
|
|
743
|
+
writeFileSync(file, JSON.stringify({ spec: relay, values: { [BROKER_TOKEN]: broker.token } }), { mode: 0o600 });
|
|
596
744
|
const key = `mcp_servers.${tool.spec.name}`;
|
|
597
745
|
argv.push("-c", `${key}.command=${toml(node)}`, "-c", `${key}.args=${tomlList([launcher, file])}`, "-c", `${key}.startup_timeout_sec=60`,
|
|
598
746
|
// Adding a tool to a project is the operator's yes to its calls: an unattended build has no one to ask.
|
|
@@ -602,7 +750,7 @@ export function codexToolArgs(launch, options = {}) {
|
|
|
602
750
|
if (enabled !== undefined)
|
|
603
751
|
argv.push("-c", `${key}.enabled_tools=${tomlList(enabled)}`);
|
|
604
752
|
}
|
|
605
|
-
return { argv, env: {}, omitEnv: [], cleanup
|
|
753
|
+
return { argv, env: {}, omitEnv: [], cleanup, privateDir: dir, serverPids: broker.pids, brokerPort: broker.port };
|
|
606
754
|
}
|
|
607
755
|
/** Gemini: no way yet to hand it exactly these servers, so it gets none. */
|
|
608
756
|
export const GEMINI_NO_TOOLS_ARGV = ["--allowed-mcp-server-names", "standing-orders-none"];
|
|
@@ -631,14 +779,14 @@ export function projectToolsOf(store, repo) {
|
|
|
631
779
|
* sealed gets today's list), each with every required secret set. What is
|
|
632
780
|
* left out says why, in words the task page can show.
|
|
633
781
|
*/
|
|
634
|
-
export function toolLaunchFor(store, runId, home
|
|
782
|
+
export function toolLaunchFor(store, runId, home) {
|
|
635
783
|
const run = store.getRun(runId);
|
|
636
784
|
const ref = run === null ? null : store.refById(run.taskRef);
|
|
637
785
|
if (ref === null || ref.repo === null)
|
|
638
786
|
return { tools: [], skipped: [] };
|
|
639
787
|
const approved = store.getScope(ref.externalId)?.approvedDigest ?? null;
|
|
640
788
|
const seal = approved === null ? null : store.toolSealFor(ref.externalId, approved);
|
|
641
|
-
const launch = { tools: [], skipped: [] };
|
|
789
|
+
const launch = { tools: [], skipped: [], repo: ref.repo };
|
|
642
790
|
const claimed = new Map();
|
|
643
791
|
const policy = store.orgPolicy();
|
|
644
792
|
for (const tool of projectToolsOf(store, ref.repo)) {
|
|
@@ -652,13 +800,18 @@ export function toolLaunchFor(store, runId, home = homedir()) {
|
|
|
652
800
|
launch.skipped.push({ name: tool.name, reason: "added or changed after this task was approved; approve it again to use it" });
|
|
653
801
|
continue;
|
|
654
802
|
}
|
|
655
|
-
|
|
803
|
+
// A tool that declares no secret never reads the store: a store that isn't ready (a process that didn't start
|
|
804
|
+
// Toolroll's folder) or a keychain that refuses can't take a keyless tool out of the run.
|
|
805
|
+
const values = tool.spec.secrets.length === 0 ? {} : readToolSecrets(ref.repo, tool.name, home);
|
|
656
806
|
const missing = missingSecrets(tool.spec, values);
|
|
657
807
|
if (missing.length > 0) {
|
|
658
808
|
launch.skipped.push({ name: tool.name, reason: `needs ${missing.join(", ")} set on the Tools page` });
|
|
659
809
|
continue;
|
|
660
810
|
}
|
|
661
|
-
|
|
811
|
+
// A tool connected by signing in launches with its access token alone: its refresh token, client and the rest
|
|
812
|
+
// stay in the store, and a run that renews gets an access token of its own (prepareRunTools).
|
|
813
|
+
const kept = signedIn(tool.spec) ? { [ACCESS]: values[ACCESS] }
|
|
814
|
+
: Object.fromEntries(tool.spec.secrets.filter(one => (values[one.name] ?? "") !== "").map(one => [one.name, values[one.name]]));
|
|
662
815
|
const clash = Object.entries(kept).find(([name, value]) => claimed.has(name) && claimed.get(name) !== value);
|
|
663
816
|
if (clash !== undefined) {
|
|
664
817
|
launch.skipped.push({ name: tool.name, reason: `uses ${clash[0]} with a different value than another tool` });
|
|
@@ -670,33 +823,145 @@ export function toolLaunchFor(store, runId, home = homedir()) {
|
|
|
670
823
|
}
|
|
671
824
|
return launch;
|
|
672
825
|
}
|
|
826
|
+
/**
|
|
827
|
+
* Why a local tool that carries secrets can't start for this launch, or null when it can. Its server's environment
|
|
828
|
+
* holds the values, and a process of the same user can read another's (KERN_PROCARGS2, /proc/<pid>/environ) unless
|
|
829
|
+
* an OS fence stops it: only the macOS sandbox around a Claude build does. Codex's filesystem policy can't deny that
|
|
830
|
+
* read (OpenRouter uses the same harness); Claude's own file rules and Linux's bubblewrap don't stop it, and on Linux
|
|
831
|
+
* hidepid hides nothing from the same user, so Linux never starts one.
|
|
832
|
+
*/
|
|
833
|
+
export function secretToolRefusal(provider, containment) {
|
|
834
|
+
if (provider === "codex" || provider === "openrouter")
|
|
835
|
+
return "This tool needs a Claude build to protect its sign-in details.";
|
|
836
|
+
if (containment.platform === "linux")
|
|
837
|
+
return "Toolroll doesn't start tools with sign-in details on Linux: a build there could read them from the running tool.";
|
|
838
|
+
if (containment.platform !== "darwin" || containment.fence !== "macos-sandbox")
|
|
839
|
+
return "This build has no macOS sandbox, so it could read this tool's sign-in details. Toolroll didn't start it.";
|
|
840
|
+
return null;
|
|
841
|
+
}
|
|
673
842
|
/**
|
|
674
843
|
* One attempt's tools, ready for its provider, and recorded on the run:
|
|
675
844
|
* what it launched with and what was left out and why. The caller spawns
|
|
676
845
|
* with `argv`/`env` and calls `cleanup` when the process is done.
|
|
677
846
|
*/
|
|
678
|
-
export function prepareRunTools(store, runId, provider, options) {
|
|
847
|
+
export async function prepareRunTools(store, runId, provider, options) {
|
|
679
848
|
const launch = limitedTo(toolLaunchFor(store, runId, options.home), options.readOnly, options.withheld);
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
849
|
+
// A local server with secrets starts only where the build can't read its environment; the rest are left out,
|
|
850
|
+
// saying why, before the broker exists. Gemini and ACP agents start none (below).
|
|
851
|
+
const toolless = provider === "gemini" || isAcpProvider(provider);
|
|
852
|
+
const refusal = toolless ? null : secretToolRefusal(provider, options.containment);
|
|
853
|
+
if (refusal !== null) {
|
|
854
|
+
launch.tools = launch.tools.filter(one => {
|
|
855
|
+
if (one.spec.transport === "http" || Object.keys(one.values).length === 0)
|
|
856
|
+
return true;
|
|
857
|
+
launch.skipped.push({ name: one.spec.name, reason: refusal });
|
|
858
|
+
return false;
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
const revokes = toolless ? [] : await signInRun(launch, { readOnly: options.readOnly !== undefined, now: options.now, ...(options.home === undefined ? {} : { home: options.home }), ...(options.fetcher === undefined ? {} : { fetcher: options.fetcher }) });
|
|
862
|
+
const revoke = () => Promise.all(revokes.splice(0).map(one => one())).then(() => undefined);
|
|
863
|
+
const offered = { ...(options.readOnly === undefined ? {} : { enabledTools: options.readOnly }), ...(options.broker === undefined ? {} : { broker: options.broker }) };
|
|
864
|
+
let launched;
|
|
865
|
+
try {
|
|
866
|
+
launched = provider === "claude" ? await claudeToolArgs(launch, offered)
|
|
867
|
+
: provider === "codex" || provider === "openrouter" ? await codexToolArgs(launch, offered)
|
|
868
|
+
: provider === "gemini" ? { argv: [...GEMINI_NO_TOOLS_ARGV], env: {}, omitEnv: [], cleanup: () => undefined }
|
|
869
|
+
: { argv: [], env: {}, omitEnv: [], cleanup: () => undefined };
|
|
870
|
+
}
|
|
871
|
+
catch (error) {
|
|
872
|
+
// Tokens were made for a run that won't start: each is revoked, and the run with them.
|
|
873
|
+
if (revokes.length === 0)
|
|
874
|
+
throw error;
|
|
875
|
+
await revoke();
|
|
876
|
+
throw new RunSignInError("This run's tools couldn't be prepared, so it didn't start; the sign-ins made for it were revoked.");
|
|
877
|
+
}
|
|
878
|
+
// The run's own sign-ins end with it: the broker closes first, so nothing uses a token as it is revoked.
|
|
879
|
+
const prepared = revokes.length === 0 ? launched : { ...launched, cleanup: () => { launched.cleanup(); void revoke(); } };
|
|
880
|
+
const skipped = toolless
|
|
881
|
+
? [...launch.skipped, ...launch.tools.map(one => ({ name: one.spec.name, reason: provider === "gemini" ? "Gemini builds can't use project tools yet" : "ACP agents can't use project tools yet" }))]
|
|
686
882
|
: launch.skipped;
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
883
|
+
try {
|
|
884
|
+
store.recordRunTools(runId, JSON.stringify({
|
|
885
|
+
provider,
|
|
886
|
+
tools: toolless ? [] : launch.tools.map(one => ({ name: one.spec.name, digest: one.digest })),
|
|
887
|
+
skipped,
|
|
888
|
+
}), options.now);
|
|
889
|
+
}
|
|
890
|
+
catch (error) {
|
|
891
|
+
launched.cleanup();
|
|
892
|
+
await revoke();
|
|
893
|
+
throw revokes.length === 0 ? error : new RunSignInError("This run's tools couldn't be recorded, so it didn't start; the sign-ins made for it were revoked.");
|
|
894
|
+
}
|
|
692
895
|
return prepared;
|
|
693
896
|
}
|
|
897
|
+
/** A run whose sign-ins couldn't all be made and kept: it doesn't start, and every token made for it is revoked. */
|
|
898
|
+
export class RunSignInError extends Error {
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* Each signed-in tool's own access token for this run (oauth-run-token.ts), read and renewed here, outside the fence,
|
|
902
|
+
* one renewal at a time across Toolroll's processes. A rotated refresh token is saved before the run is let in; when
|
|
903
|
+
* it can't be, or anything else fails once a token is made, every token made for this run is revoked, the saved
|
|
904
|
+
* sign-in is left as it was, and the run doesn't start (RunSignInError). Only the new access token joins the launch.
|
|
905
|
+
* A tool that can't be signed in is left out, saying why. Returns how to revoke the run's tokens.
|
|
906
|
+
*/
|
|
907
|
+
async function signInRun(launch, options) {
|
|
908
|
+
const repo = launch.repo;
|
|
909
|
+
const revokes = [];
|
|
910
|
+
if (repo === undefined)
|
|
911
|
+
return revokes;
|
|
912
|
+
const kept = [];
|
|
913
|
+
try {
|
|
914
|
+
for (const tool of launch.tools) {
|
|
915
|
+
if (!signedIn(tool.spec)) {
|
|
916
|
+
kept.push(tool);
|
|
917
|
+
continue;
|
|
918
|
+
}
|
|
919
|
+
const minted = await renewingSignIn(repo, tool.spec.name, options.home, async () => {
|
|
920
|
+
const values = readToolSecrets(repo, tool.spec.name, options.home);
|
|
921
|
+
// No refresh token: the one access token the sign-in gave stays in the broker, like a key.
|
|
922
|
+
if (!mintsRunTokens(values))
|
|
923
|
+
return { ok: true, token: null };
|
|
924
|
+
const scope = runScope(scopeList(values[GRANTED]), options.readOnly);
|
|
925
|
+
const answer = await mintRunToken(values, { scope, now: options.now, ...(options.fetcher === undefined ? {} : { fetcher: options.fetcher }) });
|
|
926
|
+
if (answer.ok && answer.token.revoke !== null)
|
|
927
|
+
revokes.push(answer.token.revoke);
|
|
928
|
+
if (answer.ok && answer.token.rotated !== null) {
|
|
929
|
+
try {
|
|
930
|
+
setToolSecrets(repo, tool.spec.name, { [REFRESH]: answer.token.rotated }, options.home);
|
|
931
|
+
}
|
|
932
|
+
catch {
|
|
933
|
+
throw new RunSignInError(`${tool.spec.name}'s renewed sign-in couldn't be saved, so this run didn't start. Check that the keychain is unlocked, then try again.`);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
return answer;
|
|
937
|
+
});
|
|
938
|
+
if (!minted.ok) {
|
|
939
|
+
launch.skipped.push({ name: tool.spec.name, reason: minted.reason });
|
|
940
|
+
continue;
|
|
941
|
+
}
|
|
942
|
+
if (minted.token === null) {
|
|
943
|
+
kept.push(tool);
|
|
944
|
+
continue;
|
|
945
|
+
}
|
|
946
|
+
kept.push({ ...tool, values: { [ACCESS]: minted.token.access } });
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
catch (error) {
|
|
950
|
+
await Promise.all(revokes.splice(0).map(one => one()));
|
|
951
|
+
if (error instanceof RunSignInError)
|
|
952
|
+
throw error;
|
|
953
|
+
throw new RunSignInError("A tool's sign-in couldn't be made for this run, so it didn't start. Check that the keychain is unlocked, then try again.");
|
|
954
|
+
}
|
|
955
|
+
launch.tools = kept;
|
|
956
|
+
return revokes;
|
|
957
|
+
}
|
|
694
958
|
/** A research step's launch: only the servers it may read, each limited to its read-only actions; the rest left out, saying why (`withheld`, when a service has its own reason). */
|
|
695
959
|
function limitedTo(launch, readOnly, withheld = {}) {
|
|
696
960
|
if (readOnly === undefined)
|
|
697
961
|
return launch;
|
|
698
962
|
const readable = (name) => (readOnly[name] ?? []).length > 0;
|
|
699
963
|
return {
|
|
964
|
+
...launch,
|
|
700
965
|
tools: launch.tools.filter(one => readable(one.spec.name)),
|
|
701
966
|
skipped: [...launch.skipped, ...launch.tools.filter(one => !readable(one.spec.name)).map(one => ({ name: one.spec.name, reason: withheld[one.spec.name] ?? "research reads only connected services' read-only actions" }))],
|
|
702
967
|
};
|
|
@@ -707,7 +972,7 @@ export function noToolsArgs(provider) {
|
|
|
707
972
|
return { argv: ["--mcp-config", '{"mcpServers":{}}'], env: {}, omitEnv: [], cleanup: () => undefined };
|
|
708
973
|
if (provider === "gemini")
|
|
709
974
|
return { argv: [...GEMINI_NO_TOOLS_ARGV], env: {}, omitEnv: [], cleanup: () => undefined };
|
|
710
|
-
return
|
|
975
|
+
return { argv: [], env: {}, omitEnv: [], cleanup: () => undefined };
|
|
711
976
|
}
|
|
712
977
|
/** Add one tool (and, for an import, the values it brings) to a project's builds. */
|
|
713
978
|
export function addToolTo(store, repo, input, source, by, now, options = {}) {
|
|
@@ -763,10 +1028,44 @@ export function toolStanding(tool, secretsSet) {
|
|
|
763
1028
|
return { ready: false, words: "Last test failed" };
|
|
764
1029
|
return { ready: true, words: `Working · ${tool.lastTest.tools.length} tool${tool.lastTest.tools.length === 1 ? "" : "s"}` };
|
|
765
1030
|
}
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
1031
|
+
export const CREDENTIAL_MODES = {
|
|
1032
|
+
"run-token": { words: "Short-lived token per run", detail: "Each run gets its own access token when it starts, revoked or left to expire when it ends. The refresh token stays with Toolroll." },
|
|
1033
|
+
"held-key": { words: "Key held by Toolroll", detail: "Runs reach this tool through Toolroll, which adds the key. The key never enters a build." },
|
|
1034
|
+
"no-key": { words: "No key needed", detail: null },
|
|
1035
|
+
};
|
|
1036
|
+
/** A tool's credential mode from the names of its secrets that are set (never their values); null while a key it needs is missing. */
|
|
1037
|
+
export function credentialModeOf(spec, secretsSet) {
|
|
1038
|
+
const mode = spec.secrets.every(one => one.optional && !secretsSet.includes(one.name)) ? "no-key"
|
|
1039
|
+
: signedIn(spec) && mintsRunTokens(secretsSet) ? "run-token"
|
|
1040
|
+
: spec.secrets.some(one => !one.optional && !secretsSet.includes(one.name)) ? null
|
|
1041
|
+
: "held-key";
|
|
1042
|
+
return mode === null ? null : { mode, ...CREDENTIAL_MODES[mode] };
|
|
1043
|
+
}
|
|
1044
|
+
/** Names of a tool's secrets that have a value, never the values: in the keychain, from the names kept beside them. */
|
|
1045
|
+
export function secretsSetFor(repo, tool, state) {
|
|
1046
|
+
const folder = secretFolder(state);
|
|
1047
|
+
let names = null;
|
|
1048
|
+
const backend = secretBackend();
|
|
1049
|
+
if (backend !== null && !existsSync(toolFile(folder, repo, tool.name, ".json")) && (folder.legacy === null || !existsSync(legacyFile(folder, repo, tool.name)))) {
|
|
1050
|
+
try {
|
|
1051
|
+
const listed = JSON.parse(readOwnFile(toolFile(folder, repo, tool.name, ".names.json")) ?? "null");
|
|
1052
|
+
names = Array.isArray(listed) ? listed.filter((one) => typeof one === "string") : null;
|
|
1053
|
+
}
|
|
1054
|
+
catch {
|
|
1055
|
+
names = null;
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
if (names === null) {
|
|
1059
|
+
const values = readToolSecrets(repo, tool.name, state);
|
|
1060
|
+
names = Object.keys(values).filter(name => values[name] !== "");
|
|
1061
|
+
if (backend !== null)
|
|
1062
|
+
writeNames(folder, repo, tool.name, names);
|
|
1063
|
+
}
|
|
1064
|
+
return tool.secrets.filter(one => names.includes(one.name)).map(one => one.name);
|
|
1065
|
+
}
|
|
1066
|
+
/** Where this computer keeps tool secrets, in the words Settings and `toolroll integrations` share; `problem` when they can't be used. */
|
|
1067
|
+
export function toolSecretStorage() {
|
|
1068
|
+
return secretStorage();
|
|
770
1069
|
}
|
|
771
1070
|
/** A command line split into program and arguments, honouring "double" and 'single' quotes. */
|
|
772
1071
|
export function splitCommandLine(line) {
|