@agent-sh/agent-workspace-linux 0.3.2 → 0.4.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/README.md CHANGED
@@ -19,6 +19,30 @@ then verifies the required `<asset>.sha256` sidecar before installing it.
19
19
  > node $(npm root -g)/@agent-sh/agent-workspace-linux/scripts/postinstall.js
20
20
  > ```
21
21
 
22
+ Release downloads accept absolute or relative HTTPS redirect locations, with
23
+ a maximum of five redirect hops. Malformed URLs and non-HTTPS redirect targets
24
+ fail the download; redirects do not bypass the required checksum verification.
25
+
26
+ Each binary or checksum download has a 60-second inactivity timeout while waiting
27
+ for response headers or body progress. The timer resets whenever bytes arrive, so
28
+ a slow transfer that is still making progress can continue. A separate 30-minute
29
+ hard ceiling only bounds pathological transfers. Timeout failures close the active
30
+ output and remove its partial file before a retry can begin.
31
+
32
+ Checksum sidecars must contain a valid 64-hex-digit SHA-256 digest and name the
33
+ expected release asset. Space and tab separators are accepted. A bare digest or
34
+ an entry naming a different asset is rejected.
35
+
36
+ The postinstall script applies executable permissions to the verified staging
37
+ file before replacing an installed binary. If permission preparation fails, it
38
+ reports failure and leaves the previously installed binary untouched.
39
+
40
+ Each install uses its own uniquely created staging directory beside the binary.
41
+ Concurrent installs do not remove or overwrite one another's downloads. Normal
42
+ completion and errors clean up the attempt's own staging directory; forced
43
+ termination can leave an abandoned `.staging-*` directory. A later install does
44
+ not delete it automatically, because another installer may still be using it.
45
+
22
46
  ## Usage
23
47
 
24
48
  Once installed, the server is on your PATH (the command stays unscoped):
@@ -40,6 +64,10 @@ It is an [MCP](https://modelcontextprotocol.io/) server that speaks JSON-RPC ove
40
64
  }
41
65
  ```
42
66
 
67
+ The command-line wrapper preserves the native program's exit code and terminating
68
+ signal. In particular, a child terminated by SIGINT causes the wrapper to
69
+ terminate by SIGINT too, rather than report exit code zero.
70
+
43
71
  ## Source and full documentation
44
72
 
45
73
  All source code, tool documentation, and issue tracking are at:
@@ -37,7 +37,8 @@ if (!fs.existsSync(binaryPath)) {
37
37
  // Ignore SIGINT in the launcher: let the child (MCP server) handle its own
38
38
  // shutdown. Without this both processes would receive the signal and the child
39
39
  // might not get a chance to flush stdio cleanly.
40
- process.on("SIGINT", () => {});
40
+ const ignoreSigint = () => {};
41
+ process.on("SIGINT", ignoreSigint);
41
42
 
42
43
  const child = spawn(binaryPath, process.argv.slice(2), {
43
44
  stdio: "inherit",
@@ -47,7 +48,10 @@ const child = spawn(binaryPath, process.argv.slice(2), {
47
48
  child.on("close", (code, signal) => {
48
49
  if (signal) {
49
50
  // Propagate signal so the parent's exit looks like a signalled death to
50
- // any caller (e.g. npm run scripts that check $?).
51
+ // any caller (e.g. npm run scripts that check $?). Drop the SIGINT handler
52
+ // first: while it is installed it would swallow a re-raised SIGINT and the
53
+ // launcher would exit 0, reporting success for a Ctrl-C'd child.
54
+ process.removeListener("SIGINT", ignoreSigint);
51
55
  process.kill(process.pid, signal);
52
56
  } else {
53
57
  process.exit(code ?? 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-sh/agent-workspace-linux",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Isolated Linux desktop workspaces for AI agents — MCP server binary wrapper.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -54,71 +54,214 @@ const checksumUrl = `${downloadUrl}.sha256`;
54
54
 
55
55
  const binDir = path.join(__dirname, "..", "bin");
56
56
  const destPath = path.join(binDir, "agent-workspace-linux");
57
- const tmpPath = destPath + ".tmp";
58
- const tmpChecksumPath = tmpPath + ".sha256";
57
+
58
+ // Concurrent installs share bin/, so staging must not use fixed file names: one
59
+ // install would delete or overwrite bytes another one is still verifying.
60
+ // mkdtemp allocates a unique directory atomically, inside bin/ so publishing
61
+ // stays a same-filesystem rename. Only the invocation that created a staging
62
+ // directory ever removes it — never another invocation's files. Catchable
63
+ // signals clean up before exit (below). A forced exit (SIGKILL/power loss)
64
+ // can still leave an abandoned directory, so the next install sweeps staging
65
+ // directories older than STALE_STAGING_MS; a live concurrent install is far
66
+ // younger than that (its download is bounded), so it is never touched.
67
+ let stagingDir = null;
68
+
69
+ function createStagingDir() {
70
+ stagingDir = fs.mkdtempSync(path.join(binDir, ".staging-"));
71
+ return stagingDir;
72
+ }
73
+
74
+ function cleanupStagingDir() {
75
+ if (stagingDir === null) return;
76
+ try {
77
+ fs.rmSync(stagingDir, { recursive: true, force: true });
78
+ } catch (_) {}
79
+ stagingDir = null;
80
+ }
81
+
82
+ // Catchable termination (Ctrl-C, SIGTERM from a supervisor) must not leak the
83
+ // staging directory. Clean up, then re-raise with default disposition so the
84
+ // exit status still reflects the signal.
85
+ if (require.main === module && typeof process.on === "function") {
86
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
87
+ process.on(signal, () => {
88
+ cleanupStagingDir();
89
+ process.removeAllListeners(signal);
90
+ process.kill(process.pid, signal);
91
+ });
92
+ }
93
+ }
94
+
95
+ // SIGKILL and power loss cannot be caught, so bound the leftovers: sweep
96
+ // staging directories that are clearly abandoned (older than STALE_STAGING_MS).
97
+ // Fresh ones may belong to a concurrent installer and are left alone.
98
+ const STALE_STAGING_MS = 60 * 60 * 1000;
99
+ function sweepStaleStagingDirs() {
100
+ let entries;
101
+ try {
102
+ entries = fs.readdirSync(binDir, { withFileTypes: true });
103
+ } catch (_) {
104
+ return;
105
+ }
106
+ const now = Date.now();
107
+ for (const entry of entries) {
108
+ if (!entry.isDirectory() || !entry.name.startsWith(".staging-")) continue;
109
+ const full = path.join(binDir, entry.name);
110
+ try {
111
+ if (now - fs.statSync(full).mtimeMs > STALE_STAGING_MS) {
112
+ fs.rmSync(full, { recursive: true, force: true });
113
+ }
114
+ } catch (_) {}
115
+ }
116
+ }
59
117
 
60
118
  // ── Download helper (follows redirects, max 5 hops) ─────────────────────────
61
119
 
120
+ // Fail a download after a full minute without useful network progress. This
121
+ // catches missing headers and stalled response bodies without penalizing a slow
122
+ // connection that is still delivering bytes. Keep a separate generous ceiling
123
+ // so a pathological transfer cannot keep npm install alive forever.
124
+ const DOWNLOAD_IDLE_TIMEOUT_MS = 60 * 1000;
125
+ const DOWNLOAD_MAX_DURATION_MS = 30 * 60 * 1000;
126
+
62
127
  /**
63
128
  * Download `url` to `tmpFile`, following up to `maxRedirects` 3xx responses.
64
- * Resolves when the file is fully written, rejects on error or bad status.
129
+ * Resolves when the file is fully written. Rejects on transport/status errors,
130
+ * after DOWNLOAD_IDLE_TIMEOUT_MS without headers/body progress, or when the
131
+ * generous DOWNLOAD_MAX_DURATION_MS hard ceiling is reached.
65
132
  */
66
133
  function download(url, tmpFile, maxRedirects = 5) {
67
134
  return new Promise((resolve, reject) => {
135
+ let settled = false;
136
+ let out = null;
137
+ let idleTimer = null;
138
+ let maxDurationTimer = null;
139
+ const requests = new Set();
140
+ const responses = new Set();
141
+
142
+ function settle(err) {
143
+ if (settled) return;
144
+ settled = true;
145
+ clearTimeout(idleTimer);
146
+ clearTimeout(maxDurationTimer);
147
+ if (!err) return resolve();
148
+
149
+ for (const req of requests) req.destroy();
150
+ for (const res of responses) res.destroy();
151
+
152
+ const removePartial = () => fs.unlink(tmpFile, () => reject(err));
153
+ if (out && !out.closed) {
154
+ out.once("close", removePartial);
155
+ out.destroy();
156
+ } else {
157
+ removePartial();
158
+ }
159
+ }
160
+
161
+ function resetIdleTimer() {
162
+ if (settled) return;
163
+ clearTimeout(idleTimer);
164
+ idleTimer = setTimeout(
165
+ () =>
166
+ settle(
167
+ new Error(
168
+ `No download progress for ${DOWNLOAD_IDLE_TIMEOUT_MS} ms downloading ${url}`
169
+ )
170
+ ),
171
+ DOWNLOAD_IDLE_TIMEOUT_MS
172
+ );
173
+ }
174
+
68
175
  function get(currentUrl, hopsLeft) {
69
- https
70
- .get(currentUrl, { headers: { "User-Agent": "node-fetch/postinstall" } }, (res) => {
71
- const { statusCode, headers } = res;
72
-
73
- // Follow redirects (GitHub releases always redirect to S3).
74
- if (statusCode >= 300 && statusCode < 400 && headers.location) {
75
- if (hopsLeft === 0) {
76
- res.resume();
77
- return reject(new Error(`Too many redirects downloading ${url}`));
176
+ if (settled) return;
177
+ try {
178
+ const req = https.get(
179
+ currentUrl,
180
+ { headers: { "User-Agent": "node-fetch/postinstall" } },
181
+ (res) => {
182
+ responses.add(res);
183
+ res.once("close", () => responses.delete(res));
184
+ res.on("error", settle);
185
+ if (settled) {
186
+ res.destroy();
187
+ return;
78
188
  }
79
- res.resume(); // drain and ignore body
80
- return get(headers.location, hopsLeft - 1);
81
- }
82
189
 
83
- if (statusCode !== 200) {
84
- res.resume();
85
- return reject(
86
- new Error(
87
- `Failed to download ${url}: HTTP ${statusCode}. ` +
88
- "Check that the release exists, includes the binary and .sha256 sidecar, " +
89
- "and the version in package.json matches."
90
- )
91
- );
190
+ // Response headers are progress. A successful body must continue to
191
+ // reset this timer whenever bytes arrive.
192
+ resetIdleTimer();
193
+
194
+ try {
195
+ const { statusCode, headers } = res;
196
+
197
+ if (statusCode >= 300 && statusCode < 400 && headers.location) {
198
+ res.resume();
199
+ if (hopsLeft === 0) {
200
+ settle(new Error(`Too many redirects downloading ${url}`));
201
+ return;
202
+ }
203
+
204
+ // Keep #98 intact: resolve relative redirects against the
205
+ // current URL and never permit a downgrade away from HTTPS.
206
+ let nextUrl;
207
+ try {
208
+ nextUrl = new URL(headers.location, currentUrl);
209
+ } catch (_) {
210
+ settle(new Error(`Invalid redirect location downloading ${url}`));
211
+ return;
212
+ }
213
+ if (nextUrl.protocol !== "https:") {
214
+ settle(new Error(`Refusing non-HTTPS redirect downloading ${url}`));
215
+ return;
216
+ }
217
+ get(nextUrl.href, hopsLeft - 1);
218
+ return;
219
+ }
220
+
221
+ if (statusCode !== 200) {
222
+ res.resume();
223
+ settle(
224
+ new Error(
225
+ `Failed to download ${url}: HTTP ${statusCode}. ` +
226
+ "Check that the release exists, includes the binary and .sha256 sidecar, " +
227
+ "and the version in package.json matches."
228
+ )
229
+ );
230
+ return;
231
+ }
232
+
233
+ out = fs.createWriteStream(tmpFile);
234
+ out.on("error", settle);
235
+ out.on("finish", () => out.close((err) => settle(err || null)));
236
+ res.on("data", resetIdleTimer);
237
+ res.pipe(out);
238
+ } catch (err) {
239
+ settle(err);
240
+ }
92
241
  }
93
-
94
- const out = fs.createWriteStream(tmpFile);
95
- res.pipe(out);
96
- out.on("finish", () => out.close(resolve));
97
- out.on("error", (err) => {
98
- fs.unlink(tmpFile, () => {}); // best-effort cleanup
99
- reject(err);
100
- });
101
- res.on("error", (err) => {
102
- fs.unlink(tmpFile, () => {});
103
- reject(err);
104
- });
105
- })
106
- .on("error", (err) => {
107
- fs.unlink(tmpFile, () => {});
108
- reject(err);
109
- });
242
+ );
243
+ requests.add(req);
244
+ req.once("close", () => requests.delete(req));
245
+ req.on("error", settle);
246
+ } catch (err) {
247
+ settle(err);
248
+ }
110
249
  }
111
250
 
251
+ maxDurationTimer = setTimeout(
252
+ () =>
253
+ settle(
254
+ new Error(
255
+ `Download exceeded ${DOWNLOAD_MAX_DURATION_MS} ms downloading ${url}`
256
+ )
257
+ ),
258
+ DOWNLOAD_MAX_DURATION_MS
259
+ );
260
+ resetIdleTimer();
112
261
  get(url, maxRedirects);
113
262
  });
114
263
  }
115
264
 
116
- function removeFileQuietly(file) {
117
- try {
118
- fs.unlinkSync(file);
119
- } catch (_) {}
120
- }
121
-
122
265
  function parseSha256Sidecar(contents, expectedAssetName) {
123
266
  const lines = contents
124
267
  .split(/\r?\n/)
@@ -137,18 +280,18 @@ function parseSha256Sidecar(contents, expectedAssetName) {
137
280
  if (!/^[a-fA-F0-9]{64}(\s+\*?(\S.*)?)?$/.test(trimmed)) {
138
281
  throw new Error(`invalid checksum sidecar line: ${line}`);
139
282
  }
140
- const spaceIdx = trimmed.indexOf(' ');
141
- let checksum, fileNamePart;
142
- if (spaceIdx === -1) {
143
- checksum = trimmed;
144
- fileNamePart = null;
145
- } else {
146
- checksum = trimmed.slice(0, spaceIdx);
147
- fileNamePart = trimmed.slice(spaceIdx + 1).replace(/^\*?\s*/, '');
148
- }
283
+ // The line matched above, so the first 64 chars are the checksum; the rest
284
+ // is "<whitespace>[*]<filename>". Scan forward (no regex backtracking) so
285
+ // any whitespace delimiter works, not just a single space.
286
+ const checksum = trimmed.slice(0, 64);
287
+ let i = 64;
288
+ while (i < trimmed.length && /\s/.test(trimmed[i])) i++;
289
+ if (trimmed[i] === '*') i++; // sha256sum binary-mode marker
290
+ while (i < trimmed.length && /\s/.test(trimmed[i])) i++;
291
+ const fileNamePart = trimmed.slice(i);
149
292
  candidates.push({
150
293
  checksum: checksum.toLowerCase(),
151
- fileName: fileNamePart && fileNamePart.length ? fileNamePart.trim() : null,
294
+ fileName: fileNamePart.length ? fileNamePart : null,
152
295
  });
153
296
  }
154
297
 
@@ -196,9 +339,11 @@ async function main() {
196
339
  // be defensive for edge-case installs).
197
340
  fs.mkdirSync(binDir, { recursive: true });
198
341
 
199
- // Remove stale tmp file from a previous interrupted install.
200
- removeFileQuietly(tmpPath);
201
- removeFileQuietly(tmpChecksumPath);
342
+ // Staging owned by this invocation only.
343
+ sweepStaleStagingDirs();
344
+ const stagingRoot = createStagingDir();
345
+ const tmpPath = path.join(stagingRoot, assetName);
346
+ const tmpChecksumPath = `${tmpPath}.sha256`;
202
347
 
203
348
  console.log(
204
349
  `agent-workspace-linux: downloading ${assetName} v${version}…`
@@ -209,7 +354,7 @@ async function main() {
209
354
  await download(downloadUrl, tmpPath);
210
355
  } catch (err) {
211
356
  // Clean up in case the file was partially created.
212
- removeFileQuietly(tmpPath);
357
+ cleanupStagingDir();
213
358
  // The download error text embeds the request URL (built from
214
359
  // package.json's version + the target triple). Log a fixed message rather
215
360
  // than interpolating that value, to avoid a log-injection sink; the URL
@@ -229,8 +374,7 @@ async function main() {
229
374
  await download(checksumUrl, tmpChecksumPath);
230
375
  await verifyChecksum(tmpPath, tmpChecksumPath, assetName);
231
376
  } catch (err) {
232
- removeFileQuietly(tmpPath);
233
- removeFileQuietly(tmpChecksumPath);
377
+ cleanupStagingDir();
234
378
  // Do not interpolate raw err.message (from untrusted sidecar/network) into logs.
235
379
  // This was flagged as log-injection (js/log-injection).
236
380
  console.error(
@@ -238,40 +382,54 @@ async function main() {
238
382
  );
239
383
  process.exit(1);
240
384
  }
241
- removeFileQuietly(tmpChecksumPath);
242
385
 
243
- // Atomic rename: avoids leaving a half-written binary.
386
+ // Mark the verified staging file executable *before* publishing it. Doing
387
+ // this after the rename would expose a window where readers see a
388
+ // non-executable binary at destPath, and a chmod failure there would already
389
+ // have destroyed the previously installed, runnable binary.
244
390
  try {
245
- fs.renameSync(tmpPath, destPath);
391
+ fs.chmodSync(tmpPath, 0o755);
246
392
  } catch (err) {
393
+ cleanupStagingDir();
247
394
  console.error(
248
- `agent-workspace-linux: could not move binary into place — ${err.message}`
395
+ `agent-workspace-linux: chmod failed — ${err.message}. ` +
396
+ "The previously installed binary (if any) was left untouched; " +
397
+ "re-run installation."
249
398
  );
250
399
  process.exit(1);
251
400
  }
252
401
 
253
- // Mark executable.
402
+ // Atomic rename: avoids leaving a half-written binary, and publishes a file
403
+ // that is already executable.
254
404
  try {
255
- fs.chmodSync(destPath, 0o755);
405
+ fs.renameSync(tmpPath, destPath);
256
406
  } catch (err) {
407
+ cleanupStagingDir();
257
408
  console.error(
258
- `agent-workspace-linux: chmod failed — ${err.message}. ` +
259
- `Try: chmod 755 ${destPath}`
409
+ `agent-workspace-linux: could not move binary into place — ${err.message}`
260
410
  );
261
411
  process.exit(1);
262
412
  }
263
413
 
414
+ cleanupStagingDir();
415
+
264
416
  console.log(`agent-workspace-linux: binary installed at ${destPath}`);
265
417
  }
266
418
 
267
419
  if (require.main === module) {
268
420
  main().catch((err) => {
421
+ // process.exit() is immediate, so staging must be removed before it: every
422
+ // error path above cleans up synchronously, and this handles unexpected
423
+ // throws in between.
424
+ cleanupStagingDir();
269
425
  console.error(`agent-workspace-linux: postinstall failed — ${err.message}`);
270
426
  process.exit(1);
271
427
  });
272
428
  }
273
429
 
274
430
  module.exports = {
431
+ sweepStaleStagingDirs,
432
+ STALE_STAGING_MS,
275
433
  parseSha256Sidecar,
276
434
  fileSha256,
277
435
  verifyChecksum,