@phnx-labs/agents-cli 1.22.66 → 1.22.68
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/CHANGELOG.md +26 -0
- package/README.md +7 -2
- package/dist/bootstrap.js +3 -3
- package/dist/commands/browser.js +46 -21
- package/dist/commands/daemon-test-harness.d.ts +1 -1
- package/dist/commands/daemon-test-harness.js +2 -2
- package/dist/commands/daemon.js +21 -22
- package/dist/commands/exec.js +40 -0
- package/dist/commands/monitors.js +2 -2
- package/dist/commands/repo.js +57 -19
- package/dist/commands/resume.d.ts +16 -0
- package/dist/commands/resume.js +41 -8
- package/dist/commands/routines.js +42 -21
- package/dist/commands/sessions-optimize.js +1 -1
- package/dist/commands/sessions.js +4 -11
- package/dist/commands/share.d.ts +5 -3
- package/dist/commands/share.js +33 -9
- package/dist/lib/accounting/account-pool-collect.d.ts +6 -4
- package/dist/lib/accounting/account-pool-collect.js +6 -4
- package/dist/lib/accounting/usage-ingest.js +4 -2
- package/dist/lib/accounting/usage-sync.d.ts +38 -93
- package/dist/lib/accounting/usage-sync.js +66 -210
- package/dist/lib/accounting/usage.d.ts +11 -3
- package/dist/lib/accounting/usage.js +23 -10
- package/dist/lib/auth-health.d.ts +8 -0
- package/dist/lib/auth-health.js +4 -4
- package/dist/lib/browser/caller-identity.d.ts +12 -0
- package/dist/lib/browser/caller-identity.js +19 -0
- package/dist/lib/browser/ipc.d.ts +37 -32
- package/dist/lib/browser/ipc.js +146 -94
- package/dist/lib/browser/task-index.d.ts +10 -2
- package/dist/lib/browser/task-index.js +22 -3
- package/dist/lib/daemon/account-state-daemon-service.d.ts +49 -9
- package/dist/lib/daemon/account-state-daemon-service.js +81 -18
- package/dist/lib/daemon/auth-sync-service.d.ts +4 -4
- package/dist/lib/daemon/auth-sync-service.js +17 -6
- package/dist/lib/daemon/catchup-service.d.ts +51 -0
- package/dist/lib/daemon/catchup-service.js +51 -0
- package/dist/lib/daemon/daemon.d.ts +12 -22
- package/dist/lib/daemon/daemon.js +463 -176
- package/dist/lib/daemon/runner.js +2 -0
- package/dist/lib/daemon/service.d.ts +22 -4
- package/dist/lib/daemon/service.js +2 -2
- package/dist/lib/daemon/supervisor.d.ts +55 -15
- package/dist/lib/daemon/supervisor.js +119 -29
- package/dist/lib/daemon/usage-sync-service.d.ts +4 -6
- package/dist/lib/daemon/usage-sync-service.js +22 -18
- package/dist/lib/daemon-health.js +36 -31
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +12 -2
- package/dist/lib/daemon-ticks.d.ts +9 -6
- package/dist/lib/daemon-ticks.js +14 -8
- package/dist/lib/devices/registry.js +2 -0
- package/dist/lib/exec.js +11 -0
- package/dist/lib/feed/watch.d.ts +3 -0
- package/dist/lib/feed/watch.js +13 -3
- package/dist/lib/fleet-shared-repo-sync.d.ts +36 -0
- package/dist/lib/fleet-shared-repo-sync.js +333 -0
- package/dist/lib/fleet-shared-state.d.ts +38 -0
- package/dist/lib/fleet-shared-state.js +105 -0
- package/dist/lib/hosts/remote-cmd.d.ts +2 -0
- package/dist/lib/hosts/remote-cmd.js +12 -3
- package/dist/lib/lock-compromise.d.ts +8 -0
- package/dist/lib/lock-compromise.js +12 -0
- package/dist/lib/monitors/engine.d.ts +2 -1
- package/dist/lib/monitors/engine.js +9 -2
- package/dist/lib/refresh-coordinator.js +2 -0
- package/dist/lib/run-trace-sync.d.ts +13 -0
- package/dist/lib/run-trace-sync.js +77 -0
- package/dist/lib/secrets/push.d.ts +10 -0
- package/dist/lib/secrets/push.js +86 -7
- package/dist/lib/secrets/remote.d.ts +18 -6
- package/dist/lib/secrets/remote.js +29 -4
- package/dist/lib/secrets/reserved-sync.d.ts +28 -27
- package/dist/lib/secrets/reserved-sync.js +119 -101
- package/dist/lib/session/active.d.ts +3 -1
- package/dist/lib/session/active.js +2 -0
- package/dist/lib/session/actor-sidecar.d.ts +12 -0
- package/dist/lib/session/actor-sidecar.js +2 -0
- package/dist/lib/session/db.d.ts +5 -2
- package/dist/lib/session/db.js +54 -22
- package/dist/lib/session/discover.d.ts +12 -3
- package/dist/lib/session/discover.js +106 -25
- package/dist/lib/session/prompt.d.ts +11 -0
- package/dist/lib/session/prompt.js +25 -0
- package/dist/lib/session/recovery.d.ts +30 -2
- package/dist/lib/session/recovery.js +56 -4
- package/dist/lib/session/remote/remote-list.d.ts +17 -1
- package/dist/lib/session/remote/remote-list.js +29 -4
- package/dist/lib/session/remote/watch.d.ts +25 -2
- package/dist/lib/session/remote/watch.js +185 -11
- package/dist/lib/session/session-cache.d.ts +2 -1
- package/dist/lib/session/session-cache.js +1 -0
- package/dist/lib/session/types.d.ts +2 -0
- package/dist/lib/share/publish.d.ts +55 -9
- package/dist/lib/share/publish.js +87 -9
- package/dist/lib/share/worker-template.js +100 -8
- package/dist/lib/ssh-exec.d.ts +2 -0
- package/dist/lib/ssh-exec.js +20 -4
- package/dist/lib/teams/registry.js +2 -0
- package/dist/lib/testdata/daemon-health-writer.d.ts +1 -0
- package/dist/lib/testdata/daemon-health-writer.js +8 -0
- package/dist/lib/traces/sync.d.ts +7 -0
- package/dist/lib/traces/sync.js +9 -0
- package/dist/lib/usage-refresh.d.ts +8 -2
- package/dist/lib/usage-refresh.js +3 -3
- package/package.json +1 -1
- package/dist/lib/account-state-service.d.ts +0 -21
- package/dist/lib/account-state-service.js +0 -60
package/dist/lib/browser/ipc.js
CHANGED
|
@@ -4,10 +4,10 @@ import * as path from 'path';
|
|
|
4
4
|
import { IS_WINDOWS, ipcEndpoint } from '../platform/index.js';
|
|
5
5
|
import { getHelpersDir } from '../state.js';
|
|
6
6
|
import { resolveBrowserTaskIdleMs } from '../device-config.js';
|
|
7
|
-
import { startDaemon,
|
|
7
|
+
import { getDaemonLogPath, isDaemonRunning, signalDaemonReload, startDaemon, } from '../daemon/daemon.js';
|
|
8
8
|
import { getCliVersion } from '../version.js';
|
|
9
9
|
import { compareVersions } from '../agent-spec/primitives.js';
|
|
10
|
-
import {
|
|
10
|
+
import { isDaemonServiceEnabled, queueDaemonServiceRestart, setDaemonServiceEnabled, } from '../daemon-services.js';
|
|
11
11
|
import { isFleetRemoteInvocation } from './remote-control.js';
|
|
12
12
|
import { resolveCallerIdentity } from './caller-identity.js';
|
|
13
13
|
import { actionable } from './service.js';
|
|
@@ -52,6 +52,19 @@ const PAGE_RESOLVE_VERBS = new Set([
|
|
|
52
52
|
/** Close verbs never create a task. */
|
|
53
53
|
const CLOSE_VERBS = new Set(['done', 'stop']);
|
|
54
54
|
const SOCKET_NAME = 'browser.sock';
|
|
55
|
+
function readSocketIdentity(socketPath) {
|
|
56
|
+
try {
|
|
57
|
+
const stat = fs.lstatSync(socketPath);
|
|
58
|
+
return { dev: stat.dev, ino: stat.ino };
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function isSameSocket(socketPath, expected) {
|
|
65
|
+
const current = readSocketIdentity(socketPath);
|
|
66
|
+
return current !== null && current.dev === expected.dev && current.ino === expected.ino;
|
|
67
|
+
}
|
|
55
68
|
/**
|
|
56
69
|
* Backstop for {@link BrowserIPCServer.stop} — how long to wait for the socket
|
|
57
70
|
* to be released before unlinking it anyway (RUSH-2421).
|
|
@@ -70,14 +83,14 @@ const SOCKET_NAME = 'browser.sock';
|
|
|
70
83
|
*/
|
|
71
84
|
const IPC_CLOSE_TIMEOUT_MS = 1_500;
|
|
72
85
|
/**
|
|
73
|
-
* How long {@link
|
|
86
|
+
* How long {@link waitForBrowserService} waits for the browser service to come up before
|
|
74
87
|
* failing loud (PHNX-3289).
|
|
75
88
|
*
|
|
76
89
|
* The old flat 6s ceiling was the wedge: the shared daemon's browser IPC server
|
|
77
90
|
* restarts (version reconcile, supervisor restart, a hard-crashed predecessor's
|
|
78
91
|
* successor claiming the socket), and a client that started its wait *during*
|
|
79
92
|
* one of those restart windows could burn the whole 6s and throw
|
|
80
|
-
* `Timeout waiting for browser
|
|
93
|
+
* `Timeout waiting for browser service socket` on an otherwise-healthy daemon. A
|
|
81
94
|
* browser start/navigate then failed intermittently and self-healed on the next
|
|
82
95
|
* try. 15s comfortably spans a restart; the stable-probe requirement below is
|
|
83
96
|
* what keeps a socket that "appears and is immediately destroyed" (#556) from
|
|
@@ -85,21 +98,21 @@ const IPC_CLOSE_TIMEOUT_MS = 1_500;
|
|
|
85
98
|
*/
|
|
86
99
|
const SOCKET_WAIT_TIMEOUT_MS = 15_000;
|
|
87
100
|
/**
|
|
88
|
-
* Consecutive successful probes required before {@link
|
|
89
|
-
* the
|
|
101
|
+
* Consecutive successful probes required before {@link waitForBrowserService} declares
|
|
102
|
+
* the service ready. A single accept can land in the sliver between a restarting
|
|
90
103
|
* server binding and tearing back down; requiring two accepts ~100ms apart means
|
|
91
104
|
* we only return once the daemon is *staying* up, so the caller's real request
|
|
92
105
|
* doesn't race a restart it happened to probe mid-flight.
|
|
93
106
|
*/
|
|
94
107
|
const SOCKET_WAIT_STABLE_PROBES = 2;
|
|
95
108
|
/**
|
|
96
|
-
* How long a single {@link
|
|
109
|
+
* How long a single {@link probeBrowserServiceResponsive} attempt waits for the service to
|
|
97
110
|
* answer a trivial `version` request before giving up (PHNX-3411).
|
|
98
111
|
*
|
|
99
112
|
* A unix-socket `connect` succeeds at the KERNEL level the moment the connection
|
|
100
113
|
* is queued in the listen backlog — it does NOT require the server's event loop
|
|
101
114
|
* to run. So a daemon whose event loop is blocked (e.g. a long synchronous burst
|
|
102
|
-
* on the shared loop) still passes {@link
|
|
115
|
+
* on the shared loop) still passes {@link isBrowserServiceReachable}: the socket accepts,
|
|
103
116
|
* but the request is never serviced. The live symptom on zion was exactly this —
|
|
104
117
|
* `browser.sock` accepted every connection while the daemon re-indexed sessions,
|
|
105
118
|
* then never replied, so cross-device browser drives hung or surfaced a confusing
|
|
@@ -108,7 +121,7 @@ const SOCKET_WAIT_STABLE_PROBES = 2;
|
|
|
108
121
|
*/
|
|
109
122
|
const RESPONSIVENESS_PROBE_TIMEOUT_MS = 1_500;
|
|
110
123
|
/**
|
|
111
|
-
* Consecutive missed {@link
|
|
124
|
+
* Consecutive missed {@link probeBrowserServiceResponsive} attempts before a *reachable*
|
|
112
125
|
* daemon is declared wedged. One missed reply can be a transient GC pause on an
|
|
113
126
|
* otherwise-healthy loop, so a single failure never condemns the daemon; a daemon
|
|
114
127
|
* that cannot answer a trivial `version` request across this whole window has a
|
|
@@ -117,16 +130,17 @@ const RESPONSIVENESS_PROBE_TIMEOUT_MS = 1_500;
|
|
|
117
130
|
* a healthy daemon answers the first attempt in ~1ms.
|
|
118
131
|
*/
|
|
119
132
|
const RESPONSIVENESS_PROBE_ATTEMPTS = 3;
|
|
120
|
-
export class
|
|
133
|
+
export class BrowserServiceNotRunningError extends Error {
|
|
121
134
|
constructor() {
|
|
122
|
-
super(
|
|
123
|
-
this.name = '
|
|
135
|
+
super(formatBrowserServiceNotRunningError());
|
|
136
|
+
this.name = 'BrowserServiceNotRunningError';
|
|
124
137
|
}
|
|
125
138
|
}
|
|
126
|
-
export function
|
|
139
|
+
export function formatBrowserServiceNotRunningError(daemonRunning = isDaemonRunning()) {
|
|
127
140
|
return [
|
|
128
|
-
'Browser
|
|
129
|
-
|
|
141
|
+
'Browser service is not running.',
|
|
142
|
+
`Shared daemon: ${daemonRunning ? 'running (other services are unaffected)' : 'stopped'}.`,
|
|
143
|
+
'Start it with: agents browser start (enables only the browser-ipc service and uses this machine\'s configured default browser)',
|
|
130
144
|
'Pick / pin a profile: agents browser use <name> (or: agents browser start --profile <name>)',
|
|
131
145
|
'List profiles: agents browser profiles list',
|
|
132
146
|
].join('\n');
|
|
@@ -149,7 +163,7 @@ function getIpcEndpoint() {
|
|
|
149
163
|
* a crashed daemon (or one that "appears and is immediately destroyed", #556)
|
|
150
164
|
* rejects with ECONNREFUSED and is correctly reported as not reachable —
|
|
151
165
|
* something fs.existsSync can't distinguish. */
|
|
152
|
-
function
|
|
166
|
+
function probeBrowserService(endpoint, timeoutMs = 500) {
|
|
153
167
|
return new Promise((resolve) => {
|
|
154
168
|
const sock = net.createConnection(endpoint);
|
|
155
169
|
let done = false;
|
|
@@ -162,13 +176,13 @@ function probeDaemon(endpoint, timeoutMs = 500) {
|
|
|
162
176
|
}
|
|
163
177
|
/** Is the daemon reachable? A real connect probe on every platform — a socket
|
|
164
178
|
* file existing on disk is not proof a daemon is listening on it. */
|
|
165
|
-
export async function
|
|
166
|
-
return
|
|
179
|
+
export async function isBrowserServiceReachable() {
|
|
180
|
+
return probeBrowserService(getIpcEndpoint());
|
|
167
181
|
}
|
|
168
182
|
/**
|
|
169
183
|
* Can the daemon actually REPLY right now? Opens a connection and requires a
|
|
170
184
|
* parseable response to a `version` request within `timeoutMs`. Unlike
|
|
171
|
-
* {@link
|
|
185
|
+
* {@link probeBrowserService} (which resolves on the kernel-level `connect`), this only
|
|
172
186
|
* succeeds when the daemon's event loop is running and services the request — so
|
|
173
187
|
* it is the one probe that distinguishes a healthy daemon from a wedged one whose
|
|
174
188
|
* loop is blocked (PHNX-3411). Resolves false on connect error, timeout, an early
|
|
@@ -176,7 +190,7 @@ export async function isDaemonReachable() {
|
|
|
176
190
|
* trivial and synchronous (never touches the browser), so a slow reply means the
|
|
177
191
|
* loop is blocked, not that a real action is in flight.
|
|
178
192
|
*/
|
|
179
|
-
function
|
|
193
|
+
function probeBrowserServiceResponsive(endpoint, timeoutMs = RESPONSIVENESS_PROBE_TIMEOUT_MS) {
|
|
180
194
|
return new Promise((resolve) => {
|
|
181
195
|
const sock = net.createConnection(endpoint);
|
|
182
196
|
let buffer = '';
|
|
@@ -204,20 +218,20 @@ function probeDaemonResponsive(endpoint, timeoutMs = RESPONSIVENESS_PROBE_TIMEOU
|
|
|
204
218
|
}
|
|
205
219
|
/**
|
|
206
220
|
* Is a *reachable* daemon actually responsive, or is its event loop wedged
|
|
207
|
-
* (PHNX-3411)? Retries {@link
|
|
221
|
+
* (PHNX-3411)? Retries {@link probeBrowserServiceResponsive} up to
|
|
208
222
|
* {@link RESPONSIVENESS_PROBE_ATTEMPTS} times so a single transient miss (a GC
|
|
209
223
|
* pause) never condemns a healthy daemon; returns true as soon as any attempt
|
|
210
224
|
* gets a reply, and false only when every attempt fails.
|
|
211
225
|
*/
|
|
212
|
-
export async function
|
|
226
|
+
export async function isBrowserServiceResponsive(endpoint = getIpcEndpoint(), attempts = RESPONSIVENESS_PROBE_ATTEMPTS) {
|
|
213
227
|
for (let i = 0; i < attempts; i++) {
|
|
214
|
-
if (await
|
|
228
|
+
if (await probeBrowserServiceResponsive(endpoint))
|
|
215
229
|
return true;
|
|
216
230
|
}
|
|
217
231
|
return false;
|
|
218
232
|
}
|
|
219
233
|
/**
|
|
220
|
-
* Wait until the browser
|
|
234
|
+
* Wait until the browser service is genuinely reachable, or throw.
|
|
221
235
|
*
|
|
222
236
|
* Re-probes across the whole window rather than latching on the first accept, so
|
|
223
237
|
* it survives an IPC-server restart that happens mid-wait (PHNX-3289): a restart
|
|
@@ -226,12 +240,12 @@ export async function isDaemonResponsive(endpoint = getIpcEndpoint(), attempts =
|
|
|
226
240
|
* that never comes up throws a message naming the endpoint and the budget, never
|
|
227
241
|
* a silent hang.
|
|
228
242
|
*/
|
|
229
|
-
export async function
|
|
243
|
+
export async function waitForBrowserService(_socketPath, timeoutMs = SOCKET_WAIT_TIMEOUT_MS) {
|
|
230
244
|
const endpoint = getIpcEndpoint();
|
|
231
245
|
const deadline = Date.now() + timeoutMs;
|
|
232
246
|
let consecutive = 0;
|
|
233
247
|
while (Date.now() < deadline) {
|
|
234
|
-
if (await
|
|
248
|
+
if (await probeBrowserService(endpoint)) {
|
|
235
249
|
consecutive += 1;
|
|
236
250
|
if (consecutive >= SOCKET_WAIT_STABLE_PROBES)
|
|
237
251
|
return;
|
|
@@ -243,55 +257,60 @@ export async function waitForSocket(_socketPath, timeoutMs = SOCKET_WAIT_TIMEOUT
|
|
|
243
257
|
}
|
|
244
258
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
245
259
|
}
|
|
246
|
-
throw new Error(`Timeout waiting for browser
|
|
260
|
+
throw new Error(`Timeout waiting for browser service socket after ${Math.round(timeoutMs / 1000)}s (${endpoint}).`);
|
|
247
261
|
}
|
|
248
|
-
/** How long {@link
|
|
249
|
-
* signalling
|
|
250
|
-
*
|
|
251
|
-
const
|
|
262
|
+
/** How long {@link stopBrowserService} waits for the endpoint to go quiet after
|
|
263
|
+
* signalling the daemon-owned service transition. Bounded so a wedged daemon
|
|
264
|
+
* fails loud instead of leaving the client hung on a control request. */
|
|
265
|
+
const SERVICE_STOP_QUIESCE_MS = 5_000;
|
|
252
266
|
/**
|
|
253
|
-
*
|
|
254
|
-
* (PHNX-
|
|
255
|
-
* `
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
267
|
+
* Stop only the daemon-hosted browser IPC service so a subsequent `start` comes
|
|
268
|
+
* up clean (PHNX-3605). The short-lived browser client persists the
|
|
269
|
+
* `browser-ipc` service off and asks the daemon to apply that transition over
|
|
270
|
+
* its existing SIGHUP control path. It never stops or restarts the shared
|
|
271
|
+
* supervisor, so routines, usage sync, secrets, and every sibling service keep
|
|
272
|
+
* their process and in-flight work.
|
|
259
273
|
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
274
|
+
* Once the endpoint is quiet, a stale socket left by a hard-crashed browser
|
|
275
|
+
* service is removed. If the daemon event loop itself is wedged and cannot
|
|
276
|
+
* consume SIGHUP, the endpoint remains reachable and this fails loud with the
|
|
277
|
+
* deliberate operator-owned whole-daemon restart command; the client never
|
|
278
|
+
* escalates into that process-wide action itself.
|
|
264
279
|
*/
|
|
265
|
-
export async function
|
|
280
|
+
export async function stopBrowserService() {
|
|
266
281
|
const endpoint = getIpcEndpoint();
|
|
267
|
-
const wasRunning = await
|
|
268
|
-
|
|
282
|
+
const wasRunning = await probeBrowserService(endpoint);
|
|
283
|
+
const daemonRunning = isDaemonRunning();
|
|
284
|
+
setDaemonServiceEnabled('browser-ipc', false);
|
|
285
|
+
if (daemonRunning && !signalDaemonReload()) {
|
|
286
|
+
throw new Error(actionable('Browser service stop could not be delivered to the shared daemon.', `Endpoint: ${endpoint}`, 'Next: agents daemon restart (operator-owned recovery when SIGHUP is unavailable)'));
|
|
287
|
+
}
|
|
269
288
|
// Wait for the listener to actually release the endpoint. We must decide
|
|
270
289
|
// reachability BEFORE touching the socket file: unlinking a path out from
|
|
271
290
|
// under a live server makes new connects ENOENT (so it would *look* cleared)
|
|
272
291
|
// while the server keeps running — the two-servers orphan the eviction
|
|
273
292
|
// protocol exists to prevent. So a still-reachable endpoint fails loud here,
|
|
274
293
|
// and only a genuinely dead one gets its stale file removed below.
|
|
275
|
-
const deadline = Date.now() +
|
|
294
|
+
const deadline = Date.now() + SERVICE_STOP_QUIESCE_MS;
|
|
276
295
|
let reachable = wasRunning;
|
|
277
296
|
while (reachable && Date.now() < deadline) {
|
|
278
297
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
279
|
-
reachable = await
|
|
298
|
+
reachable = await probeBrowserService(endpoint);
|
|
280
299
|
}
|
|
281
300
|
if (reachable) {
|
|
282
|
-
throw new Error(actionable('Browser
|
|
301
|
+
throw new Error(actionable('Browser service is still reachable after its stop request — the shared daemon did not apply the transition.', `Endpoint: ${endpoint}`, 'Next: agents daemon restart (operator-owned recovery for a wedged daemon event loop)'));
|
|
283
302
|
}
|
|
284
303
|
// Nothing is listening now — clear the leftover socket file a hard-crashed
|
|
285
304
|
// daemon left behind, so the next `start` binds clean instead of unlinking it
|
|
286
305
|
// blindly. (Named pipes vanish with their owning process, so Windows has no
|
|
287
306
|
// stale file to clear.)
|
|
288
307
|
const socketCleared = IS_WINDOWS ? false : await clearDeadSocketFile(endpoint, getSocketPath());
|
|
289
|
-
return { wasRunning, socketCleared };
|
|
308
|
+
return { wasRunning, daemonRunning, socketCleared };
|
|
290
309
|
}
|
|
291
310
|
/**
|
|
292
311
|
* Remove a leftover browser socket FILE, but only when nothing is listening on
|
|
293
312
|
* it — re-probing liveness IMMEDIATELY before the unlink to close the TOCTOU
|
|
294
|
-
* window (PHNX-3289 review). Between {@link
|
|
313
|
+
* window (PHNX-3289 review). Between {@link stopBrowserService}'s quiesce loop
|
|
295
314
|
* deciding the endpoint was unreachable and this unlink, a concurrent
|
|
296
315
|
* `browser start` could bind a NEW listener on the same path; an unconditional
|
|
297
316
|
* unlink would then delete a LIVE daemon's socket — the exact two-servers orphan
|
|
@@ -303,7 +322,7 @@ export async function clearDeadSocketFile(endpoint, socketPath) {
|
|
|
303
322
|
if (!fs.existsSync(socketPath))
|
|
304
323
|
return false;
|
|
305
324
|
// A listener bound again since the quiesce loop → do not touch its socket.
|
|
306
|
-
if (await
|
|
325
|
+
if (await probeBrowserService(endpoint))
|
|
307
326
|
return false;
|
|
308
327
|
try {
|
|
309
328
|
fs.unlinkSync(socketPath);
|
|
@@ -316,7 +335,7 @@ export async function clearDeadSocketFile(endpoint, socketPath) {
|
|
|
316
335
|
}
|
|
317
336
|
}
|
|
318
337
|
/**
|
|
319
|
-
* One long-lived connection to the existing browser
|
|
338
|
+
* One long-lived connection to the existing browser service. Requests are
|
|
320
339
|
* serialized so the daemon's newline-delimited responses always map to the
|
|
321
340
|
* caller that produced them, while the process and socket stay warm between
|
|
322
341
|
* actions.
|
|
@@ -332,7 +351,7 @@ export class BrowserIPCConnection {
|
|
|
332
351
|
socket.on('data', (data) => this.handleData(data));
|
|
333
352
|
socket.on('error', (error) => this.fail(error));
|
|
334
353
|
socket.on('close', () => {
|
|
335
|
-
this.fail(new Error('Browser
|
|
354
|
+
this.fail(new Error('Browser service connection closed'));
|
|
336
355
|
});
|
|
337
356
|
}
|
|
338
357
|
request(request) {
|
|
@@ -352,7 +371,7 @@ export class BrowserIPCConnection {
|
|
|
352
371
|
}
|
|
353
372
|
requestOnce(request) {
|
|
354
373
|
if (this.closed || this.socket.destroyed) {
|
|
355
|
-
return Promise.reject(new Error('Browser
|
|
374
|
+
return Promise.reject(new Error('Browser service connection is closed'));
|
|
356
375
|
}
|
|
357
376
|
return new Promise((resolve, reject) => {
|
|
358
377
|
this.pending = { resolve, reject };
|
|
@@ -378,7 +397,7 @@ export class BrowserIPCConnection {
|
|
|
378
397
|
const pending = this.pending;
|
|
379
398
|
if (!pending) {
|
|
380
399
|
this.socket.destroy();
|
|
381
|
-
this.fail(new Error('Browser
|
|
400
|
+
this.fail(new Error('Browser service sent an unexpected response'));
|
|
382
401
|
return;
|
|
383
402
|
}
|
|
384
403
|
this.pending = undefined;
|
|
@@ -386,7 +405,7 @@ export class BrowserIPCConnection {
|
|
|
386
405
|
pending.resolve(JSON.parse(line));
|
|
387
406
|
}
|
|
388
407
|
catch {
|
|
389
|
-
const error = new Error('Browser
|
|
408
|
+
const error = new Error('Browser service returned invalid JSON');
|
|
390
409
|
pending.reject(error);
|
|
391
410
|
this.socket.destroy();
|
|
392
411
|
this.fail(error);
|
|
@@ -404,6 +423,8 @@ export class BrowserIPCConnection {
|
|
|
404
423
|
}
|
|
405
424
|
export class BrowserIPCServer {
|
|
406
425
|
server = null;
|
|
426
|
+
/** Filesystem identity of the POSIX binding this server created. */
|
|
427
|
+
socketIdentity = null;
|
|
407
428
|
service;
|
|
408
429
|
/** Live client connections, so {@link stop} can end them rather than wait. */
|
|
409
430
|
connections = new Set();
|
|
@@ -476,6 +497,7 @@ export class BrowserIPCServer {
|
|
|
476
497
|
this.server.listen(socketPath, () => {
|
|
477
498
|
try {
|
|
478
499
|
fs.chmodSync(socketPath, 0o600);
|
|
500
|
+
this.socketIdentity = readSocketIdentity(socketPath);
|
|
479
501
|
resolve();
|
|
480
502
|
}
|
|
481
503
|
catch (err) {
|
|
@@ -522,10 +544,29 @@ export class BrowserIPCServer {
|
|
|
522
544
|
return this.stopping;
|
|
523
545
|
}
|
|
524
546
|
async doStop() {
|
|
525
|
-
|
|
547
|
+
const socketPath = getSocketPath();
|
|
548
|
+
const owned = this.socketIdentity;
|
|
549
|
+
let preservedReplacement = null;
|
|
550
|
+
if (!IS_WINDOWS && owned && readSocketIdentity(socketPath) && !isSameSocket(socketPath, owned)) {
|
|
551
|
+
// Node/libuv remembers the pathname passed to listen() and unlinks that
|
|
552
|
+
// pathname during server.close(). If a successor has already replaced the
|
|
553
|
+
// directory entry, an inode check AFTER close is too late: libuv has
|
|
554
|
+
// deleted the successor. Move the replacement aside for the close and put
|
|
555
|
+
// it back before returning. The listening socket stays live across rename.
|
|
556
|
+
preservedReplacement = `${socketPath}.preserved-${process.pid}-${Date.now()}`;
|
|
557
|
+
fs.renameSync(socketPath, preservedReplacement);
|
|
558
|
+
}
|
|
559
|
+
try {
|
|
560
|
+
await this.closeServer();
|
|
561
|
+
}
|
|
562
|
+
finally {
|
|
563
|
+
if (preservedReplacement && fs.existsSync(preservedReplacement) && !fs.existsSync(socketPath)) {
|
|
564
|
+
fs.renameSync(preservedReplacement, socketPath);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
526
567
|
if (!IS_WINDOWS) {
|
|
527
|
-
|
|
528
|
-
if (
|
|
568
|
+
this.socketIdentity = null;
|
|
569
|
+
if (owned && isSameSocket(socketPath, owned)) {
|
|
529
570
|
fs.unlinkSync(socketPath);
|
|
530
571
|
}
|
|
531
572
|
}
|
|
@@ -999,21 +1040,21 @@ export class BrowserIPCServer {
|
|
|
999
1040
|
}
|
|
1000
1041
|
let versionReconciledThisProcess = false;
|
|
1001
1042
|
/**
|
|
1002
|
-
* Decide whether a running daemon is stale
|
|
1043
|
+
* Decide whether a running daemon is stale enough to name an operator refresh.
|
|
1003
1044
|
*
|
|
1004
|
-
* FORWARD ONLY:
|
|
1005
|
-
*
|
|
1006
|
-
*
|
|
1007
|
-
* not flap the daemon
|
|
1045
|
+
* FORWARD ONLY: recommend a refresh when the daemon is *older* than this CLI.
|
|
1046
|
+
* An older CLI rides a newer daemon without noise. A client never performs the
|
|
1047
|
+
* refresh itself — two installs sharing one daemon dir (keyed off $HOME) must
|
|
1048
|
+
* not flap the daemon or interrupt one another's hosted services.
|
|
1008
1049
|
*
|
|
1009
1050
|
* `undefined`/`'unknown'` means the daemon is too old to answer the `version`
|
|
1010
|
-
* action reliably — don't
|
|
1051
|
+
* action reliably — don't recommend an action on that ambiguous signal.
|
|
1011
1052
|
*
|
|
1012
1053
|
* When numeric compare cannot order two distinct strings (e.g. two
|
|
1013
|
-
* `0.0.0-dev.*` builds),
|
|
1014
|
-
*
|
|
1054
|
+
* `0.0.0-dev.*` builds), surface the mismatch without deciding either client
|
|
1055
|
+
* owns the shared process.
|
|
1015
1056
|
*/
|
|
1016
|
-
export function
|
|
1057
|
+
export function shouldRecommendDaemonRefresh(daemonVersion, clientVersion) {
|
|
1017
1058
|
if (!daemonVersion || daemonVersion === 'unknown')
|
|
1018
1059
|
return false;
|
|
1019
1060
|
if (daemonVersion === clientVersion)
|
|
@@ -1027,13 +1068,13 @@ export function shouldRestartStaleDaemon(daemonVersion, clientVersion) {
|
|
|
1027
1068
|
return true;
|
|
1028
1069
|
}
|
|
1029
1070
|
/**
|
|
1030
|
-
* Reconcile the running daemon's version with ours. If the
|
|
1031
|
-
* stale code,
|
|
1032
|
-
*
|
|
1033
|
-
*
|
|
1034
|
-
* to
|
|
1071
|
+
* Reconcile the running daemon's version with ours without evicting it. If the
|
|
1072
|
+
* daemon is serving stale code, surface the deliberate operator command once
|
|
1073
|
+
* and continue on the already-running service. A browser client owns neither
|
|
1074
|
+
* the shared supervisor nor its sibling services, so version skew can never be
|
|
1075
|
+
* permission to stop or restart that process (PHNX-3605).
|
|
1035
1076
|
*/
|
|
1036
|
-
async function reconcileDaemonVersion(
|
|
1077
|
+
async function reconcileDaemonVersion() {
|
|
1037
1078
|
if (versionReconciledThisProcess)
|
|
1038
1079
|
return;
|
|
1039
1080
|
versionReconciledThisProcess = true;
|
|
@@ -1047,15 +1088,11 @@ async function reconcileDaemonVersion(socketPath) {
|
|
|
1047
1088
|
return;
|
|
1048
1089
|
}
|
|
1049
1090
|
const client = getCliVersion();
|
|
1050
|
-
if (!
|
|
1091
|
+
if (!shouldRecommendDaemonRefresh(daemon, client))
|
|
1051
1092
|
return;
|
|
1052
|
-
process.stderr.write(`\
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
if (!(await isDaemonReachable())) {
|
|
1056
|
-
await waitForSocket(socketPath);
|
|
1057
|
-
}
|
|
1058
|
-
await new Promise((r) => setTimeout(r, 300));
|
|
1093
|
+
process.stderr.write(`\nBrowser service is running on shared daemon ${daemon}, while this CLI is ${client}. `
|
|
1094
|
+
+ 'Continuing without evicting the daemon or its other services.\n'
|
|
1095
|
+
+ 'To load current daemon code when it is safe to interrupt every hosted service: agents daemon restart\n\n');
|
|
1059
1096
|
}
|
|
1060
1097
|
export async function sendIPCRequest(request, opts = {}) {
|
|
1061
1098
|
// Stamp caller identity ONCE here so the 28+ call sites don't each have to.
|
|
@@ -1098,7 +1135,7 @@ export async function connectBrowserIPC(opts = {}) {
|
|
|
1098
1135
|
const onError = (error) => {
|
|
1099
1136
|
socket.destroy();
|
|
1100
1137
|
if (!autoStartDaemon && (error.code === 'ENOENT' || error.code === 'ECONNREFUSED')) {
|
|
1101
|
-
reject(new
|
|
1138
|
+
reject(new BrowserServiceNotRunningError());
|
|
1102
1139
|
return;
|
|
1103
1140
|
}
|
|
1104
1141
|
reject(new Error(`IPC error: ${error.message}`));
|
|
@@ -1131,7 +1168,7 @@ async function sendRawIPCRequest(request, opts = {}) {
|
|
|
1131
1168
|
});
|
|
1132
1169
|
socket.on('error', (err) => {
|
|
1133
1170
|
if (!autoStartDaemon && (err.code === 'ENOENT' || err.code === 'ECONNREFUSED')) {
|
|
1134
|
-
reject(new
|
|
1171
|
+
reject(new BrowserServiceNotRunningError());
|
|
1135
1172
|
return;
|
|
1136
1173
|
}
|
|
1137
1174
|
reject(new Error(`IPC error: ${err.message}`));
|
|
@@ -1146,20 +1183,35 @@ async function sendRawIPCRequest(request, opts = {}) {
|
|
|
1146
1183
|
async function prepareIPC(action, opts) {
|
|
1147
1184
|
const socketPath = getSocketPath();
|
|
1148
1185
|
const autoStartDaemon = opts.autoStartDaemon ?? true;
|
|
1149
|
-
if (!(await
|
|
1186
|
+
if (!(await isBrowserServiceReachable())) {
|
|
1150
1187
|
if (!autoStartDaemon) {
|
|
1151
|
-
throw new
|
|
1188
|
+
throw new BrowserServiceNotRunningError();
|
|
1152
1189
|
}
|
|
1190
|
+
const serviceEnabled = isDaemonServiceEnabled('browser-ipc');
|
|
1191
|
+
if (!serviceEnabled)
|
|
1192
|
+
setDaemonServiceEnabled('browser-ipc', true);
|
|
1153
1193
|
if (!IS_WINDOWS) {
|
|
1154
1194
|
await fs.promises.mkdir(path.dirname(socketPath), { recursive: true, mode: 0o700 });
|
|
1155
1195
|
await fs.promises.chmod(path.dirname(socketPath), 0o700);
|
|
1156
1196
|
}
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1197
|
+
if (isDaemonRunning()) {
|
|
1198
|
+
// A stopped service is enabled by the config transition. An enabled but
|
|
1199
|
+
// absent service is failed/parked, so request a supervised restart. Both
|
|
1200
|
+
// actions are daemon-owned and in-flight-gated by ServiceSupervisor.
|
|
1201
|
+
if (serviceEnabled)
|
|
1202
|
+
queueDaemonServiceRestart('browser-ipc');
|
|
1203
|
+
if (!signalDaemonReload()) {
|
|
1204
|
+
throw new Error(actionable('Browser service is not running and its start request could not be delivered.', `Endpoint: ${getIpcEndpoint()}`, 'Next: agents daemon restart (operator-owned recovery when SIGHUP is unavailable)'));
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
else {
|
|
1208
|
+
startDaemon();
|
|
1209
|
+
}
|
|
1210
|
+
if (!(await isBrowserServiceReachable())) {
|
|
1211
|
+
await waitForBrowserService(socketPath);
|
|
1160
1212
|
}
|
|
1161
|
-
if (!(await
|
|
1162
|
-
throw new Error(actionable('Failed to start browser
|
|
1213
|
+
if (!(await isBrowserServiceReachable())) {
|
|
1214
|
+
throw new Error(actionable('Failed to start browser service.', `Log: ${getDaemonLogPath()}`, 'Next: agents doctor (checks for a second agents-cli install)'));
|
|
1163
1215
|
}
|
|
1164
1216
|
await new Promise((r) => setTimeout(r, 300));
|
|
1165
1217
|
}
|
|
@@ -1170,14 +1222,14 @@ async function prepareIPC(action, opts) {
|
|
|
1170
1222
|
// instead of the confusing bare socket timeout / indefinite hang the browser
|
|
1171
1223
|
// verb would otherwise hit (including inside reconcileDaemonVersion's own
|
|
1172
1224
|
// version probe, which has no response timeout). Skipped for callers that opt
|
|
1173
|
-
// out of auto-start — they want the clean
|
|
1174
|
-
if (autoStartDaemon && !(await
|
|
1175
|
-
throw new Error(actionable('Browser
|
|
1225
|
+
// out of auto-start — they want the clean BrowserServiceNotRunningError instead.
|
|
1226
|
+
if (autoStartDaemon && !(await isBrowserServiceResponsive(getIpcEndpoint()))) {
|
|
1227
|
+
throw new Error(actionable('Browser service is running but unresponsive — the shared daemon event loop is blocked, so it accepts the connection but never replies.', `Endpoint: ${getIpcEndpoint()}`, `Log: ${getDaemonLogPath()}`, 'Next: agents browser stop --service (stops only browser IPC; if SIGHUP cannot run, it names the operator-owned daemon recovery)'));
|
|
1176
1228
|
}
|
|
1177
1229
|
// Before serving a real request, make sure the daemon isn't running stale
|
|
1178
1230
|
// code. Skips the internal `version` probe (avoids recursion) and callers
|
|
1179
1231
|
// that opt out of auto-start. No-ops once reconciled or when versions match.
|
|
1180
1232
|
if (action !== 'version' && autoStartDaemon) {
|
|
1181
|
-
await reconcileDaemonVersion(
|
|
1233
|
+
await reconcileDaemonVersion();
|
|
1182
1234
|
}
|
|
1183
1235
|
}
|
|
@@ -23,6 +23,7 @@ export type TaskRoute = {
|
|
|
23
23
|
kind: 'reject-device';
|
|
24
24
|
message: string;
|
|
25
25
|
};
|
|
26
|
+
export declare function isTerminalBrowserVerb(verb: string): boolean;
|
|
26
27
|
export declare function taskIndexPath(): string;
|
|
27
28
|
export declare function readTaskIndex(): TaskIndex;
|
|
28
29
|
export declare function getTaskBinding(name: string): TaskBinding | undefined;
|
|
@@ -52,8 +53,14 @@ export declare function ambiguousTasksMessage(entries: Array<{
|
|
|
52
53
|
* `--device` on a later verb is always rejected (bind it at start).
|
|
53
54
|
* A named task that is not in the local index fails loud with the open-task
|
|
54
55
|
* list. Two or more tasks for this caller, with no `--task`, fail the same way.
|
|
55
|
-
* Zero matches with no `--task`
|
|
56
|
-
*
|
|
56
|
+
* Zero matches with no `--task` follows the fleet browser hub when the caller
|
|
57
|
+
* passed one (`opts.hub` — the box drives `browser.device` by default), so a
|
|
58
|
+
* cold page verb reaches the logged-in hub browser exactly as a bare
|
|
59
|
+
* `agents browser start` already does; with no hub it proceeds locally and the
|
|
60
|
+
* daemon resolves from caller identity for the first implicit create. The caller
|
|
61
|
+
* (the CLI's task-routing hook) supplies `opts.hub` from `defaultBrowserHub()`,
|
|
62
|
+
* which is already undefined on the hub itself and on a fleet-remote re-exec, so
|
|
63
|
+
* this can never forward to self or loop.
|
|
57
64
|
*/
|
|
58
65
|
export declare function resolveTaskRoute(opts: {
|
|
59
66
|
task?: string;
|
|
@@ -61,6 +68,7 @@ export declare function resolveTaskRoute(opts: {
|
|
|
61
68
|
sessionId?: string;
|
|
62
69
|
launchId?: string;
|
|
63
70
|
self?: string;
|
|
71
|
+
hub?: string;
|
|
64
72
|
}): TaskRoute;
|
|
65
73
|
/**
|
|
66
74
|
* The daemon sandboxes screenshot writes to the browser runtime dir, so `-o`
|
|
@@ -19,6 +19,19 @@ import { machineId } from '../machine-id.js';
|
|
|
19
19
|
export const REJECT_DEVICE_MESSAGE = '--device is only valid on `agents browser start`.\n' +
|
|
20
20
|
'The task is bound to a device at start; later verbs resolve it from --task.\n' +
|
|
21
21
|
'Next: agents browser start --task <name> --device <device>';
|
|
22
|
+
/**
|
|
23
|
+
* Verbs that CLOSE a browsing context. A cold (no bound task) close verb must
|
|
24
|
+
* never follow the fleet hub: with no task it would target a browsing context
|
|
25
|
+
* this session never opened, and on the hub could stop an unrelated task. They
|
|
26
|
+
* stay local unless an explicit `--task` or a local binding routes them. The
|
|
27
|
+
* driving/read verbs (navigate, screenshot, click, …) are safe to forward cold
|
|
28
|
+
* because the hub creates the caller's OWN task, keyed to the identity the
|
|
29
|
+
* dispatch now forwards.
|
|
30
|
+
*/
|
|
31
|
+
const TERMINAL_BROWSER_VERBS = new Set(['done', 'stop']);
|
|
32
|
+
export function isTerminalBrowserVerb(verb) {
|
|
33
|
+
return TERMINAL_BROWSER_VERBS.has(verb);
|
|
34
|
+
}
|
|
22
35
|
export function taskIndexPath() {
|
|
23
36
|
return path.join(getBrowserRuntimeDir(), 'task-index.json');
|
|
24
37
|
}
|
|
@@ -138,8 +151,14 @@ export function ambiguousTasksMessage(entries) {
|
|
|
138
151
|
* `--device` on a later verb is always rejected (bind it at start).
|
|
139
152
|
* A named task that is not in the local index fails loud with the open-task
|
|
140
153
|
* list. Two or more tasks for this caller, with no `--task`, fail the same way.
|
|
141
|
-
* Zero matches with no `--task`
|
|
142
|
-
*
|
|
154
|
+
* Zero matches with no `--task` follows the fleet browser hub when the caller
|
|
155
|
+
* passed one (`opts.hub` — the box drives `browser.device` by default), so a
|
|
156
|
+
* cold page verb reaches the logged-in hub browser exactly as a bare
|
|
157
|
+
* `agents browser start` already does; with no hub it proceeds locally and the
|
|
158
|
+
* daemon resolves from caller identity for the first implicit create. The caller
|
|
159
|
+
* (the CLI's task-routing hook) supplies `opts.hub` from `defaultBrowserHub()`,
|
|
160
|
+
* which is already undefined on the hub itself and on a fleet-remote re-exec, so
|
|
161
|
+
* this can never forward to self or loop.
|
|
143
162
|
*/
|
|
144
163
|
export function resolveTaskRoute(opts) {
|
|
145
164
|
if (opts.device) {
|
|
@@ -161,7 +180,7 @@ export function resolveTaskRoute(opts) {
|
|
|
161
180
|
const only = matches[0];
|
|
162
181
|
return { kind: 'proceed', task: only.name, device: only.device };
|
|
163
182
|
}
|
|
164
|
-
return { kind: 'proceed', device: self };
|
|
183
|
+
return { kind: 'proceed', device: opts.hub ?? self };
|
|
165
184
|
}
|
|
166
185
|
/**
|
|
167
186
|
* The daemon sandboxes screenshot writes to the browser runtime dir, so `-o`
|