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.
- package/CHANGELOG.md +29 -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 +3 -0
- 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 +3 -2
- package/dist/landed-cleanup.js +12 -5
- 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 +7 -1
- 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-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 +4 -0
- package/dist/remote-main.js +3 -3
- package/dist/scope.d.ts +1 -1
- package/dist/serve.js +11 -3
- package/dist/server/flows.js +27 -5
- package/dist/server/route-table.js +3 -0
- package/dist/server/runtime.d.ts +1 -1
- package/dist/server/settings.js +11 -3
- package/dist/storage-sweep-child.js +3 -1
- package/dist/storage-sweep-process.d.ts +12 -5
- package/dist/storage-sweep-process.js +23 -6
- package/dist/store.d.ts +38 -6
- package/dist/store.js +148 -15
- package/dist/subagent-tools.d.ts +1 -1
- package/dist/surface.js +6 -4
- 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 +3 -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 +10 -4
- package/dist/worktree.js +70 -29
- 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",
|
package/dist/remote-main.d.ts
CHANGED
|
@@ -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;
|
package/dist/remote-main.js
CHANGED
|
@@ -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
|
-
|
|
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));
|
package/dist/server/flows.js
CHANGED
|
@@ -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 });
|
package/dist/server/runtime.d.ts
CHANGED
|
@@ -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;
|
package/dist/server/settings.js
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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
|
-
/**
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
/**
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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) {
|