@span-io/agent-link 0.1.4 → 0.2.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/dist/bootstrap.js +192 -0
- package/dist/config.js +6 -0
- package/dist/index.js +54 -0
- package/dist/process-runner.js +35 -5
- package/dist/remote-command.js +203 -0
- package/dist/transport.js +72 -3
- package/package.json +1 -1
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import process from "process";
|
|
3
|
+
import { mkdir } from "fs/promises";
|
|
4
|
+
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
|
|
5
|
+
const MAX_TIMEOUT_MS = 20 * 60 * 1000;
|
|
6
|
+
const DEFAULT_ALLOWED_COMMANDS = ["npx", "npm"];
|
|
7
|
+
const DEFAULT_ALLOWED_ENV = ["CI", "NODE_ENV", "NPM_CONFIG_YES"];
|
|
8
|
+
const MAX_ARGS = 128;
|
|
9
|
+
const MAX_ARG_LEN = 4096;
|
|
10
|
+
export function parseCsvList(value, fallback) {
|
|
11
|
+
if (!value || !value.trim()) {
|
|
12
|
+
return new Set(fallback);
|
|
13
|
+
}
|
|
14
|
+
const entries = value
|
|
15
|
+
.split(",")
|
|
16
|
+
.map((part) => part.trim())
|
|
17
|
+
.filter((part) => part.length > 0);
|
|
18
|
+
return new Set(entries.length > 0 ? entries : fallback);
|
|
19
|
+
}
|
|
20
|
+
export function sanitizeBootstrapEnv(input, allowed) {
|
|
21
|
+
if (!input) {
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
const sanitized = {};
|
|
25
|
+
for (const [key, value] of Object.entries(input)) {
|
|
26
|
+
if (!allowed.has(key)) {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (typeof value !== "string") {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const trimmed = value.trim();
|
|
33
|
+
if (!trimmed || trimmed.length > MAX_ARG_LEN) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
sanitized[key] = trimmed;
|
|
37
|
+
}
|
|
38
|
+
return sanitized;
|
|
39
|
+
}
|
|
40
|
+
export function normalizeBootstrapPayload(payload, commandAllowlist, envAllowlist) {
|
|
41
|
+
const runId = typeof payload?.runId === "string" ? payload.runId.trim() : "";
|
|
42
|
+
if (!runId) {
|
|
43
|
+
return { ok: false, error: "Missing runId." };
|
|
44
|
+
}
|
|
45
|
+
const workingDirectory = typeof payload?.workingDirectory === "string" ? payload.workingDirectory.trim() : "";
|
|
46
|
+
if (!workingDirectory) {
|
|
47
|
+
return { ok: false, error: "Missing workingDirectory." };
|
|
48
|
+
}
|
|
49
|
+
const command = typeof payload?.command === "string" && payload.command.trim().length > 0
|
|
50
|
+
? payload.command.trim()
|
|
51
|
+
: "npx";
|
|
52
|
+
if (!commandAllowlist.has(command)) {
|
|
53
|
+
return {
|
|
54
|
+
ok: false,
|
|
55
|
+
error: `Bootstrap command \"${command}\" is not allowed.`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const argsRaw = Array.isArray(payload?.args) ? payload.args : [];
|
|
59
|
+
const args = argsRaw
|
|
60
|
+
.filter((arg) => typeof arg === "string")
|
|
61
|
+
.map((arg) => arg.trim())
|
|
62
|
+
.filter((arg) => arg.length > 0);
|
|
63
|
+
if (args.length > MAX_ARGS) {
|
|
64
|
+
return { ok: false, error: `Bootstrap args exceed max count (${MAX_ARGS}).` };
|
|
65
|
+
}
|
|
66
|
+
for (const arg of args) {
|
|
67
|
+
if (arg.length > MAX_ARG_LEN) {
|
|
68
|
+
return { ok: false, error: `Bootstrap arg exceeds max length (${MAX_ARG_LEN}).` };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const requestedTimeout = typeof payload?.timeoutMs === "number" && Number.isFinite(payload.timeoutMs)
|
|
72
|
+
? payload.timeoutMs
|
|
73
|
+
: DEFAULT_TIMEOUT_MS;
|
|
74
|
+
const timeoutMs = Math.min(Math.max(requestedTimeout, 1), MAX_TIMEOUT_MS);
|
|
75
|
+
const env = sanitizeBootstrapEnv(payload?.env, envAllowlist);
|
|
76
|
+
return {
|
|
77
|
+
ok: true,
|
|
78
|
+
value: {
|
|
79
|
+
runId,
|
|
80
|
+
workingDirectory,
|
|
81
|
+
command,
|
|
82
|
+
args,
|
|
83
|
+
timeoutMs,
|
|
84
|
+
env,
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
export class BootstrapRunner {
|
|
89
|
+
deps;
|
|
90
|
+
activeRunId = null;
|
|
91
|
+
commandAllowlist;
|
|
92
|
+
envAllowlist;
|
|
93
|
+
constructor(deps = {}, options = {}) {
|
|
94
|
+
this.deps = {
|
|
95
|
+
mkdirFn: deps.mkdirFn ?? (async (path) => {
|
|
96
|
+
await mkdir(path, { recursive: true });
|
|
97
|
+
}),
|
|
98
|
+
spawnFn: deps.spawnFn ??
|
|
99
|
+
((command, args, options) => spawn(command, args, options)),
|
|
100
|
+
};
|
|
101
|
+
this.commandAllowlist =
|
|
102
|
+
options.commandAllowlist ??
|
|
103
|
+
parseCsvList(process.env.AGENT_LINK_BOOTSTRAP_ALLOWED_COMMANDS, DEFAULT_ALLOWED_COMMANDS);
|
|
104
|
+
this.envAllowlist =
|
|
105
|
+
options.envAllowlist ??
|
|
106
|
+
parseCsvList(process.env.AGENT_LINK_BOOTSTRAP_ALLOWED_ENV, DEFAULT_ALLOWED_ENV);
|
|
107
|
+
}
|
|
108
|
+
async run(payload, transport) {
|
|
109
|
+
const normalized = normalizeBootstrapPayload(payload, this.commandAllowlist, this.envAllowlist);
|
|
110
|
+
if (!normalized.ok) {
|
|
111
|
+
const runId = typeof payload?.runId === "string" ? payload.runId.trim() : "";
|
|
112
|
+
if (runId) {
|
|
113
|
+
transport.sendBootstrap(runId, "error", normalized.error);
|
|
114
|
+
}
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const request = normalized.value;
|
|
118
|
+
if (this.activeRunId) {
|
|
119
|
+
transport.sendBootstrap(request.runId, "error", `Bootstrap already running for run ${this.activeRunId}.`);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
this.activeRunId = request.runId;
|
|
123
|
+
try {
|
|
124
|
+
await this.deps.mkdirFn(request.workingDirectory);
|
|
125
|
+
transport.sendBootstrap(request.runId, "started", `Running bootstrap command: ${request.command} ${request.args.join(" ")} (cwd=${request.workingDirectory})`);
|
|
126
|
+
await this.runProcess(request, transport);
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
const message = error instanceof Error ? error.message : "unknown error";
|
|
130
|
+
transport.sendBootstrap(request.runId, "error", `Bootstrap failed: ${message}`);
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
if (this.activeRunId === request.runId) {
|
|
134
|
+
this.activeRunId = null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
async runProcess(request, transport) {
|
|
139
|
+
await new Promise((resolve) => {
|
|
140
|
+
let child;
|
|
141
|
+
try {
|
|
142
|
+
child = this.deps.spawnFn(request.command, request.args, {
|
|
143
|
+
cwd: request.workingDirectory,
|
|
144
|
+
env: {
|
|
145
|
+
...process.env,
|
|
146
|
+
...request.env,
|
|
147
|
+
},
|
|
148
|
+
shell: false,
|
|
149
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
const message = error instanceof Error ? error.message : "unknown error";
|
|
154
|
+
transport.sendBootstrap(request.runId, "error", `Bootstrap process error: ${message}`);
|
|
155
|
+
resolve();
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
let settled = false;
|
|
159
|
+
const complete = (status, message) => {
|
|
160
|
+
if (settled) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
settled = true;
|
|
164
|
+
clearTimeout(timer);
|
|
165
|
+
transport.sendBootstrap(request.runId, status, message);
|
|
166
|
+
resolve();
|
|
167
|
+
};
|
|
168
|
+
const timer = setTimeout(() => {
|
|
169
|
+
child.kill("SIGKILL");
|
|
170
|
+
complete("error", `Bootstrap timed out after ${request.timeoutMs}ms.`);
|
|
171
|
+
}, request.timeoutMs);
|
|
172
|
+
child.stdout?.setEncoding("utf8");
|
|
173
|
+
child.stderr?.setEncoding("utf8");
|
|
174
|
+
child.stdout?.on("data", (chunk) => {
|
|
175
|
+
transport.sendBootstrap(request.runId, "log", chunk);
|
|
176
|
+
});
|
|
177
|
+
child.stderr?.on("data", (chunk) => {
|
|
178
|
+
transport.sendBootstrap(request.runId, "log", chunk);
|
|
179
|
+
});
|
|
180
|
+
child.on("error", (error) => {
|
|
181
|
+
complete("error", `Bootstrap process error: ${error.message}`);
|
|
182
|
+
});
|
|
183
|
+
child.on("exit", (code, signal) => {
|
|
184
|
+
if (code === 0) {
|
|
185
|
+
complete("complete", "Bootstrap completed successfully.");
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
complete("error", `Bootstrap exited with code ${String(code ?? "null")} signal ${String(signal ?? "null")}.`);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
package/dist/config.js
CHANGED
|
@@ -42,4 +42,10 @@ export function saveConfig(config) {
|
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
fs.writeFileSync(CONFIG_FILE, JSON.stringify(toSave, null, 2), "utf8");
|
|
45
|
+
try {
|
|
46
|
+
fs.chmodSync(CONFIG_FILE, 0o600);
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
console.warn("Failed to set secure permissions on config file:", error);
|
|
50
|
+
}
|
|
45
51
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import os from "os";
|
|
3
3
|
import process from "process";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { access } from "fs/promises";
|
|
6
|
+
import { constants as fsConstants } from "fs";
|
|
4
7
|
import { loadConfig, saveConfig } from "./config.js";
|
|
5
8
|
import { findAgentsOnPath, resolveAgentBinary } from "./agent.js";
|
|
6
9
|
import { spawnAgentProcess as spawnAgentProcessAdvanced } from "./process-runner.js";
|
|
7
10
|
import { LogBuffer } from "./log-buffer.js";
|
|
8
11
|
import { compactPrompt, resolvePromptCompactionPolicy } from "./prompt-compact.js";
|
|
12
|
+
import { BootstrapRunner } from "./bootstrap.js";
|
|
13
|
+
import { RemoteCommandRunner } from "./remote-command.js";
|
|
9
14
|
import { NoopTransport, WebSocketTransport } from "./transport.js";
|
|
10
15
|
const args = parseArgs(process.argv.slice(2));
|
|
11
16
|
if (args.list) {
|
|
@@ -45,6 +50,8 @@ const logBuffer = new LogBuffer();
|
|
|
45
50
|
// Update map to hold SpawnedProcess which includes the child
|
|
46
51
|
const activeAgents = new Map();
|
|
47
52
|
const promptPolicy = resolvePromptCompactionPolicy();
|
|
53
|
+
const bootstrapRunner = new BootstrapRunner();
|
|
54
|
+
const remoteCommandRunner = new RemoteCommandRunner();
|
|
48
55
|
let transport;
|
|
49
56
|
try {
|
|
50
57
|
transport = await createTransport({
|
|
@@ -61,6 +68,18 @@ catch (error) {
|
|
|
61
68
|
}
|
|
62
69
|
function handleControl(message) {
|
|
63
70
|
const { action, agentId, payload } = message;
|
|
71
|
+
if (action === "bootstrap") {
|
|
72
|
+
void bootstrapRunner.run(payload ?? {}, transport);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (action === "check_file") {
|
|
76
|
+
void runRemoteFileCheck(payload ?? {}, transport);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (action === "execute_command") {
|
|
80
|
+
void runRemoteCommand(payload ?? {}, transport);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
64
83
|
if (!agentId)
|
|
65
84
|
return;
|
|
66
85
|
switch (action) {
|
|
@@ -160,6 +179,41 @@ function handleControl(message) {
|
|
|
160
179
|
}
|
|
161
180
|
}
|
|
162
181
|
}
|
|
182
|
+
async function runRemoteFileCheck(payload, transport) {
|
|
183
|
+
const runId = typeof payload.runId === "string" ? payload.runId.trim() : "";
|
|
184
|
+
if (!runId) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const workingDirectory = typeof payload.workingDirectory === "string" ? payload.workingDirectory.trim() : "";
|
|
188
|
+
if (!workingDirectory) {
|
|
189
|
+
transport.sendPreflight(runId, "error", false, "Missing workingDirectory.");
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const filePath = typeof payload.filePath === "string" && payload.filePath.trim().length > 0
|
|
193
|
+
? payload.filePath.trim()
|
|
194
|
+
: "AGENTS.md";
|
|
195
|
+
const baseDir = path.resolve(workingDirectory);
|
|
196
|
+
const target = path.resolve(baseDir, filePath);
|
|
197
|
+
const relative = path.relative(baseDir, target);
|
|
198
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
199
|
+
transport.sendPreflight(runId, "error", false, "Invalid filePath outside working directory.");
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
await access(target, fsConstants.F_OK);
|
|
204
|
+
transport.sendPreflight(runId, "complete", true, filePath + " exists.");
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
transport.sendPreflight(runId, "complete", false, filePath + " not found.");
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
async function runRemoteCommand(payload, transport) {
|
|
211
|
+
await remoteCommandRunner.run(payload, {
|
|
212
|
+
sendCommand: (requestId, status, details) => {
|
|
213
|
+
transport.sendCommand(requestId, status, details);
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
}
|
|
163
217
|
function setupAgentPiping(agentId, proc) {
|
|
164
218
|
const rawFlushSize = Number.parseInt(process.env.AGENT_LINK_STREAM_FLUSH_CHARS ?? "16384", 10);
|
|
165
219
|
const flushSize = Number.isFinite(rawFlushSize) && rawFlushSize > 0 ? rawFlushSize : 16384;
|
package/dist/process-runner.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { mkdirSync } from "fs";
|
|
2
|
+
import path from "path";
|
|
2
3
|
import { spawn } from "child_process";
|
|
3
4
|
const DEFAULT_CODEX_ARGS = "exec --skip-git-repo-check";
|
|
4
5
|
const DEFAULT_GEMINI_ARGS = "";
|
|
@@ -11,7 +12,32 @@ function splitArgs(raw) {
|
|
|
11
12
|
}
|
|
12
13
|
function shellQuote(value) {
|
|
13
14
|
// Simple single-quote wrapping for display purposes
|
|
14
|
-
return `'${value.replace(/'/g, `
|
|
15
|
+
return `'${value.replace(/'/g, `"'"'`)}'`;
|
|
16
|
+
}
|
|
17
|
+
function parseAllowedRoots() {
|
|
18
|
+
const raw = process.env.AGENT_LINK_ALLOWED_WORKDIR_ROOTS?.trim();
|
|
19
|
+
if (!raw) {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
return raw
|
|
23
|
+
.split(",")
|
|
24
|
+
.map((entry) => entry.trim())
|
|
25
|
+
.filter(Boolean)
|
|
26
|
+
.map((entry) => path.resolve(entry));
|
|
27
|
+
}
|
|
28
|
+
function isUnderRoot(candidate, root) {
|
|
29
|
+
const rel = path.relative(root, candidate);
|
|
30
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
31
|
+
}
|
|
32
|
+
function assertAllowedDirectory(directory, allowedRoots, label) {
|
|
33
|
+
if (allowedRoots.length === 0) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const resolved = path.resolve(directory);
|
|
37
|
+
const allowed = allowedRoots.some((root) => isUnderRoot(resolved, root));
|
|
38
|
+
if (!allowed) {
|
|
39
|
+
throw new Error(`${label} '${resolved}' is outside AGENT_LINK_ALLOWED_WORKDIR_ROOTS (${allowedRoots.join(", ")}).`);
|
|
40
|
+
}
|
|
15
41
|
}
|
|
16
42
|
function collectDirectoriesFromArgs(args) {
|
|
17
43
|
const directories = [];
|
|
@@ -43,17 +69,19 @@ function collectDirectoriesFromArgs(args) {
|
|
|
43
69
|
}
|
|
44
70
|
return directories;
|
|
45
71
|
}
|
|
46
|
-
function ensureDirectoriesExist(rawDirs) {
|
|
72
|
+
function ensureDirectoriesExist(rawDirs, allowedRoots) {
|
|
47
73
|
const deduped = new Set(rawDirs
|
|
48
74
|
.map((entry) => entry.trim())
|
|
49
75
|
.filter((entry) => entry.length > 0));
|
|
50
76
|
for (const dir of deduped) {
|
|
77
|
+
const resolved = path.resolve(dir);
|
|
78
|
+
assertAllowedDirectory(resolved, allowedRoots, "Requested directory");
|
|
51
79
|
try {
|
|
52
|
-
mkdirSync(
|
|
80
|
+
mkdirSync(resolved, { recursive: true });
|
|
53
81
|
}
|
|
54
82
|
catch (error) {
|
|
55
83
|
const message = error instanceof Error ? error.message : String(error);
|
|
56
|
-
throw new Error(`Failed to create working directory '${
|
|
84
|
+
throw new Error(`Failed to create working directory '${resolved}': ${message}`);
|
|
57
85
|
}
|
|
58
86
|
}
|
|
59
87
|
}
|
|
@@ -133,16 +161,18 @@ export function spawnAgentProcess(config) {
|
|
|
133
161
|
const startedAt = new Date().toISOString();
|
|
134
162
|
const isCodexModel = !agent.model.startsWith("gemini-") && !agent.model.startsWith("claude-");
|
|
135
163
|
const sanitizedOptions = optionsArgs;
|
|
164
|
+
const allowedRoots = parseAllowedRoots();
|
|
136
165
|
const spawnWithMode = (promptModeOverride) => {
|
|
137
166
|
const { command, args, promptMode } = buildCommand({ ...config, optionsArgs: sanitizedOptions }, promptModeOverride);
|
|
138
167
|
// Ensure requested project/working directories exist before launching CLI.
|
|
139
|
-
ensureDirectoriesExist(collectDirectoriesFromArgs(args));
|
|
168
|
+
ensureDirectoriesExist(collectDirectoriesFromArgs(args), allowedRoots);
|
|
140
169
|
const ttyMode = process.env.CODEX_TTY_MODE ?? DEFAULT_TTY_MODE;
|
|
141
170
|
const hasExec = args.includes("exec");
|
|
142
171
|
const useScriptWrapper = ttyMode === "script" || (ttyMode === "auto" && hasExec);
|
|
143
172
|
const ttyTerm = process.env.CODEX_TTY_TERM ?? DEFAULT_TTY_TERM;
|
|
144
173
|
const defaultCwd = process.cwd(); // Client uses current working dir
|
|
145
174
|
const workingDir = process.env.CODEX_CWD ?? defaultCwd;
|
|
175
|
+
assertAllowedDirectory(workingDir, allowedRoots, "Spawn cwd");
|
|
146
176
|
// For logging/display
|
|
147
177
|
const commandString = [command, ...args].map(shellQuote).join(" ");
|
|
148
178
|
const platform = process.platform;
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import process from "process";
|
|
4
|
+
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
|
5
|
+
const MAX_TIMEOUT_MS = 15 * 60 * 1000;
|
|
6
|
+
const DEFAULT_ALLOWED_COMMANDS = ["ls", "pwd", "cat", "find", "git", "node", "npx", "npm"];
|
|
7
|
+
const MAX_ARGS = 128;
|
|
8
|
+
const MAX_ARG_LEN = 4096;
|
|
9
|
+
const DEFAULT_MAX_OUTPUT_CHARS = 64_000;
|
|
10
|
+
function parseCsvList(value, fallback) {
|
|
11
|
+
if (!value || !value.trim()) {
|
|
12
|
+
return new Set(fallback);
|
|
13
|
+
}
|
|
14
|
+
const entries = value
|
|
15
|
+
.split(",")
|
|
16
|
+
.map((part) => part.trim())
|
|
17
|
+
.filter((part) => part.length > 0);
|
|
18
|
+
return new Set(entries.length > 0 ? entries : fallback);
|
|
19
|
+
}
|
|
20
|
+
function parseAllowedRoots() {
|
|
21
|
+
const raw = process.env.AGENT_LINK_ALLOWED_WORKDIR_ROOTS?.trim();
|
|
22
|
+
if (!raw) {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
return raw
|
|
26
|
+
.split(",")
|
|
27
|
+
.map((entry) => entry.trim())
|
|
28
|
+
.filter(Boolean)
|
|
29
|
+
.map((entry) => path.resolve(entry));
|
|
30
|
+
}
|
|
31
|
+
function isUnderRoot(candidate, root) {
|
|
32
|
+
const rel = path.relative(root, candidate);
|
|
33
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
34
|
+
}
|
|
35
|
+
function assertAllowedDirectory(directory, allowedRoots) {
|
|
36
|
+
if (allowedRoots.length === 0) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const resolved = path.resolve(directory);
|
|
40
|
+
const allowed = allowedRoots.some((root) => isUnderRoot(resolved, root));
|
|
41
|
+
if (!allowed) {
|
|
42
|
+
throw new Error(`Requested directory '${resolved}' is outside AGENT_LINK_ALLOWED_WORKDIR_ROOTS (${allowedRoots.join(", ")}).`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function normalizeRemoteCommandPayload(payload, commandAllowlist) {
|
|
46
|
+
const requestId = typeof payload?.requestId === "string" ? payload.requestId.trim() : "";
|
|
47
|
+
if (!requestId) {
|
|
48
|
+
return { ok: false, error: "Missing requestId." };
|
|
49
|
+
}
|
|
50
|
+
const command = typeof payload?.command === "string" ? payload.command.trim() : "";
|
|
51
|
+
if (!command) {
|
|
52
|
+
return { ok: false, requestId, error: "Missing command." };
|
|
53
|
+
}
|
|
54
|
+
if (!commandAllowlist.has(command)) {
|
|
55
|
+
return {
|
|
56
|
+
ok: false,
|
|
57
|
+
requestId,
|
|
58
|
+
error: `Command \"${command}\" is not allowed by AGENT_LINK_REMOTE_COMMAND_ALLOWLIST.`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const rawArgs = Array.isArray(payload?.args) ? payload.args : [];
|
|
62
|
+
const args = rawArgs
|
|
63
|
+
.filter((arg) => typeof arg === "string")
|
|
64
|
+
.map((arg) => arg.trim())
|
|
65
|
+
.filter((arg) => arg.length > 0);
|
|
66
|
+
if (args.length > MAX_ARGS) {
|
|
67
|
+
return { ok: false, requestId, error: `Command args exceed max count (${MAX_ARGS}).` };
|
|
68
|
+
}
|
|
69
|
+
for (const arg of args) {
|
|
70
|
+
if (arg.length > MAX_ARG_LEN) {
|
|
71
|
+
return { ok: false, requestId, error: `Command arg exceeds max length (${MAX_ARG_LEN}).` };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const workingDirectoryRaw = typeof payload?.workingDirectory === "string" ? payload.workingDirectory.trim() : "";
|
|
75
|
+
const workingDirectory = path.resolve(workingDirectoryRaw || process.cwd());
|
|
76
|
+
const allowedRoots = parseAllowedRoots();
|
|
77
|
+
try {
|
|
78
|
+
assertAllowedDirectory(workingDirectory, allowedRoots);
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
requestId,
|
|
84
|
+
error: error instanceof Error ? error.message : "Invalid workingDirectory.",
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
const requestedTimeout = typeof payload?.timeoutMs === "number" && Number.isFinite(payload.timeoutMs)
|
|
88
|
+
? payload.timeoutMs
|
|
89
|
+
: DEFAULT_TIMEOUT_MS;
|
|
90
|
+
const timeoutMs = Math.min(Math.max(requestedTimeout, 1), MAX_TIMEOUT_MS);
|
|
91
|
+
return {
|
|
92
|
+
ok: true,
|
|
93
|
+
value: {
|
|
94
|
+
requestId,
|
|
95
|
+
command,
|
|
96
|
+
args,
|
|
97
|
+
workingDirectory,
|
|
98
|
+
timeoutMs,
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
export class RemoteCommandRunner {
|
|
103
|
+
deps;
|
|
104
|
+
commandAllowlist;
|
|
105
|
+
maxOutputChars;
|
|
106
|
+
constructor(deps = {}, options = {}) {
|
|
107
|
+
this.deps = {
|
|
108
|
+
spawnFn: deps.spawnFn ??
|
|
109
|
+
((command, args, spawnOptions) => spawn(command, args, spawnOptions)),
|
|
110
|
+
};
|
|
111
|
+
this.commandAllowlist =
|
|
112
|
+
options.commandAllowlist ??
|
|
113
|
+
parseCsvList(process.env.AGENT_LINK_REMOTE_COMMAND_ALLOWLIST, DEFAULT_ALLOWED_COMMANDS);
|
|
114
|
+
const configuredMax = Number.parseInt(process.env.AGENT_LINK_COMMAND_MAX_OUTPUT_CHARS ?? "", 10);
|
|
115
|
+
this.maxOutputChars =
|
|
116
|
+
options.maxOutputChars ??
|
|
117
|
+
(Number.isFinite(configuredMax) && configuredMax > 0 ? configuredMax : DEFAULT_MAX_OUTPUT_CHARS);
|
|
118
|
+
}
|
|
119
|
+
async run(payload, transport) {
|
|
120
|
+
const normalized = normalizeRemoteCommandPayload(payload, this.commandAllowlist);
|
|
121
|
+
if (!normalized.ok) {
|
|
122
|
+
if (normalized.requestId) {
|
|
123
|
+
transport.sendCommand(normalized.requestId, "error", { message: normalized.error });
|
|
124
|
+
}
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const request = normalized.value;
|
|
128
|
+
transport.sendCommand(request.requestId, "started", {
|
|
129
|
+
message: `Running command: ${request.command} ${request.args.join(" ")} (cwd=${request.workingDirectory})`,
|
|
130
|
+
});
|
|
131
|
+
await this.runProcess(request, transport);
|
|
132
|
+
}
|
|
133
|
+
async runProcess(request, transport) {
|
|
134
|
+
await new Promise((resolve) => {
|
|
135
|
+
let child;
|
|
136
|
+
try {
|
|
137
|
+
child = this.deps.spawnFn(request.command, request.args, {
|
|
138
|
+
cwd: request.workingDirectory,
|
|
139
|
+
env: {
|
|
140
|
+
...process.env,
|
|
141
|
+
},
|
|
142
|
+
shell: false,
|
|
143
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
const message = error instanceof Error ? error.message : "unknown error";
|
|
148
|
+
transport.sendCommand(request.requestId, "error", { message: `Command process error: ${message}` });
|
|
149
|
+
resolve();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
let settled = false;
|
|
153
|
+
let stdout = "";
|
|
154
|
+
let stderr = "";
|
|
155
|
+
const appendClamped = (current, incoming) => {
|
|
156
|
+
const merged = current + incoming;
|
|
157
|
+
if (merged.length <= this.maxOutputChars) {
|
|
158
|
+
return merged;
|
|
159
|
+
}
|
|
160
|
+
return merged.slice(-this.maxOutputChars);
|
|
161
|
+
};
|
|
162
|
+
const complete = (status, details = {}) => {
|
|
163
|
+
if (settled) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
settled = true;
|
|
167
|
+
clearTimeout(timer);
|
|
168
|
+
transport.sendCommand(request.requestId, status, {
|
|
169
|
+
exitCode: details.exitCode,
|
|
170
|
+
stdout,
|
|
171
|
+
stderr,
|
|
172
|
+
message: details.message,
|
|
173
|
+
});
|
|
174
|
+
resolve();
|
|
175
|
+
};
|
|
176
|
+
const timer = setTimeout(() => {
|
|
177
|
+
child.kill("SIGKILL");
|
|
178
|
+
complete("error", { message: `Command timed out after ${request.timeoutMs}ms.` });
|
|
179
|
+
}, request.timeoutMs);
|
|
180
|
+
child.stdout?.setEncoding("utf8");
|
|
181
|
+
child.stderr?.setEncoding("utf8");
|
|
182
|
+
child.stdout?.on("data", (chunk) => {
|
|
183
|
+
stdout = appendClamped(stdout, chunk);
|
|
184
|
+
});
|
|
185
|
+
child.stderr?.on("data", (chunk) => {
|
|
186
|
+
stderr = appendClamped(stderr, chunk);
|
|
187
|
+
});
|
|
188
|
+
child.on("error", (error) => {
|
|
189
|
+
complete("error", { message: `Command process error: ${error.message}` });
|
|
190
|
+
});
|
|
191
|
+
child.on("exit", (code, signal) => {
|
|
192
|
+
if (code === 0) {
|
|
193
|
+
complete("completed", { exitCode: 0 });
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
complete("error", {
|
|
197
|
+
exitCode: code ?? undefined,
|
|
198
|
+
message: `Command exited with code ${String(code ?? "null")} signal ${String(signal ?? "null")}.`,
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
package/dist/transport.js
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
import { encodeEnvelope, nowIso } from "./protocol.js";
|
|
2
2
|
import os from "os";
|
|
3
|
+
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
|
|
4
|
+
function isTruthyEnv(value) {
|
|
5
|
+
const normalized = value?.trim().toLowerCase();
|
|
6
|
+
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
|
|
7
|
+
}
|
|
8
|
+
function assertSecureTransport(url) {
|
|
9
|
+
const allowInsecure = isTruthyEnv(process.env.AGENT_LINK_ALLOW_INSECURE_TRANSPORT);
|
|
10
|
+
const isLocal = LOCAL_HOSTS.has(url.hostname.toLowerCase());
|
|
11
|
+
if (url.protocol === "https:" || url.protocol === "wss:") {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
if ((url.protocol === "http:" || url.protocol === "ws:") && (allowInsecure || isLocal)) {
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
throw new Error("Insecure transport is blocked. Use https:// (or set AGENT_LINK_ALLOW_INSECURE_TRANSPORT=1 for explicit local override).");
|
|
18
|
+
}
|
|
19
|
+
function toDisplayUrl(url) {
|
|
20
|
+
const clone = new URL(url.toString());
|
|
21
|
+
clone.search = "";
|
|
22
|
+
clone.hash = "";
|
|
23
|
+
return clone.toString();
|
|
24
|
+
}
|
|
3
25
|
export class NoopTransport {
|
|
4
26
|
async connect() {
|
|
5
27
|
return undefined;
|
|
@@ -10,6 +32,15 @@ export class NoopTransport {
|
|
|
10
32
|
sendStatus(_agentId, _state) {
|
|
11
33
|
return undefined;
|
|
12
34
|
}
|
|
35
|
+
sendBootstrap(_runId, _status, _message) {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
sendPreflight(_runId, _status, _exists, _message) {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
sendCommand(_requestId, _status, _details) {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
13
44
|
close() {
|
|
14
45
|
return undefined;
|
|
15
46
|
}
|
|
@@ -52,6 +83,7 @@ export class WebSocketTransport {
|
|
|
52
83
|
return;
|
|
53
84
|
}
|
|
54
85
|
const url = new URL("/api/ws", serverUrl);
|
|
86
|
+
assertSecureTransport(url);
|
|
55
87
|
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
56
88
|
url.searchParams.set("token", token);
|
|
57
89
|
return new Promise((resolve, reject) => {
|
|
@@ -62,7 +94,7 @@ export class WebSocketTransport {
|
|
|
62
94
|
this.socket.onopen = null;
|
|
63
95
|
this.socket.close();
|
|
64
96
|
}
|
|
65
|
-
console.log(`Connecting to ${url
|
|
97
|
+
console.log(`Connecting to ${toDisplayUrl(url)}...`);
|
|
66
98
|
const socket = new WebSocket(url.toString());
|
|
67
99
|
this.socket = socket;
|
|
68
100
|
const onOpen = () => {
|
|
@@ -82,7 +114,7 @@ export class WebSocketTransport {
|
|
|
82
114
|
};
|
|
83
115
|
resolve();
|
|
84
116
|
};
|
|
85
|
-
const onFail = (
|
|
117
|
+
const onFail = (_err) => {
|
|
86
118
|
console.error("WebSocket connection failed to open.");
|
|
87
119
|
reject(new Error("WebSocket connection failed"));
|
|
88
120
|
};
|
|
@@ -112,7 +144,7 @@ export class WebSocketTransport {
|
|
|
112
144
|
this.options.onAck(message.id);
|
|
113
145
|
}
|
|
114
146
|
}
|
|
115
|
-
catch
|
|
147
|
+
catch {
|
|
116
148
|
// ignore malformed
|
|
117
149
|
}
|
|
118
150
|
};
|
|
@@ -150,6 +182,43 @@ export class WebSocketTransport {
|
|
|
150
182
|
payload: { state },
|
|
151
183
|
}));
|
|
152
184
|
}
|
|
185
|
+
sendBootstrap(runId, status, message) {
|
|
186
|
+
if (!this.socket || this.socket.readyState !== WebSocket.OPEN)
|
|
187
|
+
return;
|
|
188
|
+
this.socket.send(JSON.stringify({
|
|
189
|
+
type: "bootstrap",
|
|
190
|
+
payload: {
|
|
191
|
+
runId,
|
|
192
|
+
status,
|
|
193
|
+
message,
|
|
194
|
+
},
|
|
195
|
+
}));
|
|
196
|
+
}
|
|
197
|
+
sendPreflight(runId, status, exists, message) {
|
|
198
|
+
if (!this.socket || this.socket.readyState !== WebSocket.OPEN)
|
|
199
|
+
return;
|
|
200
|
+
this.socket.send(JSON.stringify({
|
|
201
|
+
type: "preflight",
|
|
202
|
+
payload: {
|
|
203
|
+
runId,
|
|
204
|
+
status,
|
|
205
|
+
exists,
|
|
206
|
+
message,
|
|
207
|
+
},
|
|
208
|
+
}));
|
|
209
|
+
}
|
|
210
|
+
sendCommand(requestId, status, details) {
|
|
211
|
+
if (!this.socket || this.socket.readyState !== WebSocket.OPEN)
|
|
212
|
+
return;
|
|
213
|
+
this.socket.send(JSON.stringify({
|
|
214
|
+
type: "command",
|
|
215
|
+
payload: {
|
|
216
|
+
requestId,
|
|
217
|
+
status,
|
|
218
|
+
...details,
|
|
219
|
+
},
|
|
220
|
+
}));
|
|
221
|
+
}
|
|
153
222
|
close() {
|
|
154
223
|
this.isExplicitClose = true;
|
|
155
224
|
this.stopPingInterval();
|