@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 +22 -0
- package/bin/agent-workspace-linux.js +6 -2
- package/package.json +3 -8
- package/scripts/postinstall.js +158 -67
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
|
-
|
|
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.
|
|
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
|
-
|
|
28
|
-
],
|
|
29
|
-
"cpu": [
|
|
30
|
-
"x64",
|
|
31
|
-
"arm64"
|
|
32
|
-
],
|
|
26
|
+
"os": ["linux"],
|
|
27
|
+
"cpu": ["x64", "arm64"],
|
|
33
28
|
"engines": {
|
|
34
29
|
"node": ">=18"
|
|
35
30
|
},
|
package/scripts/postinstall.js
CHANGED
|
@@ -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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
74
|
-
if (statusCode >= 300 && statusCode < 400 && headers.location) {
|
|
75
|
-
if (hopsLeft === 0) {
|
|
159
|
+
if (statusCode !== 200) {
|
|
76
160
|
res.resume();
|
|
77
|
-
return reject(
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
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
|
-
//
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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.
|
|
324
|
+
fs.chmodSync(tmpPath, 0o755);
|
|
246
325
|
} catch (err) {
|
|
326
|
+
cleanupStagingDir();
|
|
247
327
|
console.error(
|
|
248
|
-
`agent-workspace-linux:
|
|
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
|
-
//
|
|
335
|
+
// Atomic rename: avoids leaving a half-written binary, and publishes a file
|
|
336
|
+
// that is already executable.
|
|
254
337
|
try {
|
|
255
|
-
fs.
|
|
338
|
+
fs.renameSync(tmpPath, destPath);
|
|
256
339
|
} catch (err) {
|
|
340
|
+
cleanupStagingDir();
|
|
257
341
|
console.error(
|
|
258
|
-
`agent-workspace-linux:
|
|
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,
|