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/doctor.js
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `toolroll doctor`: whether Toolroll's own service holds a credential a build could read.
|
|
3
|
+
*
|
|
4
|
+
* A build runs as the operator's own user. Where no OS fence stops it (any build on Linux, where hidepid hides nothing
|
|
5
|
+
* from the same user; a Codex build anywhere, whose profile can't deny KERN_PROCARGS2; a Claude build without the
|
|
6
|
+
* macOS sandbox) it can read another process's launch arguments and environment: KERN_PROCARGS2 on macOS,
|
|
7
|
+
* /proc/<pid>/environ on Linux. Toolroll's own service is such a process, so its environment must hold no key:
|
|
8
|
+
* provider keys belong in Toolroll's key file (`toolroll keys set`), a tool's secrets on its Tools page, the runner
|
|
9
|
+
* token in its 0600 file.
|
|
10
|
+
*
|
|
11
|
+
* The doctor only reads: each installed service definition (a launchd plist; a systemd unit, its drop-ins and
|
|
12
|
+
* environment files), what `daemon install` would write, the environment a service inherits from its manager
|
|
13
|
+
* (`launchctl getenv`, `systemctl --user show-environment`), and the environment of each running service process.
|
|
14
|
+
* It reports names and where they are, never a value, with the exact steps for the owner. It never edits a unit,
|
|
15
|
+
* moves a key into a file or restarts anything. A key exported in the terminal that starts `toolroll up` keeps
|
|
16
|
+
* working (the ambient fallback); the doctor names it so the owner can choose.
|
|
17
|
+
*/
|
|
18
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
19
|
+
import { homedir } from "node:os";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { daemonStatus, installedServiceDefinition, planDaemon } from "./daemon.js";
|
|
22
|
+
import { scanForSecrets } from "./evidence.js";
|
|
23
|
+
import { envelopeJson } from "./envelope.js";
|
|
24
|
+
import { run as runCommand } from "./exec.js";
|
|
25
|
+
import { OWN_KEY_ENV } from "./keys.js";
|
|
26
|
+
import { ALL_CREDENTIAL_ENV } from "./provider.js";
|
|
27
|
+
/** Names that say they hold a credential. */
|
|
28
|
+
const CREDENTIAL_NAME = /(?:^|_)(?:API_?KEY|ACCESS_?KEY|PRIVATE_?KEY|SECRET|SECRET_?KEY|TOKEN|AUTH_?TOKEN|PASSWORD|PASSWD|CREDENTIALS?|PAT)(?:_|$)/i;
|
|
29
|
+
/** Names that only point at one: a path, a socket, an address. */
|
|
30
|
+
const POINTER_NAME = /(?:_FILE|_PATH|_DIR|_SOCK|_URL|_HOST)$/i;
|
|
31
|
+
/** Launch flags whose next argument (or `=value`) is a credential. */
|
|
32
|
+
const CREDENTIAL_FLAG = /^--?(?:token|api-?key|key|secret|password|auth-token)$/i;
|
|
33
|
+
/** Toolroll's own launchd labels, current and from before the rename. */
|
|
34
|
+
const LABEL = /^(?:com\.toolroll|com\.standing-orders)\.[A-Za-z0-9._-]+$/;
|
|
35
|
+
/** The names among `pairs` that hold a credential: a provider key, a credential-looking name, or a key-shaped value.
|
|
36
|
+
* Values are only tested here and never leave. */
|
|
37
|
+
export function credentialNames(pairs) {
|
|
38
|
+
const found = new Set();
|
|
39
|
+
for (const [name, value] of pairs) {
|
|
40
|
+
if (value === "")
|
|
41
|
+
continue;
|
|
42
|
+
if (ALL_CREDENTIAL_ENV.includes(name) || (CREDENTIAL_NAME.test(name) && !POINTER_NAME.test(name)) || scanForSecrets(value).length > 0)
|
|
43
|
+
found.add(name);
|
|
44
|
+
}
|
|
45
|
+
return [...found].sort();
|
|
46
|
+
}
|
|
47
|
+
/** The flags of a launch command that carry a credential inline (`--token <value>`), and arguments that look like a key. */
|
|
48
|
+
function commandCredentials(args) {
|
|
49
|
+
const found = new Set();
|
|
50
|
+
args.forEach((arg, index) => {
|
|
51
|
+
const flag = /^(--?[A-Za-z-]+)=/.exec(arg)?.[1] ?? arg;
|
|
52
|
+
if (CREDENTIAL_FLAG.test(flag) && (arg.includes("=") || index + 1 < args.length))
|
|
53
|
+
found.add(`${flag} (command line)`);
|
|
54
|
+
else if (scanForSecrets(arg).length > 0)
|
|
55
|
+
found.add(`argument ${index + 1} (command line)`);
|
|
56
|
+
});
|
|
57
|
+
return [...found].sort();
|
|
58
|
+
}
|
|
59
|
+
const unxml = (text) => text.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, "&");
|
|
60
|
+
/** A plist's EnvironmentVariables and ProgramArguments. */
|
|
61
|
+
function plistParts(content) {
|
|
62
|
+
const dict = /<key>EnvironmentVariables<\/key>\s*<dict>([\s\S]*?)<\/dict>/.exec(content)?.[1] ?? "";
|
|
63
|
+
const env = [...dict.matchAll(/<key>([^<]*)<\/key>\s*<string>([^<]*)<\/string>/g)].map(match => [unxml(match[1] ?? ""), unxml(match[2] ?? "")]);
|
|
64
|
+
const args = [...(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(content)?.[1] ?? "").matchAll(/<string>([^<]*)<\/string>/g)].map(match => unxml(match[1] ?? ""));
|
|
65
|
+
return { env, args };
|
|
66
|
+
}
|
|
67
|
+
/** A systemd line's words, its "quotes" undone. */
|
|
68
|
+
function systemdWords(line) {
|
|
69
|
+
return (line.match(/"(?:[^"\\]|\\.)*"|'[^']*'|\S+/g) ?? []).map(word => word.startsWith('"') ? word.slice(1, -1).replace(/\\(.)/g, "$1") : word.startsWith("'") ? word.slice(1, -1) : word);
|
|
70
|
+
}
|
|
71
|
+
const assignment = (word) => {
|
|
72
|
+
const at = word.indexOf("=");
|
|
73
|
+
return at <= 0 ? null : [word.slice(0, at), word.slice(at + 1)];
|
|
74
|
+
};
|
|
75
|
+
/** `NAME=value` lines (an EnvironmentFile, `show-environment`), comments and blanks skipped. */
|
|
76
|
+
function envLines(text) {
|
|
77
|
+
return text.split("\n").map(line => line.trim()).filter(line => line !== "" && !line.startsWith("#") && !line.startsWith(";"))
|
|
78
|
+
.flatMap(line => { const pair = assignment(line.replace(/^export\s+/, "")); return pair === null ? [] : [[pair[0], pair[1].replace(/^(["'])(.*)\1$/, "$2")]]; });
|
|
79
|
+
}
|
|
80
|
+
/** A systemd unit's (or drop-in's) Environment= assignments, ExecStart words and EnvironmentFile= paths. */
|
|
81
|
+
function systemdParts(content) {
|
|
82
|
+
const env = [];
|
|
83
|
+
const files = [];
|
|
84
|
+
let args = [];
|
|
85
|
+
for (const raw of content.split("\n")) {
|
|
86
|
+
const line = raw.trim();
|
|
87
|
+
const value = (key) => line.startsWith(`${key}=`) ? line.slice(key.length + 1) : null;
|
|
88
|
+
const environment = value("Environment");
|
|
89
|
+
if (environment !== null)
|
|
90
|
+
for (const word of systemdWords(environment)) {
|
|
91
|
+
const pair = assignment(word);
|
|
92
|
+
if (pair !== null)
|
|
93
|
+
env.push(pair);
|
|
94
|
+
}
|
|
95
|
+
const file = value("EnvironmentFile");
|
|
96
|
+
if (file !== null && file.trim() !== "")
|
|
97
|
+
files.push({ path: file.trim().replace(/^-/, ""), optional: file.trim().startsWith("-") });
|
|
98
|
+
const exec = value("ExecStart");
|
|
99
|
+
if (exec !== null)
|
|
100
|
+
args = systemdWords(exec.replace(/^[-@:+!]+/, ""));
|
|
101
|
+
}
|
|
102
|
+
return { env, args, files };
|
|
103
|
+
}
|
|
104
|
+
/** Only understood options may enter remediation. Free-form values are never reflected, even when they
|
|
105
|
+
* look like paths or runner names: any one could actually contain a credential. */
|
|
106
|
+
function reinstallCommand(args) {
|
|
107
|
+
const at = args.indexOf("watch");
|
|
108
|
+
if (at < 0)
|
|
109
|
+
return null;
|
|
110
|
+
const freeform = new Set(["--runner", "--repo", "--token-file", "--pool", "--model", "--repair-model", "--plan-model"]);
|
|
111
|
+
const numeric = new Set(["--max", "--turns", "--tick-every", "--bridge-every", "--reconcile-every", "--max-open-decisions"]);
|
|
112
|
+
const enums = {
|
|
113
|
+
"--provider": ["claude", "codex", "gemini", "openrouter", "opencode", "cursor", "cline", "qwen", "goose"],
|
|
114
|
+
"--plan-provider": ["claude", "codex", "gemini", "openrouter"],
|
|
115
|
+
"--containment": ["observed", "preferred", "required"],
|
|
116
|
+
};
|
|
117
|
+
const kept = [];
|
|
118
|
+
let rejected = false;
|
|
119
|
+
for (let index = at + 1; index < args.length; index += 1) {
|
|
120
|
+
const arg = args[index];
|
|
121
|
+
const equal = arg.indexOf("=");
|
|
122
|
+
const flag = equal < 0 ? arg : arg.slice(0, equal);
|
|
123
|
+
const value = equal < 0 ? args[index + 1] : arg.slice(equal + 1);
|
|
124
|
+
const known = freeform.has(flag) || numeric.has(flag) || Object.hasOwn(enums, flag);
|
|
125
|
+
if (equal < 0 && (known || CREDENTIAL_FLAG.test(flag)) && value !== undefined && !value.startsWith("-"))
|
|
126
|
+
index += 1;
|
|
127
|
+
if (flag === "--token") {
|
|
128
|
+
kept.push("--token-file", "<set-again>");
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (!known) {
|
|
132
|
+
rejected = true;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const safe = value !== undefined && scanForSecrets(value).length === 0;
|
|
136
|
+
const literal = safe && numeric.has(flag) && /^[0-9]{1,8}$/.test(value) ? String(Number(value))
|
|
137
|
+
: safe ? enums[flag]?.find(candidate => candidate === value) : undefined;
|
|
138
|
+
kept.push(flag, literal ?? "<set-again>");
|
|
139
|
+
}
|
|
140
|
+
if (rejected)
|
|
141
|
+
kept.push("<set-again>");
|
|
142
|
+
return ["toolroll", "daemon", "install", ...kept].join(" ");
|
|
143
|
+
}
|
|
144
|
+
function reinstallSteps(command) {
|
|
145
|
+
return [
|
|
146
|
+
`Prepare this reinstall command: ${command}`,
|
|
147
|
+
"Replace each <set-again> beside a flag with its non-secret setting. For --token-file, use the path to the runner token file (mode 0600). Unknown or credential arguments were omitted; remove the final standalone placeholder and re-enter only supported non-secret options from `toolroll daemon install --help`. Never put a key or token value in the command.",
|
|
148
|
+
"Save provider keys with `toolroll keys set` and project tool secrets on the project's Tools page before reinstalling. Review the completed command, then run it yourself; nothing has been changed.",
|
|
149
|
+
];
|
|
150
|
+
}
|
|
151
|
+
/** One shell word, quoted when it needs to be. */
|
|
152
|
+
const quote = (part) => /^[A-Za-z0-9@%+=:,./_-]+$/.test(part) ? part : `'${part.replace(/'/g, "'\\''")}'`;
|
|
153
|
+
/** Where a provider key goes instead, per provider, or the general home for any other secret. */
|
|
154
|
+
function keySteps(names) {
|
|
155
|
+
const providers = [...new Set(names.flatMap(name => Object.entries(OWN_KEY_ENV).filter(([, env]) => env.includes(name)).map(([provider]) => provider)))].sort();
|
|
156
|
+
return [
|
|
157
|
+
...providers.map(provider => `Keep the ${provider} key in Toolroll instead: \`toolroll keys set ${provider} < <file holding the key>\` (then delete that file).`),
|
|
158
|
+
...(names.some(name => !Object.values(OWN_KEY_ENV).some(env => env.includes(name)))
|
|
159
|
+
? ["Set a project tool's secret on that project's Tools page; anything else the service doesn't need can simply go."] : []),
|
|
160
|
+
];
|
|
161
|
+
}
|
|
162
|
+
/** Toolroll's installed service definitions on this computer, current and older names alike. */
|
|
163
|
+
function installedServices(platform, home) {
|
|
164
|
+
const dir = platform === "darwin" ? join(home, "Library", "LaunchAgents") : join(home, ".config", "systemd", "user");
|
|
165
|
+
const suffix = platform === "darwin" ? ".plist" : ".service";
|
|
166
|
+
let names = [];
|
|
167
|
+
try {
|
|
168
|
+
names = readdirSync(dir);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return [];
|
|
172
|
+
}
|
|
173
|
+
return names.filter(name => name.endsWith(suffix) && LABEL.test(name.slice(0, -suffix.length))).sort()
|
|
174
|
+
.flatMap(name => { const one = installedServiceDefinition({ label: name.slice(0, -suffix.length), platform, home }); return one === null ? [] : [one]; });
|
|
175
|
+
}
|
|
176
|
+
/** A running process's environment names (never values) that hold a credential: macOS through `ps -E`, which reads
|
|
177
|
+
* KERN_PROCARGS2; Linux from /proc/<pid>/environ. */
|
|
178
|
+
export async function processCredentialNames(pid, deps) {
|
|
179
|
+
if (deps.platform === "linux") {
|
|
180
|
+
try {
|
|
181
|
+
const raw = readFileSync(join(deps.procRoot ?? "/proc", String(pid), "environ"), "utf8");
|
|
182
|
+
return { ok: true, names: credentialNames(raw.split("\0").flatMap(entry => { const pair = assignment(entry); return pair === null ? [] : [pair]; })) };
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
return { ok: false, problem: `its environment (/proc/${pid}/environ) couldn't be read (${error.code ?? "error"})` };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (deps.platform === "darwin") {
|
|
189
|
+
const [plain, full] = await Promise.all([
|
|
190
|
+
deps.run("/bin/ps", ["-ww", "-o", "command=", "-p", String(pid)]),
|
|
191
|
+
deps.run("/bin/ps", ["-wwE", "-o", "command=", "-p", String(pid)]),
|
|
192
|
+
]);
|
|
193
|
+
if (plain.code !== 0 || full.code !== 0 || full.stdout.trim() === "")
|
|
194
|
+
return { ok: false, problem: `its environment couldn't be read with ps (exit ${full.code})` };
|
|
195
|
+
const command = plain.stdout.replace(/\n$/, ""), whole = full.stdout.replace(/\n$/, "");
|
|
196
|
+
if (command === "" || !whole.startsWith(command) || (whole.length > command.length && whole[command.length] !== " ")) {
|
|
197
|
+
return { ok: false, problem: "the process command changed or its environment boundary is unclear" };
|
|
198
|
+
}
|
|
199
|
+
const tail = whole.slice(command.length).trimStart();
|
|
200
|
+
if (tail === "")
|
|
201
|
+
return { ok: true, names: [] };
|
|
202
|
+
// ps separates entries with spaces and doesn't quote, so a value with spaces can look like more entries. Read it
|
|
203
|
+
// names-only: every NAME= at the start or after a space is a candidate (a few may be text inside a value, which
|
|
204
|
+
// only ever adds a name to report), each candidate's value runs to the next one, and the whole tail is also scanned
|
|
205
|
+
// for key-shaped text so a secret inside a value is never missed. Fail closed only when nothing parses.
|
|
206
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*=/.test(tail)) {
|
|
207
|
+
return { ok: false, problem: "ps returned an environment Toolroll couldn't parse" };
|
|
208
|
+
}
|
|
209
|
+
const starts = [...tail.matchAll(/(?:^|\s)([A-Za-z_][A-Za-z0-9_]*)=/g)].map(m => ({ name: m[1], at: m.index + m[0].length }));
|
|
210
|
+
const pairs = starts.map((one, i) => [one.name, tail.slice(one.at, i + 1 < starts.length ? starts[i + 1].at - starts[i + 1].name.length - 1 : tail.length).trim()]);
|
|
211
|
+
const names = new Set(credentialNames(pairs));
|
|
212
|
+
if (scanForSecrets(tail).length > 0 && names.size === 0)
|
|
213
|
+
names.add("(a key-shaped value; its variable couldn't be told apart)");
|
|
214
|
+
return { ok: true, names: [...names].sort() };
|
|
215
|
+
}
|
|
216
|
+
return { ok: false, problem: `Toolroll can't read a process's environment on ${deps.platform}` };
|
|
217
|
+
}
|
|
218
|
+
/** Audit what Toolroll's service holds. Reads only; the report names variables, never their values. */
|
|
219
|
+
export async function auditServiceEnvironment(deps = {}) {
|
|
220
|
+
const platform = deps.platform ?? process.platform;
|
|
221
|
+
const home = deps.home ?? homedir();
|
|
222
|
+
const uid = deps.uid ?? (typeof process.getuid === "function" ? process.getuid() : 501);
|
|
223
|
+
const run = deps.run ?? runCommand;
|
|
224
|
+
const report = { ok: true, checked: [], findings: [], unreadable: [], notes: [] };
|
|
225
|
+
const find = (where, names, steps) => { if (names.length > 0)
|
|
226
|
+
report.findings.push({ where, names, steps }); };
|
|
227
|
+
// The ambient fallback: a key in this terminal keeps working for a Toolroll started here, and the owner should know
|
|
228
|
+
// that Toolroll would then hold it.
|
|
229
|
+
const ambient = credentialNames(Object.entries(deps.env ?? process.env).filter((pair) => ALL_CREDENTIAL_ENV.includes(pair[0]) && typeof pair[1] === "string"));
|
|
230
|
+
if (ambient.length > 0) {
|
|
231
|
+
report.notes.push(`This terminal exports ${ambient.join(", ")}. A Toolroll started from here still uses it when no key is saved, and holds it where a build without the macOS sandbox could read it. ${keySteps(ambient).join(" ")} Then remove the export from your shell profile.`);
|
|
232
|
+
}
|
|
233
|
+
if (platform !== "darwin" && platform !== "linux") {
|
|
234
|
+
report.unreadable.push({ where: `services on ${platform}`, problem: `Toolroll can't check its services on ${platform}`,
|
|
235
|
+
steps: ["Open the service that runs `toolroll watch` and make sure its environment sets no API key, token or password.", ...keySteps([...ALL_CREDENTIAL_ENV])] });
|
|
236
|
+
report.ok = false;
|
|
237
|
+
return report;
|
|
238
|
+
}
|
|
239
|
+
// What `daemon install` writes: planned exactly as install plans it, from this process's own environment.
|
|
240
|
+
const planned = planDaemon({ platform, bin: process.execPath, binArgs: [], runner: "runner", repo: home, configDir: join(home, ".toolroll"), watchFlags: [], home });
|
|
241
|
+
if (!("error" in planned)) {
|
|
242
|
+
const parts = platform === "darwin" ? plistParts(planned.unitContent) : systemdParts(planned.unitContent);
|
|
243
|
+
report.checked.push(`\`toolroll daemon install\` writes ${parts.env.map(([name]) => name).join(", ")} into the service; its runner token stays in a 0600 file.`);
|
|
244
|
+
find("what `toolroll daemon install` writes", [...credentialNames(parts.env), ...commandCredentials(parts.args)], ["This is a Toolroll bug: don't reinstall the service until it is fixed, and report it."]);
|
|
245
|
+
}
|
|
246
|
+
const services = installedServices(platform, home);
|
|
247
|
+
report.checked.push(services.length === 0 ? "No Toolroll service is installed." : `Installed services: ${services.map(one => one.label).join(", ")}.`);
|
|
248
|
+
// The environment every service of this login inherits from its manager.
|
|
249
|
+
if (platform === "darwin") {
|
|
250
|
+
const inherited = [];
|
|
251
|
+
let failed = null;
|
|
252
|
+
for (const name of ALL_CREDENTIAL_ENV) {
|
|
253
|
+
// An unset name answers exit 0 and nothing; any other exit is a read that didn't happen.
|
|
254
|
+
const answer = await run("launchctl", ["getenv", name]);
|
|
255
|
+
if (answer.code !== 0)
|
|
256
|
+
failed ??= answer.code;
|
|
257
|
+
else if (answer.stdout.trim() !== "")
|
|
258
|
+
inherited.push(name);
|
|
259
|
+
}
|
|
260
|
+
if (failed === null)
|
|
261
|
+
report.checked.push("launchd's environment for your login (launchctl getenv).");
|
|
262
|
+
else if (services.length > 0) {
|
|
263
|
+
report.unreadable.push({ where: "launchd's environment for your login", problem: `launchctl getenv failed (exit ${failed})`,
|
|
264
|
+
steps: [`Run \`launchctl getenv <name>\` yourself for ${ALL_CREDENTIAL_ENV.join(", ")} and check each prints nothing.`] });
|
|
265
|
+
}
|
|
266
|
+
else
|
|
267
|
+
report.notes.push("launchd couldn't be asked for your login's environment; a service installed later inherits it.");
|
|
268
|
+
find("launchd's environment for your login, which every service started by launchd inherits", inherited, [
|
|
269
|
+
...inherited.map(name => `launchctl unsetenv ${name}`),
|
|
270
|
+
`Remove any \`launchctl setenv\` of ${inherited.join(", ")} from your shell profile and login items, so it doesn't come back.`,
|
|
271
|
+
...keySteps(inherited),
|
|
272
|
+
...services.map(one => `launchctl kickstart -k gui/${uid}/${one.label}`),
|
|
273
|
+
]);
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
const answer = await run("systemctl", ["--user", "show-environment"]);
|
|
277
|
+
if (answer.code === 0) {
|
|
278
|
+
report.checked.push("The systemd user manager's environment (systemctl --user show-environment).");
|
|
279
|
+
const inherited = credentialNames(envLines(answer.stdout));
|
|
280
|
+
find("the systemd user manager's environment, which every user service inherits", inherited, [
|
|
281
|
+
...inherited.map(name => `systemctl --user unset-environment ${name}`),
|
|
282
|
+
`Remove ${inherited.join(", ")} from ~/.config/environment.d/ and any \`systemctl --user import-environment\` or \`dbus-update-activation-environment\` in your shell profile, so it doesn't come back.`,
|
|
283
|
+
...keySteps(inherited),
|
|
284
|
+
...services.map(one => `systemctl --user restart ${one.label}`),
|
|
285
|
+
]);
|
|
286
|
+
}
|
|
287
|
+
else if (services.length > 0) {
|
|
288
|
+
report.unreadable.push({ where: "the systemd user manager's environment", problem: `systemctl --user show-environment failed (exit ${answer.code})`,
|
|
289
|
+
steps: ["Run `systemctl --user show-environment` yourself and check it lists no API key, token or password."] });
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
report.notes.push("The systemd user manager couldn't be asked for its environment; a service installed later inherits it.");
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
for (const service of services) {
|
|
296
|
+
const unreadablePlist = platform === "darwin" && (!/<plist\b/.test(service.unitContent) || !/<\/plist>/.test(service.unitContent) || service.unitContent.startsWith("bplist"));
|
|
297
|
+
if (unreadablePlist) {
|
|
298
|
+
report.unreadable.push({ where: `${service.label}'s definition (${service.unitPath})`,
|
|
299
|
+
problem: "this launchd definition is binary or unreadable XML",
|
|
300
|
+
steps: [`Inspect it locally with /usr/bin/plutil -p ${quote(service.unitPath)} and remove credential values before reinstalling; do not share that output.`] });
|
|
301
|
+
}
|
|
302
|
+
if (!unreadablePlist)
|
|
303
|
+
report.checked.push(`${service.label}: its definition (${service.unitPath}).`);
|
|
304
|
+
const reinstall = reinstallCommand(platform === "darwin" ? plistParts(service.unitContent).args : systemdParts(service.unitContent).args);
|
|
305
|
+
if (platform === "darwin" && !unreadablePlist) {
|
|
306
|
+
const parts = plistParts(service.unitContent);
|
|
307
|
+
const names = credentialNames(parts.env);
|
|
308
|
+
const inline = commandCredentials(parts.args);
|
|
309
|
+
find(`${service.label}'s definition (${service.unitPath})`, [...names, ...inline], [
|
|
310
|
+
...(reinstall !== null ? reinstallSteps(reinstall)
|
|
311
|
+
: [...names.map(name => `/usr/libexec/PlistBuddy -c ${quote(`Delete :EnvironmentVariables:${name}`)} ${quote(service.unitPath)}`),
|
|
312
|
+
...(inline.length > 0 ? [`Remove ${inline.join(", ")} from ProgramArguments in ${service.unitPath}.`] : []),
|
|
313
|
+
`launchctl bootout gui/${uid}/${service.label}`, `launchctl bootstrap gui/${uid} ${quote(service.unitPath)}`]),
|
|
314
|
+
...keySteps(names),
|
|
315
|
+
]);
|
|
316
|
+
}
|
|
317
|
+
else if (platform === "linux") {
|
|
318
|
+
const parts = systemdParts(service.unitContent);
|
|
319
|
+
const dropins = `${service.unitPath}.d`;
|
|
320
|
+
let dropinFiles = [];
|
|
321
|
+
try {
|
|
322
|
+
dropinFiles = readdirSync(dropins).filter(name => name.endsWith(".conf")).sort().map(name => join(dropins, name));
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
dropinFiles = [];
|
|
326
|
+
}
|
|
327
|
+
const sources = [{ where: service.unitPath, env: parts.env, args: parts.args }];
|
|
328
|
+
const envFiles = [...parts.files];
|
|
329
|
+
for (const file of dropinFiles) {
|
|
330
|
+
try {
|
|
331
|
+
const one = systemdParts(readFileSync(file, "utf8"));
|
|
332
|
+
sources.push({ where: file, env: one.env, args: one.args });
|
|
333
|
+
envFiles.push(...one.files);
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
report.unreadable.push({ where: file, problem: "this drop-in couldn't be read", steps: [`Check ${file} sets no API key, token or password.`] });
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
for (const file of envFiles) {
|
|
340
|
+
if (file.optional && !existsSync(file.path))
|
|
341
|
+
continue;
|
|
342
|
+
try {
|
|
343
|
+
sources.push({ where: `${file.path} (its EnvironmentFile)`, env: envLines(readFileSync(file.path, "utf8")), args: [] });
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
report.unreadable.push({ where: file.path, problem: "this environment file couldn't be read", steps: [`Check ${file.path} sets no API key, token or password, or remove its EnvironmentFile= line.`] });
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
for (const source of sources) {
|
|
350
|
+
const names = credentialNames(source.env);
|
|
351
|
+
const inline = commandCredentials(source.args);
|
|
352
|
+
find(`${service.label}: ${source.where}`, [...names, ...inline], [
|
|
353
|
+
...(source.where === service.unitPath && reinstall !== null ? reinstallSteps(reinstall)
|
|
354
|
+
: [`Remove ${[...names, ...inline].join(", ")} from ${source.where}.`]),
|
|
355
|
+
...keySteps(names),
|
|
356
|
+
"systemctl --user daemon-reload", `systemctl --user restart ${service.label}`,
|
|
357
|
+
]);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
const status = await daemonStatus(service, run);
|
|
361
|
+
if (status.state !== "running") {
|
|
362
|
+
report.notes.push(`${service.label} isn't running (${status.state}); see its definition check above.`);
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
if (status.pid === null) {
|
|
366
|
+
report.unreadable.push({ where: `${service.label}'s running process`, problem: "its process id is unknown", steps: [platform === "darwin" ? `launchctl print gui/${uid}/${service.label}` : `systemctl --user status ${service.label}`] });
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
const live = await processCredentialNames(status.pid, { platform, run, ...(deps.procRoot === undefined ? {} : { procRoot: deps.procRoot }) });
|
|
370
|
+
if (!live.ok) {
|
|
371
|
+
report.unreadable.push({ where: `${service.label}'s running process (pid ${status.pid})`, problem: live.problem,
|
|
372
|
+
steps: [platform === "darwin" ? `Run \`ps -wwE -o command= -p ${status.pid}\` yourself and check it shows no API key, token or password.` : `Run \`tr '\\0' '\\n' < /proc/${status.pid}/environ | cut -d= -f1\` yourself and check no name is a key, token or password.`] });
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
report.checked.push(`${service.label}: its running process (pid ${status.pid}).`);
|
|
376
|
+
find(`${service.label}'s running process (pid ${status.pid})`, live.names, [
|
|
377
|
+
`Find where it got ${live.names.join(", ")}: its definition and its manager's environment are listed above when they hold it; otherwise it was started from a terminal that exports it.`,
|
|
378
|
+
...keySteps(live.names),
|
|
379
|
+
platform === "darwin" ? `launchctl kickstart -k gui/${uid}/${service.label}` : `systemctl --user restart ${service.label}`,
|
|
380
|
+
]);
|
|
381
|
+
}
|
|
382
|
+
report.ok = report.findings.length === 0 && report.unreadable.length === 0;
|
|
383
|
+
return report;
|
|
384
|
+
}
|
|
385
|
+
/** The report as the owner reads it: one state, then each finding with its steps; detail after. */
|
|
386
|
+
export function doctorLines(report) {
|
|
387
|
+
const lines = [];
|
|
388
|
+
if (report.ok)
|
|
389
|
+
lines.push("No secrets found in Toolroll's service. A build can't read a key from it.");
|
|
390
|
+
else if (report.findings.length > 0)
|
|
391
|
+
lines.push(`Toolroll's service holds ${report.findings.length === 1 ? "a secret" : "secrets"} a build could read. Nothing was changed; follow these steps.`);
|
|
392
|
+
else
|
|
393
|
+
lines.push("Toolroll couldn't check everything, so it can't say the service is clean. Nothing was changed.");
|
|
394
|
+
for (const finding of report.findings) {
|
|
395
|
+
lines.push("", `${finding.where}: ${finding.names.join(", ")}`);
|
|
396
|
+
finding.steps.forEach((step, index) => lines.push(` ${index + 1}. ${step}`));
|
|
397
|
+
}
|
|
398
|
+
for (const gap of report.unreadable) {
|
|
399
|
+
lines.push("", `Not checked: ${gap.where}: ${gap.problem}.`);
|
|
400
|
+
gap.steps.forEach((step, index) => lines.push(` ${index + 1}. ${step}`));
|
|
401
|
+
}
|
|
402
|
+
for (const note of report.notes)
|
|
403
|
+
lines.push("", `Note: ${note}`);
|
|
404
|
+
if (report.checked.length > 0)
|
|
405
|
+
lines.push("", "Checked:", ...report.checked.map(one => ` ${one}`));
|
|
406
|
+
return lines;
|
|
407
|
+
}
|
|
408
|
+
export const DOCTOR_HELP = "toolroll doctor [--json]\nChecks that Toolroll's service holds no API key, token or password a build could read: its service definitions, what `daemon install` writes, the environment its manager hands it, and its running process. Reads only: it names what it finds and the steps to fix it, and never changes a file or restarts anything.";
|
|
409
|
+
export async function runDoctorCommand(argv, write, deps = {}) {
|
|
410
|
+
const json = argv.includes("--json");
|
|
411
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
412
|
+
write(DOCTOR_HELP);
|
|
413
|
+
return 0;
|
|
414
|
+
}
|
|
415
|
+
if (argv.some(arg => arg !== "--json")) {
|
|
416
|
+
const message = "Use toolroll doctor [--json].";
|
|
417
|
+
write(json ? envelopeJson({ command: "doctor", ok: false, reason: "usage", message }) : message);
|
|
418
|
+
return 2;
|
|
419
|
+
}
|
|
420
|
+
const report = await auditServiceEnvironment(deps);
|
|
421
|
+
if (json)
|
|
422
|
+
write(envelopeJson({ command: "doctor", ...report }));
|
|
423
|
+
else
|
|
424
|
+
for (const line of doctorLines(report))
|
|
425
|
+
write(line);
|
|
426
|
+
return report.ok ? 0 : 1;
|
|
427
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** The prefix of a bridge folder in the temp folder (agent-fence.ts RUN_FOLDER_PREFIXES denies every other run's). */
|
|
2
|
+
export declare const BRIDGE_PREFIX = "so-net-";
|
|
3
|
+
/** The forwarder, run inside the namespace as `node forward.mjs <folder> <port>... -- <command>`. Zero dependencies. */
|
|
4
|
+
export declare const FORWARDER_SOURCE: string;
|
|
5
|
+
/** Whether a bridge made under `root` keeps every socket path within the limit (its folder and `<port>.sock`). */
|
|
6
|
+
export declare function bridgeFits(root: string): boolean;
|
|
7
|
+
export type EgressBridge = {
|
|
8
|
+
folder: string;
|
|
9
|
+
forwarder: string;
|
|
10
|
+
close: () => Promise<void>;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Serve one socket per port (each relayed to that port on the host's loopback) in a fresh owner-only folder under
|
|
14
|
+
* `root`: the plane's own bridge folder beside the database (agent-fence.ts BRIDGE_DIR), which every build's fence
|
|
15
|
+
* hides from launch, so no other build can change the forwarder or reach the sockets.
|
|
16
|
+
*/
|
|
17
|
+
export declare function startEgressBridge(ports: readonly number[], root?: string): Promise<EgressBridge>;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a Linux build with a network of its own reaches its proxy and tool broker (agent-fence.ts linuxFenced). The
|
|
3
|
+
* plane serves one unix socket per port in a folder of its own (`<folder>/<port>.sock`, each relayed to that port on
|
|
4
|
+
* the host's loopback); bubblewrap binds the folder read-only into the build's namespace, where the forwarder below
|
|
5
|
+
* listens on the namespace's own 127.0.0.1 at the same ports and relays each connection to its socket. Only those
|
|
6
|
+
* ports cross: nothing else of the host's network is reachable, and the relay never opens a connection of its own.
|
|
7
|
+
*
|
|
8
|
+
* The forwarder is written into the folder at launch (the plane's own file, read-only to the build), so it never runs
|
|
9
|
+
* from an installation path a fence might hide or a build could change.
|
|
10
|
+
*/
|
|
11
|
+
import { chmodSync, lstatSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { connect, createServer } from "node:net";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
/** The prefix of a bridge folder in the temp folder (agent-fence.ts RUN_FOLDER_PREFIXES denies every other run's). */
|
|
16
|
+
export const BRIDGE_PREFIX = "so-net-";
|
|
17
|
+
/** The forwarder, run inside the namespace as `node forward.mjs <folder> <port>... -- <command>`. Zero dependencies. */
|
|
18
|
+
export const FORWARDER_SOURCE = String.raw `import { connect, createServer } from "node:net";
|
|
19
|
+
import { spawn } from "node:child_process";
|
|
20
|
+
const argv = process.argv.slice(2);
|
|
21
|
+
const split = argv.indexOf("--");
|
|
22
|
+
if (split < 1 || split === argv.length - 1) { process.stderr.write("toolroll forwarder: usage <folder> <port>... -- <command>\n"); process.exit(2); }
|
|
23
|
+
const folder = argv[0];
|
|
24
|
+
const ports = argv.slice(1, split).map(Number);
|
|
25
|
+
const command = argv.slice(split + 1);
|
|
26
|
+
let ready = 0;
|
|
27
|
+
for (const port of ports) {
|
|
28
|
+
const server = createServer(client => {
|
|
29
|
+
const upstream = connect(folder + "/" + port + ".sock");
|
|
30
|
+
client.on("error", () => upstream.destroy());
|
|
31
|
+
upstream.on("error", () => client.destroy());
|
|
32
|
+
client.pipe(upstream);
|
|
33
|
+
upstream.pipe(client);
|
|
34
|
+
});
|
|
35
|
+
server.on("error", error => { process.stderr.write("toolroll forwarder: " + error.message + "\n"); process.exit(70); });
|
|
36
|
+
server.listen(port, "127.0.0.1", () => { if (++ready === ports.length) start(); });
|
|
37
|
+
}
|
|
38
|
+
if (ports.length === 0) start();
|
|
39
|
+
function start() {
|
|
40
|
+
const child = spawn(command[0], command.slice(1), { stdio: "inherit" });
|
|
41
|
+
for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"]) process.on(signal, () => child.kill(signal));
|
|
42
|
+
child.on("error", error => { process.stderr.write("toolroll forwarder: " + error.message + "\n"); process.exit(127); });
|
|
43
|
+
child.on("exit", (code, signal) => {
|
|
44
|
+
if (signal === null) process.exit(code ?? 1);
|
|
45
|
+
// Die the way the agent died: this process's own handlers (which only pass a signal on) step aside first.
|
|
46
|
+
process.removeAllListeners(signal);
|
|
47
|
+
process.kill(process.pid, signal);
|
|
48
|
+
setTimeout(() => process.exit(128), 1000).unref();
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
`;
|
|
52
|
+
/** The longest a unix socket's path may be here (macOS 104 bytes, Linux 108, with room to spare). */
|
|
53
|
+
const SOCKET_PATH_MAX = 100;
|
|
54
|
+
/** Whether a bridge made under `root` keeps every socket path within the limit (its folder and `<port>.sock`). */
|
|
55
|
+
export function bridgeFits(root) {
|
|
56
|
+
return join(root, `${BRIDGE_PREFIX}XXXXXX`, "65535.sock").length <= SOCKET_PATH_MAX;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Serve one socket per port (each relayed to that port on the host's loopback) in a fresh owner-only folder under
|
|
60
|
+
* `root`: the plane's own bridge folder beside the database (agent-fence.ts BRIDGE_DIR), which every build's fence
|
|
61
|
+
* hides from launch, so no other build can change the forwarder or reach the sockets.
|
|
62
|
+
*/
|
|
63
|
+
export async function startEgressBridge(ports, root = tmpdir()) {
|
|
64
|
+
// The root must be a real folder of this user's alone, never a link a build could have planted before it existed.
|
|
65
|
+
const held = lstatSync(root);
|
|
66
|
+
if (held.isSymbolicLink() || !held.isDirectory() || (process.getuid !== undefined && held.uid !== process.getuid()) || (root !== tmpdir() && (held.mode & 0o077) !== 0)) {
|
|
67
|
+
throw new Error(`the bridge folder ${root} isn't a private folder of this user's`);
|
|
68
|
+
}
|
|
69
|
+
if (!bridgeFits(root))
|
|
70
|
+
throw new Error(`the bridge folder ${root} is too deep for a socket path`);
|
|
71
|
+
const folder = mkdtempSync(join(root, BRIDGE_PREFIX));
|
|
72
|
+
chmodSync(folder, 0o700);
|
|
73
|
+
const forwarder = join(folder, "forward.mjs");
|
|
74
|
+
writeFileSync(forwarder, FORWARDER_SOURCE, { mode: 0o500, flag: "wx" });
|
|
75
|
+
const servers = [];
|
|
76
|
+
const sockets = new Set();
|
|
77
|
+
try {
|
|
78
|
+
for (const port of ports) {
|
|
79
|
+
const server = createServer(client => {
|
|
80
|
+
sockets.add(client);
|
|
81
|
+
const upstream = connect(port, "127.0.0.1");
|
|
82
|
+
sockets.add(upstream);
|
|
83
|
+
const drop = () => { client.destroy(); upstream.destroy(); };
|
|
84
|
+
client.on("error", drop);
|
|
85
|
+
upstream.on("error", drop);
|
|
86
|
+
client.on("close", () => { sockets.delete(client); upstream.destroy(); });
|
|
87
|
+
upstream.on("close", () => { sockets.delete(upstream); client.destroy(); });
|
|
88
|
+
client.pipe(upstream);
|
|
89
|
+
upstream.pipe(client);
|
|
90
|
+
});
|
|
91
|
+
await new Promise((done, fail) => { server.once("error", fail); server.listen(join(folder, `${port}.sock`), () => { server.off("error", fail); done(); }); });
|
|
92
|
+
servers.push(server);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
for (const server of servers)
|
|
97
|
+
server.close();
|
|
98
|
+
rmSync(folder, { recursive: true, force: true });
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
folder,
|
|
103
|
+
forwarder,
|
|
104
|
+
close: async () => {
|
|
105
|
+
for (const socket of sockets)
|
|
106
|
+
socket.destroy();
|
|
107
|
+
await Promise.all(servers.map(server => new Promise(done => server.close(() => done()))));
|
|
108
|
+
rmSync(folder, { recursive: true, force: true });
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|