@yolo-labs/yolobridge 0.1.0 → 0.2.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/cli.js CHANGED
@@ -24,6 +24,9 @@ import { runDetach } from './detach-cmd.js';
24
24
  import { getStatus, formatStatus } from './status-cmd.js';
25
25
  import { startLocalAgent, stopLocalAgent, DEFAULT_AGENT_BIN } from './local-agent.js';
26
26
  import { runListWorkspaces, formatWorkspacesTable } from './workspaces-cmd.js';
27
+ import { startMcpProxy, mcpUrl, SECRET_ENV_VAR } from './mcp-proxy.js';
28
+ import { writeLocalMcpConfig, removeLocalMcpConfig } from './local-mcp-config.js';
29
+ import { writeLocalMcpTrust, removeLocalMcpTrust } from './local-mcp-trust.js';
27
30
  const DEFAULT_API_URL = 'https://api.yolo.studio';
28
31
  const DEFAULT_AUTH_URL = 'https://auth.yololabs.ai';
29
32
  function apiUrl() {
@@ -87,12 +90,16 @@ function printHelp() {
87
90
  ' interactively from `yolo-bridge workspaces`.',
88
91
  ' [--label <name>] Operator-facing host label (reported to the workspace).',
89
92
  ' [--agent <binary>] Local coding-agent binary to spawn (default: $YOLOBRIDGE_AGENT_BIN or "claude").',
93
+ ' [--agent-id <id>] Registry identity for local MCP access, if different from --agent',
94
+ ' (e.g. a raw executable path, or an agent whose binary name differs',
95
+ ' from its registry id like qwen-code/qwen). Defaults to --agent.',
90
96
  ' detach Detach the current workspace attachment.',
91
97
  ' status Print local login/attach state.',
92
98
  ' --help Print this help.',
93
99
  '',
94
100
  `API base: ${apiUrl()} (override: YOLOBRIDGE_API_URL)`,
95
101
  `Auth base: ${authUrl()} (override: YOLOBRIDGE_AUTH_URL)`,
102
+ `MCP base: ${mcpUrl()} (override: YOLOBRIDGE_MCP_URL)`,
96
103
  `Agent bin: ${DEFAULT_AGENT_BIN} (override: --agent or YOLOBRIDGE_AGENT_BIN)`,
97
104
  '',
98
105
  ].join('\n'));
@@ -106,28 +113,44 @@ async function cmdLogin() {
106
113
  }
107
114
  /**
108
115
  * Parses `attach`'s argv into its recognized `--label <name>` / `--agent
109
- * <binary>` flag pairs plus a leftover positional workspaceId — consuming
110
- * each flag's value together with the flag itself *before* deciding what's
111
- * left over for the positional, so e.g. `attach --label laptop` doesn't
112
- * mistake "laptop" for a workspace id (it should still fall through to the
113
- * interactive picker). An unrecognized `--something` is a hard error rather
114
- * than being silently swallowed as some other flag's value.
116
+ * <binary>` / `--agent-id <registryId>` flag pairs plus a leftover
117
+ * positional workspaceId — consuming each flag's value together with the
118
+ * flag itself *before* deciding what's left over for the positional, so
119
+ * e.g. `attach --label laptop` doesn't mistake "laptop" for a workspace id
120
+ * (it should still fall through to the interactive picker). An unrecognized
121
+ * `--something` is a hard error rather than being silently swallowed as
122
+ * some other flag's value.
123
+ *
124
+ * `--agent-id` exists because `--agent` names the literal spawn command
125
+ * (whatever `startLocalAgent` execs), which is NOT always the same string
126
+ * as the mint route's registry `agentId` (Codex review, 2026-08-24, round
127
+ * 4/5): a raw executable path (`--agent /opt/bin/claude`) 400s as
128
+ * unregistered, and some registered agents' own binary differs from their
129
+ * registry id (`qwen-code`'s binary is `qwen`, `kiro`'s is `kiro-cli`).
130
+ * yolobridge has no local copy of `agents.json` to resolve this itself (it
131
+ * runs on the operator's own machine, not in a pod) — asserting it
132
+ * explicitly is the honest fix, not guessing. Defaults to `agentBin` when
133
+ * omitted, which is correct for every agent whose registry id equals its
134
+ * binary name (the common case: `claude`, `codex`, ...).
115
135
  */
116
136
  export function parseAttachArgs(args) {
117
137
  let workspaceId;
118
138
  let hostLabel;
119
139
  let agentBin;
140
+ let agentId;
120
141
  for (let i = 0; i < args.length; i++) {
121
142
  const a = args[i];
122
- if (a === '--label' || a === '--agent') {
143
+ if (a === '--label' || a === '--agent' || a === '--agent-id') {
123
144
  const value = args[i + 1];
124
145
  if (value === undefined || value.startsWith('--')) {
125
146
  return { error: `${a} requires a value` };
126
147
  }
127
148
  if (a === '--label')
128
149
  hostLabel = value;
129
- else
150
+ else if (a === '--agent')
130
151
  agentBin = value;
152
+ else
153
+ agentId = value;
131
154
  i++;
132
155
  continue;
133
156
  }
@@ -138,18 +161,32 @@ export function parseAttachArgs(args) {
138
161
  workspaceId = a;
139
162
  }
140
163
  }
141
- return { workspaceId, hostLabel, agentBin };
164
+ return { workspaceId, hostLabel, agentBin, agentId };
142
165
  }
143
166
  async function cmdAttach(args) {
167
+ // Printed unconditionally, first thing, regardless of how the rest of
168
+ // this command goes — a self-diagnosing fix for a real, repeated support
169
+ // cost (2026-08-24): every one of that day's "Invalid delegated token" /
170
+ // wrong-workspace confusions traced back to ONE of these three URLs being
171
+ // stale in the caller's shell (e.g. YOLOBRIDGE_MCP_URL added to a .bashrc
172
+ // AFTER the terminal in use had already sourced it), with nothing in the
173
+ // command's own output making that visible until well after the fact.
174
+ process.stdout.write(`yolo-bridge: API base ${apiUrl()} · Auth base ${authUrl()} · MCP base ${mcpUrl()}\n`);
144
175
  const parsed = parseAttachArgs(args);
145
176
  if ('error' in parsed) {
146
177
  process.stderr.write(`yolo-bridge attach: ${parsed.error}\n`);
147
- process.stderr.write('Usage: yolo-bridge attach [workspaceId] [--label <name>] [--agent <binary>]\n');
178
+ process.stderr.write('Usage: yolo-bridge attach [workspaceId] [--label <name>] [--agent <binary>] [--agent-id <registryId>]\n');
148
179
  return 64;
149
180
  }
150
181
  let workspaceId = parsed.workspaceId;
151
182
  const hostLabel = parsed.hostLabel;
152
183
  const agentBin = parsed.agentBin;
184
+ // The mint route's registry identity, NOT necessarily the same string as
185
+ // the spawn command above (see parseAttachArgs's doc comment). Falls back
186
+ // to agentBin (then DEFAULT_AGENT_BIN) for the common case where they
187
+ // match, which is every built-in agent this daemon has been used with so
188
+ // far (claude, codex).
189
+ const resolvedAgentId = parsed.agentId ?? agentBin ?? DEFAULT_AGENT_BIN;
153
190
  if (workspaceId) {
154
191
  const resolved = await resolveWorkspaceIdOrName(workspaceId, { commonApiBaseUrl: apiUrl() });
155
192
  if (!resolved.ok) {
@@ -178,7 +215,7 @@ async function cmdAttach(args) {
178
215
  process.stderr.write(`yolo-bridge attach: ${pick.message}\n`);
179
216
  break;
180
217
  }
181
- process.stderr.write('Usage: yolo-bridge attach [workspaceId] [--label <name>] [--agent <binary>]\n');
218
+ process.stderr.write('Usage: yolo-bridge attach [workspaceId] [--label <name>] [--agent <binary>] [--agent-id <registryId>]\n');
182
219
  return 64;
183
220
  }
184
221
  workspaceId = pick.workspaceId;
@@ -193,39 +230,243 @@ async function cmdAttach(args) {
193
230
  };
194
231
  process.on('SIGINT', onSignal);
195
232
  process.on('SIGTERM', onSignal);
196
- // Spawns the local coding agent under a real PTY right away — this
197
- // command is what launches the user's local session (see
198
- // docs/YOLOBRIDGE_PLAN.md's "⚠ Not yet functional" section). The PTY's
199
- // output streams live to this process's own stdout and this process's
200
- // stdin is piped into the PTY, so the terminal running `attach` is a
201
- // live view onto the exact session remote prompts land in.
202
- startLocalAgent({
203
- agentBin,
204
- onExit: ({ exitCode, signal }) => {
205
- localAgentExited = true;
206
- stopRequested = true;
207
- process.stdout.write(`\nyolo-bridge: local agent exited (code=${exitCode}${signal ? `, signal=${signal}` : ''}), detaching...\n`);
208
- // Fire-and-forget: don't wait on the SSE loop to unwind on its own
209
- // (it only re-checks shouldStop() at loop boundaries) to report the
210
- // status change — tell the server immediately so the tile flips to
211
- // `stopped` right away instead of riding out the heartbeat
212
- // staleness window (~90s, Decision Q2). The daemon loop below still
213
- // exits promptly too, via `shouldStop`.
214
- runDetach({ commonApiBaseUrl: apiUrl() }).catch(() => undefined);
215
- },
216
- });
217
- const result = await runAttachFromDisk({
218
- workspaceId,
219
- commonApiBaseUrl: apiUrl(),
220
- hostLabel,
221
- shouldStop: () => stopRequested,
222
- });
223
- process.removeListener('SIGINT', onSignal);
224
- process.removeListener('SIGTERM', onSignal);
225
- // Whatever ended the attach loop — local Ctrl+C, a server-initiated
226
- // `detached` frame, or the agent process exiting on its own — also ends
227
- // the PTY session `attach` spawned. Safe no-op if it already exited.
228
- stopLocalAgent();
233
+ const spawnCwd = process.cwd();
234
+ let mcpProxyHandle;
235
+ let mcpConfigCleanup;
236
+ let mcpTrustRemoval;
237
+ let result;
238
+ try {
239
+ result = await runAttachFromDisk({
240
+ workspaceId,
241
+ commonApiBaseUrl: apiUrl(),
242
+ hostLabel,
243
+ shouldStop: () => stopRequested,
244
+ // Fires once the real tileId exists (docs/YOLOBRIDGE_PLAN.md's "Local
245
+ // MCP access" section) — starts the local MCP proxy and writes
246
+ // `.mcp.json` BEFORE spawning the local agent, since Claude Code reads
247
+ // that file at process launch. A proxy-start failure is logged and
248
+ // skipped, not fatal — MCP access is an enhancement on a tile that
249
+ // already works without it (send_to_tile/read_tile_output are
250
+ // unaffected either way).
251
+ onAttached: async ({ getAccessToken, clearScreen }) => {
252
+ // Isolated from `startLocalAgent` below on purpose (Codex review,
253
+ // 2026-08-24): `startMcpProxy` itself never throws, but
254
+ // `writeLocalMcpConfig`/`writeLocalMcpTrust` do plain synchronous
255
+ // `fs` writes (e.g. a read-only `spawnCwd` throws EACCES) — without
256
+ // this try/catch, that exception propagates out of the WHOLE
257
+ // `onAttached` callback (`runAttachDaemon`'s own best-effort wrapper
258
+ // only logs it), and `startLocalAgent` — later in this same
259
+ // callback — never runs. That leaves a daemon holding a live
260
+ // attachment + SSE stream with no local PTY to ever receive a
261
+ // prompt. MCP access is an enhancement on a tile that already works
262
+ // without it; the local agent spawning is not optional.
263
+ try {
264
+ mcpProxyHandle = await startMcpProxy({
265
+ apiUrl: apiUrl(),
266
+ getAccessToken,
267
+ workspaceId,
268
+ agentId: resolvedAgentId,
269
+ log: (line) => process.stdout.write(`${line}\n`),
270
+ });
271
+ // `.mcp.json` + `.claude/settings.json` are Claude Code-specific
272
+ // conventions — Codex reads `~/.codex/config.toml`'s
273
+ // `[mcp_servers.*]` instead (`containers/services/container-api/
274
+ // mcp-config-writer.js:5-7`). Writing Claude's files for a
275
+ // non-Claude `--agent` would silently configure nothing that
276
+ // binary ever reads (Codex review, 2026-08-24) — the proxy still
277
+ // starts (harmless, agent-agnostic), but only Claude gets it
278
+ // wired in until a Codex-format writer exists.
279
+ //
280
+ // Keyed on `resolvedAgentId`, NOT `agentBin`/`resolvedAgentBin`
281
+ // (Codex review, round 5): `--agent-id` exists precisely to assert
282
+ // "this really is claude" even when spawned via a nonstandard path
283
+ // or name (`--agent /opt/bin/claude --agent-id claude`) — keying
284
+ // this decision on the raw spawn string instead would mint
285
+ // successfully but still skip writing the config a real Claude
286
+ // Code process would actually read.
287
+ if (mcpProxyHandle && resolvedAgentId !== 'claude') {
288
+ // A non-claude agent never reads `.mcp.json`/`${SECRET_ENV_VAR}`
289
+ // at all, so exporting the secret here is harmless (nothing
290
+ // ever consumes it) — kept for that case only; see below for
291
+ // why the claude case is NOT unconditional.
292
+ process.env[SECRET_ENV_VAR] = mcpProxyHandle.secret;
293
+ process.stdout.write(`yolo-bridge: local MCP auto-config is only implemented for claude (resolved agent id "${resolvedAgentId}") — the proxy is running at ${mcpProxyHandle.url} but nothing points the local agent at it.\n`);
294
+ }
295
+ else if (mcpProxyHandle) {
296
+ const configResult = writeLocalMcpConfig(spawnCwd, mcpProxyHandle.url);
297
+ if (!configResult.ok) {
298
+ // Deliberately does NOT export `${SECRET_ENV_VAR}` here (Codex
299
+ // review, 2026-08-24, round 29): the most common refusal
300
+ // reason is a SIBLING attach in the same directory that
301
+ // already owns the shared `.mcp.json` entry — `.mcp.json`
302
+ // still points at THAT sibling's proxy URL, unrelated to
303
+ // this process's own secret. Exporting our own secret anyway
304
+ // used to make this session's Claude authenticate against
305
+ // the sibling's proxy with the WRONG secret — a consistent,
306
+ // confusing 401 on every MCP tool call, not the "left
307
+ // unconfigured" degrade this log line describes. Leaving the
308
+ // var unset doesn't fully fix that (Claude still sees the
309
+ // sibling's entry either way — `.mcp.json` is shared, not
310
+ // per-process), but it stops actively contributing a
311
+ // guaranteed-wrong credential to an entry this process
312
+ // doesn't own.
313
+ process.stdout.write(`yolo-bridge: could not configure local MCP access (${spawnCwd}/.mcp.json is unparseable, already has its own "yolo-studio" entry — possibly from a live sibling attach in this same directory — or would not be safe from a future commit) — leaving it as-is rather than overwrite/dirty it.\n`);
314
+ }
315
+ else {
316
+ // Exported on THIS process's env, before `startLocalAgent`
317
+ // spawns the local agent below (which inherits it) — the
318
+ // actual secret never touches `.mcp.json` itself (Codex
319
+ // review, 2026-08-24, round 12: that file is a
320
+ // `${SECRET_ENV_VAR}` template Claude Code expands against its
321
+ // own inherited env at load time). Set ONLY after confirming
322
+ // THIS process actually owns the `.mcp.json` entry it points
323
+ // at — see the refusal branch above for why setting it
324
+ // unconditionally was wrong.
325
+ process.env[SECRET_ENV_VAR] = mcpProxyHandle.secret;
326
+ mcpConfigCleanup = { expectedProxyUrl: mcpProxyHandle.url, createdFile: configResult.createdFile };
327
+ // Pre-trusts ONLY the yolo-studio server (server-discovery trust +
328
+ // its own tool-call approvals) so Claude Code doesn't sit on an
329
+ // interactive "New MCP server found" / per-tool-call prompt with
330
+ // nobody watching. Best-effort: a failure here still leaves the
331
+ // MCP server configured and usable, just with the normal
332
+ // approval prompts, so it's logged rather than fatal.
333
+ const trustResult = writeLocalMcpTrust(spawnCwd);
334
+ if (!trustResult.ok) {
335
+ process.stdout.write(`yolo-bridge: could not pre-trust the local MCP server (${spawnCwd}/.claude/settings.local.json is unparseable, or would not be safe from a future commit) — MCP tool calls will need manual approval.\n`);
336
+ }
337
+ else {
338
+ // Only remove on cleanup what THIS attach actually inserted —
339
+ // an entry the operator already had (added_*Entry: false)
340
+ // was their own standing trust grant, not ours to revoke.
341
+ mcpTrustRemoval = {
342
+ removeServerEntry: trustResult.addedServerEntry,
343
+ removePermissionEntry: trustResult.addedPermissionEntry,
344
+ createdFile: trustResult.createdFile,
345
+ attachId: trustResult.attachId,
346
+ };
347
+ }
348
+ }
349
+ }
350
+ }
351
+ catch (err) {
352
+ process.stdout.write(`yolo-bridge: local MCP setup failed (${err instanceof Error ? err.message : String(err)}) — continuing without it.\n`);
353
+ }
354
+ // A stop signal (Ctrl+C) can arrive while this callback was still
355
+ // awaiting the MCP-setup block above — `runAttachDaemon` only checks
356
+ // `shouldStop()` again after `onAttached` RETURNS, so without this
357
+ // check a cancellation mid-setup would still spawn a brand-new PTY
358
+ // process just to kill it moments later (Codex review, 2026-08-24).
359
+ if (stopRequested)
360
+ return;
361
+ // Clears the terminal right before the agent's own UI takes over —
362
+ // NOT on the SSE 'connected' frame (reverted design, see
363
+ // attach-cmd.ts's `onAttached` doc comment for why: this is the one
364
+ // moment guaranteed to be before any agent output, regardless of how
365
+ // fast the agent boots or how slow the SSE connect is).
366
+ clearScreen();
367
+ // Spawns the local coding agent under a real PTY — this is what
368
+ // launches the user's local session (see docs/YOLOBRIDGE_PLAN.md's
369
+ // "⚠ Not yet functional" section). The PTY's output streams live to
370
+ // this process's own stdout and this process's stdin is piped into
371
+ // the PTY, so the terminal running `attach` is a live view onto the
372
+ // exact session remote prompts land in.
373
+ //
374
+ // Deliberately OUTSIDE the MCP-setup try/catch above and in its own
375
+ // (Codex review, 2026-08-24): an unspawnable agent (missing/
376
+ // non-executable binary — node-pty's `spawn()` throws synchronously,
377
+ // ENOENT) must not be swallowed by `runAttachDaemon`'s own
378
+ // best-effort `onAttached` wrapper, which only logs and continues —
379
+ // that would leave a live, apparently-connected tile with no PTY,
380
+ // waiting forever with nothing able to receive a prompt. Mirrors the
381
+ // real `onExit` handler below: stop + detach immediately rather than
382
+ // let the daemon loop ride out the full heartbeat-staleness window.
383
+ try {
384
+ startLocalAgent({
385
+ agentBin,
386
+ cwd: spawnCwd,
387
+ onExit: ({ exitCode, signal }) => {
388
+ localAgentExited = true;
389
+ stopRequested = true;
390
+ process.stdout.write(`\nyolo-bridge: local agent exited (code=${exitCode}${signal ? `, signal=${signal}` : ''}), detaching...\n`);
391
+ // Fire-and-forget: don't wait on the SSE loop to unwind on its own
392
+ // (it only re-checks shouldStop() at loop boundaries) to report the
393
+ // status change — tell the server immediately so the tile flips to
394
+ // `stopped` right away instead of riding out the heartbeat
395
+ // staleness window (~90s, Decision Q2). The daemon loop below still
396
+ // exits promptly too, via `shouldStop`.
397
+ runDetach({ commonApiBaseUrl: apiUrl() }).catch(() => undefined);
398
+ },
399
+ });
400
+ }
401
+ catch (err) {
402
+ localAgentExited = true;
403
+ stopRequested = true;
404
+ process.stdout.write(`\nyolo-bridge: failed to start the local agent (${err instanceof Error ? err.message : String(err)}), detaching...\n`);
405
+ runDetach({ commonApiBaseUrl: apiUrl() }).catch(() => undefined);
406
+ }
407
+ },
408
+ });
409
+ }
410
+ finally {
411
+ // Codex review, 2026-08-24, round 17: this whole block used to run
412
+ // unconditionally AFTER the `await` above, which only happens if
413
+ // `runAttachFromDisk` actually RESOLVES. If it instead throws (e.g. a
414
+ // reconnect-time fetch inside the daemon loop rejects in a way its own
415
+ // internal handling doesn't catch), the exception skips straight past
416
+ // ALL of this — signal listeners stay attached, the local PTY and the
417
+ // MCP proxy's HTTP server both stay alive, and neither local nor
418
+ // server-side state ever gets cleaned up. `main()`'s own top-level
419
+ // `.catch()` only sets `process.exitCode`, which does NOT force an
420
+ // exit — with the PTY/HTTP server still referenced, the process's
421
+ // event loop has no reason to ever end on its own, leaving the CLI
422
+ // hung indefinitely with a stale attachment and a live local proxy. A
423
+ // `finally` runs this cleanup on EITHER outcome, resolve or reject.
424
+ process.removeListener('SIGINT', onSignal);
425
+ process.removeListener('SIGTERM', onSignal);
426
+ // Whatever ended the attach loop — local Ctrl+C, a server-initiated
427
+ // `detached` frame, or the agent process exiting on its own — also ends
428
+ // the PTY session `attach` spawned. Safe no-op if it already exited.
429
+ stopLocalAgent();
430
+ // Same "nothing left running detached" discipline for the local MCP
431
+ // proxy: stop the server (drops the delegated token from memory) and
432
+ // remove the .mcp.json entry we added, if we added one. Each step is
433
+ // wrapped individually (Codex review, 2026-08-24, round 12): the removal
434
+ // helpers' own `writeFileSync`/`unlinkSync` calls are unguarded, and an
435
+ // exception from any one of them — a permission change or a full disk
436
+ // mid-session — would otherwise propagate out of this whole cleanup
437
+ // sequence and skip the SERVER-side detach below entirely, leaving the
438
+ // tile live on the server even though the local process is exiting. Local
439
+ // cleanup is best-effort; the server detach is not.
440
+ if (mcpProxyHandle) {
441
+ try {
442
+ await mcpProxyHandle.stop();
443
+ }
444
+ catch (err) {
445
+ process.stdout.write(`yolo-bridge: local MCP proxy shutdown failed (${err instanceof Error ? err.message : String(err)}).\n`);
446
+ }
447
+ }
448
+ if (mcpTrustRemoval) {
449
+ try {
450
+ removeLocalMcpTrust(spawnCwd, mcpTrustRemoval);
451
+ }
452
+ catch (err) {
453
+ process.stdout.write(`yolo-bridge: local MCP trust cleanup failed (${err instanceof Error ? err.message : String(err)}).\n`);
454
+ }
455
+ }
456
+ // removeLocalMcpConfig only deletes the entry if its CURRENT value still
457
+ // matches the exact URL captured in mcpConfigCleanup, and only unlinks
458
+ // the whole file if THIS attachment is the one that created it
459
+ // (createdFile) -- an undefined mcpConfigCleanup (nothing was ever
460
+ // successfully written) correctly skips the call.
461
+ if (mcpConfigCleanup) {
462
+ try {
463
+ removeLocalMcpConfig(spawnCwd, mcpConfigCleanup.expectedProxyUrl, mcpConfigCleanup.createdFile);
464
+ }
465
+ catch (err) {
466
+ process.stdout.write(`yolo-bridge: local MCP config cleanup failed (${err instanceof Error ? err.message : String(err)}).\n`);
467
+ }
468
+ }
469
+ }
229
470
  if (!result.ok) {
230
471
  if (result.reason === 'not-logged-in') {
231
472
  process.stderr.write('yolo-bridge attach: not logged in — run `yolo-bridge login` first.\n');
@@ -13,15 +13,16 @@
13
13
  * 'authorization_pending' | 'access_denied' | 'expired_token'
14
14
  * | 'Invalid device code' | 'Invalid device code state'
15
15
  *
16
- * NOTE (discrepancy from docs/YOLOBRIDGE_PLAN.md): the plan's Architecture
17
- * section says login "opens the browser to the verification URL with the
18
- * code pre-filled". The actual `initiateDeviceFlow` controller returns a
19
- * bare `verification_uri` (`${FRONTEND_URL}/device`, no query string) —
20
- * there is no code-prefill parameter in the real response. The CLI below
21
- * prints the user_code alongside the URL and expects the user to type it
22
- * in manually, same as GitHub's device flow UX. If prefill lands later on
23
- * the webapp `/device` page, this client doesn't need to change — it just
24
- * won't benefit from it.
16
+ * NOTE (was a discrepancy from docs/YOLOBRIDGE_PLAN.md, now resolved): the
17
+ * `initiateDeviceFlow` controller returns a bare `verification_uri`
18
+ * (`${FRONTEND_URL}/device`, no query string) — there is no code-prefill
19
+ * parameter in the raw response, and no auth-service change was needed to
20
+ * fix this. `webapp/app/device/page.tsx` already reads a `code` query
21
+ * param and pre-fills its input from it (an existing convention this CLI
22
+ * simply wasn't using yet). `login-cmd.ts` builds the pre-filled URL
23
+ * client-side (`verificationUri` + `?code=<userCode>`) before opening the
24
+ * browser — this module still hands back the bare `verificationUri`
25
+ * unchanged; the prefill is entirely the caller's concern.
25
26
  */
26
27
  export class DeviceAuthError extends Error {
27
28
  }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Shared by `local-mcp-trust.ts` and `local-mcp-config.ts`: both write
3
+ * per-attach, machine-local state into a project file that MANY real
4
+ * projects intentionally track in Git (`.claude/settings.local.json` and
5
+ * `.mcp.json` respectively — this repo's own root tracks BOTH, confirmed
6
+ * with `git ls-files`/`git cat-file`, not assumed). A spawned coding agent
7
+ * running with YOLO-mode autonomy can `git add -A && commit` at any point
8
+ * while attached, publishing that ephemeral state to every collaborator;
9
+ * `chmod`/cleanup-on-detach only ever touch the WORKING TREE, never a
10
+ * commit already made. Extracted once both writers needed the identical
11
+ * check (Codex review, 2026-08-24, rounds 15 and 16).
12
+ */
13
+ import { spawnSync } from 'node:child_process';
14
+ import { existsSync, readFileSync, mkdirSync } from 'node:fs';
15
+ import { dirname, join, isAbsolute, relative, sep } from 'node:path';
16
+ import { atomicWriteFileSync, resolveWriteTarget } from './atomic-write.js';
17
+ /**
18
+ * True only when `git check-ignore` DEFINITIVELY confirms `path` is NOT
19
+ * ignored inside a real git repo at `cwd` (exit code 1) — i.e. a `git add
20
+ * -A` could actually pick it up. Empirically verified exit codes (not
21
+ * assumed): 0 = ignored (safe), 1 = not ignored (risky), 128 = `cwd` isn't
22
+ * a git repo at all (safe — nothing can ever commit it; also the exact
23
+ * code git reports for a path OUTSIDE the repository entirely, e.g. a
24
+ * symlink resolving somewhere `git add -A` from `cwd` could never reach
25
+ * anyway — verified with a real `git check-ignore` against `/etc/passwd`,
26
+ * not assumed). Any OTHER outcome (git missing, a weird error) is also
27
+ * treated as safe: this function's only job is to catch a CONFIRMED risk,
28
+ * not to require positive proof of safety, matching both callers' existing
29
+ * philosophy that this state is a best-effort enhancement, not something
30
+ * worth hard-failing an attach over.
31
+ *
32
+ * Also checks the REAL, symlink-resolved target, not just `path` itself
33
+ * (Codex review, 2026-08-24, round 21): `atomicWriteFileSync` follows a
34
+ * symlink at `path` and writes through it (round 16), so an ignored
35
+ * SYMLINK pointing at a TRACKED file elsewhere in the same repo would pass
36
+ * the check on `path` alone while every subsequent write actually lands in
37
+ * the tracked target — exactly what this guard exists to prevent, defeated
38
+ * by a resolution neither caller was checking. Verified empirically (not
39
+ * assumed): an ignored symlink to a tracked in-repo file reports
40
+ * `check-ignore` status 0 for the link itself but status 1 for its
41
+ * resolved target. Uses the SAME resolution `atomicWriteFileSync` itself
42
+ * performs, so this check can never diverge from what actually gets
43
+ * written to.
44
+ */
45
+ export function riskyToCommit(cwd, path) {
46
+ if (isConfirmedNotIgnored(cwd, path))
47
+ return true;
48
+ const realTarget = resolveWriteTarget(path);
49
+ // `null` (Codex review, 2026-08-24, round 31) means `atomicWriteFileSync`
50
+ // would THROW rather than write anything through this symlink at all —
51
+ // nothing will be created, so there's nothing for a future commit to
52
+ // pick up either.
53
+ if (realTarget === null)
54
+ return false;
55
+ return realTarget !== path && isConfirmedNotIgnored(cwd, realTarget);
56
+ }
57
+ function isConfirmedNotIgnored(cwd, path) {
58
+ const result = spawnSync('git', ['check-ignore', '-q', path], { cwd });
59
+ return result.status === 1;
60
+ }
61
+ /**
62
+ * Idempotently adds a LOCAL-ONLY exclude pattern — `.git/info/exclude`, the
63
+ * git-native mechanism for machine-specific excludes that are never shared
64
+ * via `.gitignore` — covering an ephemeral sibling this module's callers
65
+ * create next to a destination path (Codex review, 2026-08-24, round 25;
66
+ * generalized to an explicit `pattern` in round 28 — see below).
67
+ *
68
+ * `riskyToCommit` validates the DESTINATION path, but a sibling this code
69
+ * itself creates (`atomicWriteFileSync`'s `<name>.tmp-<pid>-<hex>`, or
70
+ * `acquireConfigLock`'s `<lock>`/`<lock>.claim-*`/`<lock>.reclaim-*`) has a
71
+ * DIFFERENT literal name — an operator's typical EXACT-match `.gitignore`
72
+ * entry for `.mcp.json` (the common, expected shape — verified empirically,
73
+ * round 15) does NOT also cover a suffixed sibling. A crash in the narrow
74
+ * window between creating one of these and either renaming/unlinking it or
75
+ * cleaning it up on the next call would leave an untracked-but-not-ignored
76
+ * copy of live local state sitting in the working tree, ready for a
77
+ * spawned YOLO-mode agent's next `git add -A && commit` to publish — and
78
+ * even OUTSIDE a crash, a concurrently-running agent can `git add -A` at
79
+ * any moment while one of these briefly exists mid-operation.
80
+ *
81
+ * Refusing the write outright whenever a sibling name isn't covered was
82
+ * considered and rejected: an exact-match `.gitignore` pattern NEVER covers
83
+ * a suffixed sibling, so that would break local MCP configuration for
84
+ * every correctly-configured repo, not just a misconfigured one. Making the
85
+ * name actually covered — once, per repo, via the same mechanism
86
+ * `.gitignore` itself uses under the hood — closes the gap without that
87
+ * regression.
88
+ *
89
+ * `patternBasename` is the LITERAL exclude-file basename to add (a plain
90
+ * name, or a `*`-glob — round 28 generalized this from always appending
91
+ * `.tmp-*` onto a passed-in basename, since `acquireConfigLock`'s own
92
+ * siblings don't fit that one fixed shape). `destDir` is the absolute
93
+ * directory `patternBasename` lives in.
94
+ *
95
+ * Anchored to `destDir`, relative to the repo's working-tree root (Codex
96
+ * review, 2026-08-25, round 32) — a bare basename pattern with no `/` in it
97
+ * (what this function wrote through round 31) matches that basename in
98
+ * EVERY directory of the repo, not just the one the caller actually writes
99
+ * to: running `attach` in one monorepo package permanently hid a
100
+ * same-shaped file in every OTHER package too (e.g. an operator's own
101
+ * `.mcp.json.tmp-backup` sitting in an unrelated package now matches the
102
+ * trailing `*` and vanishes from `git status`). A leading `/` makes a
103
+ * `.git/info/exclude` pattern match only at the given path from the
104
+ * worktree root, the same anchoring a `/`-prefixed line in a root
105
+ * `.gitignore` gets.
106
+ *
107
+ * Best-effort and silent on any failure (no `.git` dir, a worktree/
108
+ * submodule shape `git rev-parse` can't resolve cleanly, a read-only
109
+ * `.git`, `destDir` outside the working tree entirely): degrades to "only
110
+ * the destination's own git-ignore status is checked," exactly the
111
+ * pre-round-25 behavior — never blocks the write itself over this.
112
+ *
113
+ * Writes via `atomicWriteFileSync` (Codex review, 2026-08-25, round 32),
114
+ * not a direct `writeFileSync` — this file routinely already holds an
115
+ * operator's OWN local excludes, so a truncate-then-write that gets cut off
116
+ * by ENOSPC/SIGKILL mid-write used to leave it partial or empty, silently
117
+ * un-hiding whatever the operator had excluded before. Write-to-temp then
118
+ * rename means the original content survives any failure up to the rename
119
+ * itself.
120
+ */
121
+ export function ensureTempSiblingExcluded(cwd, destDir, patternBasename) {
122
+ try {
123
+ const topLevelResult = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf-8' });
124
+ if (topLevelResult.status !== 0)
125
+ return;
126
+ const topLevel = topLevelResult.stdout.trim();
127
+ if (!topLevel)
128
+ return;
129
+ const relDir = relative(topLevel, destDir);
130
+ if (relDir.startsWith('..') || isAbsolute(relDir))
131
+ return; // `destDir` isn't inside this working tree at all.
132
+ const relDirPosix = relDir.split(sep).join('/');
133
+ const pattern = relDirPosix ? `/${relDirPosix}/${patternBasename}` : `/${patternBasename}`;
134
+ const gitDirResult = spawnSync('git', ['rev-parse', '--git-common-dir'], { cwd, encoding: 'utf-8' });
135
+ if (gitDirResult.status !== 0)
136
+ return;
137
+ const gitDir = gitDirResult.stdout.trim();
138
+ if (!gitDir)
139
+ return;
140
+ const excludePath = join(isAbsolute(gitDir) ? gitDir : join(cwd, gitDir), 'info', 'exclude');
141
+ const existing = existsSync(excludePath) ? readFileSync(excludePath, 'utf-8') : '';
142
+ if (existing.split('\n').some((line) => line.trim() === pattern))
143
+ return; // Already present.
144
+ mkdirSync(dirname(excludePath), { recursive: true });
145
+ const withTrailingNewline = existing.length > 0 && !existing.endsWith('\n') ? `${existing}\n` : existing;
146
+ atomicWriteFileSync(excludePath, `${withTrailingNewline}${pattern}\n`);
147
+ }
148
+ catch {
149
+ // Best-effort — see doc comment above.
150
+ }
151
+ }