@agent-sh/agent-workspace-linux 0.3.3 → 0.5.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
@@ -23,6 +23,12 @@ Release downloads accept absolute or relative HTTPS redirect locations, with
23
23
  a maximum of five redirect hops. Malformed URLs and non-HTTPS redirect targets
24
24
  fail the download; redirects do not bypass the required checksum verification.
25
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
+
26
32
  Checksum sidecars must contain a valid 64-hex-digit SHA-256 digest and name the
27
33
  expected release asset. Space and tab separators are accepted. A bare digest or
28
34
  an entry naming a different asset is rejected.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-sh/agent-workspace-linux",
3
- "version": "0.3.3",
3
+ "version": "0.5.0",
4
4
  "description": "Isolated Linux desktop workspaces for AI agents — MCP server binary wrapper.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -23,8 +23,13 @@
23
23
  "workspace",
24
24
  "agent"
25
25
  ],
26
- "os": ["linux"],
27
- "cpu": ["x64", "arm64"],
26
+ "os": [
27
+ "linux"
28
+ ],
29
+ "cpu": [
30
+ "x64",
31
+ "arm64"
32
+ ],
28
33
  "engines": {
29
34
  "node": ">=18"
30
35
  },
@@ -117,80 +117,147 @@ function sweepStaleStagingDirs() {
117
117
 
118
118
  // ── Download helper (follows redirects, max 5 hops) ─────────────────────────
119
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
+
120
127
  /**
121
128
  * Download `url` to `tmpFile`, following up to `maxRedirects` 3xx responses.
122
- * 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.
123
132
  */
124
133
  function download(url, tmpFile, maxRedirects = 5) {
125
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
+
126
175
  function get(currentUrl, hopsLeft) {
176
+ if (settled) return;
127
177
  try {
128
- https
129
- .get(currentUrl, { headers: { "User-Agent": "node-fetch/postinstall" } }, (res) => {
130
- const { statusCode, headers } = res;
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;
188
+ }
189
+
190
+ // Response headers are progress. A successful body must continue to
191
+ // reset this timer whenever bytes arrive.
192
+ resetIdleTimer();
131
193
 
132
- // Follow redirects (GitHub releases always redirect to S3).
133
- if (statusCode >= 300 && statusCode < 400 && headers.location) {
134
- if (hopsLeft === 0) {
194
+ try {
195
+ const { statusCode, headers } = res;
196
+
197
+ if (statusCode >= 300 && statusCode < 400 && headers.location) {
135
198
  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
- );
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;
150
219
  }
151
- if (nextUrl.protocol !== "https:") {
152
- return reject(
153
- new Error(`Refusing non-HTTPS redirect downloading ${url}`)
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
+ )
154
229
  );
230
+ return;
155
231
  }
156
- return get(nextUrl.href, hopsLeft - 1);
157
- }
158
232
 
159
- if (statusCode !== 200) {
160
- res.resume();
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
- );
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);
168
240
  }
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) => {
183
- fs.unlink(tmpFile, () => {});
184
- reject(err);
185
- });
241
+ }
242
+ );
243
+ requests.add(req);
244
+ req.once("close", () => requests.delete(req));
245
+ req.on("error", settle);
186
246
  } 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);
247
+ settle(err);
191
248
  }
192
249
  }
193
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();
194
261
  get(url, maxRedirects);
195
262
  });
196
263
  }