@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.
- package/dist/api-client.js +88 -3
- package/dist/attach-cmd.js +511 -38
- package/dist/cli.js +53 -2
- 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/api-client.js
CHANGED
|
@@ -29,12 +29,40 @@ async function parseErrorBody(res) {
|
|
|
29
29
|
return { message: `HTTP ${res.status}` };
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
|
-
|
|
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(
|
|
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
|
-
|
|
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}`, {
|
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,244 @@ 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');
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
685
|
+
const refreshCheck = await ensureFreshAccountToken();
|
|
253
686
|
if (!refreshCheck.ok) {
|
|
254
687
|
refreshFailed = refreshCheck;
|
|
255
688
|
return;
|
|
256
689
|
}
|
|
257
|
-
|
|
258
|
-
|
|
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(
|
|
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(
|
|
271
|
-
.catch((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
|
-
|
|
723
|
+
noteConnection('detached');
|
|
276
724
|
sawDetached = true;
|
|
277
725
|
break;
|
|
278
726
|
case 'unknown':
|
|
279
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/dist/config-store.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/detach-cmd.js
CHANGED
|
@@ -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
|
-
|
|
24
|
-
|
|
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:
|
|
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
|
package/dist/status-cmd.js
CHANGED
|
@@ -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.
|
|
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",
|