@venturewild/workspace 0.6.26 → 0.6.28
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/package.json +1 -1
- package/server/src/agent.mjs +29 -3
- package/server/src/daemon-supervisor.mjs +69 -7
package/package.json
CHANGED
package/server/src/agent.mjs
CHANGED
|
@@ -46,13 +46,32 @@ export function ensureToolPath(env = process.env, { platform = process.platform,
|
|
|
46
46
|
return env.PATH;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
// On Windows, `where.exe <binary>` lists the extensionless POSIX npm shim
|
|
50
|
+
// (e.g. `…\AppData\Roaming\npm\claude`) BEFORE claude.cmd. spawn() with
|
|
51
|
+
// shell:false can't run that shim — it's a sh script — so a npm-installed
|
|
52
|
+
// Claude resolved to ENOENT (caught live 2026-06-26). Prefer a real Windows
|
|
53
|
+
// launcher (.cmd > .exe > .bat); fall back to the first hit if none. Exported
|
|
54
|
+
// for unit testing.
|
|
55
|
+
export function pickWindowsBinary(lines) {
|
|
56
|
+
const list = Array.isArray(lines) ? lines : [];
|
|
57
|
+
for (const ext of ['.exe', '.cmd', '.bat']) {
|
|
58
|
+
const re = new RegExp(ext.replace('.', '\\.') + '$', 'i');
|
|
59
|
+
const hit = list.find((l) => re.test(l.trim()));
|
|
60
|
+
if (hit) return hit.trim();
|
|
61
|
+
}
|
|
62
|
+
return list[0]?.trim() || null;
|
|
63
|
+
}
|
|
64
|
+
|
|
49
65
|
async function isOnPath(binary) {
|
|
50
66
|
ensureToolPath(); // make sure the tool dirs are on PATH before we look
|
|
51
67
|
const probe = process.platform === 'win32' ? 'where.exe' : 'which';
|
|
52
68
|
try {
|
|
53
69
|
const { stdout } = await execFile(probe, [binary], { timeout: PATH_LOOKUP_TIMEOUT_MS });
|
|
54
70
|
const lines = stdout.split(/\r?\n/).filter(Boolean);
|
|
55
|
-
if (lines.length > 0)
|
|
71
|
+
if (lines.length > 0) {
|
|
72
|
+
// Skip the extensionless npm shim on Windows (see pickWindowsBinary).
|
|
73
|
+
return process.platform === 'win32' ? pickWindowsBinary(lines) : lines[0].trim();
|
|
74
|
+
}
|
|
56
75
|
} catch { /* fall through to a direct probe */ }
|
|
57
76
|
// which/where can still miss a freshly-installed binary in a stripped launchd
|
|
58
77
|
// environment — probe the known install dirs directly and return an ABSOLUTE
|
|
@@ -180,12 +199,19 @@ export class AgentSession extends EventEmitter {
|
|
|
180
199
|
? buildClaudeArgs(this.agent.args, ctx)
|
|
181
200
|
: [...this.agent.args];
|
|
182
201
|
// Prefer the resolved absolute path from detection; fall back to the bare
|
|
183
|
-
// name.
|
|
202
|
+
// name. On Windows a npm-installed Claude resolves to claude.cmd — which
|
|
203
|
+
// Node refuses to spawn with shell:false (CVE-2024-27980) — so a .cmd/.bat
|
|
204
|
+
// shim (or the extensionless npm shim, which cmd rescues via PATHEXT) must
|
|
205
|
+
// go through cmd.exe. A real .exe (node, native claude.exe) spawns
|
|
206
|
+
// directly with shell:false, so tests + native installs are untouched.
|
|
207
|
+
// POSIX keeps shell:false (the native binary there spawns directly).
|
|
184
208
|
const command = this.agent.resolvedPath || this.agent.binary;
|
|
209
|
+
const isWin = process.platform === 'win32';
|
|
210
|
+
const needsShell = isWin && !/\.exe$/i.test(command);
|
|
185
211
|
this.proc = spawn(command, args, {
|
|
186
212
|
cwd: ctx.cwd || this.opts.cwd || process.cwd(),
|
|
187
213
|
env: { ...process.env, ...(ctx.env || {}) },
|
|
188
|
-
shell:
|
|
214
|
+
shell: needsShell,
|
|
189
215
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
190
216
|
windowsHide: true,
|
|
191
217
|
});
|
|
@@ -26,12 +26,38 @@ import {
|
|
|
26
26
|
writeFileSync,
|
|
27
27
|
unlinkSync,
|
|
28
28
|
} from 'node:fs';
|
|
29
|
+
import net from 'node:net';
|
|
29
30
|
import path from 'node:path';
|
|
30
31
|
import os from 'node:os';
|
|
31
32
|
import { resolveDaemonBinary } from './daemon-bin.mjs';
|
|
32
33
|
|
|
33
34
|
const DEFAULT_HTTP_BASE = 'http://127.0.0.1:8320';
|
|
34
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Is something LISTENING on host:port? A successful TCP connect means the socket
|
|
38
|
+
* is still held (a new daemon would hit EADDRINUSE); ECONNREFUSED means nothing
|
|
39
|
+
* is listening — the port is FREE to bind. Loopback connects resolve instantly,
|
|
40
|
+
* so this is fast. Conservative on ambiguous outcomes (timeout / odd error):
|
|
41
|
+
* report "in use" rather than risk spawning into a half-released socket.
|
|
42
|
+
* @returns {Promise<boolean>} true = in use, false = free to bind.
|
|
43
|
+
*/
|
|
44
|
+
function defaultProbePort(host, port, timeoutMs = 1000) {
|
|
45
|
+
return new Promise((resolve) => {
|
|
46
|
+
const sock = net.connect({ host, port });
|
|
47
|
+
let done = false;
|
|
48
|
+
const finish = (inUse) => {
|
|
49
|
+
if (done) return;
|
|
50
|
+
done = true;
|
|
51
|
+
try { sock.destroy(); } catch { /* already closed */ }
|
|
52
|
+
resolve(inUse);
|
|
53
|
+
};
|
|
54
|
+
sock.setTimeout(timeoutMs);
|
|
55
|
+
sock.once('connect', () => finish(true)); // someone is listening
|
|
56
|
+
sock.once('timeout', () => finish(true)); // filtered/hung — assume held
|
|
57
|
+
sock.once('error', (e) => finish(e?.code !== 'ECONNREFUSED')); // refused = free
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
35
61
|
export class DaemonSupervisor {
|
|
36
62
|
/**
|
|
37
63
|
* @param {object} [opts]
|
|
@@ -53,6 +79,7 @@ export class DaemonSupervisor {
|
|
|
53
79
|
spawnImpl = spawn,
|
|
54
80
|
fetchImpl = globalThis.fetch,
|
|
55
81
|
killImpl = (pid, sig) => process.kill(pid, sig),
|
|
82
|
+
probePortImpl = defaultProbePort, // (host, port, timeoutMs) -> Promise<bool>; test seam
|
|
56
83
|
env = process.env,
|
|
57
84
|
// b-ii: when the install is logged in (account.json present), these are
|
|
58
85
|
// injected into the daemon's spawn env so it opens the proxy link
|
|
@@ -72,6 +99,7 @@ export class DaemonSupervisor {
|
|
|
72
99
|
this.spawnImpl = spawnImpl;
|
|
73
100
|
this.fetchImpl = fetchImpl;
|
|
74
101
|
this.killImpl = killImpl;
|
|
102
|
+
this.probePortImpl = probePortImpl;
|
|
75
103
|
this.env = env;
|
|
76
104
|
this.accountToken = accountToken;
|
|
77
105
|
this.serverUrl = serverUrl;
|
|
@@ -246,18 +274,52 @@ export class DaemonSupervisor {
|
|
|
246
274
|
return { stopped: true, pid };
|
|
247
275
|
}
|
|
248
276
|
|
|
277
|
+
/** The daemon's API host+port, parsed from httpBase (for raw socket checks). */
|
|
278
|
+
get apiHostPort() {
|
|
279
|
+
try {
|
|
280
|
+
const u = new URL(this.httpBase);
|
|
281
|
+
return { host: u.hostname || '127.0.0.1', port: Number(u.port) || 8320 };
|
|
282
|
+
} catch {
|
|
283
|
+
return { host: '127.0.0.1', port: 8320 };
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Is the daemon's API port still held by a listener? (true = can't bind yet) */
|
|
288
|
+
portInUse(timeoutMs = 1000) {
|
|
289
|
+
const { host, port } = this.apiHostPort;
|
|
290
|
+
return this.probePortImpl(host, port, timeoutMs);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Block until the daemon's API port is FREE to bind, or the deadline passes.
|
|
295
|
+
* Returns true if it became free, false on timeout (caller proceeds anyway —
|
|
296
|
+
* best-effort, like the old health-poll).
|
|
297
|
+
*/
|
|
298
|
+
async waitForPortFree(timeoutMs = 8000, pollMs = 150) {
|
|
299
|
+
const deadline = Date.now() + timeoutMs;
|
|
300
|
+
for (;;) {
|
|
301
|
+
if (!(await this.portInUse())) return true;
|
|
302
|
+
if (Date.now() >= deadline) return false;
|
|
303
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
249
307
|
/**
|
|
250
308
|
* Recycle the daemon so it loads a freshly-resolved binary (after an
|
|
251
|
-
* auto-update). Stop the running process, wait for it to release its API
|
|
252
|
-
* then spawn again. Returns the `spawnDaemon` result.
|
|
309
|
+
* auto-update). Stop the running process, wait for it to release its API
|
|
310
|
+
* SOCKET, then spawn again. Returns the `spawnDaemon` result.
|
|
311
|
+
*
|
|
312
|
+
* Why wait on the socket, not health(): health() going false only means the
|
|
313
|
+
* daemon stopped answering HTTP — the OS can hold the :8320 listening socket a
|
|
314
|
+
* beat longer (graceful-shutdown drain / close). The daemon does NOT retry its
|
|
315
|
+
* bind: it logs "Address already in use" once and gives up, so the b-ii proxy
|
|
316
|
+
* link never forms and the public URL stays 502 until a reboot
|
|
317
|
+
* (docs/onboarding-hardening-2026-06-23.md). Spawning only once the socket is
|
|
318
|
+
* actually free closes that race.
|
|
253
319
|
*/
|
|
254
320
|
async recycle() {
|
|
255
321
|
await this.stop();
|
|
256
|
-
|
|
257
|
-
const deadline = Date.now() + 5000;
|
|
258
|
-
while (Date.now() < deadline && (await this.health()).running) {
|
|
259
|
-
await new Promise((r) => setTimeout(r, 200));
|
|
260
|
-
}
|
|
322
|
+
await this.waitForPortFree();
|
|
261
323
|
return this.spawnDaemon();
|
|
262
324
|
}
|
|
263
325
|
|