@yolo-labs/yolobridge 0.2.0 → 0.7.1

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.
@@ -29,12 +29,40 @@ async function parseErrorBody(res) {
29
29
  return { message: `HTTP ${res.status}` };
30
30
  }
31
31
  }
32
- export async function attach(cfg, workspaceId, hostLabel) {
32
+ /**
33
+ * `POST /v1/workspaces/:workspaceId/yolobridge/attach`.
34
+ *
35
+ * `scopedToken` / `scopedTokenExpiresAt` are the workspace-scoped daemon
36
+ * credential common-api mints at attach
37
+ * (`docs/YOLOBRIDGE_SCOPED_CREDENTIAL_PLAN.md`): a token confined to THIS
38
+ * workspace's YoloBridge surface, which the post-attach calls
39
+ * (`detach`/`openStream`/`postHeartbeat`/`postReadOutputReply`) use instead of
40
+ * the full-account token. `scopedTokenExpiresAt` is absolute epoch-ms computed
41
+ * server-side at mint, so the refresh schedule never requires decoding the JWT.
42
+ *
43
+ * REQUIRED since 0.7.0 (card 09, D6 — "no backwards support"). Both fields or
44
+ * neither is still the rule; what changed is that "neither" is now an ERROR
45
+ * rather than a degrade. This used to be optional to protect a daemon binary
46
+ * frozen on a laptop against a common-api predating the mint — but Boundary B
47
+ * now refuses an account token on every post-attach route, so a daemon that
48
+ * attaches without a scoped credential cannot do anything afterwards. Accepting
49
+ * the response would buy it exactly one successful call and then a 403 loop
50
+ * with no diagnosis; failing here names the real problem at the one moment the
51
+ * operator is still watching the terminal.
52
+ *
53
+ * Every other exported function's signature is unchanged: they still take "the
54
+ * bearer token to send" via `ApiClientConfig`, and which token that is remains
55
+ * the caller's decision.
56
+ */
57
+ export async function attach(cfg, workspaceId, hostLabel, remoteHost) {
33
58
  const fetchImpl = cfg.fetchImpl ?? fetch;
34
59
  const res = await fetchImpl(`${base(cfg)}/v1/workspaces/${workspaceId}/yolobridge/attach`, {
35
60
  method: 'POST',
36
61
  headers: { ...authHeaders(cfg), 'Content-Type': 'application/json' },
37
- body: JSON.stringify(hostLabel ? { hostLabel } : {}),
62
+ body: JSON.stringify({
63
+ ...(hostLabel ? { hostLabel } : {}),
64
+ ...(remoteHost && Object.values(remoteHost).some(Boolean) ? { remoteHost } : {}),
65
+ }),
38
66
  });
39
67
  if (!res.ok) {
40
68
  const { message, code } = await parseErrorBody(res);
@@ -44,7 +72,54 @@ export async function attach(cfg, workspaceId, hostLabel) {
44
72
  if (typeof body?.tileId !== 'string' || typeof body?.attachmentId !== 'string') {
45
73
  throw new YoloBridgeApiError('attach returned an unexpected shape', res.status);
46
74
  }
47
- return { tileId: body.tileId, attachmentId: body.attachmentId };
75
+ // Both-or-neither, and "neither" is a failure (see the doc comment). A token
76
+ // with no expiry cannot be renewed on time and an expiry with no token is
77
+ // nothing, so a half-pair is refused by the same check — there is no shape
78
+ // here that yields a usable-but-unschedulable credential.
79
+ if (typeof body?.scopedToken !== 'string' || typeof body?.scopedTokenExpiresAt !== 'number') {
80
+ throw new YoloBridgeApiError('attach returned no workspace-scoped credential — this server cannot host a YoloBridge daemon', res.status);
81
+ }
82
+ return {
83
+ tileId: body.tileId,
84
+ attachmentId: body.attachmentId,
85
+ scopedToken: body.scopedToken,
86
+ scopedTokenExpiresAt: body.scopedTokenExpiresAt,
87
+ };
88
+ }
89
+ /**
90
+ * `POST /v1/workspaces/:workspaceId/yolobridge/attach/:attachmentId/refresh` —
91
+ * renew the workspace-scoped daemon credential (card 07,
92
+ * docs/YOLOBRIDGE_SCOPED_CREDENTIAL_PLAN.md, D1).
93
+ *
94
+ * `cfg.accessToken` MUST be the scoped token being renewed: this endpoint
95
+ * authenticates by the presented credential itself ("proof of recent prior
96
+ * possession"), so the token IS the request's identity. There is no refresh
97
+ * credential — deliberately. The daemon never holds a long-lived one, which is
98
+ * the entire point of the scoping work: a stolen laptop yields a credential
99
+ * that expires in an hour and can only be renewed while it is still fresh.
100
+ *
101
+ * The server accepts a token that has JUST expired, within a narrow grace
102
+ * window (15 minutes server-side), so a clock skew or a short sleep across the
103
+ * scheduled renewal recovers instead of forcing a re-attach. Past that, the
104
+ * refusal is terminal and the remedy is `yolo-bridge attach`.
105
+ *
106
+ * Unlike `attach`, the response shape is validated STRICTLY: this call only
107
+ * ever reaches a server that already issued a scoped token, so a reply missing
108
+ * one is a genuine protocol disagreement, not an old-server degrade. Returning
109
+ * a half-pair would leave the caller unable to schedule the next renewal.
110
+ */
111
+ export async function refreshScopedToken(cfg, workspaceId, attachmentId) {
112
+ const fetchImpl = cfg.fetchImpl ?? fetch;
113
+ const res = await fetchImpl(`${base(cfg)}/v1/workspaces/${workspaceId}/yolobridge/attach/${encodeURIComponent(attachmentId)}/refresh`, { method: 'POST', headers: authHeaders(cfg) });
114
+ if (!res.ok) {
115
+ const { message, code } = await parseErrorBody(res);
116
+ throw new YoloBridgeApiError(`scoped credential refresh failed: ${message}`, res.status, code);
117
+ }
118
+ const body = (await res.json());
119
+ if (typeof body?.scopedToken !== 'string' || typeof body?.scopedTokenExpiresAt !== 'number') {
120
+ throw new YoloBridgeApiError('scoped credential refresh returned an unexpected shape', res.status);
121
+ }
122
+ return { scopedToken: body.scopedToken, scopedTokenExpiresAt: body.scopedTokenExpiresAt };
48
123
  }
49
124
  /**
50
125
  * `GET /v1/workspaces/selectable` — slim `{id,name,status}` list of the
@@ -78,6 +153,16 @@ export async function listSelectableWorkspaces(cfg) {
78
153
  }
79
154
  return workspaces;
80
155
  }
156
+ /**
157
+ * `DELETE /v1/workspaces/:workspaceId/yolobridge/attach/:attachmentId`.
158
+ *
159
+ * `cfg.accessToken` must be the WORKSPACE-SCOPED credential, not the account
160
+ * token: this is one of the daemon-only routes Boundary B guards, and an
161
+ * account token is refused there with 403 YOLOBRIDGE_SCOPED_TOKEN_REQUIRED
162
+ * (card 09). Both callers comply — the daemon's own cleanup path via
163
+ * `scopedCfg()`, and standalone `yolo-bridge detach` via the credential
164
+ * `attachment.json` persisted at attach.
165
+ */
81
166
  export async function detach(cfg, workspaceId, attachmentId) {
82
167
  const fetchImpl = cfg.fetchImpl ?? fetch;
83
168
  const res = await fetchImpl(`${base(cfg)}/v1/workspaces/${workspaceId}/yolobridge/attach/${attachmentId}`, {
@@ -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,244 @@ 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');
278
+ return { ok: true };
279
+ }
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;
82
316
  return { ok: true };
83
317
  }
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;
380
+ }
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
+ }
84
422
  // Cover the case where the daemon is (re)started against a token that's
85
423
  // already within the refresh buffer of expiry (e.g. `attach` run right
86
424
  // after a long-down period) — refresh before the very first network
87
425
  // call, not just before subsequent reconnects.
88
426
  const initialRefresh = await ensureFreshToken();
427
+ /**
428
+ * RESUME an existing attachment from its persisted scoped credential
429
+ * (card 08), instead of creating a new one.
430
+ *
431
+ * Deliberately reached ONLY when the account credential cannot do a fresh
432
+ * attach. A healthy account token takes the ordinary path, byte for byte as
433
+ * before — resuming is a recovery route, not a new default, so it cannot
434
+ * change what a normal `yolo-bridge attach` does.
435
+ *
436
+ * The case it exists for is the one this card creates: a daemon that has run
437
+ * for days renewing its scoped credential, whose account access token expired
438
+ * on day one and whose refresh token is deliberately no longer on disk. Its
439
+ * restart has no account credential at all — but it does still hold a
440
+ * workspace-scoped one, which is precisely the credential the attachment's
441
+ * own routes want. Without this the operator is sent back to
442
+ * `yolo-bridge login` for a session that never actually lost anything.
443
+ *
444
+ * No window check here on purpose: the SERVER is the authority on whether a
445
+ * credential is still renewable (card 07's grace-window comment), and the
446
+ * renewal that runs before the stream opens asks it. If the answer is no, the
447
+ * daemon stops with card 07's own re-attach remedy rather than inventing a
448
+ * second, possibly-drifted opinion about the window here.
449
+ */
450
+ let resumed = false;
89
451
  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 };
452
+ const stored = loadAttachment(env, io);
453
+ if (stored &&
454
+ stored.workspaceId === workspaceId &&
455
+ stored.scopedToken !== undefined &&
456
+ stored.scopedTokenExpiresAtMs !== undefined) {
457
+ attachmentId = stored.attachmentId;
458
+ tileId = stored.tileId;
459
+ attachedAt = stored.attachedAt;
460
+ rememberScopedCredential(stored.scopedToken, stored.scopedTokenExpiresAtMs);
461
+ resumed = true;
462
+ }
463
+ else {
464
+ log(`Token refresh failed: ${initialRefresh.message}`);
465
+ log('Run `yolo-bridge login` again.');
466
+ return { ok: false, reason: 'refresh-failed', message: initialRefresh.message };
467
+ }
93
468
  }
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;
469
+ if (!resumed) {
470
+ try {
471
+ const result = await apiClient.attach(accountCfg, workspaceId, hostLabel, remoteHost);
472
+ attachmentId = result.attachmentId;
473
+ tileId = result.tileId;
474
+ attachedAt = new Date().toISOString();
475
+ // `api-client.attach` now REQUIRES the scoped pair and throws on its
476
+ // absence (card 09), so this is a plain read rather than the narrowing
477
+ // the optional shape used to need. A server that issues no credential
478
+ // lands in the catch below, as an attach failure with its own message.
479
+ rememberScopedCredential(result.scopedToken, result.scopedTokenExpiresAt);
480
+ }
481
+ catch (err) {
482
+ return { ok: false, reason: 'attach-failed', message: err instanceof Error ? err.message : String(err) };
483
+ }
100
484
  }
101
- catch (err) {
102
- return { ok: false, reason: 'attach-failed', message: err instanceof Error ? err.message : String(err) };
485
+ // Belt and braces for the property everything after this point depends on:
486
+ // BOTH routes into this line (a fresh attach, and the resume branch above)
487
+ // set the scoped credential or fail, so this cannot fire today. It exists so
488
+ // that if a third route is ever added, the daemon stops HERE — with a message
489
+ // an operator can act on, while they are still watching the terminal — rather
490
+ // than proceeding to 403 on every daemon call for the rest of the session.
491
+ if (!scopedCredential.token) {
492
+ const message = 'this attach produced no workspace-scoped credential, so the daemon has nothing '
493
+ + 'the YoloBridge routes will accept';
494
+ log(message);
495
+ return { ok: false, reason: 'attach-failed', message };
103
496
  }
104
- saveAttachment({ workspaceId, tileId, attachmentId, attachedAt: new Date().toISOString() }, env, io);
105
- log(`Attached. tileId=${tileId} attachmentId=${attachmentId}`);
497
+ persistAttachment();
498
+ // THE EXCHANGE IS COMPLETE — drop the durable account credential from disk
499
+ // (card 08). Ordered after `persistAttachment` so the machine is never
500
+ // momentarily left with neither credential persisted: a crash between the two
501
+ // writes would otherwise leave a daemon that can neither resume nor re-attach.
502
+ // `persistAccountAuth` is what makes this conditional on a scoped token
503
+ // actually being in hand; see its comment for why unconditional would brick
504
+ // the degraded path.
505
+ persistAccountAuth();
506
+ // Safe on stdout: this is still BEFORE `onAttached` spawns the local
507
+ // agent, so nothing owns the screen yet (and the caller's `clearScreen()`
508
+ // wipes it moments later anyway).
509
+ log(`${resumed ? 'Resumed' : 'Attached'}. tileId=${tileId} attachmentId=${attachmentId}`);
510
+ noteConnection('connecting');
106
511
  // Codex-found race: if something already asked us to stop WHILE the
107
512
  // initial refresh/attach network round trip above was in flight (e.g. the
108
513
  // local agent process this daemon spawns exits almost immediately), the
@@ -145,7 +550,7 @@ export async function runAttachDaemon(deps) {
145
550
  }
146
551
  async function detachAndReportStopped() {
147
552
  try {
148
- await apiClient.detach(cfg, workspaceId, attachmentId);
553
+ await apiClient.detach(scopedCfg(), workspaceId, attachmentId);
149
554
  }
150
555
  catch (err) {
151
556
  // Only clear `attachment.json` on a SUCCESSFUL (or already-gone —
@@ -159,6 +564,13 @@ export async function runAttachDaemon(deps) {
159
564
  // retry against, and the next `attach` would then create a SECOND
160
565
  // server-side attachment/tile instead of ever cleaning up the first.
161
566
  log(`Cleanup detach failed: ${err instanceof Error ? err.message : String(err)}`);
567
+ // The record stays (see above) and so does the CREDENTIAL. Card 08 cleared
568
+ // it here on the reasoning that the retry authenticated with the account
569
+ // token — card 09's Boundary B made that false, and `runDetach` now
570
+ // accepts only the scoped credential. Clearing it would leave the retry
571
+ // this comment describes unable to authenticate at all, stranding a live
572
+ // server-side attachment and duplicating the tile on the next attach.
573
+ // (Codex review, gpt-5.6-sol, 2026-08-25.)
162
574
  return { ok: true, reason: 'stopped' };
163
575
  }
164
576
  clearAttachment(env, io);
@@ -171,13 +583,30 @@ export async function runAttachDaemon(deps) {
171
583
  * (forced via the stop-poll below) so the daemon stops instead of
172
584
  * looping forever reconnecting with a dead token. */
173
585
  let refreshFailed;
586
+ /** The same, for the WORKSPACE-SCOPED credential: set when its renewal
587
+ * window has closed (or the server ended the attachment), so the daemon
588
+ * stops instead of streaming on with a credential that is about to start
589
+ * 401ing every heartbeat. Kept separate from `refreshFailed` because the
590
+ * two have different remedies — `yolo-bridge login` vs `yolo-bridge attach`
591
+ * — and different reporting rules: the account failure is the daemon's exit
592
+ * message and may use stdout, this one fires mid-session while the local
593
+ * agent's TUI owns the terminal and must not. */
594
+ let scopedRefreshFailed;
174
595
  try {
175
596
  while (!shouldStop()) {
176
- const preStreamRefresh = await ensureFreshToken();
597
+ const preStreamRefresh = await ensureFreshAccountToken();
177
598
  if (!preStreamRefresh.ok) {
178
599
  refreshFailed = preStreamRefresh;
179
600
  break;
180
601
  }
602
+ // Also before every (re)connect, not only on the heartbeat tick: a long
603
+ // backoff with no stream open is exactly when a scoped credential can
604
+ // cross its renewal point unnoticed.
605
+ const preStreamScopedRefresh = await ensureFreshScopedToken();
606
+ if (!preStreamScopedRefresh.ok) {
607
+ scopedRefreshFailed = preStreamScopedRefresh;
608
+ break;
609
+ }
181
610
  let sawDetached = false;
182
611
  /** Set when the SSE stream itself (or any other call in this attempt)
183
612
  * 404s -- the attachment/workspace no longer exists server-side
@@ -190,7 +619,7 @@ export async function runAttachDaemon(deps) {
190
619
  * `detached` frame that would normally end this loop cleanly. */
191
620
  let sawGone = false;
192
621
  try {
193
- const res = await apiClient.openStream(cfg, workspaceId, attachmentId);
622
+ const res = await apiClient.openStream(scopedCfg(), workspaceId, attachmentId);
194
623
  attempt = 0; // reset backoff on a successful connect
195
624
  const parser = new SseFrameParser();
196
625
  const nodeStream = Readable.fromWeb(res.body);
@@ -219,7 +648,7 @@ export async function runAttachDaemon(deps) {
219
648
  // above) instead of riding out the connection to its next natural
220
649
  // event.
221
650
  const stopPollHandle = timers.setInterval(() => {
222
- if (shouldStop() || refreshFailed) {
651
+ if (shouldStop() || refreshFailed || scopedRefreshFailed) {
223
652
  nodeStream.destroy();
224
653
  }
225
654
  }, STOP_POLL_INTERVAL_MS);
@@ -246,18 +675,35 @@ export async function runAttachDaemon(deps) {
246
675
  // right before `startLocalAgent`, the one moment
247
676
  // guaranteed to be before any agent output regardless of
248
677
  // either timing race.
249
- log('Stream connected.');
678
+ // Out-of-band (was `log('Stream connected.')`): by this
679
+ // point `onAttached` has spawned the local agent, whose
680
+ // PTY is piped to this same stdout — a status line here
681
+ // lands in the middle of the TUI's frame.
682
+ noteConnection('connected');
250
683
  heartbeat?.stop();
251
684
  heartbeat = startHeartbeat(async () => {
252
- const refreshCheck = await ensureFreshToken();
685
+ const refreshCheck = await ensureFreshAccountToken();
253
686
  if (!refreshCheck.ok) {
254
687
  refreshFailed = refreshCheck;
255
688
  return;
256
689
  }
257
- await apiClient.postHeartbeat(cfg, workspaceId, attachmentId);
258
- }, (err) => log(`heartbeat error: ${err instanceof Error ? err.message : String(err)}`), undefined, deps.timers);
690
+ // Renew the scoped credential BEFORE the heartbeat that
691
+ // would use it, so a tick that crosses the renewal point
692
+ // heartbeats with the new token rather than spending one
693
+ // more tick on the old one.
694
+ const scopedCheck = await ensureFreshScopedToken();
695
+ if (!scopedCheck.ok) {
696
+ scopedRefreshFailed = scopedCheck;
697
+ return;
698
+ }
699
+ await apiClient.postHeartbeat(scopedCfg(), workspaceId, attachmentId);
700
+ }, (err) => noteConnection('degraded', {
701
+ detail: `heartbeat error: ${err instanceof Error ? err.message : String(err)}`,
702
+ }), undefined, deps.timers);
259
703
  // 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)}`));
704
+ apiClient.postHeartbeat(scopedCfg(), workspaceId, attachmentId).catch((err) => noteConnection('degraded', {
705
+ detail: `initial heartbeat error: ${err instanceof Error ? err.message : String(err)}`,
706
+ }));
261
707
  break;
262
708
  case 'ping':
263
709
  break;
@@ -267,16 +713,18 @@ export async function runAttachDaemon(deps) {
267
713
  case 'read-output': {
268
714
  const captured = await captureOutput();
269
715
  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)}`));
716
+ .postReadOutputReply(scopedCfg(), workspaceId, attachmentId, action.requestId, captured.output, captured.busy)
717
+ .catch((err) => noteConnection('degraded', {
718
+ detail: `read-output reply failed: ${err instanceof Error ? err.message : String(err)}`,
719
+ }));
272
720
  break;
273
721
  }
274
722
  case 'detached':
275
- log('Detached by server.');
723
+ noteConnection('detached');
276
724
  sawDetached = true;
277
725
  break;
278
726
  case 'unknown':
279
- log(`Unrecognized frame type: ${action.event}`);
727
+ noteConnection('degraded', { detail: `unrecognized frame type: ${action.event}` });
280
728
  break;
281
729
  }
282
730
  if (sawDetached)
@@ -291,7 +739,9 @@ export async function runAttachDaemon(deps) {
291
739
  }
292
740
  }
293
741
  catch (err) {
294
- log(`Stream error: ${err instanceof Error ? err.message : String(err)}`);
742
+ // The reported bug's primary symptom: this is the transient-drop
743
+ // path, and it used to write straight into the agent's PTY stream.
744
+ noteConnection('interrupted', { detail: err instanceof Error ? err.message : String(err) });
295
745
  if (err instanceof apiClient.YoloBridgeApiError && err.status === 404)
296
746
  sawGone = true;
297
747
  }
@@ -301,20 +751,43 @@ export async function runAttachDaemon(deps) {
301
751
  clearAttachment(env, io);
302
752
  return { ok: true, reason: 'detached-by-server' };
303
753
  }
304
- if (refreshFailed)
754
+ if (refreshFailed || scopedRefreshFailed)
305
755
  break;
306
756
  if (shouldStop())
307
757
  break;
308
758
  attempt += 1;
309
759
  const delay = nextBackoffMs(attempt, deps.backoffOpts);
310
- log(`Reconnecting in ${delay}ms (attempt ${attempt})...`);
760
+ noteConnection('reconnecting', { attempt, retryInMs: delay });
311
761
  await sleep(delay);
312
762
  }
313
763
  }
314
764
  finally {
315
765
  heartbeat?.stop();
316
766
  }
767
+ if (scopedRefreshFailed) {
768
+ // OUT-OF-BAND ONLY — no `log()` here, unlike the account-token path below.
769
+ // That path is reached from the daemon's own pre-attach startup or as its
770
+ // terminal exit line; this one fires from inside a live session, where the
771
+ // local agent's PTY is piped to this process's stdout and any human-
772
+ // readable line lands in the middle of a frame its TUI believes it drew
773
+ // (connection-state.ts's module header). `interrupted` is the honest
774
+ // state: the session is ending abnormally — deliberately not `degraded`,
775
+ // which means the LINK is struggling and would make `yolo-bridge status`
776
+ // misreport a healthy session. The remedy travels two ways regardless: in
777
+ // the `detail` here, and as the returned `message`, which cli.ts prints on
778
+ // STDERR after the PTY is already gone.
779
+ noteConnection('interrupted', { detail: scopedRefreshFailed.message });
780
+ // `refresh-failed` (not a new reason) on purpose: cli.ts keys off it to run
781
+ // the best-effort `runDetach()` cleanup that stops a stale attachment being
782
+ // left behind, which is exactly what should happen here too.
783
+ return { ok: false, reason: 'refresh-failed', message: scopedRefreshFailed.message };
784
+ }
317
785
  if (refreshFailed) {
786
+ noteConnection('interrupted', { detail: `token refresh failed: ${refreshFailed.message}` });
787
+ // Still on stdout, deliberately: this is the terminal EXIT message for
788
+ // a daemon that is about to return and let cli.ts kill the local PTY.
789
+ // Unlike the reconnect narration above, there is no frame left to
790
+ // corrupt, and the alternative is an unexplained silent exit.
318
791
  log(`Token refresh failed: ${refreshFailed.message}`);
319
792
  log('Run `yolo-bridge login` again.');
320
793
  return { ok: false, reason: 'refresh-failed', message: refreshFailed.message };
package/dist/cli.js CHANGED
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import { fileURLToPath } from 'node:url';
20
20
  import { realpathSync } from 'node:fs';
21
+ import { hostname } from 'node:os';
21
22
  import { runLogin } from './login-cmd.js';
22
23
  import { runAttachFromDisk, pickWorkspaceFromDisk } from './attach-cmd.js';
23
24
  import { runDetach } from './detach-cmd.js';
@@ -163,6 +164,40 @@ export function parseAttachArgs(args) {
163
164
  }
164
165
  return { workspaceId, hostLabel, agentBin, agentId };
165
166
  }
167
+ /**
168
+ * Everything the attach handshake tells the workspace about this machine,
169
+ * resolved from already-known values — pure, so it's unit-testable without
170
+ * touching a real `os`/`process` (the caller passes the real ones in).
171
+ *
172
+ * Two things happen here:
173
+ *
174
+ * - **`hostLabel` gains a default.** It was previously set ONLY by an
175
+ * explicit `--label`, so the overwhelmingly common `yolo-bridge attach`
176
+ * with no flags produced a tile named a bare "YoloBridge" with nothing
177
+ * identifying WHICH machine had attached — actively confusing for an
178
+ * operator running a daemon on more than one. The machine's own hostname
179
+ * is the obvious default and is already what `--label` is usually set to
180
+ * by hand. An explicit `--label` still wins.
181
+ * - **`remoteHost` is assembled**: the launch directory, the OS platform
182
+ * string, and which agent binary this attach drives.
183
+ *
184
+ * What is deliberately NOT collected, and should not be added without its
185
+ * own consent story: environment variables, anything listing the contents
186
+ * of `cwd`, the OS username or any other account identity, network
187
+ * addresses, or installed-software inventory. This is the operator's own
188
+ * machine being described back to the operator; it is not a survey of it.
189
+ */
190
+ export function resolveAttachHostInfo(input) {
191
+ const label = input.label?.trim();
192
+ return {
193
+ hostLabel: label || input.hostname.trim() || undefined,
194
+ remoteHost: {
195
+ cwd: input.cwd,
196
+ platform: input.platform,
197
+ agent: input.agent,
198
+ },
199
+ };
200
+ }
166
201
  async function cmdAttach(args) {
167
202
  // Printed unconditionally, first thing, regardless of how the rest of
168
203
  // this command goes — a self-diagnosing fix for a real, repeated support
@@ -231,6 +266,16 @@ async function cmdAttach(args) {
231
266
  process.on('SIGINT', onSignal);
232
267
  process.on('SIGTERM', onSignal);
233
268
  const spawnCwd = process.cwd();
269
+ // Everything the workspace tile shows about WHERE this session runs, all
270
+ // resolved here in one place (see resolveAttachHostInfo's doc comment for
271
+ // what is and isn't collected).
272
+ const attachHostInfo = resolveAttachHostInfo({
273
+ label: hostLabel,
274
+ hostname: hostname(),
275
+ cwd: spawnCwd,
276
+ platform: process.platform,
277
+ agent: resolvedAgentId,
278
+ });
234
279
  let mcpProxyHandle;
235
280
  let mcpConfigCleanup;
236
281
  let mcpTrustRemoval;
@@ -239,7 +284,8 @@ async function cmdAttach(args) {
239
284
  result = await runAttachFromDisk({
240
285
  workspaceId,
241
286
  commonApiBaseUrl: apiUrl(),
242
- hostLabel,
287
+ hostLabel: attachHostInfo.hostLabel,
288
+ remoteHost: attachHostInfo.remoteHost,
243
289
  shouldStop: () => stopRequested,
244
290
  // Fires once the real tileId exists (docs/YOLOBRIDGE_PLAN.md's "Local
245
291
  // MCP access" section) — starts the local MCP proxy and writes
@@ -248,7 +294,7 @@ async function cmdAttach(args) {
248
294
  // skipped, not fatal — MCP access is an enhancement on a tile that
249
295
  // already works without it (send_to_tile/read_tile_output are
250
296
  // unaffected either way).
251
- onAttached: async ({ getAccessToken, clearScreen }) => {
297
+ onAttached: async ({ tileId, getAccessToken, clearScreen }) => {
252
298
  // Isolated from `startLocalAgent` below on purpose (Codex review,
253
299
  // 2026-08-24): `startMcpProxy` itself never throws, but
254
300
  // `writeLocalMcpConfig`/`writeLocalMcpTrust` do plain synchronous
@@ -266,6 +312,11 @@ async function cmdAttach(args) {
266
312
  getAccessToken,
267
313
  workspaceId,
268
314
  agentId: resolvedAgentId,
315
+ // Self-identity for the spawned agent: the tile it is running in.
316
+ // Without it, an agent asked to message "the other tile" has to
317
+ // guess which studio_list_tiles row is itself — and a backwards
318
+ // guess sends the prompt into its OWN input.
319
+ callerTileId: tileId,
269
320
  log: (line) => process.stdout.write(`${line}\n`),
270
321
  });
271
322
  // `.mcp.json` + `.claude/settings.json` are Claude Code-specific
@@ -56,10 +56,21 @@ export function loadAuth(env = process.env, io = defaultIO) {
56
56
  try {
57
57
  const parsed = JSON.parse(raw);
58
58
  if (typeof parsed.accessToken === 'string' &&
59
- typeof parsed.refreshToken === 'string' &&
59
+ // Absent is VALID (the scoped path deliberately drops it); present must
60
+ // still be a string. A malformed value is treated as absent rather than
61
+ // as a corrupt file: the access token half is what this record is for,
62
+ // and refusing to load it would log the operator out over a field the
63
+ // daemon may not even need.
64
+ (parsed.refreshToken === undefined || typeof parsed.refreshToken === 'string') &&
60
65
  typeof parsed.tokenType === 'string' &&
61
66
  typeof parsed.expiresAtMs === 'number') {
62
- return parsed;
67
+ const { accessToken, refreshToken, tokenType, expiresAtMs } = parsed;
68
+ return {
69
+ accessToken,
70
+ tokenType,
71
+ expiresAtMs,
72
+ ...(refreshToken ? { refreshToken } : {}),
73
+ };
63
74
  }
64
75
  return undefined;
65
76
  }
@@ -67,8 +78,24 @@ export function loadAuth(env = process.env, io = defaultIO) {
67
78
  return undefined;
68
79
  }
69
80
  }
81
+ /**
82
+ * Writes `auth.json`. A missing (or empty) `refreshToken` is written out as an
83
+ * ABSENT KEY, never as `""`.
84
+ *
85
+ * Normalised here rather than left to callers on purpose: an empty string is a
86
+ * value that every `typeof x === 'string'` check downstream accepts, so it
87
+ * would sail through the loader and be handed to auth-service's refresh
88
+ * endpoint as a credential, turning "we deliberately dropped this" into an
89
+ * unexplained 401. Absent is the honest encoding of absent.
90
+ */
70
91
  export function saveAuth(auth, env = process.env, io = defaultIO) {
71
- io.writeFile(authPath(env), `${JSON.stringify(auth, null, 2)}\n`);
92
+ const record = {
93
+ accessToken: auth.accessToken,
94
+ ...(auth.refreshToken ? { refreshToken: auth.refreshToken } : {}),
95
+ tokenType: auth.tokenType,
96
+ expiresAtMs: auth.expiresAtMs,
97
+ };
98
+ io.writeFile(authPath(env), `${JSON.stringify(record, null, 2)}\n`);
72
99
  }
73
100
  export function clearAuth(env = process.env, io = defaultIO) {
74
101
  io.removeFile(authPath(env));
@@ -83,7 +110,15 @@ export function loadAttachment(env = process.env, io = defaultIO) {
83
110
  typeof parsed.tileId === 'string' &&
84
111
  typeof parsed.attachmentId === 'string' &&
85
112
  typeof parsed.attachedAt === 'string') {
86
- return parsed;
113
+ const { workspaceId, tileId, attachmentId, attachedAt } = parsed;
114
+ // Both-or-neither. A half-pair is dropped rather than rejecting the whole
115
+ // record: the attachment IDENTITY is still perfectly good (detach and the
116
+ // status display need only that), and the daemon simply falls back to a
117
+ // fresh attach instead of resuming.
118
+ const scoped = typeof parsed.scopedToken === 'string' && typeof parsed.scopedTokenExpiresAtMs === 'number'
119
+ ? { scopedToken: parsed.scopedToken, scopedTokenExpiresAtMs: parsed.scopedTokenExpiresAtMs }
120
+ : {};
121
+ return { workspaceId, tileId, attachmentId, attachedAt, ...scoped };
87
122
  }
88
123
  return undefined;
89
124
  }
@@ -94,6 +129,25 @@ export function loadAttachment(env = process.env, io = defaultIO) {
94
129
  export function saveAttachment(attachment, env = process.env, io = defaultIO) {
95
130
  io.writeFile(attachmentPath(env), `${JSON.stringify(attachment, null, 2)}\n`);
96
131
  }
132
+ /**
133
+ * Strips the workspace-scoped credential from `attachment.json`, leaving the
134
+ * attachment identity behind.
135
+ *
136
+ * Distinct from `clearAttachment` because the two answer different questions.
137
+ * `detach` clears the whole record on success — but on FAILURE it deliberately
138
+ * keeps it, so the retry path (`cli.ts`, or a manual `yolo-bridge detach`)
139
+ * knows what to retry against. The stored credential has no such second use:
140
+ * the server refuses it the moment the attachment stops being live, so leaving
141
+ * it on disk is residue that can only ever be leaked, never spent. No-ops when
142
+ * there is nothing stored, and never creates a file.
143
+ */
144
+ export function clearStoredScopedToken(env = process.env, io = defaultIO) {
145
+ const attachment = loadAttachment(env, io);
146
+ if (!attachment || attachment.scopedToken === undefined)
147
+ return;
148
+ const { scopedToken: _dropped, scopedTokenExpiresAtMs: _droppedExpiry, ...rest } = attachment;
149
+ saveAttachment(rest, env, io);
150
+ }
97
151
  export function clearAttachment(env = process.env, io = defaultIO) {
98
152
  io.removeFile(attachmentPath(env));
99
153
  }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Out-of-band connection-state channel for the `yolo-bridge attach` daemon.
3
+ *
4
+ * **Why this module exists (bug, 2026-08-25).** `attach` spawns the user's
5
+ * local coding agent under a real PTY and pipes that PTY straight to this
6
+ * process's own `process.stdout` (`local-agent.ts`'s module header), so from
7
+ * the moment `startLocalAgent` runs the terminal belongs to a full-screen
8
+ * TUI that owns the alternate screen buffer and repaints on its own
9
+ * schedule. The daemon's own `log()` defaults to `process.stdout.write` —
10
+ * the SAME stream — so every connection-state line it emitted from inside
11
+ * the reconnect loop (`Stream error: …`, `Reconnecting in 1000ms
12
+ * (attempt 1)...`, `Stream connected.`) was injected into the middle of a
13
+ * frame the TUI believed it had drawn. The result is a garbled/overlapping
14
+ * display that persists until the agent happens to do a full repaint: a
15
+ * transient wifi blip the daemon recovers from entirely on its own still
16
+ * trashed the user's screen.
17
+ *
18
+ * Writing the same text to `process.stderr` instead is NOT a fix: in an
19
+ * interactive session both file descriptors point at the same tty, so the
20
+ * bytes land in exactly the same place.
21
+ *
22
+ * The fix is to stop putting human-readable status into a stream a TUI is
23
+ * actively rendering to, and route it to a channel that has nothing to do
24
+ * with the terminal. That channel is this file: a small JSON record under
25
+ * the daemon's existing config dir (`~/.config/yolobridge/`, alongside
26
+ * `auth.json`/`attachment.json`) holding the current connection state plus
27
+ * a bounded tail of recent transitions, written through the same injectable
28
+ * `ConfigStoreIO` every other piece of local state already uses.
29
+ *
30
+ * The information is deliberately NOT dropped — it is surfaced two ways:
31
+ * - locally, by `yolo-bridge status` (status-cmd.ts), which reads this
32
+ * record and renders the current state plus recent transitions;
33
+ * - in the workspace, by the tile's own status: yolobridge tile status is
34
+ * derived server-side from `lastHeartbeatAt` (docs/YOLOBRIDGE_PLAN.md's
35
+ * "Status derivation" — ≤30s `running`, ≤90s `paused`, older
36
+ * `stopped`), so a real drop already degrades the tile without the
37
+ * daemon having to narrate it into the PTY.
38
+ *
39
+ * Scoped to one attachment (`attachmentId`): a record left over from a
40
+ * previous attach is ignored rather than shown as if it described the
41
+ * current one, which avoids needing a cleanup call at every teardown site.
42
+ */
43
+ import * as path from 'node:path';
44
+ import { defaultIO, configDir } from './config-store.js';
45
+ /** How many prior transitions to keep. Enough to show "it blipped three
46
+ * times in the last minute" without turning this into a log file. */
47
+ export const MAX_RECENT_EVENTS = 20;
48
+ function connectionPath(env) {
49
+ return path.join(configDir(env), 'connection.json');
50
+ }
51
+ export function loadConnectionState(env = process.env, io = defaultIO) {
52
+ const raw = io.readFile(connectionPath(env));
53
+ if (!raw)
54
+ return undefined;
55
+ try {
56
+ const parsed = JSON.parse(raw);
57
+ if (typeof parsed.attachmentId === 'string' &&
58
+ parsed.current !== undefined &&
59
+ typeof parsed.current.state === 'string' &&
60
+ typeof parsed.current.at === 'string') {
61
+ return {
62
+ attachmentId: parsed.attachmentId,
63
+ current: parsed.current,
64
+ recent: Array.isArray(parsed.recent) ? parsed.recent : [],
65
+ };
66
+ }
67
+ return undefined;
68
+ }
69
+ catch {
70
+ return undefined;
71
+ }
72
+ }
73
+ /**
74
+ * Starts a fresh record for `attachmentId`. Called once, right after the
75
+ * attach succeeds, so `status` never renders a previous attach's history as
76
+ * if it belonged to the live one.
77
+ */
78
+ export function resetConnectionState(attachmentId, event, env = process.env, io = defaultIO) {
79
+ writeState({ attachmentId, current: event, recent: [] }, env, io);
80
+ }
81
+ /**
82
+ * Appends `event` as the new current state, rolling the previous one into
83
+ * the bounded `recent` tail. A record belonging to a different attachment
84
+ * is replaced rather than appended to.
85
+ */
86
+ export function recordConnectionEvent(attachmentId, event, env = process.env, io = defaultIO) {
87
+ const existing = loadConnectionState(env, io);
88
+ if (!existing || existing.attachmentId !== attachmentId) {
89
+ writeState({ attachmentId, current: event, recent: [] }, env, io);
90
+ return;
91
+ }
92
+ const recent = [...existing.recent, existing.current].slice(-MAX_RECENT_EVENTS);
93
+ writeState({ attachmentId, current: event, recent }, env, io);
94
+ }
95
+ function writeState(state, env, io) {
96
+ io.writeFile(connectionPath(env), `${JSON.stringify(state, null, 2)}\n`);
97
+ }
98
+ /** One-line human rendering, used by `yolo-bridge status`. */
99
+ export function formatConnectionEvent(event) {
100
+ const parts = [event.state];
101
+ if (event.state === 'reconnecting' && typeof event.attempt === 'number') {
102
+ parts.push(`(attempt ${event.attempt}${typeof event.retryInMs === 'number' ? `, retrying in ${event.retryInMs}ms` : ''})`);
103
+ }
104
+ parts.push(`at ${event.at}`);
105
+ if (event.detail)
106
+ parts.push(`— ${event.detail}`);
107
+ return parts.join(' ');
108
+ }
@@ -18,20 +18,58 @@
18
18
  * cli.ts).
19
19
  */
20
20
  import { detach as apiDetach } from './api-client.js';
21
- import { loadAuth, loadAttachment, clearAttachment } from './config-store.js';
21
+ import { loadAuth, loadAttachment, clearAttachment, clearStoredScopedToken, } from './config-store.js';
22
22
  export async function runDetach(deps) {
23
- const auth = loadAuth(deps.env, deps.io);
24
- if (!auth)
23
+ // Still a real precondition, but no longer the credential: `auth.json` is
24
+ // what makes this a set-up machine at all, and its absence has a much better
25
+ // remedy to offer than a 403 would.
26
+ if (!loadAuth(deps.env, deps.io)) {
25
27
  return { ok: false, reason: 'not-logged-in', message: 'Not logged in — run `yolo-bridge login` first.' };
28
+ }
26
29
  const attachment = loadAttachment(deps.env, deps.io);
27
30
  if (!attachment)
28
31
  return { ok: false, reason: 'not-attached', message: 'No active attachment found.' };
32
+ // READ THE CREDENTIAL BEFORE ANY CLEARING BELOW. `DELETE .../attach/:id` is a
33
+ // daemon-only route behind Boundary B (card 09): an account token is refused
34
+ // there with 403 YOLOBRIDGE_SCOPED_TOKEN_REQUIRED, so this command must
35
+ // present the workspace-scoped credential `attach` persisted alongside the
36
+ // attachment identity — the same one the running daemon uses.
37
+ const scopedToken = attachment.scopedToken;
38
+ if (!scopedToken) {
39
+ // No credential the daemon surface will accept, and nothing on this machine
40
+ // can mint one for an attachment that already exists. Say so plainly rather
41
+ // than sending an account token to be refused: the operator's real remedy
42
+ // is to let the tile go stale on its own (the server stops it once the
43
+ // heartbeat lapses) or to re-attach.
44
+ return {
45
+ ok: false,
46
+ reason: 'error',
47
+ message: 'No workspace-scoped credential is stored for this attachment, so it cannot be '
48
+ + 'detached from this machine. The tile stops on its own once its heartbeat lapses; '
49
+ + 'run `yolo-bridge attach` to reconnect.',
50
+ };
51
+ }
29
52
  try {
30
- await apiDetach({ commonApiBaseUrl: deps.commonApiBaseUrl, accessToken: auth.accessToken, fetchImpl: deps.fetchImpl }, attachment.workspaceId, attachment.attachmentId);
53
+ await apiDetach({ commonApiBaseUrl: deps.commonApiBaseUrl, accessToken: scopedToken, fetchImpl: deps.fetchImpl }, attachment.workspaceId, attachment.attachmentId);
31
54
  }
32
55
  catch (err) {
56
+ // The attachment RECORD **and** its credential are both kept on a genuine
57
+ // failure, because the retry needs both.
58
+ //
59
+ // Card 08 stripped the credential here, reasoning it was leakable residue
60
+ // the server would refuse anyway. Card 09 invalidated that: the scoped
61
+ // credential is now the ONLY thing Boundary B accepts on this route, so
62
+ // discarding it made every retry — automatic or manual — take the
63
+ // no-credential branch above. The attachment stays live server-side, this
64
+ // machine can no longer remove it, and the next `attach` creates a SECOND
65
+ // attachment and tile. (Codex review, gpt-5.6-sol, 2026-08-25: two P1s.)
66
+ //
67
+ // The credential is discarded only on a SUCCESSFUL or confirmed-gone
68
+ // detach — the same rule the record already follows, and for the same
69
+ // reason: the two are only useful together.
33
70
  return { ok: false, reason: 'error', message: err instanceof Error ? err.message : String(err) };
34
71
  }
72
+ clearStoredScopedToken(deps.env, deps.io);
35
73
  clearAttachment(deps.env, deps.io);
36
74
  return { ok: true };
37
75
  }
package/dist/mcp-proxy.js CHANGED
@@ -167,7 +167,7 @@ async function fetchAllScopes(apiUrl, fetchImpl, tracker) {
167
167
  /** One cached, self-refreshing, workspace-wide token — scoped to whatever
168
168
  * `agentId`'s registry entry actually allows out of the full scope
169
169
  * universe (see this file's header comment). */
170
- function makeTokenCache(apiUrl, getAccessToken, workspaceId, agentId, fetchImpl, tracker) {
170
+ function makeTokenCache(apiUrl, getAccessToken, workspaceId, agentId, callerTileId, fetchImpl, tracker) {
171
171
  let cached;
172
172
  async function mint() {
173
173
  const scopes = await fetchAllScopes(apiUrl, fetchImpl, tracker);
@@ -179,6 +179,7 @@ function makeTokenCache(apiUrl, getAccessToken, workspaceId, agentId, fetchImpl,
179
179
  workspaceId,
180
180
  agentId,
181
181
  scopes,
182
+ ...(callerTileId ? { callerTileId } : {}),
182
183
  ttlSeconds: REQUESTED_TTL_SECONDS,
183
184
  }),
184
185
  signal,
@@ -243,7 +244,7 @@ export async function startMcpProxy(opts) {
243
244
  const log = opts.log ?? (() => { });
244
245
  const fetchImpl = opts.fetchImpl ?? fetch;
245
246
  const tracker = new RequestTracker();
246
- const tokenCache = makeTokenCache(opts.apiUrl, opts.getAccessToken, opts.workspaceId, opts.agentId, fetchImpl, tracker);
247
+ const tokenCache = makeTokenCache(opts.apiUrl, opts.getAccessToken, opts.workspaceId, opts.agentId, opts.callerTileId, fetchImpl, tracker);
247
248
  // Bounded (STARTUP_MINT_TIMEOUT_MS): a stalled scope-discovery/mint fetch
248
249
  // must not block `onAttached` from ever reaching `startLocalAgent`
249
250
  // (Codex review, 2026-08-24). `abortAll()` only affects requests in
@@ -11,8 +11,18 @@
11
11
  * a pidfile / lockfile next to attachment.json to close that gap; not
12
12
  * implemented here — flagged as a known limitation, not silently glossed
13
13
  * over.
14
+ *
15
+ * Partially narrowed since (2026-08-25): the daemon now records every
16
+ * connection transition to `connection.json` (connection-state.ts) instead
17
+ * of narrating it into the terminal the local agent's TUI is rendering
18
+ * into, and this command renders that record. That still isn't liveness —
19
+ * a crashed daemon leaves its last transition frozen on disk, so
20
+ * `Connection: connected` means "the last thing it managed to record",
21
+ * not "it is connected right now" — but a drop/reconnect blip is no
22
+ * longer invisible just because it can't be printed to the screen.
14
23
  */
15
24
  import { loadAuth, loadAttachment } from './config-store.js';
25
+ import { loadConnectionState, formatConnectionEvent } from './connection-state.js';
16
26
  export function getStatus(deps = {}) {
17
27
  const now = deps.now ?? Date.now;
18
28
  const auth = loadAuth(deps.env, deps.io);
@@ -30,6 +40,14 @@ export function getStatus(deps = {}) {
30
40
  report.tileId = attachment.tileId;
31
41
  report.attachmentId = attachment.attachmentId;
32
42
  report.attachedAt = attachment.attachedAt;
43
+ const connection = loadConnectionState(deps.env, deps.io);
44
+ // Only report a record that belongs to the CURRENT attachment — a
45
+ // leftover from a previous attach describes a connection that no
46
+ // longer exists and would be actively misleading here.
47
+ if (connection && connection.attachmentId === attachment.attachmentId) {
48
+ report.connection = connection.current;
49
+ report.connectionHistory = connection.recent;
50
+ }
33
51
  }
34
52
  return report;
35
53
  }
@@ -47,6 +65,18 @@ export function formatStatus(report) {
47
65
  else {
48
66
  lines.push(`Attached: yes — workspace=${report.workspaceId} tile=${report.tileId} attachmentId=${report.attachmentId} since=${report.attachedAt}`);
49
67
  lines.push('(local file only — does not confirm the attach daemon process is still running/connected)');
68
+ if (report.connection) {
69
+ lines.push(`Connection: ${formatConnectionEvent(report.connection)}`);
70
+ const history = report.connectionHistory ?? [];
71
+ if (history.length > 0) {
72
+ // Newest first — a reconnect blip is easiest to read as "what just
73
+ // happened", not "what happened when this attach started".
74
+ lines.push('Recent connection events (newest first):');
75
+ for (const event of [...history].reverse()) {
76
+ lines.push(` ${formatConnectionEvent(event)}`);
77
+ }
78
+ }
79
+ }
50
80
  }
51
81
  return lines.join('\n');
52
82
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yolo-labs/yolobridge",
3
- "version": "0.2.0",
3
+ "version": "0.7.1",
4
4
  "description": "YoloBridge — local coding-agent daemon that attaches a user's own Claude Code/Codex session to a YOLO Studio workspace as a first-class tile (docs/YOLOBRIDGE_PLAN.md, build-order Phase 5).",
5
5
  "license": "MIT",
6
6
  "type": "module",