@yolo-labs/yolobridge 0.30.0 → 0.32.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/dist/check-pty-helper.js +217 -0
- package/dist/cli.js +17 -0
- package/dist/resolve-agent-binary.js +243 -4
- package/package.json +1 -1
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Can node-pty spawn ITS OWN helper? On macOS this is what usually failed.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ NODE-PTY DOES NOT SPAWN YOUR BINARY. On unix it spawns `spawn-helper`
|
|
5
|
+
* and passes the real command as an argument (`pty.cc`: `argv[0] =
|
|
6
|
+
* helper_path`). When that spawn fails it throws the literal string
|
|
7
|
+
* `posix_spawnp failed.` — which names neither the helper nor your agent, and
|
|
8
|
+
* is the same message you get when the agent genuinely is missing.
|
|
9
|
+
*
|
|
10
|
+
* So an operator can have a perfectly good `claude`, pass every check in
|
|
11
|
+
* `resolve-agent-binary.ts`, and still see:
|
|
12
|
+
*
|
|
13
|
+
* yolo-bridge: failed to start the local agent (posix_spawnp failed.)
|
|
14
|
+
*
|
|
15
|
+
* because the thing that could not be spawned was node-pty's own helper. That
|
|
16
|
+
* is exactly what happened on macOS with `claude` installed twice and both
|
|
17
|
+
* copies healthy.
|
|
18
|
+
*
|
|
19
|
+
* ⚠️ macOS ONLY, deliberately. The helper is a darwin build artifact; Linux
|
|
20
|
+
* has no `spawn-helper` on disk at all, so probing for one there would report
|
|
21
|
+
* a missing file on every healthy install — a false alarm in the one direction
|
|
22
|
+
* a diagnostic must never fire.
|
|
23
|
+
*
|
|
24
|
+
* Common causes: npm dropping the executable bit when unpacking, Gatekeeper
|
|
25
|
+
* quarantine on a downloaded prebuild, or a partially-restored node_modules.
|
|
26
|
+
*/
|
|
27
|
+
import { createRequire } from 'node:module';
|
|
28
|
+
import * as fs from 'node:fs';
|
|
29
|
+
import * as path from 'node:path';
|
|
30
|
+
const DEFAULT_FS = {
|
|
31
|
+
loadsNative(p) {
|
|
32
|
+
// node-pty has already loaded successfully by the time this runs (the CLI
|
|
33
|
+
// imports it), so for the winning path this is a require-cache hit with no
|
|
34
|
+
// side effects; for a stale or ABI-incompatible one it throws, exactly as
|
|
35
|
+
// it does inside node-pty's own loader.
|
|
36
|
+
try {
|
|
37
|
+
createRequire(import.meta.url)(p);
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
existsSync: (p) => fs.existsSync(p),
|
|
45
|
+
statSync: (p) => fs.statSync(p),
|
|
46
|
+
accessSync: (p, mode) => fs.accessSync(p, mode),
|
|
47
|
+
constants: { X_OK: fs.constants.X_OK },
|
|
48
|
+
};
|
|
49
|
+
/** node-pty's package root, or undefined when it cannot be located. */
|
|
50
|
+
export function findNodePtyRoot(resolveFrom, io = DEFAULT_FS) {
|
|
51
|
+
try {
|
|
52
|
+
const require_ = createRequire(resolveFrom);
|
|
53
|
+
let dir = path.dirname(require_.resolve('node-pty'));
|
|
54
|
+
for (let i = 0; i < 8; i++) {
|
|
55
|
+
if (io.existsSync(path.join(dir, 'package.json')) && path.basename(dir) === 'node-pty')
|
|
56
|
+
return dir;
|
|
57
|
+
const parent = path.dirname(dir);
|
|
58
|
+
if (parent === dir)
|
|
59
|
+
break;
|
|
60
|
+
dir = parent;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Where node-pty will look for its native module — and therefore its helper.
|
|
70
|
+
*
|
|
71
|
+
* ⚠️ NOT `build/Release`. That was the first version of this check and it was
|
|
72
|
+
* wrong in the worst direction: node-pty 1.1.0 ships PREBUILDS, and
|
|
73
|
+
* `scripts/prebuild.js` only verifies they exist — it never copies them into
|
|
74
|
+
* `build/Release`. So a healthy macOS install loads from
|
|
75
|
+
* `prebuilds/darwin-arm64/` and has no `build/Release` at all, and hardcoding
|
|
76
|
+
* that path would have aborted EVERY attach on EVERY healthy Mac. (codex P1.)
|
|
77
|
+
*
|
|
78
|
+
* This mirrors node-pty's own order from `lib/utils.js` — Release, Debug, then
|
|
79
|
+
* the platform/arch prebuild — and identifies the live directory by the
|
|
80
|
+
* presence of `pty.node`, the same file node-pty itself requires. Both the
|
|
81
|
+
* package root and its `lib/` are checked, matching node-pty's unbundled and
|
|
82
|
+
* bundled relative lookups.
|
|
83
|
+
*/
|
|
84
|
+
export function findHelperPath(root, platform, arch, io) {
|
|
85
|
+
return findHelperCandidates(root, platform, arch, io)[0];
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* EVERY directory node-pty might load from, in its own order.
|
|
89
|
+
*
|
|
90
|
+
* ⚠️ EXISTENCE IS NOT SELECTION, and neither is "any usable helper wins".
|
|
91
|
+
* Two review rounds pushed this in opposite directions, and both were right
|
|
92
|
+
* about the version they saw:
|
|
93
|
+
*
|
|
94
|
+
* · stopping at the first pty.node that merely EXISTS inspects a stale or
|
|
95
|
+
* ABI-incompatible `build/Release` that node-pty skips at runtime — and
|
|
96
|
+
* aborts an attach that works;
|
|
97
|
+
* · accepting ANY usable helper among the candidates passes an install where
|
|
98
|
+
* node-pty loads a Release module whose OWN helper is broken, while a
|
|
99
|
+
* complete prebuild sits unused behind it — and reports health while the
|
|
100
|
+
* spawn fails.
|
|
101
|
+
*
|
|
102
|
+
* Neither is fixable by ordering, because the question is which module
|
|
103
|
+
* actually LOADS. So `loadsNative` mirrors node-pty's require()-and-catch and
|
|
104
|
+
* the winner is the first candidate that genuinely loads; its paired helper is
|
|
105
|
+
* the only one that matters.
|
|
106
|
+
*/
|
|
107
|
+
export function findHelperCandidates(root, platform, arch, io) {
|
|
108
|
+
// ⚠️ BUILD TYPE OUTER, BASE INNER — node-pty's `loadNativeModule` tries each
|
|
109
|
+
// build type across BOTH relative bases before advancing to the next type.
|
|
110
|
+
// Inverting these loops picks the root prebuild over a bundled
|
|
111
|
+
// `lib/build/Release` that node-pty would actually load, so the check would
|
|
112
|
+
// inspect a different helper than the one being executed. (codex P2.)
|
|
113
|
+
const dirs = ['build/Release', 'build/Debug', `prebuilds/${platform}-${arch}`];
|
|
114
|
+
const found = [];
|
|
115
|
+
for (const d of dirs) {
|
|
116
|
+
for (const base of [root, path.join(root, 'lib')]) {
|
|
117
|
+
const dir = path.join(base, ...d.split('/'));
|
|
118
|
+
const native = path.join(dir, 'pty.node');
|
|
119
|
+
if (io.existsSync(native) && io.loadsNative(native))
|
|
120
|
+
found.push(path.join(dir, 'spawn-helper'));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return found;
|
|
124
|
+
}
|
|
125
|
+
/** Whether one candidate helper is a real, executable file. */
|
|
126
|
+
function helperUsable(helperPath, io) {
|
|
127
|
+
if (!io.existsSync(helperPath))
|
|
128
|
+
return false;
|
|
129
|
+
try {
|
|
130
|
+
if (!io.statSync(helperPath).isFile())
|
|
131
|
+
return false;
|
|
132
|
+
io.accessSync(helperPath, io.constants.X_OK);
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Whether node-pty's spawn-helper is present and executable.
|
|
141
|
+
*
|
|
142
|
+
* Returns `not-applicable` off darwin and whenever the helper cannot be
|
|
143
|
+
* located — an inconclusive probe must not manufacture a failure, because the
|
|
144
|
+
* cost of a false alarm here is sending someone to chmod a file that was never
|
|
145
|
+
* meant to exist.
|
|
146
|
+
*/
|
|
147
|
+
export function checkPtyHelper(opts = {}) {
|
|
148
|
+
const platform = opts.platform ?? process.platform;
|
|
149
|
+
if (platform !== 'darwin')
|
|
150
|
+
return { ok: true, reason: 'not-applicable' };
|
|
151
|
+
const io = opts.io ?? DEFAULT_FS;
|
|
152
|
+
const root = opts.nodePtyRoot ?? findNodePtyRoot(opts.resolveFrom ?? import.meta.url, io);
|
|
153
|
+
if (!root)
|
|
154
|
+
return { ok: true, reason: 'not-applicable' };
|
|
155
|
+
const candidates = findHelperCandidates(root, platform, opts.arch ?? process.arch, io);
|
|
156
|
+
// Could not tell where node-pty loads from: stay silent rather than invent a
|
|
157
|
+
// fault. Same rule as an unlocatable package.
|
|
158
|
+
if (candidates.length === 0)
|
|
159
|
+
return { ok: true, reason: 'not-applicable' };
|
|
160
|
+
// ⚠️ THE FIRST CANDIDATE THAT ACTUALLY LOADS IS THE ONE NODE-PTY USES, and
|
|
161
|
+
// only ITS helper matters. A complete prebuild sitting behind a loadable
|
|
162
|
+
// Release module is never reached, so it cannot excuse a broken helper
|
|
163
|
+
// there. (codex P2, correcting the previous round's over-correction.)
|
|
164
|
+
const helperPath = candidates[0];
|
|
165
|
+
if (helperUsable(helperPath, io))
|
|
166
|
+
return { ok: true, reason: 'healthy' };
|
|
167
|
+
if (!io.existsSync(helperPath)) {
|
|
168
|
+
return {
|
|
169
|
+
ok: false,
|
|
170
|
+
reason: 'missing',
|
|
171
|
+
helperPath,
|
|
172
|
+
message: `node-pty's spawn-helper is missing at ${helperPath}.\n`
|
|
173
|
+
+ ` ⚠️ This is NOT a problem with your agent. node-pty spawns this helper rather than\n`
|
|
174
|
+
+ ` your binary directly, so its absence surfaces as the same "posix_spawnp failed."\n`
|
|
175
|
+
+ ` you would see if the agent itself were missing.\n`
|
|
176
|
+
+ ` Reinstall to restore it: npm i -g @yolo-labs/yolobridge`,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
// ⚠️ A DIRECTORY PASSES X_OK. `accessSync(dir, X_OK)` succeeds for any
|
|
180
|
+
// searchable directory, so a corrupt or partially-restored install with a
|
|
181
|
+
// directory at this path would be waved through as healthy while node-pty
|
|
182
|
+
// cannot execute it. (codex P2.)
|
|
183
|
+
let isFile;
|
|
184
|
+
try {
|
|
185
|
+
isFile = io.statSync(helperPath).isFile();
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
isFile = false;
|
|
189
|
+
}
|
|
190
|
+
if (!isFile) {
|
|
191
|
+
return {
|
|
192
|
+
ok: false,
|
|
193
|
+
reason: 'missing',
|
|
194
|
+
helperPath,
|
|
195
|
+
message: `node-pty's spawn-helper at ${helperPath} is not a file.\n`
|
|
196
|
+
+ ` ⚠️ This is NOT a problem with your agent — node-pty spawns this helper rather than\n`
|
|
197
|
+
+ ` your binary directly, so it fails with the same "posix_spawnp failed."\n`
|
|
198
|
+
+ ` Reinstall to restore it: npm i -g @yolo-labs/yolobridge`,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
io.accessSync(helperPath, io.constants.X_OK);
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return {
|
|
206
|
+
ok: false,
|
|
207
|
+
reason: 'not-executable',
|
|
208
|
+
helperPath,
|
|
209
|
+
message: `node-pty's spawn-helper at ${helperPath} is not executable.\n`
|
|
210
|
+
+ ` ⚠️ This is NOT a problem with your agent. node-pty spawns this helper rather than\n`
|
|
211
|
+
+ ` your binary directly, so it fails with the same "posix_spawnp failed." you would\n`
|
|
212
|
+
+ ` see if the agent itself were missing. npm can drop the executable bit on unpack.\n`
|
|
213
|
+
+ ` Fix it with: chmod +x ${helperPath}`,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
return { ok: true, reason: 'healthy' };
|
|
217
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -36,6 +36,7 @@ import { runAllow } from './approved-paths.js';
|
|
|
36
36
|
import { runDetach } from './detach-cmd.js';
|
|
37
37
|
import { getStatus, formatStatus } from './status-cmd.js';
|
|
38
38
|
import { resolveAgentBinary } from './resolve-agent-binary.js';
|
|
39
|
+
import { checkPtyHelper } from './check-pty-helper.js';
|
|
39
40
|
import { startLocalAgent, stopLocalAgent, DEFAULT_AGENT_BIN } from './local-agent.js';
|
|
40
41
|
import { runListWorkspaces, formatWorkspacesTable } from './workspaces-cmd.js';
|
|
41
42
|
import { startMcpProxy, mcpUrl, SECRET_ENV_VAR } from './mcp-proxy.js';
|
|
@@ -466,6 +467,22 @@ async function cmdAttach(args) {
|
|
|
466
467
|
// Say where Ctrl+C goes BEFORE the agent takes over the screen.
|
|
467
468
|
// Without this the operator presses it expecting to quit, nothing
|
|
468
469
|
// happens, and there is no way to discover why.
|
|
470
|
+
// ⚠️ THE HELPER FIRST, BEFORE THE AGENT. node-pty does not spawn
|
|
471
|
+
// your binary — on unix it spawns its own `spawn-helper` and passes
|
|
472
|
+
// the real command as an argument (`pty.cc`: argv[0] = helper_path).
|
|
473
|
+
// A broken helper therefore fails with the SAME
|
|
474
|
+
// `posix_spawnp failed.` as a missing agent, and checking the agent
|
|
475
|
+
// first would report it healthy and leave the operator debugging a
|
|
476
|
+
// binary that was never at fault — which is exactly what happened on
|
|
477
|
+
// macOS with two working `claude` installs on PATH.
|
|
478
|
+
const helper = checkPtyHelper();
|
|
479
|
+
if (!helper.ok) {
|
|
480
|
+
process.stdout.write(`yolo-bridge: cannot start the agent — ${helper.message}\n`);
|
|
481
|
+
process.stdout.write('yolo-bridge: detaching...\n');
|
|
482
|
+
stopRequested = true;
|
|
483
|
+
await runDetach({ commonApiBaseUrl: apiUrl() }).catch(() => undefined);
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
469
486
|
// ⚠️ RESOLVE THE BINARY BEFORE SPAWNING IT, so a failure can be
|
|
470
487
|
// EXPLAINED. node-pty reports an unspawnable binary as the bare
|
|
471
488
|
// string `posix_spawnp failed.` — no binary name, no PATH, no
|
|
@@ -25,7 +25,246 @@
|
|
|
25
25
|
*/
|
|
26
26
|
import * as fs from 'node:fs';
|
|
27
27
|
import * as path from 'node:path';
|
|
28
|
-
const DEFAULT_FS =
|
|
28
|
+
const DEFAULT_FS = {
|
|
29
|
+
statSync: (p) => fs.statSync(p),
|
|
30
|
+
accessSync: (p, mode) => fs.accessSync(p, mode),
|
|
31
|
+
constants: { X_OK: fs.constants.X_OK },
|
|
32
|
+
readFirstLine(p) {
|
|
33
|
+
// Only the shebang is wanted, and the file may be a multi-megabyte binary,
|
|
34
|
+
// so this reads a small prefix rather than slurping it.
|
|
35
|
+
let fd;
|
|
36
|
+
try {
|
|
37
|
+
fd = fs.openSync(p, 'r');
|
|
38
|
+
const buf = Buffer.alloc(512);
|
|
39
|
+
const read = fs.readSync(fd, buf, 0, 512, 0);
|
|
40
|
+
const text = buf.subarray(0, read).toString('utf-8');
|
|
41
|
+
const nl = text.indexOf('\n');
|
|
42
|
+
return nl === -1 ? text : text.slice(0, nl);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
if (fd !== undefined) {
|
|
49
|
+
try {
|
|
50
|
+
fs.closeSync(fd);
|
|
51
|
+
}
|
|
52
|
+
catch { /* already gone */ }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* The interpreter a `#!` line names, resolved the way the kernel would.
|
|
59
|
+
*
|
|
60
|
+
* ⚠️ THIS IS THE CASE EVERY OTHER CHECK PASSES. An executable script whose
|
|
61
|
+
* interpreter is missing fails with ENOENT — the kernel reports the absent
|
|
62
|
+
* INTERPRETER, and it is indistinguishable from the script itself not
|
|
63
|
+
* existing. So a binary can be on PATH, be a real file, and be +x, and still
|
|
64
|
+
* be unspawnable. That is precisely the macOS report this was written for:
|
|
65
|
+
* `type -a claude` listed two real installs while node-pty said only
|
|
66
|
+
* `posix_spawnp failed.`
|
|
67
|
+
*
|
|
68
|
+
* `#!/usr/bin/env node` delegates the search back to PATH, so the argument is
|
|
69
|
+
* resolved recursively rather than treated as a literal path.
|
|
70
|
+
*/
|
|
71
|
+
function checkInterpreter(scriptPath, env, io, depth = 0) {
|
|
72
|
+
if (depth > 4)
|
|
73
|
+
return { ok: true }; // pathological chain; let the spawn speak
|
|
74
|
+
const first = io.readFirstLine(scriptPath);
|
|
75
|
+
if (!first || !first.startsWith('#!'))
|
|
76
|
+
return { ok: true }; // a real binary, not a script
|
|
77
|
+
const parts = tokenizeShebang(first.slice(2).trim());
|
|
78
|
+
if (parts.length === 0)
|
|
79
|
+
return { ok: true };
|
|
80
|
+
const [interpreter, ...rest] = parts;
|
|
81
|
+
// ⚠️ THE INTERPRETER ITSELF FIRST, ALWAYS. `#!/missing/env node` must fail on
|
|
82
|
+
// `/missing/env` even when `node` is perfectly available: the kernel execs
|
|
83
|
+
// the interpreter, and delegating straight to env's argument would report a
|
|
84
|
+
// clean bill of health for a script that cannot spawn. (codex P2.)
|
|
85
|
+
const interpreterFound = resolveAgentBinary(interpreter, env, io, depth + 1);
|
|
86
|
+
if (!interpreterFound.ok) {
|
|
87
|
+
// ⚠️ DO NOT BLAME THE OUTER INTERPRETER FOR AN INNER FAULT. If the
|
|
88
|
+
// interpreter EXISTS but is itself a script with a missing interpreter
|
|
89
|
+
// (claude -> /usr/bin/node -> /missing/runtime), its own message already
|
|
90
|
+
// names the file that is actually absent. Overwriting it here would state
|
|
91
|
+
// that /usr/bin/node cannot be found — about a file that is right there —
|
|
92
|
+
// which is the same misdirection this whole check exists to end.
|
|
93
|
+
// (codex P2.)
|
|
94
|
+
if (interpreterFound.reason === 'bad-interpreter') {
|
|
95
|
+
return { ok: false, nested: interpreterFound.message };
|
|
96
|
+
}
|
|
97
|
+
return { ok: false, interpreter, via: scriptPath };
|
|
98
|
+
}
|
|
99
|
+
// `env` hands the search back to PATH, so the command it delegates to is a
|
|
100
|
+
// second thing that must exist.
|
|
101
|
+
if (path.basename(interpreter) === 'env' && rest.length > 0) {
|
|
102
|
+
const { command, env: envForCommand } = envInvocation(rest, env);
|
|
103
|
+
if (!command)
|
|
104
|
+
return { ok: true };
|
|
105
|
+
// ⚠️ `envForCommand`, not `env`: the lookup must use the environment env
|
|
106
|
+
// will have BUILT by the time it resolves the command.
|
|
107
|
+
const resolved = resolveAgentBinary(command, envForCommand, io, depth + 1);
|
|
108
|
+
if (resolved.ok)
|
|
109
|
+
return { ok: true };
|
|
110
|
+
if (resolved.reason === 'bad-interpreter')
|
|
111
|
+
return { ok: false, nested: resolved.message };
|
|
112
|
+
return { ok: false, interpreter: command, via: scriptPath };
|
|
113
|
+
}
|
|
114
|
+
return { ok: true };
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Split a shebang tail the way a shell would, not on raw whitespace.
|
|
118
|
+
*
|
|
119
|
+
* ⚠️ KNOWN LIMIT, ACCEPTED DELIBERATELY. This is shell-style tokenization, and
|
|
120
|
+
* the kernel's rules differ by platform: macOS splits a shebang tail on
|
|
121
|
+
* whitespace (so this matches it closely — and macOS is where the report that
|
|
122
|
+
* prompted this came from), while Linux hands the whole tail to the
|
|
123
|
+
* interpreter as ONE literal argument. So an exotic line like
|
|
124
|
+
* `#!/usr/bin/env NODE_ENV="a b" node` can be called spawnable here and still
|
|
125
|
+
* fail to exec on Linux.
|
|
126
|
+
*
|
|
127
|
+
* That residual gap is the RIGHT direction to leave open. Missing a broken
|
|
128
|
+
* script costs nothing new — the spawn then fails exactly as it does today,
|
|
129
|
+
* with node-pty's opaque message, which is the status quo this file improves
|
|
130
|
+
* on rather than a regression it introduces. Falsely REJECTING a working
|
|
131
|
+
* agent, by contrast, would block a working setup and be strictly worse than
|
|
132
|
+
* the silence being replaced. Every fix in review has closed a false-reject;
|
|
133
|
+
* this one would trade a small class of false-accepts for the risk of new
|
|
134
|
+
* false-rejects by emulating two kernels' exec semantics, which is not a
|
|
135
|
+
* trade a diagnostic helper should make.
|
|
136
|
+
*
|
|
137
|
+
* ⚠️ QUOTED VALUES CONTAIN SPACES. `env -S NODE_OPTIONS="--require /tmp/h.js"
|
|
138
|
+
* node` is a valid, spawnable shebang; splitting it on whitespace picks
|
|
139
|
+
* `/tmp/h.js"` as the command and declares a working agent broken. Rejecting
|
|
140
|
+
* something that works is a worse outcome than the silence this file replaces,
|
|
141
|
+
* so quotes and backslash escapes are honoured. (codex P2.)
|
|
142
|
+
*/
|
|
143
|
+
export function tokenizeShebang(tail) {
|
|
144
|
+
const out = [];
|
|
145
|
+
let cur = '';
|
|
146
|
+
let quote = null;
|
|
147
|
+
let started = false;
|
|
148
|
+
for (let i = 0; i < tail.length; i++) {
|
|
149
|
+
const c = tail[i];
|
|
150
|
+
if (c === '\\' && quote !== "'" && i + 1 < tail.length) {
|
|
151
|
+
cur += tail[++i];
|
|
152
|
+
started = true;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (quote) {
|
|
156
|
+
if (c === quote)
|
|
157
|
+
quote = null;
|
|
158
|
+
else
|
|
159
|
+
cur += c;
|
|
160
|
+
started = true;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (c === '"' || c === "'") {
|
|
164
|
+
quote = c;
|
|
165
|
+
started = true;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (/\s/.test(c)) {
|
|
169
|
+
if (started) {
|
|
170
|
+
out.push(cur);
|
|
171
|
+
cur = '';
|
|
172
|
+
started = false;
|
|
173
|
+
}
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
cur += c;
|
|
177
|
+
started = true;
|
|
178
|
+
}
|
|
179
|
+
if (started)
|
|
180
|
+
out.push(cur);
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* The command `env` will run, AND the environment it will run it in.
|
|
185
|
+
*
|
|
186
|
+
* ⚠️ BOTH HALVES MATTER. `env` constructs the environment first and resolves
|
|
187
|
+
* the command second, so `#!/usr/bin/env PATH=/opt/runtime/bin node` looks for
|
|
188
|
+
* `node` in `/opt/runtime/bin` — not wherever the daemon's own PATH points.
|
|
189
|
+
* Resolving against the daemon's PATH would reject a spawnable agent whose
|
|
190
|
+
* runtime lives only in the assigned path, and accept one that will fail the
|
|
191
|
+
* moment env replaces PATH. (codex P2.)
|
|
192
|
+
*
|
|
193
|
+
* ⚠️ The command is NOT "the first token without a leading dash". Every one of
|
|
194
|
+
* these is valid and its command is `node`, and that naive rule picks the
|
|
195
|
+
* wrong token in three:
|
|
196
|
+
*
|
|
197
|
+
* env node -> node
|
|
198
|
+
* env -S node --flag -> node (-S splits the rest)
|
|
199
|
+
* env -u NODE_OPTIONS node -> node (-u CONSUMES an operand)
|
|
200
|
+
* env NODE_ENV=production node -> node (NAME=VALUE assignment)
|
|
201
|
+
* env PATH=/opt/runtime/bin node -> node, looked up in /opt/runtime/bin
|
|
202
|
+
*/
|
|
203
|
+
export function envInvocation(args, base) {
|
|
204
|
+
const TAKES_OPERAND = new Set(['-u', '--unset', '-C', '--chdir']);
|
|
205
|
+
let env = { ...base };
|
|
206
|
+
let i = 0;
|
|
207
|
+
while (i < args.length) {
|
|
208
|
+
const a = args[i];
|
|
209
|
+
if (a === '--') {
|
|
210
|
+
i += 1;
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
// `-i` starts from an EMPTY environment, which means no PATH at all — the
|
|
214
|
+
// delegated lookup then has nothing to search, and saying so is more use
|
|
215
|
+
// than guessing.
|
|
216
|
+
if (a === '-i' || a === '--ignore-environment' || a === '-') {
|
|
217
|
+
env = {};
|
|
218
|
+
i += 1;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (a === '-u' || a === '--unset') {
|
|
222
|
+
if (args[i + 1])
|
|
223
|
+
delete env[args[i + 1]];
|
|
224
|
+
i += 2;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (a.startsWith('-u') && a.length > 2) {
|
|
228
|
+
delete env[a.slice(2)];
|
|
229
|
+
i += 1;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (TAKES_OPERAND.has(a)) {
|
|
233
|
+
i += 2;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (a.startsWith('-')) {
|
|
237
|
+
i += 1;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
const assignment = /^([A-Za-z_][A-Za-z0-9_]*)=([\s\S]*)$/.exec(a);
|
|
241
|
+
if (assignment) {
|
|
242
|
+
env[assignment[1]] = assignment[2];
|
|
243
|
+
i += 1;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
return { command: a, env };
|
|
247
|
+
}
|
|
248
|
+
return { command: args[i], env };
|
|
249
|
+
}
|
|
250
|
+
/** A found binary, unless its shebang interpreter is the thing that is missing. */
|
|
251
|
+
function withInterpreter(found, env, io, depth) {
|
|
252
|
+
const interp = checkInterpreter(found, env, io, depth);
|
|
253
|
+
if (interp.ok)
|
|
254
|
+
return { ok: true, path: found };
|
|
255
|
+
if ('nested' in interp)
|
|
256
|
+
return { ok: false, reason: 'bad-interpreter', message: interp.nested };
|
|
257
|
+
return {
|
|
258
|
+
ok: false,
|
|
259
|
+
reason: 'bad-interpreter',
|
|
260
|
+
message: `\`${found}\` exists and is executable, but it is a script whose interpreter\n`
|
|
261
|
+
+ ` \`${interp.interpreter}\` cannot be found. The spawn fails with a "not found" error that\n`
|
|
262
|
+
+ ` names the SCRIPT rather than the missing interpreter, which is why this looks like\n`
|
|
263
|
+
+ ` the agent is missing when it plainly is not.\n`
|
|
264
|
+
+ ` Reinstall the agent against a current runtime, or point yolo-bridge at another copy\n`
|
|
265
|
+
+ ` with \`--agent /full/path\` (or YOLOBRIDGE_AGENT_BIN).`,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
29
268
|
function isExecutableFile(p, io) {
|
|
30
269
|
try {
|
|
31
270
|
if (!io.statSync(p).isFile())
|
|
@@ -49,7 +288,7 @@ function isExecutableFile(p, io) {
|
|
|
49
288
|
* `posix_spawnp failed.`, so it names the binary, says where the search
|
|
50
289
|
* looked, and gives the two ways out.
|
|
51
290
|
*/
|
|
52
|
-
export function resolveAgentBinary(bin, env = process.env, io = DEFAULT_FS) {
|
|
291
|
+
export function resolveAgentBinary(bin, env = process.env, io = DEFAULT_FS, depth = 0) {
|
|
53
292
|
const escapeHatch = `Point yolo-bridge at it explicitly with \`--agent /full/path/to/${bin}\` `
|
|
54
293
|
+ `(or set YOLOBRIDGE_AGENT_BIN).`;
|
|
55
294
|
// An explicit path is taken at its word — no PATH search, same as execve.
|
|
@@ -57,7 +296,7 @@ export function resolveAgentBinary(bin, env = process.env, io = DEFAULT_FS) {
|
|
|
57
296
|
const abs = path.resolve(bin);
|
|
58
297
|
const state = isExecutableFile(abs, io);
|
|
59
298
|
if (state === 'yes')
|
|
60
|
-
return
|
|
299
|
+
return withInterpreter(abs, env, io, depth);
|
|
61
300
|
if (state === 'not-executable') {
|
|
62
301
|
return {
|
|
63
302
|
ok: false,
|
|
@@ -81,7 +320,7 @@ export function resolveAgentBinary(bin, env = process.env, io = DEFAULT_FS) {
|
|
|
81
320
|
const candidate = path.join(dir, bin);
|
|
82
321
|
const state = isExecutableFile(candidate, io);
|
|
83
322
|
if (state === 'yes')
|
|
84
|
-
return
|
|
323
|
+
return withInterpreter(candidate, env, io, depth);
|
|
85
324
|
if (state === 'not-executable' && !sawNonExecutable)
|
|
86
325
|
sawNonExecutable = candidate;
|
|
87
326
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yolo-labs/yolobridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.0",
|
|
4
4
|
"description": "YoloBridge \u2014 local coding-agent daemon that attaches a user's own Claude Code/Codex session to a YOLO Studio workspace as a first-class tile (docs/YOLOBRIDGE_PLAN.md, build-order Phase 5).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|