@harness-control/runner 0.1.0
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 +202 -0
- package/README.md +21 -0
- package/dist/audit/index.d.ts +18 -0
- package/dist/audit/index.js +28 -0
- package/dist/config/index.d.ts +179 -0
- package/dist/config/index.js +124 -0
- package/dist/connection/index.d.ts +2 -0
- package/dist/connection/index.js +2 -0
- package/dist/connection/runner-connection.d.ts +22 -0
- package/dist/connection/runner-connection.js +631 -0
- package/dist/harnesses/adapters/providers/claude-runtime.d.ts +5 -0
- package/dist/harnesses/adapters/providers/claude-runtime.js +186 -0
- package/dist/harnesses/adapters/providers/claude.d.ts +24 -0
- package/dist/harnesses/adapters/providers/claude.js +189 -0
- package/dist/harnesses/adapters/providers/cli-process.d.ts +44 -0
- package/dist/harnesses/adapters/providers/cli-process.js +195 -0
- package/dist/harnesses/adapters/providers/codex-models.d.ts +4 -0
- package/dist/harnesses/adapters/providers/codex-models.js +62 -0
- package/dist/harnesses/adapters/providers/codex-rpc.d.ts +21 -0
- package/dist/harnesses/adapters/providers/codex-rpc.js +114 -0
- package/dist/harnesses/adapters/providers/codex-runtime.d.ts +3 -0
- package/dist/harnesses/adapters/providers/codex-runtime.js +267 -0
- package/dist/harnesses/adapters/providers/codex.d.ts +22 -0
- package/dist/harnesses/adapters/providers/codex.js +161 -0
- package/dist/harnesses/adapters/providers/mock.d.ts +13 -0
- package/dist/harnesses/adapters/providers/mock.js +64 -0
- package/dist/harnesses/adapters/providers/native-process.d.ts +9 -0
- package/dist/harnesses/adapters/providers/native-process.js +41 -0
- package/dist/harnesses/adapters/providers/native-turn.d.ts +17 -0
- package/dist/harnesses/adapters/providers/native-turn.js +139 -0
- package/dist/harnesses/adapters/providers/opencode.d.ts +44 -0
- package/dist/harnesses/adapters/providers/opencode.js +416 -0
- package/dist/harnesses/adapters/providers/shared.d.ts +9 -0
- package/dist/harnesses/adapters/providers/shared.js +97 -0
- package/dist/harnesses/adapters/registry.d.ts +12 -0
- package/dist/harnesses/adapters/registry.js +47 -0
- package/dist/harnesses/adapters/types.d.ts +54 -0
- package/dist/harnesses/adapters/types.js +9 -0
- package/dist/harnesses/adapters.d.ts +8 -0
- package/dist/harnesses/adapters.js +8 -0
- package/dist/harnesses/index.d.ts +93 -0
- package/dist/harnesses/index.js +620 -0
- package/dist/host/provider-registry.d.ts +34 -0
- package/dist/host/provider-registry.js +162 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +201 -0
- package/dist/local-actions/dispatcher.d.ts +28 -0
- package/dist/local-actions/dispatcher.js +407 -0
- package/dist/local-actions/executors.d.ts +159 -0
- package/dist/local-actions/executors.js +1103 -0
- package/dist/local-actions/index.d.ts +74 -0
- package/dist/local-actions/index.js +275 -0
- package/dist/logs/index.d.ts +6 -0
- package/dist/logs/index.js +9 -0
- package/dist/mcp/McpAttachmentClient.d.ts +111 -0
- package/dist/mcp/McpAttachmentClient.js +345 -0
- package/dist/mcp/McpProxyServer.d.ts +18 -0
- package/dist/mcp/McpProxyServer.js +188 -0
- package/dist/mcp/McpStdioProfileClient.d.ts +19 -0
- package/dist/mcp/McpStdioProfileClient.js +91 -0
- package/dist/mcp/index.d.ts +5 -0
- package/dist/mcp/index.js +5 -0
- package/dist/mcp/redaction.d.ts +3 -0
- package/dist/mcp/redaction.js +40 -0
- package/dist/pairing/index.d.ts +38 -0
- package/dist/pairing/index.js +180 -0
- package/dist/state/index.d.ts +76 -0
- package/dist/state/index.js +242 -0
- package/dist/workspaces/index.d.ts +13 -0
- package/dist/workspaces/index.js +110 -0
- package/package.json +76 -0
|
@@ -0,0 +1,1103 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { realpathSync } from "node:fs";
|
|
4
|
+
import { lstat, mkdir, readdir, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
6
|
+
import { redactValue } from "../mcp/redaction.js";
|
|
7
|
+
import { LocalCapabilityPolicyError, } from "./index.js";
|
|
8
|
+
const MAX_CAPTURED_OUTPUT_BYTES = 64 * 1024;
|
|
9
|
+
const PROCESS_TIMEOUT_KILL_GRACE_MS = 500;
|
|
10
|
+
const PROCESS_FORCE_KILL_SETTLE_MS = 500;
|
|
11
|
+
const DEV_SERVER_START_SETTLE_MS = 250;
|
|
12
|
+
const DEV_SERVER_READINESS_POLL_MS = 100;
|
|
13
|
+
export class LocalCapabilityExecutor {
|
|
14
|
+
#engine;
|
|
15
|
+
#auditLogger;
|
|
16
|
+
#devServers = new Map();
|
|
17
|
+
constructor(engine, auditLogger) {
|
|
18
|
+
this.#engine = engine;
|
|
19
|
+
this.#auditLogger = auditLogger;
|
|
20
|
+
}
|
|
21
|
+
async readFile(context, path, options = {}) {
|
|
22
|
+
return this.#runAction(context, "filesystem", "read_file", "read", async () => {
|
|
23
|
+
this.#engine.authorizeFilesystemAction(context.lease, {
|
|
24
|
+
session_id: context.session_id,
|
|
25
|
+
turn_id: context.turn_id,
|
|
26
|
+
workspace_id: context.workspace_id,
|
|
27
|
+
provider_instance_id: context.provider_instance_id,
|
|
28
|
+
action: "read",
|
|
29
|
+
});
|
|
30
|
+
const resolvedPath = await resolveExistingWorkspacePath(path, context.workspace_root);
|
|
31
|
+
const contentBytes = await readFile(resolvedPath);
|
|
32
|
+
const selectedBytes = applyByteRange(contentBytes, options.range);
|
|
33
|
+
const limitedContent = limitBuffer(selectedBytes, options.contentByteLimit);
|
|
34
|
+
const encoding = options.encoding ?? "utf8";
|
|
35
|
+
return {
|
|
36
|
+
path: workspaceRelativePath(resolvedPath, context.workspace_root),
|
|
37
|
+
content: encodeBuffer(limitedContent.buffer, encoding),
|
|
38
|
+
encoding,
|
|
39
|
+
hash: sha256Hash(contentBytes),
|
|
40
|
+
...(limitedContent.truncated ? { truncated: true } : {}),
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
async listDirectory(context, path, options = {}) {
|
|
45
|
+
return this.#runAction(context, "filesystem", "list_directory", "list", async () => {
|
|
46
|
+
this.#engine.authorizeFilesystemAction(context.lease, {
|
|
47
|
+
session_id: context.session_id,
|
|
48
|
+
turn_id: context.turn_id,
|
|
49
|
+
workspace_id: context.workspace_id,
|
|
50
|
+
provider_instance_id: context.provider_instance_id,
|
|
51
|
+
action: "list",
|
|
52
|
+
});
|
|
53
|
+
const resolvedPath = await resolveExistingWorkspacePath(path, context.workspace_root);
|
|
54
|
+
const listResult = await listWorkspaceEntries(resolvedPath, options);
|
|
55
|
+
return {
|
|
56
|
+
path: workspaceRelativePath(resolvedPath, context.workspace_root),
|
|
57
|
+
entries: listResult.entries,
|
|
58
|
+
...(listResult.truncated ? { truncated: true } : {}),
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
async writeFile(context, path, content, options = {}) {
|
|
63
|
+
const filesystemAction = options.mode === "create" ? "create" : "write";
|
|
64
|
+
return this.#runAction(context, "filesystem", "write_file", filesystemAction, async () => {
|
|
65
|
+
assertWritableSandbox(context, filesystemAction);
|
|
66
|
+
this.#engine.authorizeFilesystemAction(context.lease, {
|
|
67
|
+
session_id: context.session_id,
|
|
68
|
+
turn_id: context.turn_id,
|
|
69
|
+
workspace_id: context.workspace_id,
|
|
70
|
+
provider_instance_id: context.provider_instance_id,
|
|
71
|
+
action: filesystemAction,
|
|
72
|
+
});
|
|
73
|
+
const resolvedPath = await resolveWritableWorkspacePath(path, context.workspace_root);
|
|
74
|
+
await assertWriteModeAllowed(resolvedPath, options);
|
|
75
|
+
const contentBytes = decodeContent(content, options.encoding ?? "utf8");
|
|
76
|
+
if (options.createParents ?? true) {
|
|
77
|
+
await mkdir(dirname(resolvedPath), { recursive: true });
|
|
78
|
+
}
|
|
79
|
+
await writeFile(resolvedPath, contentBytes);
|
|
80
|
+
const writtenBytes = await readFile(resolvedPath);
|
|
81
|
+
return {
|
|
82
|
+
path: workspaceRelativePath(resolvedPath, context.workspace_root),
|
|
83
|
+
bytes_written: contentBytes.byteLength,
|
|
84
|
+
new_hash: sha256Hash(writtenBytes),
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
async patchFile(context, path, options) {
|
|
89
|
+
return this.#runAction(context, "filesystem", "patch_file", "patch", async () => {
|
|
90
|
+
assertWritableSandbox(context, "patch");
|
|
91
|
+
this.#engine.authorizeFilesystemAction(context.lease, {
|
|
92
|
+
session_id: context.session_id,
|
|
93
|
+
turn_id: context.turn_id,
|
|
94
|
+
workspace_id: context.workspace_id,
|
|
95
|
+
provider_instance_id: context.provider_instance_id,
|
|
96
|
+
action: "patch",
|
|
97
|
+
});
|
|
98
|
+
const resolvedPath = await resolveWritableWorkspacePath(path, context.workspace_root);
|
|
99
|
+
const existingBytes = await readExistingFileForPatch(resolvedPath, options.createIfMissing ?? false);
|
|
100
|
+
assertExpectedHash(existingBytes, options.expectedBaseHash);
|
|
101
|
+
const originalContent = existingBytes.toString("utf8");
|
|
102
|
+
const patchResult = applyUnifiedDiff(originalContent, options.patchContent);
|
|
103
|
+
const newBytes = Buffer.from(patchResult.content, "utf8");
|
|
104
|
+
if (patchResult.changed) {
|
|
105
|
+
await mkdir(dirname(resolvedPath), { recursive: true });
|
|
106
|
+
await writeFile(resolvedPath, newBytes);
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
path: workspaceRelativePath(resolvedPath, context.workspace_root),
|
|
110
|
+
changed: patchResult.changed,
|
|
111
|
+
new_hash: sha256Hash(newBytes),
|
|
112
|
+
};
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
async deletePath(context, path) {
|
|
116
|
+
return this.#runAction(context, "filesystem", "delete_path", "delete", async () => {
|
|
117
|
+
assertWritableSandbox(context, "delete");
|
|
118
|
+
this.#engine.authorizeFilesystemAction(context.lease, {
|
|
119
|
+
session_id: context.session_id,
|
|
120
|
+
turn_id: context.turn_id,
|
|
121
|
+
workspace_id: context.workspace_id,
|
|
122
|
+
provider_instance_id: context.provider_instance_id,
|
|
123
|
+
action: "delete",
|
|
124
|
+
});
|
|
125
|
+
const resolvedPath = await resolveExistingWorkspacePath(path, context.workspace_root);
|
|
126
|
+
await rm(resolvedPath, { recursive: true, force: false });
|
|
127
|
+
return {
|
|
128
|
+
path: workspaceRelativePath(resolvedPath, context.workspace_root),
|
|
129
|
+
};
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
async git(context, operation, options = {}) {
|
|
133
|
+
return this.#runAction(context, "git", `git.${operation}`, operation, async () => {
|
|
134
|
+
if (operation === "commit" || operation === "checkout" || operation === "push") {
|
|
135
|
+
assertWritableSandbox(context, operation);
|
|
136
|
+
}
|
|
137
|
+
this.#engine.authorizeGitAction(context.lease, {
|
|
138
|
+
session_id: context.session_id,
|
|
139
|
+
turn_id: context.turn_id,
|
|
140
|
+
workspace_id: context.workspace_id,
|
|
141
|
+
provider_instance_id: context.provider_instance_id,
|
|
142
|
+
action: operation,
|
|
143
|
+
});
|
|
144
|
+
const cwd = await resolveExistingWorkspacePath(".", context.workspace_root);
|
|
145
|
+
const argv = await gitArgvForOperation(operation, context.workspace_root, options);
|
|
146
|
+
const outputByteLimit = operation === "status" ? options.status?.outputByteLimit : operation === "diff" ? options.diff?.outputByteLimit : undefined;
|
|
147
|
+
const output = await runProcess("git", argv, {
|
|
148
|
+
cwd,
|
|
149
|
+
timeoutSeconds: 30,
|
|
150
|
+
useShell: false,
|
|
151
|
+
env: minimalEnv(),
|
|
152
|
+
...(outputByteLimit ? { stdoutByteLimit: outputByteLimit } : {}),
|
|
153
|
+
});
|
|
154
|
+
return {
|
|
155
|
+
operation,
|
|
156
|
+
exit_code: output.exitCode ?? 1,
|
|
157
|
+
stdout: output.stdout,
|
|
158
|
+
stderr: output.stderr,
|
|
159
|
+
...(output.stdoutTruncated ? { stdout_truncated: true } : {}),
|
|
160
|
+
...(output.stderrTruncated ? { stderr_truncated: true } : {}),
|
|
161
|
+
...gitStatusBranchResult(operation, options, output.stdout),
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
async shell(context, request) {
|
|
166
|
+
return this.#runAction(context, "shell", "run_command", "run_command", async () => {
|
|
167
|
+
const cwd = await resolveExistingWorkspacePath(request.cwd ?? ".", context.workspace_root);
|
|
168
|
+
const useShell = request.use_shell ?? false;
|
|
169
|
+
await this.#engine.authorizeShellCommand(context.lease, {
|
|
170
|
+
session_id: context.session_id,
|
|
171
|
+
turn_id: context.turn_id,
|
|
172
|
+
workspace_id: context.workspace_id,
|
|
173
|
+
provider_instance_id: context.provider_instance_id,
|
|
174
|
+
executable: request.executable,
|
|
175
|
+
argv: request.argv,
|
|
176
|
+
cwd,
|
|
177
|
+
workspace_root: context.workspace_root,
|
|
178
|
+
use_shell: useShell,
|
|
179
|
+
timeout_seconds: request.timeout_seconds,
|
|
180
|
+
env: request.env ?? {},
|
|
181
|
+
});
|
|
182
|
+
const output = await runProcess(request.executable, request.argv, {
|
|
183
|
+
cwd,
|
|
184
|
+
timeoutSeconds: request.timeout_seconds,
|
|
185
|
+
useShell,
|
|
186
|
+
env: processEnvFor(request.env),
|
|
187
|
+
...(request.stdin !== undefined ? { stdin: request.stdin } : {}),
|
|
188
|
+
...(request.stdout_byte_limit ? { stdoutByteLimit: request.stdout_byte_limit } : {}),
|
|
189
|
+
...(request.stderr_byte_limit ? { stderrByteLimit: request.stderr_byte_limit } : {}),
|
|
190
|
+
...(request.signal ? { signal: request.signal } : {}),
|
|
191
|
+
});
|
|
192
|
+
return {
|
|
193
|
+
executable: request.executable,
|
|
194
|
+
argv: request.argv,
|
|
195
|
+
cwd,
|
|
196
|
+
exit_code: output.exitCode,
|
|
197
|
+
signal: output.signal,
|
|
198
|
+
stdout: output.stdout,
|
|
199
|
+
stderr: output.stderr,
|
|
200
|
+
timed_out: output.timedOut,
|
|
201
|
+
...(output.stdoutTruncated ? { stdout_truncated: true } : {}),
|
|
202
|
+
...(output.stderrTruncated ? { stderr_truncated: true } : {}),
|
|
203
|
+
};
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
async startDevServer(context, request) {
|
|
207
|
+
return this.#runAction(context, "dev_server", "dev_server.start", "start", async () => {
|
|
208
|
+
if (this.#devServers.has(request.server_id)) {
|
|
209
|
+
throw new LocalCapabilityPolicyError("local_capability_dev_server_exists", `Dev server '${request.server_id}' is already running.`);
|
|
210
|
+
}
|
|
211
|
+
assertAllowedDevServerEndpoint(request.host, request.port);
|
|
212
|
+
const cwd = await resolveExistingWorkspacePath(request.cwd ?? ".", context.workspace_root);
|
|
213
|
+
await this.#engine.authorizeDevServerAction(context.lease, {
|
|
214
|
+
session_id: context.session_id,
|
|
215
|
+
turn_id: context.turn_id,
|
|
216
|
+
workspace_id: context.workspace_id,
|
|
217
|
+
provider_instance_id: context.provider_instance_id,
|
|
218
|
+
action: "start",
|
|
219
|
+
executable: request.executable,
|
|
220
|
+
argv: request.argv,
|
|
221
|
+
cwd,
|
|
222
|
+
workspace_root: context.workspace_root,
|
|
223
|
+
use_shell: request.use_shell ?? false,
|
|
224
|
+
timeout_seconds: request.timeout_seconds,
|
|
225
|
+
env: request.env ?? {},
|
|
226
|
+
});
|
|
227
|
+
if (request.session_active && !request.session_active()) {
|
|
228
|
+
throw new LocalCapabilityPolicyError("local_capability_lease_revoked", "Local action session stopped before dev server start.");
|
|
229
|
+
}
|
|
230
|
+
const child = spawn(request.executable, request.argv, {
|
|
231
|
+
cwd,
|
|
232
|
+
shell: request.use_shell ?? false,
|
|
233
|
+
env: processEnvFor(request.env),
|
|
234
|
+
detached: true,
|
|
235
|
+
stdio: "pipe",
|
|
236
|
+
});
|
|
237
|
+
child.once("error", () => {
|
|
238
|
+
this.#devServers.delete(request.server_id);
|
|
239
|
+
});
|
|
240
|
+
child.stdout.on("data", () => undefined);
|
|
241
|
+
child.stderr.on("data", () => undefined);
|
|
242
|
+
const pid = child.pid;
|
|
243
|
+
if (pid === undefined) {
|
|
244
|
+
throw new LocalCapabilityPolicyError("local_capability_dev_server_start_failed", "Dev server process did not expose a pid.");
|
|
245
|
+
}
|
|
246
|
+
const record = {
|
|
247
|
+
server_id: request.server_id,
|
|
248
|
+
pid,
|
|
249
|
+
host: request.host,
|
|
250
|
+
port: request.port,
|
|
251
|
+
cwd,
|
|
252
|
+
started_at: new Date().toISOString(),
|
|
253
|
+
};
|
|
254
|
+
this.#devServers.set(request.server_id, {
|
|
255
|
+
...record,
|
|
256
|
+
session_id: context.session_id,
|
|
257
|
+
process: child,
|
|
258
|
+
});
|
|
259
|
+
child.once("exit", () => {
|
|
260
|
+
this.#devServers.delete(request.server_id);
|
|
261
|
+
});
|
|
262
|
+
try {
|
|
263
|
+
await waitForDevServerStartup(child, request);
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
await terminateProcessTree(child, "SIGTERM", PROCESS_TIMEOUT_KILL_GRACE_MS);
|
|
267
|
+
this.#devServers.delete(request.server_id);
|
|
268
|
+
throw error;
|
|
269
|
+
}
|
|
270
|
+
return record;
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
async stopDevServer(context, serverId, signal = "SIGTERM", timeoutMs = 5_000) {
|
|
274
|
+
return this.#runAction(context, "dev_server", "dev_server.stop", "stop", async () => {
|
|
275
|
+
await this.#engine.authorizeDevServerAction(context.lease, {
|
|
276
|
+
session_id: context.session_id,
|
|
277
|
+
turn_id: context.turn_id,
|
|
278
|
+
workspace_id: context.workspace_id,
|
|
279
|
+
provider_instance_id: context.provider_instance_id,
|
|
280
|
+
action: "stop",
|
|
281
|
+
});
|
|
282
|
+
const server = this.#devServers.get(serverId);
|
|
283
|
+
if (!server) {
|
|
284
|
+
throw new LocalCapabilityPolicyError("local_capability_dev_server_not_found", `Dev server '${serverId}' is not running.`);
|
|
285
|
+
}
|
|
286
|
+
await terminateProcessTree(server.process, signal, timeoutMs);
|
|
287
|
+
this.#devServers.delete(serverId);
|
|
288
|
+
return { server_id: serverId };
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
listDevServers() {
|
|
292
|
+
return Array.from(this.#devServers.values()).map(({ process: _process, session_id: _sessionId, ...record }) => record);
|
|
293
|
+
}
|
|
294
|
+
async stopDevServersForSession(sessionId, timeoutMs = 5_000) {
|
|
295
|
+
for (const server of Array.from(this.#devServers.values())) {
|
|
296
|
+
if (server.session_id !== sessionId) {
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
await terminateProcessTree(server.process, "SIGTERM", timeoutMs);
|
|
300
|
+
this.#devServers.delete(server.server_id);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
async #runAction(context, capabilityId, action, policyAction, run) {
|
|
304
|
+
const events = [
|
|
305
|
+
localCapabilityEvent(context, "local_capability.action.started", capabilityId, action, "started"),
|
|
306
|
+
];
|
|
307
|
+
await this.#recordAudit(context, `${capabilityId}.${action}.started`, { capability_id: capabilityId, action });
|
|
308
|
+
try {
|
|
309
|
+
const result = await run();
|
|
310
|
+
events.push(localCapabilityEvent(context, "local_capability.action.completed", capabilityId, action, "completed", {
|
|
311
|
+
output: summarizeResult(result),
|
|
312
|
+
}));
|
|
313
|
+
await this.#recordAudit(context, `${capabilityId}.${action}.completed`, {
|
|
314
|
+
capability_id: capabilityId,
|
|
315
|
+
action,
|
|
316
|
+
result: summarizeResult(result),
|
|
317
|
+
});
|
|
318
|
+
return { result, events };
|
|
319
|
+
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
const errorSummary = errorToSummary(error);
|
|
322
|
+
events.push(localCapabilityEvent(context, "local_capability.action.failed", capabilityId, action, "failed", {
|
|
323
|
+
error: errorSummary,
|
|
324
|
+
input: { policy_action: policyAction },
|
|
325
|
+
}));
|
|
326
|
+
await this.#recordAudit(context, `${capabilityId}.${action}.failed`, {
|
|
327
|
+
capability_id: capabilityId,
|
|
328
|
+
action,
|
|
329
|
+
error: errorSummary,
|
|
330
|
+
});
|
|
331
|
+
throw new LocalCapabilityExecutionError(events, error);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
async #recordAudit(context, event, data) {
|
|
335
|
+
if (!this.#auditLogger) {
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
await this.#auditLogger.record({
|
|
339
|
+
event,
|
|
340
|
+
session_id: context.session_id,
|
|
341
|
+
turn_id: context.turn_id,
|
|
342
|
+
provider_instance_id: context.provider_instance_id,
|
|
343
|
+
workspace_id: context.workspace_id,
|
|
344
|
+
data,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
export class LocalCapabilityExecutionError extends Error {
|
|
349
|
+
events;
|
|
350
|
+
cause;
|
|
351
|
+
constructor(events, cause) {
|
|
352
|
+
super(cause instanceof Error ? cause.message : "Local capability action failed.");
|
|
353
|
+
this.events = events;
|
|
354
|
+
this.cause = cause;
|
|
355
|
+
this.name = "LocalCapabilityExecutionError";
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
function localCapabilityEvent(context, eventType, capabilityId, action, status, data = {}) {
|
|
359
|
+
return {
|
|
360
|
+
event_type: eventType,
|
|
361
|
+
data: {
|
|
362
|
+
lease_id: context.lease.lease_id,
|
|
363
|
+
run_id: context.lease.run_id,
|
|
364
|
+
workspace_id: context.workspace_id,
|
|
365
|
+
provider_instance_id: context.provider_instance_id,
|
|
366
|
+
capability_id: capabilityId,
|
|
367
|
+
action,
|
|
368
|
+
status,
|
|
369
|
+
...data,
|
|
370
|
+
},
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
async function resolveExistingWorkspacePath(path, workspaceRoot) {
|
|
374
|
+
const candidate = await resolveCandidatePath(path, workspaceRoot);
|
|
375
|
+
let resolvedPath;
|
|
376
|
+
try {
|
|
377
|
+
resolvedPath = await realpath(candidate);
|
|
378
|
+
}
|
|
379
|
+
catch (error) {
|
|
380
|
+
if (error instanceof Error) {
|
|
381
|
+
throw new LocalCapabilityPolicyError("local_capability_path_unresolved", `Path '${path}' could not be resolved inside the selected workspace: ${error.message}`);
|
|
382
|
+
}
|
|
383
|
+
throw error;
|
|
384
|
+
}
|
|
385
|
+
await assertInsideWorkspace(resolvedPath, workspaceRoot);
|
|
386
|
+
return resolvedPath;
|
|
387
|
+
}
|
|
388
|
+
async function resolveWritableWorkspacePath(path, workspaceRoot) {
|
|
389
|
+
const candidate = await resolveCandidatePath(path, workspaceRoot);
|
|
390
|
+
await assertCandidateInsideWorkspace(candidate, workspaceRoot);
|
|
391
|
+
try {
|
|
392
|
+
const resolvedPath = await realpath(candidate);
|
|
393
|
+
await assertInsideWorkspace(resolvedPath, workspaceRoot);
|
|
394
|
+
return resolvedPath;
|
|
395
|
+
}
|
|
396
|
+
catch (error) {
|
|
397
|
+
if (!isFileMissingError(error)) {
|
|
398
|
+
throw error;
|
|
399
|
+
}
|
|
400
|
+
const nearestExistingParent = await findNearestExistingParent(candidate, workspaceRoot);
|
|
401
|
+
await assertInsideWorkspace(nearestExistingParent, workspaceRoot);
|
|
402
|
+
await assertMissingWritableTargetHasNoSymlink(candidate);
|
|
403
|
+
return candidate;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
async function resolveCandidatePath(path, workspaceRoot) {
|
|
407
|
+
return isAbsolute(path) ? path : resolve(await realpath(workspaceRoot), path);
|
|
408
|
+
}
|
|
409
|
+
async function assertInsideWorkspace(path, workspaceRoot) {
|
|
410
|
+
const resolvedWorkspace = await realpath(workspaceRoot);
|
|
411
|
+
const relativePath = relative(resolvedWorkspace, path);
|
|
412
|
+
if (relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath))) {
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
throw new LocalCapabilityPolicyError("local_capability_path_denied", `Path '${path}' is outside the selected workspace.`);
|
|
416
|
+
}
|
|
417
|
+
async function assertCandidateInsideWorkspace(path, workspaceRoot) {
|
|
418
|
+
const resolvedWorkspace = await realpath(workspaceRoot);
|
|
419
|
+
const relativePath = relative(resolvedWorkspace, path);
|
|
420
|
+
if (relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath))) {
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
throw new LocalCapabilityPolicyError("local_capability_path_denied", `Path '${path}' is outside the selected workspace.`);
|
|
424
|
+
}
|
|
425
|
+
async function findNearestExistingParent(path, workspaceRoot) {
|
|
426
|
+
let current = dirname(path);
|
|
427
|
+
const resolvedWorkspace = await realpath(workspaceRoot);
|
|
428
|
+
while (true) {
|
|
429
|
+
try {
|
|
430
|
+
return await realpath(current);
|
|
431
|
+
}
|
|
432
|
+
catch (error) {
|
|
433
|
+
if (!isFileMissingError(error)) {
|
|
434
|
+
throw error;
|
|
435
|
+
}
|
|
436
|
+
const parent = dirname(current);
|
|
437
|
+
if (parent === current) {
|
|
438
|
+
throw error;
|
|
439
|
+
}
|
|
440
|
+
const relativeParent = relative(resolvedWorkspace, parent);
|
|
441
|
+
if (relativeParent.startsWith("..") || isAbsolute(relativeParent)) {
|
|
442
|
+
throw error;
|
|
443
|
+
}
|
|
444
|
+
current = parent;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
function workspaceRelativePath(path, workspaceRoot) {
|
|
449
|
+
const relativePath = relative(realpathSync(workspaceRoot), path);
|
|
450
|
+
return relativePath.length === 0 ? "." : relativePath;
|
|
451
|
+
}
|
|
452
|
+
function assertWritableSandbox(context, action) {
|
|
453
|
+
if (context.sandbox_mode === "read_only") {
|
|
454
|
+
throw new LocalCapabilityPolicyError("local_capability_sandbox_read_only", `Action '${action}' is not allowed in read_only sandbox mode.`);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
async function gitArgvForOperation(operation, workspaceRoot, options) {
|
|
458
|
+
switch (operation) {
|
|
459
|
+
case "status": {
|
|
460
|
+
const statusOptions = options.status ?? {};
|
|
461
|
+
return [
|
|
462
|
+
"status",
|
|
463
|
+
`--porcelain=${statusOptions.porcelainVersion ?? "v1"}`,
|
|
464
|
+
...(statusOptions.includeBranch ? ["--branch"] : []),
|
|
465
|
+
];
|
|
466
|
+
}
|
|
467
|
+
case "diff": {
|
|
468
|
+
const diffOptions = options.diff ?? {};
|
|
469
|
+
if (diffOptions.paths) {
|
|
470
|
+
await assertRequestedPathsInsideWorkspace(diffOptions.paths, workspaceRoot);
|
|
471
|
+
}
|
|
472
|
+
if (diffOptions.baseRef) {
|
|
473
|
+
assertSafeGitRevision(diffOptions.baseRef);
|
|
474
|
+
}
|
|
475
|
+
return [
|
|
476
|
+
"diff",
|
|
477
|
+
...(diffOptions.staged ? ["--staged"] : []),
|
|
478
|
+
...(diffOptions.baseRef ? [diffOptions.baseRef] : []),
|
|
479
|
+
"--",
|
|
480
|
+
...(diffOptions.paths ?? []),
|
|
481
|
+
];
|
|
482
|
+
}
|
|
483
|
+
case "branch":
|
|
484
|
+
return ["branch", "--show-current"];
|
|
485
|
+
case "commit_metadata":
|
|
486
|
+
return ["log", "-1", "--format=%H%n%an%n%ae%n%aI%n%s"];
|
|
487
|
+
case "commit":
|
|
488
|
+
case "checkout":
|
|
489
|
+
case "push":
|
|
490
|
+
throw new LocalCapabilityPolicyError("local_capability_git_operation_requires_args", `Git operation '${operation}' requires explicit arguments and is not available through the generic executor.`);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
function assertSafeGitRevision(revision) {
|
|
494
|
+
if (revision.startsWith("-") || revision.includes("\0")) {
|
|
495
|
+
throw new LocalCapabilityPolicyError("local_capability_command_denied", "Git base ref is not a safe revision token.");
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
async function runProcess(executable, argv, options) {
|
|
499
|
+
return await new Promise((resolveProcess, rejectProcess) => {
|
|
500
|
+
const child = spawn(executable, argv, {
|
|
501
|
+
cwd: options.cwd,
|
|
502
|
+
env: options.env,
|
|
503
|
+
shell: options.useShell,
|
|
504
|
+
detached: true,
|
|
505
|
+
stdio: "pipe",
|
|
506
|
+
});
|
|
507
|
+
let stdout = "";
|
|
508
|
+
let stderr = "";
|
|
509
|
+
let timedOut = false;
|
|
510
|
+
let stdoutTruncated = false;
|
|
511
|
+
let stderrTruncated = false;
|
|
512
|
+
let settled = false;
|
|
513
|
+
let timeout;
|
|
514
|
+
let forceKillTimeout;
|
|
515
|
+
let forceKillSettleTimeout;
|
|
516
|
+
let onAbort = () => undefined;
|
|
517
|
+
child.stdin.on("error", () => undefined);
|
|
518
|
+
child.stdin.end(options.stdin ?? "");
|
|
519
|
+
const clearProcessTimers = () => {
|
|
520
|
+
if (timeout) {
|
|
521
|
+
clearTimeout(timeout);
|
|
522
|
+
timeout = undefined;
|
|
523
|
+
}
|
|
524
|
+
if (forceKillTimeout) {
|
|
525
|
+
clearTimeout(forceKillTimeout);
|
|
526
|
+
forceKillTimeout = undefined;
|
|
527
|
+
}
|
|
528
|
+
if (forceKillSettleTimeout) {
|
|
529
|
+
clearTimeout(forceKillSettleTimeout);
|
|
530
|
+
forceKillSettleTimeout = undefined;
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
const resolveOutput = (exitCode, signal) => {
|
|
534
|
+
if (settled) {
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
settled = true;
|
|
538
|
+
clearProcessTimers();
|
|
539
|
+
if (options.signal) {
|
|
540
|
+
options.signal.removeEventListener("abort", onAbort);
|
|
541
|
+
}
|
|
542
|
+
resolveProcess({
|
|
543
|
+
exitCode,
|
|
544
|
+
signal,
|
|
545
|
+
stdout,
|
|
546
|
+
stderr,
|
|
547
|
+
timedOut,
|
|
548
|
+
stdoutTruncated,
|
|
549
|
+
stderrTruncated,
|
|
550
|
+
});
|
|
551
|
+
};
|
|
552
|
+
const terminateChild = (signal) => {
|
|
553
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
killProcessTree(child, signal);
|
|
557
|
+
if (forceKillTimeout || forceKillSettleTimeout) {
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
forceKillTimeout = setTimeout(() => {
|
|
561
|
+
forceKillTimeout = undefined;
|
|
562
|
+
killProcessTree(child, "SIGKILL");
|
|
563
|
+
forceKillSettleTimeout = setTimeout(() => {
|
|
564
|
+
resolveOutput(child.exitCode, child.signalCode ?? "SIGKILL");
|
|
565
|
+
}, PROCESS_FORCE_KILL_SETTLE_MS);
|
|
566
|
+
}, PROCESS_TIMEOUT_KILL_GRACE_MS);
|
|
567
|
+
};
|
|
568
|
+
timeout = setTimeout(() => {
|
|
569
|
+
timedOut = true;
|
|
570
|
+
terminateChild("SIGTERM");
|
|
571
|
+
}, options.timeoutSeconds * 1000);
|
|
572
|
+
onAbort = () => {
|
|
573
|
+
terminateChild("SIGTERM");
|
|
574
|
+
};
|
|
575
|
+
if (options.signal) {
|
|
576
|
+
if (options.signal.aborted) {
|
|
577
|
+
onAbort();
|
|
578
|
+
}
|
|
579
|
+
else {
|
|
580
|
+
options.signal.addEventListener("abort", onAbort, { once: true });
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
child.stdout.on("data", (chunk) => {
|
|
584
|
+
const appended = appendLimited(stdout, chunk, options.stdoutByteLimit ?? MAX_CAPTURED_OUTPUT_BYTES);
|
|
585
|
+
stdout = appended.value;
|
|
586
|
+
stdoutTruncated = stdoutTruncated || appended.truncated;
|
|
587
|
+
});
|
|
588
|
+
child.stderr.on("data", (chunk) => {
|
|
589
|
+
const appended = appendLimited(stderr, chunk, options.stderrByteLimit ?? MAX_CAPTURED_OUTPUT_BYTES);
|
|
590
|
+
stderr = appended.value;
|
|
591
|
+
stderrTruncated = stderrTruncated || appended.truncated;
|
|
592
|
+
});
|
|
593
|
+
child.once("error", (error) => {
|
|
594
|
+
if (settled) {
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
settled = true;
|
|
598
|
+
clearProcessTimers();
|
|
599
|
+
if (options.signal) {
|
|
600
|
+
options.signal.removeEventListener("abort", onAbort);
|
|
601
|
+
}
|
|
602
|
+
rejectProcess(error);
|
|
603
|
+
});
|
|
604
|
+
child.once("close", (exitCode, signal) => {
|
|
605
|
+
resolveOutput(exitCode, signal);
|
|
606
|
+
});
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
function appendLimited(existing, chunk, limitBytes) {
|
|
610
|
+
const combined = Buffer.concat([Buffer.from(existing), chunk]);
|
|
611
|
+
if (combined.byteLength <= limitBytes) {
|
|
612
|
+
return { value: combined.toString("utf8"), truncated: false };
|
|
613
|
+
}
|
|
614
|
+
return { value: combined.subarray(0, limitBytes).toString("utf8"), truncated: true };
|
|
615
|
+
}
|
|
616
|
+
function minimalEnv() {
|
|
617
|
+
return {
|
|
618
|
+
PATH: process.env.PATH ?? "/usr/bin:/bin:/usr/sbin:/sbin",
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
function processEnvFor(env) {
|
|
622
|
+
return {
|
|
623
|
+
...minimalEnv(),
|
|
624
|
+
...(env ?? {}),
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
function summarizeResult(value) {
|
|
628
|
+
return redactValue(value);
|
|
629
|
+
}
|
|
630
|
+
function errorToSummary(error) {
|
|
631
|
+
if (error instanceof LocalCapabilityPolicyError) {
|
|
632
|
+
return {
|
|
633
|
+
code: error.code,
|
|
634
|
+
message: error.message,
|
|
635
|
+
retryable: false,
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
if (error instanceof Error) {
|
|
639
|
+
return {
|
|
640
|
+
code: "local_capability_action_failed",
|
|
641
|
+
message: error.message,
|
|
642
|
+
retryable: false,
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
return {
|
|
646
|
+
code: "local_capability_action_failed",
|
|
647
|
+
message: "Local capability action failed.",
|
|
648
|
+
retryable: false,
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
function isFileMissingError(error) {
|
|
652
|
+
return (typeof error === "object" &&
|
|
653
|
+
error !== null &&
|
|
654
|
+
"code" in error &&
|
|
655
|
+
error.code === "ENOENT");
|
|
656
|
+
}
|
|
657
|
+
function assertAllowedDevServerEndpoint(host, port) {
|
|
658
|
+
if (host !== "127.0.0.1" && host !== "localhost") {
|
|
659
|
+
throw new LocalCapabilityPolicyError("local_capability_dev_server_host_denied", `Dev server host '${host}' is not allowed.`);
|
|
660
|
+
}
|
|
661
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
662
|
+
throw new LocalCapabilityPolicyError("local_capability_dev_server_port_denied", `Dev server port '${port}' is invalid.`);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
async function waitForDevServerStartup(child, request) {
|
|
666
|
+
const readiness = request.readiness;
|
|
667
|
+
if (readiness === undefined || readiness.url === undefined) {
|
|
668
|
+
await waitForDevServerNoFailure(child, request, DEV_SERVER_START_SETTLE_MS);
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
const readinessUrl = readiness.url;
|
|
672
|
+
const allowedUrl = assertAllowedReadinessUrl(readinessUrl, request);
|
|
673
|
+
const readinessTimeoutMs = Math.min(readiness.timeout_ms, devServerStartupTimeoutMs(request));
|
|
674
|
+
await waitForReadinessUrl(child, request, allowedUrl, readinessTimeoutMs);
|
|
675
|
+
}
|
|
676
|
+
function devServerStartupTimeoutMs(request) {
|
|
677
|
+
return Math.max(1, request.timeout_seconds) * 1000;
|
|
678
|
+
}
|
|
679
|
+
function assertAllowedReadinessUrl(url, request) {
|
|
680
|
+
let parsedUrl;
|
|
681
|
+
try {
|
|
682
|
+
parsedUrl = new URL(url);
|
|
683
|
+
}
|
|
684
|
+
catch (error) {
|
|
685
|
+
if (error instanceof TypeError) {
|
|
686
|
+
throw new LocalCapabilityPolicyError("local_capability_dev_server_host_denied", `Readiness URL '${url}' is invalid.`);
|
|
687
|
+
}
|
|
688
|
+
throw error;
|
|
689
|
+
}
|
|
690
|
+
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
|
|
691
|
+
throw new LocalCapabilityPolicyError("local_capability_dev_server_host_denied", `Readiness URL '${url}' must use http or https.`);
|
|
692
|
+
}
|
|
693
|
+
if (parsedUrl.hostname !== "127.0.0.1" && parsedUrl.hostname !== "localhost") {
|
|
694
|
+
throw new LocalCapabilityPolicyError("local_capability_dev_server_host_denied", `Readiness URL host '${parsedUrl.hostname}' is not allowed.`);
|
|
695
|
+
}
|
|
696
|
+
const port = portForUrl(parsedUrl);
|
|
697
|
+
if (port !== request.port) {
|
|
698
|
+
throw new LocalCapabilityPolicyError("local_capability_dev_server_port_denied", `Readiness URL port ${port} does not match dev server port ${request.port}.`);
|
|
699
|
+
}
|
|
700
|
+
return parsedUrl.toString();
|
|
701
|
+
}
|
|
702
|
+
function portForUrl(url) {
|
|
703
|
+
if (url.port.length > 0) {
|
|
704
|
+
return Number.parseInt(url.port, 10);
|
|
705
|
+
}
|
|
706
|
+
return url.protocol === "https:" ? 443 : 80;
|
|
707
|
+
}
|
|
708
|
+
async function waitForReadinessUrl(child, request, url, timeoutMs) {
|
|
709
|
+
const deadline = Date.now() + timeoutMs;
|
|
710
|
+
let lastErrorMessage;
|
|
711
|
+
while (Date.now() < deadline) {
|
|
712
|
+
assertDevServerStillRunning(child, request.server_id);
|
|
713
|
+
assertDevServerSessionActive(request);
|
|
714
|
+
const remainingMs = deadline - Date.now();
|
|
715
|
+
const probe = await probeReadinessUrl(url, Math.max(1, Math.min(remainingMs, DEV_SERVER_READINESS_POLL_MS)));
|
|
716
|
+
if (probe.ready) {
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
if (probe.errorMessage) {
|
|
720
|
+
lastErrorMessage = probe.errorMessage;
|
|
721
|
+
}
|
|
722
|
+
const waitMs = Math.max(0, Math.min(deadline - Date.now(), DEV_SERVER_READINESS_POLL_MS));
|
|
723
|
+
await waitForDevServerNoFailure(child, request, waitMs);
|
|
724
|
+
}
|
|
725
|
+
throw new LocalCapabilityPolicyError("local_capability_timeout", `Dev server '${request.server_id}' did not become ready at '${url}' within ${timeoutMs}ms${lastErrorMessage ? `: ${lastErrorMessage}` : "."}`);
|
|
726
|
+
}
|
|
727
|
+
async function probeReadinessUrl(url, timeoutMs) {
|
|
728
|
+
const controller = new AbortController();
|
|
729
|
+
const timeout = setTimeout(() => {
|
|
730
|
+
controller.abort();
|
|
731
|
+
}, timeoutMs);
|
|
732
|
+
try {
|
|
733
|
+
const response = await fetch(url, { signal: controller.signal });
|
|
734
|
+
if (response.ok) {
|
|
735
|
+
return { ready: true };
|
|
736
|
+
}
|
|
737
|
+
return { ready: false, errorMessage: `HTTP ${response.status}` };
|
|
738
|
+
}
|
|
739
|
+
catch (error) {
|
|
740
|
+
return { ready: false, errorMessage: error instanceof Error ? error.message : "Readiness probe failed." };
|
|
741
|
+
}
|
|
742
|
+
finally {
|
|
743
|
+
clearTimeout(timeout);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
async function waitForDevServerNoFailure(child, request, timeoutMs) {
|
|
747
|
+
assertDevServerStillRunning(child, request.server_id);
|
|
748
|
+
assertDevServerSessionActive(request);
|
|
749
|
+
if (timeoutMs <= 0) {
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
await new Promise((resolve, reject) => {
|
|
753
|
+
let settled = false;
|
|
754
|
+
let timer;
|
|
755
|
+
const settle = (result) => {
|
|
756
|
+
if (settled) {
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
settled = true;
|
|
760
|
+
if (timer) {
|
|
761
|
+
clearTimeout(timer);
|
|
762
|
+
timer = undefined;
|
|
763
|
+
}
|
|
764
|
+
child.off("exit", onExit);
|
|
765
|
+
child.off("error", onError);
|
|
766
|
+
if (result === "ready") {
|
|
767
|
+
try {
|
|
768
|
+
assertDevServerSessionActive(request);
|
|
769
|
+
resolve();
|
|
770
|
+
}
|
|
771
|
+
catch (error) {
|
|
772
|
+
reject(error);
|
|
773
|
+
}
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
reject(result);
|
|
777
|
+
};
|
|
778
|
+
function onExit(exitCode, signal) {
|
|
779
|
+
settle(devServerExitError(request.server_id, exitCode, signal));
|
|
780
|
+
}
|
|
781
|
+
function onError(error) {
|
|
782
|
+
settle(devServerProcessError(request.server_id, error));
|
|
783
|
+
}
|
|
784
|
+
timer = setTimeout(() => {
|
|
785
|
+
settle("ready");
|
|
786
|
+
}, timeoutMs);
|
|
787
|
+
child.once("exit", onExit);
|
|
788
|
+
child.once("error", onError);
|
|
789
|
+
try {
|
|
790
|
+
assertDevServerStillRunning(child, request.server_id);
|
|
791
|
+
}
|
|
792
|
+
catch (error) {
|
|
793
|
+
if (error instanceof Error) {
|
|
794
|
+
settle(error);
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
throw error;
|
|
798
|
+
}
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
function assertDevServerStillRunning(child, serverId) {
|
|
802
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
803
|
+
throw devServerExitError(serverId, child.exitCode, child.signalCode);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
function assertDevServerSessionActive(request) {
|
|
807
|
+
if (request.session_active && !request.session_active()) {
|
|
808
|
+
throw new LocalCapabilityPolicyError("local_capability_lease_revoked", "Local action session stopped before dev server was ready.");
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
function devServerExitError(serverId, exitCode, signal) {
|
|
812
|
+
const exitDescription = signal ? `signal ${signal}` : `exit code ${exitCode ?? "unknown"}`;
|
|
813
|
+
return new LocalCapabilityPolicyError("local_capability_dev_server_start_failed", `Dev server '${serverId}' exited before it was ready with ${exitDescription}.`);
|
|
814
|
+
}
|
|
815
|
+
function devServerProcessError(serverId, error) {
|
|
816
|
+
return new LocalCapabilityPolicyError("local_capability_dev_server_start_failed", `Dev server '${serverId}' failed to start: ${error.message}`);
|
|
817
|
+
}
|
|
818
|
+
function killProcessTree(child, signal) {
|
|
819
|
+
const pid = child.pid;
|
|
820
|
+
if (pid === undefined) {
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
try {
|
|
824
|
+
process.kill(-pid, signal);
|
|
825
|
+
}
|
|
826
|
+
catch (error) {
|
|
827
|
+
if (error instanceof Error && "code" in error && error.code === "ESRCH") {
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
child.kill(signal);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
async function terminateProcessTree(child, signal, timeoutMs) {
|
|
834
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
await new Promise((resolve) => {
|
|
838
|
+
let settled = false;
|
|
839
|
+
let forceKillTimer;
|
|
840
|
+
let forceKillSettleTimer;
|
|
841
|
+
const settle = () => {
|
|
842
|
+
if (settled) {
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
settled = true;
|
|
846
|
+
if (forceKillTimer) {
|
|
847
|
+
clearTimeout(forceKillTimer);
|
|
848
|
+
forceKillTimer = undefined;
|
|
849
|
+
}
|
|
850
|
+
if (forceKillSettleTimer) {
|
|
851
|
+
clearTimeout(forceKillSettleTimer);
|
|
852
|
+
forceKillSettleTimer = undefined;
|
|
853
|
+
}
|
|
854
|
+
child.off("exit", settle);
|
|
855
|
+
child.off("error", settle);
|
|
856
|
+
resolve();
|
|
857
|
+
};
|
|
858
|
+
forceKillTimer = setTimeout(() => {
|
|
859
|
+
forceKillTimer = undefined;
|
|
860
|
+
killProcessTree(child, "SIGKILL");
|
|
861
|
+
forceKillSettleTimer = setTimeout(() => {
|
|
862
|
+
settle();
|
|
863
|
+
}, PROCESS_FORCE_KILL_SETTLE_MS);
|
|
864
|
+
}, Math.max(1, timeoutMs));
|
|
865
|
+
child.once("exit", settle);
|
|
866
|
+
child.once("error", settle);
|
|
867
|
+
killProcessTree(child, signal);
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
async function listWorkspaceEntries(root, options) {
|
|
871
|
+
const entries = [];
|
|
872
|
+
const includeHidden = options.includeHidden ?? false;
|
|
873
|
+
const recursive = options.recursive ?? false;
|
|
874
|
+
const maxDepth = recursive ? options.maxDepth ?? Number.POSITIVE_INFINITY : 1;
|
|
875
|
+
const entryLimit = options.entryLimit;
|
|
876
|
+
let truncated = false;
|
|
877
|
+
const collect = async (directory, relativeDirectory, depth) => {
|
|
878
|
+
if (truncated) {
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
const directoryEntries = (await readdir(directory, { withFileTypes: true })).sort((left, right) => left.name.localeCompare(right.name));
|
|
882
|
+
for (const entry of directoryEntries) {
|
|
883
|
+
if (!includeHidden && entry.name.startsWith(".")) {
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
if (entryLimit !== undefined && entries.length >= entryLimit) {
|
|
887
|
+
truncated = true;
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
const name = relativeDirectory.length > 0 ? `${relativeDirectory}/${entry.name}` : entry.name;
|
|
891
|
+
entries.push({
|
|
892
|
+
name,
|
|
893
|
+
type: entry.isFile() ? "file" : entry.isDirectory() ? "directory" : "other",
|
|
894
|
+
});
|
|
895
|
+
if (recursive && entry.isDirectory() && depth < maxDepth) {
|
|
896
|
+
await collect(resolve(directory, entry.name), name, depth + 1);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
};
|
|
900
|
+
await collect(root, "", 1);
|
|
901
|
+
return { entries, truncated };
|
|
902
|
+
}
|
|
903
|
+
function applyByteRange(buffer, range) {
|
|
904
|
+
if (!range) {
|
|
905
|
+
return buffer;
|
|
906
|
+
}
|
|
907
|
+
const start = range.start ?? 0;
|
|
908
|
+
const end = range.length === undefined ? buffer.byteLength : start + range.length;
|
|
909
|
+
return buffer.subarray(start, end);
|
|
910
|
+
}
|
|
911
|
+
function limitBuffer(buffer, limitBytes) {
|
|
912
|
+
if (limitBytes === undefined || buffer.byteLength <= limitBytes) {
|
|
913
|
+
return { buffer, truncated: false };
|
|
914
|
+
}
|
|
915
|
+
return {
|
|
916
|
+
buffer: buffer.subarray(0, limitBytes),
|
|
917
|
+
truncated: true,
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
function encodeBuffer(buffer, encoding) {
|
|
921
|
+
return buffer.toString(encoding);
|
|
922
|
+
}
|
|
923
|
+
function decodeContent(content, encoding) {
|
|
924
|
+
return Buffer.from(content, encoding);
|
|
925
|
+
}
|
|
926
|
+
function sha256Hash(buffer) {
|
|
927
|
+
return `sha256:${createHash("sha256").update(buffer).digest("hex")}`;
|
|
928
|
+
}
|
|
929
|
+
async function assertWriteModeAllowed(path, options) {
|
|
930
|
+
const mode = options.mode ?? "overwrite";
|
|
931
|
+
if (mode === "create") {
|
|
932
|
+
if (await fileExists(path)) {
|
|
933
|
+
throw new LocalCapabilityPolicyError("local_capability_action_failed", `Path '${path}' already exists.`);
|
|
934
|
+
}
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
if (options.expectedBaseHash === undefined) {
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
const existingBytes = await readFile(path);
|
|
941
|
+
assertExpectedHash(existingBytes, options.expectedBaseHash);
|
|
942
|
+
}
|
|
943
|
+
async function fileExists(path) {
|
|
944
|
+
try {
|
|
945
|
+
const pathStat = await lstat(path);
|
|
946
|
+
if (pathStat.isSymbolicLink()) {
|
|
947
|
+
throw new LocalCapabilityPolicyError("local_capability_path_denied", `Path '${path}' is a dangling symlink.`);
|
|
948
|
+
}
|
|
949
|
+
return true;
|
|
950
|
+
}
|
|
951
|
+
catch (error) {
|
|
952
|
+
if (isFileMissingError(error)) {
|
|
953
|
+
return false;
|
|
954
|
+
}
|
|
955
|
+
throw error;
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
async function readExistingFileForPatch(path, createIfMissing) {
|
|
959
|
+
try {
|
|
960
|
+
return await readFile(path);
|
|
961
|
+
}
|
|
962
|
+
catch (error) {
|
|
963
|
+
if (isFileMissingError(error) && createIfMissing) {
|
|
964
|
+
await assertMissingWritableTargetHasNoSymlink(path);
|
|
965
|
+
return Buffer.alloc(0);
|
|
966
|
+
}
|
|
967
|
+
throw error;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
async function assertMissingWritableTargetHasNoSymlink(path) {
|
|
971
|
+
try {
|
|
972
|
+
const pathStat = await lstat(path);
|
|
973
|
+
if (pathStat.isSymbolicLink()) {
|
|
974
|
+
throw new LocalCapabilityPolicyError("local_capability_path_denied", `Path '${path}' is a dangling symlink.`);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
catch (error) {
|
|
978
|
+
if (isFileMissingError(error)) {
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
throw error;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
function assertExpectedHash(buffer, expectedHash) {
|
|
985
|
+
const actualHash = sha256Hash(buffer);
|
|
986
|
+
if (actualHash !== expectedHash) {
|
|
987
|
+
throw new LocalCapabilityPolicyError("local_capability_expected_hash_mismatch", `Expected base hash '${expectedHash}' did not match current hash '${actualHash}'.`);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
function applyUnifiedDiff(originalContent, patchContent) {
|
|
991
|
+
const originalLines = splitLinesPreservingEndings(originalContent);
|
|
992
|
+
const patchLines = splitLinesPreservingEndings(patchContent);
|
|
993
|
+
const outputLines = [];
|
|
994
|
+
let originalIndex = 0;
|
|
995
|
+
let patchIndex = 0;
|
|
996
|
+
let changed = false;
|
|
997
|
+
while (patchIndex < patchLines.length) {
|
|
998
|
+
const line = patchLines[patchIndex] ?? "";
|
|
999
|
+
if (!line.startsWith("@@")) {
|
|
1000
|
+
patchIndex += 1;
|
|
1001
|
+
continue;
|
|
1002
|
+
}
|
|
1003
|
+
const hunk = parseUnifiedDiffHunkHeader(line);
|
|
1004
|
+
while (originalIndex < hunk.oldStart - 1) {
|
|
1005
|
+
const originalLine = originalLines[originalIndex];
|
|
1006
|
+
if (originalLine === undefined) {
|
|
1007
|
+
throw new LocalCapabilityPolicyError("local_capability_action_failed", "Unified diff hunk starts beyond the end of the file.");
|
|
1008
|
+
}
|
|
1009
|
+
outputLines.push(originalLine);
|
|
1010
|
+
originalIndex += 1;
|
|
1011
|
+
}
|
|
1012
|
+
patchIndex += 1;
|
|
1013
|
+
while (patchIndex < patchLines.length && !(patchLines[patchIndex] ?? "").startsWith("@@")) {
|
|
1014
|
+
const hunkLine = patchLines[patchIndex] ?? "";
|
|
1015
|
+
if (hunkLine.startsWith("\\ ")) {
|
|
1016
|
+
patchIndex += 1;
|
|
1017
|
+
continue;
|
|
1018
|
+
}
|
|
1019
|
+
const prefix = hunkLine.slice(0, 1);
|
|
1020
|
+
const body = hunkLine.slice(1);
|
|
1021
|
+
if (prefix === " ") {
|
|
1022
|
+
assertPatchLineMatches(originalLines[originalIndex], body);
|
|
1023
|
+
outputLines.push(body);
|
|
1024
|
+
originalIndex += 1;
|
|
1025
|
+
}
|
|
1026
|
+
else if (prefix === "-") {
|
|
1027
|
+
assertPatchLineMatches(originalLines[originalIndex], body);
|
|
1028
|
+
originalIndex += 1;
|
|
1029
|
+
changed = true;
|
|
1030
|
+
}
|
|
1031
|
+
else if (prefix === "+") {
|
|
1032
|
+
outputLines.push(body);
|
|
1033
|
+
changed = true;
|
|
1034
|
+
}
|
|
1035
|
+
else if (hunkLine.startsWith("--- ") || hunkLine.startsWith("+++ ")) {
|
|
1036
|
+
break;
|
|
1037
|
+
}
|
|
1038
|
+
else {
|
|
1039
|
+
throw new LocalCapabilityPolicyError("local_capability_action_failed", `Unsupported unified diff line '${hunkLine.trimEnd()}'.`);
|
|
1040
|
+
}
|
|
1041
|
+
patchIndex += 1;
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
while (originalIndex < originalLines.length) {
|
|
1045
|
+
const originalLine = originalLines[originalIndex];
|
|
1046
|
+
if (originalLine !== undefined) {
|
|
1047
|
+
outputLines.push(originalLine);
|
|
1048
|
+
}
|
|
1049
|
+
originalIndex += 1;
|
|
1050
|
+
}
|
|
1051
|
+
return {
|
|
1052
|
+
content: outputLines.join(""),
|
|
1053
|
+
changed,
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
function splitLinesPreservingEndings(content) {
|
|
1057
|
+
if (content.length === 0) {
|
|
1058
|
+
return [];
|
|
1059
|
+
}
|
|
1060
|
+
const matches = content.match(/.*(?:\r\n|\n|\r|$)/g);
|
|
1061
|
+
if (!matches) {
|
|
1062
|
+
return [content];
|
|
1063
|
+
}
|
|
1064
|
+
return matches.filter((line) => line.length > 0);
|
|
1065
|
+
}
|
|
1066
|
+
function parseUnifiedDiffHunkHeader(header) {
|
|
1067
|
+
if (/^@@\s*$/.test(header)) {
|
|
1068
|
+
return { oldStart: 1 };
|
|
1069
|
+
}
|
|
1070
|
+
const match = /^@@ -(?<oldStart>\d+)(?:,\d+)? \+\d+(?:,\d+)? @@/.exec(header);
|
|
1071
|
+
if (!match?.groups?.oldStart) {
|
|
1072
|
+
throw new LocalCapabilityPolicyError("local_capability_action_failed", `Invalid unified diff hunk header '${header.trimEnd()}'.`);
|
|
1073
|
+
}
|
|
1074
|
+
return { oldStart: Number.parseInt(match.groups.oldStart, 10) };
|
|
1075
|
+
}
|
|
1076
|
+
function assertPatchLineMatches(actual, expected) {
|
|
1077
|
+
if (actual === expected) {
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
throw new LocalCapabilityPolicyError("local_capability_action_failed", "Unified diff context did not match the current file.");
|
|
1081
|
+
}
|
|
1082
|
+
async function assertRequestedPathsInsideWorkspace(paths, workspaceRoot) {
|
|
1083
|
+
for (const path of paths) {
|
|
1084
|
+
const candidate = await resolveCandidatePath(path, workspaceRoot);
|
|
1085
|
+
await assertCandidateInsideWorkspace(candidate, workspaceRoot);
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
function parseGitStatusBranch(stdout) {
|
|
1089
|
+
const firstLine = stdout.split(/\r?\n/, 1)[0];
|
|
1090
|
+
if (!firstLine?.startsWith("## ")) {
|
|
1091
|
+
return undefined;
|
|
1092
|
+
}
|
|
1093
|
+
const branch = firstLine.slice(3).split("...", 1)[0]?.trim();
|
|
1094
|
+
return branch && branch !== "HEAD (no branch)" ? branch : undefined;
|
|
1095
|
+
}
|
|
1096
|
+
function gitStatusBranchResult(operation, options, stdout) {
|
|
1097
|
+
if (operation !== "status" || !options.status?.includeBranch) {
|
|
1098
|
+
return {};
|
|
1099
|
+
}
|
|
1100
|
+
const branch = parseGitStatusBranch(stdout);
|
|
1101
|
+
return branch ? { branch } : {};
|
|
1102
|
+
}
|
|
1103
|
+
//# sourceMappingURL=executors.js.map
|