toolroll 0.9.62 → 0.9.63

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +51 -3
  3. package/dist/agent-fence.d.ts +155 -10
  4. package/dist/agent-fence.js +473 -38
  5. package/dist/browser/workspace.css +1 -1
  6. package/dist/browser/workspace.js +21 -21
  7. package/dist/browser-workspace.d.ts +28 -0
  8. package/dist/chat-actions.js +2 -2
  9. package/dist/check-levels.js +3 -0
  10. package/dist/claim.js +1 -1
  11. package/dist/cli-help.d.ts +1 -1
  12. package/dist/cli-help.js +1 -0
  13. package/dist/cli.js +3 -0
  14. package/dist/contracts/cli.d.ts +8 -0
  15. package/dist/contracts/cli.js +3 -1
  16. package/dist/contracts/console-api.d.ts +19 -1
  17. package/dist/contracts/console-api.js +2 -1
  18. package/dist/contracts/integration-metadata.d.ts +2 -1
  19. package/dist/contracts/integration-metadata.js +2 -1
  20. package/dist/desktop-update.d.ts +1 -1
  21. package/dist/dispatch.js +7 -4
  22. package/dist/doctor.d.ts +53 -0
  23. package/dist/doctor.js +427 -0
  24. package/dist/evidence-pack.d.ts +1 -1
  25. package/dist/evidence-pack.js +2 -2
  26. package/dist/exec.d.ts +3 -0
  27. package/dist/exec.js +5 -4
  28. package/dist/flow-code.d.ts +2 -2
  29. package/dist/flow-code.js +4 -15
  30. package/dist/flow-engine.js +11 -6
  31. package/dist/flow-triggers.d.ts +8 -4
  32. package/dist/flow-triggers.js +45 -25
  33. package/dist/flow-unattended-words.d.ts +28 -0
  34. package/dist/flow-unattended-words.js +50 -0
  35. package/dist/flow-unattended.d.ts +227 -0
  36. package/dist/flow-unattended.js +776 -0
  37. package/dist/flows-cli.d.ts +28 -1
  38. package/dist/flows-cli.js +55 -10
  39. package/dist/flows-ui.d.ts +2 -1
  40. package/dist/flows-ui.js +4 -1
  41. package/dist/google-mail.d.ts +1 -0
  42. package/dist/google-mail.js +61 -18
  43. package/dist/integrations-ui.d.ts +2 -1
  44. package/dist/integrations-ui.js +6 -3
  45. package/dist/integrations.d.ts +17 -3
  46. package/dist/integrations.js +38 -18
  47. package/dist/invoke.js +300 -168
  48. package/dist/landed-cleanup.d.ts +3 -2
  49. package/dist/landed-cleanup.js +12 -5
  50. package/dist/lead-tools.js +11 -3
  51. package/dist/mcp-connect.d.ts +8 -7
  52. package/dist/mcp-connect.js +49 -51
  53. package/dist/oauth-run-token.d.ts +51 -0
  54. package/dist/oauth-run-token.js +127 -0
  55. package/dist/operate.js +7 -1
  56. package/dist/plan-auto.d.ts +11 -0
  57. package/dist/plan-auto.js +62 -11
  58. package/dist/process-recovery-eligibility.d.ts +1 -1
  59. package/dist/project-access.d.ts +3 -0
  60. package/dist/project-access.js +3 -0
  61. package/dist/project-delete.d.ts +3 -1
  62. package/dist/project-delete.js +10 -4
  63. package/dist/project-file.d.ts +3 -0
  64. package/dist/project-file.js +16 -0
  65. package/dist/project-tools.d.ts +99 -25
  66. package/dist/project-tools.js +394 -98
  67. package/dist/provider-home.d.ts +22 -0
  68. package/dist/provider-home.js +99 -0
  69. package/dist/provider.d.ts +17 -3
  70. package/dist/provider.js +34 -10
  71. package/dist/remote-main.d.ts +4 -0
  72. package/dist/remote-main.js +3 -3
  73. package/dist/scope.d.ts +1 -1
  74. package/dist/serve.js +11 -3
  75. package/dist/server/flows.js +27 -5
  76. package/dist/server/route-table.js +3 -0
  77. package/dist/server/runtime.d.ts +1 -1
  78. package/dist/server/settings.js +11 -3
  79. package/dist/storage-sweep-child.js +3 -1
  80. package/dist/storage-sweep-process.d.ts +12 -5
  81. package/dist/storage-sweep-process.js +23 -6
  82. package/dist/store.d.ts +38 -6
  83. package/dist/store.js +148 -15
  84. package/dist/subagent-tools.d.ts +1 -1
  85. package/dist/surface.js +6 -4
  86. package/dist/teardown-git.d.ts +14 -0
  87. package/dist/teardown-git.js +119 -0
  88. package/dist/tool-broker.d.ts +37 -0
  89. package/dist/tool-broker.js +369 -0
  90. package/dist/tool-launcher.js +5 -4
  91. package/dist/tool-secret-store.d.ts +170 -0
  92. package/dist/tool-secret-store.js +834 -0
  93. package/dist/toolroll-update.js +3 -2
  94. package/dist/tools-ui.d.ts +7 -1
  95. package/dist/tools-ui.js +17 -8
  96. package/dist/work-index.js +5 -3
  97. package/dist/worktree.d.ts +10 -4
  98. package/dist/worktree.js +70 -29
  99. package/package.json +3 -1
  100. package/scripts/provider-canary.mjs +210 -31
package/dist/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(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/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"],
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
+ }
@@ -49,7 +49,7 @@ export type EvidencePack = {
49
49
  by: string;
50
50
  at: string;
51
51
  digest: string;
52
- basis: "person" | "mode";
52
+ basis: "person" | "mode" | "flow";
53
53
  current: boolean;
54
54
  } | null;
55
55
  writtenBy: {
@@ -55,7 +55,7 @@ export function evidencePack(store, taskId, access, who, now, evidenceRoot, chec
55
55
  goal: scope.goal, outOfScope: scope.outOfScope, touches: scope.touches, acceptance: scope.acceptance.map(one => ({ id: one.id, statement: one.statement })),
56
56
  budgetUsd: scope.budgetMicrousd === null ? null : scope.budgetMicrousd / 1_000_000, digest: scope.digest,
57
57
  approved: scope.approvedBy === null || scope.approvedAt === null || scope.approvedDigest === null ? null
58
- : { by: scope.approvedBy, at: scope.approvedAt, digest: scope.approvedDigest, basis: scope.approvalBasis === "mode" ? "mode" : "person", current: scope.approvedDigest === scope.digest },
58
+ : { by: scope.approvedBy, at: scope.approvedAt, digest: scope.approvedDigest, basis: scope.approvalBasis === "mode" ? "mode" : scope.approvalBasis === "grant" ? "flow" : "person", current: scope.approvedDigest === scope.digest },
59
59
  writtenBy: history.authors,
60
60
  approvals: history.votes,
61
61
  },
@@ -206,7 +206,7 @@ export function evidencePackHtml(pack) {
206
206
  ...(scope.touches.length ? [["Touches", joinHtml(scope.touches, ", ")]] : []),
207
207
  ...(scope.acceptance.length ? [["Acceptance", html `<ol class="criteria">${scope.acceptance.map(one => html `<li>${one.statement}</li>`)}</ol>`]] : []),
208
208
  ...(scope.writtenBy.length ? [["Scope written by", joinHtml([...new Set(scope.writtenBy.map(one => one.author))], ", ")]] : []),
209
- ["Approved", approved === null ? html `Not approved` : html `${approved.by}${approved.basis === "mode" ? " (operating mode)" : ""} · ${when(approved.at)}${approved.current ? "" : " · the scope changed after this"}`],
209
+ ["Approved", approved === null ? html `Not approved` : html `${approved.by}${approved.basis === "mode" ? " (operating mode)" : approved.basis === "flow" ? " (flow runs on its own)" : ""} · ${when(approved.at)}${approved.current ? "" : " · the scope changed after this"}`],
210
210
  ...(approvals.length > 1 ? [["Approvals", joinHtml(approvals.map(one => html `${one.approver} · ${when(one.at)}`), html `<br>`)]] : []),
211
211
  ["Scope digest", short(scope.digest)],
212
212
  ]),
package/dist/exec.d.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  * command. And nothing here rejects: a failed command is a value. Discovery
7
7
  * walks repos it has never seen, and one broken repo must not end the scan.
8
8
  */
9
+ import { type FenceGrants } from "./agent-fence.js";
9
10
  import { type ProcessTreeObserver } from "./process-tree.js";
10
11
  import { type ContainmentBackendId } from "./containment.js";
11
12
  import type { Store } from "./store.js";
@@ -43,6 +44,8 @@ export type RunOptions = ProcessTreeObserver & {
43
44
  * inside a sandbox that denies these paths. Applied here, at the one spawn
44
45
  * road, so an injected test runner still sees the provider's own argv. */
45
46
  fence?: readonly string[];
47
+ /** What the fence gives back beside those paths (agent-fence.ts FenceGrants). */
48
+ fenceGrants?: FenceGrants;
46
49
  /** UTF-8 input for JSONL or buffered process-group transports; closed after delivery. */
47
50
  stdin?: string;
48
51
  /**
package/dist/exec.js CHANGED
@@ -73,9 +73,9 @@ function rejectedChild(error, child) {
73
73
  function spawnContained(file, args, spawnOptions, label, bag, contain = true) {
74
74
  // The agent fence wraps the target itself (sandbox-exec then execs it, same pid).
75
75
  if (bag.fence !== undefined && bag.fence.length > 0 && macosFenceAvailable())
76
- ({ file, args } = macosFenced(file, args, bag.fence));
76
+ ({ file, args } = macosFenced(file, args, bag.fence, bag.fenceGrants));
77
77
  else if (bag.fence !== undefined && bag.fence.length > 0 && linuxFenceAvailable())
78
- ({ file, args } = linuxFenced(file, args, bag.fence));
78
+ ({ file, args } = linuxFenced(file, args, bag.fence, bag.fenceGrants));
79
79
  // The policy refusal comes first: nothing is reserved, recorded or
80
80
  // spawned for a spawn that cannot be contained as required.
81
81
  const effective = contain ? currentContainment() : null;
@@ -224,7 +224,7 @@ function containmentOf(container, empty, attachFailure) {
224
224
  }
225
225
  /** The streaming transports' spawn: contained when the spawn is a provider (process group), plain otherwise. */
226
226
  function spawnStream(file, args, options, stdio, childEnv) {
227
- return spawnContained(file, args, { cwd: options.cwd, env: childEnv, stdio, detached: options.processGroup === true && process.platform !== "win32" }, options.owner ?? "stream", { beforeSpawn: options.beforeSpawn, onSpawn: options.onSpawn, onSpawnFailed: options.onSpawnFailed, onContainer: options.onContainer, onContainerEmpty: options.onContainerEmpty, onUnknown: options.onUnknown, fence: options.fence }, options.processGroup === true);
227
+ return spawnContained(file, args, { cwd: options.cwd, env: childEnv, stdio, detached: options.processGroup === true && process.platform !== "win32" }, options.owner ?? "stream", { beforeSpawn: options.beforeSpawn, onSpawn: options.onSpawn, onSpawnFailed: options.onSpawnFailed, onContainer: options.onContainer, onContainerEmpty: options.onContainerEmpty, onUnknown: options.onUnknown, fence: options.fence, fenceGrants: options.fenceGrants }, options.processGroup === true);
228
228
  }
229
229
  /** The refusal-before-spawn shape shared by every containable transport. */
230
230
  function refusedResult(error) {
@@ -521,6 +521,7 @@ export function run(file, args, options = {}) {
521
521
  ...(options.onStdout === undefined ? {} : { onStdout: options.onStdout }),
522
522
  ...(options.onStderr === undefined ? {} : { onStderr: options.onStderr }),
523
523
  ...(options.fence === undefined ? {} : { fence: options.fence }),
524
+ ...(options.fenceGrants === undefined ? {} : { fenceGrants: options.fenceGrants }),
524
525
  ...(options.onDescendant === undefined ? {} : { onDescendant: options.onDescendant }),
525
526
  ...(options.onDescendantWriteFailure === undefined ? {} : { onDescendantWriteFailure: options.onDescendantWriteFailure }),
526
527
  ...(options.onDescendantExit === undefined ? {} : { onDescendantExit: options.onDescendantExit }),
@@ -563,7 +564,7 @@ function runBufferedGroup(file, args, bag) {
563
564
  let container = null;
564
565
  let attached;
565
566
  try {
566
- const spawned = spawnContained(file, args, { cwd: bag.cwd, env: bag.childEnv, stdio: [bag.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"], detached: process.platform !== "win32" }, bag.owner ?? "buffered", { beforeSpawn: bag.beforeSpawn, onSpawn: bag.onSpawn, onSpawnFailed: bag.onSpawnFailed, onContainer: bag.onContainer, onContainerEmpty: bag.onContainerEmpty, onUnknown: bag.onUnknown, fence: bag.fence });
567
+ const spawned = spawnContained(file, args, { cwd: bag.cwd, env: bag.childEnv, stdio: [bag.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"], detached: process.platform !== "win32" }, bag.owner ?? "buffered", { beforeSpawn: bag.beforeSpawn, onSpawn: bag.onSpawn, onSpawnFailed: bag.onSpawnFailed, onContainer: bag.onContainer, onContainerEmpty: bag.onContainerEmpty, onUnknown: bag.onUnknown, fence: bag.fence, fenceGrants: bag.fenceGrants });
567
568
  child = spawned.child;
568
569
  container = spawned.container;
569
570
  attached = spawned.attached;
@@ -1,11 +1,11 @@
1
1
  import type { Runner } from "./backend.js";
2
2
  import type { FlowScriptRow } from "./store.js";
3
+ import { projectFile } from "./project-file.js";
4
+ export { projectFile };
3
5
  export { LANGUAGE_WORDS, SCRIPT_LANGUAGES, type ScriptLanguage } from "./flows.js";
4
6
  /** A step's result on the card: what the script printed, up to what a step passes on (the end, when longer; the whole
5
7
  * is in the step's log). */
6
8
  export declare const CODE_OUTPUT_CHARS: 12000;
7
- /** A project file a script names, if it is one: inside the project, and there. */
8
- export declare function projectFile(root: string, path: string): string | null;
9
9
  export type CodeRun = {
10
10
  script: FlowScriptRow;
11
11
  /** Where it runs, and the project folder a file script is found in. */
package/dist/flow-code.js CHANGED
@@ -15,13 +15,15 @@
15
15
  * keys are out of reach), under a time limit, with the setup's plain
16
16
  * environment rather than this process's.
17
17
  */
18
- import { chmodSync, existsSync, mkdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
18
+ import { chmodSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
19
19
  import { randomBytes } from "node:crypto";
20
- import { isAbsolute, join, relative } from "node:path";
20
+ import { join } from "node:path";
21
21
  import { SETUP_ENV_ALLOWLIST, SETUP_ENV_DENYLIST } from "./builder.js";
22
22
  import { redactSecretLines, scanForSecrets } from "./evidence.js";
23
23
  import { scrubSecrets } from "./flow-secrets.js";
24
24
  import { TEXT_LIMITS } from "./text-limits.js";
25
+ import { projectFile } from "./project-file.js";
26
+ export { projectFile };
25
27
  import { LANGUAGE_WORDS } from "./flows.js";
26
28
  export { LANGUAGE_WORDS, SCRIPT_LANGUAGES } from "./flows.js";
27
29
  const EXTENSION = { shell: "sh", python: "py", node: "mjs" };
@@ -33,19 +35,6 @@ const LOG_CHARS = 64_000;
33
35
  function runtimeOf(language, file) {
34
36
  return language === "python" ? { file: "python3", args: [file] } : language === "node" ? { file: process.execPath, args: [file] } : { file: "/bin/sh", args: [file] };
35
37
  }
36
- /** A project file a script names, if it is one: inside the project, and there. */
37
- export function projectFile(root, path) {
38
- if (path === "" || isAbsolute(path) || path.split(/[\\/]/).includes(".."))
39
- return null;
40
- try {
41
- const base = realpathSync(root), full = realpathSync(join(root, path));
42
- const inside = relative(base, full);
43
- return inside !== "" && !inside.startsWith("..") && !isAbsolute(inside) && statSync(full).isFile() ? full : null;
44
- }
45
- catch {
46
- return null;
47
- }
48
- }
49
38
  /** What it printed, without key-shaped lines or saved secrets, and the goto line taken off the end. */
50
39
  export function codeOutput(stdout, secrets) {
51
40
  const clean = scrubSecrets(stdout.replace(/\r\n?/g, "\n"), secrets);
@@ -12,6 +12,8 @@ import { passOn, TEXT_LIMITS } from "./text-limits.js";
12
12
  import { deciderOf, durationWords, fillFlowText, flowGoal, flowValueLabel, flowWorkTemplate, validateFlowDefinition, withinHours } from "./flows.js";
13
13
  import { reportFillIns, reportSummaryFor } from "./report-summary.js";
14
14
  import { fileTaskProposal } from "./proposal.js";
15
+ import { FLOW_ACCEPTANCE, unattendedCoverageFor } from "./flow-unattended.js";
16
+ import { sealFlowFilingUnderGrant } from "./plan-auto.js";
15
17
  import { requestResultChanges } from "./result-actions.js";
16
18
  import { revisionSourceOf } from "./result-review.js";
17
19
  import { readVerifiedReport, scanForSecrets } from "./evidence.js";
@@ -33,10 +35,7 @@ export function flowDefinitionOf(flow) {
33
35
  export const flowCardHref = (flow, card) => `/flows/${flow}?card=${card}`;
34
36
  /** Where a confirmed flow action may point: a flow's canvas, maybe with one card open. */
35
37
  export const FLOW_HREF = /^\/flows\/[1-9][0-9]{0,9}(\?card=[1-9][0-9]{0,9})?$/;
36
- const ACCEPTANCE = {
37
- report: [{ id: "c1", statement: "The report answers the request, citing where in the repository each point comes from", how: null, evidence: ["manual-review"] }],
38
- task: [{ id: "c1", statement: "The requested change is made and the project's existing checks still pass", how: null, evidence: ["manual-review"] }],
39
- };
38
+ const ACCEPTANCE = FLOW_ACCEPTANCE;
40
39
  function fill(template, card) {
41
40
  return fillFlowText(template, { title: card.title, description: card.description, note: card.note, outputs: card.outputs });
42
41
  }
@@ -266,6 +265,10 @@ function workStage(store, flow, definition, stage, card, now, options, outcome,
266
265
  const fresh = store.getFlowCard(card.id);
267
266
  if (fresh === null || fresh.state !== "active" || fresh.entry !== card.entry || fresh.stage !== stage.id || fresh.task !== null)
268
267
  return null;
268
+ // A card this flow's own schedule added, while the flow runs on its own (flow-unattended.ts): the grant's frozen
269
+ // criteria and budget, sealed here, in the same transaction. Any other card files as it always did and waits.
270
+ const grant = unattendedCoverageFor(store, flow, fresh, repo, now);
271
+ const kind = report ? "report" : "task";
269
272
  const made = fileTaskProposal(store, {
270
273
  title: (report ? `${stage.title}: ${card.title}` : card.title).slice(0, 200),
271
274
  repo,
@@ -273,11 +276,13 @@ function workStage(store, flow, definition, stage, card, now, options, outcome,
273
276
  filedVia: `flow:${flow.id}`, filedBy: filerFor(card.createdBy), // who made the card asked for the work (an owner can be reassigned by anyone)
274
277
  deliverable: report ? "report" : "branch",
275
278
  planning: report ? "skip" : stage.planning ?? "auto",
276
- acceptance: report ? ACCEPTANCE.report : ACCEPTANCE.task,
279
+ acceptance: grant === null ? ACCEPTANCE[kind] : grant.terms.acceptance[kind],
280
+ ...(grant?.terms.perAttemptBudgetMicrousd == null ? {} : { budgetMicrousd: grant.terms.perAttemptBudgetMicrousd }),
277
281
  admittedRepos: [repo],
278
282
  }, now);
283
+ const ranOnItsOwn = made.ok && grant !== null && sealFlowFilingUnderGrant(store, made.id, grant, fresh, now);
279
284
  if (made.ok)
280
- store.updateFlowCard(card.id, { task: made.id, ...(report || fresh.primaryTask !== null ? {} : { primaryTask: made.id }), waiting: "Filed as a task" }, now);
285
+ store.updateFlowCard(card.id, { task: made.id, ...(report || fresh.primaryTask !== null ? {} : { primaryTask: made.id }), waiting: ranOnItsOwn ? "Filed as a task, approved: this flow runs on its own" : "Filed as a task" }, now);
281
286
  return made;
282
287
  });
283
288
  if (filed === null)