@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.
@@ -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;
@@ -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;
@@ -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.toString()}...`);
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 = (err) => {
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 (err) {
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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@span-io/agent-link",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "description": "Secure bridge between Span (AI control plane) and local agent CLI tools.",
5
5
  "type": "module",
6
6
  "bin": {