@span-io/agent-link 0.1.5 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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) {
@@ -122,6 +135,9 @@ function handleControl(message) {
122
135
  return;
123
136
  }
124
137
  const optionsArgs = [...(payload?.args || args.agentArgs)];
138
+ const promptMode = payload?.promptMode === "stdin" || payload?.promptMode === "args"
139
+ ? payload.promptMode
140
+ : undefined;
125
141
  const rawPrompt = payload?.prompt || "";
126
142
  const normalized = compactPrompt(rawPrompt, promptPolicy);
127
143
  if (normalized.action !== "none") {
@@ -135,7 +151,8 @@ function handleControl(message) {
135
151
  },
136
152
  prompt: normalized.prompt,
137
153
  optionsArgs,
138
- executablePath: agentCandidate.path
154
+ executablePath: agentCandidate.path,
155
+ promptModeOverride: promptMode,
139
156
  });
140
157
  activeAgents.set(agentId, proc);
141
158
  console.log(`[${new Date().toLocaleTimeString()}] Spawning agent: ${agentCandidate.name} (${targetModel || "default model"})`);
@@ -166,9 +183,45 @@ function handleControl(message) {
166
183
  }
167
184
  }
168
185
  }
186
+ async function runRemoteFileCheck(payload, transport) {
187
+ const runId = typeof payload.runId === "string" ? payload.runId.trim() : "";
188
+ if (!runId) {
189
+ return;
190
+ }
191
+ const workingDirectory = typeof payload.workingDirectory === "string" ? payload.workingDirectory.trim() : "";
192
+ if (!workingDirectory) {
193
+ transport.sendPreflight(runId, "error", false, "Missing workingDirectory.");
194
+ return;
195
+ }
196
+ const filePath = typeof payload.filePath === "string" && payload.filePath.trim().length > 0
197
+ ? payload.filePath.trim()
198
+ : "AGENTS.md";
199
+ const baseDir = path.resolve(workingDirectory);
200
+ const target = path.resolve(baseDir, filePath);
201
+ const relative = path.relative(baseDir, target);
202
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
203
+ transport.sendPreflight(runId, "error", false, "Invalid filePath outside working directory.");
204
+ return;
205
+ }
206
+ try {
207
+ await access(target, fsConstants.F_OK);
208
+ transport.sendPreflight(runId, "complete", true, filePath + " exists.");
209
+ }
210
+ catch {
211
+ transport.sendPreflight(runId, "complete", false, filePath + " not found.");
212
+ }
213
+ }
214
+ async function runRemoteCommand(payload, transport) {
215
+ await remoteCommandRunner.run(payload, {
216
+ sendCommand: (requestId, status, details) => {
217
+ transport.sendCommand(requestId, status, details);
218
+ },
219
+ });
220
+ }
169
221
  function setupAgentPiping(agentId, proc) {
170
222
  const rawFlushSize = Number.parseInt(process.env.AGENT_LINK_STREAM_FLUSH_CHARS ?? "16384", 10);
171
223
  const flushSize = Number.isFinite(rawFlushSize) && rawFlushSize > 0 ? rawFlushSize : 16384;
224
+ const echoStdio = process.env.AGENT_LINK_ECHO_STDIO === "1";
172
225
  const setupStream = (stream, name) => {
173
226
  if (!stream)
174
227
  return;
@@ -176,6 +229,15 @@ function setupAgentPiping(agentId, proc) {
176
229
  stream.setEncoding("utf8");
177
230
  const flushChunk = (chunk) => {
178
231
  if (chunk.length > 0) {
232
+ if (echoStdio) {
233
+ const prefix = `[agent-link][${agentId}][${name}] `;
234
+ const lines = chunk.split("\n");
235
+ for (const line of lines) {
236
+ if (line.length > 0) {
237
+ console.log(`${prefix}${line}`);
238
+ }
239
+ }
240
+ }
179
241
  transport.sendLog(agentId, name, chunk);
180
242
  }
181
243
  };
@@ -8,7 +8,64 @@ const DEFAULT_PROMPT_FLAG = "";
8
8
  const DEFAULT_TTY_MODE = "auto";
9
9
  const DEFAULT_TTY_TERM = "dumb";
10
10
  function splitArgs(raw) {
11
- return raw.trim() === "" ? [] : raw.trim().split(/\s+/g);
11
+ if (raw.trim() === "") {
12
+ return [];
13
+ }
14
+ const args = [];
15
+ let current = "";
16
+ let quote = null;
17
+ let escaped = false;
18
+ for (const char of raw) {
19
+ if (escaped) {
20
+ current += char;
21
+ escaped = false;
22
+ continue;
23
+ }
24
+ if (quote === "'") {
25
+ if (char === "'") {
26
+ quote = null;
27
+ }
28
+ else {
29
+ current += char;
30
+ }
31
+ continue;
32
+ }
33
+ if (quote === '"') {
34
+ if (char === '"') {
35
+ quote = null;
36
+ continue;
37
+ }
38
+ if (char === "\\") {
39
+ escaped = true;
40
+ continue;
41
+ }
42
+ current += char;
43
+ continue;
44
+ }
45
+ if (char === "'" || char === '"') {
46
+ quote = char;
47
+ continue;
48
+ }
49
+ if (char === "\\") {
50
+ escaped = true;
51
+ continue;
52
+ }
53
+ if (/\s/.test(char)) {
54
+ if (current !== "") {
55
+ args.push(current);
56
+ current = "";
57
+ }
58
+ continue;
59
+ }
60
+ current += char;
61
+ }
62
+ if (escaped) {
63
+ current += "\\";
64
+ }
65
+ if (current !== "") {
66
+ args.push(current);
67
+ }
68
+ return args;
12
69
  }
13
70
  function shellQuote(value) {
14
71
  // Simple single-quote wrapping for display purposes
@@ -121,7 +178,7 @@ export function buildCommand({ agent, prompt, optionsArgs = [], executablePath }
121
178
  const rawArgs = process.env.CODEX_ARGS ?? DEFAULT_CODEX_ARGS;
122
179
  const promptFlag = process.env.CODEX_PROMPT_FLAG ?? DEFAULT_PROMPT_FLAG;
123
180
  // Determine Prompt Mode
124
- const envPromptMode = process.env.CODEX_PROMPT_MODE === "stdin" ? "stdin" : "args";
181
+ const envPromptMode = process.env.CODEX_PROMPT_MODE === "args" ? "args" : "stdin";
125
182
  const promptMode = promptModeOverride ?? envPromptMode;
126
183
  const extraArgs = optionsArgs.filter((arg) => arg.trim() !== "");
127
184
  const args = splitArgs(rawArgs);
@@ -163,12 +220,12 @@ export function spawnAgentProcess(config) {
163
220
  const sanitizedOptions = optionsArgs;
164
221
  const allowedRoots = parseAllowedRoots();
165
222
  const spawnWithMode = (promptModeOverride) => {
166
- const { command, args, promptMode } = buildCommand({ ...config, optionsArgs: sanitizedOptions }, promptModeOverride);
223
+ const { command, args, promptMode } = buildCommand({ ...config, optionsArgs: sanitizedOptions }, promptModeOverride ?? config.promptModeOverride);
167
224
  // Ensure requested project/working directories exist before launching CLI.
168
225
  ensureDirectoriesExist(collectDirectoriesFromArgs(args), allowedRoots);
169
226
  const ttyMode = process.env.CODEX_TTY_MODE ?? DEFAULT_TTY_MODE;
170
227
  const hasExec = args.includes("exec");
171
- const useScriptWrapper = ttyMode === "script" || (ttyMode === "auto" && hasExec);
228
+ const useScriptWrapper = promptMode === "stdin" ? false : ttyMode === "script" || (ttyMode === "auto" && hasExec);
172
229
  const ttyTerm = process.env.CODEX_TTY_TERM ?? DEFAULT_TTY_TERM;
173
230
  const defaultCwd = process.cwd(); // Client uses current working dir
174
231
  const workingDir = process.env.CODEX_CWD ?? defaultCwd;
@@ -197,7 +254,8 @@ export function spawnAgentProcess(config) {
197
254
  }
198
255
  });
199
256
  if (promptMode === "stdin") {
200
- child.stdin?.write(config.prompt);
257
+ const promptPayload = config.prompt.endsWith("\n") ? config.prompt : `${config.prompt}\n`;
258
+ child.stdin?.write(promptPayload);
201
259
  child.stdin?.end();
202
260
  }
203
261
  return {
@@ -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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@span-io/agent-link",
3
- "version": "0.1.5",
3
+ "version": "0.2.1",
4
4
  "description": "Secure bridge between Span (AI control plane) and local agent CLI tools.",
5
5
  "type": "module",
6
6
  "bin": {