toolroll 0.9.60 → 0.9.62
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 +32 -0
- package/dist/agent-board.d.ts +208 -0
- package/dist/agent-board.js +454 -0
- package/dist/agent-fence.d.ts +19 -0
- package/dist/agent-fence.js +100 -8
- package/dist/agent-pulse.d.ts +43 -0
- package/dist/agent-pulse.js +214 -0
- package/dist/agent-turns.d.ts +52 -0
- package/dist/agent-turns.js +137 -0
- package/dist/agents-board.d.ts +101 -0
- package/dist/agents-board.js +225 -0
- package/dist/assignment-adapters.d.ts +1 -0
- package/dist/assignment-status.d.ts +1 -1
- package/dist/assignment.d.ts +4 -0
- package/dist/assignment.js +8 -6
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +29 -25
- package/dist/browser-crew.js +2 -0
- package/dist/browser-workspace.d.ts +79 -1
- package/dist/browser-workspace.js +1 -0
- package/dist/builder.d.ts +8 -0
- package/dist/builder.js +113 -13
- package/dist/chat-channel.js +9 -0
- package/dist/chat-core.d.ts +6 -0
- package/dist/chat-core.js +8 -0
- package/dist/chat-delivery.js +22 -0
- package/dist/chat-messages.js +8 -1
- package/dist/chat-quiet.js +1 -0
- package/dist/chat-voice.d.ts +2 -0
- package/dist/chat-voice.js +2 -0
- package/dist/checkout-cleanup.d.ts +3 -0
- package/dist/checkout-cleanup.js +11 -2
- package/dist/cli.js +28 -11
- package/dist/contracts/console-api.d.ts +3 -1
- package/dist/contracts/console-api.js +5 -1
- package/dist/contracts/lead-tools.d.ts +16 -2
- package/dist/contracts/lead-tools.js +5 -1
- package/dist/desktop-update.d.ts +1 -1
- package/dist/evidence-pack.js +1 -1
- package/dist/evidence.d.ts +6 -0
- package/dist/evidence.js +14 -3
- package/dist/exec.d.ts +32 -0
- package/dist/exec.js +229 -10
- package/dist/invoke.js +110 -18
- package/dist/landed-cleanup.d.ts +170 -0
- package/dist/landed-cleanup.js +814 -0
- package/dist/lead-cli.js +22 -19
- package/dist/lead-contract.d.ts +1 -1
- package/dist/lead-contract.js +4 -4
- package/dist/lead-doors.js +45 -1
- package/dist/lead-tools.js +40 -7
- package/dist/live-input.d.ts +68 -0
- package/dist/live-input.js +254 -0
- package/dist/live.d.ts +1 -1
- package/dist/live.js +4 -2
- package/dist/maintenance.d.ts +2 -0
- package/dist/maintenance.js +5 -2
- package/dist/operate-remote-arguments.js +5 -1
- package/dist/operate-remote.js +1 -1
- package/dist/operate.d.ts +1 -1
- package/dist/operate.js +249 -26
- package/dist/orphan-sweep.d.ts +2 -0
- package/dist/orphan-sweep.js +5 -3
- package/dist/project-admission.d.ts +10 -6
- package/dist/project-admission.js +33 -11
- package/dist/project-delete-ui.d.ts +12 -0
- package/dist/project-delete-ui.js +32 -2
- package/dist/project-tools.d.ts +7 -11
- package/dist/project-tools.js +13 -37
- package/dist/provider.d.ts +65 -8
- package/dist/provider.js +80 -21
- package/dist/remote-main.d.ts +57 -0
- package/dist/remote-main.js +179 -0
- package/dist/result-completion.d.ts +7 -0
- package/dist/result-completion.js +9 -0
- package/dist/runner.d.ts +4 -2
- package/dist/runner.js +3 -3
- package/dist/serve.js +19 -1
- package/dist/server/live.d.ts +3 -0
- package/dist/server/live.js +35 -2
- package/dist/server/pages.js +59 -35
- package/dist/server/render-chat.js +12 -0
- package/dist/server/render-tasks.d.ts +15 -2
- package/dist/server/render-tasks.js +30 -7
- package/dist/server/route-table.js +4 -1
- package/dist/server/settings.js +5 -3
- package/dist/server/task-views.js +11 -0
- package/dist/server/tasks.js +63 -0
- package/dist/shared-deps.d.ts +9 -4
- package/dist/shared-deps.js +89 -13
- package/dist/staged-runtimes.d.ts +16 -4
- package/dist/staged-runtimes.js +160 -38
- package/dist/steer-live.d.ts +19 -0
- package/dist/steer-live.js +93 -0
- package/dist/steer-status.d.ts +8 -2
- package/dist/steer-status.js +19 -5
- package/dist/storage-sweep-child.d.ts +1 -0
- package/dist/storage-sweep-child.js +76 -0
- package/dist/storage-sweep-process.d.ts +48 -0
- package/dist/storage-sweep-process.js +204 -0
- package/dist/storage-sweep-schedule.d.ts +30 -0
- package/dist/storage-sweep-schedule.js +164 -0
- package/dist/storage-sweep.d.ts +31 -6
- package/dist/storage-sweep.js +165 -39
- package/dist/storage-ui.d.ts +4 -0
- package/dist/storage-ui.js +22 -7
- package/dist/storage.d.ts +6 -0
- package/dist/storage.js +35 -0
- package/dist/store.d.ts +200 -3
- package/dist/store.js +443 -17
- package/dist/surface.js +8 -4
- package/dist/sweep-quarantine.d.ts +28 -0
- package/dist/sweep-quarantine.js +153 -0
- package/dist/task-base.d.ts +35 -0
- package/dist/task-base.js +51 -0
- package/dist/task-status.d.ts +2 -0
- package/dist/task-status.js +5 -1
- package/dist/test-temp.d.ts +6 -3
- package/dist/test-temp.js +15 -9
- package/dist/toolroll-update.js +4 -2
- package/dist/work-index.d.ts +1 -0
- package/dist/work-index.js +32 -12
- package/dist/work-summary.d.ts +2 -2
- package/dist/workspace-revision.js +3 -1
- package/dist/worktree.d.ts +57 -3
- package/dist/worktree.js +201 -23
- package/package.json +1 -1
- package/scripts/provider-canary.mjs +235 -147
package/dist/provider.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import { claudeFenceSettings, codexFenceArgv } from "./agent-fence.js";
|
|
22
22
|
import { claudeSteerHooks } from "./steer-mailbox.js";
|
|
23
23
|
import {} from "./exec.js";
|
|
24
|
-
import { runStreamJsonl, runClaudeStreamJsonl, runGeminiStreamJsonl } from "./exec.js";
|
|
24
|
+
import { runStreamJsonl, runClaudeStreamJsonl, runGeminiStreamJsonl, CLAUDE_COST_UNKNOWN } from "./exec.js";
|
|
25
25
|
import { scanForSecrets } from "./evidence.js";
|
|
26
26
|
import { FINDINGS_MODEL_SCHEMA } from "./contracts/review-findings.js";
|
|
27
27
|
import { REVIEW_OUTPUT_LIMITS } from "./structured-output.js";
|
|
@@ -167,11 +167,26 @@ const CLAUDE_REVIEW_JSON_SCHEMA = {
|
|
|
167
167
|
required: ["version"],
|
|
168
168
|
additionalProperties: false,
|
|
169
169
|
};
|
|
170
|
+
/**
|
|
171
|
+
* A build's own setup (plan, build and repair; never a review, which keeps
|
|
172
|
+
* the isolation above). No settings file loads — not the user's, the
|
|
173
|
+
* repository's or the local one — so none of their hooks, plugins or
|
|
174
|
+
* permissions reach the run; Toolroll's one `--settings` (the fence and the
|
|
175
|
+
* steering hook) is all there is. MCP servers come only from `--mcp-config`:
|
|
176
|
+
* the project's named tools, or an explicit empty list. The repository's
|
|
177
|
+
* CLAUDE.md still loads, through `--add-dir` on the workspace. Sign-in is
|
|
178
|
+
* the CLI's own (keychain or ~/.claude), which no setting source governs.
|
|
179
|
+
* `-p` never shows the workspace trust prompt (Claude Code 2.1.290), so a
|
|
180
|
+
* fresh worktree can't wedge a headless run. Checked against 2.1.290 with
|
|
181
|
+
* planted user, project and local hooks and MCP servers: none loaded.
|
|
182
|
+
*/
|
|
183
|
+
export const CLAUDE_BUILD_ISOLATION_ARGV = ["--setting-sources", "", "--strict-mcp-config"];
|
|
184
|
+
const EMPTY_MCP_CONFIG = '{"mcpServers":{}}';
|
|
170
185
|
/** Claude's own tools that only make sense with a person to wake them. */
|
|
171
186
|
export const HEADLESS_DISALLOWED_TOOLS = ["ScheduleWakeup", "CronCreate", "Monitor"];
|
|
172
187
|
const claudeArgv = (invocation) => [
|
|
173
188
|
"-p",
|
|
174
|
-
invocation.brief,
|
|
189
|
+
...(invocation.streamInput === true ? ["--input-format", "stream-json"] : [invocation.brief]),
|
|
175
190
|
...(invocation.resumeSession === null ? [] : ["--resume", invocation.resumeSession]),
|
|
176
191
|
// stream-json (arc 1): the terminal result event IS the old buffered
|
|
177
192
|
// envelope, now arriving as one line of many — the streaming transport
|
|
@@ -197,22 +212,47 @@ const claudeArgv = (invocation) => [
|
|
|
197
212
|
// turn that a -p process never gets. Denied at the harness too.
|
|
198
213
|
"--disallowedTools", HEADLESS_DISALLOWED_TOOLS.join(","),
|
|
199
214
|
...(invocation.allowedTools === undefined || invocation.allowedTools.length === 0 ? [] : ["--allowedTools", invocation.allowedTools.join(",")]),
|
|
215
|
+
...CLAUDE_BUILD_ISOLATION_ARGV,
|
|
216
|
+
...(invocation.workspace === undefined ? [] : ["--add-dir", invocation.workspace]),
|
|
200
217
|
...(invocation.toolArgv ?? []),
|
|
201
218
|
...(invocation.extraMcpServers === undefined ? [] : ["--mcp-config", JSON.stringify({ mcpServers: invocation.extraMcpServers })]),
|
|
219
|
+
// Strict with nothing named still names its empty list.
|
|
220
|
+
...((invocation.toolArgv ?? []).includes("--mcp-config") || invocation.extraMcpServers !== undefined ? [] : ["--mcp-config", EMPTY_MCP_CONFIG]),
|
|
202
221
|
...claudeSettingsArgv(invocation),
|
|
203
222
|
...(invocation.jsonSchema === undefined ? [] : ["--json-schema", JSON.stringify(invocation.jsonSchema)])
|
|
204
223
|
]),
|
|
205
224
|
];
|
|
206
|
-
/**
|
|
225
|
+
/** The build's one --settings: no personal memory, plus the fence and steering doorbell. */
|
|
207
226
|
function claudeSettingsArgv(invocation) {
|
|
208
|
-
const
|
|
209
|
-
if (!fenced && invocation.steerFolder === undefined)
|
|
210
|
-
return [];
|
|
227
|
+
const denied = [...(invocation.fence ?? []), ...(invocation.fileToolFence ?? [])];
|
|
211
228
|
return ["--settings", JSON.stringify({
|
|
212
|
-
|
|
229
|
+
autoMemoryEnabled: false,
|
|
230
|
+
...(denied.length > 0 ? JSON.parse(claudeFenceSettings(denied)) : {}),
|
|
213
231
|
...(invocation.steerFolder === undefined ? {} : { hooks: claudeSteerHooks(invocation.steerFolder.dir, invocation.steerFolder.name) }),
|
|
214
232
|
})];
|
|
215
233
|
}
|
|
234
|
+
/** With no settings sources, only `--add-dir` folders' CLAUDE.md load, and only with this set. */
|
|
235
|
+
const claudeEnv = (invocation) => invocation.phase === "review" || invocation.workspace === undefined ? {} : { CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: "1" };
|
|
236
|
+
/** The flags a build's own setup depends on, by provider. */
|
|
237
|
+
const ISOLATION_FLAGS = {
|
|
238
|
+
claude: ["--setting-sources", "--strict-mcp-config", "--settings", "--add-dir", "--mcp-config"],
|
|
239
|
+
codex: ["--ignore-user-config", "--ignore-rules"],
|
|
240
|
+
openrouter: ["--ignore-user-config", "--ignore-rules"],
|
|
241
|
+
};
|
|
242
|
+
/**
|
|
243
|
+
* Whether a failed launch was an installed CLI too old for a build's own
|
|
244
|
+
* setup: it names one of those flags as unknown. Then nothing ran, and the
|
|
245
|
+
* run says so plainly instead of reading as a failed sign-in or bad work.
|
|
246
|
+
*/
|
|
247
|
+
export function isolationUnsupported(provider, stderr) {
|
|
248
|
+
const flags = ISOLATION_FLAGS[provider] ?? [];
|
|
249
|
+
const said = stderr.slice(0, 8192);
|
|
250
|
+
const flag = flags.find(one => new RegExp(`(unknown|unexpected|unrecognized) (option|argument)[^\\n]{0,8}${one.replace(/-/g, "\\-")}\\b`, "i").test(said));
|
|
251
|
+
if (flag === undefined)
|
|
252
|
+
return null;
|
|
253
|
+
const name = provider === "claude" ? "Claude Code" : "Codex";
|
|
254
|
+
return `The installed ${name} doesn't support ${flag}, which builds need to run without your own hooks, plugins and MCP servers. Update ${name} and try again.`;
|
|
255
|
+
}
|
|
216
256
|
/**
|
|
217
257
|
* The turn's own words, preferring the schema-validated structured field
|
|
218
258
|
* (present only under `--json-schema`) over the plain result string — the
|
|
@@ -267,6 +307,7 @@ function claudeEnvelopeOf(result, sessionFromInit, initObserved, initSessionConf
|
|
|
267
307
|
tokensIn: typeof input === "number" && input >= 0 ? input : null,
|
|
268
308
|
tokensOut: typeof output === "number" && output >= 0 ? output : null,
|
|
269
309
|
costUsd: typeof cost === "number" && cost >= 0 ? cost : null,
|
|
310
|
+
...(result !== null && result[CLAUDE_COST_UNKNOWN] === true ? { costUnknown: true } : {}),
|
|
270
311
|
usageRaw: result?.usage === undefined ? null : JSON.stringify(result.usage).slice(0, USAGE_JSON_CAP),
|
|
271
312
|
initObserved,
|
|
272
313
|
// Structural, never prose (finding 15): consumed means the primary
|
|
@@ -376,6 +417,21 @@ const codexSandboxArgv = (mode, resuming) => resuming ? ["-c", `sandbox_mode="${
|
|
|
376
417
|
* servers, exactly the surface `--strict-mcp-config` closes for claude —
|
|
377
418
|
* ignored outright.
|
|
378
419
|
*/
|
|
420
|
+
/**
|
|
421
|
+
* A Codex build's own setup, the equivalent of Claude's: the operator's
|
|
422
|
+
* config.toml (its MCP servers, model choices, profiles) and execpolicy
|
|
423
|
+
* 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).
|
|
427
|
+
*/
|
|
428
|
+
export const CODEX_BUILD_ISOLATION_ARGV = [
|
|
429
|
+
"--ignore-user-config",
|
|
430
|
+
"--ignore-rules",
|
|
431
|
+
"-c", "apps._default.enabled=false",
|
|
432
|
+
"-c", "features.plugins=false",
|
|
433
|
+
"-c", "features.hooks=false",
|
|
434
|
+
];
|
|
379
435
|
const CODEX_REVIEW_ISOLATION_ARGV = (resuming) => [
|
|
380
436
|
"--ignore-user-config",
|
|
381
437
|
...codexSandboxArgv("read-only", resuming),
|
|
@@ -406,6 +462,7 @@ const codexArgv = (extra) => (invocation) => {
|
|
|
406
462
|
...(invocation.resumeSession === null ? [] : ["resume", invocation.resumeSession]),
|
|
407
463
|
"--json",
|
|
408
464
|
"--skip-git-repo-check",
|
|
465
|
+
...(invocation.phase === "review" ? [] : CODEX_BUILD_ISOLATION_ARGV),
|
|
409
466
|
...(invocation.phase === "review"
|
|
410
467
|
? CODEX_REVIEW_ISOLATION_ARGV(resuming)
|
|
411
468
|
// The agent fence: Codex's own sandbox with Toolroll's secrets
|
|
@@ -725,6 +782,7 @@ const ADAPTERS = {
|
|
|
725
782
|
claude: {
|
|
726
783
|
binary: "claude",
|
|
727
784
|
argv: claudeArgv,
|
|
785
|
+
env: claudeEnv,
|
|
728
786
|
parse: claudeParse,
|
|
729
787
|
defaultRunner: runClaudeStreamJsonl,
|
|
730
788
|
extraOmitEnv: foreignCredentialEnv("claude"),
|
|
@@ -788,13 +846,14 @@ const AUDITS = {
|
|
|
788
846
|
initSignal: "system-init",
|
|
789
847
|
sessionIdentity: "announced",
|
|
790
848
|
terminalContract: "none",
|
|
791
|
-
//
|
|
792
|
-
//
|
|
793
|
-
|
|
794
|
-
isolation: { flag: "--restricted", resumeSafe: true, enforced: false },
|
|
849
|
+
// Builds: no settings sources and strict MCP, fresh and resumed alike
|
|
850
|
+
// (CLAUDE_BUILD_ISOLATION_ARGV). Reviews keep --restricted --safe-mode.
|
|
851
|
+
isolation: { flag: CLAUDE_BUILD_ISOLATION_ARGV.filter(one => one !== "").join(" "), resumeSafe: true, enforced: true },
|
|
795
852
|
configSurface: [
|
|
796
|
-
"
|
|
797
|
-
"
|
|
853
|
+
"Toolroll's own --settings (the fence and steering hook)",
|
|
854
|
+
"the project's named tools (Settings → Tools)",
|
|
855
|
+
"repository CLAUDE.md",
|
|
856
|
+
"organisation-managed settings",
|
|
798
857
|
],
|
|
799
858
|
},
|
|
800
859
|
codex: {
|
|
@@ -803,10 +862,11 @@ const AUDITS = {
|
|
|
803
862
|
initSignal: "thread.started",
|
|
804
863
|
sessionIdentity: "announced",
|
|
805
864
|
terminalContract: "none",
|
|
806
|
-
//
|
|
807
|
-
//
|
|
808
|
-
|
|
809
|
-
|
|
865
|
+
// Builds ignore config.toml and execpolicy rules, with apps, plugins and
|
|
866
|
+
// hooks off (CODEX_BUILD_ISOLATION_ARGV); sign-in stays in CODEX_HOME.
|
|
867
|
+
// Never --ephemeral: repair resumes sessions.
|
|
868
|
+
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"],
|
|
810
870
|
},
|
|
811
871
|
openrouter: {
|
|
812
872
|
transport: "streaming-jsonl",
|
|
@@ -814,10 +874,9 @@ const AUDITS = {
|
|
|
814
874
|
initSignal: "thread.started",
|
|
815
875
|
sessionIdentity: "announced",
|
|
816
876
|
terminalContract: "none",
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
configSurface: ["~/.codex/config.toml (model_provider pinned per invocation)", "repository AGENTS.md"],
|
|
877
|
+
// Codex's build setup, with the model provider pinned by constant -c overrides.
|
|
878
|
+
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}`],
|
|
821
880
|
},
|
|
822
881
|
gemini: {
|
|
823
882
|
transport: "streaming-jsonl",
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { type ExecResult } from "./exec.js";
|
|
2
|
+
import type { Store } from "./store.js";
|
|
3
|
+
import type { Runner } from "./worktree.js";
|
|
4
|
+
/** The only credential helper https may use: the system keychain on macOS; elsewhere none, so a private https remote
|
|
5
|
+
* fails closed instead of asking. */
|
|
6
|
+
export declare const CREDENTIAL_HELPER: string;
|
|
7
|
+
export type RemoteUrl = {
|
|
8
|
+
kind: "network";
|
|
9
|
+
url: string;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* A URL Toolroll may fetch remote main from: https or ssh (either form). Local paths, file URLs and loopback hosts
|
|
13
|
+
* (this machine is where builds run) are always refused. Remote helpers (ext::, fd:: and the like), plain http and
|
|
14
|
+
* git:// (no authentication), relative paths, embedded passwords and anything odd are refused.
|
|
15
|
+
*/
|
|
16
|
+
export declare function parseRemoteUrl(url: string): RemoteUrl | {
|
|
17
|
+
why: string;
|
|
18
|
+
};
|
|
19
|
+
/** Why a recorded URL may not be used as the trusted remote; null when it may. */
|
|
20
|
+
export declare function remoteUrlProblem(url: string): string | null;
|
|
21
|
+
export type RemoteGit = (args: readonly string[]) => Promise<ExecResult>;
|
|
22
|
+
export type RemoteMain = {
|
|
23
|
+
url: string;
|
|
24
|
+
/** The commit the recorded remote's main is at, as fetched just now. */
|
|
25
|
+
main: string;
|
|
26
|
+
/** Git in the private directory, hardened as above. */
|
|
27
|
+
git: RemoteGit;
|
|
28
|
+
/** Bring this exact commit (with its history) from the project by fetch, for a content comparison. */
|
|
29
|
+
bring: (repo: string, head: string) => Promise<boolean>;
|
|
30
|
+
};
|
|
31
|
+
export type RemoteMainRead = {
|
|
32
|
+
ok: true;
|
|
33
|
+
remote: RemoteMain;
|
|
34
|
+
} | {
|
|
35
|
+
ok: false;
|
|
36
|
+
why: string;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* One pass's reads of recorded remotes. Every `read` fetches again (the pre-teardown re-check is never older than
|
|
40
|
+
* the teardown); a project's private directory lasts until `close`, so a fetch after the first only brings what
|
|
41
|
+
* changed. Close removes every directory.
|
|
42
|
+
*/
|
|
43
|
+
export declare class RemoteMains {
|
|
44
|
+
private readonly store;
|
|
45
|
+
private readonly runner;
|
|
46
|
+
private readonly scratch;
|
|
47
|
+
private readonly helper;
|
|
48
|
+
private readonly dirs;
|
|
49
|
+
constructor(store: Store, runner: Runner | undefined, scratch: string, helper?: string);
|
|
50
|
+
private exec;
|
|
51
|
+
/** Nothing but fetched objects decides: a private directory that grew alternates, grafts or shallow state is
|
|
52
|
+
* not trusted. */
|
|
53
|
+
private clean;
|
|
54
|
+
private dirFor;
|
|
55
|
+
read(repo: string): Promise<RemoteMainRead>;
|
|
56
|
+
close(): void;
|
|
57
|
+
}
|
|
@@ -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. */
|
|
25
|
+
const ENV_ALLOWLIST = ["PATH", "HOME", "TMPDIR", "LANG", "LC_ALL", "SSH_AUTH_SOCK", "HTTPS_PROXY", "https_proxy", "NO_PROXY", "no_proxy"];
|
|
26
|
+
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
|
+
}
|
|
@@ -6,6 +6,13 @@
|
|
|
6
6
|
import type { Store } from "./store.js";
|
|
7
7
|
/** The ledger action a person's (or lead's) completion of a result writes. */
|
|
8
8
|
export declare const COMPLETION_ACTION = "assignment handoff checked";
|
|
9
|
+
/** The actor of an automatic completion: the result's work already landed (landed-cleanup.ts). Its ledger detail is
|
|
10
|
+
* the plain-English reason ("its pull request merged"), the one place every surface reads it from. */
|
|
11
|
+
export declare const LANDED_ACTOR = "system:landed";
|
|
12
|
+
/** Who may complete a result, as a completion's ledger actor: a person, a lead, or Toolroll when the work landed. */
|
|
13
|
+
export declare const COMPLETER_ACTOR: RegExp;
|
|
14
|
+
/** Why Toolroll marked a result complete, from its completion's actor and detail; null for a person's completion. */
|
|
15
|
+
export declare function landedReasonOf(actor: string | null | undefined, detail: string | null | undefined): string | null;
|
|
9
16
|
/** Whether a task's family is marked complete. Completion is recorded on the
|
|
10
17
|
* family's root against its current version's latest result, so the root, a
|
|
11
18
|
* superseded revision and the current version all read Complete together. */
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import { readVerifiedArtifact } from "./evidence.js";
|
|
2
2
|
/** The ledger action a person's (or lead's) completion of a result writes. */
|
|
3
3
|
export const COMPLETION_ACTION = "assignment handoff checked";
|
|
4
|
+
/** The actor of an automatic completion: the result's work already landed (landed-cleanup.ts). Its ledger detail is
|
|
5
|
+
* the plain-English reason ("its pull request merged"), the one place every surface reads it from. */
|
|
6
|
+
export const LANDED_ACTOR = "system:landed";
|
|
7
|
+
/** Who may complete a result, as a completion's ledger actor: a person, a lead, or Toolroll when the work landed. */
|
|
8
|
+
export const COMPLETER_ACTOR = /^(?:(?:operator|coordinator|lead):.+|system:landed)$/;
|
|
9
|
+
/** Why Toolroll marked a result complete, from its completion's actor and detail; null for a person's completion. */
|
|
10
|
+
export function landedReasonOf(actor, detail) {
|
|
11
|
+
return actor === LANDED_ACTOR ? detail?.trim() || "its work is already in main" : null;
|
|
12
|
+
}
|
|
4
13
|
/** Whether a task's family is marked complete. Completion is recorded on the
|
|
5
14
|
* family's root against its current version's latest result, so the root, a
|
|
6
15
|
* superseded revision and the current version all read Complete together. */
|
package/dist/runner.d.ts
CHANGED
|
@@ -121,8 +121,10 @@ export declare function removeRunnerRepoAuthed(store: Store, args: {
|
|
|
121
121
|
export declare const RUNNER_NAME_MAX = 60;
|
|
122
122
|
/** The most tasks one worker may be set to run at once (the console's register form says the same). */
|
|
123
123
|
export declare const RUNNER_CAPACITY_MAX = 64;
|
|
124
|
-
/** A capacity as typed
|
|
125
|
-
export declare function parseRunnerCapacity(value: string
|
|
124
|
+
/** A capacity as typed; changing an existing worker may use zero to drain it. */
|
|
125
|
+
export declare function parseRunnerCapacity(value: string, { allowZero }?: {
|
|
126
|
+
allowZero?: boolean | undefined;
|
|
127
|
+
}): number | null;
|
|
126
128
|
export declare function validRunnerName(name: string): boolean;
|
|
127
129
|
/** A generated base name: lowercase [a-z0-9-], collapsed, trimmed. The
|
|
128
130
|
* suffix budget (finding 24) is the CALLER's arithmetic — truncate the
|
package/dist/runner.js
CHANGED
|
@@ -165,10 +165,10 @@ export function removeRunnerRepoAuthed(store, args, now) {
|
|
|
165
165
|
export const RUNNER_NAME_MAX = 60;
|
|
166
166
|
/** The most tasks one worker may be set to run at once (the console's register form says the same). */
|
|
167
167
|
export const RUNNER_CAPACITY_MAX = 64;
|
|
168
|
-
/** A capacity as typed
|
|
169
|
-
export function parseRunnerCapacity(value) {
|
|
168
|
+
/** A capacity as typed; changing an existing worker may use zero to drain it. */
|
|
169
|
+
export function parseRunnerCapacity(value, { allowZero = false } = {}) {
|
|
170
170
|
const capacity = Number(value.trim());
|
|
171
|
-
return value.trim() !== "" && Number.isInteger(capacity) && capacity >= 1 && capacity <= RUNNER_CAPACITY_MAX ? capacity : null;
|
|
171
|
+
return value.trim() !== "" && Number.isInteger(capacity) && capacity >= (allowZero ? 0 : 1) && capacity <= RUNNER_CAPACITY_MAX ? capacity : null;
|
|
172
172
|
}
|
|
173
173
|
export function validRunnerName(name) {
|
|
174
174
|
if (name === "" || name.length > RUNNER_NAME_MAX)
|
package/dist/serve.js
CHANGED
|
@@ -14,7 +14,8 @@ 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 } from "./project-tools.js";
|
|
17
|
+
import { discoverTools, projectToolsOf, secretsSetFor, TOOL_CATALOG, toolStanding } from "./project-tools.js";
|
|
18
|
+
import { toolRefusal } from "./policy.js";
|
|
18
19
|
import { isEdgeAddress, refuseEdge } from "./server/edge-refusal.js";
|
|
19
20
|
import { announcePasswordBearer, isPasswordBearer, PASSWORD_BEARER_REFUSED, passwordBearerAccepted } from "./password-bearer.js";
|
|
20
21
|
import { createSharedGuards } from "./server/guards.js";
|
|
@@ -390,7 +391,24 @@ export function createDecisionServer(options) {
|
|
|
390
391
|
repo, name: projectName(repo), holdings: projectHoldings(store, repo), running: projectRunning(store, repo, clock()),
|
|
391
392
|
canDelete: who.via === "cookie" && store.isInstanceOperator(who.name),
|
|
392
393
|
builds: projectBuildsOf(repo, who),
|
|
394
|
+
access: projectAccessOf(repo),
|
|
393
395
|
});
|
|
396
|
+
/** Settings → Project → Build access: the project's named tools and whether each loads for its builds. */
|
|
397
|
+
const projectAccessOf = (repo) => {
|
|
398
|
+
try {
|
|
399
|
+
const policy = store.orgPolicy();
|
|
400
|
+
return { tools: projectToolsOf(store, repo).map(tool => {
|
|
401
|
+
const refused = toolRefusal(policy, tool.name);
|
|
402
|
+
const set = secretsSetFor(repo, tool.spec, toolHome);
|
|
403
|
+
// As a launch decides (toolLaunchFor): a tool the organisation doesn't allow, or missing a secret, is left out.
|
|
404
|
+
return refused !== null ? { name: tool.name, loads: false, words: refused }
|
|
405
|
+
: { name: tool.name, loads: tool.spec.secrets.every(one => one.optional || set.includes(one.name)), words: toolStanding(tool, set).words };
|
|
406
|
+
}) };
|
|
407
|
+
}
|
|
408
|
+
catch {
|
|
409
|
+
return { problem: "Couldn't read this project's tools right now." };
|
|
410
|
+
}
|
|
411
|
+
};
|
|
394
412
|
/** Settings → Project → Builds at once: the saved number, its workers' capacity, and what builds now. */
|
|
395
413
|
const projectBuildsOf = (repo, who) => {
|
|
396
414
|
const capacities = store.listRunners().filter(one => one.retiredAt === null && one.repos.includes(repo)).map(one => one.capacity);
|
package/dist/server/live.d.ts
CHANGED
|
@@ -20,6 +20,9 @@ export type LiveRoom = {
|
|
|
20
20
|
} | {
|
|
21
21
|
kind: "team";
|
|
22
22
|
conversation: string | null;
|
|
23
|
+
} | {
|
|
24
|
+
kind: "agent";
|
|
25
|
+
run: number;
|
|
23
26
|
};
|
|
24
27
|
/** A room's name, read strictly: anything else is not a room. */
|
|
25
28
|
export declare function parseLiveRoom(name: string): LiveRoom | null;
|
package/dist/server/live.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { boardRunOf, holdAgentView, releaseAgentView } from "../agent-board.js";
|
|
3
|
+
import { agentRunFingerprint } from "../agents-board.js";
|
|
1
4
|
import { createLiveRooms, LiveConnection } from "../live-bus.js";
|
|
2
5
|
import { reproveRemote } from "../operate-remote.js";
|
|
3
6
|
import { rowVisible } from "../project.js";
|
|
@@ -8,7 +11,7 @@ import { adapterPolicy } from "./route-policy.js";
|
|
|
8
11
|
import { requestContext } from "./request-context.js";
|
|
9
12
|
/** A page asks for a few rooms at once: its workspace, what it shows, its conversation. */
|
|
10
13
|
export const LIVE_ROOMS_PER_PAGE = 5;
|
|
11
|
-
const ROOM_NAME = /^(workspace|task|flow|chat|team)(?::([^?]{1,400}))?(?:\?([^#]{0,600}))?$/;
|
|
14
|
+
const ROOM_NAME = /^(workspace|task|flow|chat|team|agent)(?::([^?]{1,400}))?(?:\?([^#]{0,600}))?$/;
|
|
12
15
|
/** At most one workspace nudge a second: a burst of writes is one re-read, the first heard at once. */
|
|
13
16
|
const WORKSPACE_SETTLE_MS = 1_000;
|
|
14
17
|
/** A running reply's preview: at most one snapshot every 80 ms; the last one always goes out. */
|
|
@@ -45,6 +48,8 @@ export function parseLiveRoom(name) {
|
|
|
45
48
|
if (key !== null || !only("conversation"))
|
|
46
49
|
return null;
|
|
47
50
|
return { kind, conversation: params.get("conversation") || null };
|
|
51
|
+
case "agent":
|
|
52
|
+
return key !== null && /^[1-9][0-9]{0,9}$/.test(key) && query === undefined ? { kind, run: Number(key) } : null;
|
|
48
53
|
}
|
|
49
54
|
return null;
|
|
50
55
|
}
|
|
@@ -61,6 +66,8 @@ export function createLiveHandlers(runtime, live) {
|
|
|
61
66
|
return cursor === null ? null : String(cursor);
|
|
62
67
|
}, null, { bus: live.bus });
|
|
63
68
|
const chatViewers = new Set();
|
|
69
|
+
// One agent's open view (Agents board): what it shows, keyed by the exact run.
|
|
70
|
+
const agentRooms = createLiveRooms(run => agentRunFingerprint(store, run), null, { bus: live.bus });
|
|
64
71
|
/** The same session, still signed in, from the cookie and the account (never this request's context). */
|
|
65
72
|
const readBrowser = (request, who) => {
|
|
66
73
|
const again = identify(request, false);
|
|
@@ -177,7 +184,7 @@ export function createLiveHandlers(runtime, live) {
|
|
|
177
184
|
if ([...url.searchParams.keys()].some(key => key !== "room") || names.length === 0 || names.length > LIVE_ROOMS_PER_PAGE || new Set(names).size !== names.length)
|
|
178
185
|
return json(response, 400, { error: "room" });
|
|
179
186
|
const rooms = names.map(name => ({ name, room: parseLiveRoom(name) }));
|
|
180
|
-
if (rooms.some(one => one.room === null) || rooms.filter(one => one.room?.kind === "workspace").length > 1 || rooms.filter(one => one.room?.kind === "chat").length > 1 || rooms.filter(one => one.room?.kind === "team").length > 1)
|
|
187
|
+
if (rooms.some(one => one.room === null) || rooms.filter(one => one.room?.kind === "agent").length > 1 || rooms.filter(one => one.room?.kind === "workspace").length > 1 || rooms.filter(one => one.room?.kind === "chat").length > 1 || rooms.filter(one => one.room?.kind === "team").length > 1)
|
|
181
188
|
return json(response, 400, { error: "room" });
|
|
182
189
|
// Admit every room before the stream opens, with the answers each page's own stream gave.
|
|
183
190
|
const joins = [];
|
|
@@ -198,6 +205,31 @@ export function createLiveHandlers(runtime, live) {
|
|
|
198
205
|
// A sign-out or a narrowed account stops it.
|
|
199
206
|
valid: () => signedIn() && rowVisible(liveCeiling(), repo) && store.accountCanAccess(who.name, repo) }));
|
|
200
207
|
}
|
|
208
|
+
else if (room.kind === "agent") {
|
|
209
|
+
const browser = who;
|
|
210
|
+
const run = store.getRun(room.run);
|
|
211
|
+
const repo = run === null ? null : store.refForId(run.taskRef)?.repo ?? null;
|
|
212
|
+
if (run === null || repo === null || !visible(repo) || !store.accountCanAccess(who.name, repo))
|
|
213
|
+
return json(response, 404, { error: "run" });
|
|
214
|
+
// A repair is its build's own activity: its room is the build's.
|
|
215
|
+
const signedIn = sameBrowser(request, browser), runId = boardRunOf(store, run.id), view = randomBytes(12).toString("hex");
|
|
216
|
+
// The watch is a short lease in the database, renewed on every keep-alive and released when the page goes:
|
|
217
|
+
// the process that sends chat pings reads it there (agent-board.ts watchingAgent).
|
|
218
|
+
const watch = () => { try {
|
|
219
|
+
holdAgentView(store, view, who.name, runId, clock());
|
|
220
|
+
}
|
|
221
|
+
catch { /* a missed renewal only lets a ping through */ } };
|
|
222
|
+
joins.push(connection => {
|
|
223
|
+
watch();
|
|
224
|
+
connection.onKeepAlive(watch);
|
|
225
|
+
connection.onClose(() => { try {
|
|
226
|
+
releaseAgentView(store, view, clock());
|
|
227
|
+
}
|
|
228
|
+
catch { /* it lapses on its own */ } });
|
|
229
|
+
agentRooms.join(runId, { name: who.name, room: name, connection,
|
|
230
|
+
valid: () => signedIn() && rowVisible(liveCeiling(), repo) && store.accountCanAccess(who.name, repo) });
|
|
231
|
+
});
|
|
232
|
+
}
|
|
201
233
|
else if (room.kind === "flow") {
|
|
202
234
|
const browser = who;
|
|
203
235
|
const flow = store.getFlow(room.flow);
|
|
@@ -302,6 +334,7 @@ export function createLiveHandlers(runtime, live) {
|
|
|
302
334
|
detachChat();
|
|
303
335
|
workspaceRooms.close();
|
|
304
336
|
teamRooms.close();
|
|
337
|
+
agentRooms.close();
|
|
305
338
|
for (const viewer of [...chatViewers])
|
|
306
339
|
viewer.connection.close();
|
|
307
340
|
},
|