@midhunxavier/code-flow 0.0.0-stage → 0.1.0-preview.1
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/LICENSE +21 -0
- package/README.md +122 -2
- package/bin/codeflow.mjs +8 -0
- package/bin/launcher.mjs +127 -0
- package/dist/remote-host.json +1 -0
- package/licenses/inter.txt +92 -0
- package/licenses/preact.txt +21 -0
- package/licenses/signals-core.txt +21 -0
- package/licenses/signals.txt +21 -0
- package/licenses/typescript-notices.txt +193 -0
- package/licenses/typescript.txt +55 -0
- package/licenses/uqr.txt +22 -0
- package/licenses/xterm-fit.txt +19 -0
- package/licenses/xterm.txt +21 -0
- package/package.json +34 -4
- package/public/assets/browser-7XI52A6V.js +8 -0
- package/public/assets/changes-EYFPASEI.js +1 -0
- package/public/assets/chunk-7H3ZLTKA.js +2 -0
- package/public/assets/chunk-L7TUCIYF.js +1 -0
- package/public/assets/chunk-LLMQG23J.js +163 -0
- package/public/assets/main-KIRM2OEK.js +1 -0
- package/public/assets/settings-QJZVW22Y.js +9 -0
- package/public/assets/styles-615EBB20.css +32 -0
- package/public/assets/terminal-A2FI2NAX.js +62 -0
- package/public/assets.json +1 -0
- package/public/bundle-meta.json +1 -0
- package/public/docs/.nojekyll +0 -0
- package/public/docs/accessibility.html +19 -0
- package/public/docs/acp-agents.html +117 -0
- package/public/docs/agent-containers.html +79 -0
- package/public/docs/api.html +1130 -0
- package/public/docs/app.js +342 -0
- package/public/docs/application-improvements.html +275 -0
- package/public/docs/architecture-explorer.html +19 -0
- package/public/docs/architecture.html +149 -0
- package/public/docs/auto-team.html +142 -0
- package/public/docs/automations.html +109 -0
- package/public/docs/checkpoint-review.html +37 -0
- package/public/docs/code-hosts.html +35 -0
- package/public/docs/code-intelligence.html +35 -0
- package/public/docs/configuration.html +346 -0
- package/public/docs/context-inspection.html +23 -0
- package/public/docs/coordinated-changes.html +28 -0
- package/public/docs/deferred-runs.html +25 -0
- package/public/docs/dependency-discovery.html +19 -0
- package/public/docs/documentation.html +84 -0
- package/public/docs/dynamic-teams-evaluation.html +174 -0
- package/public/docs/dynamic-teams-implementation-plan.html +882 -0
- package/public/docs/dynamic-teams-implementation-review-1.html +141 -0
- package/public/docs/dynamic-teams-plan-review-0.html +204 -0
- package/public/docs/extensions.html +42 -0
- package/public/docs/external-sources.html +96 -0
- package/public/docs/filesystem-policy.html +26 -0
- package/public/docs/filtered-tasks.html +44 -0
- package/public/docs/font-license.txt +92 -0
- package/public/docs/getting-started.html +118 -0
- package/public/docs/github-review-threads.html +26 -0
- package/public/docs/github.html +157 -0
- package/public/docs/help.html +69 -0
- package/public/docs/index.html +77 -0
- package/public/docs/installation.html +74 -0
- package/public/docs/launch-profiles.html +48 -0
- package/public/docs/maintenance.html +60 -0
- package/public/docs/memory-claims.html +31 -0
- package/public/docs/memory-storage.html +58 -0
- package/public/docs/notifications.html +39 -0
- package/public/docs/patterns.html +195 -0
- package/public/docs/performance-results.html +210 -0
- package/public/docs/performance.html +166 -0
- package/public/docs/project-conventions.html +22 -0
- package/public/docs/project-first-navigation-plan.html +587 -0
- package/public/docs/project-teams-implementation-plan.html +772 -0
- package/public/docs/project-teams.html +99 -0
- package/public/docs/provider-compatibility.html +20 -0
- package/public/docs/releasing.html +61 -0
- package/public/docs/remote-workers.html +64 -0
- package/public/docs/review-inbox.html +26 -0
- package/public/docs/run-storage.html +23 -0
- package/public/docs/runs.html +141 -0
- package/public/docs/search.json +1 -0
- package/public/docs/security.html +133 -0
- package/public/docs/shared-projects.html +31 -0
- package/public/docs/ssh-projects.html +60 -0
- package/public/docs/style.css +768 -0
- package/public/docs/task-reruns.html +30 -0
- package/public/docs/team-collaboration.html +379 -0
- package/public/docs/verification.html +22 -0
- package/public/docs/vscode.html +53 -0
- package/public/docs/worker-permission-verification.html +122 -0
- package/public/index.html +17 -0
- package/scripts/check-node.mjs +7 -0
- package/scripts/codeflow.mjs +55 -0
- package/scripts/pty.py +273 -0
- package/src/acp-connections.js +110 -0
- package/src/adapters/acp-presets.js +37 -0
- package/src/adapters/acp-types.js +0 -0
- package/src/adapters/acp.js +328 -0
- package/src/adapters/agy-auth.js +67 -0
- package/src/adapters/agy-isolation.js +223 -0
- package/src/adapters/agy.js +297 -0
- package/src/adapters/claude.js +220 -0
- package/src/adapters/codex-app-server.js +518 -0
- package/src/adapters/codex.js +299 -0
- package/src/adapters/errors.js +57 -0
- package/src/adapters/index.js +91 -0
- package/src/adapters/managed-permissions.js +37 -0
- package/src/adapters/model-discovery.js +100 -0
- package/src/adapters/process.js +418 -0
- package/src/adapters/team-tools.js +102 -0
- package/src/adapters/types.js +0 -0
- package/src/agent-container.js +155 -0
- package/src/approvals.js +121 -0
- package/src/architecture.js +456 -0
- package/src/assets.js +44 -0
- package/src/automation.js +901 -0
- package/src/certificate-renewal.js +75 -0
- package/src/chat-handoff.js +55 -0
- package/src/checkpoint-review.js +72 -0
- package/src/checks.js +303 -0
- package/src/code-host-git.js +102 -0
- package/src/code-hosts-api.js +99 -0
- package/src/code-hosts.js +482 -0
- package/src/code-intelligence-worker.mjs +452 -0
- package/src/code-intelligence.js +141 -0
- package/src/collab/api.js +365 -0
- package/src/collab/checkpoints.js +198 -0
- package/src/collab/connections.js +139 -0
- package/src/collab/host-execution.js +347 -0
- package/src/collab/hub-client.js +210 -0
- package/src/collab/link-api.js +229 -0
- package/src/collab/link.js +595 -0
- package/src/collab/model.js +496 -0
- package/src/collab/runner.js +1014 -0
- package/src/collab/store.js +1452 -0
- package/src/collab/terminals.js +361 -0
- package/src/collaboration-reviews.js +116 -0
- package/src/config.js +48 -0
- package/src/context-budget.js +14 -0
- package/src/context-inspection.js +204 -0
- package/src/context.js +154 -0
- package/src/conventions.js +283 -0
- package/src/coordinated-api.js +247 -0
- package/src/deferred-launch.js +82 -0
- package/src/deferred-runs.js +515 -0
- package/src/dependency-health.js +35 -0
- package/src/diff.js +43 -0
- package/src/docs-help.js +316 -0
- package/src/doctor.js +232 -0
- package/src/engine.js +4366 -0
- package/src/env.js +44 -0
- package/src/experience.js +50 -0
- package/src/extensions.js +144 -0
- package/src/filesystem-access.js +107 -0
- package/src/filesystem-policy.js +238 -0
- package/src/filtered-task-limits.js +93 -0
- package/src/filtered-tasks-api.js +53 -0
- package/src/filtered-tasks.js +576 -0
- package/src/fs.js +57 -0
- package/src/git.js +626 -0
- package/src/github-review-threads.js +181 -0
- package/src/github.js +538 -0
- package/src/http.js +69 -0
- package/src/improve-settings.js +38 -0
- package/src/improve.js +187 -0
- package/src/integrations.js +373 -0
- package/src/integrity.js +259 -0
- package/src/journey-api.js +77 -0
- package/src/journey-replay.js +65 -0
- package/src/journey-store.js +97 -0
- package/src/journeys.js +60 -0
- package/src/json.js +81 -0
- package/src/launch-profiles.js +292 -0
- package/src/learnings.js +80 -0
- package/src/log.js +39 -0
- package/src/maintenance.js +335 -0
- package/src/memory-analysis.js +182 -0
- package/src/memory-claims.js +153 -0
- package/src/memory-sqlite.js +236 -0
- package/src/memory.js +552 -0
- package/src/merge-validation.js +58 -0
- package/src/model-catalog.js +238 -0
- package/src/model-research.js +91 -0
- package/src/notes.js +176 -0
- package/src/notifications.js +413 -0
- package/src/patterns/auto-team-model.js +212 -0
- package/src/patterns/auto-team.js +659 -0
- package/src/patterns/council.js +199 -0
- package/src/patterns/debate.js +188 -0
- package/src/patterns/findings.js +141 -0
- package/src/patterns/index.js +19 -0
- package/src/patterns/loop.js +158 -0
- package/src/patterns/pipeline.js +125 -0
- package/src/patterns/prompts.js +181 -0
- package/src/patterns/race.js +147 -0
- package/src/patterns/schemas.js +40 -0
- package/src/patterns/solo.js +49 -0
- package/src/patterns/supervisor.js +235 -0
- package/src/patterns/task-draft.js +29 -0
- package/src/patterns/tdd.js +231 -0
- package/src/patterns/team-strategies.js +67 -0
- package/src/performance.js +43 -0
- package/src/persistence-status.js +35 -0
- package/src/phone-sessions.js +96 -0
- package/src/preview-overlay.js +383 -0
- package/src/preview.js +219 -0
- package/src/privacy.js +23 -0
- package/src/project-report.js +22 -0
- package/src/project-team-model.js +178 -0
- package/src/project-team-policy.js +42 -0
- package/src/project-team-store.js +213 -0
- package/src/project-teams-api.js +36 -0
- package/src/project-teams.js +551 -0
- package/src/projects.js +286 -0
- package/src/provider-eligibility.js +9 -0
- package/src/readiness.js +98 -0
- package/src/remote-tls.js +66 -0
- package/src/remote-workers/bundles.js +198 -0
- package/src/remote-workers/client.js +332 -0
- package/src/remote-workers/durability.js +37 -0
- package/src/remote-workers/protocol.js +68 -0
- package/src/remote-workers/stdio.js +138 -0
- package/src/remote-workers/worker.js +304 -0
- package/src/remote.js +204 -0
- package/src/repo-config.js +201 -0
- package/src/repo-coordination.js +732 -0
- package/src/repo-tree.js +106 -0
- package/src/retention-policy.js +466 -0
- package/src/review-inbox.js +38 -0
- package/src/routing.js +49 -0
- package/src/run-analysis.js +111 -0
- package/src/run-cold-store.js +231 -0
- package/src/run-environment.js +15 -0
- package/src/run-history.js +91 -0
- package/src/run-log.js +160 -0
- package/src/run-permissions.js +110 -0
- package/src/run-projection.js +75 -0
- package/src/run-recovery.js +53 -0
- package/src/run-status.js +54 -0
- package/src/serve.js +138 -0
- package/src/server.js +2738 -0
- package/src/shared-access.js +103 -0
- package/src/shared-accounts.js +301 -0
- package/src/shared-api.js +239 -0
- package/src/shell.js +346 -0
- package/src/ship.js +344 -0
- package/src/slots.js +74 -0
- package/src/snapshot.js +388 -0
- package/src/source-context.js +17 -0
- package/src/sse-writer.js +70 -0
- package/src/ssh-projects/bootstrap.mjs +95 -0
- package/src/ssh-projects/config.js +45 -0
- package/src/ssh-projects/gateway.js +143 -0
- package/src/ssh-projects/manager.js +320 -0
- package/src/ssh-projects/team-autostart.mjs +93 -0
- package/src/ssh-projects/team-remove.mjs +69 -0
- package/src/ssh-projects/team-status.mjs +39 -0
- package/src/ssh-projects/transport.js +116 -0
- package/src/stats.js +139 -0
- package/src/store.js +544 -0
- package/src/stream.js +91 -0
- package/src/sweeps.js +241 -0
- package/src/tailscale.js +101 -0
- package/src/task-draft.js +23 -0
- package/src/team-evaluation.js +242 -0
- package/src/team-mailbox.js +160 -0
- package/src/team-memory.js +84 -0
- package/src/team-rerun-graph.js +15 -0
- package/src/team-reruns.js +130 -0
- package/src/team-scheduler.js +121 -0
- package/src/team-template-store.js +98 -0
- package/src/team-templates.js +117 -0
- package/src/team-tool-client.mjs +79 -0
- package/src/team-tools.js +176 -0
- package/src/teams.js +0 -0
- package/src/templates.js +62 -0
- package/src/transcript.js +134 -0
- package/src/verification-integrity.js +27 -0
- package/src/verification.js +183 -0
- package/src/watch.js +195 -0
- package/src/work-queue.js +47 -0
- package/src/workflow-control.js +83 -0
- package/src/workspace-api.js +207 -0
- package/src/workspace-changes.js +61 -0
- package/src/workspace.js +146 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { once } from "node:events";
|
|
4
|
+
import { lstat, mkdtemp, open, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
7
|
+
import { resolveCommit } from "../git.js";
|
|
8
|
+
import { registerPrivateEnvNames } from "../env.js";
|
|
9
|
+
import { durableWorkerDirectory } from "./durability.js";
|
|
10
|
+
import { redactExport } from "../privacy.js";
|
|
11
|
+
import { verificationIntegrity } from "../verification-integrity.js";
|
|
12
|
+
import { assertTransferTree, chunks, contextManifest, importWorkerArtifact, sha256, sourceBundle } from "./bundles.js";
|
|
13
|
+
import { CAPABILITY, COMMIT_ID, DIGEST, MAX_BUNDLE_BYTES, MAX_EVENTS_BYTES, MAX_FRAME_BYTES, REQUEST_ID, RemoteWorkerError, validateWorkerCapabilities, validateWorkerRequest } from "./protocol.js";
|
|
14
|
+
const targetFingerprint = (target) => sha256(JSON.stringify({ sshHost: target.sshHost, command: target.command, containerProfile: target.containerProfile }));
|
|
15
|
+
const ID = /^[a-z][a-z0-9-]{0,63}$/;
|
|
16
|
+
const object = (value) => !!value && typeof value === "object" && !Array.isArray(value);
|
|
17
|
+
const terminal = (state) => ["completed", "failed", "cancelled"].includes(state);
|
|
18
|
+
const SSH_OPTIONS = ["-T", "-a", "-k", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes", "-o", "ForwardAgent=no", "-o", "ForwardX11=no", "-o", "ClearAllForwardings=yes", "-o", "PermitLocalCommand=no", "-o", "RemoteCommand=none", "-o", "GSSAPIDelegateCredentials=no", "-o", "SendEnv=-*", "-o", "ControlMaster=no", "-o", "ControlPath=none", "-o", "ConnectTimeout=10"];
|
|
19
|
+
function sshEnvironment() {
|
|
20
|
+
return { PATH: process.env.PATH, HOME: process.env.HOME, USER: process.env.USER, LOGNAME: process.env.LOGNAME, SYSTEMROOT: process.env.SYSTEMROOT, LANG: "C.UTF-8" };
|
|
21
|
+
}
|
|
22
|
+
function targetConfig(value) {
|
|
23
|
+
if (!object(value) || value.version !== 1 || !Array.isArray(value.workers) || value.workers.length > 20) throw new RemoteWorkerError("Remote workers need explicit version 1 configuration with at most 20 targets");
|
|
24
|
+
const workers = value.workers.map((raw) => {
|
|
25
|
+
if (!object(raw) || typeof raw.id !== "string" || !ID.test(raw.id) || typeof raw.name !== "string" || !raw.name.trim() || raw.name.length > 120 || typeof raw.sshHost !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._@-]{0,199}$/.test(raw.sshHost) || typeof raw.command !== "string" || !raw.command.trim() || raw.command.length > 2e3 || /[\r\n\0]/.test(raw.command) || !Array.isArray(raw.repoMappings) || !raw.repoMappings.length || raw.repoMappings.length > 20 || raw.repoMappings.some((id) => typeof id !== "string" || !ID.test(id)) || raw.containerProfile !== void 0 && (typeof raw.containerProfile !== "string" || !ID.test(raw.containerProfile))) throw new RemoteWorkerError("Invalid registered SSH worker, command or repository mapping");
|
|
26
|
+
return { id: raw.id, name: raw.name.trim(), sshHost: raw.sshHost, command: raw.command, repoMappings: [...new Set(raw.repoMappings)], ...raw.containerProfile ? { containerProfile: raw.containerProfile } : {} };
|
|
27
|
+
});
|
|
28
|
+
if (new Set(workers.map((worker) => worker.id)).size !== workers.length) throw new RemoteWorkerError("Remote worker IDs must be unique");
|
|
29
|
+
return workers;
|
|
30
|
+
}
|
|
31
|
+
class RemoteWorkers {
|
|
32
|
+
/** sshBinary is a transport fixture seam; application configuration cannot override it. */
|
|
33
|
+
constructor(options = {}) {
|
|
34
|
+
this.options = options;
|
|
35
|
+
this.directory = join(resolve(options.home ?? process.env.CODEFLOW_HOME ?? join(homedir(), ".codeflow")), "remote-worker-private");
|
|
36
|
+
}
|
|
37
|
+
targets = [];
|
|
38
|
+
directory;
|
|
39
|
+
artifacts = /* @__PURE__ */ new Set();
|
|
40
|
+
async load() {
|
|
41
|
+
registerPrivateEnvNames(["CODEFLOW_REMOTE_WORKERS_JSON", "CODEFLOW_REMOTE_WORKERS_FILE", "CODEFLOW_WORKER_CONFIG_FILE"]);
|
|
42
|
+
const env = this.options.environment ?? process.env;
|
|
43
|
+
let text = env.CODEFLOW_REMOTE_WORKERS_JSON;
|
|
44
|
+
if (text && env.CODEFLOW_REMOTE_WORKERS_FILE) throw new RemoteWorkerError("Choose one remote worker configuration source");
|
|
45
|
+
if (env.CODEFLOW_REMOTE_WORKERS_FILE) {
|
|
46
|
+
const path = env.CODEFLOW_REMOTE_WORKERS_FILE;
|
|
47
|
+
if (!isAbsolute(path)) throw new RemoteWorkerError("Remote worker configuration file must be absolute");
|
|
48
|
+
const info = await lstat(path);
|
|
49
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > 128 * 1024) throw new RemoteWorkerError("Remote worker configuration must be a bounded regular file");
|
|
50
|
+
text = await readFile(path, "utf8");
|
|
51
|
+
}
|
|
52
|
+
if (text && Buffer.byteLength(text) > 128 * 1024) throw new RemoteWorkerError("Remote worker configuration exceeds 128 KiB");
|
|
53
|
+
this.targets = text ? targetConfig(JSON.parse(text)) : [];
|
|
54
|
+
await durableWorkerDirectory(this.directory);
|
|
55
|
+
if (!(await lstat(this.directory)).isDirectory()) throw new RemoteWorkerError("Remote receipt store must be a regular directory");
|
|
56
|
+
for (const name of await readdir(this.directory)) if (/^artifact-[A-Za-z0-9_-]+$/.test(name)) await rm(join(this.directory, name), { recursive: true, force: true });
|
|
57
|
+
}
|
|
58
|
+
list() {
|
|
59
|
+
return this.targets.map(({ id, name, repoMappings, containerProfile }) => ({ id, name, repoMappings: [...repoMappings], containerProfile, protocol: 1 }));
|
|
60
|
+
}
|
|
61
|
+
target(id, mapping) {
|
|
62
|
+
const target = this.targets.find((item) => item.id === id);
|
|
63
|
+
if (!target || mapping && !target.repoMappings.includes(mapping)) throw new RemoteWorkerError("Select an explicitly registered worker and repository mapping");
|
|
64
|
+
return target;
|
|
65
|
+
}
|
|
66
|
+
file(id) {
|
|
67
|
+
if (!REQUEST_ID.test(id)) throw new RemoteWorkerError("Invalid remote request ID");
|
|
68
|
+
return join(this.directory, `${id}.json`);
|
|
69
|
+
}
|
|
70
|
+
async remembered(workerId, id) {
|
|
71
|
+
const file = this.file(id), info = await lstat(file).catch((error) => {
|
|
72
|
+
if (error.code === "ENOENT") return void 0;
|
|
73
|
+
throw error;
|
|
74
|
+
});
|
|
75
|
+
if (!info) return void 0;
|
|
76
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > 8192) throw new RemoteWorkerError("Private remote receipt is damaged");
|
|
77
|
+
const value = JSON.parse(await readFile(file, "utf8"));
|
|
78
|
+
if (value.version !== 1 || value.workerId !== workerId || value.requestId !== id || !CAPABILITY.test(value.capability) || !COMMIT_ID.test(value.baseCommit)) throw new RemoteWorkerError("Private remote receipt does not belong to this worker");
|
|
79
|
+
if (value.targetFingerprint !== targetFingerprint(this.target(workerId))) throw new RemoteWorkerError("The registered SSH target changed. Restore its original host, command and profile before inspecting this private receipt.");
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
async remember(value) {
|
|
83
|
+
if ((await readdir(this.directory)).filter((file) => file.endsWith(".json")).length >= 2e3) throw new RemoteWorkerError("Inspect and archive remote receipts before creating more than 2,000 requests");
|
|
84
|
+
const handle = await open(this.file(value.requestId), "wx", 384);
|
|
85
|
+
try {
|
|
86
|
+
await handle.writeFile(JSON.stringify(value));
|
|
87
|
+
await handle.sync();
|
|
88
|
+
} finally {
|
|
89
|
+
await handle.close();
|
|
90
|
+
}
|
|
91
|
+
const folder = await open(this.directory, "r");
|
|
92
|
+
try {
|
|
93
|
+
await folder.sync();
|
|
94
|
+
} finally {
|
|
95
|
+
await folder.close();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async exchange(target, frames, options = {}) {
|
|
99
|
+
return new Promise((resolveResult, reject) => {
|
|
100
|
+
const child = spawn(this.options.sshBinary ?? "ssh", [...SSH_OPTIONS, target.sshHost, target.command], { env: sshEnvironment(), detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"] });
|
|
101
|
+
const result = {};
|
|
102
|
+
let buffer = "", stderr = "", done = false, closed = false, hello = false, accepted = false, lastSeen = Date.now(), received = 0, events = 0, artifactOffset = 0;
|
|
103
|
+
let killTimer;
|
|
104
|
+
const stop = (signal) => {
|
|
105
|
+
if (closed) return;
|
|
106
|
+
try {
|
|
107
|
+
if (process.platform !== "win32" && child.pid) process.kill(-child.pid, signal);
|
|
108
|
+
else child.kill(signal);
|
|
109
|
+
} catch {
|
|
110
|
+
child.kill(signal);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
const finish = (error) => {
|
|
114
|
+
if (done) return;
|
|
115
|
+
done = true;
|
|
116
|
+
clearTimeout(deadline);
|
|
117
|
+
clearInterval(watch);
|
|
118
|
+
options.signal?.removeEventListener("abort", abort);
|
|
119
|
+
child.stdin.destroy();
|
|
120
|
+
stop("SIGTERM");
|
|
121
|
+
if (!closed) {
|
|
122
|
+
killTimer = setTimeout(() => stop("SIGKILL"), 2e3);
|
|
123
|
+
killTimer.unref();
|
|
124
|
+
}
|
|
125
|
+
if (error) reject(error);
|
|
126
|
+
else resolveResult(result);
|
|
127
|
+
};
|
|
128
|
+
const abort = () => finish(new RemoteWorkerError("Remote transport was cancelled. Inspect the durable receipt before retrying.", accepted));
|
|
129
|
+
const deadline = setTimeout(() => finish(new RemoteWorkerError("Remote channel exceeded its deadline; inspect its receipt before retrying", accepted)), options.timeoutMs ?? 2e4);
|
|
130
|
+
const watch = setInterval(() => {
|
|
131
|
+
if (accepted && Date.now() - lastSeen > 15e3) finish(new RemoteWorkerError("Remote heartbeat expired; execution outcome is ambiguous", true));
|
|
132
|
+
}, 1e3);
|
|
133
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
134
|
+
const send = async () => {
|
|
135
|
+
for (const frame of frames) {
|
|
136
|
+
if (done) return;
|
|
137
|
+
const line = JSON.stringify(frame) + "\n";
|
|
138
|
+
if (Buffer.byteLength(line) > MAX_FRAME_BYTES) throw new RemoteWorkerError("Remote input exceeds its frame bound");
|
|
139
|
+
if (!child.stdin.write(line)) await once(child.stdin, "drain");
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
const handle = (frame) => {
|
|
143
|
+
if (!frame || frame.v !== 1) throw new RemoteWorkerError("Unsupported worker protocol");
|
|
144
|
+
lastSeen = Date.now();
|
|
145
|
+
if (frame.type === "hello") {
|
|
146
|
+
if (hello || frame.protocol !== 1) throw new RemoteWorkerError("Unsupported worker handshake");
|
|
147
|
+
hello = true;
|
|
148
|
+
void send().catch((error) => finish(error));
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (!hello) throw new RemoteWorkerError("Worker did not identify its protocol");
|
|
152
|
+
if (frame.type === "error") throw new RemoteWorkerError(typeof frame.message === "string" ? frame.message.slice(0, 2e3) : "Worker rejected the request", accepted);
|
|
153
|
+
if (frame.type === "probe") {
|
|
154
|
+
const capabilities = validateWorkerCapabilities(frame.capabilities);
|
|
155
|
+
if (frame.hasBase !== void 0 && typeof frame.hasBase !== "boolean" || frame.hasRepositoryBase !== void 0 && typeof frame.hasRepositoryBase !== "boolean") throw new RemoteWorkerError("Invalid worker commit availability");
|
|
156
|
+
result.probe = { capabilities, hasBase: frame.hasBase, hasRepositoryBase: frame.hasRepositoryBase };
|
|
157
|
+
finish();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (frame.type === "accepted" || frame.type === "heartbeat" || frame.type === "receipt" || frame.type === "result") {
|
|
161
|
+
const receipt = frame.receipt;
|
|
162
|
+
if (!receipt || receipt.version !== 1 || !REQUEST_ID.test(receipt.requestId) || !["accepted", "running", "completed", "failed", "cancelled", "unknown"].includes(receipt.state)) throw new RemoteWorkerError("Invalid worker receipt");
|
|
163
|
+
const expected = frames.find((item) => item.type === "begin" || item.type === "receipt" || item.type === "cancel");
|
|
164
|
+
const id = expected?.type === "begin" ? expected.request.id : expected && "id" in expected ? expected.id : void 0;
|
|
165
|
+
if (receipt.requestId !== id) throw new RemoteWorkerError("Worker receipt belongs to a different request");
|
|
166
|
+
result.receipt = receipt;
|
|
167
|
+
accepted = true;
|
|
168
|
+
options.onReceipt?.(receipt);
|
|
169
|
+
if (frame.type === "result") {
|
|
170
|
+
if (typeof frame.result?.ok !== "boolean" || typeof frame.result.finalText !== "string" || frame.result.finalText.length > 512 * 1024) throw new RemoteWorkerError("Invalid worker result");
|
|
171
|
+
result.result = { ...frame.result, sessionId: void 0 };
|
|
172
|
+
if (result.artifact && artifactOffset !== result.artifact.size) throw new RemoteWorkerError("Remote artifact transfer is incomplete");
|
|
173
|
+
finish();
|
|
174
|
+
} else if (frame.type === "receipt" && (!receipt.artifact || frames.some((item) => item.type === "receipt" && item.downloadArtifact === false))) finish();
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (frame.type === "event") {
|
|
178
|
+
const size = Buffer.byteLength(JSON.stringify(frame.event));
|
|
179
|
+
events += size;
|
|
180
|
+
if (size > 256 * 1024 || events > MAX_EVENTS_BYTES) throw new RemoteWorkerError("Worker events exceeded their bound", accepted);
|
|
181
|
+
if (frame.event?.kind !== "session") options.onEvent?.(redactExport(frame.event));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (frame.type === "artifact") {
|
|
185
|
+
const value = frame.artifact;
|
|
186
|
+
if (result.artifact || !value || value.requestId !== result.receipt?.requestId || !COMMIT_ID.test(value.baseCommit) || !COMMIT_ID.test(value.head) || !DIGEST.test(value.sha256) || value.ref !== `refs/codeflow/results/${value.requestId}` || !Number.isSafeInteger(value.size) || value.size < 1 || value.size > MAX_BUNDLE_BYTES) throw new RemoteWorkerError("Invalid remote artifact manifest");
|
|
187
|
+
result.artifact = value;
|
|
188
|
+
result.bytes = Buffer.alloc(value.size);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (frame.type === "chunk") {
|
|
192
|
+
if (!result.bytes || frame.offset !== artifactOffset || typeof frame.data !== "string" || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(frame.data)) throw new RemoteWorkerError("Invalid remote artifact chunk");
|
|
193
|
+
const bytes = Buffer.from(frame.data, "base64");
|
|
194
|
+
if (!bytes.length || artifactOffset + bytes.length > result.bytes.length) throw new RemoteWorkerError("Remote artifact exceeded its manifest");
|
|
195
|
+
result.bytes.set(bytes, artifactOffset);
|
|
196
|
+
artifactOffset += bytes.length;
|
|
197
|
+
if (artifactOffset === result.bytes.length && frames.some((item) => item.type === "receipt")) finish();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
throw new RemoteWorkerError("Unknown worker response frame");
|
|
201
|
+
};
|
|
202
|
+
child.stdout.setEncoding("utf8");
|
|
203
|
+
child.stdout.on("data", (data) => {
|
|
204
|
+
if (done) return;
|
|
205
|
+
received += Buffer.byteLength(data);
|
|
206
|
+
if (received > 112 * 1024 * 1024) {
|
|
207
|
+
finish(new RemoteWorkerError("Remote response exceeded its total bound", accepted));
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
buffer += data;
|
|
211
|
+
try {
|
|
212
|
+
let at;
|
|
213
|
+
while ((at = buffer.indexOf("\n")) >= 0) {
|
|
214
|
+
const line = buffer.slice(0, at);
|
|
215
|
+
buffer = buffer.slice(at + 1);
|
|
216
|
+
if (Buffer.byteLength(line) > MAX_FRAME_BYTES) throw new RemoteWorkerError("Remote frame exceeds its bound", accepted);
|
|
217
|
+
handle(JSON.parse(line));
|
|
218
|
+
if (done) return;
|
|
219
|
+
}
|
|
220
|
+
if (Buffer.byteLength(buffer) > MAX_FRAME_BYTES) throw new RemoteWorkerError("Remote frame exceeds its bound", accepted);
|
|
221
|
+
} catch (error) {
|
|
222
|
+
finish(error);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
child.stderr.setEncoding("utf8");
|
|
226
|
+
child.stderr.on("data", (data) => {
|
|
227
|
+
stderr = (stderr + data).slice(-2e3);
|
|
228
|
+
});
|
|
229
|
+
child.on("error", (error) => finish(new RemoteWorkerError(error.message, accepted)));
|
|
230
|
+
child.on("close", () => {
|
|
231
|
+
closed = true;
|
|
232
|
+
clearTimeout(killTimer);
|
|
233
|
+
if (!done) finish(new RemoteWorkerError(`SSH channel closed before a complete result${stderr ? `: ${redactExport(stderr).trim()}` : ""}`, accepted || frames.some((item) => item.type === "execute")));
|
|
234
|
+
});
|
|
235
|
+
child.stdin.on("error", () => void 0);
|
|
236
|
+
if (options.signal?.aborted) abort();
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
async probe(workerId, input = {}) {
|
|
240
|
+
const target = this.target(workerId, input.repoMapping);
|
|
241
|
+
for (const commit of [input.baseCommit, input.repositoryBase]) if (commit !== void 0 && !COMMIT_ID.test(commit)) throw new RemoteWorkerError("Worker probe requires exact commit IDs");
|
|
242
|
+
const value = await this.exchange(target, [{ v: 1, type: "probe", ...input, containerProfile: target.containerProfile }]);
|
|
243
|
+
return value.probe;
|
|
244
|
+
}
|
|
245
|
+
async outcome(record, value) {
|
|
246
|
+
const receipt = value.receipt, state = receipt?.state ?? "unknown", ambiguous = !terminal(state);
|
|
247
|
+
const result = { ...value.result ?? receipt?.result ?? { ok: false, finalText: "", error: receipt?.reason ?? "Remote outcome is ambiguous; inspect its receipt before explicitly retrying", errorKind: "other" }, sessionId: void 0, remote: { workerId: record.workerId, requestId: record.requestId, state, receipt: receipt?.fingerprint, ...ambiguous ? { ambiguous: true } : {}, baseCommit: record.baseCommit, resultHead: value.artifact?.head ?? receipt?.artifact?.head } };
|
|
248
|
+
if (value.artifact && value.bytes) {
|
|
249
|
+
if (value.artifact.baseCommit !== record.baseCommit || sha256(value.bytes) !== value.artifact.sha256) throw new RemoteWorkerError("Remote artifact failed its digest or accepted base");
|
|
250
|
+
if (this.artifacts.size >= 32) throw new RemoteWorkerError("Import or discard pending remote artifacts before downloading more than 32 bundles");
|
|
251
|
+
const folder = await mkdtemp(join(this.directory, "artifact-"));
|
|
252
|
+
const file = join(folder, "result.bundle");
|
|
253
|
+
await writeFile(file, value.bytes, { flag: "wx", mode: 384 });
|
|
254
|
+
this.artifacts.add(file);
|
|
255
|
+
result.artifact = { ...value.artifact, file };
|
|
256
|
+
}
|
|
257
|
+
return result;
|
|
258
|
+
}
|
|
259
|
+
async hasRequest(workerId, id) {
|
|
260
|
+
return !!await this.remembered(workerId, id);
|
|
261
|
+
}
|
|
262
|
+
assertScope(record, expected) {
|
|
263
|
+
if (expected && (record.scope.runId !== expected.runId || !expected.stepIds.includes(record.scope.stepId))) throw new RemoteWorkerError("The private remote capability belongs to a different run or step");
|
|
264
|
+
}
|
|
265
|
+
async receipt(workerId, id, options = {}) {
|
|
266
|
+
const record = await this.remembered(workerId, id);
|
|
267
|
+
if (!record) throw new RemoteWorkerError("This server has no private capability for that remote request");
|
|
268
|
+
this.assertScope(record, options.expectedScope);
|
|
269
|
+
return this.outcome(record, await this.exchange(this.target(workerId), [{ v: 1, type: "receipt", id, capability: record.capability, downloadArtifact: options.downloadArtifact }], { timeoutMs: 9e4 }));
|
|
270
|
+
}
|
|
271
|
+
async cancel(workerId, id, options = {}) {
|
|
272
|
+
const record = await this.remembered(workerId, id);
|
|
273
|
+
if (!record) throw new RemoteWorkerError("This server has no private capability for that remote request");
|
|
274
|
+
this.assertScope(record, options.expectedScope);
|
|
275
|
+
return this.outcome(record, await this.exchange(this.target(workerId), [{ v: 1, type: "cancel", id, capability: record.capability }]));
|
|
276
|
+
}
|
|
277
|
+
async execute(input, onEvent) {
|
|
278
|
+
const target = this.target(input.workerId, input.repoMapping), opts = input.options, agent = input.agent ?? opts.agent;
|
|
279
|
+
if (!agent) throw new RemoteWorkerError("Remote execution requires an explicit provider");
|
|
280
|
+
if (opts.interactiveApprovals || opts.teamTools || opts.resumeSessionId) throw new RemoteWorkerError("Remote turns use fresh sessions and structured replies; native approvals, live team tools and resumed sessions are unsupported");
|
|
281
|
+
if (opts.agentContainer && !target.containerProfile) throw new RemoteWorkerError("Register an explicit worker-local container profile; host container settings and credential volumes are never forwarded");
|
|
282
|
+
const id = input.requestId ?? randomBytes(16).toString("hex"), baseCommit = await resolveCommit(opts.cwd);
|
|
283
|
+
await assertTransferTree(opts.cwd, baseCommit);
|
|
284
|
+
const integrity = await verificationIntegrity(opts.cwd, opts.signal);
|
|
285
|
+
if (integrity.head !== baseCommit) throw new RemoteWorkerError("The source checkout changed before transfer");
|
|
286
|
+
const managedPermissions = opts.permissions ? { version: 1, revision: opts.permissions.policyRevision, trust: opts.permissions.trust, toolApprovals: opts.permissions.toolApprovals, delegation: opts.permissions.delegation, required: [...opts.permissions.required] } : void 0;
|
|
287
|
+
const fingerprint = sha256(JSON.stringify({ managedPermissions, scope: input.scope, repoMapping: input.repoMapping, agent, baseCommit, mode: opts.mode, prompt: opts.prompt, model: opts.model, effort: opts.effort, trust: opts.trust ?? "standard", jsonSchema: opts.jsonSchema }));
|
|
288
|
+
const prior = await this.remembered(input.workerId, id);
|
|
289
|
+
if (prior) {
|
|
290
|
+
if (prior.fingerprint !== fingerprint) throw new RemoteWorkerError("Remote request ID already belongs to another scoped invocation");
|
|
291
|
+
return this.receipt(input.workerId, id);
|
|
292
|
+
}
|
|
293
|
+
const probe = await this.probe(input.workerId, { repoMapping: input.repoMapping, baseCommit, repositoryBase: input.repositoryBase });
|
|
294
|
+
if (!probe.capabilities.repoMappings.includes(input.repoMapping) || !probe.capabilities.providers.some((item) => item.id === agent && item.available) || target.containerProfile && !probe.capabilities.containerProfiles.includes(target.containerProfile)) throw new RemoteWorkerError("Selected provider, mapping or worker-local container profile is unavailable on the registered worker");
|
|
295
|
+
const context = await contextManifest(opts.cwd), capability = randomBytes(32).toString("hex"), transfer = probe.hasBase ? void 0 : await sourceBundle(opts.cwd, baseCommit, id, probe.hasRepositoryBase ? input.repositoryBase : void 0);
|
|
296
|
+
const request = validateWorkerRequest({ managedPermissions, id, capability, scope: input.scope, repoMapping: input.repoMapping, baseCommit, agent, mode: opts.mode, prompt: opts.prompt, model: opts.model, effort: opts.effort, trust: opts.trust ?? "standard", timeoutMs: Math.min(opts.timeoutMs ?? 30 * 6e4, 30 * 6e4), idleTimeoutMs: opts.idleTimeoutMs, jsonSchema: opts.jsonSchema, questionProtocol: opts.questionProtocol, containerProfile: target.containerProfile, context: context.files, ...transfer ? { source: { size: transfer.size, sha256: transfer.sha256, ref: transfer.ref } } : {} });
|
|
297
|
+
const record = { version: 1, workerId: input.workerId, requestId: id, capability, scope: input.scope, baseCommit, fingerprint, targetFingerprint: targetFingerprint(target), createdAt: Date.now() };
|
|
298
|
+
await integrity.assert("source transfer");
|
|
299
|
+
await this.remember(record);
|
|
300
|
+
const frames = [{ v: 1, type: "begin", request }];
|
|
301
|
+
if (transfer) for (const chunk of chunks(transfer.data)) frames.push({ v: 1, type: "source", id, capability, ...chunk });
|
|
302
|
+
for (const [path, bytes] of context.data) for (const chunk of chunks(bytes)) frames.push({ v: 1, type: "context", id, capability, path, ...chunk });
|
|
303
|
+
frames.push({ v: 1, type: "execute", id, capability });
|
|
304
|
+
const abort = () => {
|
|
305
|
+
void this.cancel(input.workerId, id).catch(() => void 0);
|
|
306
|
+
};
|
|
307
|
+
opts.signal?.addEventListener("abort", abort, { once: true });
|
|
308
|
+
try {
|
|
309
|
+
return await this.outcome(record, await this.exchange(target, frames, { onEvent, signal: opts.signal, timeoutMs: request.timeoutMs + 45e3, onReceipt: (receipt) => input.onReceipt?.({ workerId: input.workerId, requestId: id, baseCommit, state: receipt.state, receipt: receipt.fingerprint, ...receipt.state === "unknown" ? { ambiguous: true } : {} }) }));
|
|
310
|
+
} catch (error) {
|
|
311
|
+
return { ok: false, finalText: "", error: redactExport(error.message), errorKind: opts.signal?.aborted ? "cancelled" : "other", remote: { workerId: input.workerId, requestId: id, baseCommit, state: "unknown", ambiguous: true } };
|
|
312
|
+
} finally {
|
|
313
|
+
opts.signal?.removeEventListener("abort", abort);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
async importArtifact(repo, artifact, expectedHead) {
|
|
317
|
+
if (!this.artifacts.has(artifact.file)) throw new RemoteWorkerError("Artifact does not belong to this worker client");
|
|
318
|
+
try {
|
|
319
|
+
return await importWorkerArtifact(repo, artifact, expectedHead);
|
|
320
|
+
} finally {
|
|
321
|
+
this.artifacts.delete(artifact.file);
|
|
322
|
+
await rm(resolve(artifact.file, ".."), { recursive: true, force: true });
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
async discardArtifact(artifact) {
|
|
326
|
+
if (this.artifacts.delete(artifact.file)) await rm(resolve(artifact.file, ".."), { recursive: true, force: true });
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
export {
|
|
330
|
+
RemoteWorkers,
|
|
331
|
+
SSH_OPTIONS
|
|
332
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { lstat, mkdir, open } from "node:fs/promises";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
async function syncWorkerDirectory(path) {
|
|
4
|
+
if (process.platform === "win32") return;
|
|
5
|
+
const directory = await open(path, "r");
|
|
6
|
+
try {
|
|
7
|
+
await directory.sync();
|
|
8
|
+
} finally {
|
|
9
|
+
await directory.close();
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
async function durableWorkerDirectory(path, synchronize = syncWorkerDirectory) {
|
|
13
|
+
const destination = resolve(path), missing = [];
|
|
14
|
+
let current = destination;
|
|
15
|
+
for (; ; ) {
|
|
16
|
+
const info2 = await lstat(current).catch((error) => {
|
|
17
|
+
if (error.code === "ENOENT") return void 0;
|
|
18
|
+
throw error;
|
|
19
|
+
});
|
|
20
|
+
if (info2) {
|
|
21
|
+
if (!info2.isDirectory() || info2.isSymbolicLink()) throw new Error("Worker state must use a regular directory");
|
|
22
|
+
break;
|
|
23
|
+
}
|
|
24
|
+
missing.push(current);
|
|
25
|
+
const parent = dirname(current);
|
|
26
|
+
if (parent === current) throw new Error("Worker directory has no existing ancestor");
|
|
27
|
+
current = parent;
|
|
28
|
+
}
|
|
29
|
+
await mkdir(destination, { recursive: true, mode: 448 });
|
|
30
|
+
const info = await lstat(destination);
|
|
31
|
+
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error("Worker state must use a regular directory");
|
|
32
|
+
for (const directory of new Set(missing.length ? [...missing, current] : [destination, dirname(destination)])) await synchronize(directory);
|
|
33
|
+
}
|
|
34
|
+
export {
|
|
35
|
+
durableWorkerDirectory,
|
|
36
|
+
syncWorkerDirectory
|
|
37
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { validateRunPermissions } from "../run-permissions.js";
|
|
2
|
+
const WORKER_PROTOCOL = 1;
|
|
3
|
+
const MAX_BUNDLE_BYTES = 64 * 1024 * 1024;
|
|
4
|
+
const MAX_CONTEXT_BYTES = 16 * 1024 * 1024;
|
|
5
|
+
const CHUNK_BYTES = 192 * 1024;
|
|
6
|
+
const MAX_FRAME_BYTES = 2 * 1024 * 1024;
|
|
7
|
+
const MAX_EVENTS_BYTES = 16 * 1024 * 1024;
|
|
8
|
+
const REQUEST_ID = /^[a-f0-9]{32}$/;
|
|
9
|
+
const CAPABILITY = /^[a-f0-9]{64}$/;
|
|
10
|
+
const COMMIT_ID = /^[a-f0-9]{40,64}$/i;
|
|
11
|
+
const DIGEST = /^[a-f0-9]{64}$/;
|
|
12
|
+
function validateWorkerSelection(value) {
|
|
13
|
+
if (value === void 0 || value === null) return void 0;
|
|
14
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).some((key) => !["workerId", "repoMapping"].includes(key)) || !/^[a-z][a-z0-9-]{0,63}$/.test(value.workerId) || !/^[a-z][a-z0-9-]{0,63}$/.test(value.repoMapping)) throw new RemoteWorkerError("Choose a registered worker ID and repository mapping");
|
|
15
|
+
return { workerId: value.workerId, repoMapping: value.repoMapping };
|
|
16
|
+
}
|
|
17
|
+
class RemoteWorkerError extends Error {
|
|
18
|
+
constructor(message, ambiguous = false) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.ambiguous = ambiguous;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function validateWorkerCapabilities(value) {
|
|
24
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new RemoteWorkerError("Invalid worker capabilities");
|
|
25
|
+
const raw = value, mapping = (item) => typeof item === "string" && /^[a-z][a-z0-9-]{0,63}$/.test(item);
|
|
26
|
+
if (raw.protocol !== 1 || !Array.isArray(raw.providers) || raw.providers.length > 3 || raw.providers.some((provider) => !provider || typeof provider !== "object" || Object.keys(provider).some((key) => !["id", "available"].includes(key)) || !["claude", "codex", "agy"].includes(provider.id) || typeof provider.available !== "boolean") || new Set(raw.providers.map((provider) => provider.id)).size !== raw.providers.length || !Array.isArray(raw.repoMappings) || raw.repoMappings.length > 20 || !raw.repoMappings.every(mapping) || !Array.isArray(raw.containerProfiles) || raw.containerProfiles.length > 20 || !raw.containerProfiles.every(mapping) || raw.resumeSessions !== false || raw.liveTeamTools !== false || raw.interactiveApprovals !== false || raw.stagedContext !== true || raw.maximumBundleBytes !== MAX_BUNDLE_BYTES || raw.maximumContextBytes !== MAX_CONTEXT_BYTES) throw new RemoteWorkerError("Invalid or incompatible bounded worker capabilities");
|
|
27
|
+
return structuredClone(raw);
|
|
28
|
+
}
|
|
29
|
+
const object = (value) => !!value && typeof value === "object" && !Array.isArray(value);
|
|
30
|
+
const bounded = (value, max) => typeof value === "string" && value.length > 0 && value.length <= max && !value.includes("\0");
|
|
31
|
+
function contextPath(value) {
|
|
32
|
+
return typeof value === "string" && value.startsWith(".codeflow-context/") && value.length <= 500 && !/[\\\x00-\x1f\x7f]/.test(value) && !value.split("/").some((part) => !part || part === "." || part === ".." || part.toLowerCase() === ".git") && !/(^|\/)(?:\.env(?:\..*)?|.*\.(?:pem|key))$/i.test(value);
|
|
33
|
+
}
|
|
34
|
+
function validateWorkerRequest(value) {
|
|
35
|
+
if (!object(value) || !REQUEST_ID.test(String(value.id)) || !CAPABILITY.test(String(value.capability)) || !object(value.scope) || Object.values(value.scope).length !== 3 || ![value.scope.runId, value.scope.stepId, value.scope.threadId].every((item) => bounded(item, 256)) || !bounded(value.repoMapping, 64) || !/^[a-z][a-z0-9-]*$/.test(value.repoMapping) || !COMMIT_ID.test(String(value.baseCommit)) || !["claude", "codex", "agy"].includes(String(value.agent)) || !["read", "write"].includes(String(value.mode)) || !["safe", "standard", "trusted", "full"].includes(String(value.trust)) || !bounded(value.prompt, 512 * 1024) || !Number.isInteger(value.timeoutMs) || value.timeoutMs < 100 || value.timeoutMs > 30 * 6e4) throw new RemoteWorkerError("Invalid or unbounded remote execution request");
|
|
36
|
+
if (value.model !== void 0 && !/^[A-Za-z0-9][\w.:/[\]@-]{0,99}$/.test(String(value.model)) || value.effort !== void 0 && !/^[a-z]{2,12}$/.test(String(value.effort)) || value.idleTimeoutMs !== void 0 && (!Number.isInteger(value.idleTimeoutMs) || value.idleTimeoutMs < 100 || value.idleTimeoutMs > 30 * 6e4)) throw new RemoteWorkerError("Invalid remote model, effort or idle timeout");
|
|
37
|
+
if (value.jsonSchema !== void 0 && (!object(value.jsonSchema) || JSON.stringify(value.jsonSchema).length > 128 * 1024)) throw new RemoteWorkerError("Remote output schema exceeds its bound");
|
|
38
|
+
if (value.containerProfile !== void 0 && !/^[a-z][a-z0-9-]{0,63}$/.test(String(value.containerProfile)) || value.questionProtocol !== void 0 && value.questionProtocol !== "structured") throw new RemoteWorkerError("Invalid worker-local execution profile");
|
|
39
|
+
if (!Array.isArray(value.context) || value.context.length > 100 || value.context.some((file) => !object(file) || !contextPath(file.path) || typeof file.size !== "number" || !Number.isSafeInteger(file.size) || file.size < 0 || file.size > 8 * 1024 * 1024 || typeof file.sha256 !== "string" || !DIGEST.test(file.sha256)) || value.context.reduce((total, file) => total + file.size, 0) > MAX_CONTEXT_BYTES || new Set(value.context.map((file) => file.path)).size !== value.context.length) throw new RemoteWorkerError("Remote context must contain at most 100 bounded regular reference files");
|
|
40
|
+
if (value.source !== void 0 && (!object(value.source) || !Number.isSafeInteger(value.source.size) || value.source.size < 1 || value.source.size > MAX_BUNDLE_BYTES || !DIGEST.test(String(value.source.sha256)) || value.source.ref !== `refs/codeflow/transfers/${value.id}`)) throw new RemoteWorkerError("Invalid source bundle manifest");
|
|
41
|
+
if (value.managedPermissions !== void 0) {
|
|
42
|
+
try {
|
|
43
|
+
validateRunPermissions(value.managedPermissions);
|
|
44
|
+
} catch {
|
|
45
|
+
throw new RemoteWorkerError("Invalid managed permission policy");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const allowed = ["managedPermissions", "id", "capability", "scope", "repoMapping", "baseCommit", "agent", "mode", "prompt", "model", "effort", "trust", "timeoutMs", "idleTimeoutMs", "jsonSchema", "questionProtocol", "containerProfile", "source", "context"];
|
|
49
|
+
if (Object.keys(value).some((key) => !allowed.includes(key))) throw new RemoteWorkerError("Remote requests cannot forward arbitrary environment, callbacks, session handles or paths");
|
|
50
|
+
return structuredClone(value);
|
|
51
|
+
}
|
|
52
|
+
export {
|
|
53
|
+
CAPABILITY,
|
|
54
|
+
CHUNK_BYTES,
|
|
55
|
+
COMMIT_ID,
|
|
56
|
+
DIGEST,
|
|
57
|
+
MAX_BUNDLE_BYTES,
|
|
58
|
+
MAX_CONTEXT_BYTES,
|
|
59
|
+
MAX_EVENTS_BYTES,
|
|
60
|
+
MAX_FRAME_BYTES,
|
|
61
|
+
REQUEST_ID,
|
|
62
|
+
RemoteWorkerError,
|
|
63
|
+
WORKER_PROTOCOL,
|
|
64
|
+
contextPath,
|
|
65
|
+
validateWorkerCapabilities,
|
|
66
|
+
validateWorkerRequest,
|
|
67
|
+
validateWorkerSelection
|
|
68
|
+
};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { once } from "node:events";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { git } from "../git.js";
|
|
5
|
+
import { chunks } from "./bundles.js";
|
|
6
|
+
import { MAX_FRAME_BYTES, RemoteWorkerError, validateWorkerRequest } from "./protocol.js";
|
|
7
|
+
async function serveWorker(worker) {
|
|
8
|
+
const controller = new AbortController();
|
|
9
|
+
let buffer = "", frames = 0, finished = false, executing = false;
|
|
10
|
+
let request, source, sourceOffset = 0;
|
|
11
|
+
const context = /* @__PURE__ */ new Map(), offsets = /* @__PURE__ */ new Map();
|
|
12
|
+
let output = Promise.resolve();
|
|
13
|
+
const write = async (frame) => {
|
|
14
|
+
const line = JSON.stringify(frame) + "\n";
|
|
15
|
+
if (Buffer.byteLength(line) > MAX_FRAME_BYTES) throw new RemoteWorkerError("Worker response frame exceeds its bound");
|
|
16
|
+
if (!process.stdout.write(line)) await once(process.stdout, "drain");
|
|
17
|
+
};
|
|
18
|
+
const send = (frame) => {
|
|
19
|
+
const next = output.then(() => write(frame));
|
|
20
|
+
output = next.catch(() => void 0);
|
|
21
|
+
return next;
|
|
22
|
+
};
|
|
23
|
+
const uploadDeadline = setTimeout(() => {
|
|
24
|
+
void fail(new RemoteWorkerError("Worker channel did not complete its upload within 30 seconds"));
|
|
25
|
+
}, 3e4);
|
|
26
|
+
const close = () => {
|
|
27
|
+
finished = true;
|
|
28
|
+
clearTimeout(uploadDeadline);
|
|
29
|
+
process.stdin.destroy();
|
|
30
|
+
};
|
|
31
|
+
const fail = async (error) => {
|
|
32
|
+
controller.abort();
|
|
33
|
+
await send({ v: 1, type: "error", message: error.message.slice(0, 2e3) }).catch(() => void 0);
|
|
34
|
+
close();
|
|
35
|
+
};
|
|
36
|
+
const owned = (frame) => {
|
|
37
|
+
if (!request || frame.id !== request.id || frame.capability !== request.capability) throw new RemoteWorkerError("Frame does not own this scoped request");
|
|
38
|
+
};
|
|
39
|
+
const handle = async (frame) => {
|
|
40
|
+
if (!frame || frame.v !== 1) throw new RemoteWorkerError("Invalid protocol version or excessive frames");
|
|
41
|
+
if (frame.type === "probe") {
|
|
42
|
+
const repo = frame.repoMapping ? worker.repo(frame.repoMapping) : void 0;
|
|
43
|
+
const exists = async (commit) => !!repo && !!commit && /^[a-f0-9]{40,64}$/i.test(commit) && !(await git(["cat-file", "-e", `${commit}^{commit}`], repo, { timeoutMs: 1e4 })).code;
|
|
44
|
+
await send({ v: 1, type: "probe", capabilities: await worker.capabilities(frame.containerProfile, frame.discoverModels === true), hasBase: await exists(frame.baseCommit), hasRepositoryBase: await exists(frame.repositoryBase) });
|
|
45
|
+
close();
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (frame.type === "receipt" || frame.type === "cancel") {
|
|
49
|
+
const receipt = frame.type === "receipt" ? await worker.receipt(frame.id, frame.capability) : await worker.cancel(frame.id, frame.capability);
|
|
50
|
+
await send({ v: 1, type: "receipt", receipt });
|
|
51
|
+
if (frame.type === "receipt" && frame.downloadArtifact !== false && receipt.artifact) {
|
|
52
|
+
const artifact = receipt.artifact, file = join(worker.config.root, "requests", frame.id, "result.bundle");
|
|
53
|
+
await send({ v: 1, type: "artifact", artifact });
|
|
54
|
+
for (const chunk of chunks(await readFile(file))) await send({ v: 1, type: "chunk", ...chunk });
|
|
55
|
+
}
|
|
56
|
+
if (!executing) close();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (frame.type === "begin") {
|
|
60
|
+
if (request || executing) throw new RemoteWorkerError("Only one execution is allowed per channel");
|
|
61
|
+
request = validateWorkerRequest(frame.request);
|
|
62
|
+
if (request.source) source = Buffer.alloc(request.source.size);
|
|
63
|
+
for (const file of request.context) {
|
|
64
|
+
context.set(file.path, Buffer.alloc(file.size));
|
|
65
|
+
offsets.set(file.path, 0);
|
|
66
|
+
}
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
owned(frame);
|
|
70
|
+
if (frame.type === "source" || frame.type === "context") {
|
|
71
|
+
if (executing || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(frame.data)) throw new RemoteWorkerError("Invalid binary transfer frame");
|
|
72
|
+
const data = Buffer.from(frame.data, "base64"), target = frame.type === "source" ? source : context.get(frame.path ?? ""), offset = frame.type === "source" ? sourceOffset : offsets.get(frame.path ?? "");
|
|
73
|
+
if (!target || frame.offset !== offset || !data.length || frame.offset + data.length > target.length) throw new RemoteWorkerError("Transfer offsets or file identity do not match the accepted manifest");
|
|
74
|
+
target.set(data, frame.offset);
|
|
75
|
+
if (frame.type === "source") sourceOffset += data.length;
|
|
76
|
+
else offsets.set(frame.path, frame.offset + data.length);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (frame.type !== "execute" || executing || !request || source && sourceOffset !== source.length || request.context.some((file) => offsets.get(file.path) !== file.size)) throw new RemoteWorkerError("The accepted transfer is incomplete or already executing");
|
|
80
|
+
executing = true;
|
|
81
|
+
clearTimeout(uploadDeadline);
|
|
82
|
+
void worker.execute(request, source, context, (event) => {
|
|
83
|
+
void send({ v: 1, type: "event", event }).catch(() => controller.abort());
|
|
84
|
+
}, (receipt) => {
|
|
85
|
+
void send({ v: 1, type: receipt.state === "accepted" ? "accepted" : "heartbeat", receipt }).catch(() => controller.abort());
|
|
86
|
+
}, controller.signal).then(async ({ result, receipt, artifact }) => {
|
|
87
|
+
if (artifact) {
|
|
88
|
+
const { file, ...manifest } = artifact;
|
|
89
|
+
await send({ v: 1, type: "artifact", artifact: manifest });
|
|
90
|
+
for (const chunk of chunks(await readFile(file))) await send({ v: 1, type: "chunk", ...chunk });
|
|
91
|
+
}
|
|
92
|
+
await send({ v: 1, type: "result", receipt, result });
|
|
93
|
+
close();
|
|
94
|
+
}).catch(fail);
|
|
95
|
+
};
|
|
96
|
+
let queue = Promise.resolve();
|
|
97
|
+
await send({ v: 1, type: "hello", protocol: 1 });
|
|
98
|
+
process.stdin.setEncoding("utf8");
|
|
99
|
+
process.stdin.on("data", (data) => {
|
|
100
|
+
if (finished) return;
|
|
101
|
+
buffer += data;
|
|
102
|
+
if (Buffer.byteLength(buffer) > MAX_FRAME_BYTES && !buffer.includes("\n")) {
|
|
103
|
+
void fail(new RemoteWorkerError("Worker input frame exceeds its bound"));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
let at;
|
|
107
|
+
while ((at = buffer.indexOf("\n")) >= 0) {
|
|
108
|
+
const line = buffer.slice(0, at);
|
|
109
|
+
buffer = buffer.slice(at + 1);
|
|
110
|
+
if (++frames > 1e3 || Buffer.byteLength(line) > MAX_FRAME_BYTES) {
|
|
111
|
+
void fail(new RemoteWorkerError("Worker frame limit exceeded"));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
queue = queue.then(async () => {
|
|
115
|
+
if (finished) return;
|
|
116
|
+
if (Buffer.byteLength(line) > MAX_FRAME_BYTES) throw new RemoteWorkerError("Worker input frame exceeds its bound");
|
|
117
|
+
await handle(JSON.parse(line));
|
|
118
|
+
}).catch(fail);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
process.stdin.on("end", () => {
|
|
122
|
+
if (!finished) controller.abort();
|
|
123
|
+
});
|
|
124
|
+
process.stdout.on("error", () => controller.abort());
|
|
125
|
+
process.on("SIGTERM", () => controller.abort());
|
|
126
|
+
process.on("SIGHUP", () => controller.abort());
|
|
127
|
+
return new Promise((resolve) => {
|
|
128
|
+
const timer = setInterval(() => {
|
|
129
|
+
if (finished) {
|
|
130
|
+
clearInterval(timer);
|
|
131
|
+
resolve();
|
|
132
|
+
}
|
|
133
|
+
}, 20);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
export {
|
|
137
|
+
serveWorker
|
|
138
|
+
};
|