@yolo-labs/yolobridge 0.13.0 → 0.16.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.
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Builds the COMMAND-LINE arguments that point a locally-spawned coding
3
+ * agent at this attach's local MCP proxy (`mcp-proxy.ts`).
4
+ *
5
+ * Replaces the previous mechanism (2026-08-26), which wrote a
6
+ * project-scoped `.mcp.json` (plus a companion `.claude/settings.local.json`
7
+ * trust grant) into the `yolo-bridge attach` spawn `cwd` and deleted them
8
+ * again on the way out. That design was wrong at the root, not merely
9
+ * leaky:
10
+ *
11
+ * - The file only ever WORKED inside a daemon-spawned agent. Its
12
+ * credential is a `${YOLOBRIDGE_MCP_PROXY_SECRET}` template, and only
13
+ * the process this daemon spawns inherits that variable.
14
+ * - Cleanup lived in a `finally`, which a `kill -9`, a crash, a reboot,
15
+ * or a wedged daemon simply skips. The leftover file then breaks the
16
+ * user's OWN standalone `claude` in that directory —
17
+ * "Missing environment variables: YOLOBRIDGE_MCP_PROXY_SECRET",
18
+ * `yolo-studio · ✘ failed` — for every session afterwards, because a
19
+ * dead loopback port and an unset variable look exactly like a
20
+ * misconfigured server. The operator hit this with two orphans.
21
+ * - A file in the project tree is reachable by the very agent this
22
+ * daemon spawns, running with YOLO-mode autonomy: `git add -A` at the
23
+ * wrong moment commits per-attach machine-local daemon state into a
24
+ * shared repo, which no `finally` can undo.
25
+ *
26
+ * Command-line configuration has none of those failure modes: the
27
+ * configuration lives and dies with the process it configures, so there is
28
+ * nothing to clean up, nothing to leak, nothing to commit, and nothing a
29
+ * crash can leave behind. `attach` now writes NOTHING into the project
30
+ * tree.
31
+ *
32
+ * ## The secret stays a TEMPLATE, never a literal
33
+ *
34
+ * argv is world-readable on this host (`ps -ef`, `/proc/<pid>/cmdline` is
35
+ * mode 444), while `/proc/<pid>/environ` is owner-only. Inlining the real
36
+ * per-attach secret into an argument would therefore publish a live
37
+ * full-workspace credential to every other user on the machine — strictly
38
+ * worse than the file it replaces, which at least was `chmod 600`.
39
+ *
40
+ * So both agents receive a REFERENCE to the environment variable, never
41
+ * its value:
42
+ * - claude gets the literal four-character-plus string
43
+ * `${YOLOBRIDGE_MCP_PROXY_SECRET}` inside the inline JSON, which
44
+ * Claude Code expands against its own inherited process env at load
45
+ * time (verified empirically on the wire, 2026-08-26: a probe MCP
46
+ * server received the EXPANDED value from a `"${PROBE_SECRET}"`
47
+ * template passed via `--mcp-config`, not the literal template text).
48
+ * - codex gets the NAME of the variable via `bearer_token_env_var`,
49
+ * which is Codex's own sanctioned mechanism for exactly this. A
50
+ * variable name is not a secret.
51
+ * `agent-mcp-args.test.ts` asserts the literal never appears in either
52
+ * argv.
53
+ *
54
+ * ## Why codex needs the proxy's second auth location
55
+ *
56
+ * Codex's MCP client has ONE credential mechanism — `bearer_token_env_var`,
57
+ * sent as `Authorization: Bearer <value>`. It has no custom-header support
58
+ * whatsoever, so it cannot send `x-yolobridge-proxy-secret`. That is why
59
+ * `mcp-proxy.ts` accepts the same secret from either location (see
60
+ * `providedSecrets` there); it is one credential with two transports, both
61
+ * compared by the same constant-time `secretsMatch`.
62
+ *
63
+ * ## Why every flag is probed before it is passed
64
+ *
65
+ * The user's locally-installed `claude`/`codex` can be arbitrarily old —
66
+ * this daemon does not install or pin them. Both are strict argument
67
+ * parsers that EXIT NON-ZERO on an unrecognized flag, so passing a flag an
68
+ * old binary has never heard of does not degrade to "no MCP", it produces
69
+ * a tile whose agent died at startup: a dead tile, and a worse outcome
70
+ * than no MCP at all. Same reasoning (and same resolution) as
71
+ * `terminalLaunch.argsIfSupported` in CLAUDE.md's "Agent install-on-demand"
72
+ * rule: ask the installed binary what it advertises, and only pass what it
73
+ * does.
74
+ */
75
+ import { spawnSync } from 'node:child_process';
76
+ import { SECRET_HEADER, SECRET_ENV_VAR } from './mcp-proxy.js';
77
+ /** MCP server name the agent sees. Matches the in-pod writer
78
+ * (`containers/services/container-api/mcp-config-writer.js`) so a prompt
79
+ * written for a cloud session names the same server locally. */
80
+ export const MCP_SERVER_NAME = 'yolo-studio';
81
+ /**
82
+ * The literal string embedded in claude's inline JSON in place of the real
83
+ * secret. Kept as a named constant precisely so a test can assert the
84
+ * TEMPLATE is present and the VALUE is not — the two assertions together
85
+ * are what make "the secret never reaches argv" non-vacuous (a test that
86
+ * only checked for the absence of the secret would also pass against an
87
+ * empty argv).
88
+ */
89
+ export const SECRET_TEMPLATE = `\${${SECRET_ENV_VAR}}`;
90
+ /** Wildcard permission entry for this server's tools. Shell-glob `*`, NOT
91
+ * a regex — Claude Code's permission matcher treats `*` as "any tool from
92
+ * this server"; a `.*` requires a literal dot no real
93
+ * `mcp__yolo-studio__<tool>` id has, and silently matches nothing. */
94
+ export const TOOL_PERMISSION_PATTERN = `mcp__${MCP_SERVER_NAME}__*`;
95
+ /** How long to wait for `<bin> --help`. Both real binaries answer in well
96
+ * under a second (measured 2026-08-26: claude 0.50s, codex 0.18s); this is
97
+ * a hang guard, not a tuned budget. A binary that cannot print its own
98
+ * help in 10s is treated as advertising nothing. */
99
+ const HELP_PROBE_TIMEOUT_MS = 10_000;
100
+ /**
101
+ * Runs `<bin> --help` for real and returns what it printed.
102
+ *
103
+ * Deliberately a REAL subprocess with no injection seam (this package's
104
+ * house rule: `src/*.test.ts` use the real filesystem and real
105
+ * subprocesses). Tests exercise it by pointing `bin` at real executable
106
+ * scripts that advertise, or don't advertise, the flags in question.
107
+ *
108
+ * stdin is `'ignore'` — a help probe must never be able to block waiting
109
+ * for input. Every failure mode (ENOENT, a non-zero exit, the timeout
110
+ * kill, a binary that prints nothing) collapses to `ok: false`, which the
111
+ * callers below treat exactly like "advertises no flags": launch without
112
+ * MCP rather than risk a dead tile.
113
+ */
114
+ export function probeAgentHelp(bin) {
115
+ let result;
116
+ try {
117
+ result = spawnSync(bin, ['--help'], {
118
+ encoding: 'utf-8',
119
+ timeout: HELP_PROBE_TIMEOUT_MS,
120
+ stdio: ['ignore', 'pipe', 'pipe'],
121
+ // Never a shell: `bin` can be an operator-supplied path (`--agent
122
+ // /opt/bin/claude`) and must not be re-interpreted as a command line.
123
+ shell: false,
124
+ });
125
+ }
126
+ catch {
127
+ return { ok: false, text: '' };
128
+ }
129
+ if (result.error)
130
+ return { ok: false, text: '' };
131
+ // A `timeout` kill reports signal SIGTERM with whatever partial output
132
+ // arrived; treat it as no information rather than parsing a truncated
133
+ // help page.
134
+ if (result.signal)
135
+ return { ok: false, text: '' };
136
+ const text = `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
137
+ // A non-zero exit is NOT automatically disqualifying (some CLIs exit 1
138
+ // from `--help`), but an empty output is: there is nothing to read a
139
+ // flag out of.
140
+ if (text.trim() === '')
141
+ return { ok: false, text: '' };
142
+ return { ok: true, text };
143
+ }
144
+ /**
145
+ * Whether `helpText` advertises `flag`.
146
+ *
147
+ * Matches the flag as a whole token (bounded on the right by a non
148
+ * `[A-Za-z0-9-]` character or end of text) so `--mcp-config` is not
149
+ * satisfied by a mention of `--mcp-config-something-else`, and
150
+ * `--allowed-tools` is not satisfied by `--allowed-tools-file`. The left
151
+ * boundary is the flag's own leading `--`.
152
+ */
153
+ export function helpAdvertisesFlag(helpText, flag) {
154
+ const escaped = flag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
155
+ return new RegExp(`${escaped}(?![A-Za-z0-9-])`).test(helpText);
156
+ }
157
+ /**
158
+ * The inline `--mcp-config` payload for claude. Exported so a test can
159
+ * parse the exact JSON the agent will receive rather than re-deriving it.
160
+ */
161
+ export function claudeMcpConfigJson(proxyUrl) {
162
+ return JSON.stringify({
163
+ mcpServers: {
164
+ [MCP_SERVER_NAME]: {
165
+ type: 'http',
166
+ url: proxyUrl,
167
+ // Template, never the value — see this module's header. Claude Code
168
+ // expands `${VAR}` in inline `--mcp-config` JSON exactly as it does
169
+ // in a `.mcp.json` file (verified on the wire, 2026-08-26).
170
+ headers: { [SECRET_HEADER]: SECRET_TEMPLATE },
171
+ },
172
+ },
173
+ });
174
+ }
175
+ /**
176
+ * Builds the argv fragment that wires the spawned agent to `proxyUrl`, or
177
+ * explains why it can't.
178
+ *
179
+ * NEVER throws and never returns a partially-applied configuration: an
180
+ * agent this daemon has no config format for, or a binary too old to
181
+ * advertise the flags, both come back `ok: false` and the caller launches
182
+ * the agent with no MCP at all. A half-configured launch is the dead-tile
183
+ * outcome this whole probe exists to avoid.
184
+ */
185
+ export function buildAgentMcpArgs(opts) {
186
+ // No injection seam on purpose: this package's tests use real
187
+ // subprocesses (see `probeAgentHelp`), so a stubbable probe would only
188
+ // ever be a way to write a test that never proves the probe works.
189
+ switch (opts.agentId) {
190
+ case 'claude':
191
+ return buildClaudeArgs(opts.proxyUrl, probeAgentHelp(opts.agentBin));
192
+ case 'codex':
193
+ return buildCodexArgs(opts.proxyUrl, probeAgentHelp(opts.agentBin));
194
+ default:
195
+ return {
196
+ ok: false,
197
+ reason: 'unsupported-agent',
198
+ message: `no local MCP config format is implemented for agent id "${opts.agentId}"`,
199
+ };
200
+ }
201
+ }
202
+ /**
203
+ * claude: `--mcp-config <inline JSON>` `--strict-mcp-config`
204
+ * [`--allowedTools mcp__yolo-studio__*`].
205
+ *
206
+ * BOTH mcp flags are required, all-or-nothing. `--strict-mcp-config` is not
207
+ * a nicety here: without it Claude Code ALSO loads whatever other MCP
208
+ * configuration it finds, which on this operator's machine includes exactly
209
+ * the orphaned `.mcp.json` files this change exists to stop producing — so
210
+ * dropping it would leave the original "Missing environment variables"
211
+ * failure in place on the very sessions this daemon spawns.
212
+ *
213
+ * `--allowedTools` is additive-if-supported rather than required: without
214
+ * it every MCP tool call sits on an interactive approval prompt with nobody
215
+ * watching (the job the deleted `.claude/settings.local.json` trust file
216
+ * used to do, now done without touching the project tree), but MCP itself
217
+ * still works if a human is present. Losing prompt-free operation is a
218
+ * degrade; losing MCP entirely for want of it would not be.
219
+ *
220
+ * Argument ORDER matters: `--mcp-config <configs...>` and
221
+ * `--allowedTools <tools...>` are both variadic, so each value is followed
222
+ * either by another `--`-prefixed flag or by end-of-argv, never by a bare
223
+ * token a variadic could swallow.
224
+ */
225
+ function buildClaudeArgs(proxyUrl, help) {
226
+ const missing = ['--mcp-config', '--strict-mcp-config'].filter((flag) => !help.ok || !helpAdvertisesFlag(help.text, flag));
227
+ if (missing.length > 0) {
228
+ return {
229
+ ok: false,
230
+ reason: 'flags-unsupported',
231
+ message: help.ok
232
+ ? `the installed claude does not advertise ${missing.join(' / ')}`
233
+ : 'the installed claude did not answer `--help`',
234
+ };
235
+ }
236
+ const args = ['--mcp-config', claudeMcpConfigJson(proxyUrl), '--strict-mcp-config'];
237
+ // Either spelling is accepted by current Claude Code; an older build may
238
+ // advertise only one, so probe for both and pass whichever it names.
239
+ const permissionFlag = ['--allowedTools', '--allowed-tools'].find((flag) => helpAdvertisesFlag(help.text, flag));
240
+ if (permissionFlag)
241
+ args.push(permissionFlag, TOOL_PERMISSION_PATTERN);
242
+ return { ok: true, args };
243
+ }
244
+ /**
245
+ * codex: two `-c` overrides against its flat `[mcp_servers.<name>]` TOML
246
+ * schema. The `value` half of `-c key=value` is parsed as TOML, hence the
247
+ * embedded double quotes making each one a TOML string.
248
+ *
249
+ * `bearer_token_env_var` names the variable; Codex reads it from its own
250
+ * environment (which the PTY spawn sets, exactly as it already did for
251
+ * claude) and sends `Authorization: Bearer <value>`. `codex mcp list`
252
+ * reports this as "Auth: Bearer token".
253
+ */
254
+ function buildCodexArgs(proxyUrl, help) {
255
+ if (!help.ok || !helpAdvertisesFlag(help.text, '--config')) {
256
+ return {
257
+ ok: false,
258
+ reason: 'flags-unsupported',
259
+ message: help.ok
260
+ ? 'the installed codex does not advertise --config'
261
+ : 'the installed codex did not answer `--help`',
262
+ };
263
+ }
264
+ return {
265
+ ok: true,
266
+ args: [
267
+ '-c',
268
+ `mcp_servers.${MCP_SERVER_NAME}.url=${JSON.stringify(proxyUrl)}`,
269
+ '-c',
270
+ `mcp_servers.${MCP_SERVER_NAME}.bearer_token_env_var=${JSON.stringify(SECRET_ENV_VAR)}`,
271
+ ],
272
+ };
273
+ }
@@ -21,7 +21,7 @@ import * as readline from 'node:readline';
21
21
  import { SseFrameParser } from './sse-frame-parser.js';
22
22
  import { actionForFrame } from './frame-actions.js';
23
23
  import { startHeartbeat, defaultTimers } from './heartbeat.js';
24
- import { nextBackoffMs } from './reconnect.js';
24
+ import { nextBackoffMs, isFatalCredentialRefusal, fatalCredentialRefusalMessage, } from './reconnect.js';
25
25
  import { deliverPromptToLocalAgent, captureLocalAgentOutput, onLocalAgentData, takeRawSeed as takeRawSeedFromAgent, primeRawStream as primeRawStreamFromAgent, getLocalAgentGeometry, } from './local-agent.js';
26
26
  import { OutputStreamBuffer, DEFAULT_FLUSH_INTERVAL_MS, } from './output-stream.js';
27
27
  import * as apiClient from './api-client.js';
@@ -744,6 +744,49 @@ export async function runAttachDaemon(deps) {
744
744
  * message and may use stdout, this one fires mid-session while the local
745
745
  * agent's TUI owns the terminal and must not. */
746
746
  let scopedRefreshFailed;
747
+ /**
748
+ * Set when the server PERMANENTLY refuses this daemon's credential — a 403
749
+ * carrying one of reconnect.ts's `FATAL_CREDENTIAL_REFUSAL_CODES`, from
750
+ * either the stream open or a heartbeat POST.
751
+ *
752
+ * A third terminal flag rather than folding into `scopedRefreshFailed`,
753
+ * because the two describe different facts and a future reader must not have
754
+ * to guess which: that one means "the renewal window closed", this one means
755
+ * "the credential we are holding right now is not accepted, and will not be
756
+ * on the next attempt either". Both share the reporting RULE, though — this
757
+ * fires mid-session while the local agent's TUI owns the terminal, so it goes
758
+ * out of band via `noteConnection` and never through `log`.
759
+ */
760
+ let credentialRejected;
761
+ /**
762
+ * Classify one failure. Returns true when it was a PERMANENT credential
763
+ * refusal — recorded out of band, and the caller should take its terminal
764
+ * branch instead of its retry/degrade one. Returns false for everything else,
765
+ * which keeps its existing behaviour untouched.
766
+ *
767
+ * One helper for all three call sites (stream open, the recurring heartbeat,
768
+ * the immediate first heartbeat) on purpose: the whole defect was one path
769
+ * treating this refusal differently from another, and three hand-written
770
+ * copies of the same `if` is how that comes back.
771
+ *
772
+ * FIRST one wins. Tearing the stream down after a refused heartbeat makes the
773
+ * `for await` throw its own (uninformative) abort error a moment later; the
774
+ * first failure is the one that explains why the daemon is stopping, so a
775
+ * later one must not overwrite the message or push a second `interrupted`.
776
+ */
777
+ function noteCredentialRejection(err) {
778
+ if (!isFatalCredentialRefusal(err))
779
+ return false;
780
+ if (!credentialRejected) {
781
+ const message = fatalCredentialRefusalMessage(err);
782
+ credentialRejected = { message };
783
+ // OUT OF BAND, never `log()` — connection-state.ts's module header: the
784
+ // local agent's PTY is piped to this process's stdout, so a human-readable
785
+ // line here lands inside a frame its TUI believes it drew.
786
+ noteConnection('interrupted', { detail: message });
787
+ }
788
+ return true;
789
+ }
747
790
  try {
748
791
  while (!shouldStop()) {
749
792
  const preStreamRefresh = await ensureFreshAccountToken();
@@ -800,7 +843,14 @@ export async function runAttachDaemon(deps) {
800
843
  // above) instead of riding out the connection to its next natural
801
844
  // event.
802
845
  const stopPollHandle = timers.setInterval(() => {
803
- if (shouldStop() || refreshFailed || scopedRefreshFailed) {
846
+ // `credentialRejected` joins this list for the HEARTBEAT case: a
847
+ // heartbeat POST refused with a fatal code proves the credential is
848
+ // dead, but the SSE connection it was opened on can stay open
849
+ // indefinitely afterwards (the server only pushes keepalives). Without
850
+ // this the daemon would sit on a live socket it can no longer
851
+ // heartbeat for, and the tile would age out to `stopped` while the
852
+ // process pretended to be attached.
853
+ if (shouldStop() || refreshFailed || scopedRefreshFailed || credentialRejected) {
804
854
  nodeStream.destroy();
805
855
  }
806
856
  }, STOP_POLL_INTERVAL_MS);
@@ -849,13 +899,26 @@ export async function runAttachDaemon(deps) {
849
899
  return;
850
900
  }
851
901
  await apiClient.postHeartbeat(scopedCfg(), workspaceId, attachmentId);
852
- }, (err) => noteConnection('degraded', {
853
- detail: `heartbeat error: ${err instanceof Error ? err.message : String(err)}`,
854
- }), undefined, deps.timers);
902
+ }, (err) => {
903
+ // The heartbeat hits the SAME boundary as the stream open
904
+ // and gets the SAME 403, so it needs the same answer: a
905
+ // permanent refusal is not a `degraded` link that the next
906
+ // tick might recover from — every subsequent tick is
907
+ // refused identically, forever, ten seconds apart.
908
+ if (noteCredentialRejection(err))
909
+ return;
910
+ noteConnection('degraded', {
911
+ detail: `heartbeat error: ${err instanceof Error ? err.message : String(err)}`,
912
+ });
913
+ }, undefined, deps.timers);
855
914
  // Send one immediately so status isn't stale for the first ~10s.
856
- apiClient.postHeartbeat(scopedCfg(), workspaceId, attachmentId).catch((err) => noteConnection('degraded', {
857
- detail: `initial heartbeat error: ${err instanceof Error ? err.message : String(err)}`,
858
- }));
915
+ apiClient.postHeartbeat(scopedCfg(), workspaceId, attachmentId).catch((err) => {
916
+ if (noteCredentialRejection(err))
917
+ return;
918
+ noteConnection('degraded', {
919
+ detail: `initial heartbeat error: ${err instanceof Error ? err.message : String(err)}`,
920
+ });
921
+ });
859
922
  break;
860
923
  case 'ping':
861
924
  break;
@@ -924,11 +987,24 @@ export async function runAttachDaemon(deps) {
924
987
  }
925
988
  }
926
989
  catch (err) {
927
- // The reported bug's primary symptom: this is the transient-drop
928
- // path, and it used to write straight into the agent's PTY stream.
929
- noteConnection('interrupted', { detail: err instanceof Error ? err.message : String(err) });
930
- if (err instanceof apiClient.YoloBridgeApiError && err.status === 404)
931
- sawGone = true;
990
+ // A PERMANENT refusal short-circuits here (2026-08-26): it is recorded
991
+ // by `noteCredentialRejection` with the remedy attached, and must NOT
992
+ // also be narrated as an ordinary transient interruption that framing
993
+ // is what sent it into the backoff path and produced the endless
994
+ // "Reconnecting in 28576ms (attempt 6)…". Also covers the case where a
995
+ // refused HEARTBEAT already set the flag and the stop-poll destroyed
996
+ // this stream: the abort error that surfaces here explains nothing, and
997
+ // the real reason is already recorded.
998
+ if (credentialRejected || noteCredentialRejection(err)) {
999
+ // recorded; fall through to the terminal break below.
1000
+ }
1001
+ else {
1002
+ // The reported bug's primary symptom: this is the transient-drop
1003
+ // path, and it used to write straight into the agent's PTY stream.
1004
+ noteConnection('interrupted', { detail: err instanceof Error ? err.message : String(err) });
1005
+ if (err instanceof apiClient.YoloBridgeApiError && err.status === 404)
1006
+ sawGone = true;
1007
+ }
932
1008
  }
933
1009
  heartbeat?.stop();
934
1010
  heartbeat = undefined;
@@ -941,6 +1017,11 @@ export async function runAttachDaemon(deps) {
941
1017
  clearAttachment(env, io);
942
1018
  return { ok: true, reason: 'detached-by-server' };
943
1019
  }
1020
+ // BEFORE `attempt += 1`, deliberately: the acceptance criterion is that a
1021
+ // fatal refusal stops on the FIRST occurrence, so no second attempt is
1022
+ // counted, scheduled or slept through.
1023
+ if (credentialRejected)
1024
+ break;
944
1025
  if (refreshFailed || scopedRefreshFailed)
945
1026
  break;
946
1027
  if (shouldStop())
@@ -959,6 +1040,17 @@ export async function runAttachDaemon(deps) {
959
1040
  // exact failure this whole mechanism exists to prevent.
960
1041
  stopOutputStream();
961
1042
  }
1043
+ if (credentialRejected) {
1044
+ // OUT-OF-BAND ONLY, for exactly the reason spelled out in the
1045
+ // `scopedRefreshFailed` branch below: this fires from inside a live session
1046
+ // with the agent's TUI on the terminal. `noteCredentialRejection` already
1047
+ // recorded the `interrupted` event with this same message, so there is
1048
+ // nothing more to narrate here — the remedy still reaches the operator, via
1049
+ // `yolo-bridge status` and via `message`, which cli.ts prints on STDERR
1050
+ // once the PTY is gone. cli.ts exits 1 on any `ok: false`, which is what
1051
+ // makes the daemon's death visible to a supervisor rather than silent.
1052
+ return { ok: false, reason: 'credential-rejected', message: credentialRejected.message };
1053
+ }
962
1054
  if (scopedRefreshFailed) {
963
1055
  // OUT-OF-BAND ONLY — no `log()` here, unlike the account-token path below.
964
1056
  // That path is reached from the daemon's own pre-attach startup or as its