@yolo-labs/yolobridge 0.30.0 → 0.31.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.
@@ -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 = 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 { ok: true, path: abs };
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 { ok: true, path: candidate };
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.30.0",
3
+ "version": "0.31.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",