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.
- package/CHANGELOG.md +46 -0
- package/README.md +51 -3
- package/dist/agent-fence.d.ts +155 -10
- package/dist/agent-fence.js +473 -38
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +21 -21
- package/dist/browser-workspace.d.ts +28 -0
- package/dist/chat-actions.js +2 -2
- package/dist/check-levels.js +3 -0
- package/dist/claim.js +1 -1
- package/dist/cli-help.d.ts +1 -1
- package/dist/cli-help.js +1 -0
- package/dist/cli.js +31 -11
- package/dist/contracts/cli.d.ts +8 -0
- package/dist/contracts/cli.js +3 -1
- package/dist/contracts/console-api.d.ts +19 -1
- package/dist/contracts/console-api.js +2 -1
- package/dist/contracts/integration-metadata.d.ts +2 -1
- package/dist/contracts/integration-metadata.js +2 -1
- package/dist/desktop-update.d.ts +1 -1
- package/dist/dispatch.js +7 -4
- package/dist/doctor.d.ts +53 -0
- package/dist/doctor.js +427 -0
- package/dist/evidence-pack.d.ts +1 -1
- package/dist/evidence-pack.js +2 -2
- package/dist/exec.d.ts +3 -0
- package/dist/exec.js +5 -4
- package/dist/flow-code.d.ts +2 -2
- package/dist/flow-code.js +4 -15
- package/dist/flow-engine.js +11 -6
- package/dist/flow-triggers.d.ts +8 -4
- package/dist/flow-triggers.js +45 -25
- package/dist/flow-unattended-words.d.ts +28 -0
- package/dist/flow-unattended-words.js +50 -0
- package/dist/flow-unattended.d.ts +227 -0
- package/dist/flow-unattended.js +776 -0
- package/dist/flows-cli.d.ts +28 -1
- package/dist/flows-cli.js +55 -10
- package/dist/flows-ui.d.ts +2 -1
- package/dist/flows-ui.js +4 -1
- package/dist/google-mail.d.ts +1 -0
- package/dist/google-mail.js +61 -18
- package/dist/integrations-ui.d.ts +2 -1
- package/dist/integrations-ui.js +6 -3
- package/dist/integrations.d.ts +17 -3
- package/dist/integrations.js +38 -18
- package/dist/invoke.js +300 -168
- package/dist/landed-cleanup.d.ts +26 -12
- package/dist/landed-cleanup.js +199 -85
- package/dist/lead-contract.d.ts +1 -1
- package/dist/lead-contract.js +3 -3
- package/dist/lead-tools.js +11 -3
- package/dist/mcp-connect.d.ts +8 -7
- package/dist/mcp-connect.js +49 -51
- package/dist/oauth-run-token.d.ts +51 -0
- package/dist/oauth-run-token.js +127 -0
- package/dist/operate.js +74 -17
- package/dist/plan-auto.d.ts +11 -0
- package/dist/plan-auto.js +62 -11
- package/dist/process-recovery-eligibility.d.ts +1 -1
- package/dist/project-access.d.ts +3 -0
- package/dist/project-access.js +3 -0
- package/dist/project-admission.d.ts +10 -6
- package/dist/project-admission.js +33 -11
- package/dist/project-delete-ui.d.ts +1 -0
- package/dist/project-delete-ui.js +4 -1
- package/dist/project-delete.d.ts +3 -1
- package/dist/project-delete.js +10 -4
- package/dist/project-file.d.ts +3 -0
- package/dist/project-file.js +16 -0
- package/dist/project-tools.d.ts +99 -25
- package/dist/project-tools.js +394 -98
- package/dist/provider-home.d.ts +22 -0
- package/dist/provider-home.js +99 -0
- package/dist/provider.d.ts +17 -3
- package/dist/provider.js +34 -10
- package/dist/remote-main.d.ts +61 -0
- package/dist/remote-main.js +179 -0
- package/dist/scope.d.ts +1 -1
- package/dist/serve.js +11 -3
- package/dist/server/flows.js +27 -5
- package/dist/server/render-tasks.d.ts +3 -0
- package/dist/server/render-tasks.js +3 -0
- package/dist/server/route-table.js +3 -0
- package/dist/server/runtime.d.ts +1 -1
- package/dist/server/settings.js +14 -5
- package/dist/server/task-views.js +2 -0
- package/dist/storage-sweep-child.js +3 -1
- package/dist/storage-sweep-process.d.ts +14 -0
- package/dist/storage-sweep-process.js +37 -3
- package/dist/storage-sweep-schedule.d.ts +30 -0
- package/dist/storage-sweep-schedule.js +164 -0
- package/dist/storage-sweep.d.ts +6 -5
- package/dist/storage-sweep.js +10 -70
- package/dist/storage-ui.d.ts +2 -0
- package/dist/storage-ui.js +7 -4
- package/dist/store.d.ts +75 -7
- package/dist/store.js +241 -17
- package/dist/subagent-tools.d.ts +1 -1
- package/dist/surface.js +10 -6
- package/dist/task-base.d.ts +35 -0
- package/dist/task-base.js +51 -0
- package/dist/teardown-git.d.ts +14 -0
- package/dist/teardown-git.js +119 -0
- package/dist/tool-broker.d.ts +37 -0
- package/dist/tool-broker.js +369 -0
- package/dist/tool-launcher.js +5 -4
- package/dist/tool-secret-store.d.ts +170 -0
- package/dist/tool-secret-store.js +834 -0
- package/dist/toolroll-update.js +4 -2
- package/dist/tools-ui.d.ts +7 -1
- package/dist/tools-ui.js +17 -8
- package/dist/work-index.js +5 -3
- package/dist/worktree.d.ts +64 -6
- package/dist/worktree.js +171 -35
- package/package.json +3 -1
- 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
|
+
}
|
package/dist/provider.d.ts
CHANGED
|
@@ -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.
|
|
225
|
-
*
|
|
226
|
-
*
|
|
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
|
-
...(
|
|
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
|
-
...(
|
|
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.
|
|
425
|
-
*
|
|
426
|
-
*
|
|
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
|
-
...(
|
|
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)
|
|
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)", "
|
|
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)", "
|
|
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
|
-
|
|
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
|
-
|
|
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));
|