@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.
- package/dist/agent-mcp-args.js +273 -0
- package/dist/attach-cmd.js +105 -13
- package/dist/cli.js +118 -119
- package/dist/mcp-proxy.js +91 -34
- package/dist/reconnect.js +78 -0
- package/package.json +1 -1
- package/dist/atomic-write.js +0 -297
- package/dist/git-safety.js +0 -151
- package/dist/local-mcp-config.js +0 -877
- package/dist/local-mcp-trust.js +0 -371
|
@@ -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
|
+
}
|
package/dist/attach-cmd.js
CHANGED
|
@@ -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
|
-
|
|
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) =>
|
|
853
|
-
|
|
854
|
-
|
|
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) =>
|
|
857
|
-
|
|
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
|
-
//
|
|
928
|
-
//
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
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
|