flowviant 0.125.28 → 0.125.29
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.
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** A human browser handoff holds the existing turn, worktree and browser.
|
|
2
|
+
* No model process polls while the person signs in. A returned handoff resumes
|
|
3
|
+
* the same CLI conversation before final delivery/verification can settle. */
|
|
4
|
+
export const BROWSER_HANDOFF_CONTINUATIONS = 2;
|
|
5
|
+
|
|
6
|
+
export function browserHandoffPrompt({ reason }) {
|
|
7
|
+
return reason === 'returned'
|
|
8
|
+
? 'A person returned control of the SAME managed browser session. Inspect its current page and verify whether sign-in or the requested interaction succeeded, then continue the existing card and its checks. Reuse the running dev server and browser; do not restart them. Report only actual observations and retained evidence.'
|
|
9
|
+
: `The managed browser handoff ${reason === 'expired' ? 'expired' : 'was stopped by a person'}. This browser is no longer interactive. Respect that stop; report the unfinished browser checks as unverified, complete any other available verification, and finish the existing card. Do not claim sign-in or interactive checks succeeded.`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function completeBrowserReview(initial, { browser, signal, finish, resume }) {
|
|
13
|
+
let out = initial, finished = false, continuations = 0;
|
|
14
|
+
for (;;) {
|
|
15
|
+
if (signal?.aborted) return out;
|
|
16
|
+
const handoff = await browser?.waitForHandoff?.();
|
|
17
|
+
if (signal?.aborted || handoff?.reason === 'closed') return out;
|
|
18
|
+
if (handoff) {
|
|
19
|
+
if (continuations >= BROWSER_HANDOFF_CONTINUATIONS) throw new Error('The browser handoff continuation limit was reached. Interactive verification is incomplete.');
|
|
20
|
+
out = await resume(handoff, ++continuations);
|
|
21
|
+
finished = false;
|
|
22
|
+
} else if (finished) return out;
|
|
23
|
+
else { out = await finish(out); finished = true; }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -12,7 +12,7 @@ import { installChannel } from './launchCommand.mjs';
|
|
|
12
12
|
import { boxesUrlFrom, classifyMachineResponse, fetchBoxesFor } from './machines.mjs';
|
|
13
13
|
import { daemonLockPid, daemonLogPath, readDaemonState } from './daemonState.mjs';
|
|
14
14
|
import { detectRuntimes } from './runtimeDetection.mjs';
|
|
15
|
-
import { readClaudeAuthStatus } from './runtimeAuth.mjs';
|
|
15
|
+
import { readClaudeAuthStatus, readCodexAuthStatus } from './runtimeAuth.mjs';
|
|
16
16
|
import { fleetEndpoint } from './fleetWire.mjs';
|
|
17
17
|
|
|
18
18
|
/**
|
|
@@ -42,11 +42,13 @@ export function busyOf(liveState, now = Date.now()) {
|
|
|
42
42
|
// One home for the channel (launchCommand.mjs); re-exported for views.mjs.
|
|
43
43
|
export { installChannel };
|
|
44
44
|
|
|
45
|
-
export function desktopStatus({ entries = listStoredProjects(), runtimes = detectRuntimes(), runningFor = daemonRunningFor, stateFor = readDaemonState, pidFor = daemonLockPid, drainFor = drainingFor, nowMs = Date.now(), authFor = readClaudeAuthStatus, refreshAuth = false } = {}) {
|
|
45
|
+
export function desktopStatus({ entries = listStoredProjects(), runtimes = detectRuntimes(), runningFor = daemonRunningFor, stateFor = readDaemonState, pidFor = daemonLockPid, drainFor = drainingFor, nowMs = Date.now(), authFor = readClaudeAuthStatus, codexAuthFor = readCodexAuthStatus, refreshAuth = false } = {}) {
|
|
46
46
|
const runtimeRows = runtimes.map((rt) => ({
|
|
47
47
|
id: rt.id, installed: rt.installed, version: rt.version ?? null, dispatchable: rt.dispatchable,
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
authSource: 'local',
|
|
49
|
+
signedIn: null, billing: null, subscriptionType: null, checkedAt: null, expiresAt: null, expiryKind: null,
|
|
50
|
+
...(rt.installed && rt.id === 'claude' ? authFor({ refresh: refreshAuth }) :
|
|
51
|
+
rt.installed && rt.id === 'codex' ? codexAuthFor({ refresh: refreshAuth }) : {}),
|
|
50
52
|
}));
|
|
51
53
|
return {
|
|
52
54
|
schema: 1,
|
|
@@ -148,10 +150,10 @@ const refusalWords = (r) =>
|
|
|
148
150
|
|
|
149
151
|
export async function desktopStatusRemote({ entries = listStoredProjects(), runtimes = detectRuntimes(),
|
|
150
152
|
runningFor = daemonRunningFor, stateFor = readDaemonState, pidFor = daemonLockPid,
|
|
151
|
-
authFor = readClaudeAuthStatus, refreshAuth = false,
|
|
153
|
+
authFor = readClaudeAuthStatus, codexAuthFor = readCodexAuthStatus, refreshAuth = false,
|
|
152
154
|
fetchImpl = fetch, envpub = readStoredPubB64(), fleetUrl = FLEET_URL, host = MACHINE_HOST,
|
|
153
155
|
now = () => new Date().toISOString(), timeoutMs = REMOTE_TIMEOUT_MS, log = console.error } = {}) {
|
|
154
|
-
const status = desktopStatus({ entries, runtimes, runningFor, stateFor, pidFor, authFor, refreshAuth });
|
|
156
|
+
const status = desktopStatus({ entries, runtimes, runningFor, stateFor, pidFor, authFor, codexAuthFor, refreshAuth });
|
|
155
157
|
const boxesUrl = boxesUrlFrom(fleetUrl);
|
|
156
158
|
const liveUrl = fleetEndpoint('live-agents', fleetUrl);
|
|
157
159
|
const remotes = await Promise.all(entries.map(async (entry) => {
|
|
@@ -40,6 +40,8 @@ export function browserAction(input) {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
const TOOLS = [
|
|
43
|
+
{ name: 'handoff', description: 'Keep this browser open while a person signs in or tries the app. Call before asking for human input. The daemon waits without model polling and resumes this turn after control is returned or the handoff expires.', inputSchema: { type: 'object', additionalProperties: false,
|
|
44
|
+
properties: { kind: { type: 'string', enum: ['signin', 'control'] }, reason: { type: 'string', minLength: 1, maxLength: 300 } }, required: ['kind', 'reason'] } },
|
|
43
45
|
{ name: 'open', description: 'Open the shared managed browser on a dev-server port measured in this worktree. Start the dev server first. Requires disposable test fixtures. Returns the actual page as native image input.', inputSchema: { type: 'object', additionalProperties: false,
|
|
44
46
|
properties: { port: { type: 'integer', minimum: 1, maximum: 65535 }, hostname: { type: 'string', enum: ['localhost'], description: 'Use localhost when the app’s configured authentication callback or cookies require that hostname instead of the measured loopback IP.' }, scheme: { type: 'string', enum: ['http', 'https'], description: 'Match the dev server protocol. HTTPS uses normal browser certificate validation.' }, path: { type: 'string' }, fixture: { type: 'string', description: 'Describe the disposable test data/account and why these interactions are safe.' } }, required: ['port', 'fixture'] } },
|
|
45
47
|
{ name: 'act', description: 'Interact with the SAME browser shown in Flowviant. Snapshot returns page text, controls, console/network errors and a screenshot. User control pauses agent actions; continue other work until returned.', inputSchema: { type: 'object', additionalProperties: false,
|
|
@@ -58,7 +60,7 @@ export function managedPreviewOrigin(wt, port, hostname, scheme = 'http') {
|
|
|
58
60
|
}
|
|
59
61
|
|
|
60
62
|
export async function createManagedBrowser({ wt, turnId, signal, onChild, openPage = openManagedPage,
|
|
61
|
-
measuredOrigin = managedPreviewOrigin, connect = url => new WebSocket(url, {
|
|
63
|
+
measuredOrigin = managedPreviewOrigin, handoffTimeoutMs = 10 * 60_000, connect = url => new WebSocket(url, {
|
|
62
64
|
headers: { ...fleetHeaders({ json: false }), 'X-Flowviant-Instance': DAEMON_INSTANCE },
|
|
63
65
|
handshakeTimeout: 10_000, maxPayload: 16_384,
|
|
64
66
|
}) }) {
|
|
@@ -68,11 +70,40 @@ export async function createManagedBrowser({ wt, turnId, signal, onChild, openPa
|
|
|
68
70
|
let lastRxAt = 0;
|
|
69
71
|
let captures = 0;
|
|
70
72
|
let observationEpoch = 0, pageEpoch = 0;
|
|
73
|
+
let pageOrigin = null, pageMeasured = null, statusPending = false, statusRetry = null;
|
|
74
|
+
let handoff = null, handoffResult = null;
|
|
75
|
+
let transportStarted = false;
|
|
71
76
|
const controller = new AbortController();
|
|
72
|
-
const send = message => {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
77
|
+
const send = message => {
|
|
78
|
+
if (ws?.readyState !== 1 || ws.bufferedAmount >= 1_200_000) return false;
|
|
79
|
+
ws.send(JSON.stringify(message)); return true;
|
|
80
|
+
};
|
|
81
|
+
const flushStatus = () => {
|
|
82
|
+
if (!statusPending) return;
|
|
83
|
+
if (send(status.private ? { ...status, path: '/', error: undefined } : status)) {
|
|
84
|
+
statusPending = false; clearTimeout(statusRetry); statusRetry = null;
|
|
85
|
+
} else if (!closed && ws?.readyState === 1 && !statusRetry) {
|
|
86
|
+
statusRetry = setTimeout(() => { statusRetry = null; flushStatus(); }, 100);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
const change = patch => { status = { ...status, ...patch }; statusPending = true; flushStatus(); };
|
|
90
|
+
const frame = message => { if (closed || stopped) return; lastFrame = message; flushStatus(); if (!statusPending) send(message); };
|
|
91
|
+
const release = () => { pageEpoch++; observationEpoch++; page?.close(); page = null; pageOrigin = null; pageMeasured = null; lastFrame = null; };
|
|
92
|
+
const endHandoff = reason => {
|
|
93
|
+
if (!handoff) return;
|
|
94
|
+
const active = handoff; handoff = null; clearTimeout(active.timer);
|
|
95
|
+
handoffResult = { reason }; active.resolve(handoffResult);
|
|
96
|
+
};
|
|
97
|
+
const beginHandoff = () => {
|
|
98
|
+
if (handoff) return;
|
|
99
|
+
let resolve;
|
|
100
|
+
const promise = new Promise(done => { resolve = done; });
|
|
101
|
+
const timer = setTimeout(() => {
|
|
102
|
+
endHandoff('expired'); stopped = true; controller.abort(); release();
|
|
103
|
+
change({ state: 'ended', mode: 'agent', private: false, handoff: undefined, error: 'The browser handoff expired. Run the app again to start a new session.' });
|
|
104
|
+
}, handoffTimeoutMs);
|
|
105
|
+
handoff = { promise, resolve, timer }; handoffResult = null;
|
|
106
|
+
};
|
|
76
107
|
const observe = async (retain = false) => {
|
|
77
108
|
const epoch = observationEpoch;
|
|
78
109
|
if (status.mode === 'user') throw new Error('A person has control. Browser observations are paused until control is returned.');
|
|
@@ -94,11 +125,19 @@ export async function createManagedBrowser({ wt, turnId, signal, onChild, openPa
|
|
|
94
125
|
const call = async (name, input) => {
|
|
95
126
|
if (closed || signal?.aborted) throw new Error('This browser turn has ended.');
|
|
96
127
|
if (stopped) throw new Error('A person stopped the browser for this turn.');
|
|
128
|
+
if (status.handoff && name !== 'handoff') throw new Error('The browser is waiting for a person. Continue other work or finish this invocation; the daemon will resume after the handoff.');
|
|
97
129
|
if (status.mode === 'user') throw new Error('A person has control of the shared browser. Continue other work and retry after they return control.');
|
|
98
130
|
if (busy) throw new Error('A browser action is already running.');
|
|
99
131
|
busy = true;
|
|
132
|
+
let openingNew = false;
|
|
100
133
|
try {
|
|
101
|
-
if (name === '
|
|
134
|
+
if (name === 'handoff') {
|
|
135
|
+
if (status.protocol !== 2) throw new Error('Update the app and daemon to use browser handoff.');
|
|
136
|
+
if (!page || status.state !== 'live') throw new Error('Open the managed browser before requesting handoff.');
|
|
137
|
+
if (!['signin', 'control'].includes(input.kind) || typeof input.reason !== 'string' || !input.reason.trim() || input.reason.length > 300) throw new Error('Supply a handoff kind and a short reason.');
|
|
138
|
+
beginHandoff(); change({ handoff: { kind: input.kind, reason: input.reason.trim() } });
|
|
139
|
+
return { content: [text({ waiting: input.kind, reason: input.reason.trim(), instruction: 'Ask the person to take control in the Live app frame. Do other work or return your current result; the daemon holds this turn and resumes it after the handoff.' })] };
|
|
140
|
+
} else if (name === 'open') {
|
|
102
141
|
const path = browserPath(input.path ?? '/');
|
|
103
142
|
if (typeof input.fixture !== 'string' || input.fixture.trim().length < 12 || input.fixture.length > 2000) throw new Error('Describe the disposable test fixtures before opening the browser.');
|
|
104
143
|
if (input.hostname != null && input.hostname !== 'localhost') throw new Error('The optional preview hostname must be localhost.');
|
|
@@ -107,22 +146,29 @@ export async function createManagedBrowser({ wt, turnId, signal, onChild, openPa
|
|
|
107
146
|
const connectHost = upstreamUrl.hostname === '[::1]' ? '::1' : upstreamUrl.hostname;
|
|
108
147
|
if (input.hostname === 'localhost') upstreamUrl.hostname = 'localhost';
|
|
109
148
|
const upstream = upstreamUrl.origin;
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
149
|
+
if (!transportStarted) { transportStarted = true; openSocket(); }
|
|
150
|
+
if (!page || status.state !== 'live' || pageOrigin !== upstream || pageMeasured !== measured) {
|
|
151
|
+
openingNew = true;
|
|
152
|
+
release(); change({ state: 'starting', error: undefined, networkError: undefined, pendingOrigins: [], path });
|
|
153
|
+
const opening = pageEpoch;
|
|
154
|
+
page = await openPage({ origin: upstream, connectHost: input.hostname === 'localhost' ? connectHost : undefined, signal: controller.signal, stillOwned: () => {
|
|
155
|
+
try { return measuredOrigin(wt, input.port, undefined, input.scheme) === measured; } catch { return false; }
|
|
156
|
+
}, onFrame: message => { if (opening === pageEpoch) frame(message); },
|
|
157
|
+
onChange: patch => { if (opening === pageEpoch && !closed && !stopped) change(patch.error ? { ...patch, state: 'error' } : patch); }, onChild });
|
|
158
|
+
if (opening !== pageEpoch || closed || stopped) { page.close(); page = null; throw new Error('The browser turn has ended.'); }
|
|
159
|
+
pageOrigin = upstream; pageMeasured = measured;
|
|
160
|
+
}
|
|
117
161
|
await page.act({ type: 'navigate', path });
|
|
118
162
|
change({ state: 'live' });
|
|
119
163
|
} else {
|
|
120
164
|
if (!page || status.state !== 'live') throw new Error('Open the managed browser first.');
|
|
121
165
|
await page.act(browserAction(input));
|
|
122
166
|
}
|
|
123
|
-
|
|
167
|
+
const result = await observe(name === 'act' && input.type === 'snapshot');
|
|
168
|
+
if (!handoff) handoffResult = null;
|
|
169
|
+
return result;
|
|
124
170
|
} catch (error) {
|
|
125
|
-
if (
|
|
171
|
+
if (openingNew && status.mode !== 'user') { release(); if (!stopped && !closed) change({ state: 'error', error: reviewDiagnostic(error.message) }); }
|
|
126
172
|
throw error;
|
|
127
173
|
} finally { busy = false; }
|
|
128
174
|
};
|
|
@@ -133,10 +179,11 @@ export async function createManagedBrowser({ wt, turnId, signal, onChild, openPa
|
|
|
133
179
|
observationEpoch++;
|
|
134
180
|
if (Number.isSafeInteger(msg.controlId) && msg.controlId >= 0) status.controlId = msg.controlId;
|
|
135
181
|
}
|
|
136
|
-
if (msg.action === 'take') { change({ mode: 'user' }); return; }
|
|
182
|
+
if (msg.action === 'take') { if (!stopped && page) beginHandoff(); change({ mode: 'user' }); return; }
|
|
137
183
|
if (msg.action === 'signin') {
|
|
138
184
|
if (status.protocol !== 2) throw new Error('Update the app and daemon to use private sign-in.');
|
|
139
185
|
const epoch = observationEpoch;
|
|
186
|
+
if (!stopped && page) beginHandoff();
|
|
140
187
|
status = { ...status, mode: 'user', private: true }; lastFrame = null;
|
|
141
188
|
await page?.setPrivate?.(true);
|
|
142
189
|
if (epoch === observationEpoch && !closed && !stopped) change({});
|
|
@@ -146,10 +193,10 @@ export async function createManagedBrowser({ wt, turnId, signal, onChild, openPa
|
|
|
146
193
|
const epoch = observationEpoch;
|
|
147
194
|
lastFrame = null;
|
|
148
195
|
await page?.setPrivate?.(false);
|
|
149
|
-
if (epoch === observationEpoch && !closed && !stopped) change({ mode: 'agent', private: false });
|
|
196
|
+
if (epoch === observationEpoch && !closed && !stopped) { change({ mode: 'agent', private: false, handoff: undefined }); endHandoff('returned'); }
|
|
150
197
|
return;
|
|
151
198
|
}
|
|
152
|
-
if (msg.action === 'stop') { stopped = true; controller.abort(); release(); change({ state: 'ended', mode: 'agent', private: false, error: undefined }); return; }
|
|
199
|
+
if (msg.action === 'stop') { endHandoff('stopped'); stopped = true; controller.abort(); release(); change({ state: 'ended', mode: 'agent', private: false, handoff: undefined, error: undefined }); return; }
|
|
153
200
|
if (status.mode !== 'user') throw new Error('Take control before interacting with the browser.');
|
|
154
201
|
if (busy) throw new Error('The preceding browser action is still finishing.');
|
|
155
202
|
if (!page || status.state !== 'live') throw new Error('The browser is not open.');
|
|
@@ -173,13 +220,14 @@ export async function createManagedBrowser({ wt, turnId, signal, onChild, openPa
|
|
|
173
220
|
if (closed) return;
|
|
174
221
|
try { ws = connect(FLEET_URL.replace(/\/agents\/?$/, `/managed-browser/${encodeURIComponent(turnId)}`).replace(/^http/, 'ws')); }
|
|
175
222
|
catch { reconnect = setTimeout(openSocket, 3000); return; }
|
|
176
|
-
|
|
177
|
-
lastRxAt = Date.now();
|
|
223
|
+
const connected = () => {
|
|
224
|
+
lastRxAt = Date.now(); change({}); if (lastFrame && !statusPending) send(lastFrame);
|
|
178
225
|
ping = setInterval(() => {
|
|
179
226
|
if (Date.now() - lastRxAt > 45_000) { ws.terminate(); return; }
|
|
180
|
-
send({ type: 'heartbeat' });
|
|
227
|
+
flushStatus(); send({ type: 'heartbeat' });
|
|
181
228
|
}, 15_000);
|
|
182
|
-
}
|
|
229
|
+
};
|
|
230
|
+
ws.on('open', connected);
|
|
183
231
|
ws.on('message', data => {
|
|
184
232
|
lastRxAt = Date.now();
|
|
185
233
|
let msg; try { msg = JSON.parse(data.toString()); } catch { return; }
|
|
@@ -188,7 +236,13 @@ export async function createManagedBrowser({ wt, turnId, signal, onChild, openPa
|
|
|
188
236
|
void control(msg).catch(error => { if (!closed && !stopped) send({ type: 'notice', message: reviewDiagnostic(error.message) }); });
|
|
189
237
|
});
|
|
190
238
|
ws.on('error', () => {});
|
|
191
|
-
ws.on('close', code => {
|
|
239
|
+
ws.on('close', code => {
|
|
240
|
+
clearInterval(ping); clearTimeout(statusRetry); statusRetry = null; statusPending = true;
|
|
241
|
+
if (!status.private && status.mode === 'user') { status = { ...status, mode: 'agent', handoff: undefined }; endHandoff('returned'); }
|
|
242
|
+
if (!closed && code !== 1008) reconnect = setTimeout(openSocket, 3000);
|
|
243
|
+
});
|
|
244
|
+
// Test transports and already-open local adapters can connect immediately.
|
|
245
|
+
if (ws.readyState === 1) connected();
|
|
192
246
|
};
|
|
193
247
|
const held = await listenHeldTool({ envPrefix: 'FLOWVIANT_BROWSER', verb: '__managed-browser-mcp', requestTimeoutMs: 35_000,
|
|
194
248
|
isClosed: () => closed, respond: async (req, res) => {
|
|
@@ -204,16 +258,24 @@ export async function createManagedBrowser({ wt, turnId, signal, onChild, openPa
|
|
|
204
258
|
const close = async () => {
|
|
205
259
|
if (closed) return;
|
|
206
260
|
closed = true; signal?.removeEventListener('abort', abort);
|
|
207
|
-
clearTimeout(reconnect); clearInterval(ping);
|
|
261
|
+
clearTimeout(reconnect); clearInterval(ping); clearTimeout(statusRetry); statusRetry = null; endHandoff('closed');
|
|
208
262
|
controller.abort(); release(); change({ state: 'ended', mode: 'agent' });
|
|
209
263
|
ws?.close(1000); held.server.close(); held.server.closeAllConnections();
|
|
210
264
|
forgetExtraSecrets([held.capability]);
|
|
211
265
|
};
|
|
212
266
|
const abort = () => { void close(); };
|
|
213
267
|
signal?.addEventListener('abort', abort, { once: true });
|
|
214
|
-
if (signal?.aborted) await close();
|
|
268
|
+
if (signal?.aborted) await close();
|
|
215
269
|
return { env: held.env, config: { command: process.execPath, args: held.args, env: held.env }, close,
|
|
216
|
-
|
|
270
|
+
async waitForHandoff() {
|
|
271
|
+
for (;;) {
|
|
272
|
+
const result = handoff ? await handoff.promise : handoffResult;
|
|
273
|
+
if (handoff) continue;
|
|
274
|
+
if (handoffResult === result) handoffResult = null;
|
|
275
|
+
return result;
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
instructions: 'Use card_browser.open and card_browser.act for interactive browser work. Both CLIs use this same Flowviant-managed browser, visible live to the project. Start the repository-supported dev server in this worktree first; open its measured port with disposable fixtures. Match the app’s configured auth callback hostname: pass hostname localhost to open when its callback/cookies use localhost rather than 127.0.0.1. Use a disposable test account when provided. If login, MFA or SSO needs a person, call card_browser.handoff with kind signin and a short reason BEFORE asking them to use Sign in privately in the Live app frame; never ask for passwords or one-time codes in chat. For human testing use handoff kind control. Human control pauses browser actions and observations. After requesting handoff, do other work or return your current result; the daemon keeps this turn and browser open without model polling for up to ten minutes, then resumes the same conversation after return, stop or expiry. Repeated open calls on the same measured origin preserve login and origin approvals; another origin gets a fresh profile. Additional HTTP(S) origins require a person to approve the actual requested origin in the frame and retry. You cannot approve network access through tools. Popups, frames, workers, sockets and downloads are blocked; use same-tab sign-in and report unsupported passkey, client-certificate or popup flows. Network access is that of the daemon machine, including its VPN/DNS/certificates; do not claim a VPN is connected from a timeout. Report observed failures, ask the person to connect that machine if needed, and retry after they confirm. Do not open an independent browser or publish a preview to bypass access. Screenshots are native image input: inspect them and report actual observations, errors and skipped checks. Call act with type snapshot before finishing to retain a screenshot as a task artifact. Interactions do not establish a passing test or final-source verification; use the local UI review helper for retained scenario evidence.' };
|
|
217
279
|
}
|
|
218
280
|
|
|
219
281
|
export async function managedBrowserMain() {
|
|
@@ -15,7 +15,7 @@ import { createBrowserNetwork, MANAGED_BROWSER_CSP } from './managedBrowserNetwo
|
|
|
15
15
|
const SNAPSHOT = `JSON.stringify({title:document.title,path:location.pathname,
|
|
16
16
|
text:document.body?.innerText.slice(0,12000),controls:Array.from(document.querySelectorAll('a,button,input,textarea,select,[role="button"]')).slice(0,100).map(e=>({tag:e.tagName,id:e.id,name:e.getAttribute('name'),type:e.getAttribute('type'),text:(e.innerText||e.getAttribute('aria-label')||e.getAttribute('placeholder')||'').slice(0,160)}))})`;
|
|
17
17
|
|
|
18
|
-
export async function openManagedPage({ origin, connectHost, stillOwned, signal, onFrame = () => {}, onChange = () => {}, onChild = () => () => {}, browser = resolveBrowser() }) {
|
|
18
|
+
export async function openManagedPage({ origin, connectHost, stillOwned, signal, onFrame = () => {}, onChange = () => {}, onChild = () => () => {}, browser = resolveBrowser(), navigationTimeoutMs = 15_000 }) {
|
|
19
19
|
if (!browser) throw new Error('No Chrome/Chromium/Edge found on this machine.');
|
|
20
20
|
if (browser.viaWindows) throw new Error('A Windows browser reached through WSL cannot carry the scoped DevTools pipe.');
|
|
21
21
|
if (signal?.aborted) throw new Error('The browser turn has ended.');
|
|
@@ -34,24 +34,29 @@ export async function openManagedPage({ origin, connectHost, stillOwned, signal,
|
|
|
34
34
|
let streamActive = true, streamEpoch = 0, streamQueue = Promise.resolve();
|
|
35
35
|
const screencast = { format: 'jpeg', quality: 65, maxWidth: 1440, maxHeight: 900, everyNthFrame: 1 };
|
|
36
36
|
const note = text => { if (privateInput) return; diagnostics.push(reviewDiagnostic(text)); if (diagnostics.length > 40) diagnostics.shift(); };
|
|
37
|
-
const close =
|
|
37
|
+
const close = reason => {
|
|
38
38
|
if (closed) return;
|
|
39
39
|
closed = true;
|
|
40
40
|
clearTimeout(frameTimer);
|
|
41
41
|
socketDeny.close();
|
|
42
42
|
signal?.removeEventListener('abort', close);
|
|
43
|
-
cdp?.close('Managed browser closed.');
|
|
43
|
+
cdp?.close(typeof reason === 'string' ? reason : 'Managed browser closed.');
|
|
44
44
|
if (child) killGroup(child);
|
|
45
45
|
untrack?.();
|
|
46
46
|
// Reap only after Chromium exits; Windows cannot unlink an open profile.
|
|
47
47
|
if (child && child.exitCode == null && child.signalCode == null) child.once('close', () => rmSync(home, { recursive: true, force: true }));
|
|
48
48
|
else rmSync(home, { recursive: true, force: true });
|
|
49
49
|
};
|
|
50
|
+
const fail = reason => { if (!closed) { onChange({ error: reviewDiagnostic(reason) }); close(reason); } };
|
|
50
51
|
signal?.addEventListener('abort', close, { once: true });
|
|
51
52
|
const raw = (method, params = {}, session) => {
|
|
52
53
|
let timer;
|
|
53
54
|
return Promise.race([cdp.send(method, params, session), new Promise((_, reject) => {
|
|
54
|
-
|
|
55
|
+
const timeoutMs = method === 'Page.navigate' ? navigationTimeoutMs : 15_000;
|
|
56
|
+
timer = setTimeout(() => {
|
|
57
|
+
const reason = `The managed browser did not answer within ${timeoutMs / 1000} seconds.`;
|
|
58
|
+
fail(reason); reject(new Error(reason));
|
|
59
|
+
}, timeoutMs);
|
|
55
60
|
})]).finally(() => clearTimeout(timer));
|
|
56
61
|
};
|
|
57
62
|
const send = (method, params) => raw(method, params, sessionId);
|
|
@@ -73,8 +78,8 @@ export async function openManagedPage({ origin, connectHost, stillOwned, signal,
|
|
|
73
78
|
if (!child.stdio?.[3] || !child.stdio?.[4]) throw new Error('No DevTools pipe is available.');
|
|
74
79
|
cdp = createCdpClient(child.stdio[3], child.stdio[4], { maxMessageBytes: 8 * 1024 * 1024 });
|
|
75
80
|
child.stderr.on('data', data => note(data.toString()));
|
|
76
|
-
child.on('error', error =>
|
|
77
|
-
child.on('exit', () =>
|
|
81
|
+
child.on('error', error => fail(error.message));
|
|
82
|
+
child.on('exit', () => fail('The managed browser exited.'));
|
|
78
83
|
for (const pipe of [child.stdio[3], child.stdio[4]]) pipe.on('error', () => {});
|
|
79
84
|
let lastFrame = 0;
|
|
80
85
|
cdp.on(msg => {
|
|
@@ -95,7 +100,7 @@ export async function openManagedPage({ origin, connectHost, stillOwned, signal,
|
|
|
95
100
|
responsePhrase: p.responseStatusText ?? '', responseHeaders: [...(p.responseHeaders ?? []), { name: 'Content-Security-Policy', value: MANAGED_BROWSER_CSP }] });
|
|
96
101
|
} else await send('Fetch.continueRequest', { requestId: p.requestId });
|
|
97
102
|
})().catch(error => {
|
|
98
|
-
note(error.message);
|
|
103
|
+
note(error.message); fail('Browser network policy could not be applied.');
|
|
99
104
|
});
|
|
100
105
|
}
|
|
101
106
|
if (msg.method === 'Page.screencastFrame') {
|
package/bin/lib/runtimeAuth.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** Coding tools' own auth verdicts for the local desktop contract.
|
|
2
2
|
*
|
|
3
3
|
* The credential file is only a context clue (claudeAuth.mjs): on some boxes
|
|
4
4
|
* Claude stores a working login in a keychain. Only `claude auth status --json`
|
|
@@ -15,6 +15,28 @@ const UNKNOWN = Object.freeze({ signedIn: null, billing: null, subscriptionType:
|
|
|
15
15
|
const TTL_MS = 90_000;
|
|
16
16
|
const SUBSCRIPTIONS = new Set(['pro', 'max', 'team', 'enterprise']);
|
|
17
17
|
|
|
18
|
+
function cachedAuth(cachePath, bin, now, refresh) {
|
|
19
|
+
if (refresh) return null;
|
|
20
|
+
try {
|
|
21
|
+
const cached = JSON.parse(readFileSync(cachePath, 'utf8'));
|
|
22
|
+
if (cached.bin === bin && Number.isFinite(cached.at) && now >= cached.at && now - cached.at < TTL_MS &&
|
|
23
|
+
cached.result?.checkedAt === new Date(cached.at).toISOString() &&
|
|
24
|
+
[true, false, null].includes(cached.result?.signedIn) &&
|
|
25
|
+
[null, 'api', 'subscription'].includes(cached.result?.billing)) return cached.result;
|
|
26
|
+
} catch { /* No usable cache: ask the CLI, including after an upgrade. */ }
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function retainAuth(cachePath, bin, now, result) {
|
|
31
|
+
const measured = { ...result, checkedAt: new Date(now).toISOString(),
|
|
32
|
+
expiresAt: result.expiresAt ?? null, expiryKind: result.expiryKind ?? null };
|
|
33
|
+
try {
|
|
34
|
+
mkdirSync(dirname(cachePath), { recursive: true, mode: 0o700 });
|
|
35
|
+
writeFileSync(cachePath, JSON.stringify({ bin, at: now, result: measured }), { mode: 0o600 });
|
|
36
|
+
} catch { /* Caching is optional; never break status. */ }
|
|
37
|
+
return measured;
|
|
38
|
+
}
|
|
39
|
+
|
|
18
40
|
export function parseClaudeAuthStatus(raw) {
|
|
19
41
|
let value;
|
|
20
42
|
try { value = JSON.parse(raw); } catch { return UNKNOWN; }
|
|
@@ -31,14 +53,8 @@ export function parseClaudeAuthStatus(raw) {
|
|
|
31
53
|
|
|
32
54
|
export function readClaudeAuthStatus({ bin = 'claude', cachePath = join(homedir(), '.flowviant', 'claude-auth-status.json'),
|
|
33
55
|
refresh = false, timeoutMs = 3_000, now = Date.now(), spawn = spawnSync, contextFor = claudeAuthContext } = {}) {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const cached = JSON.parse(readFileSync(cachePath, 'utf8'));
|
|
37
|
-
if (cached.bin === bin && Number.isFinite(cached.at) && now >= cached.at && now - cached.at < TTL_MS &&
|
|
38
|
-
[true, false, null].includes(cached.result?.signedIn) &&
|
|
39
|
-
[null, 'api', 'subscription'].includes(cached.result?.billing)) return cached.result;
|
|
40
|
-
} catch { /* no cache, or unreadable cache: ask the CLI */ }
|
|
41
|
-
}
|
|
56
|
+
const cached = cachedAuth(cachePath, bin, now, refresh);
|
|
57
|
+
if (cached) return cached;
|
|
42
58
|
let result = UNKNOWN;
|
|
43
59
|
try {
|
|
44
60
|
const child = spawn(bin, ['auth', 'status', '--json'], {
|
|
@@ -46,16 +62,40 @@ export function readClaudeAuthStatus({ bin = 'claude', cachePath = join(homedir(
|
|
|
46
62
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
47
63
|
});
|
|
48
64
|
if (!child.error && (child.status === 0 || child.status === 1)) result = parseClaudeAuthStatus(child.stdout);
|
|
49
|
-
if (result.signedIn === true && result.billing === 'subscription'
|
|
65
|
+
if (result.signedIn === true && result.billing === 'subscription') {
|
|
50
66
|
const context = contextFor();
|
|
51
|
-
if (context.source === 'file'
|
|
52
|
-
result
|
|
67
|
+
if (context.source === 'file') {
|
|
68
|
+
if (result.subscriptionType === null && SUBSCRIPTIONS.has(context.subscriptionType)) {
|
|
69
|
+
result = { ...result, subscriptionType: context.subscriptionType };
|
|
70
|
+
}
|
|
71
|
+
// Access-token expiry is renewable. Only the store's explicit refresh
|
|
72
|
+
// expiry can describe the end of this login; absent stays unknown.
|
|
73
|
+
const expiry = Date.parse(context.refreshExpiresAt ?? '');
|
|
74
|
+
if (Number.isFinite(expiry)) result = { ...result, expiresAt: new Date(expiry).toISOString(), expiryKind: 'refresh' };
|
|
53
75
|
}
|
|
54
76
|
}
|
|
55
77
|
} catch { /* A failed probe means unknown, not signed out. */ }
|
|
78
|
+
return retainAuth(cachePath, bin, now, result);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** `codex login status` writes its verdict to stderr. Keep only the verdict,
|
|
82
|
+
* never its output: API-key mode can include credential material. */
|
|
83
|
+
export function readCodexAuthStatus({ bin = 'codex', cachePath = join(homedir(), '.flowviant', 'codex-auth-status.json'),
|
|
84
|
+
refresh = false, timeoutMs = 3_000, now = Date.now(), spawn = spawnSync } = {}) {
|
|
85
|
+
const cached = cachedAuth(cachePath, bin, now, refresh);
|
|
86
|
+
if (cached) return cached;
|
|
87
|
+
let result = UNKNOWN;
|
|
56
88
|
try {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
89
|
+
const child = spawn(bin, ['login', 'status'], {
|
|
90
|
+
encoding: 'utf8', timeout: timeoutMs, maxBuffer: 64 * 1024,
|
|
91
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
92
|
+
});
|
|
93
|
+
const words = `${child.stdout ?? ''}\n${child.stderr ?? ''}`.trim();
|
|
94
|
+
if (!child.error && child.status === 0 && /^logged in\b/im.test(words)) {
|
|
95
|
+
result = { signedIn: true, billing: /api.?key/i.test(words) ? 'api' : /chatgpt/i.test(words) ? 'subscription' : null, subscriptionType: null };
|
|
96
|
+
} else if (!child.error && child.status === 1 && /^not logged in\b/im.test(words)) {
|
|
97
|
+
result = { ...UNKNOWN, signedIn: false };
|
|
98
|
+
}
|
|
99
|
+
} catch { /* Unknown, never a fabricated sign-out. */ }
|
|
100
|
+
return retainAuth(cachePath, bin, now, result);
|
|
61
101
|
}
|
|
@@ -71,6 +71,7 @@ import { createIntermediateArtifacts, IntermediateArtifactCapacityError } from '
|
|
|
71
71
|
import { createUiReview } from './uiReview.mjs';
|
|
72
72
|
import { createVideoRender } from './videoRender.mjs';
|
|
73
73
|
import { createManagedBrowser } from './managedBrowser.mjs';
|
|
74
|
+
import { completeBrowserReview, browserHandoffPrompt } from './browserHandoff.mjs';
|
|
74
75
|
import { withUiReview, appendUiReviewEvidence } from './uiReviewTurn.mjs';
|
|
75
76
|
|
|
76
77
|
export function createAgentTurnExecution({
|
|
@@ -518,6 +519,7 @@ export function createAgentTurnExecution({
|
|
|
518
519
|
projectTools = tools.projectTools;
|
|
519
520
|
localCleanup?.begin(turnId);
|
|
520
521
|
const { toolsNote } = tools;
|
|
522
|
+
let browserThread = agentResume.resumeThreadId;
|
|
521
523
|
const agentTurnArgs = {
|
|
522
524
|
...((liveInput || orchestrator) ? { liveMessages: (run.live = makeLiveMessages({
|
|
523
525
|
scope: 'agent', turnId, cwd: wt, sessionMetaPath, interrupted: () => run.interrupted,
|
|
@@ -565,12 +567,14 @@ export function createAgentTurnExecution({
|
|
|
565
567
|
resume: agentResume.resume,
|
|
566
568
|
resumeThreadId: agentResume.resumeThreadId,
|
|
567
569
|
onInit: (init) => {
|
|
570
|
+
if (rt === 'claude' && typeof init.sessionId === 'string') browserThread = init.sessionId;
|
|
568
571
|
orchestrator?.root(init.sessionId);
|
|
569
572
|
// A server that did not connect, as the CLI said (unknown: nothing).
|
|
570
573
|
trace.mcpStatus(init.mcpServers);
|
|
571
574
|
agentResume.sawSession(init.sessionId);
|
|
572
575
|
},
|
|
573
576
|
onThreadId: (id) => {
|
|
577
|
+
browserThread = id;
|
|
574
578
|
orchestrator?.root(id);
|
|
575
579
|
agentResume.sawThread(id);
|
|
576
580
|
},
|
|
@@ -741,6 +745,7 @@ export function createAgentTurnExecution({
|
|
|
741
745
|
runtime: rt,
|
|
742
746
|
beforeFresh: () => {
|
|
743
747
|
agentResume.forgetThread();
|
|
748
|
+
browserThread = undefined;
|
|
744
749
|
lastAnswer = null;
|
|
745
750
|
model = null;
|
|
746
751
|
},
|
|
@@ -751,19 +756,34 @@ export function createAgentTurnExecution({
|
|
|
751
756
|
void postAgentActivity(agentId, 'resuming after an internal wait');
|
|
752
757
|
} }
|
|
753
758
|
);
|
|
754
|
-
out = await
|
|
755
|
-
|
|
756
|
-
resume: async (
|
|
759
|
+
out = await completeBrowserReview(out, {
|
|
760
|
+
browser: managedBrowser, signal: setupController.signal,
|
|
761
|
+
resume: async (handoff) => {
|
|
762
|
+
const thread = agentResume.threadNow() ?? browserThread;
|
|
763
|
+
if (!thread) throw new Error('The browser handoff could not resume: the CLI reported no conversation id. Interactive verification is incomplete.');
|
|
757
764
|
lastAnswer = null;
|
|
758
765
|
const counted = usage;
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
writableDirs: repositoryRun.sourcesNow().filter(
|
|
763
|
-
|
|
764
|
-
resumeThreadId: agentResume.threadNow() ?? agentTurnArgs.resumeThreadId,
|
|
766
|
+
trace.prose('text', handoff.reason === 'returned' ? 'Browser control returned. Resuming verification.' : 'The browser handoff ended. Resuming to report incomplete interactive checks.');
|
|
767
|
+
return runAgentWithInternalWaits(args => runCli(args), {
|
|
768
|
+
...agentTurnArgs, prompt: browserHandoffPrompt(handoff), resume: true, resumeThreadId: thread, liveContinuation: true,
|
|
769
|
+
...(repositoryRun ? { writableDirs: repositoryRun.sourcesNow().filter(r => r.wt !== wt).map(r => r.wt) } : {}),
|
|
770
|
+
onUsage: next => agentTurnArgs.onUsage(addUsage(counted, next)),
|
|
765
771
|
});
|
|
766
772
|
},
|
|
773
|
+
finish: current => finishRepositoryDelivery({
|
|
774
|
+
job, run: repositoryRun, out: current, answer: () => lastAnswer,
|
|
775
|
+
resume: async (prompt) => {
|
|
776
|
+
lastAnswer = null;
|
|
777
|
+
const counted = usage;
|
|
778
|
+
return runCli({
|
|
779
|
+
...agentTurnArgs, prompt, liveContinuation: true,
|
|
780
|
+
onUsage: (next) => agentTurnArgs.onUsage(addUsage(counted, next)),
|
|
781
|
+
writableDirs: repositoryRun.sourcesNow().filter((r) => r.wt !== wt).map((r) => r.wt),
|
|
782
|
+
resume: true,
|
|
783
|
+
resumeThreadId: agentResume.threadNow() ?? browserThread,
|
|
784
|
+
});
|
|
785
|
+
},
|
|
786
|
+
}),
|
|
767
787
|
});
|
|
768
788
|
} finally {
|
|
769
789
|
// The loopback capability stays registered until every retained CLI
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.125.
|
|
3
|
+
"version": "0.125.29",
|
|
4
4
|
"description": "Run your own coding CLIs as build agents for Flowviant \u2014 Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|