@agent-sh/agent-workspace-linux 0.3.1 → 0.3.3

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,24 @@ 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
+ Checksum sidecars must contain a valid 64-hex-digit SHA-256 digest and name the
27
+ expected release asset. Space and tab separators are accepted. A bare digest or
28
+ an entry naming a different asset is rejected.
29
+
30
+ The postinstall script applies executable permissions to the verified staging
31
+ file before replacing an installed binary. If permission preparation fails, it
32
+ reports failure and leaves the previously installed binary untouched.
33
+
34
+ Each install uses its own uniquely created staging directory beside the binary.
35
+ Concurrent installs do not remove or overwrite one another's downloads. Normal
36
+ completion and errors clean up the attempt's own staging directory; forced
37
+ termination can leave an abandoned `.staging-*` directory. A later install does
38
+ not delete it automatically, because another installer may still be using it.
39
+
22
40
  ## Usage
23
41
 
24
42
  Once installed, the server is on your PATH (the command stays unscoped):
@@ -40,6 +58,10 @@ It is an [MCP](https://modelcontextprotocol.io/) server that speaks JSON-RPC ove
40
58
  }
41
59
  ```
42
60
 
61
+ The command-line wrapper preserves the native program's exit code and terminating
62
+ signal. In particular, a child terminated by SIGINT causes the wrapper to
63
+ terminate by SIGINT too, rather than report exit code zero.
64
+
43
65
  ## Source and full documentation
44
66
 
45
67
  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.1",
3
+ "version": "0.3.3",
4
4
  "description": "Isolated Linux desktop workspaces for AI agents — MCP server binary wrapper.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -23,13 +23,8 @@
23
23
  "workspace",
24
24
  "agent"
25
25
  ],
26
- "os": [
27
- "linux"
28
- ],
29
- "cpu": [
30
- "x64",
31
- "arm64"
32
- ],
26
+ "os": ["linux"],
27
+ "cpu": ["x64", "arm64"],
33
28
  "engines": {
34
29
  "node": ">=18"
35
30
  },
@@ -54,8 +54,66 @@ 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
 
@@ -66,59 +124,77 @@ const tmpChecksumPath = tmpPath + ".sha256";
66
124
  function download(url, tmpFile, maxRedirects = 5) {
67
125
  return new Promise((resolve, reject) => {
68
126
  function get(currentUrl, hopsLeft) {
69
- https
70
- .get(currentUrl, { headers: { "User-Agent": "node-fetch/postinstall" } }, (res) => {
71
- const { statusCode, headers } = res;
127
+ try {
128
+ https
129
+ .get(currentUrl, { headers: { "User-Agent": "node-fetch/postinstall" } }, (res) => {
130
+ const { statusCode, headers } = res;
131
+
132
+ // Follow redirects (GitHub releases always redirect to S3).
133
+ if (statusCode >= 300 && statusCode < 400 && headers.location) {
134
+ if (hopsLeft === 0) {
135
+ res.resume();
136
+ return reject(new Error(`Too many redirects downloading ${url}`));
137
+ }
138
+ res.resume(); // drain and ignore body
139
+
140
+ // Location may be relative, query-only or protocol-relative
141
+ // (RFC 7231 allows all three), so resolve it against the URL we
142
+ // just requested instead of handing it to https.get verbatim.
143
+ let nextUrl;
144
+ try {
145
+ nextUrl = new URL(headers.location, currentUrl);
146
+ } catch (_) {
147
+ return reject(
148
+ new Error(`Invalid redirect location downloading ${url}`)
149
+ );
150
+ }
151
+ if (nextUrl.protocol !== "https:") {
152
+ return reject(
153
+ new Error(`Refusing non-HTTPS redirect downloading ${url}`)
154
+ );
155
+ }
156
+ return get(nextUrl.href, hopsLeft - 1);
157
+ }
72
158
 
73
- // Follow redirects (GitHub releases always redirect to S3).
74
- if (statusCode >= 300 && statusCode < 400 && headers.location) {
75
- if (hopsLeft === 0) {
159
+ if (statusCode !== 200) {
76
160
  res.resume();
77
- return reject(new Error(`Too many redirects downloading ${url}`));
161
+ return reject(
162
+ new Error(
163
+ `Failed to download ${url}: HTTP ${statusCode}. ` +
164
+ "Check that the release exists, includes the binary and .sha256 sidecar, " +
165
+ "and the version in package.json matches."
166
+ )
167
+ );
78
168
  }
79
- res.resume(); // drain and ignore body
80
- return get(headers.location, hopsLeft - 1);
81
- }
82
-
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
- );
92
- }
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) => {
169
+
170
+ const out = fs.createWriteStream(tmpFile);
171
+ res.pipe(out);
172
+ out.on("finish", () => out.close(resolve));
173
+ out.on("error", (err) => {
174
+ fs.unlink(tmpFile, () => {}); // best-effort cleanup
175
+ reject(err);
176
+ });
177
+ res.on("error", (err) => {
178
+ fs.unlink(tmpFile, () => {});
179
+ reject(err);
180
+ });
181
+ })
182
+ .on("error", (err) => {
102
183
  fs.unlink(tmpFile, () => {});
103
184
  reject(err);
104
185
  });
105
- })
106
- .on("error", (err) => {
107
- fs.unlink(tmpFile, () => {});
108
- reject(err);
109
- });
186
+ } catch (err) {
187
+ // https.get throws synchronously on an unusable target; from inside a
188
+ // response callback that would be an uncaught exception, so reject.
189
+ fs.unlink(tmpFile, () => {});
190
+ reject(err);
191
+ }
110
192
  }
111
193
 
112
194
  get(url, maxRedirects);
113
195
  });
114
196
  }
115
197
 
116
- function removeFileQuietly(file) {
117
- try {
118
- fs.unlinkSync(file);
119
- } catch (_) {}
120
- }
121
-
122
198
  function parseSha256Sidecar(contents, expectedAssetName) {
123
199
  const lines = contents
124
200
  .split(/\r?\n/)
@@ -137,18 +213,18 @@ function parseSha256Sidecar(contents, expectedAssetName) {
137
213
  if (!/^[a-fA-F0-9]{64}(\s+\*?(\S.*)?)?$/.test(trimmed)) {
138
214
  throw new Error(`invalid checksum sidecar line: ${line}`);
139
215
  }
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
- }
216
+ // The line matched above, so the first 64 chars are the checksum; the rest
217
+ // is "<whitespace>[*]<filename>". Scan forward (no regex backtracking) so
218
+ // any whitespace delimiter works, not just a single space.
219
+ const checksum = trimmed.slice(0, 64);
220
+ let i = 64;
221
+ while (i < trimmed.length && /\s/.test(trimmed[i])) i++;
222
+ if (trimmed[i] === '*') i++; // sha256sum binary-mode marker
223
+ while (i < trimmed.length && /\s/.test(trimmed[i])) i++;
224
+ const fileNamePart = trimmed.slice(i);
149
225
  candidates.push({
150
226
  checksum: checksum.toLowerCase(),
151
- fileName: fileNamePart && fileNamePart.length ? fileNamePart.trim() : null,
227
+ fileName: fileNamePart.length ? fileNamePart : null,
152
228
  });
153
229
  }
154
230
 
@@ -196,9 +272,11 @@ async function main() {
196
272
  // be defensive for edge-case installs).
197
273
  fs.mkdirSync(binDir, { recursive: true });
198
274
 
199
- // Remove stale tmp file from a previous interrupted install.
200
- removeFileQuietly(tmpPath);
201
- removeFileQuietly(tmpChecksumPath);
275
+ // Staging owned by this invocation only.
276
+ sweepStaleStagingDirs();
277
+ const stagingRoot = createStagingDir();
278
+ const tmpPath = path.join(stagingRoot, assetName);
279
+ const tmpChecksumPath = `${tmpPath}.sha256`;
202
280
 
203
281
  console.log(
204
282
  `agent-workspace-linux: downloading ${assetName} v${version}…`
@@ -209,7 +287,7 @@ async function main() {
209
287
  await download(downloadUrl, tmpPath);
210
288
  } catch (err) {
211
289
  // Clean up in case the file was partially created.
212
- removeFileQuietly(tmpPath);
290
+ cleanupStagingDir();
213
291
  // The download error text embeds the request URL (built from
214
292
  // package.json's version + the target triple). Log a fixed message rather
215
293
  // than interpolating that value, to avoid a log-injection sink; the URL
@@ -229,8 +307,7 @@ async function main() {
229
307
  await download(checksumUrl, tmpChecksumPath);
230
308
  await verifyChecksum(tmpPath, tmpChecksumPath, assetName);
231
309
  } catch (err) {
232
- removeFileQuietly(tmpPath);
233
- removeFileQuietly(tmpChecksumPath);
310
+ cleanupStagingDir();
234
311
  // Do not interpolate raw err.message (from untrusted sidecar/network) into logs.
235
312
  // This was flagged as log-injection (js/log-injection).
236
313
  console.error(
@@ -238,40 +315,54 @@ async function main() {
238
315
  );
239
316
  process.exit(1);
240
317
  }
241
- removeFileQuietly(tmpChecksumPath);
242
318
 
243
- // Atomic rename: avoids leaving a half-written binary.
319
+ // Mark the verified staging file executable *before* publishing it. Doing
320
+ // this after the rename would expose a window where readers see a
321
+ // non-executable binary at destPath, and a chmod failure there would already
322
+ // have destroyed the previously installed, runnable binary.
244
323
  try {
245
- fs.renameSync(tmpPath, destPath);
324
+ fs.chmodSync(tmpPath, 0o755);
246
325
  } catch (err) {
326
+ cleanupStagingDir();
247
327
  console.error(
248
- `agent-workspace-linux: could not move binary into place — ${err.message}`
328
+ `agent-workspace-linux: chmod failed — ${err.message}. ` +
329
+ "The previously installed binary (if any) was left untouched; " +
330
+ "re-run installation."
249
331
  );
250
332
  process.exit(1);
251
333
  }
252
334
 
253
- // Mark executable.
335
+ // Atomic rename: avoids leaving a half-written binary, and publishes a file
336
+ // that is already executable.
254
337
  try {
255
- fs.chmodSync(destPath, 0o755);
338
+ fs.renameSync(tmpPath, destPath);
256
339
  } catch (err) {
340
+ cleanupStagingDir();
257
341
  console.error(
258
- `agent-workspace-linux: chmod failed — ${err.message}. ` +
259
- `Try: chmod 755 ${destPath}`
342
+ `agent-workspace-linux: could not move binary into place — ${err.message}`
260
343
  );
261
344
  process.exit(1);
262
345
  }
263
346
 
347
+ cleanupStagingDir();
348
+
264
349
  console.log(`agent-workspace-linux: binary installed at ${destPath}`);
265
350
  }
266
351
 
267
352
  if (require.main === module) {
268
353
  main().catch((err) => {
354
+ // process.exit() is immediate, so staging must be removed before it: every
355
+ // error path above cleans up synchronously, and this handles unexpected
356
+ // throws in between.
357
+ cleanupStagingDir();
269
358
  console.error(`agent-workspace-linux: postinstall failed — ${err.message}`);
270
359
  process.exit(1);
271
360
  });
272
361
  }
273
362
 
274
363
  module.exports = {
364
+ sweepStaleStagingDirs,
365
+ STALE_STAGING_MS,
275
366
  parseSha256Sidecar,
276
367
  fileSha256,
277
368
  verifyChecksum,