@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/atomic-write.js +297 -0
- package/dist/attach-cmd.js +74 -3
- package/dist/cli.js +285 -44
- package/dist/device-auth.js +10 -9
- package/dist/git-safety.js +151 -0
- package/dist/local-mcp-config.js +877 -0
- package/dist/local-mcp-trust.js +371 -0
- package/dist/login-cmd.js +12 -4
- package/dist/mcp-proxy.js +590 -0
- package/package.json +1 -1
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
|
|
110
|
-
* each flag's value together with the
|
|
111
|
-
* left over for the positional, so
|
|
112
|
-
* mistake "laptop" for a workspace id
|
|
113
|
-
* interactive picker). An unrecognized
|
|
114
|
-
* than being silently swallowed as
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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');
|
package/dist/device-auth.js
CHANGED
|
@@ -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
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
+
}
|