@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 +28 -0
- package/bin/agent-workspace-linux.js +6 -2
- package/package.json +1 -1
- package/scripts/postinstall.js +229 -71
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
|
-
|
|
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
package/scripts/postinstall.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
|
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
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
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
|
-
//
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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.
|
|
391
|
+
fs.chmodSync(tmpPath, 0o755);
|
|
246
392
|
} catch (err) {
|
|
393
|
+
cleanupStagingDir();
|
|
247
394
|
console.error(
|
|
248
|
-
`agent-workspace-linux:
|
|
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
|
-
//
|
|
402
|
+
// Atomic rename: avoids leaving a half-written binary, and publishes a file
|
|
403
|
+
// that is already executable.
|
|
254
404
|
try {
|
|
255
|
-
fs.
|
|
405
|
+
fs.renameSync(tmpPath, destPath);
|
|
256
406
|
} catch (err) {
|
|
407
|
+
cleanupStagingDir();
|
|
257
408
|
console.error(
|
|
258
|
-
`agent-workspace-linux:
|
|
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,
|