@phnx-labs/agents-cli 1.22.116 → 1.22.118
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 +247 -0
- package/dist/bootstrap.js +4 -11
- package/dist/commands/browser.d.ts +52 -70
- package/dist/commands/browser.js +192 -3562
- package/dist/commands/daemon.d.ts +39 -2
- package/dist/commands/daemon.js +72 -73
- package/dist/commands/events.js +1 -1
- package/dist/commands/exec.d.ts +18 -0
- package/dist/commands/exec.js +104 -12
- package/dist/commands/repo.js +13 -49
- package/dist/commands/route.js +5 -5
- package/dist/commands/routines.js +39 -12
- package/dist/commands/sessions-render.d.ts +1 -1
- package/dist/commands/sessions-render.js +0 -1
- package/dist/commands/sessions.test-fixture.d.ts +0 -1
- package/dist/commands/sessions.test-fixture.js +0 -37
- package/dist/commands/setup-browser.d.ts +17 -12
- package/dist/commands/setup-browser.js +52 -87
- package/dist/commands/setup-preferences.d.ts +0 -17
- package/dist/commands/setup-preferences.js +18 -49
- package/dist/commands/setup-term.d.ts +4 -3
- package/dist/commands/setup-term.js +5 -4
- package/dist/commands/setup.js +11 -11
- package/dist/commands/ssh.js +10 -1
- package/dist/commands/sync.js +6 -44
- package/dist/commands/teams.js +0 -1
- package/dist/commands/update.d.ts +19 -0
- package/dist/commands/update.js +34 -6
- package/dist/commands/view.d.ts +12 -1
- package/dist/commands/view.js +47 -2
- package/dist/index.js +107 -26
- package/dist/lib/account-capabilities.js +0 -2
- package/dist/lib/account-catalog.d.ts +38 -0
- package/dist/lib/account-catalog.js +110 -26
- package/dist/lib/account-provider-registry.js +1 -1
- package/dist/lib/account-registry.d.ts +1 -1
- package/dist/lib/account-registry.js +36 -2
- package/dist/lib/accounting/rotate.js +22 -8
- package/dist/lib/accounting/usage-ingest.d.ts +6 -1
- package/dist/lib/accounting/usage-ingest.js +115 -45
- package/dist/lib/accounting/usage-sync.d.ts +134 -31
- package/dist/lib/accounting/usage-sync.js +227 -66
- package/dist/lib/accounting/usage.d.ts +16 -3
- package/dist/lib/accounting/usage.js +26 -8
- package/dist/lib/accounts/slots.d.ts +8 -0
- package/dist/lib/accounts/slots.js +17 -0
- package/dist/lib/add-dir.js +0 -1
- package/dist/lib/agent-cli-commands.js +0 -1
- package/dist/lib/agent-spec/agents.d.ts +6 -7
- package/dist/lib/agent-spec/agents.js +6 -60
- package/dist/lib/auth-health.d.ts +69 -1
- package/dist/lib/auth-health.js +133 -4
- package/dist/lib/browser/context.d.ts +82 -0
- package/dist/lib/browser/context.js +88 -0
- package/dist/lib/browser/paths.d.ts +25 -0
- package/dist/lib/browser/paths.js +51 -0
- package/dist/lib/browser/record.d.ts +38 -0
- package/dist/lib/browser/record.js +57 -0
- package/dist/lib/browser/sessions-list.js +1 -2
- package/dist/lib/browser-client.d.ts +158 -0
- package/dist/lib/browser-client.js +234 -0
- package/dist/lib/channels/owner-sink.d.ts +1 -18
- package/dist/lib/channels/owner-sink.js +29 -82
- package/dist/lib/channels/providers/rush.d.ts +7 -7
- package/dist/lib/channels/providers/rush.js +127 -57
- package/dist/lib/claude-statusline.d.ts +1 -1
- package/dist/lib/claude-statusline.js +15 -3
- package/dist/lib/commands.js +1 -1
- package/dist/lib/crabbox/lease.js +1 -1
- package/dist/lib/crabbox/runtimes.js +1 -3
- package/dist/lib/daemon/account-state-daemon-service.js +1 -0
- package/dist/lib/daemon/auth-sync-service.d.ts +21 -18
- package/dist/lib/daemon/auth-sync-service.js +37 -43
- package/dist/lib/daemon/daemon.d.ts +25 -14
- package/dist/lib/daemon/daemon.js +92 -154
- package/dist/lib/daemon/runner.d.ts +2 -2
- package/dist/lib/daemon/runner.js +32 -9
- package/dist/lib/daemon/self-update-service.d.ts +35 -34
- package/dist/lib/daemon/self-update-service.js +40 -36
- package/dist/lib/daemon/service.d.ts +12 -8
- package/dist/lib/daemon/supervisor.d.ts +65 -54
- package/dist/lib/daemon/supervisor.js +136 -171
- package/dist/lib/daemon/testdata/process-view-start.js +5 -2
- package/dist/lib/daemon/usage-sync-service.d.ts +21 -30
- package/dist/lib/daemon/usage-sync-service.js +46 -78
- package/dist/lib/daemon-health.d.ts +22 -5
- package/dist/lib/daemon-health.js +47 -3
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +0 -10
- package/dist/lib/daemon-ticks.js +4 -2
- package/dist/lib/devices/doctor-findings.js +8 -11
- package/dist/lib/devices/harness-inventory.js +14 -5
- package/dist/lib/exec-account-home.js +5 -0
- package/dist/lib/exec.js +0 -11
- package/dist/lib/feed/events.js +0 -1
- package/dist/lib/feed/tool-activity.js +5 -2
- package/dist/lib/fleet-shared-state.d.ts +45 -0
- package/dist/lib/fleet-shared-state.js +78 -9
- package/dist/lib/fs-atomic.js +25 -0
- package/dist/lib/harness-auth-capabilities.js +0 -1
- package/dist/lib/helper-versions.js +1 -1
- package/dist/lib/hooks/cache.d.ts +7 -0
- package/dist/lib/hooks/cache.js +27 -2
- package/dist/lib/hooks/install.js +7 -1
- package/dist/lib/hosts/ready.d.ts +38 -8
- package/dist/lib/hosts/ready.js +62 -14
- package/dist/lib/hosts/remote-cmd.js +1 -0
- package/dist/lib/installations/active-check.d.ts +27 -0
- package/dist/lib/installations/active-check.js +61 -0
- package/dist/lib/installations/migrate.js +6 -3
- package/dist/lib/installations/shims.js +9 -7
- package/dist/lib/installations/update.js +21 -15
- package/dist/lib/installations/versions.js +12 -1
- package/dist/lib/manifest.js +1 -1
- package/dist/lib/models.js +0 -2
- package/dist/lib/open-url.js +28 -54
- package/dist/lib/placement.d.ts +2 -0
- package/dist/lib/placement.js +5 -0
- package/dist/lib/profiles.js +0 -2
- package/dist/lib/refresh.js +1 -1
- package/dist/lib/routine-readiness.d.ts +35 -0
- package/dist/lib/routine-readiness.js +74 -16
- package/dist/lib/rules/compile.js +6 -0
- package/dist/lib/sandbox.js +0 -2
- package/dist/lib/secrets-policy.d.ts +89 -11
- package/dist/lib/secrets-policy.js +175 -28
- package/dist/lib/session/db.d.ts +1 -1
- package/dist/lib/session/db.js +65 -18
- package/dist/lib/session/discover.js +1 -283
- package/dist/lib/session/mirror.d.ts +4 -3
- package/dist/lib/session/mirror.js +4 -3
- package/dist/lib/session/parse.d.ts +0 -2
- package/dist/lib/session/parse.js +1 -138
- package/dist/lib/session/throughput.d.ts +1 -3
- package/dist/lib/session/throughput.js +0 -20
- package/dist/lib/session/types.d.ts +2 -2
- package/dist/lib/session/types.js +2 -2
- package/dist/lib/sessions-client.d.ts +60 -1
- package/dist/lib/sessions-client.js +227 -4
- package/dist/lib/setup-tool-status.js +8 -12
- package/dist/lib/signin-badge.d.ts +3 -2
- package/dist/lib/signin-badge.js +6 -3
- package/dist/lib/smart-launch.js +4 -1
- package/dist/lib/staleness/detectors/commands.d.ts +1 -1
- package/dist/lib/staleness/detectors/hooks.d.ts +1 -1
- package/dist/lib/staleness/detectors/mcp.d.ts +1 -1
- package/dist/lib/staleness/detectors/permissions.d.ts +1 -1
- package/dist/lib/staleness/detectors/plugins.d.ts +1 -1
- package/dist/lib/staleness/detectors/rules.d.ts +1 -1
- package/dist/lib/staleness/detectors/skills.d.ts +1 -1
- package/dist/lib/staleness/detectors/subagents.d.ts +1 -1
- package/dist/lib/staleness/detectors/workflows.d.ts +1 -1
- package/dist/lib/staleness/writers/commands.d.ts +1 -1
- package/dist/lib/staleness/writers/hooks.d.ts +1 -1
- package/dist/lib/staleness/writers/mcp.d.ts +1 -1
- package/dist/lib/staleness/writers/permissions.d.ts +1 -1
- package/dist/lib/staleness/writers/plugins.d.ts +1 -1
- package/dist/lib/staleness/writers/rules.d.ts +1 -1
- package/dist/lib/staleness/writers/skills.d.ts +1 -1
- package/dist/lib/staleness/writers/sources.js +5 -0
- package/dist/lib/staleness/writers/subagents.d.ts +1 -1
- package/dist/lib/staleness/writers/workflows.d.ts +1 -1
- package/dist/lib/state.d.ts +6 -4
- package/dist/lib/state.js +7 -17
- package/dist/lib/teams/parsers.d.ts +1 -1
- package/dist/lib/teams/parsers.js +1 -136
- package/dist/lib/teams/placement-probe.js +50 -15
- package/dist/lib/teams/scheduler.d.ts +7 -0
- package/dist/lib/types.d.ts +10 -1
- package/dist/lib/types.js +1 -1
- package/dist/lib/view-types.d.ts +32 -0
- package/dist/lib/watchdog/read.js +0 -1
- package/dist/lib/watchdog/rotate.js +1 -2
- package/dist/lib/watchdog/runner.js +2 -2
- package/dist/lib/watchdog/watchdog.d.ts +1 -1
- package/dist/lib/watchdog/watchdogTail.js +0 -31
- package/package.json +1 -1
- package/scripts/postinstall.js +8 -8
- package/dist/browser.d.ts +0 -2
- package/dist/browser.js +0 -17
- package/dist/commands/browser-picker.d.ts +0 -18
- package/dist/commands/browser-picker.js +0 -97
- package/dist/lib/browser/arc-discovery.d.ts +0 -52
- package/dist/lib/browser/arc-discovery.js +0 -197
- package/dist/lib/browser/arc-dom.d.ts +0 -14
- package/dist/lib/browser/arc-dom.js +0 -121
- package/dist/lib/browser/caller-identity.d.ts +0 -40
- package/dist/lib/browser/caller-identity.js +0 -175
- package/dist/lib/browser/cdp.d.ts +0 -51
- package/dist/lib/browser/cdp.js +0 -254
- package/dist/lib/browser/chrome.d.ts +0 -141
- package/dist/lib/browser/chrome.js +0 -704
- package/dist/lib/browser/chromium-discovery.d.ts +0 -28
- package/dist/lib/browser/chromium-discovery.js +0 -96
- package/dist/lib/browser/devices.d.ts +0 -34
- package/dist/lib/browser/devices.js +0 -61
- package/dist/lib/browser/domain-skills.d.ts +0 -71
- package/dist/lib/browser/domain-skills.js +0 -195
- package/dist/lib/browser/drivers/arc.d.ts +0 -40
- package/dist/lib/browser/drivers/arc.js +0 -276
- package/dist/lib/browser/drivers/firefox.d.ts +0 -99
- package/dist/lib/browser/drivers/firefox.js +0 -377
- package/dist/lib/browser/drivers/local.d.ts +0 -55
- package/dist/lib/browser/drivers/local.js +0 -266
- package/dist/lib/browser/drivers/ssh.d.ts +0 -120
- package/dist/lib/browser/drivers/ssh.js +0 -467
- package/dist/lib/browser/editor.d.ts +0 -3
- package/dist/lib/browser/editor.js +0 -50
- package/dist/lib/browser/ffmpeg.d.ts +0 -12
- package/dist/lib/browser/ffmpeg.js +0 -184
- package/dist/lib/browser/firefox-discovery.d.ts +0 -69
- package/dist/lib/browser/firefox-discovery.js +0 -162
- package/dist/lib/browser/har.d.ts +0 -85
- package/dist/lib/browser/har.js +0 -77
- package/dist/lib/browser/hygiene.d.ts +0 -97
- package/dist/lib/browser/hygiene.js +0 -153
- package/dist/lib/browser/index.d.ts +0 -5
- package/dist/lib/browser/index.js +0 -5
- package/dist/lib/browser/input.d.ts +0 -7
- package/dist/lib/browser/input.js +0 -92
- package/dist/lib/browser/ipc.d.ts +0 -204
- package/dist/lib/browser/ipc.js +0 -1453
- package/dist/lib/browser/login-detection.d.ts +0 -87
- package/dist/lib/browser/login-detection.js +0 -274
- package/dist/lib/browser/profiles.d.ts +0 -422
- package/dist/lib/browser/profiles.js +0 -1157
- package/dist/lib/browser/refs.d.ts +0 -89
- package/dist/lib/browser/refs.js +0 -191
- package/dist/lib/browser/registry.d.ts +0 -70
- package/dist/lib/browser/registry.js +0 -252
- package/dist/lib/browser/remote-control.d.ts +0 -59
- package/dist/lib/browser/remote-control.js +0 -85
- package/dist/lib/browser/resolve-target.d.ts +0 -66
- package/dist/lib/browser/resolve-target.js +0 -264
- package/dist/lib/browser/runtime-state.d.ts +0 -291
- package/dist/lib/browser/runtime-state.js +0 -584
- package/dist/lib/browser/secret-ref.d.ts +0 -10
- package/dist/lib/browser/secret-ref.js +0 -14
- package/dist/lib/browser/service.d.ts +0 -812
- package/dist/lib/browser/service.js +0 -4693
- package/dist/lib/browser/stream.d.ts +0 -17
- package/dist/lib/browser/stream.js +0 -72
- package/dist/lib/browser/task-index.d.ts +0 -78
- package/dist/lib/browser/task-index.js +0 -199
- package/dist/lib/browser/types.d.ts +0 -647
- package/dist/lib/browser/types.js +0 -61
- package/dist/lib/browser/upload.d.ts +0 -29
- package/dist/lib/browser/upload.js +0 -298
- package/dist/lib/daemon/browser-ipc-service.d.ts +0 -20
- package/dist/lib/daemon/browser-ipc-service.js +0 -46
- package/dist/lib/daemon/browser-task-reap-service.d.ts +0 -14
- package/dist/lib/daemon/browser-task-reap-service.js +0 -26
- package/dist/lib/fleet-shared-repo-sync.d.ts +0 -55
- package/dist/lib/fleet-shared-repo-sync.js +0 -490
package/dist/lib/browser/ipc.js
DELETED
|
@@ -1,1453 +0,0 @@
|
|
|
1
|
-
import * as net from 'net';
|
|
2
|
-
import * as fs from 'fs';
|
|
3
|
-
import * as path from 'path';
|
|
4
|
-
import { IS_WINDOWS, ipcEndpoint } from '../platform/index.js';
|
|
5
|
-
import { getHelpersDir } from '../state.js';
|
|
6
|
-
import { resolveBrowserTaskIdleMs } from '../device-config.js';
|
|
7
|
-
import { getDaemonLogPath, isDaemonRunning, signalDaemonReload, startDaemon, } from '../daemon/daemon.js';
|
|
8
|
-
import { getCliVersion } from '../version.js';
|
|
9
|
-
import { compareVersions } from '../agent-spec/primitives.js';
|
|
10
|
-
import { scheduleSelfUpdateExit, selfUpdateSyncDeclineReason, triggerSelfUpdateInBackground } from '../daemon/self-update-service.js';
|
|
11
|
-
import { log as daemonLog } from '../daemon/daemon.js';
|
|
12
|
-
import { isDaemonServiceEnabled, queueDaemonServiceRestart, setDaemonServiceEnabled, } from '../daemon-services.js';
|
|
13
|
-
import { isFleetRemoteInvocation } from './remote-control.js';
|
|
14
|
-
import { resolveCallerIdentity } from './caller-identity.js';
|
|
15
|
-
import { actionable } from './service.js';
|
|
16
|
-
/**
|
|
17
|
-
* Verbs that imply a page and may CREATE a task when none resolves.
|
|
18
|
-
* Observation-only verbs (console/logs/…) resolve but never create — an
|
|
19
|
-
* empty `agents browser logs` must not open a browser just to return [].
|
|
20
|
-
*/
|
|
21
|
-
const PAGE_CREATE_VERBS = new Set([
|
|
22
|
-
'navigate',
|
|
23
|
-
'tab-add',
|
|
24
|
-
'evaluate',
|
|
25
|
-
'screenshot',
|
|
26
|
-
'pdf',
|
|
27
|
-
'refs',
|
|
28
|
-
'click',
|
|
29
|
-
'type',
|
|
30
|
-
'press',
|
|
31
|
-
'hover',
|
|
32
|
-
'scroll',
|
|
33
|
-
'set-viewport',
|
|
34
|
-
'set-device',
|
|
35
|
-
'wait',
|
|
36
|
-
'set-download-path',
|
|
37
|
-
'wait-download',
|
|
38
|
-
'upload',
|
|
39
|
-
'record-start',
|
|
40
|
-
]);
|
|
41
|
-
/** Task-scoped verbs that resolve identity but never create. */
|
|
42
|
-
const PAGE_RESOLVE_VERBS = new Set([
|
|
43
|
-
...PAGE_CREATE_VERBS,
|
|
44
|
-
'tab-focus',
|
|
45
|
-
'tab-close',
|
|
46
|
-
'tab-list',
|
|
47
|
-
'console',
|
|
48
|
-
'errors',
|
|
49
|
-
'requests',
|
|
50
|
-
'response-body',
|
|
51
|
-
'getAppLogs',
|
|
52
|
-
'record-stop',
|
|
53
|
-
]);
|
|
54
|
-
/** Close verbs never create a task. */
|
|
55
|
-
const CLOSE_VERBS = new Set(['done', 'stop']);
|
|
56
|
-
const SOCKET_NAME = 'browser.sock';
|
|
57
|
-
function readSocketIdentity(socketPath) {
|
|
58
|
-
try {
|
|
59
|
-
const stat = fs.lstatSync(socketPath);
|
|
60
|
-
return { dev: stat.dev, ino: stat.ino };
|
|
61
|
-
}
|
|
62
|
-
catch {
|
|
63
|
-
return null;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
function isSameSocket(socketPath, expected) {
|
|
67
|
-
const current = readSocketIdentity(socketPath);
|
|
68
|
-
return current !== null && current.dev === expected.dev && current.ino === expected.ino;
|
|
69
|
-
}
|
|
70
|
-
/**
|
|
71
|
-
* Backstop for {@link BrowserIPCServer.stop} — how long to wait for the socket
|
|
72
|
-
* to be released before unlinking it anyway (RUSH-2421).
|
|
73
|
-
*
|
|
74
|
-
* This MUST stay well under the daemon's SIGTERM grace window
|
|
75
|
-
* (`STOP_GRACE_MS`, daemon.ts). `handleShutdown` awaits `stop()`, so a close
|
|
76
|
-
* that takes as long as the grace window means `stopDaemon` gives up waiting
|
|
77
|
-
* and escalates to `killTree` — turning every graceful stop into a kill. The
|
|
78
|
-
* first version of this constant was set to exactly 5000, the same value as
|
|
79
|
-
* `STOP_GRACE_MS`, and did precisely that whenever a browser client held its
|
|
80
|
-
* (deliberately) long-lived connection open.
|
|
81
|
-
*
|
|
82
|
-
* With connections now ended explicitly in `closeServer`, reaching this
|
|
83
|
-
* timeout at all means a socket refused to die; 1.5s bounds that without
|
|
84
|
-
* coming near the grace window.
|
|
85
|
-
*/
|
|
86
|
-
const IPC_CLOSE_TIMEOUT_MS = 1_500;
|
|
87
|
-
/**
|
|
88
|
-
* Client-side ceiling on the on-demand `request-self-update` call inside
|
|
89
|
-
* {@link reconcileDaemonVersion}. The daemon handler is DECOUPLED from the
|
|
90
|
-
* install — it kicks the self-update off in the background and answers
|
|
91
|
-
* immediately — so this only needs to be long enough to confirm the daemon
|
|
92
|
-
* ACCEPTED the trigger, never long enough to wait out a check/install/verify.
|
|
93
|
-
* It must stay short because a version-skewed browser verb routes through this
|
|
94
|
-
* reconcile on its way to the real request; a wedged or gone daemon degrades to
|
|
95
|
-
* the advisory PHNX-3605 shipped rather than stalling the verb.
|
|
96
|
-
*/
|
|
97
|
-
const DAEMON_SELF_UPDATE_TRIGGER_TIMEOUT_MS = 5_000;
|
|
98
|
-
/**
|
|
99
|
-
* How long {@link waitForBrowserService} waits for the browser service to come up before
|
|
100
|
-
* failing loud (PHNX-3289).
|
|
101
|
-
*
|
|
102
|
-
* The old flat 6s ceiling was the wedge: the shared daemon's browser IPC server
|
|
103
|
-
* restarts (version reconcile, supervisor restart, a hard-crashed predecessor's
|
|
104
|
-
* successor claiming the socket), and a client that started its wait *during*
|
|
105
|
-
* one of those restart windows could burn the whole 6s and throw
|
|
106
|
-
* `Timeout waiting for browser service socket` on an otherwise-healthy daemon. A
|
|
107
|
-
* browser start/navigate then failed intermittently and self-healed on the next
|
|
108
|
-
* try. 15s comfortably spans a restart; the stable-probe requirement below is
|
|
109
|
-
* what keeps a socket that "appears and is immediately destroyed" (#556) from
|
|
110
|
-
* being mistaken for a ready daemon.
|
|
111
|
-
*/
|
|
112
|
-
const SOCKET_WAIT_TIMEOUT_MS = 15_000;
|
|
113
|
-
/**
|
|
114
|
-
* Consecutive successful probes required before {@link waitForBrowserService} declares
|
|
115
|
-
* the service ready. A single accept can land in the sliver between a restarting
|
|
116
|
-
* server binding and tearing back down; requiring two accepts ~100ms apart means
|
|
117
|
-
* we only return once the daemon is *staying* up, so the caller's real request
|
|
118
|
-
* doesn't race a restart it happened to probe mid-flight.
|
|
119
|
-
*/
|
|
120
|
-
const SOCKET_WAIT_STABLE_PROBES = 2;
|
|
121
|
-
/**
|
|
122
|
-
* How long a single {@link probeBrowserServiceResponsive} attempt waits for the service to
|
|
123
|
-
* answer a trivial `version` request before giving up (PHNX-3411).
|
|
124
|
-
*
|
|
125
|
-
* A unix-socket `connect` succeeds at the KERNEL level the moment the connection
|
|
126
|
-
* is queued in the listen backlog — it does NOT require the server's event loop
|
|
127
|
-
* to run. So a daemon whose event loop is blocked (e.g. a long synchronous burst
|
|
128
|
-
* on the shared loop) still passes {@link isBrowserServiceReachable}: the socket accepts,
|
|
129
|
-
* but the request is never serviced. The live symptom on zion was exactly this —
|
|
130
|
-
* `browser.sock` accepted every connection while the daemon re-indexed sessions,
|
|
131
|
-
* then never replied, so cross-device browser drives hung or surfaced a confusing
|
|
132
|
-
* bare socket timeout. Requiring an actual reply is the only probe that tells a
|
|
133
|
-
* healthy daemon apart from a wedged one.
|
|
134
|
-
*/
|
|
135
|
-
const RESPONSIVENESS_PROBE_TIMEOUT_MS = 1_500;
|
|
136
|
-
/**
|
|
137
|
-
* Consecutive missed {@link probeBrowserServiceResponsive} attempts before a *reachable*
|
|
138
|
-
* daemon is declared wedged. One missed reply can be a transient GC pause on an
|
|
139
|
-
* otherwise-healthy loop, so a single failure never condemns the daemon; a daemon
|
|
140
|
-
* that cannot answer a trivial `version` request across this whole window has a
|
|
141
|
-
* genuinely blocked event loop. The added latency (attempts × timeout) is paid
|
|
142
|
-
* ONLY on the wedged path — where the alternative was an indefinite hang — while
|
|
143
|
-
* a healthy daemon answers the first attempt in ~1ms.
|
|
144
|
-
*/
|
|
145
|
-
const RESPONSIVENESS_PROBE_ATTEMPTS = 3;
|
|
146
|
-
export class BrowserServiceNotRunningError extends Error {
|
|
147
|
-
constructor() {
|
|
148
|
-
super(formatBrowserServiceNotRunningError());
|
|
149
|
-
this.name = 'BrowserServiceNotRunningError';
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
export function formatBrowserServiceNotRunningError(daemonRunning = isDaemonRunning()) {
|
|
153
|
-
return [
|
|
154
|
-
'Browser service is not running.',
|
|
155
|
-
`Shared daemon: ${daemonRunning ? 'running (other services are unaffected)' : 'stopped'}.`,
|
|
156
|
-
'Start it with: agents browser start (enables only the browser-ipc service and uses this machine\'s configured default browser)',
|
|
157
|
-
'Pick / pin a profile: agents browser use <name> (or: agents browser start --profile <name>)',
|
|
158
|
-
'List profiles: agents browser profiles list',
|
|
159
|
-
].join('\n');
|
|
160
|
-
}
|
|
161
|
-
export function getSocketPath() {
|
|
162
|
-
return path.join(getHelpersDir(), 'browser', SOCKET_NAME);
|
|
163
|
-
}
|
|
164
|
-
/**
|
|
165
|
-
* The address the daemon actually listens on / clients connect to: the unix
|
|
166
|
-
* socket file on POSIX, a `\\.\pipe\` named pipe on Windows. `getSocketPath`
|
|
167
|
-
* stays the canonical key (and the POSIX socket path); on Windows it's only used
|
|
168
|
-
* to derive a stable pipe name, never touched on disk.
|
|
169
|
-
*/
|
|
170
|
-
function getIpcEndpoint() {
|
|
171
|
-
return ipcEndpoint(getSocketPath());
|
|
172
|
-
}
|
|
173
|
-
/** Can we open a connection to the daemon right now? Resolves false on any
|
|
174
|
-
* error. This is the authoritative liveness check on every platform: a live
|
|
175
|
-
* daemon accepts the connection, while a stale POSIX socket file left behind by
|
|
176
|
-
* a crashed daemon (or one that "appears and is immediately destroyed", #556)
|
|
177
|
-
* rejects with ECONNREFUSED and is correctly reported as not reachable —
|
|
178
|
-
* something fs.existsSync can't distinguish. */
|
|
179
|
-
function probeBrowserService(endpoint, timeoutMs = 500) {
|
|
180
|
-
return new Promise((resolve) => {
|
|
181
|
-
const sock = net.createConnection(endpoint);
|
|
182
|
-
let done = false;
|
|
183
|
-
const finish = (ok) => { if (done)
|
|
184
|
-
return; done = true; sock.destroy(); resolve(ok); };
|
|
185
|
-
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
186
|
-
sock.on('connect', () => { clearTimeout(timer); finish(true); });
|
|
187
|
-
sock.on('error', () => { clearTimeout(timer); finish(false); });
|
|
188
|
-
});
|
|
189
|
-
}
|
|
190
|
-
/** Is the daemon reachable? A real connect probe on every platform — a socket
|
|
191
|
-
* file existing on disk is not proof a daemon is listening on it. */
|
|
192
|
-
export async function isBrowserServiceReachable() {
|
|
193
|
-
return probeBrowserService(getIpcEndpoint());
|
|
194
|
-
}
|
|
195
|
-
/**
|
|
196
|
-
* Can the daemon actually REPLY right now? Opens a connection and requires a
|
|
197
|
-
* parseable response to a `version` request within `timeoutMs`. Unlike
|
|
198
|
-
* {@link probeBrowserService} (which resolves on the kernel-level `connect`), this only
|
|
199
|
-
* succeeds when the daemon's event loop is running and services the request — so
|
|
200
|
-
* it is the one probe that distinguishes a healthy daemon from a wedged one whose
|
|
201
|
-
* loop is blocked (PHNX-3411). Resolves false on connect error, timeout, an early
|
|
202
|
-
* close, or an unparseable reply. `version` is the right probe: its handler is
|
|
203
|
-
* trivial and synchronous (never touches the browser), so a slow reply means the
|
|
204
|
-
* loop is blocked, not that a real action is in flight.
|
|
205
|
-
*/
|
|
206
|
-
function probeBrowserServiceResponsive(endpoint, timeoutMs = RESPONSIVENESS_PROBE_TIMEOUT_MS) {
|
|
207
|
-
return new Promise((resolve) => {
|
|
208
|
-
const sock = net.createConnection(endpoint);
|
|
209
|
-
let buffer = '';
|
|
210
|
-
let done = false;
|
|
211
|
-
const finish = (ok) => { if (done)
|
|
212
|
-
return; done = true; clearTimeout(timer); sock.destroy(); resolve(ok); };
|
|
213
|
-
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
214
|
-
sock.on('connect', () => { sock.write(JSON.stringify({ action: 'version' }) + '\n'); });
|
|
215
|
-
sock.on('data', (data) => {
|
|
216
|
-
buffer += data.toString();
|
|
217
|
-
const idx = buffer.indexOf('\n');
|
|
218
|
-
if (idx === -1)
|
|
219
|
-
return;
|
|
220
|
-
try {
|
|
221
|
-
const resp = JSON.parse(buffer.slice(0, idx));
|
|
222
|
-
finish(resp.ok === true);
|
|
223
|
-
}
|
|
224
|
-
catch {
|
|
225
|
-
finish(false);
|
|
226
|
-
}
|
|
227
|
-
});
|
|
228
|
-
sock.on('error', () => finish(false));
|
|
229
|
-
sock.on('close', () => finish(false));
|
|
230
|
-
});
|
|
231
|
-
}
|
|
232
|
-
/**
|
|
233
|
-
* Is a *reachable* daemon actually responsive, or is its event loop wedged
|
|
234
|
-
* (PHNX-3411)? Retries {@link probeBrowserServiceResponsive} up to
|
|
235
|
-
* {@link RESPONSIVENESS_PROBE_ATTEMPTS} times so a single transient miss (a GC
|
|
236
|
-
* pause) never condemns a healthy daemon; returns true as soon as any attempt
|
|
237
|
-
* gets a reply, and false only when every attempt fails.
|
|
238
|
-
*/
|
|
239
|
-
export async function isBrowserServiceResponsive(endpoint = getIpcEndpoint(), attempts = RESPONSIVENESS_PROBE_ATTEMPTS) {
|
|
240
|
-
for (let i = 0; i < attempts; i++) {
|
|
241
|
-
if (await probeBrowserServiceResponsive(endpoint))
|
|
242
|
-
return true;
|
|
243
|
-
}
|
|
244
|
-
return false;
|
|
245
|
-
}
|
|
246
|
-
/**
|
|
247
|
-
* Wait until the browser service is genuinely reachable, or throw.
|
|
248
|
-
*
|
|
249
|
-
* Re-probes across the whole window rather than latching on the first accept, so
|
|
250
|
-
* it survives an IPC-server restart that happens mid-wait (PHNX-3289): a restart
|
|
251
|
-
* just resets the consecutive-accept counter, and the loop keeps going until the
|
|
252
|
-
* daemon is *stably* up or the deadline passes. Bounded and fail-loud — a daemon
|
|
253
|
-
* that never comes up throws a message naming the endpoint and the budget, never
|
|
254
|
-
* a silent hang.
|
|
255
|
-
*/
|
|
256
|
-
export async function waitForBrowserService(_socketPath, timeoutMs = SOCKET_WAIT_TIMEOUT_MS) {
|
|
257
|
-
const endpoint = getIpcEndpoint();
|
|
258
|
-
const deadline = Date.now() + timeoutMs;
|
|
259
|
-
let consecutive = 0;
|
|
260
|
-
while (Date.now() < deadline) {
|
|
261
|
-
if (await probeBrowserService(endpoint)) {
|
|
262
|
-
consecutive += 1;
|
|
263
|
-
if (consecutive >= SOCKET_WAIT_STABLE_PROBES)
|
|
264
|
-
return;
|
|
265
|
-
}
|
|
266
|
-
else {
|
|
267
|
-
// A dropped probe means a restart (or the daemon isn't up yet) — start the
|
|
268
|
-
// stability count over rather than counting accepts from before the gap.
|
|
269
|
-
consecutive = 0;
|
|
270
|
-
}
|
|
271
|
-
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
272
|
-
}
|
|
273
|
-
throw new Error(`Timeout waiting for browser service socket after ${Math.round(timeoutMs / 1000)}s (${endpoint}).`);
|
|
274
|
-
}
|
|
275
|
-
/** How long {@link stopBrowserService} waits for the endpoint to go quiet after
|
|
276
|
-
* signalling the daemon-owned service transition. Bounded so a wedged daemon
|
|
277
|
-
* fails loud instead of leaving the client hung on a control request. */
|
|
278
|
-
const SERVICE_STOP_QUIESCE_MS = 5_000;
|
|
279
|
-
/**
|
|
280
|
-
* Stop only the daemon-hosted browser IPC service so a subsequent `start` comes
|
|
281
|
-
* up clean (PHNX-3605). The short-lived browser client persists the
|
|
282
|
-
* `browser-ipc` service off and asks the daemon to apply that transition over
|
|
283
|
-
* its existing SIGHUP control path. It never stops or restarts the shared
|
|
284
|
-
* supervisor, so routines, usage sync, secrets, and every sibling service keep
|
|
285
|
-
* their process and in-flight work.
|
|
286
|
-
*
|
|
287
|
-
* Once the endpoint is quiet, a stale socket left by a hard-crashed browser
|
|
288
|
-
* service is removed. If the daemon event loop itself is wedged and cannot
|
|
289
|
-
* consume SIGHUP, the endpoint remains reachable and this fails loud with the
|
|
290
|
-
* deliberate operator-owned whole-daemon restart command; the client never
|
|
291
|
-
* escalates into that process-wide action itself.
|
|
292
|
-
*/
|
|
293
|
-
export async function stopBrowserService() {
|
|
294
|
-
const endpoint = getIpcEndpoint();
|
|
295
|
-
const wasRunning = await probeBrowserService(endpoint);
|
|
296
|
-
const daemonRunning = isDaemonRunning();
|
|
297
|
-
setDaemonServiceEnabled('browser-ipc', false);
|
|
298
|
-
if (daemonRunning && !signalDaemonReload()) {
|
|
299
|
-
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)'));
|
|
300
|
-
}
|
|
301
|
-
// Wait for the listener to actually release the endpoint. We must decide
|
|
302
|
-
// reachability BEFORE touching the socket file: unlinking a path out from
|
|
303
|
-
// under a live server makes new connects ENOENT (so it would *look* cleared)
|
|
304
|
-
// while the server keeps running — the two-servers orphan the eviction
|
|
305
|
-
// protocol exists to prevent. So a still-reachable endpoint fails loud here,
|
|
306
|
-
// and only a genuinely dead one gets its stale file removed below.
|
|
307
|
-
const deadline = Date.now() + SERVICE_STOP_QUIESCE_MS;
|
|
308
|
-
let reachable = wasRunning;
|
|
309
|
-
while (reachable && Date.now() < deadline) {
|
|
310
|
-
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
311
|
-
reachable = await probeBrowserService(endpoint);
|
|
312
|
-
}
|
|
313
|
-
if (reachable) {
|
|
314
|
-
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)'));
|
|
315
|
-
}
|
|
316
|
-
// Nothing is listening now — clear the leftover socket file a hard-crashed
|
|
317
|
-
// daemon left behind, so the next `start` binds clean instead of unlinking it
|
|
318
|
-
// blindly. (Named pipes vanish with their owning process, so Windows has no
|
|
319
|
-
// stale file to clear.)
|
|
320
|
-
const socketCleared = IS_WINDOWS ? false : await clearDeadSocketFile(endpoint, getSocketPath());
|
|
321
|
-
return { wasRunning, daemonRunning, socketCleared };
|
|
322
|
-
}
|
|
323
|
-
/**
|
|
324
|
-
* Remove a leftover browser socket FILE, but only when nothing is listening on
|
|
325
|
-
* it — re-probing liveness IMMEDIATELY before the unlink to close the TOCTOU
|
|
326
|
-
* window (PHNX-3289 review). Between {@link stopBrowserService}'s quiesce loop
|
|
327
|
-
* deciding the endpoint was unreachable and this unlink, a concurrent
|
|
328
|
-
* `browser start` could bind a NEW listener on the same path; an unconditional
|
|
329
|
-
* unlink would then delete a LIVE daemon's socket — the exact two-servers orphan
|
|
330
|
-
* this function exists to prevent. A listener seen here means the wedge is already
|
|
331
|
-
* resolved (a fresh daemon owns the path), so its socket is left intact. Returns
|
|
332
|
-
* true only when a genuinely dead file was removed.
|
|
333
|
-
*/
|
|
334
|
-
export async function clearDeadSocketFile(endpoint, socketPath) {
|
|
335
|
-
if (!fs.existsSync(socketPath))
|
|
336
|
-
return false;
|
|
337
|
-
// A listener bound again since the quiesce loop → do not touch its socket.
|
|
338
|
-
if (await probeBrowserService(endpoint))
|
|
339
|
-
return false;
|
|
340
|
-
try {
|
|
341
|
-
fs.unlinkSync(socketPath);
|
|
342
|
-
return true;
|
|
343
|
-
}
|
|
344
|
-
catch {
|
|
345
|
-
// Raced with a fresh start that already claimed it — the goal (no stale
|
|
346
|
-
// socket) still holds, so report cleared only when it is genuinely gone.
|
|
347
|
-
return !fs.existsSync(socketPath);
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
/**
|
|
351
|
-
* One long-lived connection to the existing browser service. Requests are
|
|
352
|
-
* serialized so the daemon's newline-delimited responses always map to the
|
|
353
|
-
* caller that produced them, while the process and socket stay warm between
|
|
354
|
-
* actions.
|
|
355
|
-
*/
|
|
356
|
-
class BrowserIPCConnection {
|
|
357
|
-
socket;
|
|
358
|
-
buffer = '';
|
|
359
|
-
pending;
|
|
360
|
-
tail = Promise.resolve();
|
|
361
|
-
closed = false;
|
|
362
|
-
constructor(socket) {
|
|
363
|
-
this.socket = socket;
|
|
364
|
-
socket.on('data', (data) => this.handleData(data));
|
|
365
|
-
socket.on('error', (error) => this.fail(error));
|
|
366
|
-
socket.on('close', () => {
|
|
367
|
-
this.fail(new Error('Browser service connection closed'));
|
|
368
|
-
});
|
|
369
|
-
}
|
|
370
|
-
request(request) {
|
|
371
|
-
const stamped = stampCallerIdentity(request);
|
|
372
|
-
const result = this.tail.then(() => this.requestOnce(stamped));
|
|
373
|
-
this.tail = result.then(() => undefined, () => undefined);
|
|
374
|
-
return result;
|
|
375
|
-
}
|
|
376
|
-
async close() {
|
|
377
|
-
await this.tail;
|
|
378
|
-
if (this.closed || this.socket.destroyed)
|
|
379
|
-
return;
|
|
380
|
-
await new Promise((resolve) => {
|
|
381
|
-
this.socket.once('close', resolve);
|
|
382
|
-
this.socket.end();
|
|
383
|
-
});
|
|
384
|
-
}
|
|
385
|
-
requestOnce(request) {
|
|
386
|
-
if (this.closed || this.socket.destroyed) {
|
|
387
|
-
return Promise.reject(new Error('Browser service connection is closed'));
|
|
388
|
-
}
|
|
389
|
-
return new Promise((resolve, reject) => {
|
|
390
|
-
this.pending = { resolve, reject };
|
|
391
|
-
this.socket.write(`${JSON.stringify(request)}\n`, (error) => {
|
|
392
|
-
if (!error)
|
|
393
|
-
return;
|
|
394
|
-
if (this.pending?.reject === reject)
|
|
395
|
-
this.pending = undefined;
|
|
396
|
-
reject(error);
|
|
397
|
-
});
|
|
398
|
-
});
|
|
399
|
-
}
|
|
400
|
-
handleData(data) {
|
|
401
|
-
this.buffer += data.toString();
|
|
402
|
-
while (true) {
|
|
403
|
-
const index = this.buffer.indexOf('\n');
|
|
404
|
-
if (index === -1)
|
|
405
|
-
return;
|
|
406
|
-
const line = this.buffer.slice(0, index);
|
|
407
|
-
this.buffer = this.buffer.slice(index + 1);
|
|
408
|
-
if (!line.trim())
|
|
409
|
-
continue;
|
|
410
|
-
const pending = this.pending;
|
|
411
|
-
if (!pending) {
|
|
412
|
-
this.socket.destroy();
|
|
413
|
-
this.fail(new Error('Browser service sent an unexpected response'));
|
|
414
|
-
return;
|
|
415
|
-
}
|
|
416
|
-
this.pending = undefined;
|
|
417
|
-
try {
|
|
418
|
-
pending.resolve(JSON.parse(line));
|
|
419
|
-
}
|
|
420
|
-
catch {
|
|
421
|
-
const error = new Error('Browser service returned invalid JSON');
|
|
422
|
-
pending.reject(error);
|
|
423
|
-
this.socket.destroy();
|
|
424
|
-
this.fail(error);
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
fail(error) {
|
|
429
|
-
if (this.closed)
|
|
430
|
-
return;
|
|
431
|
-
this.closed = true;
|
|
432
|
-
const pending = this.pending;
|
|
433
|
-
this.pending = undefined;
|
|
434
|
-
pending?.reject(error);
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
export class BrowserIPCServer {
|
|
438
|
-
server = null;
|
|
439
|
-
/** Filesystem identity of the POSIX binding this server created. */
|
|
440
|
-
socketIdentity = null;
|
|
441
|
-
service;
|
|
442
|
-
/** Live client connections, so {@link stop} can end them rather than wait. */
|
|
443
|
-
connections = new Set();
|
|
444
|
-
/** In-flight stop, so a second SIGTERM awaits the same close, never skips it. */
|
|
445
|
-
stopping = null;
|
|
446
|
-
/**
|
|
447
|
-
* Optional explicit socket path for TEST ISOLATION only (POSIX). When set, the
|
|
448
|
-
* server binds (and unlinks a stale copy of) THIS path instead of the shared
|
|
449
|
-
* production `browser.sock`, so a test never unlinks the user's live daemon
|
|
450
|
-
* socket. Default (undefined) is unchanged. Not for production use.
|
|
451
|
-
*/
|
|
452
|
-
socketPathOverride;
|
|
453
|
-
constructor(service, socketPathOverride) {
|
|
454
|
-
this.service = service;
|
|
455
|
-
this.socketPathOverride = socketPathOverride;
|
|
456
|
-
}
|
|
457
|
-
async start() {
|
|
458
|
-
const socketPath = this.socketPathOverride ?? getSocketPath();
|
|
459
|
-
// On POSIX the endpoint IS the socket path; an override addresses the same
|
|
460
|
-
// isolated file. (Windows keeps its derived pipe name; the override is
|
|
461
|
-
// POSIX-only test machinery.)
|
|
462
|
-
const endpoint = this.socketPathOverride ?? getIpcEndpoint();
|
|
463
|
-
const socketDir = path.dirname(socketPath);
|
|
464
|
-
fs.mkdirSync(socketDir, { recursive: true, mode: 0o700 });
|
|
465
|
-
if (!IS_WINDOWS) {
|
|
466
|
-
fs.chmodSync(socketDir, 0o700);
|
|
467
|
-
// Remove a stale unix socket from a prior crash. (Named pipes are not
|
|
468
|
-
// filesystem objects and vanish with their owning process.)
|
|
469
|
-
if (fs.existsSync(socketPath)) {
|
|
470
|
-
fs.unlinkSync(socketPath);
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
this.server = net.createServer((socket) => {
|
|
474
|
-
// `net.Server.close()` stops accepting but does not complete until every
|
|
475
|
-
// EXISTING connection ends, and clients here hold one open on purpose
|
|
476
|
-
// ("the process and socket stay warm between actions"). Tracking them is
|
|
477
|
-
// what lets stop() end them deliberately instead of waiting out a timeout.
|
|
478
|
-
this.connections.add(socket);
|
|
479
|
-
socket.on('close', () => this.connections.delete(socket));
|
|
480
|
-
let buffer = '';
|
|
481
|
-
socket.on('data', async (data) => {
|
|
482
|
-
buffer += data.toString();
|
|
483
|
-
const lines = buffer.split('\n');
|
|
484
|
-
buffer = lines.pop() || '';
|
|
485
|
-
for (const line of lines) {
|
|
486
|
-
if (!line.trim())
|
|
487
|
-
continue;
|
|
488
|
-
try {
|
|
489
|
-
const request = JSON.parse(line);
|
|
490
|
-
const response = await this.handleRequest(request);
|
|
491
|
-
socket.write(JSON.stringify(response) + '\n');
|
|
492
|
-
}
|
|
493
|
-
catch (err) {
|
|
494
|
-
const error = err instanceof Error ? err.message : 'Unknown error';
|
|
495
|
-
socket.write(JSON.stringify({ ok: false, error }) + '\n');
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
});
|
|
499
|
-
socket.on('error', () => {
|
|
500
|
-
// Client disconnected
|
|
501
|
-
});
|
|
502
|
-
});
|
|
503
|
-
return new Promise((resolve, reject) => {
|
|
504
|
-
if (IS_WINDOWS) {
|
|
505
|
-
// Windows named pipe: no umask/chmod — filesystem perms don't apply and
|
|
506
|
-
// pipe ACLs default to the creating user.
|
|
507
|
-
this.server.listen(endpoint, () => resolve());
|
|
508
|
-
this.server.on('error', (err) => reject(err));
|
|
509
|
-
return;
|
|
510
|
-
}
|
|
511
|
-
// Lock down the browser socket dir before opening the socket; on macOS
|
|
512
|
-
// the parent dir is the real local-user boundary for AF_UNIX sockets.
|
|
513
|
-
const prevUmask = process.umask(0o077);
|
|
514
|
-
let restored = false;
|
|
515
|
-
const restoreUmask = () => {
|
|
516
|
-
if (restored)
|
|
517
|
-
return;
|
|
518
|
-
restored = true;
|
|
519
|
-
process.umask(prevUmask);
|
|
520
|
-
};
|
|
521
|
-
this.server.listen(socketPath, () => {
|
|
522
|
-
try {
|
|
523
|
-
fs.chmodSync(socketPath, 0o600);
|
|
524
|
-
this.socketIdentity = readSocketIdentity(socketPath);
|
|
525
|
-
resolve();
|
|
526
|
-
}
|
|
527
|
-
catch (err) {
|
|
528
|
-
reject(err);
|
|
529
|
-
}
|
|
530
|
-
finally {
|
|
531
|
-
restoreUmask();
|
|
532
|
-
}
|
|
533
|
-
});
|
|
534
|
-
this.server.on('error', (err) => {
|
|
535
|
-
restoreUmask();
|
|
536
|
-
reject(err);
|
|
537
|
-
});
|
|
538
|
-
});
|
|
539
|
-
}
|
|
540
|
-
/**
|
|
541
|
-
* Stop accepting, release the binding, and only then resolve.
|
|
542
|
-
*
|
|
543
|
-
* RUSH-2421: this used to call `server.close()` and move on. `close()` is
|
|
544
|
-
* asynchronous — it stops accepting immediately but the binding is not
|
|
545
|
-
* actually released until the `'close'` event fires — so `stop()` resolved
|
|
546
|
-
* while the socket (or named pipe) was still held. The daemon's
|
|
547
|
-
* `handleShutdown` awaits this before it exits, and a successor's
|
|
548
|
-
* `claimDaemonInstance` waits for that exit as its proof the predecessor's
|
|
549
|
-
* resources are free (SING-11). Resolving early made that proof false: the
|
|
550
|
-
* newcomer could bind before the incumbent's binding was gone, which is the
|
|
551
|
-
* two-servers-on-one-socket orphan the eviction protocol exists to prevent.
|
|
552
|
-
*
|
|
553
|
-
* The first version of this waited out a timeout instead, and that made every
|
|
554
|
-
* graceful daemon stop escalate to `killTree`: `close()` does not complete
|
|
555
|
-
* while a connection is open, browser clients hold one open on purpose, and
|
|
556
|
-
* the timeout was set equal to the daemon's own SIGTERM grace window
|
|
557
|
-
* (`STOP_GRACE_MS`) — so `handleShutdown` was still inside `stop()` when
|
|
558
|
-
* `stopDaemon` gave up waiting and killed it. So we END the connections
|
|
559
|
-
* rather than wait for them: `close()` stops accepting, destroying the
|
|
560
|
-
* tracked sockets lets it complete, and the timeout is only a backstop for a
|
|
561
|
-
* socket that will not die, deliberately far below `STOP_GRACE_MS`.
|
|
562
|
-
*/
|
|
563
|
-
async stop() {
|
|
564
|
-
// A second SIGTERM must await the SAME stop, not skip past it because
|
|
565
|
-
// `this.server` was already nulled by the first.
|
|
566
|
-
if (!this.stopping)
|
|
567
|
-
this.stopping = this.doStop();
|
|
568
|
-
return this.stopping;
|
|
569
|
-
}
|
|
570
|
-
async doStop() {
|
|
571
|
-
const socketPath = getSocketPath();
|
|
572
|
-
const owned = this.socketIdentity;
|
|
573
|
-
let preservedReplacement = null;
|
|
574
|
-
if (!IS_WINDOWS && owned && readSocketIdentity(socketPath) && !isSameSocket(socketPath, owned)) {
|
|
575
|
-
// Node/libuv remembers the pathname passed to listen() and unlinks that
|
|
576
|
-
// pathname during server.close(). If a successor has already replaced the
|
|
577
|
-
// directory entry, an inode check AFTER close is too late: libuv has
|
|
578
|
-
// deleted the successor. Move the replacement aside for the close and put
|
|
579
|
-
// it back before returning. The listening socket stays live across rename.
|
|
580
|
-
preservedReplacement = `${socketPath}.preserved-${process.pid}-${Date.now()}`;
|
|
581
|
-
fs.renameSync(socketPath, preservedReplacement);
|
|
582
|
-
}
|
|
583
|
-
try {
|
|
584
|
-
await this.closeServer();
|
|
585
|
-
}
|
|
586
|
-
finally {
|
|
587
|
-
if (preservedReplacement && fs.existsSync(preservedReplacement) && !fs.existsSync(socketPath)) {
|
|
588
|
-
fs.renameSync(preservedReplacement, socketPath);
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
if (!IS_WINDOWS) {
|
|
592
|
-
this.socketIdentity = null;
|
|
593
|
-
if (owned && isSameSocket(socketPath, owned)) {
|
|
594
|
-
fs.unlinkSync(socketPath);
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
await this.service.shutdown();
|
|
598
|
-
}
|
|
599
|
-
/** Release the listening binding, resolving only once it is genuinely gone. */
|
|
600
|
-
closeServer() {
|
|
601
|
-
const server = this.server;
|
|
602
|
-
this.server = null;
|
|
603
|
-
if (!server)
|
|
604
|
-
return Promise.resolve();
|
|
605
|
-
return new Promise((resolve) => {
|
|
606
|
-
const done = () => { clearTimeout(timer); resolve(); };
|
|
607
|
-
// Backstop only, for a socket that refuses to die. Far below the daemon's
|
|
608
|
-
// SIGTERM grace window so a slow close can never be what gets the daemon
|
|
609
|
-
// killed — the whole point is that the graceful path stays graceful.
|
|
610
|
-
const timer = setTimeout(done, IPC_CLOSE_TIMEOUT_MS);
|
|
611
|
-
// `close()` reports an error when the server was never listening —
|
|
612
|
-
// nothing to release, so that is a completed close, not a failure.
|
|
613
|
-
server.close(() => done());
|
|
614
|
-
// `close()` waits for existing connections; ending them is what lets it
|
|
615
|
-
// finish now instead of at the timeout.
|
|
616
|
-
for (const socket of this.connections)
|
|
617
|
-
socket.destroy();
|
|
618
|
-
this.connections.clear();
|
|
619
|
-
});
|
|
620
|
-
}
|
|
621
|
-
/**
|
|
622
|
-
* Resolve `request.task` from caller identity when omitted. Page verbs may
|
|
623
|
-
* create; done/stop never create. Returns an error response to short-circuit
|
|
624
|
-
* the action, or undefined when the request is ready (task filled in).
|
|
625
|
-
*/
|
|
626
|
-
async bindTask(request) {
|
|
627
|
-
const createIfMissing = PAGE_CREATE_VERBS.has(request.action);
|
|
628
|
-
const isClose = CLOSE_VERBS.has(request.action);
|
|
629
|
-
// NEVER trust these off the wire — they are server-derived bind state written
|
|
630
|
-
// BELOW from the resolver's own result. A client that pre-set `firstOpen` in
|
|
631
|
-
// its request JSON could otherwise steer navigate/tab-add to short-circuit
|
|
632
|
-
// onto an arbitrary tab id / forged created flag (or forge a picked-device
|
|
633
|
-
// hint). Clear before binding so only this handler's values survive (PHNX-2399).
|
|
634
|
-
delete request.firstOpen;
|
|
635
|
-
delete request.picked;
|
|
636
|
-
// stop with --profile only (no task) is handled by the stop case itself.
|
|
637
|
-
if (request.action === 'stop' && request.profile && !request.task)
|
|
638
|
-
return undefined;
|
|
639
|
-
try {
|
|
640
|
-
const resolved = await this.service.resolveOrCreateTask({
|
|
641
|
-
task: request.task,
|
|
642
|
-
profile: request.profile,
|
|
643
|
-
actor: request.actor,
|
|
644
|
-
launchId: request.launchId,
|
|
645
|
-
sessionId: request.sessionId,
|
|
646
|
-
fleetRemote: request.fleetRemote,
|
|
647
|
-
createIfMissing,
|
|
648
|
-
title: request.title,
|
|
649
|
-
url: request.url,
|
|
650
|
-
});
|
|
651
|
-
if (!resolved) {
|
|
652
|
-
if (isClose) {
|
|
653
|
-
return { ok: true, message: 'nothing to close' };
|
|
654
|
-
}
|
|
655
|
-
return {
|
|
656
|
-
ok: false,
|
|
657
|
-
error: actionable('No browser task for this session.', 'Next: agents browser navigate <url> | agents browser start'),
|
|
658
|
-
};
|
|
659
|
-
}
|
|
660
|
-
request.task = resolved.task.name;
|
|
661
|
-
if (resolved.created && resolved.picked) {
|
|
662
|
-
request.picked = resolved.picked;
|
|
663
|
-
}
|
|
664
|
-
// The implicit create already opened request.url on this tab. Carry the
|
|
665
|
-
// ACTUAL page result so the navigate/tab-add handler reports that first
|
|
666
|
-
// open honestly (created/refreshed as it happened) instead of executing
|
|
667
|
-
// the URL a second time (PHNX-2399).
|
|
668
|
-
if (resolved.created && resolved.firstOpen) {
|
|
669
|
-
request.firstOpen = resolved.firstOpen;
|
|
670
|
-
}
|
|
671
|
-
return undefined;
|
|
672
|
-
}
|
|
673
|
-
catch (err) {
|
|
674
|
-
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
675
|
-
}
|
|
676
|
-
}
|
|
677
|
-
async handleRequest(request) {
|
|
678
|
-
if (request.action === 'upload-stage') {
|
|
679
|
-
const source = request.source;
|
|
680
|
-
if (!source) {
|
|
681
|
-
return { ok: false, error: 'Source required' };
|
|
682
|
-
}
|
|
683
|
-
const result = this.service.stageUpload(source);
|
|
684
|
-
return { ok: true, path: result.path };
|
|
685
|
-
}
|
|
686
|
-
// Task-scoped actions: resolve identity / create / refuse once here.
|
|
687
|
-
if (PAGE_RESOLVE_VERBS.has(request.action) || CLOSE_VERBS.has(request.action)) {
|
|
688
|
-
const early = await this.bindTask(request);
|
|
689
|
-
if (early)
|
|
690
|
-
return early;
|
|
691
|
-
}
|
|
692
|
-
switch (request.action) {
|
|
693
|
-
case 'version': {
|
|
694
|
-
return { ok: true, version: getCliVersion() };
|
|
695
|
-
}
|
|
696
|
-
// On-demand self-update, triggered by a client that noticed version
|
|
697
|
-
// skew (reconcileDaemonVersion below). DECOUPLED from the install
|
|
698
|
-
// (PHNX-3605): this handler MUST NOT await the check→download→install→
|
|
699
|
-
// verify, because it sits on the path of EVERY version-skewed
|
|
700
|
-
// `agents browser <verb>` (reconcileDaemonVersion → prepareIPC → here), so
|
|
701
|
-
// awaiting the whole thing would synchronously stall an ordinary browser
|
|
702
|
-
// verb for tens of seconds — worst case ~15 min — behind daemon
|
|
703
|
-
// housekeeping, exactly the client-stall PHNX-3605 exists to prevent.
|
|
704
|
-
// Instead: kick the self-update off in the BACKGROUND (bounded, and
|
|
705
|
-
// sharing the module-level in-flight guard so two triggers can't race a
|
|
706
|
-
// second install) and respond IMMEDIATELY that it was accepted. The daemon
|
|
707
|
-
// does install→verify→exit(0) on its own; the OS supervisor relaunches it
|
|
708
|
-
// and the browser reconnects. scheduleSelfUpdateExit (fired from the
|
|
709
|
-
// background continuation on a verified update) still delays the exit so
|
|
710
|
-
// this response has flushed to the socket first, and serializes against
|
|
711
|
-
// the periodic tick's own exit.
|
|
712
|
-
case 'request-self-update': {
|
|
713
|
-
// Instant, network-free decline gate FIRST (dev build / shadowed
|
|
714
|
-
// install), so a version-skewed browser client gets the PHNX-3605
|
|
715
|
-
// "nothing changed, not evicting" advisory immediately rather than a
|
|
716
|
-
// "triggered" it would never act on — reconcileDaemonVersion keys the
|
|
717
|
-
// advisory on !selfUpdateTriggered. Only a genuinely-eligible install is
|
|
718
|
-
// backgrounded below; the slow registry/download/install/verify never
|
|
719
|
-
// runs on this response path.
|
|
720
|
-
const declineReason = selfUpdateSyncDeclineReason();
|
|
721
|
-
if (declineReason) {
|
|
722
|
-
return { ok: true, selfUpdateTriggered: false, reason: declineReason };
|
|
723
|
-
}
|
|
724
|
-
void triggerSelfUpdateInBackground({ log: daemonLog })
|
|
725
|
-
.then((outcome) => {
|
|
726
|
-
if (outcome.updated)
|
|
727
|
-
scheduleSelfUpdateExit();
|
|
728
|
-
})
|
|
729
|
-
.catch((err) => {
|
|
730
|
-
daemonLog('ERROR', `self-update: background trigger failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
731
|
-
});
|
|
732
|
-
return { ok: true, selfUpdateTriggered: true };
|
|
733
|
-
}
|
|
734
|
-
case 'show': {
|
|
735
|
-
// Task-less by design — see BrowserService.showUrl. Absent from both
|
|
736
|
-
// PAGE_CREATE_VERBS and PAGE_RESOLVE_VERBS, so bindTask never ran above
|
|
737
|
-
// and this request carries no task.
|
|
738
|
-
if (!request.url) {
|
|
739
|
-
return { ok: false, error: actionable('URL required.', 'Next: agents browser navigate <url>') };
|
|
740
|
-
}
|
|
741
|
-
if (!request.profile) {
|
|
742
|
-
return { ok: false, error: actionable('Profile required.', 'Next: agents browser profiles list') };
|
|
743
|
-
}
|
|
744
|
-
const shown = await this.service.showUrl(request.profile, request.url, {
|
|
745
|
-
fleetRemote: request.fleetRemote,
|
|
746
|
-
actor: request.actor,
|
|
747
|
-
});
|
|
748
|
-
return { ok: true, tabId: shown.tabId, message: shown.picked };
|
|
749
|
-
}
|
|
750
|
-
case 'start': {
|
|
751
|
-
if (!request.profile) {
|
|
752
|
-
return { ok: false, error: actionable('Profile required.', 'Next: agents browser profiles list') };
|
|
753
|
-
}
|
|
754
|
-
const result = await this.service.start(request.profile, {
|
|
755
|
-
taskName: request.taskName,
|
|
756
|
-
url: request.url,
|
|
757
|
-
endpointName: request.endpoint,
|
|
758
|
-
skipDomainSkill: request.skipDomainSkill,
|
|
759
|
-
fresh: request.fresh,
|
|
760
|
-
actor: request.actor,
|
|
761
|
-
launchId: request.launchId,
|
|
762
|
-
sessionId: request.sessionId,
|
|
763
|
-
fleetRemote: request.fleetRemote,
|
|
764
|
-
title: request.title,
|
|
765
|
-
});
|
|
766
|
-
// `reused` is task-level (a same-name retry matched an existing task);
|
|
767
|
-
// `created`/`refreshed` describe the actual PAGE operation, carried from
|
|
768
|
-
// `firstOpen`. A no-URL retry is `reused: true` with created/refreshed
|
|
769
|
-
// both false — no reload happened, so it must NOT read as refreshed. A
|
|
770
|
-
// pure task reuse with no page refresh gets the "Reused existing task"
|
|
771
|
-
// note; a same-URL reopen gets its own "Tab already open—refreshed" note
|
|
772
|
-
// from `firstOpen.message` (PHNX-2399).
|
|
773
|
-
const startReuseNote = result.reused && !result.firstOpen?.refreshed && !result.firstOpen?.created
|
|
774
|
-
? 'Reused existing task'
|
|
775
|
-
: undefined;
|
|
776
|
-
return {
|
|
777
|
-
ok: true,
|
|
778
|
-
task: result.name,
|
|
779
|
-
tabId: result.tabId,
|
|
780
|
-
windowTargetId: result.windowId,
|
|
781
|
-
skill: result.skill,
|
|
782
|
-
reused: result.reused,
|
|
783
|
-
created: result.firstOpen?.created,
|
|
784
|
-
refreshed: result.firstOpen?.refreshed,
|
|
785
|
-
message: [result.picked, result.firstOpen?.message, startReuseNote].filter(Boolean).join(' · ') || undefined,
|
|
786
|
-
};
|
|
787
|
-
}
|
|
788
|
-
// The out-of-process seam onto the abandoned-task reaper: the daemon
|
|
789
|
-
// owns the live BrowserService, so a CLI verb (`agents browser prune`) can
|
|
790
|
-
// only reach `reapAbandoned` through IPC. The daemon's own periodic tick
|
|
791
|
-
// calls the service method directly.
|
|
792
|
-
case 'gc': {
|
|
793
|
-
// 0 is the "disable idle reaping" signal (browser.task-idle-minutes),
|
|
794
|
-
// not a zero-ms window — translate it to reapAbandoned's `null` rather
|
|
795
|
-
// than let it hit the idleMs<=0 guard. Omitting the flag falls back to
|
|
796
|
-
// this box's own configured default (or 30 minutes when unset).
|
|
797
|
-
const idleMs = request.idleMinutes !== undefined
|
|
798
|
-
? (request.idleMinutes === 0 ? null : request.idleMinutes * 60_000)
|
|
799
|
-
: await resolveBrowserTaskIdleMs();
|
|
800
|
-
const reaped = await this.service.reapAbandoned({ idleMs, dryRun: request.dryRun });
|
|
801
|
-
return { ok: true, reaped };
|
|
802
|
-
}
|
|
803
|
-
case 'done': {
|
|
804
|
-
if (!request.task) {
|
|
805
|
-
return { ok: true, message: 'nothing to close' };
|
|
806
|
-
}
|
|
807
|
-
const result = await this.service.done(request.task);
|
|
808
|
-
return {
|
|
809
|
-
ok: result.ok,
|
|
810
|
-
error: result.ok
|
|
811
|
-
? undefined
|
|
812
|
-
: actionable(`Task "${request.task}" not found.`, 'Next: agents browser status'),
|
|
813
|
-
task: result.ok ? request.task : undefined,
|
|
814
|
-
};
|
|
815
|
-
}
|
|
816
|
-
case 'stop': {
|
|
817
|
-
if (request.task) {
|
|
818
|
-
const result = await this.service.stop(request.task);
|
|
819
|
-
return {
|
|
820
|
-
ok: result.ok,
|
|
821
|
-
error: result.ok
|
|
822
|
-
? undefined
|
|
823
|
-
: actionable(`Task "${request.task}" not found.`, 'Next: agents browser status'),
|
|
824
|
-
task: result.ok ? request.task : undefined,
|
|
825
|
-
};
|
|
826
|
-
}
|
|
827
|
-
if (request.profile) {
|
|
828
|
-
try {
|
|
829
|
-
await this.service.stopProfile(request.profile, {
|
|
830
|
-
fleetRemote: request.fleetRemote,
|
|
831
|
-
actor: request.actor,
|
|
832
|
-
});
|
|
833
|
-
}
|
|
834
|
-
catch (err) {
|
|
835
|
-
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
836
|
-
}
|
|
837
|
-
return { ok: true };
|
|
838
|
-
}
|
|
839
|
-
return {
|
|
840
|
-
ok: false,
|
|
841
|
-
error: actionable('Task or profile required.', 'Next: agents browser status | agents browser stop --profile <name>'),
|
|
842
|
-
};
|
|
843
|
-
}
|
|
844
|
-
case 'status': {
|
|
845
|
-
const profiles = await this.service.status(request.profile);
|
|
846
|
-
return { ok: true, profiles };
|
|
847
|
-
}
|
|
848
|
-
case 'history': {
|
|
849
|
-
const history = await this.service.getHistory(request.limit ?? 10);
|
|
850
|
-
return { ok: true, history };
|
|
851
|
-
}
|
|
852
|
-
case 'navigate': {
|
|
853
|
-
if (!request.url) {
|
|
854
|
-
return {
|
|
855
|
-
ok: false,
|
|
856
|
-
error: actionable('URL required.', 'Next: agents browser navigate <url>'),
|
|
857
|
-
};
|
|
858
|
-
}
|
|
859
|
-
if (!request.task) {
|
|
860
|
-
return {
|
|
861
|
-
ok: false,
|
|
862
|
-
error: actionable('No browser task for this session.', 'Next: agents browser navigate <url> | agents browser start'),
|
|
863
|
-
};
|
|
864
|
-
}
|
|
865
|
-
// The implicit create already opened this URL on this tab. Report the
|
|
866
|
-
// ACTUAL first-open result (adopt → created:false, fresh → created:true)
|
|
867
|
-
// instead of re-navigating it or assuming a create (PHNX-2399).
|
|
868
|
-
const firstOpen = request.firstOpen;
|
|
869
|
-
const picked = request.picked;
|
|
870
|
-
if (firstOpen) {
|
|
871
|
-
return {
|
|
872
|
-
ok: true,
|
|
873
|
-
tabId: firstOpen.tabId,
|
|
874
|
-
task: request.task,
|
|
875
|
-
created: firstOpen.created,
|
|
876
|
-
refreshed: firstOpen.refreshed,
|
|
877
|
-
message: [picked, firstOpen.message].filter(Boolean).join(' · ') || undefined,
|
|
878
|
-
};
|
|
879
|
-
}
|
|
880
|
-
const result = await this.service.navigate(request.task, request.url, request.profile);
|
|
881
|
-
return {
|
|
882
|
-
ok: true,
|
|
883
|
-
tabId: result.tabId,
|
|
884
|
-
task: request.task,
|
|
885
|
-
created: result.created,
|
|
886
|
-
refreshed: result.refreshed,
|
|
887
|
-
// A picked-device hint and a reopen note are distinct signals; combine
|
|
888
|
-
// them so a first navigate on a remote-picked device still surfaces the
|
|
889
|
-
// pick, and a plain reopen surfaces the refresh note.
|
|
890
|
-
message: [picked, result.message].filter(Boolean).join(' · ') || undefined,
|
|
891
|
-
};
|
|
892
|
-
}
|
|
893
|
-
case 'tab-add': {
|
|
894
|
-
if (!request.task || !request.url) {
|
|
895
|
-
return { ok: false, error: 'Task and URL required' };
|
|
896
|
-
}
|
|
897
|
-
const tabAddPicked = request.picked;
|
|
898
|
-
const firstOpen = request.firstOpen;
|
|
899
|
-
if (firstOpen) {
|
|
900
|
-
return {
|
|
901
|
-
ok: true,
|
|
902
|
-
tabId: firstOpen.tabId,
|
|
903
|
-
task: request.task,
|
|
904
|
-
created: firstOpen.created,
|
|
905
|
-
refreshed: firstOpen.refreshed,
|
|
906
|
-
message: [tabAddPicked, firstOpen.message].filter(Boolean).join(' · ') || undefined,
|
|
907
|
-
};
|
|
908
|
-
}
|
|
909
|
-
const result = await this.service.tabAdd(request.task, request.url, request.profile);
|
|
910
|
-
return {
|
|
911
|
-
ok: true,
|
|
912
|
-
tabId: result.tabId,
|
|
913
|
-
task: request.task,
|
|
914
|
-
created: result.created,
|
|
915
|
-
refreshed: result.refreshed,
|
|
916
|
-
// Keep the picked-device hint AND any reopen note (same shape as navigate).
|
|
917
|
-
message: [tabAddPicked, result.message].filter(Boolean).join(' · ') || undefined,
|
|
918
|
-
};
|
|
919
|
-
}
|
|
920
|
-
case 'tab-focus': {
|
|
921
|
-
if (!request.task || !request.tabId) {
|
|
922
|
-
return { ok: false, error: 'Task and tabId required' };
|
|
923
|
-
}
|
|
924
|
-
const result = await this.service.tabFocus(request.task, request.tabId);
|
|
925
|
-
return { ok: true, tabId: result.tabId };
|
|
926
|
-
}
|
|
927
|
-
case 'tab-close': {
|
|
928
|
-
if (!request.task) {
|
|
929
|
-
return { ok: false, error: 'Task required' };
|
|
930
|
-
}
|
|
931
|
-
await this.service.tabClose(request.task, request.tabId);
|
|
932
|
-
return { ok: true };
|
|
933
|
-
}
|
|
934
|
-
case 'tab-list': {
|
|
935
|
-
if (!request.task) {
|
|
936
|
-
return { ok: false, error: 'Task required' };
|
|
937
|
-
}
|
|
938
|
-
if (request.all) {
|
|
939
|
-
const tabs = await this.service.profileTabs(request.task);
|
|
940
|
-
return { ok: true, tabs: tabs.map(t => ({ id: t.id, url: t.url, title: t.title, current: t.current, task: t.task })) };
|
|
941
|
-
}
|
|
942
|
-
const tabs = await this.service.tabList(request.task);
|
|
943
|
-
// Carry `current` through — it is the one field a caller needs to show
|
|
944
|
-
// which tab URL-less verbs act on, and dropping it here left `agents
|
|
945
|
-
// browser tabs` unable to mark the selection (PHNX-2399).
|
|
946
|
-
return { ok: true, tabs: tabs.map(t => ({ id: t.id, url: t.url, title: t.title, current: t.current, task: request.task })) };
|
|
947
|
-
}
|
|
948
|
-
case 'evaluate': {
|
|
949
|
-
if (!request.task || !request.expr) {
|
|
950
|
-
return { ok: false, error: 'Task and expression required' };
|
|
951
|
-
}
|
|
952
|
-
const result = await this.service.evaluate(request.task, request.tabId, request.expr);
|
|
953
|
-
return { ok: true, result };
|
|
954
|
-
}
|
|
955
|
-
case 'record-start': {
|
|
956
|
-
if (!request.task)
|
|
957
|
-
return { ok: false, error: 'Task required' };
|
|
958
|
-
try {
|
|
959
|
-
const r = await this.service.recordStart(request.task, request.tabId, {
|
|
960
|
-
fps: request.fps,
|
|
961
|
-
duration: request.duration,
|
|
962
|
-
maxMb: request.maxMb,
|
|
963
|
-
});
|
|
964
|
-
return { ok: true, path: r.path, fps: r.fps, durationCapSec: r.durationCapSec, maxMb: r.maxMb };
|
|
965
|
-
}
|
|
966
|
-
catch (err) {
|
|
967
|
-
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
968
|
-
}
|
|
969
|
-
}
|
|
970
|
-
case 'record-stop': {
|
|
971
|
-
if (!request.task)
|
|
972
|
-
return { ok: false, error: 'Task required' };
|
|
973
|
-
try {
|
|
974
|
-
const r = await this.service.recordStop(request.task);
|
|
975
|
-
return { ok: true, path: r.path, bytes: r.bytes, durationMs: r.durationMs, stopReason: r.reason };
|
|
976
|
-
}
|
|
977
|
-
catch (err) {
|
|
978
|
-
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
979
|
-
}
|
|
980
|
-
}
|
|
981
|
-
case 'screenshot': {
|
|
982
|
-
if (!request.task) {
|
|
983
|
-
return {
|
|
984
|
-
ok: false,
|
|
985
|
-
error: actionable('No browser task for this session.', 'Next: agents browser navigate <url> | agents browser start'),
|
|
986
|
-
};
|
|
987
|
-
}
|
|
988
|
-
const shot = await this.service.screenshot(request.task, request.tabId, request.path, request.quality);
|
|
989
|
-
return {
|
|
990
|
-
ok: true,
|
|
991
|
-
path: shot.path,
|
|
992
|
-
bytes: shot.bytes,
|
|
993
|
-
width: shot.width,
|
|
994
|
-
height: shot.height,
|
|
995
|
-
task: request.task,
|
|
996
|
-
};
|
|
997
|
-
}
|
|
998
|
-
case 'pdf': {
|
|
999
|
-
if (!request.task) {
|
|
1000
|
-
return { ok: false, error: 'Task required' };
|
|
1001
|
-
}
|
|
1002
|
-
const doc = await this.service.printToPdf(request.task, request.tabId, request.path);
|
|
1003
|
-
return { ok: true, path: doc.path, bytes: doc.bytes };
|
|
1004
|
-
}
|
|
1005
|
-
case 'refs': {
|
|
1006
|
-
if (!request.task) {
|
|
1007
|
-
return { ok: false, error: 'Task required' };
|
|
1008
|
-
}
|
|
1009
|
-
const { refs, nodeMap } = await this.service.refs(request.task, request.tabId, {
|
|
1010
|
-
interactive: request.interactive ?? true,
|
|
1011
|
-
limit: request.limit ?? 500,
|
|
1012
|
-
});
|
|
1013
|
-
const nodes = Array.from(nodeMap.values()).map(n => {
|
|
1014
|
-
const entry = { ref: n.ref, role: n.role, name: n.name, attrs: n.attrs };
|
|
1015
|
-
if (n.editor !== undefined)
|
|
1016
|
-
entry.editor = n.editor;
|
|
1017
|
-
return entry;
|
|
1018
|
-
});
|
|
1019
|
-
return { ok: true, refs, nodes };
|
|
1020
|
-
}
|
|
1021
|
-
case 'click': {
|
|
1022
|
-
if (!request.task) {
|
|
1023
|
-
return { ok: false, error: 'Task required' };
|
|
1024
|
-
}
|
|
1025
|
-
// Coordinate click (`--at X,Y`) bypasses ref resolution entirely.
|
|
1026
|
-
if (request.atX !== undefined && request.atY !== undefined) {
|
|
1027
|
-
await this.service.clickAt(request.task, request.atX, request.atY, request.tabId);
|
|
1028
|
-
return { ok: true };
|
|
1029
|
-
}
|
|
1030
|
-
if (request.ref === undefined) {
|
|
1031
|
-
return { ok: false, error: 'Task and ref (or --at X,Y) required' };
|
|
1032
|
-
}
|
|
1033
|
-
const { healed } = await this.service.click(request.task, request.ref, request.tabId);
|
|
1034
|
-
if (healed) {
|
|
1035
|
-
return {
|
|
1036
|
-
ok: true,
|
|
1037
|
-
message: `self-healed ref ${healed.from} -> ${healed.to} (${healed.role} "${healed.name}")`,
|
|
1038
|
-
};
|
|
1039
|
-
}
|
|
1040
|
-
return { ok: true };
|
|
1041
|
-
}
|
|
1042
|
-
case 'type': {
|
|
1043
|
-
if (!request.task || request.ref === undefined || !request.text) {
|
|
1044
|
-
return { ok: false, error: 'Task, ref, and text required' };
|
|
1045
|
-
}
|
|
1046
|
-
await this.service.type(request.task, request.ref, request.text, request.tabId, request.clear);
|
|
1047
|
-
return { ok: true };
|
|
1048
|
-
}
|
|
1049
|
-
case 'press': {
|
|
1050
|
-
if (!request.task || !request.key) {
|
|
1051
|
-
return { ok: false, error: 'Task and key required' };
|
|
1052
|
-
}
|
|
1053
|
-
await this.service.press(request.task, request.key, request.tabId);
|
|
1054
|
-
return { ok: true };
|
|
1055
|
-
}
|
|
1056
|
-
case 'hover': {
|
|
1057
|
-
if (!request.task || request.ref === undefined) {
|
|
1058
|
-
return { ok: false, error: 'Task and ref required' };
|
|
1059
|
-
}
|
|
1060
|
-
await this.service.hover(request.task, request.ref, request.tabId);
|
|
1061
|
-
return { ok: true };
|
|
1062
|
-
}
|
|
1063
|
-
case 'scroll': {
|
|
1064
|
-
if (!request.task) {
|
|
1065
|
-
return { ok: false, error: 'Task required' };
|
|
1066
|
-
}
|
|
1067
|
-
await this.service.scroll(request.task, request.scrollX ?? 0, request.scrollY ?? 0, request.scrollAtX, request.scrollAtY, request.tabId);
|
|
1068
|
-
return { ok: true };
|
|
1069
|
-
}
|
|
1070
|
-
case 'set-viewport': {
|
|
1071
|
-
if (!request.task || !request.width || !request.height) {
|
|
1072
|
-
return { ok: false, error: 'Task, width, and height required' };
|
|
1073
|
-
}
|
|
1074
|
-
await this.service.setViewport(request.task, request.width, request.height, {
|
|
1075
|
-
mobile: request.mobile,
|
|
1076
|
-
deviceScaleFactor: request.deviceScaleFactor,
|
|
1077
|
-
tabHint: request.tabId,
|
|
1078
|
-
});
|
|
1079
|
-
return { ok: true };
|
|
1080
|
-
}
|
|
1081
|
-
case 'set-device': {
|
|
1082
|
-
if (!request.task || !request.deviceName) {
|
|
1083
|
-
return { ok: false, error: 'Task and device name required' };
|
|
1084
|
-
}
|
|
1085
|
-
await this.service.setDevice(request.task, request.deviceName, request.tabId);
|
|
1086
|
-
return { ok: true };
|
|
1087
|
-
}
|
|
1088
|
-
case 'console': {
|
|
1089
|
-
if (!request.task) {
|
|
1090
|
-
return { ok: false, error: 'Task required' };
|
|
1091
|
-
}
|
|
1092
|
-
const logs = await this.service.getConsoleLogs(request.task, {
|
|
1093
|
-
level: request.level,
|
|
1094
|
-
clear: request.clear,
|
|
1095
|
-
tabHint: request.tabId,
|
|
1096
|
-
});
|
|
1097
|
-
return { ok: true, logs };
|
|
1098
|
-
}
|
|
1099
|
-
case 'errors': {
|
|
1100
|
-
if (!request.task) {
|
|
1101
|
-
return { ok: false, error: 'Task required' };
|
|
1102
|
-
}
|
|
1103
|
-
const errors = await this.service.getErrors(request.task, {
|
|
1104
|
-
clear: request.clear,
|
|
1105
|
-
tabHint: request.tabId,
|
|
1106
|
-
});
|
|
1107
|
-
return { ok: true, errors };
|
|
1108
|
-
}
|
|
1109
|
-
case 'requests': {
|
|
1110
|
-
if (!request.task) {
|
|
1111
|
-
return { ok: false, error: 'Task required' };
|
|
1112
|
-
}
|
|
1113
|
-
const requests = await this.service.getNetworkRequests(request.task, {
|
|
1114
|
-
filter: request.filter,
|
|
1115
|
-
clear: request.clear,
|
|
1116
|
-
tabHint: request.tabId,
|
|
1117
|
-
});
|
|
1118
|
-
return { ok: true, requests };
|
|
1119
|
-
}
|
|
1120
|
-
case 'response-body': {
|
|
1121
|
-
if (!request.task || !request.urlPattern) {
|
|
1122
|
-
return { ok: false, error: 'Task and URL pattern required' };
|
|
1123
|
-
}
|
|
1124
|
-
const body = await this.service.getResponseBody(request.task, request.urlPattern, {
|
|
1125
|
-
timeout: request.timeout,
|
|
1126
|
-
maxChars: request.maxChars,
|
|
1127
|
-
tabHint: request.tabId,
|
|
1128
|
-
});
|
|
1129
|
-
return { ok: true, body };
|
|
1130
|
-
}
|
|
1131
|
-
case 'wait': {
|
|
1132
|
-
if (!request.task || !request.waitType || request.waitValue === undefined) {
|
|
1133
|
-
return { ok: false, error: 'Task, wait type, and wait value required' };
|
|
1134
|
-
}
|
|
1135
|
-
await this.service.wait(request.task, request.waitType, request.waitValue, {
|
|
1136
|
-
timeout: request.timeout,
|
|
1137
|
-
tabHint: request.tabId,
|
|
1138
|
-
});
|
|
1139
|
-
return { ok: true };
|
|
1140
|
-
}
|
|
1141
|
-
case 'set-download-path': {
|
|
1142
|
-
if (!request.task) {
|
|
1143
|
-
return { ok: false, error: 'Task required' };
|
|
1144
|
-
}
|
|
1145
|
-
// downloadPath optional: omitted → service defaults to the profile's downloads dir.
|
|
1146
|
-
const resolved = await this.service.setDownloadPath(request.task, request.downloadPath, request.tabId);
|
|
1147
|
-
return { ok: true, downloadPath: resolved };
|
|
1148
|
-
}
|
|
1149
|
-
case 'wait-download': {
|
|
1150
|
-
if (!request.task) {
|
|
1151
|
-
return { ok: false, error: 'Task required' };
|
|
1152
|
-
}
|
|
1153
|
-
const downloadPath = await this.service.waitForDownload(request.task, request.timeout);
|
|
1154
|
-
return { ok: true, downloadPath };
|
|
1155
|
-
}
|
|
1156
|
-
case 'getAppLogs': {
|
|
1157
|
-
if (!request.task) {
|
|
1158
|
-
return { ok: false, error: 'Task required' };
|
|
1159
|
-
}
|
|
1160
|
-
const appLogs = await this.service.getAppLogs(request.task, {
|
|
1161
|
-
lines: request.lines,
|
|
1162
|
-
level: request.appLevel,
|
|
1163
|
-
filter: request.filter,
|
|
1164
|
-
message: request.message,
|
|
1165
|
-
source: request.source,
|
|
1166
|
-
since: request.since,
|
|
1167
|
-
until: request.until,
|
|
1168
|
-
});
|
|
1169
|
-
return { ok: true, appLogs };
|
|
1170
|
-
}
|
|
1171
|
-
case 'upload': {
|
|
1172
|
-
if (!request.task || !request.files || request.files.length === 0) {
|
|
1173
|
-
return { ok: false, error: 'Task and at least one file required' };
|
|
1174
|
-
}
|
|
1175
|
-
const result = await this.service.upload(request.task, request.files, {
|
|
1176
|
-
ref: request.ref,
|
|
1177
|
-
trigger: request.trigger,
|
|
1178
|
-
mode: request.uploadMode,
|
|
1179
|
-
tabHint: request.tabId,
|
|
1180
|
-
timeout: request.timeout,
|
|
1181
|
-
});
|
|
1182
|
-
return { ok: true, uploadMode: result.mode };
|
|
1183
|
-
}
|
|
1184
|
-
default:
|
|
1185
|
-
return { ok: false, error: `Unknown action: ${request.action}` };
|
|
1186
|
-
}
|
|
1187
|
-
}
|
|
1188
|
-
}
|
|
1189
|
-
let versionReconciledThisProcess = false;
|
|
1190
|
-
/**
|
|
1191
|
-
* Decide whether a running daemon is stale enough to name an operator refresh.
|
|
1192
|
-
*
|
|
1193
|
-
* FORWARD ONLY: recommend a refresh when the daemon is *older* than this CLI.
|
|
1194
|
-
* An older CLI rides a newer daemon without noise. A client never performs the
|
|
1195
|
-
* refresh itself — two installs sharing one daemon dir (keyed off $HOME) must
|
|
1196
|
-
* not flap the daemon or interrupt one another's hosted services.
|
|
1197
|
-
*
|
|
1198
|
-
* `undefined`/`'unknown'` means the daemon is too old to answer the `version`
|
|
1199
|
-
* action reliably — don't recommend an action on that ambiguous signal.
|
|
1200
|
-
*
|
|
1201
|
-
* When numeric compare cannot order two distinct strings (e.g. two
|
|
1202
|
-
* `0.0.0-dev.*` builds), surface the mismatch without deciding either client
|
|
1203
|
-
* owns the shared process.
|
|
1204
|
-
*/
|
|
1205
|
-
export function shouldRecommendDaemonRefresh(daemonVersion, clientVersion) {
|
|
1206
|
-
if (!daemonVersion || daemonVersion === 'unknown')
|
|
1207
|
-
return false;
|
|
1208
|
-
if (daemonVersion === clientVersion)
|
|
1209
|
-
return false;
|
|
1210
|
-
const cmp = compareVersions(daemonVersion, clientVersion);
|
|
1211
|
-
if (cmp < 0)
|
|
1212
|
-
return true; // daemon older than client → upgrade forward
|
|
1213
|
-
if (cmp > 0)
|
|
1214
|
-
return false; // daemon newer → older CLI rides it
|
|
1215
|
-
// cmp === 0 but strings differ (unorderable tails like 0.0.0-dev.abc)
|
|
1216
|
-
return true;
|
|
1217
|
-
}
|
|
1218
|
-
/**
|
|
1219
|
-
* Reconcile the running daemon's version with ours without evicting it. If the
|
|
1220
|
-
* daemon is serving stale code, ask the daemon to run its OWN self-update path
|
|
1221
|
-
* (`request-self-update`, same fail-closed check/install/verify/exit
|
|
1222
|
-
* SelfUpdateService's periodic tick runs, self-update-service.ts) rather than
|
|
1223
|
-
* telling a human to run `agents daemon restart` by hand. A browser client
|
|
1224
|
-
* still owns neither the shared supervisor nor its sibling services, so
|
|
1225
|
-
* version skew can never be permission for THIS client to stop or restart
|
|
1226
|
-
* that process directly (PHNX-3605 holds) — it only asks the daemon to run a
|
|
1227
|
-
* path the daemon already runs on its own schedule, which only exits once the
|
|
1228
|
-
* daemon itself has verified the new version is good. If self-update declines
|
|
1229
|
-
* (dev build, shadowed install, already current, or a real failure) this
|
|
1230
|
-
* degrades to the same "nothing changed" outcome PHNX-3605 shipped.
|
|
1231
|
-
*/
|
|
1232
|
-
export async function reconcileDaemonVersion() {
|
|
1233
|
-
if (versionReconciledThisProcess)
|
|
1234
|
-
return;
|
|
1235
|
-
versionReconciledThisProcess = true;
|
|
1236
|
-
let daemon;
|
|
1237
|
-
try {
|
|
1238
|
-
const resp = await sendRawIPCRequest({ action: 'version' }, { autoStartDaemon: false });
|
|
1239
|
-
daemon = resp.version;
|
|
1240
|
-
}
|
|
1241
|
-
catch {
|
|
1242
|
-
// Daemon unreachable or too old to speak 'version' — leave it alone.
|
|
1243
|
-
return;
|
|
1244
|
-
}
|
|
1245
|
-
const client = getCliVersion();
|
|
1246
|
-
if (!shouldRecommendDaemonRefresh(daemon, client))
|
|
1247
|
-
return;
|
|
1248
|
-
process.stderr.write(`\nBrowser service is running on shared daemon ${daemon}, while this CLI is ${client}. `
|
|
1249
|
-
+ 'Asking the daemon to self-update in the background (verify-then-exit; the OS supervisor relaunches it onto the new code)...\n');
|
|
1250
|
-
try {
|
|
1251
|
-
// SHORT timeout — the handler is decoupled from the install now (it kicks
|
|
1252
|
-
// the self-update off in the background and answers at once), so this only
|
|
1253
|
-
// waits for the daemon to CONFIRM it accepted the trigger, not for the
|
|
1254
|
-
// install to finish. It must stay short so the real browser verb that
|
|
1255
|
-
// triggered this reconcile proceeds promptly; a wedged/gone daemon degrades
|
|
1256
|
-
// to the same advisory PHNX-3605 shipped.
|
|
1257
|
-
const resp = await sendRawIPCRequest({ action: 'request-self-update' }, { autoStartDaemon: false, timeoutMs: DAEMON_SELF_UPDATE_TRIGGER_TIMEOUT_MS });
|
|
1258
|
-
if (resp.selfUpdateTriggered) {
|
|
1259
|
-
process.stderr.write('Daemon self-update requested — it verifies a newer version in the background and restarts if one is available. '
|
|
1260
|
-
+ 'Continuing without waiting.\n\n');
|
|
1261
|
-
}
|
|
1262
|
-
else {
|
|
1263
|
-
// Two shapes reach here: a CURRENT daemon that ran the instant decline
|
|
1264
|
-
// gate (dev build / shadowed install) and answered {selfUpdateTriggered:
|
|
1265
|
-
// false, reason}, and an OLDER daemon that predates the field entirely and
|
|
1266
|
-
// answered without it (no reason). Both degrade to the same non-evicting
|
|
1267
|
-
// advisory PHNX-3605 shipped; only the lead sentence differs.
|
|
1268
|
-
const declineDetail = resp.reason
|
|
1269
|
-
? `Daemon self-update declined (${resp.reason}). `
|
|
1270
|
-
: 'Daemon did not accept the self-update request. ';
|
|
1271
|
-
process.stderr.write(declineDetail
|
|
1272
|
-
+ 'Continuing without evicting the daemon or its other services. '
|
|
1273
|
-
+ 'To load current daemon code when it is safe to interrupt every hosted service: agents daemon restart\n\n');
|
|
1274
|
-
}
|
|
1275
|
-
}
|
|
1276
|
-
catch (err) {
|
|
1277
|
-
process.stderr.write(`Could not reach the daemon to request a self-update (${err instanceof Error ? err.message : String(err)}). `
|
|
1278
|
-
+ 'To load current daemon code when it is safe to interrupt every hosted service: agents daemon restart\n\n');
|
|
1279
|
-
}
|
|
1280
|
-
}
|
|
1281
|
-
/** Test-only: `reconcileDaemonVersion` runs at most once per real process — this clears that latch so a test file can drive more than one branch. */
|
|
1282
|
-
export function resetVersionReconciliationForTest() {
|
|
1283
|
-
versionReconciledThisProcess = false;
|
|
1284
|
-
}
|
|
1285
|
-
export async function sendIPCRequest(request, opts = {}) {
|
|
1286
|
-
// Stamp caller identity ONCE here so the 28+ call sites don't each have to.
|
|
1287
|
-
// The daemon cannot resolve the caller's actor/session itself.
|
|
1288
|
-
const stamped = stampCallerIdentity(request);
|
|
1289
|
-
return sendRawIPCRequest(stamped, opts);
|
|
1290
|
-
}
|
|
1291
|
-
/**
|
|
1292
|
-
* Fill actor / launchId / sessionId from the calling process when the request
|
|
1293
|
-
* left them blank. Explicit values on the request always win.
|
|
1294
|
-
*/
|
|
1295
|
-
export function stampCallerIdentity(request, env = process.env) {
|
|
1296
|
-
// The consent marker is stamped FIRST, above the identity early-return: a
|
|
1297
|
-
// request that already carries a full identity (every streamed request does)
|
|
1298
|
-
// would otherwise skip stamping entirely and reach the daemon unmarked.
|
|
1299
|
-
// OR-ed, never overwritten — a client may assert the marker but must not be
|
|
1300
|
-
// able to clear one this environment sets.
|
|
1301
|
-
const fleetRemote = isFleetRemoteInvocation(env) || request.fleetRemote === true;
|
|
1302
|
-
const marked = request.fleetRemote === fleetRemote ? request : { ...request, fleetRemote };
|
|
1303
|
-
if (marked.actor && marked.launchId && marked.sessionId)
|
|
1304
|
-
return marked;
|
|
1305
|
-
const id = resolveCallerIdentity();
|
|
1306
|
-
return {
|
|
1307
|
-
...marked,
|
|
1308
|
-
actor: marked.actor ?? id.actor,
|
|
1309
|
-
launchId: marked.launchId ?? id.launchId,
|
|
1310
|
-
sessionId: marked.sessionId ?? id.sessionId,
|
|
1311
|
-
};
|
|
1312
|
-
}
|
|
1313
|
-
/**
|
|
1314
|
-
* Open one connection that can serve many browser operations. This uses the
|
|
1315
|
-
* same daemon readiness and version-reconciliation path as sendIPCRequest;
|
|
1316
|
-
* only the client process and IPC socket lifetime differ.
|
|
1317
|
-
*/
|
|
1318
|
-
export async function connectBrowserIPC(opts = {}) {
|
|
1319
|
-
const autoStartDaemon = opts.autoStartDaemon ?? true;
|
|
1320
|
-
await prepareIPC('status', opts);
|
|
1321
|
-
return new Promise((resolve, reject) => {
|
|
1322
|
-
const socket = net.createConnection(getIpcEndpoint());
|
|
1323
|
-
const onError = (error) => {
|
|
1324
|
-
socket.destroy();
|
|
1325
|
-
if (!autoStartDaemon && (error.code === 'ENOENT' || error.code === 'ECONNREFUSED')) {
|
|
1326
|
-
reject(new BrowserServiceNotRunningError());
|
|
1327
|
-
return;
|
|
1328
|
-
}
|
|
1329
|
-
reject(new Error(`IPC error: ${error.message}`));
|
|
1330
|
-
};
|
|
1331
|
-
socket.once('error', onError);
|
|
1332
|
-
socket.once('connect', () => {
|
|
1333
|
-
socket.off('error', onError);
|
|
1334
|
-
resolve(new BrowserIPCConnection(socket));
|
|
1335
|
-
});
|
|
1336
|
-
});
|
|
1337
|
-
}
|
|
1338
|
-
/**
|
|
1339
|
-
* Exported (rather than kept module-private) so `ipc.test.ts` can drive the
|
|
1340
|
-
* `opts.timeoutMs` client-side deadline directly against a raw `net.Server`
|
|
1341
|
-
* test double that never responds — the hermetic way to prove the timeout
|
|
1342
|
-
* actually fires without needing a slow real IPC action to provoke it.
|
|
1343
|
-
*/
|
|
1344
|
-
export async function sendRawIPCRequest(request, opts = {}) {
|
|
1345
|
-
const endpoint = getIpcEndpoint();
|
|
1346
|
-
const autoStartDaemon = opts.autoStartDaemon ?? true;
|
|
1347
|
-
await prepareIPC(request.action, opts);
|
|
1348
|
-
return new Promise((resolve, reject) => {
|
|
1349
|
-
const socket = net.createConnection(endpoint);
|
|
1350
|
-
let buffer = '';
|
|
1351
|
-
let settled = false;
|
|
1352
|
-
let deadline;
|
|
1353
|
-
const settle = (fn) => {
|
|
1354
|
-
if (settled)
|
|
1355
|
-
return;
|
|
1356
|
-
settled = true;
|
|
1357
|
-
if (deadline)
|
|
1358
|
-
clearTimeout(deadline);
|
|
1359
|
-
fn();
|
|
1360
|
-
};
|
|
1361
|
-
if (opts.timeoutMs) {
|
|
1362
|
-
deadline = setTimeout(() => {
|
|
1363
|
-
settle(() => {
|
|
1364
|
-
socket.destroy();
|
|
1365
|
-
reject(new Error(`IPC request '${request.action}' timed out after ${opts.timeoutMs}ms`));
|
|
1366
|
-
});
|
|
1367
|
-
}, opts.timeoutMs);
|
|
1368
|
-
}
|
|
1369
|
-
socket.on('connect', () => {
|
|
1370
|
-
socket.write(JSON.stringify(request) + '\n');
|
|
1371
|
-
});
|
|
1372
|
-
socket.on('data', (data) => {
|
|
1373
|
-
buffer += data.toString();
|
|
1374
|
-
const idx = buffer.indexOf('\n');
|
|
1375
|
-
if (idx !== -1) {
|
|
1376
|
-
const response = JSON.parse(buffer.slice(0, idx));
|
|
1377
|
-
settle(() => {
|
|
1378
|
-
socket.end();
|
|
1379
|
-
resolve(response);
|
|
1380
|
-
});
|
|
1381
|
-
}
|
|
1382
|
-
});
|
|
1383
|
-
socket.on('error', (err) => {
|
|
1384
|
-
settle(() => {
|
|
1385
|
-
if (!autoStartDaemon && (err.code === 'ENOENT' || err.code === 'ECONNREFUSED')) {
|
|
1386
|
-
reject(new BrowserServiceNotRunningError());
|
|
1387
|
-
return;
|
|
1388
|
-
}
|
|
1389
|
-
reject(new Error(`IPC error: ${err.message}`));
|
|
1390
|
-
});
|
|
1391
|
-
});
|
|
1392
|
-
socket.on('close', () => {
|
|
1393
|
-
settle(() => {
|
|
1394
|
-
if (!buffer.includes('\n')) {
|
|
1395
|
-
reject(new Error('Connection closed before response'));
|
|
1396
|
-
}
|
|
1397
|
-
});
|
|
1398
|
-
});
|
|
1399
|
-
});
|
|
1400
|
-
}
|
|
1401
|
-
async function prepareIPC(action, opts) {
|
|
1402
|
-
const socketPath = getSocketPath();
|
|
1403
|
-
const autoStartDaemon = opts.autoStartDaemon ?? true;
|
|
1404
|
-
if (!(await isBrowserServiceReachable())) {
|
|
1405
|
-
if (!autoStartDaemon) {
|
|
1406
|
-
throw new BrowserServiceNotRunningError();
|
|
1407
|
-
}
|
|
1408
|
-
const serviceEnabled = isDaemonServiceEnabled('browser-ipc');
|
|
1409
|
-
if (!serviceEnabled)
|
|
1410
|
-
setDaemonServiceEnabled('browser-ipc', true);
|
|
1411
|
-
if (!IS_WINDOWS) {
|
|
1412
|
-
await fs.promises.mkdir(path.dirname(socketPath), { recursive: true, mode: 0o700 });
|
|
1413
|
-
await fs.promises.chmod(path.dirname(socketPath), 0o700);
|
|
1414
|
-
}
|
|
1415
|
-
if (isDaemonRunning()) {
|
|
1416
|
-
// A stopped service is enabled by the config transition. An enabled but
|
|
1417
|
-
// absent service is failed/parked, so request a supervised restart. Both
|
|
1418
|
-
// actions are daemon-owned and in-flight-gated by ServiceSupervisor.
|
|
1419
|
-
if (serviceEnabled)
|
|
1420
|
-
queueDaemonServiceRestart('browser-ipc');
|
|
1421
|
-
if (!signalDaemonReload()) {
|
|
1422
|
-
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)'));
|
|
1423
|
-
}
|
|
1424
|
-
}
|
|
1425
|
-
else {
|
|
1426
|
-
startDaemon();
|
|
1427
|
-
}
|
|
1428
|
-
if (!(await isBrowserServiceReachable())) {
|
|
1429
|
-
await waitForBrowserService(socketPath);
|
|
1430
|
-
}
|
|
1431
|
-
if (!(await isBrowserServiceReachable())) {
|
|
1432
|
-
throw new Error(actionable('Failed to start browser service.', `Log: ${getDaemonLogPath()}`, 'Next: agents doctor (checks for a second agents-cli install)'));
|
|
1433
|
-
}
|
|
1434
|
-
await new Promise((r) => setTimeout(r, 300));
|
|
1435
|
-
}
|
|
1436
|
-
// The socket accepts connections — but a unix-socket `connect` succeeds at the
|
|
1437
|
-
// kernel level even when the daemon's event loop is blocked and never services
|
|
1438
|
-
// the request (PHNX-3411). Require a real reply before treating the daemon as
|
|
1439
|
-
// usable, so a wedged daemon fails LOUD with an accurate, actionable error
|
|
1440
|
-
// instead of the confusing bare socket timeout / indefinite hang the browser
|
|
1441
|
-
// verb would otherwise hit (including inside reconcileDaemonVersion's own
|
|
1442
|
-
// version probe, which has no response timeout). Skipped for callers that opt
|
|
1443
|
-
// out of auto-start — they want the clean BrowserServiceNotRunningError instead.
|
|
1444
|
-
if (autoStartDaemon && !(await isBrowserServiceResponsive(getIpcEndpoint()))) {
|
|
1445
|
-
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)'));
|
|
1446
|
-
}
|
|
1447
|
-
// Before serving a real request, make sure the daemon isn't running stale
|
|
1448
|
-
// code. Skips the internal `version` probe (avoids recursion) and callers
|
|
1449
|
-
// that opt out of auto-start. No-ops once reconciled or when versions match.
|
|
1450
|
-
if (action !== 'version' && autoStartDaemon) {
|
|
1451
|
-
await reconcileDaemonVersion();
|
|
1452
|
-
}
|
|
1453
|
-
}
|