@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/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';
|
|
@@ -93,6 +94,10 @@ function printHelp() {
|
|
|
93
94
|
' [--agent-id <id>] Registry identity for local MCP access, if different from --agent',
|
|
94
95
|
' (e.g. a raw executable path, or an agent whose binary name differs',
|
|
95
96
|
' from its registry id like qwen-code/qwen). Defaults to --agent.',
|
|
97
|
+
' [--fresh] Always create a NEW attachment and tile. By default an attach that',
|
|
98
|
+
' finds a still-live attachment for this workspace on this machine',
|
|
99
|
+
' RESUMES it (same tile) instead of adding a duplicate; --fresh skips',
|
|
100
|
+
' that check entirely.',
|
|
96
101
|
' detach Detach the current workspace attachment.',
|
|
97
102
|
' status Print local login/attach state.',
|
|
98
103
|
' --help Print this help.',
|
|
@@ -138,8 +143,16 @@ export function parseAttachArgs(args) {
|
|
|
138
143
|
let hostLabel;
|
|
139
144
|
let agentBin;
|
|
140
145
|
let agentId;
|
|
146
|
+
let fresh = false;
|
|
141
147
|
for (let i = 0; i < args.length; i++) {
|
|
142
148
|
const a = args[i];
|
|
149
|
+
// Boolean — handled before the value-taking flags so it never swallows
|
|
150
|
+
// the following argument (`attach --fresh w1` must still see `w1` as the
|
|
151
|
+
// positional workspace id).
|
|
152
|
+
if (a === '--fresh') {
|
|
153
|
+
fresh = true;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
143
156
|
if (a === '--label' || a === '--agent' || a === '--agent-id') {
|
|
144
157
|
const value = args[i + 1];
|
|
145
158
|
if (value === undefined || value.startsWith('--')) {
|
|
@@ -161,7 +174,41 @@ export function parseAttachArgs(args) {
|
|
|
161
174
|
workspaceId = a;
|
|
162
175
|
}
|
|
163
176
|
}
|
|
164
|
-
return { workspaceId, hostLabel, agentBin, agentId };
|
|
177
|
+
return { workspaceId, hostLabel, agentBin, agentId, fresh };
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Everything the attach handshake tells the workspace about this machine,
|
|
181
|
+
* resolved from already-known values — pure, so it's unit-testable without
|
|
182
|
+
* touching a real `os`/`process` (the caller passes the real ones in).
|
|
183
|
+
*
|
|
184
|
+
* Two things happen here:
|
|
185
|
+
*
|
|
186
|
+
* - **`hostLabel` gains a default.** It was previously set ONLY by an
|
|
187
|
+
* explicit `--label`, so the overwhelmingly common `yolo-bridge attach`
|
|
188
|
+
* with no flags produced a tile named a bare "YoloBridge" with nothing
|
|
189
|
+
* identifying WHICH machine had attached — actively confusing for an
|
|
190
|
+
* operator running a daemon on more than one. The machine's own hostname
|
|
191
|
+
* is the obvious default and is already what `--label` is usually set to
|
|
192
|
+
* by hand. An explicit `--label` still wins.
|
|
193
|
+
* - **`remoteHost` is assembled**: the launch directory, the OS platform
|
|
194
|
+
* string, and which agent binary this attach drives.
|
|
195
|
+
*
|
|
196
|
+
* What is deliberately NOT collected, and should not be added without its
|
|
197
|
+
* own consent story: environment variables, anything listing the contents
|
|
198
|
+
* of `cwd`, the OS username or any other account identity, network
|
|
199
|
+
* addresses, or installed-software inventory. This is the operator's own
|
|
200
|
+
* machine being described back to the operator; it is not a survey of it.
|
|
201
|
+
*/
|
|
202
|
+
export function resolveAttachHostInfo(input) {
|
|
203
|
+
const label = input.label?.trim();
|
|
204
|
+
return {
|
|
205
|
+
hostLabel: label || input.hostname.trim() || undefined,
|
|
206
|
+
remoteHost: {
|
|
207
|
+
cwd: input.cwd,
|
|
208
|
+
platform: input.platform,
|
|
209
|
+
agent: input.agent,
|
|
210
|
+
},
|
|
211
|
+
};
|
|
165
212
|
}
|
|
166
213
|
async function cmdAttach(args) {
|
|
167
214
|
// Printed unconditionally, first thing, regardless of how the rest of
|
|
@@ -175,7 +222,7 @@ async function cmdAttach(args) {
|
|
|
175
222
|
const parsed = parseAttachArgs(args);
|
|
176
223
|
if ('error' in parsed) {
|
|
177
224
|
process.stderr.write(`yolo-bridge attach: ${parsed.error}\n`);
|
|
178
|
-
process.stderr.write('Usage: yolo-bridge attach [workspaceId] [--label <name>] [--agent <binary>] [--agent-id <registryId>]\n');
|
|
225
|
+
process.stderr.write('Usage: yolo-bridge attach [workspaceId] [--label <name>] [--agent <binary>] [--agent-id <registryId>] [--fresh]\n');
|
|
179
226
|
return 64;
|
|
180
227
|
}
|
|
181
228
|
let workspaceId = parsed.workspaceId;
|
|
@@ -187,6 +234,7 @@ async function cmdAttach(args) {
|
|
|
187
234
|
// match, which is every built-in agent this daemon has been used with so
|
|
188
235
|
// far (claude, codex).
|
|
189
236
|
const resolvedAgentId = parsed.agentId ?? agentBin ?? DEFAULT_AGENT_BIN;
|
|
237
|
+
const fresh = parsed.fresh === true;
|
|
190
238
|
if (workspaceId) {
|
|
191
239
|
const resolved = await resolveWorkspaceIdOrName(workspaceId, { commonApiBaseUrl: apiUrl() });
|
|
192
240
|
if (!resolved.ok) {
|
|
@@ -215,7 +263,7 @@ async function cmdAttach(args) {
|
|
|
215
263
|
process.stderr.write(`yolo-bridge attach: ${pick.message}\n`);
|
|
216
264
|
break;
|
|
217
265
|
}
|
|
218
|
-
process.stderr.write('Usage: yolo-bridge attach [workspaceId] [--label <name>] [--agent <binary>] [--agent-id <registryId>]\n');
|
|
266
|
+
process.stderr.write('Usage: yolo-bridge attach [workspaceId] [--label <name>] [--agent <binary>] [--agent-id <registryId>] [--fresh]\n');
|
|
219
267
|
return 64;
|
|
220
268
|
}
|
|
221
269
|
workspaceId = pick.workspaceId;
|
|
@@ -231,6 +279,16 @@ async function cmdAttach(args) {
|
|
|
231
279
|
process.on('SIGINT', onSignal);
|
|
232
280
|
process.on('SIGTERM', onSignal);
|
|
233
281
|
const spawnCwd = process.cwd();
|
|
282
|
+
// Everything the workspace tile shows about WHERE this session runs, all
|
|
283
|
+
// resolved here in one place (see resolveAttachHostInfo's doc comment for
|
|
284
|
+
// what is and isn't collected).
|
|
285
|
+
const attachHostInfo = resolveAttachHostInfo({
|
|
286
|
+
label: hostLabel,
|
|
287
|
+
hostname: hostname(),
|
|
288
|
+
cwd: spawnCwd,
|
|
289
|
+
platform: process.platform,
|
|
290
|
+
agent: resolvedAgentId,
|
|
291
|
+
});
|
|
234
292
|
let mcpProxyHandle;
|
|
235
293
|
let mcpConfigCleanup;
|
|
236
294
|
let mcpTrustRemoval;
|
|
@@ -239,7 +297,9 @@ async function cmdAttach(args) {
|
|
|
239
297
|
result = await runAttachFromDisk({
|
|
240
298
|
workspaceId,
|
|
241
299
|
commonApiBaseUrl: apiUrl(),
|
|
242
|
-
hostLabel,
|
|
300
|
+
hostLabel: attachHostInfo.hostLabel,
|
|
301
|
+
remoteHost: attachHostInfo.remoteHost,
|
|
302
|
+
fresh,
|
|
243
303
|
shouldStop: () => stopRequested,
|
|
244
304
|
// Fires once the real tileId exists (docs/YOLOBRIDGE_PLAN.md's "Local
|
|
245
305
|
// MCP access" section) — starts the local MCP proxy and writes
|
|
@@ -248,7 +308,7 @@ async function cmdAttach(args) {
|
|
|
248
308
|
// skipped, not fatal — MCP access is an enhancement on a tile that
|
|
249
309
|
// already works without it (send_to_tile/read_tile_output are
|
|
250
310
|
// unaffected either way).
|
|
251
|
-
onAttached: async ({ getAccessToken, clearScreen }) => {
|
|
311
|
+
onAttached: async ({ tileId, getAccessToken, clearScreen }) => {
|
|
252
312
|
// Isolated from `startLocalAgent` below on purpose (Codex review,
|
|
253
313
|
// 2026-08-24): `startMcpProxy` itself never throws, but
|
|
254
314
|
// `writeLocalMcpConfig`/`writeLocalMcpTrust` do plain synchronous
|
|
@@ -266,6 +326,11 @@ async function cmdAttach(args) {
|
|
|
266
326
|
getAccessToken,
|
|
267
327
|
workspaceId,
|
|
268
328
|
agentId: resolvedAgentId,
|
|
329
|
+
// Self-identity for the spawned agent: the tile it is running in.
|
|
330
|
+
// Without it, an agent asked to message "the other tile" has to
|
|
331
|
+
// guess which studio_list_tiles row is itself — and a backwards
|
|
332
|
+
// guess sends the prompt into its OWN input.
|
|
333
|
+
callerTileId: tileId,
|
|
269
334
|
log: (line) => process.stdout.write(`${line}\n`),
|
|
270
335
|
});
|
|
271
336
|
// `.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.8.0",
|
|
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",
|