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
@@ -0,0 +1,22 @@
1
+ /** The operator's own choices a no-model build keeps. */
2
+ export type ProviderDefaults = {
3
+ model?: string;
4
+ alwaysThinking?: boolean;
5
+ effort?: string;
6
+ };
7
+ export declare function claudeConfigDir(env?: Readonly<Record<string, string | undefined>>, home?: string): string;
8
+ export declare function codexHomeDir(env?: Readonly<Record<string, string | undefined>>, home?: string): string;
9
+ /** Claude's user settings: its `model` and `alwaysThinkingEnabled`, when valid. */
10
+ export declare function claudeDefaults(configDir: string): ProviderDefaults;
11
+ /** Codex's config.toml: its top-level `model` and `model_reasoning_effort`, or the selected profile's. */
12
+ export declare function codexDefaults(codexHome: string): ProviderDefaults;
13
+ /**
14
+ * One Codex launch's CODEX_HOME: a private folder linking the operator's
15
+ * sign-in and sessions. A missing `auth.json` stays missing (an API-key
16
+ * build needs none); a missing `sessions/` is made where Codex would make
17
+ * it, so a resumed build finds the thread its first launch wrote.
18
+ */
19
+ export declare function isolatedCodexHome(source: string, root?: string): {
20
+ dir: string;
21
+ cleanup: () => void;
22
+ };
@@ -0,0 +1,99 @@
1
+ /**
2
+ * What a build keeps of the provider's own home, and nothing more.
3
+ *
4
+ * A build loads none of the operator's Claude settings or Codex config
5
+ * (provider.ts), so two things from them are carried explicitly:
6
+ *
7
+ * - A build routed with no model runs as the operator's own CLI would: the
8
+ * model they chose (Claude's settings.json `model`, Codex's config.toml
9
+ * `model`), Claude's `alwaysThinkingEnabled` and Codex's reasoning effort.
10
+ * Nothing else of either file (hooks, plugins, MCP servers) is read.
11
+ * - A Codex build gets its own CODEX_HOME for the launch: links to the
12
+ * operator's `auth.json` and `sessions/` (so it signs in, and a repair
13
+ * resumes the same thread), and no skills, AGENTS.md or anything else from
14
+ * theirs. Links, never copies: a refreshed sign-in lands in the real file.
15
+ * The folder is fenced from the agent's commands and removed after the run.
16
+ */
17
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync } from "node:fs";
18
+ import { homedir, tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+ import { validModelId } from "./provider.js";
21
+ const EFFORT = /^[a-z]{1,16}$/;
22
+ export function claudeConfigDir(env = process.env, home = homedir()) {
23
+ return env["CLAUDE_CONFIG_DIR"] || join(home, ".claude");
24
+ }
25
+ export function codexHomeDir(env = process.env, home = homedir()) {
26
+ return env["CODEX_HOME"] || join(home, ".codex");
27
+ }
28
+ /** Claude's user settings: its `model` and `alwaysThinkingEnabled`, when valid. */
29
+ export function claudeDefaults(configDir) {
30
+ let settings;
31
+ try {
32
+ settings = JSON.parse(readFileSync(join(configDir, "settings.json"), "utf8"));
33
+ }
34
+ catch {
35
+ return {};
36
+ }
37
+ if (settings === null || typeof settings !== "object" || Array.isArray(settings))
38
+ return {};
39
+ const { model, alwaysThinkingEnabled } = settings;
40
+ return {
41
+ ...(typeof model === "string" && model !== "" && validModelId(model) ? { model } : {}),
42
+ ...(typeof alwaysThinkingEnabled === "boolean" ? { alwaysThinking: alwaysThinkingEnabled } : {}),
43
+ };
44
+ }
45
+ /** Codex's config.toml: its top-level `model` and `model_reasoning_effort`, or the selected profile's. */
46
+ export function codexDefaults(codexHome) {
47
+ let text;
48
+ try {
49
+ text = readFileSync(join(codexHome, "config.toml"), "utf8");
50
+ }
51
+ catch {
52
+ return {};
53
+ }
54
+ const tables = new Map([["", {}]]);
55
+ let table = "";
56
+ for (const raw of text.split(/\r?\n/)) {
57
+ const line = raw.trim();
58
+ const header = /^\[\s*([^\]]+?)\s*\]$/.exec(line);
59
+ if (header !== null) {
60
+ table = header[1].replace(/"/g, "");
61
+ if (!tables.has(table))
62
+ tables.set(table, {});
63
+ continue;
64
+ }
65
+ const pair = /^([A-Za-z0-9_]+)\s*=\s*"([^"\\]*)"\s*(?:#.*)?$/.exec(line);
66
+ if (pair !== null)
67
+ tables.get(table)[pair[1]] = pair[2];
68
+ }
69
+ const top = tables.get("");
70
+ const profile = top["profile"] === undefined ? {} : tables.get(`profiles.${top["profile"]}`) ?? {};
71
+ const model = profile["model"] ?? top["model"];
72
+ const effort = profile["model_reasoning_effort"] ?? top["model_reasoning_effort"];
73
+ return {
74
+ ...(model !== undefined && model !== "" && validModelId(model) ? { model } : {}),
75
+ ...(effort !== undefined && EFFORT.test(effort) ? { effort } : {}),
76
+ };
77
+ }
78
+ /**
79
+ * One Codex launch's CODEX_HOME: a private folder linking the operator's
80
+ * sign-in and sessions. A missing `auth.json` stays missing (an API-key
81
+ * build needs none); a missing `sessions/` is made where Codex would make
82
+ * it, so a resumed build finds the thread its first launch wrote.
83
+ */
84
+ export function isolatedCodexHome(source, root = tmpdir()) {
85
+ const dir = mkdtempSync(join(root, "so-codex-"));
86
+ try {
87
+ chmodSync(dir, 0o700);
88
+ if (existsSync(join(source, "auth.json")))
89
+ symlinkSync(join(source, "auth.json"), join(dir, "auth.json"));
90
+ mkdirSync(join(source, "sessions"), { recursive: true, mode: 0o700 });
91
+ symlinkSync(join(source, "sessions"), join(dir, "sessions"), "dir");
92
+ }
93
+ catch (error) {
94
+ rmSync(dir, { recursive: true, force: true });
95
+ throw error;
96
+ }
97
+ // rm removes the links themselves, never what they point to.
98
+ return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
99
+ }
@@ -18,6 +18,7 @@
18
18
  * TOML-quoted deterministically, and the API key excluded from every
19
19
  * shell the model itself launches (Codex provider review, Q5).
20
20
  */
21
+ import { type FenceGrants } from "./agent-fence.js";
21
22
  import { type ExecResult, type RunOptions } from "./exec.js";
22
23
  import { MODEL_ID, type AgentSpec, type ProviderId } from "./contracts/provider.js";
23
24
  export type { AgentSpec, ProviderId } from "./contracts/provider.js";
@@ -64,6 +65,17 @@ export type Invocation = {
64
65
  * Codex renders it as its own sandbox profile; Claude as deny rules for
65
66
  * its file tools (its shell is fenced by the macOS sandbox around it). */
66
67
  fence?: readonly string[];
68
+ /** What the fence gives back (agent-fence.ts): Codex renders read-only
69
+ * paths as "read", Claude's file tools never edit them. */
70
+ fenceGrants?: FenceGrants;
71
+ /** The operator's own model choices, for a build routed with no model
72
+ * (provider-home.ts): the model, Claude's always-on thinking and Codex's
73
+ * reasoning effort. Never rendered for a review or an explicit model. */
74
+ inherited?: {
75
+ model?: string;
76
+ alwaysThinking?: boolean;
77
+ effort?: string;
78
+ };
67
79
  /** More paths Claude's own file tools refuse, beside the fence: home
68
80
  * configuration its CLI still reads itself (so the sandbox around the
69
81
  * whole process can't deny them). */
@@ -199,6 +211,7 @@ export declare const OPENROUTER_ENV_KEY = "OPENROUTER_API_KEY";
199
211
  * planted user, project and local hooks and MCP servers: none loaded.
200
212
  */
201
213
  export declare const CLAUDE_BUILD_ISOLATION_ARGV: readonly string[];
214
+ export declare function claudePluginEnv(env: Readonly<Record<string, string | undefined>>): string[];
202
215
  /** Claude's own tools that only make sense with a person to wake them. */
203
216
  export declare const HEADLESS_DISALLOWED_TOOLS: readonly string[];
204
217
  /**
@@ -221,9 +234,10 @@ export declare function isolationUnsupported(provider: ProviderId, stderr: strin
221
234
  * A Codex build's own setup, the equivalent of Claude's: the operator's
222
235
  * config.toml (its MCP servers, model choices, profiles) and execpolicy
223
236
  * rules ignored, ChatGPT apps and plugins off (plugins carry their own MCP
224
- * servers), and hooks off. Sign-in still comes from CODEX_HOME, which
225
- * `--ignore-user-config` leaves alone. The project's named tools follow as
226
- * `-c mcp_servers.*` (project-tools.ts).
237
+ * servers), and hooks off. The gateway gives each launch its own CODEX_HOME
238
+ * linking only the operator's sign-in and sessions (provider-home.ts), so no
239
+ * skills or AGENTS.md of theirs load either. The project's named tools follow
240
+ * as `-c mcp_servers.*` (project-tools.ts).
227
241
  */
228
242
  export declare const CODEX_BUILD_ISOLATION_ARGV: readonly string[];
229
243
  /**
package/dist/provider.js CHANGED
@@ -181,6 +181,16 @@ const CLAUDE_REVIEW_JSON_SCHEMA = {
181
181
  * planted user, project and local hooks and MCP servers: none loaded.
182
182
  */
183
183
  export const CLAUDE_BUILD_ISOLATION_ARGV = ["--setting-sources", "", "--strict-mcp-config"];
184
+ /**
185
+ * Plugins also ride the environment: a plugin named in CLAUDE_CODE_PLUGIN_DIRS
186
+ * loads its hooks whatever `--setting-sources` says (Claude Code 2.1.290,
187
+ * the provider canary's plugin sentinel). A build's launch sheds every
188
+ * plugin variable of the environment it would inherit.
189
+ */
190
+ const CLAUDE_PLUGIN_ENV = /^CLAUDE_(CODE_\w*PLUGIN|AI_PLUGINS)\w*$/;
191
+ export function claudePluginEnv(env) {
192
+ return Object.keys(env).filter(name => CLAUDE_PLUGIN_ENV.test(name));
193
+ }
184
194
  const EMPTY_MCP_CONFIG = '{"mcpServers":{}}';
185
195
  /** Claude's own tools that only make sense with a person to wake them. */
186
196
  export const HEADLESS_DISALLOWED_TOOLS = ["ScheduleWakeup", "CronCreate", "Monitor"];
@@ -203,7 +213,7 @@ const claudeArgv = (invocation) => [
203
213
  : invocation.skipPermissions
204
214
  ? ["--dangerously-skip-permissions"]
205
215
  : ["--permission-mode", invocation.permissionMode]),
206
- ...(invocation.model === null ? [] : ["--model", invocation.model]),
216
+ ...modelArgv("--model", invocation),
207
217
  ...(invocation.maxBudgetUsd === undefined ? [] : ["--max-budget-usd", String(invocation.maxBudgetUsd)]),
208
218
  ...(invocation.phase === "review"
209
219
  ? [...CLAUDE_REVIEW_ISOLATION_ARGV, "--json-schema", JSON.stringify(CLAUDE_REVIEW_JSON_SCHEMA)]
@@ -222,12 +232,22 @@ const claudeArgv = (invocation) => [
222
232
  ...(invocation.jsonSchema === undefined ? [] : ["--json-schema", JSON.stringify(invocation.jsonSchema)])
223
233
  ]),
224
234
  ];
235
+ /** The route's model, or for a build routed with none, the operator's own (never for a review). */
236
+ function modelArgv(flag, invocation) {
237
+ const model = invocation.model ?? (invocation.phase === "review" ? undefined : invocation.inherited?.model);
238
+ return model === undefined ? [] : [flag, model];
239
+ }
240
+ /** What else a no-model build keeps of the operator's setup. */
241
+ const inheritedFrom = (invocation) => invocation.model === null && invocation.phase !== "review" ? invocation.inherited ?? {} : {};
225
242
  /** The build's one --settings: no personal memory, plus the fence and steering doorbell. */
226
243
  function claudeSettingsArgv(invocation) {
227
244
  const denied = [...(invocation.fence ?? []), ...(invocation.fileToolFence ?? [])];
245
+ const readOnly = invocation.fenceGrants?.readOnly ?? [];
246
+ const thinking = inheritedFrom(invocation).alwaysThinking;
228
247
  return ["--settings", JSON.stringify({
229
248
  autoMemoryEnabled: false,
230
- ...(denied.length > 0 ? JSON.parse(claudeFenceSettings(denied)) : {}),
249
+ ...(thinking === undefined ? {} : { alwaysThinkingEnabled: thinking }),
250
+ ...(denied.length > 0 || readOnly.length > 0 ? JSON.parse(claudeFenceSettings(denied, readOnly)) : {}),
231
251
  ...(invocation.steerFolder === undefined ? {} : { hooks: claudeSteerHooks(invocation.steerFolder.dir, invocation.steerFolder.name) }),
232
252
  })];
233
253
  }
@@ -421,9 +441,10 @@ const codexSandboxArgv = (mode, resuming) => resuming ? ["-c", `sandbox_mode="${
421
441
  * A Codex build's own setup, the equivalent of Claude's: the operator's
422
442
  * config.toml (its MCP servers, model choices, profiles) and execpolicy
423
443
  * rules ignored, ChatGPT apps and plugins off (plugins carry their own MCP
424
- * servers), and hooks off. Sign-in still comes from CODEX_HOME, which
425
- * `--ignore-user-config` leaves alone. The project's named tools follow as
426
- * `-c mcp_servers.*` (project-tools.ts).
444
+ * servers), and hooks off. The gateway gives each launch its own CODEX_HOME
445
+ * linking only the operator's sign-in and sessions (provider-home.ts), so no
446
+ * skills or AGENTS.md of theirs load either. The project's named tools follow
447
+ * as `-c mcp_servers.*` (project-tools.ts).
427
448
  */
428
449
  export const CODEX_BUILD_ISOLATION_ARGV = [
429
450
  "--ignore-user-config",
@@ -469,11 +490,12 @@ const codexArgv = (extra) => (invocation) => {
469
490
  // denied. Full access becomes that sandbox widened to write anywhere
470
491
  // with network, instead of no sandbox (which fenced nothing).
471
492
  : invocation.fence !== undefined && invocation.fence.length > 0
472
- ? codexFenceArgv(invocation.fence, invocation.skipPermissions)
493
+ ? codexFenceArgv(invocation.fence, invocation.skipPermissions, invocation.fenceGrants)
473
494
  : invocation.skipPermissions
474
495
  ? ["--dangerously-bypass-approvals-and-sandbox"]
475
496
  : codexSandboxArgv("workspace-write", resuming)),
476
- ...(invocation.model === null ? [] : ["-m", invocation.model]),
497
+ ...modelArgv("-m", invocation),
498
+ ...(inheritedFrom(invocation).effort === undefined ? [] : ["-c", `model_reasoning_effort=${toml(inheritedFrom(invocation).effort)}`]),
477
499
  ...extra,
478
500
  // After `extra`: the tools' shell_environment_policy must be the last word (it keeps OpenRouter's key out too).
479
501
  ...(invocation.phase === "review" ? [] : invocation.toolArgv ?? []),
@@ -851,6 +873,7 @@ const AUDITS = {
851
873
  isolation: { flag: CLAUDE_BUILD_ISOLATION_ARGV.filter(one => one !== "").join(" "), resumeSafe: true, enforced: true },
852
874
  configSurface: [
853
875
  "Toolroll's own --settings (the fence and steering hook)",
876
+ "your model and alwaysThinkingEnabled, when the route names no model",
854
877
  "the project's named tools (Settings → Tools)",
855
878
  "repository CLAUDE.md",
856
879
  "organisation-managed settings",
@@ -863,10 +886,11 @@ const AUDITS = {
863
886
  sessionIdentity: "announced",
864
887
  terminalContract: "none",
865
888
  // Builds ignore config.toml and execpolicy rules, with apps, plugins and
866
- // hooks off (CODEX_BUILD_ISOLATION_ARGV); sign-in stays in CODEX_HOME.
889
+ // hooks off (CODEX_BUILD_ISOLATION_ARGV), in a per-launch CODEX_HOME that
890
+ // links only the sign-in and sessions (provider-home.ts).
867
891
  // Never --ephemeral: repair resumes sessions.
868
892
  isolation: { flag: "--ignore-user-config --ignore-rules", resumeSafe: true, enforced: true },
869
- configSurface: ["the project's named tools (Settings → Tools)", "~/.codex/AGENTS.md and repository AGENTS.md", "sign-in from CODEX_HOME"],
893
+ configSurface: ["the project's named tools (Settings → Tools)", "repository AGENTS.md", "sign-in and sessions linked from CODEX_HOME (no skills or AGENTS.md)", "your model and reasoning effort, when the route names no model"],
870
894
  },
871
895
  openrouter: {
872
896
  transport: "streaming-jsonl",
@@ -876,7 +900,7 @@ const AUDITS = {
876
900
  terminalContract: "none",
877
901
  // Codex's build setup, with the model provider pinned by constant -c overrides.
878
902
  isolation: { flag: "--ignore-user-config --ignore-rules", resumeSafe: true, enforced: true },
879
- configSurface: ["the project's named tools (Settings → Tools)", "~/.codex/AGENTS.md and repository AGENTS.md", `${OPENROUTER_ENV_KEY}`],
903
+ configSurface: ["the project's named tools (Settings → Tools)", "repository AGENTS.md", `${OPENROUTER_ENV_KEY}`],
880
904
  },
881
905
  gemini: {
882
906
  transport: "streaming-jsonl",
@@ -1,6 +1,10 @@
1
1
  import { type ExecResult } from "./exec.js";
2
2
  import type { Store } from "./store.js";
3
3
  import type { Runner } from "./worktree.js";
4
+ /** What the private directory's git may see of this process's environment: nothing that names a repository, an
5
+ * object store or config. The fixed settings below go on top. Teardown git (teardown-git.ts) uses the same. */
6
+ export declare const ENV_ALLOWLIST: string[];
7
+ export declare const ENV: Record<string, string>;
4
8
  /** The only credential helper https may use: the system keychain on macOS; elsewhere none, so a private https remote
5
9
  * fails closed instead of asking. */
6
10
  export declare const CREDENTIAL_HELPER: string;
@@ -21,9 +21,9 @@ const REMOTE_MAIN = "refs/toolroll/remote-main";
21
21
  const SHA = /^[0-9a-f]{40}$/;
22
22
  const FETCH_TIMEOUT_MS = 120_000;
23
23
  /** What the private directory's git may see of this process's environment: nothing that names a repository, an
24
- * object store or config. The fixed settings below go on top. */
25
- const ENV_ALLOWLIST = ["PATH", "HOME", "TMPDIR", "LANG", "LC_ALL", "SSH_AUTH_SOCK", "HTTPS_PROXY", "https_proxy", "NO_PROXY", "no_proxy"];
26
- const ENV = {
24
+ * object store or config. The fixed settings below go on top. Teardown git (teardown-git.ts) uses the same. */
25
+ export const ENV_ALLOWLIST = ["PATH", "HOME", "TMPDIR", "LANG", "LC_ALL", "SSH_AUTH_SOCK", "HTTPS_PROXY", "https_proxy", "NO_PROXY", "no_proxy"];
26
+ export const ENV = {
27
27
  GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_NO_REPLACE_OBJECTS: "1", GIT_TERMINAL_PROMPT: "0",
28
28
  GIT_ASKPASS: "", SSH_ASKPASS: "", GIT_SSH_COMMAND: "ssh -o BatchMode=yes",
29
29
  };
package/dist/scope.d.ts CHANGED
@@ -258,7 +258,7 @@ export type Scope = SavedScopeTerms & {
258
258
  * 'mode' = sealed by the signer's live mode (modeDigest names it);
259
259
  * 'chat' = the owner's own two taps in their paired chat, under their chat-approval setting or a signed term
260
260
  * (modeDigest names that authority; the yes is the person's and doesn't lapse with it). */
261
- approvalBasis?: "password" | "mode" | "chat" | null;
261
+ approvalBasis?: "password" | "mode" | "chat" | "grant" | null;
262
262
  modeDigest?: string | null;
263
263
  /** v24 (optional so hand-built scopes in tests stay valid): the working
264
264
  * execution profile, its resolution state, and the immutable snapshot
package/dist/serve.js CHANGED
@@ -14,7 +14,7 @@ import { startMaintenance } from './maintenance.js';
14
14
  import { connectionsOf, localConnectOf, oneClickOf } from "./mcp-connect.js";
15
15
  import { MOBILE_VIEWPORT_SCRIPT } from "./mobile-viewport.js";
16
16
  import { projectAuthority } from "./project-access.js";
17
- import { discoverTools, projectToolsOf, secretsSetFor, TOOL_CATALOG, toolStanding } from "./project-tools.js";
17
+ import { discoverTools, projectToolsOf, secretsSetFor, TOOL_CATALOG, toolSecretStorage, toolStanding } from "./project-tools.js";
18
18
  import { toolRefusal } from "./policy.js";
19
19
  import { isEdgeAddress, refuseEdge } from "./server/edge-refusal.js";
20
20
  import { announcePasswordBearer, isPasswordBearer, PASSWORD_BEARER_REFUSED, passwordBearerAccepted } from "./password-bearer.js";
@@ -434,17 +434,25 @@ export function createDecisionServer(options) {
434
434
  }
435
435
  };
436
436
  /** One project's Tools page: its tools with which secrets are set (never values), the common tools not yet added, and what this computer already has. */
437
- const toolHome = options.toolHome ?? homedir();
437
+ // A test's own folder for tool secrets and found tools; Toolroll's own (beside its database, and this home) otherwise.
438
+ const toolHome = options.toolHome;
438
439
  const toolsViewOf = (repo, codex, kit = null, wanted = null) => {
439
440
  const tools = projectToolsOf(store, repo);
440
441
  const names = new Set(tools.map(one => one.name));
441
442
  return {
442
443
  repo, project: repo.split(/[\\/]/).filter(Boolean).at(-1) ?? repo,
443
- tools: tools.map(tool => ({ tool, secretsSet: secretsSetFor(repo, tool.spec, toolHome) })),
444
+ // Secrets that can't be read (a refused folder, a locked keychain) read as none set: the page says why once (storage).
445
+ tools: tools.map(tool => { let secretsSet = []; try {
446
+ secretsSet = secretsSetFor(repo, tool.spec, toolHome);
447
+ }
448
+ catch {
449
+ secretsSet = [];
450
+ } return { tool, secretsSet }; }),
444
451
  // A service that connects by signing in (or an app's own Connect) is offered only that way.
445
452
  catalog: TOOL_CATALOG.filter(one => !names.has(one.name) && oneClickOf(one.name) === null && localConnectOf(one.name) === null),
446
453
  connections: connectionsOf(store, repo, toolHome), kit, wanted,
447
454
  found: discoverTools(repo, codex, toolHome).filter(one => !names.has(one.spec.name)),
455
+ storage: toolSecretStorage(),
448
456
  };
449
457
  };
450
458
  const codingProjects = () => [...new Set([...managedRepos(), ...store.listProjects().map(project => project.path)])].filter(repo => rowVisible(liveCeiling(), repo));
@@ -21,6 +21,7 @@ import { exportFlow, fetchFlowFile, FlowFileError, importFlow, parseFlowFile, pl
21
21
  import { addFlowTriggerTo, checkFlowTriggerNow, pressFlowButton, removeFlowTrigger, removeLinearKey, setFlowTriggerOn, renewFlowHook, saveHooksBase, saveLinearKey, saveLinearSigningSecret, shareFlowButton, stopSharingFlowButton } from "../flow-triggers.js";
22
22
  import { FLOW_IMPORT_SCRIPT, flowFallbackHtml, flowImportHtml, flowsListHtml, flowView } from "../flows-ui.js";
23
23
  import { FLOW_TEMPLATES, FlowContractError, validateFlowDefinition, withZoneNames } from "../flows.js";
24
+ import { prepareUnattendedCandidates, parseUnattendedAsk, revokeUnattended, signUnattended, untilWords } from "../flow-unattended.js";
24
25
  import { keyStatus } from "../keys.js";
25
26
  import { kitPageHtml, kitsGalleryHtml } from "../kits-ui.js";
26
27
  import { addKitGithubTrigger, addKitSample, kitOf, setUpKit } from "../kits.js";
@@ -148,7 +149,7 @@ export function createFlowsHandlers(runtime) {
148
149
  return refuse(response, who, 404, "No such flow in your projects.", "/flows");
149
150
  const selected = Number(url.searchParams.get("card")), start = Number(url.searchParams.get("start"));
150
151
  const view = flowView(store, flow, { name: who.name, approver: who.via === "cookie" && who.role === "approver" }, Number.isSafeInteger(selected) && selected > 0 ? selected : null, { dir: options.configDir ?? null, repos: consoleProjects(), startTrigger: Number.isSafeInteger(start) && start > 0 ? start : null,
151
- sortReady: keyStatus("openrouter", providerHome).set, toolHome });
152
+ sortReady: keyStatus("openrouter", providerHome).set, toolHome, now: clock() });
152
153
  if (url.searchParams.get("format") === "json")
153
154
  return respond(response, 200, "application/json; charset=utf-8", JSON.stringify(checkResponse("flowView", view)));
154
155
  return sendScreen(response, 200, screen(flow.name, html `<p><a href="/flows">Flows</a></p><h1>${flow.name}</h1>${flowFallbackHtml(view)}`, { chrome: chromeFor(flow.repo, "flows"), workspace: { view } }));
@@ -546,7 +547,7 @@ export function createFlowsHandlers(runtime) {
546
547
  }
547
548
  const definition = flowDefinitionOf(flow);
548
549
  const dir = options.configDir ?? null;
549
- const viewNow = () => flowView(store, store.getFlow(flow.id) ?? flow, { name: who.name, approver: true }, null, { dir, repos: projects, sortReady: keyStatus("openrouter", providerHome).set, toolHome });
550
+ const viewNow = () => flowView(store, store.getFlow(flow.id) ?? flow, { name: who.name, approver: true }, null, { dir, repos: projects, sortReady: keyStatus("openrouter", providerHome).set, toolHome, now });
550
551
  const settle = (said, extra = {}) => {
551
552
  // Move what can move right away (a message, a decision's next zone); workers file and run the tasks.
552
553
  try {
@@ -572,6 +573,8 @@ export function createFlowsHandlers(runtime) {
572
573
  return answer(400, { ok: false, said: "That trigger couldn't be read." });
573
574
  }
574
575
  const made = addFlowTriggerTo(store, flow, raw, who.name, now, dir);
576
+ if (made.ok)
577
+ await prepareUnattendedCandidates(store, flow.id);
575
578
  return made.ok ? settle(made.said, made.reveal === null ? {} : { reveal: made.reveal }) : answer(400, { ok: false, said: made.message });
576
579
  }
577
580
  if (action === "scripts") {
@@ -579,8 +582,10 @@ export function createFlowsHandlers(runtime) {
579
582
  if (body.get("approve") === "yes")
580
583
  return store.approveFlowScript(flow.repo, body.get("name") ?? "") ? settle("Approved. Zones that run it go on.") : answer(404, { ok: false, said: "That script isn't waiting for approval." });
581
584
  if (body.get("remove") === "yes")
582
- return store.removeFlowScript(flow.repo, body.get("name") ?? "") ? settle("Script removed. Zones that ran it wait until it's back.") : answer(404, { ok: false, said: "There's no script by that name." });
585
+ return store.removeFlowScript(flow.repo, body.get("name") ?? "", who.name, now) ? settle("Script removed. Zones that ran it wait until it's back.") : answer(404, { ok: false, said: "There's no script by that name." });
583
586
  const saved = saveScript(store, flow.repo, { name: body.get("name"), about: body.get("about"), body: body.get("body"), timeoutMinutes: body.get("timeoutMinutes"), language: body.get("language"), file: body.get("file") }, who.name, now);
587
+ if (saved.ok)
588
+ await prepareUnattendedCandidates(store, flow.id);
584
589
  return saved.ok ? settle(saved.said) : answer(400, { ok: false, said: saved.message });
585
590
  }
586
591
  if (action === "linear-key" || action === "hooks-address") {
@@ -604,6 +609,19 @@ export function createFlowsHandlers(runtime) {
604
609
  store.archiveFlow(flow.id, who.name, now);
605
610
  return redirect(response, "/flows");
606
611
  }
612
+ if (action === "unattended") {
613
+ // The owner's password signs exactly the terms the panel showed; a flow saved meanwhile is refused (sawRevision).
614
+ if (!authenticateApprover(who, body.get("password") ?? "").ok)
615
+ return answer(403, { ok: false, said: "That password isn't right. Nothing was signed." });
616
+ const asked = parseUnattendedAsk({ days: body.get("days"), budget: body.get("budget"), runs: body.get("runs"), include: body.get("include") });
617
+ if (!asked.ok)
618
+ return answer(400, { ok: false, said: asked.message });
619
+ const signed = await signUnattended(store, { flow: flow.id, signer: who.name, ask: asked.ask, sawRevision: Number(body.get("revision")), sawSchedules: body.get("schedules") ?? "" }, now);
620
+ return signed.ok ? settle(`Runs on its own until ${untilWords(signed.grant.absoluteExpiry)}.`) : answer(409, { ok: false, said: signed.message });
621
+ }
622
+ if (action === "unattended-off") {
623
+ return revokeUnattended(store, flow.id, who.name, now) ? settle("It no longer runs on its own. New cards wait for you.") : answer(409, { ok: false, said: "It wasn't running on its own." });
624
+ }
607
625
  if (action === "save") {
608
626
  let saved, drawn;
609
627
  try {
@@ -637,6 +655,8 @@ export function createFlowsHandlers(runtime) {
637
655
  if (dir === null)
638
656
  return answer(409, { ok: false, said: "Secrets are kept on the console's computer." });
639
657
  const saved = setFlowSecret(dir, flow.repo, (body.get("name") ?? "").trim(), body.get("value") ?? "");
658
+ if (saved.ok)
659
+ await prepareUnattendedCandidates(store, flow.id);
640
660
  return saved.ok ? settle(saved.said) : answer(400, { ok: false, said: saved.message });
641
661
  }
642
662
  // Adding a card.
@@ -702,11 +722,11 @@ export function createFlowsHandlers(runtime) {
702
722
  if (trigger === null || trigger.flow !== flow.id || trigger.state === "removed")
703
723
  return answer(404, { ok: false, said: "That trigger isn't on this flow." });
704
724
  if (verb === "pause" || verb === "resume") {
705
- setFlowTriggerOn(store, trigger, verb === "resume", now);
725
+ setFlowTriggerOn(store, trigger, verb === "resume", now, who.name);
706
726
  return settle(verb === "pause" ? "Trigger paused." : "Trigger on again.");
707
727
  }
708
728
  if (verb === "remove") {
709
- removeFlowTrigger(store, trigger, now, dir);
729
+ removeFlowTrigger(store, trigger, now, dir, who.name);
710
730
  return settle("Trigger removed.");
711
731
  }
712
732
  if (verb === "share") {
@@ -887,6 +907,8 @@ export function createFlowsHandlers(runtime) {
887
907
  "flow.linear-key": flowAct("linear-key"),
888
908
  "flow.hooks-address": flowAct("hooks-address"),
889
909
  "flow.secrets": flowAct("secrets"),
910
+ "flow.unattended": flowAct("unattended"),
911
+ "flow.unattended-off": flowAct("unattended-off"),
890
912
  "flow.triggers": flowAct("triggers"),
891
913
  "flow.card.move": cardAct("move"),
892
914
  "flow.card.decide": cardAct("decide"),
@@ -238,6 +238,9 @@ for (const one of ["save", "cards", "archive", "scripts"])
238
238
  post("flows", `flow.${one}`, `^\\/flows\\/${FLOW}\\/${one}$`, { project: "flow", limited: "resource", sample: `/flows/1/${one}`, ...EDIT_FLOWS });
239
239
  for (const one of ["linear-key", "hooks-address", "secrets"])
240
240
  post("flows", `flow.${one}`, `^\\/flows\\/${FLOW}\\/${one}$`, { scope: "step-up", project: "flow", sample: `/flows/1/${one}`, ...EDIT_FLOWS });
241
+ // "Runs on its own": signing raises authority (the owner's password, a step-up); revoking is one click for any approver.
242
+ post("flows", "flow.unattended", `^\\/flows\\/${FLOW}\\/unattended$`, { scope: "step-up", project: "flow", sample: "/flows/1/unattended", ...EDIT_FLOWS });
243
+ post("flows", "flow.unattended-off", `^\\/flows\\/${FLOW}\\/unattended-off$`, { project: "flow", sample: "/flows/1/unattended-off", ...EDIT_FLOWS });
241
244
  post("flows", "flow.triggers", `^\\/flows\\/${FLOW}\\/triggers$`, { project: "flow", limited: "resource", sample: "/flows/1/triggers", ...EDIT_FLOWS });
242
245
  for (const one of ["move", "decide", "cancel", "comment", "assign", "watch"])
243
246
  post("flows", `flow.card.${one}`, `^\\/flows\\/${FLOW}\\/cards\\/${FLOW}\\/${one}$`, { project: "flow", limited: "resource", sample: `/flows/1/cards/2/${one}`, ...EDIT_FLOWS });
@@ -107,7 +107,7 @@ export interface ServerRuntime {
107
107
  consoleProjects: () => string[];
108
108
  flowRooms: FlowRooms;
109
109
  providerHome: string;
110
- toolHome: string;
110
+ toolHome: string | undefined;
111
111
  leadPrincipal: (who: Who & {
112
112
  via: "cookie";
113
113
  }) => VerifiedApprover | null;
@@ -82,7 +82,8 @@ import { applySavedKnowledge, changeKnowledge, knowledgeVersion, knowledgeView }
82
82
  import { changeLearning } from "../project-learning.js";
83
83
  import { listDecisions, recordDecision, retireDecision, searchMemory } from "../project-memory.js";
84
84
  import { changeSkills, githubSkill, importSkill, reviseSkillTest, skillsView, testSkill } from "../project-skills.js";
85
- import { addToolTo, catalogTool, localAppOf, removeToolFrom, setToolSecret, splitCommandLine, testToolOf } from "../project-tools.js";
85
+ import { signedIn } from "../oauth-run-token.js";
86
+ import { addToolTo, catalogTool, localAppOf, removeToolFrom, setToolSecret, splitCommandLine, testToolOf, toolSecretStorage } from "../project-tools.js";
86
87
  import { liftAuthPause } from "../provider-auth.js";
87
88
  import { isProviderId, PROVIDER_IDS, validateSpec, validModelId } from "../provider.js";
88
89
  import { checkPublishing, MERGE_METHODS, saveMergeSettings, savePublishing } from '../pull-request-flow.js';
@@ -350,7 +351,7 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
350
351
  if (!options.configDir)
351
352
  return refuse(response, who, 403, "An installation approver sees integrations.", "/settings");
352
353
  const list = integrations.list();
353
- return sendScreen(response, 200, screen("Integrations", html `<p><a href="/settings">Settings</a></p><h1>Integrations</h1>${(integrationsHtml(list, who.session.csrf, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem"), checking: integrations.checking }))}`, { chrome: chromeFor(project, "settings") }));
354
+ return sendScreen(response, 200, screen("Integrations", html `<p><a href="/settings">Settings</a></p><h1>Integrations</h1>${(integrationsHtml(list, who.session.csrf, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem"), checking: integrations.checking, secrets: toolSecretStorage() }))}`, { chrome: chromeFor(project, "settings") }));
354
355
  }
355
356
  // v104: Settings → Monitoring, where the audit stream and traces go. An instance operator's page.
356
357
  async function monitoringPage(ctx) {
@@ -624,7 +625,12 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
624
625
  }, who.role === "approver" && csrf !== "" ? (() => {
625
626
  // v87: the mail server flows send email through; the password is never shown back.
626
627
  // v89: where Inbox triggers read it, and a connected Google account (its client secret is never shown back either).
627
- const email = readEmailSettings(options.configDir ?? null), google = readGoogleMail(options.configDir ?? null), origin = consoleOrigin(request.headers.host);
628
+ const email = readEmailSettings(options.configDir ?? null), google = (() => { try {
629
+ return readGoogleMail(options.configDir ?? null);
630
+ }
631
+ catch {
632
+ return null;
633
+ } })(), origin = consoleOrigin(request.headers.host);
628
634
  const googleView = { connected: googleConnected(options.configDir ?? null)?.address ?? null, clientId: google?.clientId ?? "", redirect: origin === null ? null : `${origin}${GOOGLE_CALLBACK}` };
629
635
  return email === null ? { set: false, host: "", port: 587, secure: false, user: "", from: "", imapHost: "", imapPort: 993, google: googleView }
630
636
  : { set: true, host: email.host, port: email.port, secure: email.secure, user: email.user, from: email.from, imapHost: email.imap?.host ?? "", imapPort: email.imap?.port ?? 993, google: googleView };
@@ -1397,6 +1403,8 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
1397
1403
  const secret = body.get("secret") ?? "";
1398
1404
  if (!tool.spec.secrets.some(one => one.name === secret))
1399
1405
  return back("problem", "Choose one of this tool's secrets.");
1406
+ if (signedIn(tool.spec))
1407
+ return back("problem", `${name} is connected by signing in. Sign in again from Connect to renew it.`, `#tool-${name}`);
1400
1408
  setToolSecret(repo, name, secret, body.get("value") ?? "", toolHome);
1401
1409
  return back("said", `Saved ${secret} for ${name}. It is never shown again.`, `#tool-${name}`);
1402
1410
  }
@@ -3,7 +3,7 @@ import { Worker } from "node:worker_threads";
3
3
  import { openStoreNoMigrate } from "./store.js";
4
4
  import { WorktreePool } from "./worktree.js";
5
5
  import { executeSweep } from "./storage-sweep.js";
6
- import { sweepRequest } from "./storage-sweep-process.js";
6
+ import { enterSweepBackground, sweepRequest } from "./storage-sweep-process.js";
7
7
  const parent = process.ppid;
8
8
  let store;
9
9
  let finished = false;
@@ -57,6 +57,8 @@ process.once("message", async (request) => {
57
57
  finish(1);
58
58
  return;
59
59
  }
60
+ // Node and the sweep's code are loaded: from here on, on macOS, the work and everything it starts runs in the background.
61
+ enterSweepBackground();
60
62
  try {
61
63
  const opened = openStoreNoMigrate(request.databaseFile);
62
64
  if (!opened.ok)
@@ -31,11 +31,18 @@ export declare const SWEEP_TIMEOUT_MS: number;
31
31
  export declare const SWEEP_KILL_GRACE_MS = 5000;
32
32
  /** The sweep runs behind everything else: a positive nice and, on macOS, the background CPU and I/O policy. */
33
33
  export declare const SWEEP_NICE = 10;
34
- /** How the child starts: taskpolicy execs Node in place, so the pid, its group and the IPC channel stay the same. */
35
- export declare function sweepLaunch(platform?: NodeJS.Platform, exists?: (path: string) => boolean): {
36
- execPath: string;
37
- prefix: string[];
38
- };
34
+ /**
35
+ * How the child enters the background policy on macOS, or null elsewhere (or without taskpolicy): the child runs it on
36
+ * its own pid once Node has started and loaded the sweep, before any sweep work. Everything it starts after that (rm,
37
+ * du, git) inherits the policy. Starting Node itself under it let a busy machine hold the child's start for as long
38
+ * as anything else wanted the CPU, before it had done any of the work the policy is for.
39
+ */
40
+ export declare function sweepBackground(pid: number, platform?: NodeJS.Platform, exists?: (path: string) => boolean): {
41
+ file: string;
42
+ args: string[];
43
+ } | null;
44
+ /** In the child: the background policy from now on. Without it the sweep still runs, at the nice its parent set. */
45
+ export declare function enterSweepBackground(run?: (file: string, args: string[]) => void): boolean;
39
46
  /** Owns one child without lending its lifetime to a reconcile pass or its database connection. */
40
47
  export declare function createSweepSupervisor(options?: {
41
48
  timeoutMs?: number;
@@ -1,5 +1,5 @@
1
1
  /** The storage child's bounded IPC protocol. No paths or process arguments are written to logs. */
2
- import { fork } from "node:child_process";
2
+ import { execFileSync, fork } from "node:child_process";
3
3
  import { existsSync } from "node:fs";
4
4
  import { setPriority } from "node:os";
5
5
  import { resolve } from "node:path";
@@ -28,16 +28,33 @@ export const SWEEP_KILL_GRACE_MS = 5_000;
28
28
  /** The sweep runs behind everything else: a positive nice and, on macOS, the background CPU and I/O policy. */
29
29
  export const SWEEP_NICE = 10;
30
30
  const TASKPOLICY = "/usr/sbin/taskpolicy";
31
- /** How the child starts: taskpolicy execs Node in place, so the pid, its group and the IPC channel stay the same. */
32
- export function sweepLaunch(platform = process.platform, exists = existsSync) {
33
- return platform === "darwin" && exists(TASKPOLICY) ? { execPath: TASKPOLICY, prefix: ["-b", process.execPath] } : { execPath: process.execPath, prefix: [] };
31
+ /**
32
+ * How the child enters the background policy on macOS, or null elsewhere (or without taskpolicy): the child runs it on
33
+ * its own pid once Node has started and loaded the sweep, before any sweep work. Everything it starts after that (rm,
34
+ * du, git) inherits the policy. Starting Node itself under it let a busy machine hold the child's start for as long
35
+ * as anything else wanted the CPU, before it had done any of the work the policy is for.
36
+ */
37
+ export function sweepBackground(pid, platform = process.platform, exists = existsSync) {
38
+ return platform === "darwin" && exists(TASKPOLICY) ? { file: TASKPOLICY, args: ["-b", "-p", String(pid)] } : null;
39
+ }
40
+ /** In the child: the background policy from now on. Without it the sweep still runs, at the nice its parent set. */
41
+ export function enterSweepBackground(run = (file, args) => { execFileSync(file, args, { stdio: "ignore", timeout: 5_000 }); }) {
42
+ const command = sweepBackground(process.pid);
43
+ if (command === null)
44
+ return false;
45
+ try {
46
+ run(command.file, command.args);
47
+ return true;
48
+ }
49
+ catch {
50
+ return false;
51
+ }
34
52
  }
35
53
  /** Only source checkouts need tsx; installed builds use the sibling child and the same Node. */
36
54
  function forkSweep() {
37
55
  const source = import.meta.url.endsWith(".ts");
38
- const launch = sweepLaunch();
39
56
  const child = fork(fileURLToPath(new URL(source ? "./storage-sweep-child.ts" : "./storage-sweep-child.js", import.meta.url)), [], {
40
- execPath: launch.execPath, execArgv: [...launch.prefix, ...(source ? ["--import", "tsx"] : [])], stdio: ["ignore", "ignore", "ignore", "ipc"], detached: true,
57
+ execArgv: source ? ["--import", "tsx"] : [], stdio: ["ignore", "ignore", "ignore", "ipc"], detached: true,
41
58
  });
42
59
  // Before the request is sent, so every rm/du the child starts inherits it.
43
60
  if (child.pid !== undefined) {