@yolo-labs/yolobridge 0.1.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 +136 -0
- package/dist/attach-cmd.js +299 -0
- package/dist/browser-open.js +46 -0
- package/dist/cli.js +353 -0
- package/dist/config-store.js +99 -0
- package/dist/detach-cmd.js +37 -0
- package/dist/device-auth.js +149 -0
- package/dist/frame-actions.js +34 -0
- package/dist/heartbeat.js +31 -0
- package/dist/local-agent.js +437 -0
- package/dist/login-cmd.js +50 -0
- package/dist/reconnect.js +41 -0
- package/dist/sse-frame-parser.js +66 -0
- package/dist/status-cmd.js +52 -0
- package/dist/workspaces-cmd.js +37 -0
- package/package.json +34 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin HTTP client for the daemon-facing YoloBridge surface
|
|
3
|
+
* (`common-api/src/routes/yolobridge.ts`, mounted under
|
|
4
|
+
* `/v1/workspaces/:workspaceId/yolobridge`). Shapes here are read
|
|
5
|
+
* directly off that route file + `yolobridge-service.ts`, not guessed.
|
|
6
|
+
*/
|
|
7
|
+
export class YoloBridgeApiError extends Error {
|
|
8
|
+
status;
|
|
9
|
+
code;
|
|
10
|
+
constructor(message, status, code) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.status = status;
|
|
13
|
+
this.code = code;
|
|
14
|
+
this.name = 'YoloBridgeApiError';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function base(cfg) {
|
|
18
|
+
return cfg.commonApiBaseUrl.replace(/\/+$/, '');
|
|
19
|
+
}
|
|
20
|
+
function authHeaders(cfg) {
|
|
21
|
+
return { Authorization: `Bearer ${cfg.accessToken}` };
|
|
22
|
+
}
|
|
23
|
+
async function parseErrorBody(res) {
|
|
24
|
+
try {
|
|
25
|
+
const body = (await res.json());
|
|
26
|
+
return { message: typeof body?.error === 'string' ? body.error : `HTTP ${res.status}`, code: body?.code };
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return { message: `HTTP ${res.status}` };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export async function attach(cfg, workspaceId, hostLabel) {
|
|
33
|
+
const fetchImpl = cfg.fetchImpl ?? fetch;
|
|
34
|
+
const res = await fetchImpl(`${base(cfg)}/v1/workspaces/${workspaceId}/yolobridge/attach`, {
|
|
35
|
+
method: 'POST',
|
|
36
|
+
headers: { ...authHeaders(cfg), 'Content-Type': 'application/json' },
|
|
37
|
+
body: JSON.stringify(hostLabel ? { hostLabel } : {}),
|
|
38
|
+
});
|
|
39
|
+
if (!res.ok) {
|
|
40
|
+
const { message, code } = await parseErrorBody(res);
|
|
41
|
+
throw new YoloBridgeApiError(`attach failed: ${message}`, res.status, code);
|
|
42
|
+
}
|
|
43
|
+
const body = (await res.json());
|
|
44
|
+
if (typeof body?.tileId !== 'string' || typeof body?.attachmentId !== 'string') {
|
|
45
|
+
throw new YoloBridgeApiError('attach returned an unexpected shape', res.status);
|
|
46
|
+
}
|
|
47
|
+
return { tileId: body.tileId, attachmentId: body.attachmentId };
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* `GET /v1/workspaces/selectable` — slim `{id,name,status}` list of the
|
|
51
|
+
* caller's own non-terminated, non-ephemeral workspaces (capped at 500
|
|
52
|
+
* server-side), authenticated the same `flexibleAuth` tier as every other
|
|
53
|
+
* call in this file. There's no membership/role model in this codebase —
|
|
54
|
+
* a user's workspaces are strictly `{ userId: <them> }` — so this is a
|
|
55
|
+
* single owner-scoped list, not a "workspaces I can see" query.
|
|
56
|
+
* (`common-api/src/routes/workspaces.ts`, `WorkspaceService.listSelectable`.)
|
|
57
|
+
*/
|
|
58
|
+
export async function listSelectableWorkspaces(cfg) {
|
|
59
|
+
const fetchImpl = cfg.fetchImpl ?? fetch;
|
|
60
|
+
const res = await fetchImpl(`${base(cfg)}/v1/workspaces/selectable`, {
|
|
61
|
+
method: 'GET',
|
|
62
|
+
headers: authHeaders(cfg),
|
|
63
|
+
});
|
|
64
|
+
if (!res.ok) {
|
|
65
|
+
const { message, code } = await parseErrorBody(res);
|
|
66
|
+
throw new YoloBridgeApiError(`list workspaces failed: ${message}`, res.status, code);
|
|
67
|
+
}
|
|
68
|
+
const body = (await res.json());
|
|
69
|
+
if (!Array.isArray(body?.workspaces)) {
|
|
70
|
+
throw new YoloBridgeApiError('list workspaces returned an unexpected shape', res.status);
|
|
71
|
+
}
|
|
72
|
+
const workspaces = [];
|
|
73
|
+
for (const w of body.workspaces) {
|
|
74
|
+
if (typeof w?.id !== 'string' || typeof w?.status !== 'string') {
|
|
75
|
+
throw new YoloBridgeApiError('list workspaces returned an unexpected shape', res.status);
|
|
76
|
+
}
|
|
77
|
+
workspaces.push({ id: w.id, name: typeof w.name === 'string' ? w.name : '', status: w.status });
|
|
78
|
+
}
|
|
79
|
+
return workspaces;
|
|
80
|
+
}
|
|
81
|
+
export async function detach(cfg, workspaceId, attachmentId) {
|
|
82
|
+
const fetchImpl = cfg.fetchImpl ?? fetch;
|
|
83
|
+
const res = await fetchImpl(`${base(cfg)}/v1/workspaces/${workspaceId}/yolobridge/attach/${attachmentId}`, {
|
|
84
|
+
method: 'DELETE',
|
|
85
|
+
headers: authHeaders(cfg),
|
|
86
|
+
});
|
|
87
|
+
// 204 on success; a 404 (already detached) is treated as success too —
|
|
88
|
+
// detach is idempotent from the CLI's point of view.
|
|
89
|
+
if (!res.ok && res.status !== 404) {
|
|
90
|
+
const { message, code } = await parseErrorBody(res);
|
|
91
|
+
throw new YoloBridgeApiError(`detach failed: ${message}`, res.status, code);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** Opens the raw `GET /stream` connection. Caller owns reading/parsing `res.body`. */
|
|
95
|
+
export async function openStream(cfg, workspaceId, attachmentId) {
|
|
96
|
+
const fetchImpl = cfg.fetchImpl ?? fetch;
|
|
97
|
+
const url = `${base(cfg)}/v1/workspaces/${workspaceId}/yolobridge/stream?attachmentId=${encodeURIComponent(attachmentId)}`;
|
|
98
|
+
const res = await fetchImpl(url, { method: 'GET', headers: { ...authHeaders(cfg), Accept: 'text/event-stream' } });
|
|
99
|
+
if (!res.ok || !res.body) {
|
|
100
|
+
const { message, code } = await parseErrorBody(res);
|
|
101
|
+
throw new YoloBridgeApiError(`stream open failed: ${message}`, res.status, code);
|
|
102
|
+
}
|
|
103
|
+
return res;
|
|
104
|
+
}
|
|
105
|
+
export async function postHeartbeat(cfg, workspaceId, attachmentId) {
|
|
106
|
+
const body = await postEvent(cfg, workspaceId, { attachmentId, type: 'heartbeat' });
|
|
107
|
+
return Boolean(body?.recorded);
|
|
108
|
+
}
|
|
109
|
+
export async function postReadOutputReply(cfg, workspaceId, attachmentId, requestId, output, busy) {
|
|
110
|
+
const body = await postEvent(cfg, workspaceId, {
|
|
111
|
+
attachmentId,
|
|
112
|
+
type: 'read-output-reply',
|
|
113
|
+
requestId,
|
|
114
|
+
output,
|
|
115
|
+
busy,
|
|
116
|
+
});
|
|
117
|
+
return Boolean(body?.resolved);
|
|
118
|
+
}
|
|
119
|
+
async function postEvent(cfg, workspaceId, payload) {
|
|
120
|
+
const fetchImpl = cfg.fetchImpl ?? fetch;
|
|
121
|
+
const res = await fetchImpl(`${base(cfg)}/v1/workspaces/${workspaceId}/yolobridge/events`, {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
headers: { ...authHeaders(cfg), 'Content-Type': 'application/json' },
|
|
124
|
+
body: JSON.stringify(payload),
|
|
125
|
+
});
|
|
126
|
+
if (!res.ok) {
|
|
127
|
+
const { message, code } = await parseErrorBody(res);
|
|
128
|
+
throw new YoloBridgeApiError(`events (${payload.type}) failed: ${message}`, res.status, code);
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
return await res.json();
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `yolo-bridge attach <workspaceId>` — the daemon loop.
|
|
3
|
+
*
|
|
4
|
+
* Calls `POST .../yolobridge/attach`, then holds `GET .../yolobridge/stream`
|
|
5
|
+
* open: parses SSE frames (sse-frame-parser.ts), dispatches them
|
|
6
|
+
* (frame-actions.ts), reconnects with backoff on disconnect
|
|
7
|
+
* (reconnect.ts), and posts a heartbeat every ~10s while connected
|
|
8
|
+
* (heartbeat.ts). All the pure logic lives in those sibling modules and is
|
|
9
|
+
* unit tested there; this file is the network/process glue that wires
|
|
10
|
+
* them together, plus a light structural test below driving it through a
|
|
11
|
+
* fake in-memory SSE stream.
|
|
12
|
+
*
|
|
13
|
+
* Sleep/wake-aware resilience: see reconnect.ts's header comment — plain
|
|
14
|
+
* bounded exponential backoff is implemented; true OS sleep/wake signal
|
|
15
|
+
* detection is NOT, and is called out there and in the final report as a
|
|
16
|
+
* deliberate scope cut.
|
|
17
|
+
*/
|
|
18
|
+
import { Readable } from 'node:stream';
|
|
19
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
20
|
+
import * as readline from 'node:readline';
|
|
21
|
+
import { SseFrameParser } from './sse-frame-parser.js';
|
|
22
|
+
import { actionForFrame } from './frame-actions.js';
|
|
23
|
+
import { startHeartbeat, defaultTimers } from './heartbeat.js';
|
|
24
|
+
import { nextBackoffMs } from './reconnect.js';
|
|
25
|
+
import { deliverPromptToLocalAgent, captureLocalAgentOutput } from './local-agent.js';
|
|
26
|
+
import * as apiClient from './api-client.js';
|
|
27
|
+
import { refreshAccessToken as refreshAccessTokenApi } from './device-auth.js';
|
|
28
|
+
import { loadAuth, saveAuth, saveAttachment, clearAttachment } from './config-store.js';
|
|
29
|
+
const DEFAULT_AUTH_URL = 'https://auth.yololabs.ai';
|
|
30
|
+
/** Refresh once the access token has less than this much validity left.
|
|
31
|
+
* Production access tokens live 24h; 5min gives ample margin against a
|
|
32
|
+
* slow/retried refresh call before the old token actually 401s. */
|
|
33
|
+
const DEFAULT_REFRESH_BUFFER_MS = 5 * 60_000;
|
|
34
|
+
/** How often to re-check `shouldStop()`/a failed-refresh flag while an SSE
|
|
35
|
+
* stream is open and blocked on `for await`. The server holds the stream
|
|
36
|
+
* open indefinitely (keepalive pings only), so without an active poll here
|
|
37
|
+
* a stop signal would never be noticed until the stream happened to end on
|
|
38
|
+
* its own — which, by design, it doesn't. Small enough to be prompt,
|
|
39
|
+
* cheap enough to not matter (a no-op comparison on every tick). */
|
|
40
|
+
const STOP_POLL_INTERVAL_MS = 250;
|
|
41
|
+
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
42
|
+
export async function runAttachDaemon(deps) {
|
|
43
|
+
const { workspaceId, commonApiBaseUrl, hostLabel, auth, env, io, fetchImpl, } = deps;
|
|
44
|
+
const shouldStop = deps.shouldStop ?? (() => false);
|
|
45
|
+
const sleep = deps.sleep ?? defaultSleep;
|
|
46
|
+
const log = deps.log ?? ((line) => process.stdout.write(`${line}\n`));
|
|
47
|
+
const deliverPrompt = deps.deliverPrompt ?? deliverPromptToLocalAgent;
|
|
48
|
+
const captureOutput = deps.captureOutput ?? captureLocalAgentOutput;
|
|
49
|
+
const timers = deps.timers ?? defaultTimers;
|
|
50
|
+
const now = deps.now ?? Date.now;
|
|
51
|
+
const refreshBufferMs = deps.refreshBufferMs ?? DEFAULT_REFRESH_BUFFER_MS;
|
|
52
|
+
const authBaseUrl = deps.authBaseUrl ?? process.env.YOLOBRIDGE_AUTH_URL ?? DEFAULT_AUTH_URL;
|
|
53
|
+
const doRefresh = deps.refreshAccessToken ?? refreshAccessTokenApi;
|
|
54
|
+
const cfg = { commonApiBaseUrl, accessToken: auth.accessToken, fetchImpl };
|
|
55
|
+
let currentAuth = auth;
|
|
56
|
+
/**
|
|
57
|
+
* Proactive refresh (Bug 2 fix): checked before opening/reopening the
|
|
58
|
+
* stream and on every heartbeat tick while connected, so the daemon
|
|
59
|
+
* rotates its access token well before the 24h production expiry
|
|
60
|
+
* instead of degrading into a silent zombie that just starts 401ing.
|
|
61
|
+
* Updates both the in-memory `cfg`/`currentAuth` used by every
|
|
62
|
+
* subsequent API call in this process AND the on-disk auth.json (via
|
|
63
|
+
* `saveAuth`) so a later `status`/restart also sees the fresh token.
|
|
64
|
+
*/
|
|
65
|
+
async function ensureFreshToken() {
|
|
66
|
+
if (now() < currentAuth.expiresAtMs - refreshBufferMs)
|
|
67
|
+
return { ok: true };
|
|
68
|
+
const result = await doRefresh(authBaseUrl, currentAuth.refreshToken, fetchImpl);
|
|
69
|
+
if (result.status !== 'ok') {
|
|
70
|
+
return { ok: false, message: result.message };
|
|
71
|
+
}
|
|
72
|
+
currentAuth = {
|
|
73
|
+
accessToken: result.tokens.accessToken,
|
|
74
|
+
refreshToken: result.tokens.refreshToken,
|
|
75
|
+
tokenType: currentAuth.tokenType,
|
|
76
|
+
expiresAtMs: result.tokens.expiresAtMs,
|
|
77
|
+
};
|
|
78
|
+
cfg.accessToken = currentAuth.accessToken;
|
|
79
|
+
saveAuth(currentAuth, env, io);
|
|
80
|
+
log('Access token refreshed.');
|
|
81
|
+
return { ok: true };
|
|
82
|
+
}
|
|
83
|
+
// Cover the case where the daemon is (re)started against a token that's
|
|
84
|
+
// already within the refresh buffer of expiry (e.g. `attach` run right
|
|
85
|
+
// after a long-down period) — refresh before the very first network
|
|
86
|
+
// call, not just before subsequent reconnects.
|
|
87
|
+
const initialRefresh = await ensureFreshToken();
|
|
88
|
+
if (!initialRefresh.ok) {
|
|
89
|
+
log(`Token refresh failed: ${initialRefresh.message}`);
|
|
90
|
+
log('Run `yolo-bridge login` again.');
|
|
91
|
+
return { ok: false, reason: 'refresh-failed', message: initialRefresh.message };
|
|
92
|
+
}
|
|
93
|
+
let attachmentId;
|
|
94
|
+
let tileId;
|
|
95
|
+
try {
|
|
96
|
+
const result = await apiClient.attach(cfg, workspaceId, hostLabel);
|
|
97
|
+
attachmentId = result.attachmentId;
|
|
98
|
+
tileId = result.tileId;
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
return { ok: false, reason: 'attach-failed', message: err instanceof Error ? err.message : String(err) };
|
|
102
|
+
}
|
|
103
|
+
saveAttachment({ workspaceId, tileId, attachmentId, attachedAt: new Date().toISOString() }, env, io);
|
|
104
|
+
log(`Attached. tileId=${tileId} attachmentId=${attachmentId}`);
|
|
105
|
+
// Codex-found race: if something already asked us to stop WHILE the
|
|
106
|
+
// initial refresh/attach network round trip above was in flight (e.g. the
|
|
107
|
+
// local agent process this daemon spawns exits almost immediately), the
|
|
108
|
+
// caller's own onExit-triggered best-effort detach ran too early — before
|
|
109
|
+
// this attachment existed anywhere — and found nothing to clean up. The
|
|
110
|
+
// `while (!shouldStop())` loop below would otherwise exit on its very
|
|
111
|
+
// first check having never opened a stream, returning `{ ok: true,
|
|
112
|
+
// reason: 'stopped' }` with the attachment just created above left as a
|
|
113
|
+
// permanent orphan (the CLI's own post-return cleanup skips it too, since
|
|
114
|
+
// it believes the onExit path already handled detaching). Catch it here,
|
|
115
|
+
// right after this call is the one that created it, so there's exactly
|
|
116
|
+
// one place responsible for cleaning up what it made.
|
|
117
|
+
if (shouldStop()) {
|
|
118
|
+
await apiClient.detach(cfg, workspaceId, attachmentId).catch((err) => {
|
|
119
|
+
log(`Cleanup detach failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
120
|
+
});
|
|
121
|
+
clearAttachment(env, io);
|
|
122
|
+
return { ok: true, reason: 'stopped' };
|
|
123
|
+
}
|
|
124
|
+
let heartbeat;
|
|
125
|
+
let attempt = 0;
|
|
126
|
+
/** Set when a proactive refresh (see ensureFreshToken) fails while a
|
|
127
|
+
* stream is open — picked up right after the current for-await unwinds
|
|
128
|
+
* (forced via the stop-poll below) so the daemon stops instead of
|
|
129
|
+
* looping forever reconnecting with a dead token. */
|
|
130
|
+
let refreshFailed;
|
|
131
|
+
try {
|
|
132
|
+
while (!shouldStop()) {
|
|
133
|
+
const preStreamRefresh = await ensureFreshToken();
|
|
134
|
+
if (!preStreamRefresh.ok) {
|
|
135
|
+
refreshFailed = preStreamRefresh;
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
let sawDetached = false;
|
|
139
|
+
try {
|
|
140
|
+
const res = await apiClient.openStream(cfg, workspaceId, attachmentId);
|
|
141
|
+
attempt = 0; // reset backoff on a successful connect
|
|
142
|
+
const parser = new SseFrameParser();
|
|
143
|
+
const nodeStream = Readable.fromWeb(res.body);
|
|
144
|
+
// Codex review (2026-08-23, fourth pass): a per-chunk
|
|
145
|
+
// `chunk.toString('utf-8')` decodes each network chunk in
|
|
146
|
+
// isolation — if a multibyte UTF-8 character (e.g. in a
|
|
147
|
+
// non-ASCII prompt) straddles a chunk boundary, each half decodes
|
|
148
|
+
// independently to a replacement character (U+FFFD), corrupting
|
|
149
|
+
// the prompt before it ever reaches JSON parsing or the PTY.
|
|
150
|
+
// `StringDecoder` carries incomplete trailing bytes over to the
|
|
151
|
+
// next `write()` call, so a split character reassembles correctly
|
|
152
|
+
// regardless of where the network happened to cut the chunk. Scoped
|
|
153
|
+
// per-connection (declared here, not outside the `while` loop) —
|
|
154
|
+
// a new connection can't continue a byte sequence from a previous
|
|
155
|
+
// one, so fresh decoder state per attempt is correct, matching the
|
|
156
|
+
// per-connection `parser` right above.
|
|
157
|
+
const decoder = new StringDecoder('utf-8');
|
|
158
|
+
// Bug 1 fix: the server holds this stream open indefinitely
|
|
159
|
+
// (keepalive pings only), so `for await` below never completes on
|
|
160
|
+
// its own — `shouldStop()` being poll-based (not push-based; see
|
|
161
|
+
// cli.ts's SIGINT/SIGTERM handler) means it must be actively
|
|
162
|
+
// polled independent of whether/when the next chunk arrives, and
|
|
163
|
+
// acted on by tearing the stream down, or a signal during an
|
|
164
|
+
// active stream is never actually noticed. Same mechanism also
|
|
165
|
+
// unblocks a mid-stream proactive-refresh failure (`refreshFailed`
|
|
166
|
+
// above) instead of riding out the connection to its next natural
|
|
167
|
+
// event.
|
|
168
|
+
const stopPollHandle = timers.setInterval(() => {
|
|
169
|
+
if (shouldStop() || refreshFailed) {
|
|
170
|
+
nodeStream.destroy();
|
|
171
|
+
}
|
|
172
|
+
}, STOP_POLL_INTERVAL_MS);
|
|
173
|
+
try {
|
|
174
|
+
for await (const chunk of nodeStream) {
|
|
175
|
+
const text = Buffer.isBuffer(chunk) ? decoder.write(chunk) : String(chunk);
|
|
176
|
+
for (const frame of parser.push(text)) {
|
|
177
|
+
const action = actionForFrame(frame);
|
|
178
|
+
switch (action.kind) {
|
|
179
|
+
case 'connected':
|
|
180
|
+
log('Stream connected.');
|
|
181
|
+
heartbeat?.stop();
|
|
182
|
+
heartbeat = startHeartbeat(async () => {
|
|
183
|
+
const refreshCheck = await ensureFreshToken();
|
|
184
|
+
if (!refreshCheck.ok) {
|
|
185
|
+
refreshFailed = refreshCheck;
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
await apiClient.postHeartbeat(cfg, workspaceId, attachmentId);
|
|
189
|
+
}, (err) => log(`heartbeat error: ${err instanceof Error ? err.message : String(err)}`), undefined, deps.timers);
|
|
190
|
+
// Send one immediately so status isn't stale for the first ~10s.
|
|
191
|
+
apiClient.postHeartbeat(cfg, workspaceId, attachmentId).catch((err) => log(`initial heartbeat error: ${err instanceof Error ? err.message : String(err)}`));
|
|
192
|
+
break;
|
|
193
|
+
case 'ping':
|
|
194
|
+
break;
|
|
195
|
+
case 'prompt':
|
|
196
|
+
await deliverPrompt(action.prompt);
|
|
197
|
+
break;
|
|
198
|
+
case 'read-output': {
|
|
199
|
+
const captured = await captureOutput();
|
|
200
|
+
await apiClient
|
|
201
|
+
.postReadOutputReply(cfg, workspaceId, attachmentId, action.requestId, captured.output, captured.busy)
|
|
202
|
+
.catch((err) => log(`read-output reply failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
case 'detached':
|
|
206
|
+
log('Detached by server.');
|
|
207
|
+
sawDetached = true;
|
|
208
|
+
break;
|
|
209
|
+
case 'unknown':
|
|
210
|
+
log(`Unrecognized frame type: ${action.event}`);
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
if (sawDetached)
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
if (sawDetached)
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
timers.clearInterval(stopPollHandle);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
log(`Stream error: ${err instanceof Error ? err.message : String(err)}`);
|
|
226
|
+
}
|
|
227
|
+
heartbeat?.stop();
|
|
228
|
+
heartbeat = undefined;
|
|
229
|
+
if (sawDetached) {
|
|
230
|
+
clearAttachment(env, io);
|
|
231
|
+
return { ok: true, reason: 'detached-by-server' };
|
|
232
|
+
}
|
|
233
|
+
if (refreshFailed)
|
|
234
|
+
break;
|
|
235
|
+
if (shouldStop())
|
|
236
|
+
break;
|
|
237
|
+
attempt += 1;
|
|
238
|
+
const delay = nextBackoffMs(attempt, deps.backoffOpts);
|
|
239
|
+
log(`Reconnecting in ${delay}ms (attempt ${attempt})...`);
|
|
240
|
+
await sleep(delay);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
finally {
|
|
244
|
+
heartbeat?.stop();
|
|
245
|
+
}
|
|
246
|
+
if (refreshFailed) {
|
|
247
|
+
log(`Token refresh failed: ${refreshFailed.message}`);
|
|
248
|
+
log('Run `yolo-bridge login` again.');
|
|
249
|
+
return { ok: false, reason: 'refresh-failed', message: refreshFailed.message };
|
|
250
|
+
}
|
|
251
|
+
return { ok: true, reason: 'stopped' };
|
|
252
|
+
}
|
|
253
|
+
/** Convenience wrapper: loads auth from disk first (used by cli.ts). */
|
|
254
|
+
export async function runAttachFromDisk(opts) {
|
|
255
|
+
const auth = loadAuth(opts.env, opts.io);
|
|
256
|
+
if (!auth)
|
|
257
|
+
return { ok: false, reason: 'not-logged-in' };
|
|
258
|
+
return runAttachDaemon({ ...opts, auth });
|
|
259
|
+
}
|
|
260
|
+
function defaultPrompt(question) {
|
|
261
|
+
return new Promise((resolve) => {
|
|
262
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
263
|
+
rl.question(question, (answer) => {
|
|
264
|
+
rl.close();
|
|
265
|
+
resolve(answer);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
export async function pickWorkspaceFromDisk(deps) {
|
|
270
|
+
const auth = loadAuth(deps.env, deps.io);
|
|
271
|
+
if (!auth)
|
|
272
|
+
return { ok: false, reason: 'not-logged-in' };
|
|
273
|
+
const log = deps.log ?? ((line) => process.stdout.write(`${line}\n`));
|
|
274
|
+
const prompt = deps.prompt ?? defaultPrompt;
|
|
275
|
+
const cfg = {
|
|
276
|
+
commonApiBaseUrl: deps.commonApiBaseUrl,
|
|
277
|
+
accessToken: auth.accessToken,
|
|
278
|
+
fetchImpl: deps.fetchImpl,
|
|
279
|
+
};
|
|
280
|
+
let workspaces;
|
|
281
|
+
try {
|
|
282
|
+
workspaces = await apiClient.listSelectableWorkspaces(cfg);
|
|
283
|
+
}
|
|
284
|
+
catch (err) {
|
|
285
|
+
return { ok: false, reason: 'list-failed', message: err instanceof Error ? err.message : String(err) };
|
|
286
|
+
}
|
|
287
|
+
if (workspaces.length === 0)
|
|
288
|
+
return { ok: false, reason: 'no-workspaces' };
|
|
289
|
+
log('Select a workspace to attach:');
|
|
290
|
+
workspaces.forEach((ws, i) => {
|
|
291
|
+
log(` ${i + 1}. ${ws.name || '(unnamed)'} [${ws.status}] ${ws.id}`);
|
|
292
|
+
});
|
|
293
|
+
const answer = (await prompt('Enter a number: ')).trim();
|
|
294
|
+
const index = Number.parseInt(answer, 10);
|
|
295
|
+
if (!Number.isInteger(index) || index < 1 || index > workspaces.length) {
|
|
296
|
+
return { ok: false, reason: 'no-selection' };
|
|
297
|
+
}
|
|
298
|
+
return { ok: true, workspaceId: workspaces[index - 1].id };
|
|
299
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort "open the verification URL in a browser" helper.
|
|
3
|
+
*
|
|
4
|
+
* JUDGMENT CALL (plan says either is fine, "your call, note which you
|
|
5
|
+
* picked"): no `open`/`opener` npm dependency added. Shelling out to the
|
|
6
|
+
* platform's own opener (`open` on macOS, `xdg-open` on Linux, `start` via
|
|
7
|
+
* `cmd` on Windows) covers the common case with zero new dependency
|
|
8
|
+
* surface, and login always prints the URL + code clearly regardless of
|
|
9
|
+
* whether the spawn succeeds — headless/SSH sessions (a real case for a
|
|
10
|
+
* CLI daemon meant to run on a dev box) degrade to "copy this URL"
|
|
11
|
+
* automatically, no separate code path needed.
|
|
12
|
+
*/
|
|
13
|
+
import { spawn as realSpawn } from 'node:child_process';
|
|
14
|
+
export function openBrowserBestEffort(url, spawnImpl = realSpawn) {
|
|
15
|
+
try {
|
|
16
|
+
const platform = process.platform;
|
|
17
|
+
let child;
|
|
18
|
+
if (platform === 'darwin') {
|
|
19
|
+
child = spawnImpl('open', [url], { stdio: 'ignore', detached: true });
|
|
20
|
+
}
|
|
21
|
+
else if (platform === 'win32') {
|
|
22
|
+
child = spawnImpl('cmd', ['/c', 'start', '""', url], { stdio: 'ignore', detached: true, shell: true });
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
child = spawnImpl('xdg-open', [url], { stdio: 'ignore', detached: true });
|
|
26
|
+
}
|
|
27
|
+
// Codex review (2026-08-23, fourth pass): a missing opener binary (no
|
|
28
|
+
// `xdg-open` on a minimal/headless Linux box, the common case this
|
|
29
|
+
// helper is meant to degrade gracefully on) doesn't throw SYNCHRONOUSLY
|
|
30
|
+
// — spawn() returns a child and emits `error` (e.g. ENOENT) on a later
|
|
31
|
+
// tick, which this surrounding try/catch cannot catch. An EventEmitter
|
|
32
|
+
// `error` event with no listener throws and crashes the process, so
|
|
33
|
+
// login was crashing instead of degrading to "copy this URL" as
|
|
34
|
+
// intended. Register the listener BEFORE unref() so it's in place for
|
|
35
|
+
// that later tick.
|
|
36
|
+
child.on('error', () => {
|
|
37
|
+
// Best-effort only — the caller always prints the URL, so a failed
|
|
38
|
+
// spawn (no DISPLAY, missing xdg-open, headless box) is a non-event.
|
|
39
|
+
});
|
|
40
|
+
child.unref();
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// Best-effort only — the caller always prints the URL, so a failed
|
|
44
|
+
// spawn (no DISPLAY, missing xdg-open, headless box) is a non-event.
|
|
45
|
+
}
|
|
46
|
+
}
|