@span-io/agent-link 0.1.3 → 0.1.5

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.
@@ -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
@@ -6,6 +6,7 @@ import { findAgentsOnPath, resolveAgentBinary } from "./agent.js";
6
6
  import { spawnAgentProcess as spawnAgentProcessAdvanced } from "./process-runner.js";
7
7
  import { LogBuffer } from "./log-buffer.js";
8
8
  import { compactPrompt, resolvePromptCompactionPolicy } from "./prompt-compact.js";
9
+ import { BootstrapRunner } from "./bootstrap.js";
9
10
  import { NoopTransport, WebSocketTransport } from "./transport.js";
10
11
  const args = parseArgs(process.argv.slice(2));
11
12
  if (args.list) {
@@ -45,6 +46,7 @@ const logBuffer = new LogBuffer();
45
46
  // Update map to hold SpawnedProcess which includes the child
46
47
  const activeAgents = new Map();
47
48
  const promptPolicy = resolvePromptCompactionPolicy();
49
+ const bootstrapRunner = new BootstrapRunner();
48
50
  let transport;
49
51
  try {
50
52
  transport = await createTransport({
@@ -61,6 +63,10 @@ catch (error) {
61
63
  }
62
64
  function handleControl(message) {
63
65
  const { action, agentId, payload } = message;
66
+ if (action === "bootstrap") {
67
+ void bootstrapRunner.run(payload ?? {}, transport);
68
+ return;
69
+ }
64
70
  if (!agentId)
65
71
  return;
66
72
  switch (action) {
@@ -161,35 +167,47 @@ function handleControl(message) {
161
167
  }
162
168
  }
163
169
  function setupAgentPiping(agentId, proc) {
170
+ const rawFlushSize = Number.parseInt(process.env.AGENT_LINK_STREAM_FLUSH_CHARS ?? "16384", 10);
171
+ const flushSize = Number.isFinite(rawFlushSize) && rawFlushSize > 0 ? rawFlushSize : 16384;
164
172
  const setupStream = (stream, name) => {
165
173
  if (!stream)
166
174
  return;
167
175
  let buffer = "";
168
176
  stream.setEncoding("utf8");
177
+ const flushChunk = (chunk) => {
178
+ if (chunk.length > 0) {
179
+ transport.sendLog(agentId, name, chunk);
180
+ }
181
+ };
169
182
  stream.on("data", (chunk) => {
170
183
  buffer += chunk;
171
184
  let index = buffer.indexOf("\n");
172
185
  while (index >= 0) {
173
186
  const line = buffer.slice(0, index + 1);
174
187
  buffer = buffer.slice(index + 1);
175
- transport.sendLog(agentId, name, line);
188
+ flushChunk(line);
176
189
  index = buffer.indexOf("\n");
177
190
  }
191
+ // Prevent unbounded memory growth when tools stream without newlines.
192
+ while (buffer.length >= flushSize) {
193
+ const part = buffer.slice(0, flushSize);
194
+ buffer = buffer.slice(flushSize);
195
+ flushChunk(part);
196
+ }
178
197
  });
179
198
  stream.on("end", () => {
180
- if (buffer.length > 0) {
181
- transport.sendLog(agentId, name, buffer);
182
- }
199
+ flushChunk(buffer);
200
+ buffer = "";
183
201
  });
184
202
  };
185
203
  setupStream(proc.child.stdout, "stdout");
186
204
  setupStream(proc.child.stderr, "stderr");
187
- proc.child.on("exit", (code, signal) => {
205
+ proc.child.on("exit", (_code, _signal) => {
188
206
  transport.sendStatus(agentId, "exited");
189
207
  activeAgents.delete(agentId);
190
208
  });
191
209
  proc.child.on("error", (error) => {
192
- transport.sendLog(agentId, "stderr", `Process error: ${error.message}\n`);
210
+ transport.sendLog(agentId, "stderr", "Process error: " + error.message + "\n");
193
211
  transport.sendStatus(agentId, "error");
194
212
  activeAgents.delete(agentId);
195
213
  });
@@ -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(dir, { recursive: true });
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 '${dir}': ${message}`);
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;
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,9 @@ export class NoopTransport {
10
32
  sendStatus(_agentId, _state) {
11
33
  return undefined;
12
34
  }
35
+ sendBootstrap(_runId, _status, _message) {
36
+ return undefined;
37
+ }
13
38
  close() {
14
39
  return undefined;
15
40
  }
@@ -52,6 +77,7 @@ export class WebSocketTransport {
52
77
  return;
53
78
  }
54
79
  const url = new URL("/api/ws", serverUrl);
80
+ assertSecureTransport(url);
55
81
  url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
56
82
  url.searchParams.set("token", token);
57
83
  return new Promise((resolve, reject) => {
@@ -62,7 +88,7 @@ export class WebSocketTransport {
62
88
  this.socket.onopen = null;
63
89
  this.socket.close();
64
90
  }
65
- console.log(`Connecting to ${url.toString()}...`);
91
+ console.log(`Connecting to ${toDisplayUrl(url)}...`);
66
92
  const socket = new WebSocket(url.toString());
67
93
  this.socket = socket;
68
94
  const onOpen = () => {
@@ -82,7 +108,7 @@ export class WebSocketTransport {
82
108
  };
83
109
  resolve();
84
110
  };
85
- const onFail = (err) => {
111
+ const onFail = (_err) => {
86
112
  console.error("WebSocket connection failed to open.");
87
113
  reject(new Error("WebSocket connection failed"));
88
114
  };
@@ -112,7 +138,7 @@ export class WebSocketTransport {
112
138
  this.options.onAck(message.id);
113
139
  }
114
140
  }
115
- catch (err) {
141
+ catch {
116
142
  // ignore malformed
117
143
  }
118
144
  };
@@ -150,6 +176,18 @@ export class WebSocketTransport {
150
176
  payload: { state },
151
177
  }));
152
178
  }
179
+ sendBootstrap(runId, status, message) {
180
+ if (!this.socket || this.socket.readyState !== WebSocket.OPEN)
181
+ return;
182
+ this.socket.send(JSON.stringify({
183
+ type: "bootstrap",
184
+ payload: {
185
+ runId,
186
+ status,
187
+ message,
188
+ },
189
+ }));
190
+ }
153
191
  close() {
154
192
  this.isExplicitClose = true;
155
193
  this.stopPingInterval();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@span-io/agent-link",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Secure bridge between Span (AI control plane) and local agent CLI tools.",
5
5
  "type": "module",
6
6
  "bin": {