@yolo-labs/yolobridge 0.2.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,7 +25,8 @@ import { nextBackoffMs } from './reconnect.js';
25
25
  import { deliverPromptToLocalAgent, captureLocalAgentOutput } from './local-agent.js';
26
26
  import * as apiClient from './api-client.js';
27
27
  import { refreshAccessToken as refreshAccessTokenApi } from './device-auth.js';
28
- import { loadAuth, saveAuth, saveAttachment, clearAttachment } from './config-store.js';
28
+ import { loadAuth, saveAuth, loadAttachment, saveAttachment, clearAttachment, } from './config-store.js';
29
+ import { recordConnectionEvent, resetConnectionState, } from './connection-state.js';
29
30
  const DEFAULT_AUTH_URL = 'https://auth.yololabs.ai';
30
31
  /** Refresh once the access token has less than this much validity left.
31
32
  * Production access tokens live 24h; 5min gives ample margin against a
@@ -38,9 +39,39 @@ const DEFAULT_REFRESH_BUFFER_MS = 5 * 60_000;
38
39
  * its own — which, by design, it doesn't. Small enough to be prompt,
39
40
  * cheap enough to not matter (a no-op comparison on every tick). */
40
41
  const STOP_POLL_INTERVAL_MS = 250;
42
+ /**
43
+ * Fraction of the SCOPED credential's lifetime to spend before renewing it
44
+ * (card 07, docs/YOLOBRIDGE_SCOPED_CREDENTIAL_PLAN.md D1). At the server's 1h
45
+ * TTL this renews ~45 minutes in.
46
+ *
47
+ * Deliberately BEFORE expiry, not after: the server's grace window for a
48
+ * just-expired token is a SKEW allowance, not a refresh interval. Spending it
49
+ * on the normal path would leave nothing in reserve for the cases it exists
50
+ * for — a laptop that slept, a clock that drifted, a network outage that
51
+ * happened to straddle the scheduled renewal. On the happy path the daemon
52
+ * never presents an expired credential at all.
53
+ *
54
+ * Derived from the expiry the SERVER reported (`scopedTokenExpiresAt`), never
55
+ * from a TTL constant duplicated here: this binary sits frozen on a laptop for
56
+ * months and must follow whatever lifetime the server it is talking to today
57
+ * actually issued.
58
+ */
59
+ const SCOPED_REFRESH_AT_FRACTION = 0.75;
60
+ /**
61
+ * The daemon's own copy of the server's `YOLOBRIDGE_REFRESH_MAX_EXPIRED_MS`
62
+ * (15 minutes) — how long past expiry a renewal can still succeed.
63
+ *
64
+ * A copy, not an import: the two live on opposite sides of a frozen-binary
65
+ * seam. It is used ONLY to decide when to stop retrying and tell the operator
66
+ * to re-attach; the server is the authority on whether any given renewal is
67
+ * accepted. A copy that drifted SHORT makes this daemon give up slightly early
68
+ * (an honest re-attach), and one that drifted LONG makes it retry a few
69
+ * doomed requests — neither can widen the server's actual window.
70
+ */
71
+ const SCOPED_REFRESH_GRACE_MS = 15 * 60_000;
41
72
  const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
42
73
  export async function runAttachDaemon(deps) {
43
- const { workspaceId, commonApiBaseUrl, hostLabel, auth, env, io, fetchImpl, } = deps;
74
+ const { workspaceId, commonApiBaseUrl, hostLabel, remoteHost, auth, env, io, fetchImpl, } = deps;
44
75
  const shouldStop = deps.shouldStop ?? (() => false);
45
76
  const sleep = deps.sleep ?? defaultSleep;
46
77
  const log = deps.log ?? ((line) => process.stdout.write(`${line}\n`));
@@ -52,20 +83,183 @@ export async function runAttachDaemon(deps) {
52
83
  const refreshBufferMs = deps.refreshBufferMs ?? DEFAULT_REFRESH_BUFFER_MS;
53
84
  const authBaseUrl = deps.authBaseUrl ?? process.env.YOLOBRIDGE_AUTH_URL ?? DEFAULT_AUTH_URL;
54
85
  const doRefresh = deps.refreshAccessToken ?? refreshAccessTokenApi;
55
- const cfg = { commonApiBaseUrl, accessToken: auth.accessToken, fetchImpl };
86
+ /**
87
+ * THE ACCOUNT identity. Full-account bearer from `yolo-bridge login`, and the
88
+ * ONLY thing `ensureFreshToken` may write to. Used for exactly one call —
89
+ * `attach` — because that call is what CREATES the scope; there is nothing
90
+ * narrower to present until it returns.
91
+ */
92
+ const accountCfg = { commonApiBaseUrl, accessToken: auth.accessToken, fetchImpl };
56
93
  let currentAuth = auth;
94
+ /**
95
+ * THE ATTACHMENT identity — the workspace-scoped credential common-api mints
96
+ * at attach (docs/YOLOBRIDGE_SCOPED_CREDENTIAL_PLAN.md). Every post-attach
97
+ * call presents this instead of the account token, so a stolen laptop yields
98
+ * a credential confined to ONE workspace's YoloBridge surface.
99
+ */
100
+ const scopedCredential = {};
101
+ /**
102
+ * Write `auth.json` back, NEVER with the account refresh token (cards 08+09).
103
+ *
104
+ * The access token expires on its own; the REFRESH token is the durable key
105
+ * to the whole account, and this daemon does not need it on disk to do its
106
+ * job. Once `attach` has exchanged it for a workspace-scoped credential the
107
+ * daemon's entire YoloBridge traffic runs on that instead, so a leaked
108
+ * `auth.json` should be worth at worst a self-expiring access token.
109
+ *
110
+ * UNCONDITIONAL since 0.7.0. Card 08 made the drop conditional on a scoped
111
+ * token being in hand, to protect the DEGRADED path — a common-api predating
112
+ * the mint, where `scopedCfg()` fell back to the account token and the daemon
113
+ * ran the whole session on it. Boundary B deleted that path: an account token
114
+ * is now refused on every post-attach route, so there is no session left for
115
+ * the refresh token to be load-bearing in, and `runAttachDaemon` fails at
116
+ * attach rather than continuing without a scoped credential.
117
+ *
118
+ * ONE CONSEQUENCE, ACCEPTED AND NOT HIDDEN: the pre-attach
119
+ * `ensureFreshToken()` call also writes through here, so a rotation that
120
+ * happens moments BEFORE a failed attach drops the refresh token without an
121
+ * exchange ever completing. The operator keeps a ~24h access token and must
122
+ * `yolo-bridge login` again after that. Narrow (it needs a near-expiry token
123
+ * AND a failing attach in the same run) and it errs toward less crown-jewel
124
+ * material on disk, which is the direction this work exists to push.
125
+ *
126
+ * Every `auth.json` write in this file goes through here rather than calling
127
+ * `saveAuth` directly, so an account rotation cannot quietly re-persist the
128
+ * very token the attach exchange just dropped.
129
+ */
130
+ function persistAccountAuth() {
131
+ saveAuth({ ...currentAuth, refreshToken: undefined }, env, io);
132
+ }
133
+ /**
134
+ * Record a freshly-issued scoped credential and schedule its renewal.
135
+ *
136
+ * `refreshAtMs` is computed from THIS moment plus 75% of the remaining
137
+ * lifetime the server just advertised, so a credential handed over already
138
+ * part-used (a slow attach round trip, a clock a little ahead) still renews
139
+ * with margin rather than at a fixed offset from an issue time this daemon
140
+ * never observed. A non-positive remaining lifetime schedules the renewal
141
+ * immediately rather than in the past.
142
+ */
143
+ function rememberScopedCredential(token, expiresAtMs) {
144
+ const at = now();
145
+ const remaining = Math.max(0, expiresAtMs - at);
146
+ scopedCredential.token = token;
147
+ scopedCredential.expiresAtMs = expiresAtMs;
148
+ scopedCredential.refreshAtMs = at + Math.floor(remaining * SCOPED_REFRESH_AT_FRACTION);
149
+ }
150
+ /**
151
+ * Deliberately a FACTORY over a separate holder, not a second mutable config
152
+ * object.
153
+ *
154
+ * The trap this avoids: `ensureFreshToken` refreshes the ACCOUNT token on a
155
+ * ~24h cadence and writes `accountCfg.accessToken` in place. Had the scoped
156
+ * token been assigned onto that same object, the next refresh tick would
157
+ * silently overwrite it and the daemon would quietly revert to sending the
158
+ * account token — with every test still green. Because the scoped value lives
159
+ * in its own holder that `ensureFreshToken` has no reference to, the revert
160
+ * is structurally impossible rather than merely avoided.
161
+ *
162
+ * NO ACCOUNT FALLBACK, since 0.7.0 (card 09). It used to read
163
+ * `?? currentAuth.accessToken` so a daemon talking to a common-api predating
164
+ * the mint could still work. Boundary B refuses an account token on every
165
+ * route this config is used for, so the fallback can no longer produce a
166
+ * working call — it can only convert one legible failure at attach into an
167
+ * unexplained 403 on every heartbeat for the rest of the session. The daemon
168
+ * stops at attach instead (see the `scopedCredential.token` check below), so
169
+ * by the time anything calls this a scoped token is always in hand; the throw
170
+ * is a structural backstop for a future caller that reorders that, not a
171
+ * reachable path today.
172
+ */
173
+ function scopedCfg() {
174
+ const accessToken = scopedCredential.token;
175
+ if (!accessToken) {
176
+ throw new Error('internal: scopedCfg() called before a workspace-scoped credential was obtained');
177
+ }
178
+ return { commonApiBaseUrl, accessToken, fetchImpl };
179
+ }
180
+ // Declared up here (rather than at their first assignment below) purely
181
+ // so `noteConnection` can close over `attachmentId` without a temporal-
182
+ // dead-zone throw: the very first `ensureFreshToken()` call runs before
183
+ // the attach round trip has produced one.
184
+ let attachmentId = '';
185
+ let tileId = '';
186
+ let attachedAt = '';
187
+ /**
188
+ * Write `attachment.json`, INCLUDING the current scoped credential (card 08).
189
+ *
190
+ * The credential is persisted so a daemon that dies and is restarted while
191
+ * its scoped token is still renewable resumes THIS attachment rather than
192
+ * needing a fresh one. That is not a convenience: this card also drops the
193
+ * account refresh token, so once the account access token expires there is
194
+ * nothing else left on the machine to authenticate a new `attach` with — the
195
+ * stored scoped token is the only way a long-lived daemon survives its own
196
+ * restart without sending the operator back to `yolo-bridge login`.
197
+ *
198
+ * Same file, same writer, same 0600 posture as before — no new file and no
199
+ * new mode. Called at attach and again after every renewal, so a restart
200
+ * resumes from the CURRENT credential rather than the one attach happened to
201
+ * hand out hours ago.
202
+ */
203
+ function persistAttachment() {
204
+ saveAttachment({
205
+ workspaceId,
206
+ tileId,
207
+ attachmentId,
208
+ attachedAt,
209
+ ...(scopedCredential.token && scopedCredential.expiresAtMs !== undefined
210
+ ? { scopedToken: scopedCredential.token, scopedTokenExpiresAtMs: scopedCredential.expiresAtMs }
211
+ : {}),
212
+ }, env, io);
213
+ }
214
+ /**
215
+ * The out-of-band connection-state sink (see `AttachDaemonDeps.onConnectionEvent`).
216
+ * The default persists to `~/.config/yolobridge/connection.json`; a
217
+ * `connecting` event starts a fresh per-attachment record so `status`
218
+ * never shows a previous attach's history as if it were this one's.
219
+ */
220
+ const emitConnectionEvent = deps.onConnectionEvent ??
221
+ ((event) => {
222
+ if (!attachmentId)
223
+ return;
224
+ if (event.state === 'connecting')
225
+ resetConnectionState(attachmentId, event, env, io);
226
+ else
227
+ recordConnectionEvent(attachmentId, event, env, io);
228
+ });
229
+ function noteConnection(state, extra = {}) {
230
+ try {
231
+ emitConnectionEvent({ state, at: new Date(now()).toISOString(), ...extra });
232
+ }
233
+ catch {
234
+ // This channel is diagnostics. A read-only config dir or a full disk
235
+ // must not take down an otherwise-working attach — and must NOT fall
236
+ // back to stdout, which is precisely the bug this replaced.
237
+ }
238
+ }
57
239
  /**
58
240
  * Proactive refresh (Bug 2 fix): checked before opening/reopening the
59
241
  * stream and on every heartbeat tick while connected, so the daemon
60
242
  * rotates its access token well before the 24h production expiry
61
243
  * instead of degrading into a silent zombie that just starts 401ing.
62
- * Updates both the in-memory `cfg`/`currentAuth` used by every
63
- * subsequent API call in this process AND the on-disk auth.json (via
64
- * `saveAuth`) so a later `status`/restart also sees the fresh token.
244
+ * Updates the in-memory `accountCfg`/`currentAuth` used by `attach` AND the
245
+ * on-disk auth.json (via `saveAuth`) so a later `status`/restart also sees
246
+ * the fresh token. It must NEVER write the scoped credential — see
247
+ * `scopedCfg` for why that separation is load-bearing.
65
248
  */
66
249
  async function ensureFreshToken() {
67
250
  if (now() < currentAuth.expiresAtMs - refreshBufferMs)
68
251
  return { ok: true };
252
+ // No refresh token: either this daemon dropped it after its own scoped
253
+ // attach exchange (card 08) and is now running from a restart, or the
254
+ // operator's `auth.json` predates login. Either way there is nothing to
255
+ // refresh WITH — report it as an outcome rather than handing `undefined`
256
+ // to auth-service and getting back an unexplained 400.
257
+ if (!currentAuth.refreshToken) {
258
+ return {
259
+ ok: false,
260
+ message: 'the account access token has expired and no refresh token is stored on this machine',
261
+ };
262
+ }
69
263
  const result = await doRefresh(authBaseUrl, currentAuth.refreshToken, fetchImpl);
70
264
  if (result.status !== 'ok') {
71
265
  return { ok: false, message: result.message };
@@ -76,33 +270,262 @@ export async function runAttachDaemon(deps) {
76
270
  tokenType: currentAuth.tokenType,
77
271
  expiresAtMs: result.tokens.expiresAtMs,
78
272
  };
79
- cfg.accessToken = currentAuth.accessToken;
80
- saveAuth(currentAuth, env, io);
81
- log('Access token refreshed.');
273
+ accountCfg.accessToken = currentAuth.accessToken;
274
+ persistAccountAuth();
275
+ // Out-of-band, not `log`: this fires on a ~24h cadence from inside the
276
+ // heartbeat tick, i.e. while the local agent's TUI owns the terminal.
277
+ noteConnection('refreshed');
82
278
  return { ok: true };
83
279
  }
84
- // Cover the case where the daemon is (re)started against a token that's
85
- // already within the refresh buffer of expiry (e.g. `attach` run right
86
- // after a long-down period) — refresh before the very first network
87
- // call, not just before subsequent reconnects.
88
- const initialRefresh = await ensureFreshToken();
89
- if (!initialRefresh.ok) {
90
- log(`Token refresh failed: ${initialRefresh.message}`);
91
- log('Run `yolo-bridge login` again.');
92
- return { ok: false, reason: 'refresh-failed', message: initialRefresh.message };
280
+ /**
281
+ * Latched once the account refresh has failed on the SCOPED path, so the
282
+ * heartbeat tick doesn't retry a call that cannot start working again.
283
+ */
284
+ let accountRefreshAbandoned = false;
285
+ /**
286
+ * `ensureFreshToken`, downgraded from fatal to best-effort.
287
+ *
288
+ * Nothing in this daemon's YoloBridge traffic uses the account token past
289
+ * attach, so killing a perfectly healthy session because it could not be
290
+ * rotated would be a self-inflicted brick. The rotation is kept running — it
291
+ * is not dead code: `onAttached` hands `getAccessToken` to the local MCP
292
+ * proxy, whose delegated-token mints are still account-authenticated (see
293
+ * mcp-proxy.ts) — but a failure DEGRADES that one enhancement rather than
294
+ * ending the session, and latches so the heartbeat tick stops retrying a call
295
+ * that cannot start working again.
296
+ *
297
+ * Card 08 classified this failure by whether a scoped credential existed,
298
+ * because on the degraded path the account token WAS the daemon's credential
299
+ * and a failed refresh had to be terminal. Card 09 removed that path
300
+ * entirely: `runAttachDaemon` never reaches this loop without a scoped
301
+ * credential, so the classification had exactly one branch left.
302
+ *
303
+ * Deliberately silent when it degrades. `noteConnection('degraded')` means
304
+ * "the LINK is struggling" and, being the last event recorded, would leave
305
+ * `yolo-bridge status` reporting a healthy session as degraded for the rest
306
+ * of its life. The one consumer, MCP minting, already reports its own
307
+ * failures, and best-effort MCP is its documented contract.
308
+ */
309
+ async function ensureFreshAccountToken() {
310
+ if (accountRefreshAbandoned)
311
+ return { ok: true };
312
+ const outcome = await ensureFreshToken();
313
+ if (outcome.ok)
314
+ return outcome;
315
+ accountRefreshAbandoned = true;
316
+ return { ok: true };
93
317
  }
94
- let attachmentId;
95
- let tileId;
96
- try {
97
- const result = await apiClient.attach(cfg, workspaceId, hostLabel);
98
- attachmentId = result.attachmentId;
99
- tileId = result.tileId;
318
+ /**
319
+ * SINGLE-FLIGHT guard for the renewal below.
320
+ *
321
+ * The heartbeat scheduler fires on a plain interval and does NOT wait for
322
+ * the previous tick's async work to finish, so a renewal that takes longer
323
+ * than one tick would otherwise be started again by the next one. Two
324
+ * concurrent renewals are not merely wasteful: they can resolve out of
325
+ * order, and the loser would overwrite the live credential with the older of
326
+ * the two tokens — a bug that only ever appears on a slow network, and one
327
+ * whose symptom (heartbeats 401ing a few minutes later) points nowhere near
328
+ * here. Overlapping callers await the SAME renewal instead.
329
+ */
330
+ let scopedRefreshInFlight;
331
+ /**
332
+ * Latched terminal outcome. Once a credential is unrenewable it never
333
+ * becomes renewable again, so every later caller gets the same answer
334
+ * without another doomed round trip — which matters because the heartbeat
335
+ * interval keeps firing for the fraction of a second between the failure
336
+ * and the stream loop actually unwinding.
337
+ */
338
+ let scopedRefreshTerminal;
339
+ /**
340
+ * Renew the WORKSPACE-SCOPED credential before it expires (card 07).
341
+ *
342
+ * Checked in the same two places `ensureFreshToken` is — before opening or
343
+ * reopening the stream, and on every heartbeat tick while connected — so no
344
+ * new timer is introduced and the whole thing is driven by the already-
345
+ * injected `timers`/`now` seams. At a 10s heartbeat the renewal lands within
346
+ * ~10s of its scheduled moment, which against a 15-minute grace window is
347
+ * noise.
348
+ *
349
+ * Returns `{ ok: false }` ONLY when the situation is terminal — the grace
350
+ * window has closed, or the server said the attachment is gone. A transient
351
+ * failure while the credential is still renewable returns `ok` and simply
352
+ * lets the next tick try again (`refreshAtMs` is left where it was, so the
353
+ * retry is immediate rather than deferred another 45 minutes).
354
+ */
355
+ function ensureFreshScopedToken() {
356
+ if (scopedRefreshTerminal)
357
+ return Promise.resolve(scopedRefreshTerminal);
358
+ // Unreachable in this loop — `rememberScopedCredential` sets all three
359
+ // fields together and the daemon refuses to start without them (card 09).
360
+ // Kept as the type-level narrowing `scopedCredential.refreshAtMs` needs
361
+ // below, not as a live degrade branch.
362
+ if (!scopedCredential.token || scopedCredential.refreshAtMs === undefined) {
363
+ return Promise.resolve({ ok: true });
364
+ }
365
+ if (now() < scopedCredential.refreshAtMs)
366
+ return Promise.resolve({ ok: true });
367
+ if (scopedRefreshInFlight)
368
+ return scopedRefreshInFlight;
369
+ const attempt = renewScopedCredential();
370
+ scopedRefreshInFlight = attempt;
371
+ // `renewScopedCredential` never rejects (it converts every failure into an
372
+ // outcome), so one settle handler is enough. Cleared only if this attempt
373
+ // is still the current one, so a later attempt is never dropped by an
374
+ // earlier one's completion.
375
+ void attempt.then(() => {
376
+ if (scopedRefreshInFlight === attempt)
377
+ scopedRefreshInFlight = undefined;
378
+ });
379
+ return attempt;
100
380
  }
101
- catch (err) {
102
- return { ok: false, reason: 'attach-failed', message: err instanceof Error ? err.message : String(err) };
381
+ async function renewScopedCredential() {
382
+ try {
383
+ const renewed = await apiClient.refreshScopedToken(scopedCfg(), workspaceId, attachmentId);
384
+ rememberScopedCredential(renewed.scopedToken, renewed.scopedTokenExpiresAt);
385
+ // Persist the RENEWED credential, not just the one attach issued: a
386
+ // restart hours into a session must resume from a token the server will
387
+ // still accept.
388
+ persistAttachment();
389
+ // Same out-of-band channel the account rotation uses, for the same
390
+ // reason: this fires mid-session, while the local agent's TUI owns the
391
+ // terminal.
392
+ noteConnection('refreshed', { detail: 'workspace-scoped credential renewed' });
393
+ return { ok: true };
394
+ }
395
+ catch (err) {
396
+ const message = err instanceof Error ? err.message : String(err);
397
+ // 403 means the SERVER has ended this attachment (detached, or the
398
+ // workspace is gone). Retrying cannot help and waiting out the grace
399
+ // window only delays the truth.
400
+ const attachmentGone = err instanceof apiClient.YoloBridgeApiError && err.status === 403;
401
+ const expiresAtMs = scopedCredential.expiresAtMs ?? 0;
402
+ const windowBlown = now() >= expiresAtMs + SCOPED_REFRESH_GRACE_MS;
403
+ if (attachmentGone || windowBlown) {
404
+ scopedRefreshTerminal = {
405
+ ok: false,
406
+ message: 'YoloBridge session credential could not be renewed '
407
+ + `(${message}). Run \`yolo-bridge attach\` again to reconnect this machine.`,
408
+ };
409
+ return scopedRefreshTerminal;
410
+ }
411
+ // Still renewable. `degraded` is exactly what this vocabulary means by
412
+ // "still connected, but an individual call failed" — the same state a
413
+ // failed heartbeat POST records — and it is transient by construction:
414
+ // the next tick either succeeds (→ `refreshed`) or the window closes
415
+ // (→ the terminal `interrupted` below). It is NOT used for the terminal
416
+ // failure, which would otherwise leave `yolo-bridge status` reporting a
417
+ // healthy session as degraded.
418
+ noteConnection('degraded', { detail: `scoped credential refresh failed: ${message}` });
419
+ return { ok: true };
420
+ }
421
+ }
422
+ /**
423
+ * RESUME an existing attachment from its persisted scoped credential
424
+ * (card 08), instead of creating a second one.
425
+ *
426
+ * **LIVENESS decides, not the account token's health** (D7). Card 08 gated
427
+ * this on the account refresh having failed, which meant a daemon restarting
428
+ * with a healthy account token ignored a perfectly good stored attachment and
429
+ * called `attach` again — a SECOND server-side attachment and a second tile.
430
+ * The first stops heartbeating and is reaped, but the operator is left
431
+ * looking at a duplicate. Account-token health says nothing about whether the
432
+ * attachment is still alive, so it was never the right question.
433
+ *
434
+ * The probe is card 07's own renewal route
435
+ * (`POST .../yolobridge/attach/:attachmentId/refresh`). That route re-reads
436
+ * live attachment state on every call and 403s a detached one, so it is
437
+ * simultaneously the liveness check AND the source of a fresh credential —
438
+ * there is deliberately no second probe to drift out of agreement with it.
439
+ *
440
+ * A failure of ANY kind (403 detached, 401 past the renewal window, a
441
+ * network error) is not fatal here: it just means there is nothing to
442
+ * resume, and the ordinary attach path below runs. The server, not this
443
+ * binary's copy of the window, is the authority on which it was.
444
+ */
445
+ let resumed = false;
446
+ if (!deps.fresh) {
447
+ const stored = loadAttachment(env, io);
448
+ if (stored &&
449
+ stored.workspaceId === workspaceId &&
450
+ stored.scopedToken !== undefined &&
451
+ stored.scopedTokenExpiresAtMs !== undefined) {
452
+ try {
453
+ // A one-off config rather than `scopedCfg()`: the stored token is not
454
+ // adopted as THE credential until the server has confirmed it is
455
+ // still good, so a failed probe leaves the daemon's own state
456
+ // untouched and the fall-through is a clean ordinary attach.
457
+ const renewed = await apiClient.refreshScopedToken({ commonApiBaseUrl, accessToken: stored.scopedToken, fetchImpl }, workspaceId, stored.attachmentId);
458
+ attachmentId = stored.attachmentId;
459
+ tileId = stored.tileId;
460
+ attachedAt = stored.attachedAt;
461
+ rememberScopedCredential(renewed.scopedToken, renewed.scopedTokenExpiresAt);
462
+ resumed = true;
463
+ }
464
+ catch (err) {
465
+ // Pre-spawn, so `log` is safe here (nothing owns the terminal yet) —
466
+ // and worth saying out loud: the operator is about to get a NEW tile
467
+ // where they may have expected the old one back.
468
+ const message = err instanceof Error ? err.message : String(err);
469
+ log(`Stored attachment is no longer resumable (${message}) — attaching fresh.`);
470
+ }
471
+ }
103
472
  }
104
- saveAttachment({ workspaceId, tileId, attachmentId, attachedAt: new Date().toISOString() }, env, io);
105
- log(`Attached. tileId=${tileId} attachmentId=${attachmentId}`);
473
+ if (!resumed) {
474
+ // ONLY the attach path needs the account credential — attach is the call
475
+ // that creates the scope, and nothing after it presents an account token.
476
+ // Keeping this refresh inside the branch is the ordering win D7 exists
477
+ // for: a daemon whose account token expired days ago, but whose
478
+ // attachment is still live, resumes above and never reaches this line.
479
+ // (It also still covers the original reason it existed: an `attach` run
480
+ // right after a long-down period, against a token already inside the
481
+ // refresh buffer, rotates before the very first network call.)
482
+ const initialRefresh = await ensureFreshToken();
483
+ if (!initialRefresh.ok) {
484
+ log(`Token refresh failed: ${initialRefresh.message}`);
485
+ log('Run `yolo-bridge login` again.');
486
+ return { ok: false, reason: 'refresh-failed', message: initialRefresh.message };
487
+ }
488
+ try {
489
+ const result = await apiClient.attach(accountCfg, workspaceId, hostLabel, remoteHost);
490
+ attachmentId = result.attachmentId;
491
+ tileId = result.tileId;
492
+ attachedAt = new Date().toISOString();
493
+ // `api-client.attach` now REQUIRES the scoped pair and throws on its
494
+ // absence (card 09), so this is a plain read rather than the narrowing
495
+ // the optional shape used to need. A server that issues no credential
496
+ // lands in the catch below, as an attach failure with its own message.
497
+ rememberScopedCredential(result.scopedToken, result.scopedTokenExpiresAt);
498
+ }
499
+ catch (err) {
500
+ return { ok: false, reason: 'attach-failed', message: err instanceof Error ? err.message : String(err) };
501
+ }
502
+ }
503
+ // Belt and braces for the property everything after this point depends on:
504
+ // BOTH routes into this line (a fresh attach, and the resume branch above)
505
+ // set the scoped credential or fail, so this cannot fire today. It exists so
506
+ // that if a third route is ever added, the daemon stops HERE — with a message
507
+ // an operator can act on, while they are still watching the terminal — rather
508
+ // than proceeding to 403 on every daemon call for the rest of the session.
509
+ if (!scopedCredential.token) {
510
+ const message = 'this attach produced no workspace-scoped credential, so the daemon has nothing '
511
+ + 'the YoloBridge routes will accept';
512
+ log(message);
513
+ return { ok: false, reason: 'attach-failed', message };
514
+ }
515
+ persistAttachment();
516
+ // THE EXCHANGE IS COMPLETE — drop the durable account credential from disk
517
+ // (card 08). Ordered after `persistAttachment` so the machine is never
518
+ // momentarily left with neither credential persisted: a crash between the two
519
+ // writes would otherwise leave a daemon that can neither resume nor re-attach.
520
+ // `persistAccountAuth` is what makes this conditional on a scoped token
521
+ // actually being in hand; see its comment for why unconditional would brick
522
+ // the degraded path.
523
+ persistAccountAuth();
524
+ // Safe on stdout: this is still BEFORE `onAttached` spawns the local
525
+ // agent, so nothing owns the screen yet (and the caller's `clearScreen()`
526
+ // wipes it moments later anyway).
527
+ log(`${resumed ? 'Resumed' : 'Attached'}. tileId=${tileId} attachmentId=${attachmentId}`);
528
+ noteConnection('connecting');
106
529
  // Codex-found race: if something already asked us to stop WHILE the
107
530
  // initial refresh/attach network round trip above was in flight (e.g. the
108
531
  // local agent process this daemon spawns exits almost immediately), the
@@ -145,7 +568,7 @@ export async function runAttachDaemon(deps) {
145
568
  }
146
569
  async function detachAndReportStopped() {
147
570
  try {
148
- await apiClient.detach(cfg, workspaceId, attachmentId);
571
+ await apiClient.detach(scopedCfg(), workspaceId, attachmentId);
149
572
  }
150
573
  catch (err) {
151
574
  // Only clear `attachment.json` on a SUCCESSFUL (or already-gone —
@@ -159,6 +582,13 @@ export async function runAttachDaemon(deps) {
159
582
  // retry against, and the next `attach` would then create a SECOND
160
583
  // server-side attachment/tile instead of ever cleaning up the first.
161
584
  log(`Cleanup detach failed: ${err instanceof Error ? err.message : String(err)}`);
585
+ // The record stays (see above) and so does the CREDENTIAL. Card 08 cleared
586
+ // it here on the reasoning that the retry authenticated with the account
587
+ // token — card 09's Boundary B made that false, and `runDetach` now
588
+ // accepts only the scoped credential. Clearing it would leave the retry
589
+ // this comment describes unable to authenticate at all, stranding a live
590
+ // server-side attachment and duplicating the tile on the next attach.
591
+ // (Codex review, gpt-5.6-sol, 2026-08-25.)
162
592
  return { ok: true, reason: 'stopped' };
163
593
  }
164
594
  clearAttachment(env, io);
@@ -171,13 +601,30 @@ export async function runAttachDaemon(deps) {
171
601
  * (forced via the stop-poll below) so the daemon stops instead of
172
602
  * looping forever reconnecting with a dead token. */
173
603
  let refreshFailed;
604
+ /** The same, for the WORKSPACE-SCOPED credential: set when its renewal
605
+ * window has closed (or the server ended the attachment), so the daemon
606
+ * stops instead of streaming on with a credential that is about to start
607
+ * 401ing every heartbeat. Kept separate from `refreshFailed` because the
608
+ * two have different remedies — `yolo-bridge login` vs `yolo-bridge attach`
609
+ * — and different reporting rules: the account failure is the daemon's exit
610
+ * message and may use stdout, this one fires mid-session while the local
611
+ * agent's TUI owns the terminal and must not. */
612
+ let scopedRefreshFailed;
174
613
  try {
175
614
  while (!shouldStop()) {
176
- const preStreamRefresh = await ensureFreshToken();
615
+ const preStreamRefresh = await ensureFreshAccountToken();
177
616
  if (!preStreamRefresh.ok) {
178
617
  refreshFailed = preStreamRefresh;
179
618
  break;
180
619
  }
620
+ // Also before every (re)connect, not only on the heartbeat tick: a long
621
+ // backoff with no stream open is exactly when a scoped credential can
622
+ // cross its renewal point unnoticed.
623
+ const preStreamScopedRefresh = await ensureFreshScopedToken();
624
+ if (!preStreamScopedRefresh.ok) {
625
+ scopedRefreshFailed = preStreamScopedRefresh;
626
+ break;
627
+ }
181
628
  let sawDetached = false;
182
629
  /** Set when the SSE stream itself (or any other call in this attempt)
183
630
  * 404s -- the attachment/workspace no longer exists server-side
@@ -190,7 +637,7 @@ export async function runAttachDaemon(deps) {
190
637
  * `detached` frame that would normally end this loop cleanly. */
191
638
  let sawGone = false;
192
639
  try {
193
- const res = await apiClient.openStream(cfg, workspaceId, attachmentId);
640
+ const res = await apiClient.openStream(scopedCfg(), workspaceId, attachmentId);
194
641
  attempt = 0; // reset backoff on a successful connect
195
642
  const parser = new SseFrameParser();
196
643
  const nodeStream = Readable.fromWeb(res.body);
@@ -219,7 +666,7 @@ export async function runAttachDaemon(deps) {
219
666
  // above) instead of riding out the connection to its next natural
220
667
  // event.
221
668
  const stopPollHandle = timers.setInterval(() => {
222
- if (shouldStop() || refreshFailed) {
669
+ if (shouldStop() || refreshFailed || scopedRefreshFailed) {
223
670
  nodeStream.destroy();
224
671
  }
225
672
  }, STOP_POLL_INTERVAL_MS);
@@ -246,18 +693,35 @@ export async function runAttachDaemon(deps) {
246
693
  // right before `startLocalAgent`, the one moment
247
694
  // guaranteed to be before any agent output regardless of
248
695
  // either timing race.
249
- log('Stream connected.');
696
+ // Out-of-band (was `log('Stream connected.')`): by this
697
+ // point `onAttached` has spawned the local agent, whose
698
+ // PTY is piped to this same stdout — a status line here
699
+ // lands in the middle of the TUI's frame.
700
+ noteConnection('connected');
250
701
  heartbeat?.stop();
251
702
  heartbeat = startHeartbeat(async () => {
252
- const refreshCheck = await ensureFreshToken();
703
+ const refreshCheck = await ensureFreshAccountToken();
253
704
  if (!refreshCheck.ok) {
254
705
  refreshFailed = refreshCheck;
255
706
  return;
256
707
  }
257
- await apiClient.postHeartbeat(cfg, workspaceId, attachmentId);
258
- }, (err) => log(`heartbeat error: ${err instanceof Error ? err.message : String(err)}`), undefined, deps.timers);
708
+ // Renew the scoped credential BEFORE the heartbeat that
709
+ // would use it, so a tick that crosses the renewal point
710
+ // heartbeats with the new token rather than spending one
711
+ // more tick on the old one.
712
+ const scopedCheck = await ensureFreshScopedToken();
713
+ if (!scopedCheck.ok) {
714
+ scopedRefreshFailed = scopedCheck;
715
+ return;
716
+ }
717
+ await apiClient.postHeartbeat(scopedCfg(), workspaceId, attachmentId);
718
+ }, (err) => noteConnection('degraded', {
719
+ detail: `heartbeat error: ${err instanceof Error ? err.message : String(err)}`,
720
+ }), undefined, deps.timers);
259
721
  // Send one immediately so status isn't stale for the first ~10s.
260
- apiClient.postHeartbeat(cfg, workspaceId, attachmentId).catch((err) => log(`initial heartbeat error: ${err instanceof Error ? err.message : String(err)}`));
722
+ apiClient.postHeartbeat(scopedCfg(), workspaceId, attachmentId).catch((err) => noteConnection('degraded', {
723
+ detail: `initial heartbeat error: ${err instanceof Error ? err.message : String(err)}`,
724
+ }));
261
725
  break;
262
726
  case 'ping':
263
727
  break;
@@ -267,16 +731,18 @@ export async function runAttachDaemon(deps) {
267
731
  case 'read-output': {
268
732
  const captured = await captureOutput();
269
733
  await apiClient
270
- .postReadOutputReply(cfg, workspaceId, attachmentId, action.requestId, captured.output, captured.busy)
271
- .catch((err) => log(`read-output reply failed: ${err instanceof Error ? err.message : String(err)}`));
734
+ .postReadOutputReply(scopedCfg(), workspaceId, attachmentId, action.requestId, captured.output, captured.busy)
735
+ .catch((err) => noteConnection('degraded', {
736
+ detail: `read-output reply failed: ${err instanceof Error ? err.message : String(err)}`,
737
+ }));
272
738
  break;
273
739
  }
274
740
  case 'detached':
275
- log('Detached by server.');
741
+ noteConnection('detached');
276
742
  sawDetached = true;
277
743
  break;
278
744
  case 'unknown':
279
- log(`Unrecognized frame type: ${action.event}`);
745
+ noteConnection('degraded', { detail: `unrecognized frame type: ${action.event}` });
280
746
  break;
281
747
  }
282
748
  if (sawDetached)
@@ -291,7 +757,9 @@ export async function runAttachDaemon(deps) {
291
757
  }
292
758
  }
293
759
  catch (err) {
294
- log(`Stream error: ${err instanceof Error ? err.message : String(err)}`);
760
+ // The reported bug's primary symptom: this is the transient-drop
761
+ // path, and it used to write straight into the agent's PTY stream.
762
+ noteConnection('interrupted', { detail: err instanceof Error ? err.message : String(err) });
295
763
  if (err instanceof apiClient.YoloBridgeApiError && err.status === 404)
296
764
  sawGone = true;
297
765
  }
@@ -301,20 +769,43 @@ export async function runAttachDaemon(deps) {
301
769
  clearAttachment(env, io);
302
770
  return { ok: true, reason: 'detached-by-server' };
303
771
  }
304
- if (refreshFailed)
772
+ if (refreshFailed || scopedRefreshFailed)
305
773
  break;
306
774
  if (shouldStop())
307
775
  break;
308
776
  attempt += 1;
309
777
  const delay = nextBackoffMs(attempt, deps.backoffOpts);
310
- log(`Reconnecting in ${delay}ms (attempt ${attempt})...`);
778
+ noteConnection('reconnecting', { attempt, retryInMs: delay });
311
779
  await sleep(delay);
312
780
  }
313
781
  }
314
782
  finally {
315
783
  heartbeat?.stop();
316
784
  }
785
+ if (scopedRefreshFailed) {
786
+ // OUT-OF-BAND ONLY — no `log()` here, unlike the account-token path below.
787
+ // That path is reached from the daemon's own pre-attach startup or as its
788
+ // terminal exit line; this one fires from inside a live session, where the
789
+ // local agent's PTY is piped to this process's stdout and any human-
790
+ // readable line lands in the middle of a frame its TUI believes it drew
791
+ // (connection-state.ts's module header). `interrupted` is the honest
792
+ // state: the session is ending abnormally — deliberately not `degraded`,
793
+ // which means the LINK is struggling and would make `yolo-bridge status`
794
+ // misreport a healthy session. The remedy travels two ways regardless: in
795
+ // the `detail` here, and as the returned `message`, which cli.ts prints on
796
+ // STDERR after the PTY is already gone.
797
+ noteConnection('interrupted', { detail: scopedRefreshFailed.message });
798
+ // `refresh-failed` (not a new reason) on purpose: cli.ts keys off it to run
799
+ // the best-effort `runDetach()` cleanup that stops a stale attachment being
800
+ // left behind, which is exactly what should happen here too.
801
+ return { ok: false, reason: 'refresh-failed', message: scopedRefreshFailed.message };
802
+ }
317
803
  if (refreshFailed) {
804
+ noteConnection('interrupted', { detail: `token refresh failed: ${refreshFailed.message}` });
805
+ // Still on stdout, deliberately: this is the terminal EXIT message for
806
+ // a daemon that is about to return and let cli.ts kill the local PTY.
807
+ // Unlike the reconnect narration above, there is no frame left to
808
+ // corrupt, and the alternative is an unexplained silent exit.
318
809
  log(`Token refresh failed: ${refreshFailed.message}`);
319
810
  log('Run `yolo-bridge login` again.');
320
811
  return { ok: false, reason: 'refresh-failed', message: refreshFailed.message };