toolroll 0.9.61 → 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 (117) hide show
  1. package/CHANGELOG.md +46 -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 +31 -11
  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 +26 -12
  49. package/dist/landed-cleanup.js +199 -85
  50. package/dist/lead-contract.d.ts +1 -1
  51. package/dist/lead-contract.js +3 -3
  52. package/dist/lead-tools.js +11 -3
  53. package/dist/mcp-connect.d.ts +8 -7
  54. package/dist/mcp-connect.js +49 -51
  55. package/dist/oauth-run-token.d.ts +51 -0
  56. package/dist/oauth-run-token.js +127 -0
  57. package/dist/operate.js +74 -17
  58. package/dist/plan-auto.d.ts +11 -0
  59. package/dist/plan-auto.js +62 -11
  60. package/dist/process-recovery-eligibility.d.ts +1 -1
  61. package/dist/project-access.d.ts +3 -0
  62. package/dist/project-access.js +3 -0
  63. package/dist/project-admission.d.ts +10 -6
  64. package/dist/project-admission.js +33 -11
  65. package/dist/project-delete-ui.d.ts +1 -0
  66. package/dist/project-delete-ui.js +4 -1
  67. package/dist/project-delete.d.ts +3 -1
  68. package/dist/project-delete.js +10 -4
  69. package/dist/project-file.d.ts +3 -0
  70. package/dist/project-file.js +16 -0
  71. package/dist/project-tools.d.ts +99 -25
  72. package/dist/project-tools.js +394 -98
  73. package/dist/provider-home.d.ts +22 -0
  74. package/dist/provider-home.js +99 -0
  75. package/dist/provider.d.ts +17 -3
  76. package/dist/provider.js +34 -10
  77. package/dist/remote-main.d.ts +61 -0
  78. package/dist/remote-main.js +179 -0
  79. package/dist/scope.d.ts +1 -1
  80. package/dist/serve.js +11 -3
  81. package/dist/server/flows.js +27 -5
  82. package/dist/server/render-tasks.d.ts +3 -0
  83. package/dist/server/render-tasks.js +3 -0
  84. package/dist/server/route-table.js +3 -0
  85. package/dist/server/runtime.d.ts +1 -1
  86. package/dist/server/settings.js +14 -5
  87. package/dist/server/task-views.js +2 -0
  88. package/dist/storage-sweep-child.js +3 -1
  89. package/dist/storage-sweep-process.d.ts +14 -0
  90. package/dist/storage-sweep-process.js +37 -3
  91. package/dist/storage-sweep-schedule.d.ts +30 -0
  92. package/dist/storage-sweep-schedule.js +164 -0
  93. package/dist/storage-sweep.d.ts +6 -5
  94. package/dist/storage-sweep.js +10 -70
  95. package/dist/storage-ui.d.ts +2 -0
  96. package/dist/storage-ui.js +7 -4
  97. package/dist/store.d.ts +75 -7
  98. package/dist/store.js +241 -17
  99. package/dist/subagent-tools.d.ts +1 -1
  100. package/dist/surface.js +10 -6
  101. package/dist/task-base.d.ts +35 -0
  102. package/dist/task-base.js +51 -0
  103. package/dist/teardown-git.d.ts +14 -0
  104. package/dist/teardown-git.js +119 -0
  105. package/dist/tool-broker.d.ts +37 -0
  106. package/dist/tool-broker.js +369 -0
  107. package/dist/tool-launcher.js +5 -4
  108. package/dist/tool-secret-store.d.ts +170 -0
  109. package/dist/tool-secret-store.js +834 -0
  110. package/dist/toolroll-update.js +4 -2
  111. package/dist/tools-ui.d.ts +7 -1
  112. package/dist/tools-ui.js +17 -8
  113. package/dist/work-index.js +5 -3
  114. package/dist/worktree.d.ts +64 -6
  115. package/dist/worktree.js +171 -35
  116. package/package.json +3 -1
  117. 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",
@@ -0,0 +1,61 @@
1
+ import { type ExecResult } from "./exec.js";
2
+ import type { Store } from "./store.js";
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>;
8
+ /** The only credential helper https may use: the system keychain on macOS; elsewhere none, so a private https remote
9
+ * fails closed instead of asking. */
10
+ export declare const CREDENTIAL_HELPER: string;
11
+ export type RemoteUrl = {
12
+ kind: "network";
13
+ url: string;
14
+ };
15
+ /**
16
+ * A URL Toolroll may fetch remote main from: https or ssh (either form). Local paths, file URLs and loopback hosts
17
+ * (this machine is where builds run) are always refused. Remote helpers (ext::, fd:: and the like), plain http and
18
+ * git:// (no authentication), relative paths, embedded passwords and anything odd are refused.
19
+ */
20
+ export declare function parseRemoteUrl(url: string): RemoteUrl | {
21
+ why: string;
22
+ };
23
+ /** Why a recorded URL may not be used as the trusted remote; null when it may. */
24
+ export declare function remoteUrlProblem(url: string): string | null;
25
+ export type RemoteGit = (args: readonly string[]) => Promise<ExecResult>;
26
+ export type RemoteMain = {
27
+ url: string;
28
+ /** The commit the recorded remote's main is at, as fetched just now. */
29
+ main: string;
30
+ /** Git in the private directory, hardened as above. */
31
+ git: RemoteGit;
32
+ /** Bring this exact commit (with its history) from the project by fetch, for a content comparison. */
33
+ bring: (repo: string, head: string) => Promise<boolean>;
34
+ };
35
+ export type RemoteMainRead = {
36
+ ok: true;
37
+ remote: RemoteMain;
38
+ } | {
39
+ ok: false;
40
+ why: string;
41
+ };
42
+ /**
43
+ * One pass's reads of recorded remotes. Every `read` fetches again (the pre-teardown re-check is never older than
44
+ * the teardown); a project's private directory lasts until `close`, so a fetch after the first only brings what
45
+ * changed. Close removes every directory.
46
+ */
47
+ export declare class RemoteMains {
48
+ private readonly store;
49
+ private readonly runner;
50
+ private readonly scratch;
51
+ private readonly helper;
52
+ private readonly dirs;
53
+ constructor(store: Store, runner: Runner | undefined, scratch: string, helper?: string);
54
+ private exec;
55
+ /** Nothing but fetched objects decides: a private directory that grew alternates, grafts or shallow state is
56
+ * not trusted. */
57
+ private clean;
58
+ private dirFor;
59
+ read(repo: string): Promise<RemoteMainRead>;
60
+ close(): void;
61
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * The project's main as its recorded remote has it, read by Toolroll's own process. Automatic completion of landed
3
+ * work (landed-cleanup.ts) trusts only this: a build shares the project's .git, so it can move the local main or
4
+ * repoint origin in .git/config, and neither may complete its own task.
5
+ *
6
+ * The remote URL is the one captured on first admission or explicitly supplied by the owner (project-admission.ts), never the repository's
7
+ * config. Each read fetches that URL's main into a fresh, private Git directory under Toolroll's evidence folder (which
8
+ * the agent fence denies to builds), with no system or global config, no includes or URL rewriting, no replace
9
+ * objects, no hooks, no ext or file transport and only an explicit credential helper. The shared repository's objects, refs,
10
+ * replace refs, grafts and shallow state are never read: commits enter only by fetch. A content comparison brings the
11
+ * result's exact recorded commit in by fetch too; git checks every object against its hash.
12
+ *
13
+ * Every doubt fails closed: no recorded remote, a URL that isn't allowed, a failed fetch or a remote without main all
14
+ * read as "not proven", and the task stays for a person.
15
+ */
16
+ import { mkdirSync, mkdtempSync, rmSync, existsSync } from "node:fs";
17
+ import { isAbsolute, join } from "node:path";
18
+ import { run } from "./exec.js";
19
+ /** The ref a fetched remote main is kept under, inside the private directory only. */
20
+ const REMOTE_MAIN = "refs/toolroll/remote-main";
21
+ const SHA = /^[0-9a-f]{40}$/;
22
+ const FETCH_TIMEOUT_MS = 120_000;
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. 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
+ GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_NO_REPLACE_OBJECTS: "1", GIT_TERMINAL_PROMPT: "0",
28
+ GIT_ASKPASS: "", SSH_ASKPASS: "", GIT_SSH_COMMAND: "ssh -o BatchMode=yes",
29
+ };
30
+ /** The only credential helper https may use: the system keychain on macOS; elsewhere none, so a private https remote
31
+ * fails closed instead of asking. */
32
+ export const CREDENTIAL_HELPER = process.platform === "darwin" ? "osxkeychain" : "";
33
+ /** Settings for every command in the private directory. The directory's own config is ours and empty. File transport
34
+ * is off for the recorded remote; only the result fetch from the registered project path turns it on. */
35
+ function hardened(helper, file = "never") {
36
+ return ["-c", "core.hooksPath=/dev/null", "-c", "protocol.allow=never", "-c", "protocol.https.allow=always", "-c", "protocol.ssh.allow=always",
37
+ "-c", `protocol.file.allow=${file}`, "-c", "protocol.ext.allow=never", "-c", "credential.helper=", ...(helper === "" ? [] : ["-c", `credential.helper=${helper}`]),
38
+ "-c", "gc.auto=0", "-c", "maintenance.auto=false", "-c", "fetch.recurseSubmodules=false", "-c", "core.fsmonitor=false"];
39
+ }
40
+ /**
41
+ * A URL Toolroll may fetch remote main from: https or ssh (either form). Local paths, file URLs and loopback hosts
42
+ * (this machine is where builds run) are always refused. Remote helpers (ext::, fd:: and the like), plain http and
43
+ * git:// (no authentication), relative paths, embedded passwords and anything odd are refused.
44
+ */
45
+ export function parseRemoteUrl(url) {
46
+ const refused = { why: "its remote URL isn't one Toolroll fetches from" };
47
+ if (url === "" || url.length > 2048 || /[\x00-\x20\x7f]/.test(url) || url.startsWith("-") || url.includes("\\"))
48
+ return refused;
49
+ if (/^[A-Za-z][A-Za-z0-9+.-]*::/.test(url) || /^file:/i.test(url) || /^[A-Za-z]:[\\/]/.test(url))
50
+ return refused;
51
+ const scheme = /^([A-Za-z][A-Za-z0-9+.-]*):\/\//.exec(url)?.[1]?.toLowerCase();
52
+ if (scheme !== undefined) {
53
+ if (scheme !== "https" && scheme !== "ssh")
54
+ return refused;
55
+ try {
56
+ const parsed = new URL(url);
57
+ if (parsed.password !== "" || (scheme === "https" && parsed.username !== "") || parsed.search !== "" || parsed.hash !== "" || parsed.hostname === "" || loopback(parsed.hostname))
58
+ return refused;
59
+ }
60
+ catch {
61
+ return refused;
62
+ }
63
+ return { kind: "network", url };
64
+ }
65
+ // scp-like ssh: [user@]host:path, with no slash before the first colon.
66
+ const colon = url.indexOf(":");
67
+ const host = /^(?:[A-Za-z0-9._-]+@)?([A-Za-z0-9.-]+)$/.exec(url.slice(0, colon))?.[1];
68
+ if (colon > 0 && colon < url.length - 1 && host !== undefined && !loopback(host))
69
+ return { kind: "network", url };
70
+ return refused;
71
+ }
72
+ /** localhost (and its subdomains), any IPv4 spelling of 127.0.0.0/8 or 0.0.0.0 (inet_aton accepts 127.1, 0x7f.1 and
73
+ * the like), and ::1, :: or an IPv4-mapped loopback. */
74
+ function loopback(hostname) {
75
+ const host = hostname.toLowerCase().replace(/^\[(.*)\]$/, "$1").replace(/\.+$/, "");
76
+ if (host === "localhost" || host.endsWith(".localhost"))
77
+ return true;
78
+ const parts = host.split(".");
79
+ if (parts.length <= 4 && parts.every(part => /^(0x[0-9a-f]*|[0-9]+)$/.test(part))) {
80
+ const values = parts.map(part => part.startsWith("0x") ? parseInt(part.slice(2) || "0", 16) : /^0[0-7]+$/.test(part) ? parseInt(part, 8) : Number(part));
81
+ const first = values.length === 1 ? Math.floor(values[0] / 2 ** 24) : values[0];
82
+ return first === 127 || values.every(value => value === 0);
83
+ }
84
+ if (!host.includes(":"))
85
+ return false;
86
+ try {
87
+ const v6 = new URL(`http://[${host}]/`).hostname;
88
+ return v6 === "[::1]" || v6 === "[::]" || /^\[::ffff:(7f[0-9a-f]{2}:|0:)/.test(v6);
89
+ }
90
+ catch {
91
+ return true;
92
+ }
93
+ }
94
+ /** Why a recorded URL may not be used as the trusted remote; null when it may. */
95
+ export function remoteUrlProblem(url) {
96
+ const parsed = parseRemoteUrl(url);
97
+ return "why" in parsed ? parsed.why : null;
98
+ }
99
+ /**
100
+ * One pass's reads of recorded remotes. Every `read` fetches again (the pre-teardown re-check is never older than
101
+ * the teardown); a project's private directory lasts until `close`, so a fetch after the first only brings what
102
+ * changed. Close removes every directory.
103
+ */
104
+ export class RemoteMains {
105
+ store;
106
+ runner;
107
+ scratch;
108
+ helper;
109
+ dirs = new Map();
110
+ constructor(store, runner = run, scratch, helper = CREDENTIAL_HELPER) {
111
+ this.store = store;
112
+ this.runner = runner;
113
+ this.scratch = scratch;
114
+ this.helper = helper;
115
+ }
116
+ exec(dir, args, file = "never") {
117
+ return this.runner("git", ["--git-dir", dir, ...hardened(this.helper, file), ...args], { cwd: dir, envAllowlist: ENV_ALLOWLIST, env: ENV, timeoutMs: FETCH_TIMEOUT_MS, maxBuffer: 512 * 1024 * 1024 });
118
+ }
119
+ /** Nothing but fetched objects decides: a private directory that grew alternates, grafts or shallow state is
120
+ * not trusted. */
121
+ clean(dir) {
122
+ return !["shallow", join("info", "grafts"), join("objects", "info", "alternates"), join("objects", "info", "http-alternates")].some(name => existsSync(join(dir, name)));
123
+ }
124
+ async dirFor(repo) {
125
+ const known = this.dirs.get(repo);
126
+ if (known !== undefined && existsSync(known))
127
+ return known;
128
+ try {
129
+ mkdirSync(this.scratch, { recursive: true });
130
+ const dir = mkdtempSync(join(this.scratch, ".remote-main-"));
131
+ this.dirs.set(repo, dir);
132
+ const made = await this.runner("git", ["init", "--quiet", "--bare", "--template=", dir], { cwd: dir, envAllowlist: ENV_ALLOWLIST, env: ENV, timeoutMs: 30_000 });
133
+ return made.code === 0 ? dir : null;
134
+ }
135
+ catch {
136
+ return null;
137
+ }
138
+ }
139
+ async read(repo) {
140
+ const url = this.store.projectRemote(repo);
141
+ if (url === null)
142
+ return { ok: false, why: "the project has no recorded remote" };
143
+ const problem = remoteUrlProblem(url);
144
+ if (problem !== null)
145
+ return { ok: false, why: problem };
146
+ const dir = await this.dirFor(repo);
147
+ if (dir === null)
148
+ return { ok: false, why: "Toolroll couldn't read the project's remote main" };
149
+ const fetched = await this.exec(dir, ["fetch", "--quiet", "--no-tags", "--no-write-fetch-head", "--", url, `+refs/heads/main:${REMOTE_MAIN}`]);
150
+ if (fetched.code !== 0 || !this.clean(dir))
151
+ return { ok: false, why: "Toolroll couldn't read the project's remote main" };
152
+ const resolved = await this.exec(dir, ["rev-parse", "--verify", "--quiet", `${REMOTE_MAIN}^{commit}`]);
153
+ const main = resolved.stdout.trim();
154
+ if (resolved.code !== 0 || !SHA.test(main))
155
+ return { ok: false, why: "Toolroll couldn't read the project's remote main" };
156
+ const git = args => this.exec(dir, args);
157
+ const bring = async (from, head) => {
158
+ if (!SHA.test(head))
159
+ return false;
160
+ if ((await git(["cat-file", "-e", `${head}^{commit}`])).code === 0)
161
+ return true;
162
+ // The one local fetch: from the project path Toolroll registered (never a URL), for this exact hash-checked commit.
163
+ if (!isAbsolute(from))
164
+ return false;
165
+ const got = await this.exec(dir, ["fetch", "--quiet", "--no-tags", "--no-write-fetch-head", "--", from, `+${head}:refs/toolroll/results/${head}`], "always");
166
+ return got.code === 0 && this.clean(dir) && (await git(["cat-file", "-e", `${head}^{commit}`])).code === 0;
167
+ };
168
+ return { ok: true, remote: { url, main, git, bring } };
169
+ }
170
+ close() {
171
+ for (const dir of this.dirs.values()) {
172
+ try {
173
+ rmSync(dir, { recursive: true, force: true });
174
+ }
175
+ catch { /* the next pass makes its own */ }
176
+ }
177
+ this.dirs.clear();
178
+ }
179
+ }
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));