@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.
- package/dist/api-client.js +88 -3
- package/dist/attach-cmd.js +535 -44
- package/dist/cli.js +70 -5
- package/dist/config-store.js +58 -4
- package/dist/connection-state.js +108 -0
- package/dist/detach-cmd.js +42 -4
- package/dist/mcp-proxy.js +3 -2
- package/dist/status-cmd.js +30 -0
- package/package.json +1 -1
package/dist/attach-cmd.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
log
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
703
|
+
const refreshCheck = await ensureFreshAccountToken();
|
|
253
704
|
if (!refreshCheck.ok) {
|
|
254
705
|
refreshFailed = refreshCheck;
|
|
255
706
|
return;
|
|
256
707
|
}
|
|
257
|
-
|
|
258
|
-
|
|
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(
|
|
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(
|
|
271
|
-
.catch((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
|
-
|
|
741
|
+
noteConnection('detached');
|
|
276
742
|
sawDetached = true;
|
|
277
743
|
break;
|
|
278
744
|
case 'unknown':
|
|
279
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|