@span-io/agent-link 0.1.5 → 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/index.js +48 -0
- package/dist/remote-command.js +203 -0
- package/dist/transport.js +31 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,12 +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";
|
|
9
12
|
import { BootstrapRunner } from "./bootstrap.js";
|
|
13
|
+
import { RemoteCommandRunner } from "./remote-command.js";
|
|
10
14
|
import { NoopTransport, WebSocketTransport } from "./transport.js";
|
|
11
15
|
const args = parseArgs(process.argv.slice(2));
|
|
12
16
|
if (args.list) {
|
|
@@ -47,6 +51,7 @@ const logBuffer = new LogBuffer();
|
|
|
47
51
|
const activeAgents = new Map();
|
|
48
52
|
const promptPolicy = resolvePromptCompactionPolicy();
|
|
49
53
|
const bootstrapRunner = new BootstrapRunner();
|
|
54
|
+
const remoteCommandRunner = new RemoteCommandRunner();
|
|
50
55
|
let transport;
|
|
51
56
|
try {
|
|
52
57
|
transport = await createTransport({
|
|
@@ -67,6 +72,14 @@ function handleControl(message) {
|
|
|
67
72
|
void bootstrapRunner.run(payload ?? {}, transport);
|
|
68
73
|
return;
|
|
69
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
|
+
}
|
|
70
83
|
if (!agentId)
|
|
71
84
|
return;
|
|
72
85
|
switch (action) {
|
|
@@ -166,6 +179,41 @@ function handleControl(message) {
|
|
|
166
179
|
}
|
|
167
180
|
}
|
|
168
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
|
+
}
|
|
169
217
|
function setupAgentPiping(agentId, proc) {
|
|
170
218
|
const rawFlushSize = Number.parseInt(process.env.AGENT_LINK_STREAM_FLUSH_CHARS ?? "16384", 10);
|
|
171
219
|
const flushSize = Number.isFinite(rawFlushSize) && rawFlushSize > 0 ? rawFlushSize : 16384;
|
|
@@ -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
|
@@ -35,6 +35,12 @@ export class NoopTransport {
|
|
|
35
35
|
sendBootstrap(_runId, _status, _message) {
|
|
36
36
|
return undefined;
|
|
37
37
|
}
|
|
38
|
+
sendPreflight(_runId, _status, _exists, _message) {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
sendCommand(_requestId, _status, _details) {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
38
44
|
close() {
|
|
39
45
|
return undefined;
|
|
40
46
|
}
|
|
@@ -188,6 +194,31 @@ export class WebSocketTransport {
|
|
|
188
194
|
},
|
|
189
195
|
}));
|
|
190
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
|
+
}
|
|
191
222
|
close() {
|
|
192
223
|
this.isExplicitClose = true;
|
|
193
224
|
this.stopPingInterval();
|