@phnx-labs/agents-cli 1.22.117 → 1.22.119
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 +301 -0
- package/dist/bootstrap.js +4 -11
- package/dist/commands/accounts.js +7 -4
- 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.js +53 -1
- package/dist/commands/projects.js +92 -7
- 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.js +9 -9
- 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/add.d.ts +3 -1
- package/dist/lib/accounts/add.js +24 -13
- 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 +7 -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-account-token.d.ts +7 -1
- package/dist/lib/claude-account-token.js +26 -4
- 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 +10 -13
- 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/github/pr-mergeable.d.ts +4 -2
- package/dist/lib/github/pr-mergeable.js +5 -3
- package/dist/lib/github/project-prs.d.ts +58 -3
- package/dist/lib/github/project-prs.js +205 -7
- package/dist/lib/harness/adapters/cursor.js +14 -4
- package/dist/lib/harness-auth-capabilities.d.ts +4 -3
- package/dist/lib/harness-auth-capabilities.js +1 -2
- 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/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.d.ts +1 -1
- package/dist/lib/installations/shims.js +32 -12
- package/dist/lib/installations/store.d.ts +9 -0
- package/dist/lib/installations/store.js +24 -0
- package/dist/lib/installations/update.js +21 -15
- package/dist/lib/installations/versions.js +15 -3
- 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/profiles.js +0 -2
- package/dist/lib/projects.d.ts +16 -0
- package/dist/lib/projects.js +31 -0
- 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/active.d.ts +17 -6
- package/dist/lib/session/active.js +105 -49
- 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/pid-registry.d.ts +6 -3
- package/dist/lib/session/pid-registry.js +18 -8
- package/dist/lib/session/remote/watch.d.ts +1 -1
- package/dist/lib/session/remote/watch.js +7 -3
- 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 +5 -10
- 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 +16 -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
|
@@ -1,4693 +0,0 @@
|
|
|
1
|
-
import * as fs from 'fs';
|
|
2
|
-
import * as os from 'os';
|
|
3
|
-
import * as path from 'path';
|
|
4
|
-
import { BrowserCdpConnectionError, CDPClient, discoverBrowserWsUrl, verifyBrowserIdentity, } from './cdp.js';
|
|
5
|
-
import { getProfile, getProfileRuntimeDir, getProfileDownloadsDir, getProfileSessionsDir, getBrowserRuntimeDir, listProfiles, extractConfiguredPort, resolveEndpoint, parseEndpointUrl, } from './profiles.js';
|
|
6
|
-
import { killChrome, getRunningChromeInfo, launchBrowser, allocatePort } from './chrome.js';
|
|
7
|
-
import { assertRemoteControlAllowedForRequest } from './remote-control.js';
|
|
8
|
-
import { connectLocal } from './drivers/local.js';
|
|
9
|
-
import { connectSSH, shellQuote } from './drivers/ssh.js';
|
|
10
|
-
import { adoptLegacyRuntimeIfLocal, isLegacyEndpointKey, resolveBrowserTarget, shouldForkProfile, } from './resolve-target.js';
|
|
11
|
-
import { adoptProfileRuntimeOwner, clearProfileRuntime, listProfileCacheDirs, readProfileRuntimeMeta, isProcessAlive } from './runtime-state.js';
|
|
12
|
-
import { resolveDomainSkill } from './domain-skills.js';
|
|
13
|
-
import { generateTaskId, generateShortId, asConnectionKey, parseConnectionKey, keyBelongsToProfile, } from './types.js';
|
|
14
|
-
import { reapAbandonedTasks, resolveLiveIdentities, taskOwnerIsGone, } from './hygiene.js';
|
|
15
|
-
import { taskMatchesCaller } from './caller-identity.js';
|
|
16
|
-
import { getRefs, resolveRefToCoords, describeRefs, healRef } from './refs.js';
|
|
17
|
-
import { clickAtCoords, hoverAtCoords, scrollAtCoords, typeText, pressKey, focusNode } from './input.js';
|
|
18
|
-
import { typeEditorText } from './editor.js';
|
|
19
|
-
import { arcClickExpression, arcFillExpression, arcRefsExpression, arcScrollExpression, parseArcRefsResult, } from './arc-dom.js';
|
|
20
|
-
import { detectUploadPattern, stageUploadFile, uploadToDropTarget, uploadToFileInput, uploadViaFileChooser, } from './upload.js';
|
|
21
|
-
import { emit } from '../feed/events.js';
|
|
22
|
-
import { resolveActor } from '../actor.js';
|
|
23
|
-
import { recordBrowserSession } from '../session/db.js';
|
|
24
|
-
import { sshExecAsync } from '../ssh-exec.js';
|
|
25
|
-
import { resolveFfmpeg } from './ffmpeg.js';
|
|
26
|
-
import { FirefoxCapabilityError, connectFirefox, bidiTopLevelContexts, bidiCreateTab, bidiNavigate, bidiReload, bidiCloseTab, bidiActivate, bidiEvaluate, bidiScreenshot, bidiClickAt, } from './drivers/firefox.js';
|
|
27
|
-
import { ArcNativeCapabilityError, executeJavaScript, navigateArcTab, closeArcTab, createArcTab, enumerateArcSpaces, isArcRunning, resolveArcTab, restoreArcSelection, selectWindowTab, selectArcTab, } from './drivers/arc.js';
|
|
28
|
-
/**
|
|
29
|
-
* Canonical form for comparing a requested URL against what CDP reports for a
|
|
30
|
-
* live target. `new URL('https://example.com').href` is `https://example.com/`,
|
|
31
|
-
* which is exactly what Chrome reports — comparing the raw strings would miss
|
|
32
|
-
* every bare-origin match. Unparseable input compares as itself.
|
|
33
|
-
*/
|
|
34
|
-
function canonicalTabUrl(raw) {
|
|
35
|
-
try {
|
|
36
|
-
return new URL(raw).href;
|
|
37
|
-
}
|
|
38
|
-
catch {
|
|
39
|
-
return raw;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
function isPathInside(candidate, dir) {
|
|
43
|
-
const rel = path.relative(dir, candidate);
|
|
44
|
-
return rel === '' || (!!rel && !rel.startsWith('..') && !path.isAbsolute(rel));
|
|
45
|
-
}
|
|
46
|
-
export function resolveScreenshotOutputPath(outputPath, automaticPath) {
|
|
47
|
-
if (!outputPath)
|
|
48
|
-
return automaticPath;
|
|
49
|
-
const runtimeDir = getBrowserRuntimeDir();
|
|
50
|
-
fs.mkdirSync(runtimeDir, { recursive: true });
|
|
51
|
-
const runtimeReal = fs.realpathSync(runtimeDir);
|
|
52
|
-
const requested = path.resolve(outputPath);
|
|
53
|
-
const parent = path.dirname(requested);
|
|
54
|
-
fs.mkdirSync(parent, { recursive: true });
|
|
55
|
-
const parentReal = fs.realpathSync(parent);
|
|
56
|
-
const resolved = path.join(parentReal, path.basename(requested));
|
|
57
|
-
if (!isPathInside(resolved, runtimeReal)) {
|
|
58
|
-
return automaticPath;
|
|
59
|
-
}
|
|
60
|
-
return resolved;
|
|
61
|
-
}
|
|
62
|
-
/**
|
|
63
|
-
* Read width/height from a JPEG buffer by walking SOF markers. Returns null
|
|
64
|
-
* if the buffer doesn't start with the JPEG SOI marker or no SOF segment is
|
|
65
|
-
* found. We use this on every screenshot so the CLI can surface the actual
|
|
66
|
-
* captured pixel dimensions (which differ from viewport size at non-1x DPR).
|
|
67
|
-
*/
|
|
68
|
-
function readPngDimensions(buf) {
|
|
69
|
-
// PNG signature (8 bytes) + IHDR chunk: length (4) + 'IHDR' (4) + width (4) + height (4)
|
|
70
|
-
if (buf.length < 24)
|
|
71
|
-
return null;
|
|
72
|
-
const sig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
73
|
-
for (let i = 0; i < 8; i++)
|
|
74
|
-
if (buf[i] !== sig[i])
|
|
75
|
-
return null;
|
|
76
|
-
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
|
77
|
-
}
|
|
78
|
-
function readJpegDimensions(buf) {
|
|
79
|
-
if (buf.length < 4 || buf[0] !== 0xff || buf[1] !== 0xd8)
|
|
80
|
-
return null;
|
|
81
|
-
let i = 2;
|
|
82
|
-
while (i + 9 < buf.length) {
|
|
83
|
-
if (buf[i] !== 0xff)
|
|
84
|
-
return null;
|
|
85
|
-
const marker = buf[i + 1];
|
|
86
|
-
// SOF0–SOFn carry dimensions, except DHT (0xC4), JPG (0xC8), DAC (0xCC).
|
|
87
|
-
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
|
88
|
-
return { height: buf.readUInt16BE(i + 5), width: buf.readUInt16BE(i + 7) };
|
|
89
|
-
}
|
|
90
|
-
i += 2 + buf.readUInt16BE(i + 2);
|
|
91
|
-
}
|
|
92
|
-
return null;
|
|
93
|
-
}
|
|
94
|
-
/**
|
|
95
|
-
* Parse a `targetFilter` string into its kind + value, or return `null`
|
|
96
|
-
* when the input is missing or malformed. Filter syntax:
|
|
97
|
-
* - `url:<substring>` — picks the first page target whose URL contains the substring
|
|
98
|
-
* - `title:<substring>` — picks the first page target whose title contains the substring
|
|
99
|
-
*
|
|
100
|
-
* The match is case-insensitive on both sides because Electron apps
|
|
101
|
-
* frequently lowercase or title-case their target metadata in unpredictable ways.
|
|
102
|
-
*/
|
|
103
|
-
export function parseTargetFilter(filter) {
|
|
104
|
-
if (!filter)
|
|
105
|
-
return null;
|
|
106
|
-
const idx = filter.indexOf(':');
|
|
107
|
-
if (idx <= 0)
|
|
108
|
-
return null;
|
|
109
|
-
const kind = filter.slice(0, idx).trim().toLowerCase();
|
|
110
|
-
// Strip whitespace around the value so `url: https://x` (with a copy-pasted
|
|
111
|
-
// space after the colon) doesn't silently fail to match — `.includes(' x')`
|
|
112
|
-
// never hits a URL because URLs don't contain spaces.
|
|
113
|
-
const value = filter.slice(idx + 1).trim();
|
|
114
|
-
if (kind !== 'url' && kind !== 'title')
|
|
115
|
-
return null;
|
|
116
|
-
if (!value)
|
|
117
|
-
return null;
|
|
118
|
-
return { kind, value };
|
|
119
|
-
}
|
|
120
|
-
/**
|
|
121
|
-
* URLs that the skip-invisible heuristic excludes when no explicit filter
|
|
122
|
-
* matches. These are page targets Electron apps ship for housekeeping;
|
|
123
|
-
* picking one means screenshots come back blank.
|
|
124
|
-
*/
|
|
125
|
-
const INVISIBLE_URL_PATTERNS = [
|
|
126
|
-
/^about:blank$/i,
|
|
127
|
-
/^file:\/\//i,
|
|
128
|
-
/\/_desktop-background-service(\?|$|\/)/i,
|
|
129
|
-
/\/_internal(\?|$|\/)/i,
|
|
130
|
-
/\/_background(\?|$|\/)/i,
|
|
131
|
-
];
|
|
132
|
-
function isLikelyInvisible(url) {
|
|
133
|
-
if (!url)
|
|
134
|
-
return true;
|
|
135
|
-
return INVISIBLE_URL_PATTERNS.some((re) => re.test(url));
|
|
136
|
-
}
|
|
137
|
-
/**
|
|
138
|
-
* Choose the CDP page target that represents the visible UI.
|
|
139
|
-
*
|
|
140
|
-
* Order:
|
|
141
|
-
* 1. If `filter` is set and parseable, narrow to page targets matching it
|
|
142
|
-
* (case-insensitive substring). Among matches, prefer one that is not in
|
|
143
|
-
* `INVISIBLE_URL_PATTERNS` — this is the tiebreaker that makes a coarse
|
|
144
|
-
* filter like `url:https://www.canva.com/` skip the background service
|
|
145
|
-
* (`https://www.canva.com/_desktop-background-service` *also* matches the
|
|
146
|
-
* substring). If every match is invisible, return the first match so the
|
|
147
|
-
* caller still gets something rather than silently falling through.
|
|
148
|
-
* An explicit filter that finds *no* match returns `undefined` — callers
|
|
149
|
-
* should surface this as an error rather than create an orphan window.
|
|
150
|
-
* 2. If `filter` is unset (or unparseable), apply the skip-invisible heuristic
|
|
151
|
-
* across all page targets.
|
|
152
|
-
* 3. As a last resort, return the first page target.
|
|
153
|
-
*/
|
|
154
|
-
export function pickWindowTarget(targets, filter) {
|
|
155
|
-
const pages = targets.filter((t) => t.type === 'page');
|
|
156
|
-
if (pages.length === 0)
|
|
157
|
-
return undefined;
|
|
158
|
-
const parsed = parseTargetFilter(filter);
|
|
159
|
-
if (parsed) {
|
|
160
|
-
const needle = parsed.value.toLowerCase();
|
|
161
|
-
const matches = pages.filter((t) => {
|
|
162
|
-
const hay = (parsed.kind === 'url' ? t.url : t.title) ?? '';
|
|
163
|
-
return hay.toLowerCase().includes(needle);
|
|
164
|
-
});
|
|
165
|
-
if (matches.length === 0)
|
|
166
|
-
return undefined;
|
|
167
|
-
const visible = matches.find((t) => !isLikelyInvisible(t.url));
|
|
168
|
-
return visible ?? matches[0];
|
|
169
|
-
}
|
|
170
|
-
const visible = pages.find((t) => !isLikelyInvisible(t.url));
|
|
171
|
-
if (visible)
|
|
172
|
-
return visible;
|
|
173
|
-
return pages[0];
|
|
174
|
-
}
|
|
175
|
-
/**
|
|
176
|
-
* Parse a `--since`/`--until` value. Accepts ISO-8601 absolute timestamps
|
|
177
|
-
* or relative offsets like `30s`, `5m`, `2h`, `1d`.
|
|
178
|
-
*/
|
|
179
|
-
export function parseSinceUntil(s) {
|
|
180
|
-
const ms = Date.parse(s);
|
|
181
|
-
if (!isNaN(ms))
|
|
182
|
-
return new Date(ms);
|
|
183
|
-
const m = s.match(/^(\d+)([smhd])$/);
|
|
184
|
-
if (!m)
|
|
185
|
-
throw new Error(`Invalid since/until: ${s}`);
|
|
186
|
-
const n = parseInt(m[1], 10);
|
|
187
|
-
const unitMs = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 };
|
|
188
|
-
return new Date(Date.now() - n * unitMs[m[2]]);
|
|
189
|
-
}
|
|
190
|
-
async function execSSH(host, cmd) {
|
|
191
|
-
const res = await sshExecAsync(host, cmd, { timeoutMs: 10_000 });
|
|
192
|
-
if (res.code !== 0) {
|
|
193
|
-
throw new Error(`ssh ${host} failed${res.stderr.trim() ? `: ${res.stderr.trim()}` : ''}`);
|
|
194
|
-
}
|
|
195
|
-
return res.stdout;
|
|
196
|
-
}
|
|
197
|
-
export function readNewestMatchingRemoteFileCommand(dir, prefix, tailLines) {
|
|
198
|
-
const glob = `${shellQuote(dir)}/${prefix}*.jsonl`;
|
|
199
|
-
return `latest=$(ls -1t ${glob} 2>/dev/null | head -1); if [ -n "$latest" ]; then tail -n ${tailLines} "$latest"; fi`;
|
|
200
|
-
}
|
|
201
|
-
export function readNewestMatchingFile(dir, prefix, tailLines) {
|
|
202
|
-
let entries;
|
|
203
|
-
try {
|
|
204
|
-
entries = fs.readdirSync(dir);
|
|
205
|
-
}
|
|
206
|
-
catch {
|
|
207
|
-
return '';
|
|
208
|
-
}
|
|
209
|
-
const candidates = entries
|
|
210
|
-
.filter((f) => f.startsWith(prefix) && f.endsWith('.jsonl'))
|
|
211
|
-
.map((f) => ({ f, mtime: fs.statSync(path.join(dir, f)).mtimeMs }))
|
|
212
|
-
.sort((a, b) => b.mtime - a.mtime);
|
|
213
|
-
if (candidates.length === 0)
|
|
214
|
-
return '';
|
|
215
|
-
const lines = fs
|
|
216
|
-
.readFileSync(path.join(dir, candidates[0].f), 'utf8')
|
|
217
|
-
.split('\n')
|
|
218
|
-
.filter(Boolean);
|
|
219
|
-
return lines.slice(-tailLines).join('\n');
|
|
220
|
-
}
|
|
221
|
-
function expandHome(p) {
|
|
222
|
-
if (p.startsWith('~/'))
|
|
223
|
-
return path.join(os.homedir(), p.slice(2));
|
|
224
|
-
if (p === '~')
|
|
225
|
-
return os.homedir();
|
|
226
|
-
return p;
|
|
227
|
-
}
|
|
228
|
-
/**
|
|
229
|
-
* Probe a cached connection before reuse. A WebSocket can quietly transition
|
|
230
|
-
* to CLOSED without anyone noticing — most commonly when the user kills the
|
|
231
|
-
* browser process by hand. `Browser.getVersion` is the lightest CDP call we
|
|
232
|
-
* can make; if it doesn't round-trip within 1s the connection is dead.
|
|
233
|
-
*/
|
|
234
|
-
async function isConnHealthy(conn, timeoutMs = 1000) {
|
|
235
|
-
// Native Arc connections: healthy if Arc is still running (no CDP socket to probe).
|
|
236
|
-
if (conn.backend === 'arc-native') {
|
|
237
|
-
return isArcRunning();
|
|
238
|
-
}
|
|
239
|
-
// Firefox BiDi connections: healthy while the WebSocket is open (a killed
|
|
240
|
-
// Firefox closes it). No lightweight round-trip verb is needed.
|
|
241
|
-
if (conn.backend === 'bidi') {
|
|
242
|
-
return conn.bidi.isOpen;
|
|
243
|
-
}
|
|
244
|
-
if (!conn.cdp.isOpen)
|
|
245
|
-
return false;
|
|
246
|
-
try {
|
|
247
|
-
await Promise.race([
|
|
248
|
-
conn.cdp.send('Browser.getVersion'),
|
|
249
|
-
new Promise((_, reject) => setTimeout(() => reject(new Error('healthcheck timeout')), timeoutMs)),
|
|
250
|
-
]);
|
|
251
|
-
return true;
|
|
252
|
-
}
|
|
253
|
-
catch {
|
|
254
|
-
return false;
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
/**
|
|
258
|
-
* Narrow `conn` to the CDP backend, or throw the backend's capability error.
|
|
259
|
-
* Because the union now carries `arc-native` AND `bidi`, every action that
|
|
260
|
-
* reaches `conn.cdp` without a preceding backend branch fails to type-check —
|
|
261
|
-
* the compiler is the checklist that no verb silently calls CDP on a
|
|
262
|
-
* non-CDP connection.
|
|
263
|
-
*/
|
|
264
|
-
function requireCdp(conn, capability) {
|
|
265
|
-
if (conn.backend === 'arc-native')
|
|
266
|
-
throw new ArcNativeCapabilityError(capability);
|
|
267
|
-
if (conn.backend === 'bidi')
|
|
268
|
-
throw new FirefoxCapabilityError(capability);
|
|
269
|
-
}
|
|
270
|
-
/** Join error lines so callers get a next command, not a dead-end message. */
|
|
271
|
-
export function actionable(...lines) {
|
|
272
|
-
return lines.filter((l) => l != null && l !== '').join('\n');
|
|
273
|
-
}
|
|
274
|
-
/**
|
|
275
|
-
* Arc DOES expose CDP page targets and honors `Page.navigate` on the ones the
|
|
276
|
-
* user already has open (measured live against Arc: 33 targets, PR #2786), so
|
|
277
|
-
* agents browser drives an attached Arc by reusing existing tabs. What it CANNOT
|
|
278
|
-
* survive is `Target.createTarget` — opening a brand-new tab crashes the user's
|
|
279
|
-
* Arc window (verified live, PR #2778). Tab creation is the one CDP-only op that
|
|
280
|
-
* fails clearly here (PHNX-2399): every tab-creating path throws this actionable
|
|
281
|
-
* error instead of crashing Arc, and steers the caller to reuse an existing
|
|
282
|
-
* tab/Space (via `--target-filter`) or to a Chromium-family browser for new tabs.
|
|
283
|
-
*/
|
|
284
|
-
export function arcNotDrivableError(profileName) {
|
|
285
|
-
const scope = profileName ? ` (profile "${profileName}")` : '';
|
|
286
|
-
return new Error(actionable(`Browser "arc"${scope} cannot open a NEW tab: Arc crashes when a tab is created`, 'over CDP (Target.createTarget). agents browser drives Arc by attaching to your', 'running window and reusing an EXISTING tab — bind the profile to the tab/Space you', 'want with --target-filter (url:<substring> or title:<substring>), or navigate to a', 'URL that is already open. To open brand-new tabs, use a Chromium-family browser:', ' agents browser profiles create <name> --browser comet'));
|
|
287
|
-
}
|
|
288
|
-
/** Derive a human label from an explicit title or a navigated URL. */
|
|
289
|
-
export function deriveTaskLabel(opts) {
|
|
290
|
-
if (opts.title?.trim())
|
|
291
|
-
return opts.title.trim();
|
|
292
|
-
if (opts.existing && opts.existing !== 'untitled')
|
|
293
|
-
return opts.existing;
|
|
294
|
-
if (opts.url) {
|
|
295
|
-
try {
|
|
296
|
-
const host = new URL(opts.url).hostname.replace(/^www\./, '');
|
|
297
|
-
if (host)
|
|
298
|
-
return host;
|
|
299
|
-
}
|
|
300
|
-
catch {
|
|
301
|
-
/* unparseable */
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
return opts.existing ?? 'untitled';
|
|
305
|
-
}
|
|
306
|
-
/**
|
|
307
|
-
* Resolve the identity stamped on a task at start: WHO (`owner`) and WHICH run
|
|
308
|
-
* (`launchId`). The forwarded values come from the caller's own CLI process and
|
|
309
|
-
* are authoritative — the browser daemon is shared and long-lived, so resolving
|
|
310
|
-
* the actor daemon-side (the RUSH-2020 bug) mis-attributes every task to the
|
|
311
|
-
* daemon's owner. `resolveLocalActor` is consulted ONLY when no actor was
|
|
312
|
-
* forwarded (a CLI that predates the field, mid-rollout) — never to override a
|
|
313
|
-
* forwarded one.
|
|
314
|
-
*/
|
|
315
|
-
export function resolveTaskIdentity(forwarded, resolveLocalActor) {
|
|
316
|
-
return {
|
|
317
|
-
owner: forwarded.actor ?? resolveLocalActor(),
|
|
318
|
-
launchId: forwarded.launchId,
|
|
319
|
-
sessionId: forwarded.sessionId,
|
|
320
|
-
};
|
|
321
|
-
}
|
|
322
|
-
/**
|
|
323
|
-
* The unique URL a native Arc tab is created at before it is navigated to its
|
|
324
|
-
* real target (PHNX-2399). Arc's `make new tab` accepts only http(s) URLs (it
|
|
325
|
-
* rejects `data:` and `about:` outright), so the marker is an https URL on the
|
|
326
|
-
* reserved `.invalid` TLD: DNS fails locally, no request leaves the machine,
|
|
327
|
-
* and the tab keeps the exact URL for the enumerate-and-match that follows.
|
|
328
|
-
*/
|
|
329
|
-
const ARC_CREATE_MARKER_PREFIX = 'https://agents-browser.invalid/';
|
|
330
|
-
export function arcCreateMarker() {
|
|
331
|
-
return `${ARC_CREATE_MARKER_PREFIX}${crypto.randomUUID()}`;
|
|
332
|
-
}
|
|
333
|
-
export function isArcCreateMarker(url) {
|
|
334
|
-
return url.startsWith(ARC_CREATE_MARKER_PREFIX);
|
|
335
|
-
}
|
|
336
|
-
/** Run `fn` once per pass and share its promise, rejections included. */
|
|
337
|
-
function onceAsync(fn) {
|
|
338
|
-
let pending;
|
|
339
|
-
return () => (pending ??= fn());
|
|
340
|
-
}
|
|
341
|
-
export class BrowserService {
|
|
342
|
-
static SOURCE_PREFIX = {
|
|
343
|
-
'rush-app': 'rush-app-',
|
|
344
|
-
'rush-cli': 'rush-cli-',
|
|
345
|
-
};
|
|
346
|
-
/**
|
|
347
|
-
* Live browsers, keyed by {@link ConnectionKey} — NOT by profile name. The
|
|
348
|
-
* branded key type is what makes `connections.get(someProfileName)` (the
|
|
349
|
-
* RUSH-2709 miss that made `status --profile <name>` return empty for a
|
|
350
|
-
* running `<name>@<device>`) fail to compile. Keys are `<profile>@<device>`
|
|
351
|
-
* (legacy leftover `<profile>@endpoint-N` dirs are still recognized).
|
|
352
|
-
*/
|
|
353
|
-
connections = new Map();
|
|
354
|
-
forkingProfiles = new Set();
|
|
355
|
-
// Per-task storage for console, errors, network, downloads
|
|
356
|
-
consoleLogs = new Map();
|
|
357
|
-
pageErrors = new Map();
|
|
358
|
-
networkRequests = new Map();
|
|
359
|
-
pendingDownloads = new Map();
|
|
360
|
-
enabledSessions = new Map(); // sessionId -> enabled domains
|
|
361
|
-
/** Profile -> when a `touchTask` last persisted tasks.json for it. */
|
|
362
|
-
lastTouchPersist = new Map();
|
|
363
|
-
/** Coalescing window for the `touchTask` write. See {@link touchTask}. */
|
|
364
|
-
static TOUCH_PERSIST_INTERVAL_MS = 60_000;
|
|
365
|
-
/**
|
|
366
|
-
* Serialization chains for the reopen/create critical section (PHNX-2399).
|
|
367
|
-
* Nothing else serializes IPC requests — `BrowserIPCServer` registers a
|
|
368
|
-
* per-connection `socket.on('data', async …)` that Node never awaits — so two
|
|
369
|
-
* concurrent `navigate`/`tab-add` requests, or two concurrent first-use
|
|
370
|
-
* creates for one caller, race the "is this URL already open?" lookup and each
|
|
371
|
-
* open a duplicate tab. Each key funnels its section through a promise chain so
|
|
372
|
-
* the second request sees the first's tab and refreshes it in place.
|
|
373
|
-
*
|
|
374
|
-
* Keys are DISJOINT by concern so a nested call can never wait on a lock its
|
|
375
|
-
* own outer frame holds: task-scoped work keys on `task:<key>:<name>`, first-
|
|
376
|
-
* use creation keys on `create:<callerId>`. `start()`'s Arc/Electron branch
|
|
377
|
-
* calls `navigate()` (a `task:` key) while the create path holds a `create:`
|
|
378
|
-
* key — different namespaces, so no re-entrant deadlock.
|
|
379
|
-
*/
|
|
380
|
-
critical = new Map();
|
|
381
|
-
/**
|
|
382
|
-
* Run `fn` mutually exclusive against every other call sharing `key`. The
|
|
383
|
-
* chain never rejects (each link swallows its own settlement) so one failed
|
|
384
|
-
* section cannot wedge the queue; `fn`'s own result/throw is returned to THIS
|
|
385
|
-
* caller unchanged. The map entry is dropped once its tail settles with no
|
|
386
|
-
* newer waiter, so idle tasks/callers don't accumulate.
|
|
387
|
-
*/
|
|
388
|
-
async runExclusive(key, fn) {
|
|
389
|
-
const prev = this.critical.get(key) ?? Promise.resolve();
|
|
390
|
-
const run = prev.then(fn, fn);
|
|
391
|
-
const tail = run.then(() => undefined, () => undefined);
|
|
392
|
-
this.critical.set(key, tail);
|
|
393
|
-
try {
|
|
394
|
-
return await run;
|
|
395
|
-
}
|
|
396
|
-
finally {
|
|
397
|
-
// Only the last link clears the entry; a newer waiter has replaced it.
|
|
398
|
-
if (this.critical.get(key) === tail)
|
|
399
|
-
this.critical.delete(key);
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
/**
|
|
403
|
-
* The task's OWN tabs (excluding borrowed ones — a borrowed tab predates the
|
|
404
|
-
* task, see {@link Task.borrowedTabs}) that are still live in the browser AND
|
|
405
|
-
* whose current document canonically equals `url`. Deliberately scoped to THIS
|
|
406
|
-
* task: it is neither {@link adoptTabShowing} (reclaims OTHER abandoned tasks'
|
|
407
|
-
* tabs) nor {@link pickReusableTargetWithoutCreate} (borrows a USER tab on Arc)
|
|
408
|
-
* — the same-task reopen contract only ever touches a tab the task itself owns.
|
|
409
|
-
* A registered id with no live target (a tab closed out from under us) is
|
|
410
|
-
* skipped, so a stale mapping never masquerades as a reopenable tab.
|
|
411
|
-
*/
|
|
412
|
-
findOwnedTabShowing(task, url, liveTargets) {
|
|
413
|
-
const borrowed = new Set(task.borrowedTabs ?? []);
|
|
414
|
-
const wanted = canonicalTabUrl(url);
|
|
415
|
-
const liveById = new Map(liveTargets.filter((t) => t.type === 'page').map((t) => [t.targetId, t]));
|
|
416
|
-
for (const [shortId, cdpId] of Object.entries(task.tabs)) {
|
|
417
|
-
if (borrowed.has(shortId))
|
|
418
|
-
continue;
|
|
419
|
-
const live = liveById.get(cdpId);
|
|
420
|
-
if (!live)
|
|
421
|
-
continue; // registered but gone from the browser — not reopenable
|
|
422
|
-
if (canonicalTabUrl(live.url) === wanted)
|
|
423
|
-
return { shortId, targetId: cdpId };
|
|
424
|
-
}
|
|
425
|
-
return undefined;
|
|
426
|
-
}
|
|
427
|
-
/**
|
|
428
|
-
* Same-task reopen (PHNX-2399): the requested URL is already live in a tab this
|
|
429
|
-
* task owns, so RELOAD that same tab and keep its ids rather than opening a
|
|
430
|
-
* duplicate. Returns the retained short id, or undefined when no owned tab
|
|
431
|
-
* shows the URL (the caller then falls through to its normal navigate/create
|
|
432
|
-
* semantics — that no-match split is intentional and preserved).
|
|
433
|
-
*
|
|
434
|
-
* A real `Page.reload` is issued; a failed reload THROWS rather than reporting
|
|
435
|
-
* a phantom refresh (a stale target was already excluded by
|
|
436
|
-
* {@link findOwnedTabShowing}). The tab is marked current WITHOUT
|
|
437
|
-
* `Target.activateTarget` — reopen must not steal window focus.
|
|
438
|
-
*/
|
|
439
|
-
async reopenOwnedTab(conn, task, url) {
|
|
440
|
-
const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
|
|
441
|
-
const match = this.findOwnedTabShowing(task, url, targetInfos);
|
|
442
|
-
if (!match)
|
|
443
|
-
return undefined;
|
|
444
|
-
const sessionId = await this.getSessionId(conn, match.targetId);
|
|
445
|
-
// Real reload of the SAME document. If it throws, the ids are left exactly as
|
|
446
|
-
// they were and the error surfaces — never a success that didn't happen.
|
|
447
|
-
await conn.cdp.send('Page.reload', {}, sessionId);
|
|
448
|
-
// Mark current without foreground activation (no Target.activateTarget).
|
|
449
|
-
task.currentTabId = match.shortId;
|
|
450
|
-
await this.saveTaskState(task.profile, conn.tasks);
|
|
451
|
-
return { tabId: match.shortId };
|
|
452
|
-
}
|
|
453
|
-
async start(profileName, opts = {}) {
|
|
454
|
-
// A NAMED start is serialized on the task NAME so two concurrent
|
|
455
|
-
// `start --task <name>` requests can't both pass the existence check and
|
|
456
|
-
// both create — the second observes the first's task and takes the retry (or
|
|
457
|
-
// conflict) path. The key is the bare name, NOT `profile:name`: the existence
|
|
458
|
-
// check (`findTaskByHandle`) is global by name, so two starts of the same
|
|
459
|
-
// name under DIFFERENT profiles must share one lock or they'd both pass.
|
|
460
|
-
// Disjoint `namedstart:` namespace, so the nested navigate()/create locks
|
|
461
|
-
// below never deadlock against it (PHNX-2399). An unnamed start needs no such
|
|
462
|
-
// guard: it always mints a fresh id.
|
|
463
|
-
if (opts.taskName) {
|
|
464
|
-
return this.runExclusive(`namedstart:${opts.taskName}`, () => this.startBody(profileName, opts));
|
|
465
|
-
}
|
|
466
|
-
return this.startBody(profileName, opts);
|
|
467
|
-
}
|
|
468
|
-
async startBody(profileName, opts) {
|
|
469
|
-
// Consent gate, before anything is resolved or launched. This is the
|
|
470
|
-
// authoritative one: gating only the `browser start` COMMAND left the ~18
|
|
471
|
-
// page verbs that create a browser implicitly (navigate, click, screenshot,
|
|
472
|
-
// …) able to open a browser on a machine whose owner never opted in.
|
|
473
|
-
assertRemoteControlAllowedForRequest(opts.fleetRemote, { actor: opts.actor });
|
|
474
|
-
// Registry decides WHERE. Three outcomes, no fourth: local if this
|
|
475
|
-
// device declares the name; tunnel to a declaring device otherwise;
|
|
476
|
-
// fail loud if nobody does. Never auto-create a local browser under a
|
|
477
|
-
// name that means a logged-in browser somewhere else.
|
|
478
|
-
const routed = resolveBrowserTarget(profileName, {
|
|
479
|
-
endpointName: opts.endpointName,
|
|
480
|
-
probe: opts.probe,
|
|
481
|
-
});
|
|
482
|
-
if (!routed.local && routed.commandDispatch) {
|
|
483
|
-
throw new Error(`Native Arc profile "${profileName}" is owned by ${routed.device}; dispatch the complete browser command there.`);
|
|
484
|
-
}
|
|
485
|
-
const composite = routed.key;
|
|
486
|
-
const effectiveProfile = routed.profile;
|
|
487
|
-
const taskId = generateTaskId();
|
|
488
|
-
let taskName;
|
|
489
|
-
if (opts.taskName) {
|
|
490
|
-
const existing = this.findTaskByHandle(opts.taskName);
|
|
491
|
-
if (existing) {
|
|
492
|
-
// Same-name start RETRY (PHNX-2399): reuse the existing task rather than
|
|
493
|
-
// erroring — but only when it is genuinely the same task. A different
|
|
494
|
-
// profile, endpoint, or caller identity is a real conflict and must NOT
|
|
495
|
-
// silently acquire someone else's task.
|
|
496
|
-
return this.retryNamedStart(existing, profileName, routed, opts);
|
|
497
|
-
}
|
|
498
|
-
taskName = opts.taskName;
|
|
499
|
-
}
|
|
500
|
-
else {
|
|
501
|
-
// Address by the short machine id — no word-salad. Status shows `label`.
|
|
502
|
-
taskName = taskId;
|
|
503
|
-
}
|
|
504
|
-
const taskLabel = deriveTaskLabel({ title: opts.title, url: opts.url });
|
|
505
|
-
const reused = await this.reuseProfileConnection(profileName, composite, routed.local);
|
|
506
|
-
let conn = reused?.conn;
|
|
507
|
-
let effectiveKey = reused?.key ?? composite;
|
|
508
|
-
// Fork is a local Electron chrome-data mint. Never fork a tunnelled
|
|
509
|
-
// connection — that would launch a logged-out browser on THIS box.
|
|
510
|
-
if (conn && routed.local && shouldForkProfile(routed.kind, conn)) {
|
|
511
|
-
if (this.forkingProfiles.has(composite)) {
|
|
512
|
-
while (this.forkingProfiles.has(composite)) {
|
|
513
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
514
|
-
}
|
|
515
|
-
const existingFork = this.findAvailableFork(composite);
|
|
516
|
-
if (existingFork) {
|
|
517
|
-
conn = existingFork.conn;
|
|
518
|
-
effectiveKey = existingFork.name;
|
|
519
|
-
}
|
|
520
|
-
else {
|
|
521
|
-
throw new Error(`Fork in progress but no available fork found for "${composite}"`);
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
else {
|
|
525
|
-
this.forkingProfiles.add(composite);
|
|
526
|
-
try {
|
|
527
|
-
const { forkName, connection } = await this.forkElectronProfile(effectiveProfile, composite);
|
|
528
|
-
conn = connection;
|
|
529
|
-
effectiveKey = forkName;
|
|
530
|
-
}
|
|
531
|
-
finally {
|
|
532
|
-
this.forkingProfiles.delete(composite);
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
}
|
|
536
|
-
else if (!conn) {
|
|
537
|
-
adoptLegacyRuntimeIfLocal(routed.local, profileName, composite, getBrowserRuntimeDir());
|
|
538
|
-
conn = await this.openConnection(effectiveProfile, routed.target, composite, profileName, { persistRemote: !routed.local });
|
|
539
|
-
effectiveKey = composite;
|
|
540
|
-
}
|
|
541
|
-
if (!conn) {
|
|
542
|
-
throw new Error(`Could not connect to profile "${profileName}"`);
|
|
543
|
-
}
|
|
544
|
-
if (!conn.profile)
|
|
545
|
-
conn.profile = profileName;
|
|
546
|
-
// Browsers launch with --no-startup-window (session-cookie persistence,
|
|
547
|
-
// see launchBrowser), so a bare `start` with no --url would otherwise
|
|
548
|
-
// leave the user staring at a process with zero windows. Recreate the
|
|
549
|
-
// old startup-window affordance: if no page target exists, open a blank one.
|
|
550
|
-
//
|
|
551
|
-
// This tab IS registered on the task below. It used to be deliberately
|
|
552
|
-
// unregistered ("tasks track only tabs they created") — but this daemon did
|
|
553
|
-
// create it, and an unregistered tab is one `done`/`stop` can never close:
|
|
554
|
-
// `stop` closes exactly the entries in `task.tabs`. So every bare `start`
|
|
555
|
-
// left a blank globe tab behind forever, which is a large share of the
|
|
556
|
-
// pile-up in RUSH-2622. Tabs the daemon did NOT open are still left alone —
|
|
557
|
-
// the branch only fires when the profile has no page target at all.
|
|
558
|
-
let startupBlankTargetId;
|
|
559
|
-
if (!opts.url && !conn.electron && conn.backend !== 'arc-native' && conn.backend !== 'bidi') {
|
|
560
|
-
const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
|
|
561
|
-
if (!targetInfos.some((t) => t.type === 'page')) {
|
|
562
|
-
const created = await this.createPageTarget(conn, {
|
|
563
|
-
url: 'about:blank',
|
|
564
|
-
});
|
|
565
|
-
startupBlankTargetId = created.targetId;
|
|
566
|
-
this.invalidateTargetCache(conn);
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
const now = Date.now();
|
|
570
|
-
const task = {
|
|
571
|
-
id: taskId,
|
|
572
|
-
name: taskName,
|
|
573
|
-
label: taskLabel,
|
|
574
|
-
profile: effectiveKey,
|
|
575
|
-
tabs: {},
|
|
576
|
-
currentTabId: undefined,
|
|
577
|
-
createdAt: now,
|
|
578
|
-
// A brand-new task has done exactly one thing — start — so its last
|
|
579
|
-
// action is its creation. The reaper's idle window runs from here.
|
|
580
|
-
lastActionAt: now,
|
|
581
|
-
pid: conn.pid,
|
|
582
|
-
// Identity is forwarded from the caller (see resolveTaskIdentity): WHO
|
|
583
|
-
// (owner), WHICH run (launchId), and WHICH agent session (sessionId).
|
|
584
|
-
// Resolving daemon-side would attribute every task to the shared daemon's
|
|
585
|
-
// actor (the RUSH-2020 bug).
|
|
586
|
-
...resolveTaskIdentity({ actor: opts.actor, launchId: opts.launchId, sessionId: opts.sessionId }, () => resolveActor().id),
|
|
587
|
-
};
|
|
588
|
-
if (conn.backend === 'arc-native') {
|
|
589
|
-
const selected = await this.resolveArcSpace(conn.arcProfile);
|
|
590
|
-
task.arcNative = {
|
|
591
|
-
profileId: conn.arcProfile.profileId,
|
|
592
|
-
windowId: selected.windowId,
|
|
593
|
-
spaceId: selected.spaceId,
|
|
594
|
-
spaceTitle: selected.spaceTitle,
|
|
595
|
-
tabs: {},
|
|
596
|
-
};
|
|
597
|
-
}
|
|
598
|
-
if (startupBlankTargetId) {
|
|
599
|
-
const shortId = generateShortId();
|
|
600
|
-
task.tabs[shortId] = startupBlankTargetId;
|
|
601
|
-
task.currentTabId = shortId;
|
|
602
|
-
}
|
|
603
|
-
// For Electron, get the existing window as the tab
|
|
604
|
-
if (conn.electron && conn.backend !== 'arc-native' && conn.backend !== 'bidi') {
|
|
605
|
-
const windowId = await this.getOrCreateWindow(conn);
|
|
606
|
-
if (windowId) {
|
|
607
|
-
const shortId = generateShortId();
|
|
608
|
-
task.tabs[shortId] = windowId;
|
|
609
|
-
task.currentTabId = shortId;
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
conn.tasks.set(taskName, task);
|
|
613
|
-
await this.saveTaskState(effectiveKey, conn.tasks);
|
|
614
|
-
// Durable identity, written ONCE at start and never deleted (RUSH-2549).
|
|
615
|
-
// tasks.json above stays live state: `stop` drops the task from that map, so
|
|
616
|
-
// anything recorded only there is gone the moment the task ends -- which is
|
|
617
|
-
// why every finished task used to list as "unlinked". This row outlives the
|
|
618
|
-
// task, the daemon, and a reboot. Metadata only: the capture bytes stay on
|
|
619
|
-
// disk under capture_dir.
|
|
620
|
-
// Guarded like `emit` below ("logging should never break the CLI",
|
|
621
|
-
// events.ts): the browser task is already started and registered. An
|
|
622
|
-
// unwritable session DB must not turn a working `agents browser start` into
|
|
623
|
-
// a failure -- the cost of a miss is one unlinked row, not a dead browser.
|
|
624
|
-
try {
|
|
625
|
-
recordBrowserSession({
|
|
626
|
-
task: taskName,
|
|
627
|
-
profile: effectiveKey,
|
|
628
|
-
sessionId: task.sessionId,
|
|
629
|
-
launchId: task.launchId,
|
|
630
|
-
actor: task.owner,
|
|
631
|
-
startedAt: task.createdAt,
|
|
632
|
-
captureDir: getProfileSessionsDir(effectiveKey, taskName),
|
|
633
|
-
});
|
|
634
|
-
}
|
|
635
|
-
catch {
|
|
636
|
-
// Recording is best-effort; the task itself is live either way.
|
|
637
|
-
}
|
|
638
|
-
// Feed events and the usage rollup are read by humans: bare name only.
|
|
639
|
-
emit('browser.launch', { profile: profileName, task: taskName, pid: conn.pid });
|
|
640
|
-
void import('../analytics/usage-db.js').then(({ recordUsage }) => {
|
|
641
|
-
recordUsage({
|
|
642
|
-
kind: 'browser',
|
|
643
|
-
name: profileName,
|
|
644
|
-
event: 'launch',
|
|
645
|
-
source: 'browser',
|
|
646
|
-
meta: { task: taskName },
|
|
647
|
-
});
|
|
648
|
-
}).catch(() => { });
|
|
649
|
-
// If URL provided, reclaim a tab an abandoned task is holding on it, else
|
|
650
|
-
// create one directly (no about:blank). `firstOpen` records what actually
|
|
651
|
-
// happened so the owning IPC handler reports it truthfully rather than
|
|
652
|
-
// assuming a create (PHNX-2399): ADOPTING an abandoned tab is created:false,
|
|
653
|
-
// opening a fresh target is created:true.
|
|
654
|
-
let tabId;
|
|
655
|
-
let firstOpen;
|
|
656
|
-
if (opts.url && conn.backend === 'bidi') {
|
|
657
|
-
// Firefox: the implicit first navigate opens a fresh tab (no adopt path —
|
|
658
|
-
// Firefox tabs aren't shared across abandoned tasks the way CDP ones are).
|
|
659
|
-
const result = await this.navigate(taskName, opts.url, effectiveKey);
|
|
660
|
-
tabId = result.tabId;
|
|
661
|
-
firstOpen = { tabId: result.tabId, created: result.created, refreshed: result.refreshed, message: result.message };
|
|
662
|
-
}
|
|
663
|
-
else if (opts.url && conn.backend !== 'arc-native' && conn.backend !== 'bidi' && !conn.electron && conn.browserType !== 'arc') {
|
|
664
|
-
const adopted = opts.fresh ? undefined : await this.adoptTabShowing(conn, opts.url);
|
|
665
|
-
const targetId = adopted ?? (await this.createPageTarget(conn, { url: opts.url })).targetId;
|
|
666
|
-
const shortId = generateShortId();
|
|
667
|
-
task.tabs[shortId] = targetId;
|
|
668
|
-
task.currentTabId = shortId;
|
|
669
|
-
this.invalidateTargetCache(conn);
|
|
670
|
-
await this.saveTaskState(effectiveKey, conn.tasks);
|
|
671
|
-
tabId = shortId;
|
|
672
|
-
firstOpen = { tabId: shortId, created: !adopted, refreshed: false };
|
|
673
|
-
}
|
|
674
|
-
else if (opts.url && (conn.backend === 'arc-native' || conn.electron || conn.browserType === 'arc')) {
|
|
675
|
-
// Electron and Arc share the reuse-in-place path: neither may open a fresh
|
|
676
|
-
// tab here (Electron drives its one window; Arc crashes on Target.createTarget),
|
|
677
|
-
// so the implicit first navigate attaches to an existing tab — honoring the
|
|
678
|
-
// profile's --target-filter — instead of throwing. This is what makes the
|
|
679
|
-
// documented `navigate --profile arc --url …` first-use workflow attach rather
|
|
680
|
-
// than refuse on a task-less profile (PHNX-2399 review). Carry navigate's
|
|
681
|
-
// real result — Arc borrow is created:false, not a fabricated create.
|
|
682
|
-
const result = await this.navigate(taskName, opts.url, effectiveKey);
|
|
683
|
-
tabId = result.tabId;
|
|
684
|
-
firstOpen = { tabId: result.tabId, created: result.created, refreshed: result.refreshed, message: result.message };
|
|
685
|
-
}
|
|
686
|
-
// Domain-skill discovery: when a URL is supplied, look up site-specific
|
|
687
|
-
// operating instructions and pass them back so the calling agent can pick
|
|
688
|
-
// them up alongside the task id. Failures swallowed by resolveDomainSkill.
|
|
689
|
-
let skill;
|
|
690
|
-
if (opts.url && !opts.skipDomainSkill) {
|
|
691
|
-
const resolved = resolveDomainSkill(opts.url);
|
|
692
|
-
if (resolved)
|
|
693
|
-
skill = resolved;
|
|
694
|
-
}
|
|
695
|
-
return {
|
|
696
|
-
task: taskId,
|
|
697
|
-
name: taskName,
|
|
698
|
-
tabId,
|
|
699
|
-
profile: profileName,
|
|
700
|
-
key: effectiveKey,
|
|
701
|
-
device: routed.device,
|
|
702
|
-
picked: routed.picked,
|
|
703
|
-
skill,
|
|
704
|
-
firstOpen,
|
|
705
|
-
};
|
|
706
|
-
}
|
|
707
|
-
/**
|
|
708
|
-
* Reclaim a live page already showing `url` from an ABANDONED task, instead
|
|
709
|
-
* of opening a second copy of the same page (RUSH-2622). Returns its CDP
|
|
710
|
-
* targetId, or undefined when there is nothing safe to reclaim.
|
|
711
|
-
*
|
|
712
|
-
* "Safe" is narrow on purpose, because both of the wider readings close a tab
|
|
713
|
-
* somebody is using:
|
|
714
|
-
*
|
|
715
|
-
* - An UNOWNED matching page is NOT taken. No task claims it, which most
|
|
716
|
-
* often means the user opened it themselves — and adopting it would make
|
|
717
|
-
* this task's `done` close the user's tab.
|
|
718
|
-
* - A page owned by a LIVE task is NOT taken. Stealing it would leave that
|
|
719
|
-
* agent's `screenshot`/`click` throwing "No tabs open for this task", its
|
|
720
|
-
* `navigate` silently opening the duplicate this feature exists to
|
|
721
|
-
* prevent, and any in-flight recording truncated when the new owner calls
|
|
722
|
-
* `done`.
|
|
723
|
-
*
|
|
724
|
-
* What is left is exactly the pile-up case: a page held by a task whose owner
|
|
725
|
-
* is provably gone. Liveness uses the reaper's own predicate
|
|
726
|
-
* (`taskOwnerIsGone`), so "dead" has one definition in this codebase rather
|
|
727
|
-
* than two that can drift. A task the reaper cannot prove dead keeps its tab
|
|
728
|
-
* and is closed later by the idle rule instead.
|
|
729
|
-
*
|
|
730
|
-
* Reclaiming transfers rather than shares: two tasks holding one targetId
|
|
731
|
-
* means the first `done` closes the other's tab. `start --fresh` skips this
|
|
732
|
-
* path entirely.
|
|
733
|
-
*
|
|
734
|
-
* URLs are compared canonically (`new URL(...).href`), so a requested
|
|
735
|
-
* `https://example.com` matches the `https://example.com/` Chrome reports. A
|
|
736
|
-
* page that has since redirected elsewhere simply does not match, and the
|
|
737
|
-
* caller opens a tab as before.
|
|
738
|
-
*/
|
|
739
|
-
/** Is this CDP target already registered to any task on the connection? */
|
|
740
|
-
targetIsClaimed(conn, targetId) {
|
|
741
|
-
for (const t of conn.tasks.values()) {
|
|
742
|
-
if (Object.values(t.tabs).includes(targetId))
|
|
743
|
-
return true;
|
|
744
|
-
}
|
|
745
|
-
return false;
|
|
746
|
-
}
|
|
747
|
-
/**
|
|
748
|
-
* The CDP targets a task may CLOSE — its own tabs minus any it merely
|
|
749
|
-
* borrowed. A borrowed tab existed before the task did (see `Task.borrowedTabs`),
|
|
750
|
-
* so closing it would remove something the task never opened.
|
|
751
|
-
*/
|
|
752
|
-
closeableTargetIds(task) {
|
|
753
|
-
const borrowed = new Set(task.borrowedTabs ?? []);
|
|
754
|
-
return Object.entries(task.tabs)
|
|
755
|
-
.filter(([shortId]) => !borrowed.has(shortId))
|
|
756
|
-
.map(([, cdpId]) => cdpId);
|
|
757
|
-
}
|
|
758
|
-
/**
|
|
759
|
-
* A page target that `navigate` may take over on a browser which cannot create
|
|
760
|
-
* tabs (Arc). Deliberately narrow: only a tab ALREADY showing the requested URL
|
|
761
|
-
* -- re-showing that document there is what the caller asked for -- or an empty
|
|
762
|
-
* new-tab page. A target any task already owns is left alone. Note the honest
|
|
763
|
-
* limit: reusing a matching tab still RELOADS it, discarding scroll position and
|
|
764
|
-
* unsaved form state. Arc cannot open a tab at all, so some intrusion is
|
|
765
|
-
* unavoidable to show the document; a reload is the smallest one, and the tab is
|
|
766
|
-
* borrowed rather than owned so it is never closed.
|
|
767
|
-
*/
|
|
768
|
-
async pickReusableTargetWithoutCreate(conn, url) {
|
|
769
|
-
const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
|
|
770
|
-
const owned = new Set();
|
|
771
|
-
for (const t of conn.tasks.values()) {
|
|
772
|
-
for (const id of Object.values(t.tabs))
|
|
773
|
-
owned.add(id);
|
|
774
|
-
}
|
|
775
|
-
let free = targetInfos.filter((t) => t.type === 'page' && !owned.has(t.targetId));
|
|
776
|
-
// A bound --target-filter (url:/title:<substring>) is how the profile names the
|
|
777
|
-
// tab/Space it drives — e.g. an Arc Space pinned with `--target-filter url:notion.so`.
|
|
778
|
-
// Scope the reusable set to tabs matching it; an explicit filter that matches nothing
|
|
779
|
-
// returns undefined so the caller refuses rather than borrowing an unrelated tab (the
|
|
780
|
-
// same contract pickWindowTarget holds for the tab-creating path). Without this the
|
|
781
|
-
// filter was accepted and stored but never consulted when driving Arc (PHNX-2399 review).
|
|
782
|
-
const parsed = parseTargetFilter(conn.targetFilter);
|
|
783
|
-
if (parsed) {
|
|
784
|
-
const needle = parsed.value.toLowerCase();
|
|
785
|
-
free = free.filter((t) => ((parsed.kind === 'url' ? t.url : t.title) ?? '').toLowerCase().includes(needle));
|
|
786
|
-
if (free.length === 0)
|
|
787
|
-
return undefined;
|
|
788
|
-
}
|
|
789
|
-
const wanted = canonicalTabUrl(url);
|
|
790
|
-
const sameUrl = free.find((t) => canonicalTabUrl(t.url) === wanted);
|
|
791
|
-
if (sameUrl)
|
|
792
|
-
return sameUrl.targetId;
|
|
793
|
-
const BLANK = new Set(['', 'about:blank', 'about:newtab', 'chrome://newtab/', 'chrome://new-tab-page/']);
|
|
794
|
-
const blank = free.find((t) => BLANK.has(t.url));
|
|
795
|
-
if (blank)
|
|
796
|
-
return blank.targetId;
|
|
797
|
-
// With a filter set, every remaining tab already IS the bound Space, so reuse the
|
|
798
|
-
// first even when it is neither the exact URL nor blank — that is the target the
|
|
799
|
-
// operator pinned. With no filter, only an exact-URL or blank tab is safe to borrow.
|
|
800
|
-
return parsed ? free[0]?.targetId : undefined;
|
|
801
|
-
}
|
|
802
|
-
async adoptTabShowing(conn, url) {
|
|
803
|
-
const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
|
|
804
|
-
const wanted = canonicalTabUrl(url);
|
|
805
|
-
const matching = new Set(targetInfos
|
|
806
|
-
.filter((t) => t.type === 'page' && canonicalTabUrl(t.url) === wanted)
|
|
807
|
-
.map((t) => t.targetId));
|
|
808
|
-
if (matching.size === 0)
|
|
809
|
-
return undefined;
|
|
810
|
-
const candidates = [];
|
|
811
|
-
for (const task of conn.tasks.values()) {
|
|
812
|
-
const borrowed = new Set(task.borrowedTabs ?? []);
|
|
813
|
-
for (const [shortId, cdpId] of Object.entries(task.tabs)) {
|
|
814
|
-
// A BORROWED tab is not ours to hand on. The dead task never opened it
|
|
815
|
-
// (Task.borrowedTabs) -- reclaiming it would register a pre-existing tab
|
|
816
|
-
// as a fresh task's own, and that task's `done` would close it. Exactly
|
|
817
|
-
// the "adopting it would make this task's `done` close the user's tab"
|
|
818
|
-
// case above, arriving one hop later via reclaim instead of directly.
|
|
819
|
-
if (borrowed.has(shortId))
|
|
820
|
-
continue;
|
|
821
|
-
if (matching.has(cdpId))
|
|
822
|
-
candidates.push({ task, shortId, targetId: cdpId });
|
|
823
|
-
}
|
|
824
|
-
}
|
|
825
|
-
if (candidates.length === 0)
|
|
826
|
-
return undefined;
|
|
827
|
-
const live = resolveLiveIdentities();
|
|
828
|
-
for (const { task, shortId, targetId } of candidates) {
|
|
829
|
-
// An in-flight capture means the task is in use whatever its owner looks
|
|
830
|
-
// like — same guard the reaper applies before stopping anything.
|
|
831
|
-
if (this.recordings.has(task.name))
|
|
832
|
-
continue;
|
|
833
|
-
if (!(await taskOwnerIsGone(task, live)))
|
|
834
|
-
continue;
|
|
835
|
-
// Re-check the claim after the await. Nothing serializes IPC requests —
|
|
836
|
-
// `BrowserIPCServer` registers a per-connection `socket.on('data', async …)`
|
|
837
|
-
// that Node never awaits — so two concurrent `start`s can both pass the
|
|
838
|
-
// liveness check on one candidate and both return the same targetId,
|
|
839
|
-
// putting two live tasks on one tab and re-opening the double-owner bug
|
|
840
|
-
// this reclaim is careful to avoid. Whoever deletes it first owns it.
|
|
841
|
-
if (task.tabs[shortId] !== targetId)
|
|
842
|
-
continue;
|
|
843
|
-
delete task.tabs[shortId];
|
|
844
|
-
delete task.refDescriptors?.[shortId];
|
|
845
|
-
if (task.currentTabId === shortId) {
|
|
846
|
-
const remaining = Object.keys(task.tabs);
|
|
847
|
-
task.currentTabId = remaining.length > 0 ? remaining[remaining.length - 1] : undefined;
|
|
848
|
-
}
|
|
849
|
-
try {
|
|
850
|
-
await conn.cdp.send('Target.activateTarget', { targetId });
|
|
851
|
-
}
|
|
852
|
-
catch {
|
|
853
|
-
// Bringing the tab to the front is cosmetic and not every endpoint
|
|
854
|
-
// implements activateTarget; the tab is reclaimed either way.
|
|
855
|
-
}
|
|
856
|
-
return targetId;
|
|
857
|
-
}
|
|
858
|
-
return undefined;
|
|
859
|
-
}
|
|
860
|
-
/**
|
|
861
|
-
* Every live task across every connected profile — the reaper's input
|
|
862
|
-
* (`hygiene.ts`). A read-only view: mutating a returned `Task` mutates the
|
|
863
|
-
* daemon's live state, so callers only read it and act through `stop`.
|
|
864
|
-
*/
|
|
865
|
-
listTasks() {
|
|
866
|
-
const out = [];
|
|
867
|
-
for (const [key, conn] of this.connections) {
|
|
868
|
-
for (const task of conn.tasks.values()) {
|
|
869
|
-
out.push({ profile: conn.key ?? key, task });
|
|
870
|
-
}
|
|
871
|
-
}
|
|
872
|
-
return out;
|
|
873
|
-
}
|
|
874
|
-
/**
|
|
875
|
-
* Close tasks whose owning agent session is gone, or that have sat untouched
|
|
876
|
-
* past the idle window. The entry point the daemon's periodic tick and
|
|
877
|
-
* `agents browser prune` both call; the policy lives in `hygiene.ts`.
|
|
878
|
-
*/
|
|
879
|
-
async reapAbandoned(opts = {}) {
|
|
880
|
-
return reapAbandonedTasks(this, opts);
|
|
881
|
-
}
|
|
882
|
-
async stop(taskName) {
|
|
883
|
-
// Rehydrate from tasks.json when the daemon restarted and lost RAM state.
|
|
884
|
-
try {
|
|
885
|
-
await this.findTask(taskName);
|
|
886
|
-
}
|
|
887
|
-
catch {
|
|
888
|
-
// findTask throws when truly gone; fall through to the miss return.
|
|
889
|
-
}
|
|
890
|
-
for (const [key, conn] of this.connections) {
|
|
891
|
-
const task = this.lookupTaskOnConn(conn, taskName);
|
|
892
|
-
if (task) {
|
|
893
|
-
// Map key may be task.name, not the caller-supplied handle.
|
|
894
|
-
const mapKey = this.taskMapKey(conn, task) ?? taskName;
|
|
895
|
-
// Get domains from tabs before closing (for history)
|
|
896
|
-
const domains = new Set();
|
|
897
|
-
if (conn.backend === 'arc-native') {
|
|
898
|
-
// Resolve exact durable refs for history. Missing/moved tabs are not
|
|
899
|
-
// adopted and never become candidates for a best-effort close.
|
|
900
|
-
for (const ref of Object.values(this.requireArcTask(task).tabs)) {
|
|
901
|
-
const live = await resolveArcTab(ref);
|
|
902
|
-
if (live?.url) {
|
|
903
|
-
try {
|
|
904
|
-
const domain = new URL(live.url).hostname.replace(/^www\./, '');
|
|
905
|
-
if (domain && domain !== 'blank')
|
|
906
|
-
domains.add(domain);
|
|
907
|
-
}
|
|
908
|
-
catch { /* invalid URL */ }
|
|
909
|
-
}
|
|
910
|
-
}
|
|
911
|
-
await this.saveToHistory(task, Array.from(domains));
|
|
912
|
-
await this.closeArcNativeTabs(conn, task);
|
|
913
|
-
}
|
|
914
|
-
else if (conn.backend === 'bidi') {
|
|
915
|
-
try {
|
|
916
|
-
for (const tab of await this.listFirefoxTaskTabs(conn, task)) {
|
|
917
|
-
try {
|
|
918
|
-
const domain = new URL(tab.url).hostname.replace(/^www\./, '');
|
|
919
|
-
if (domain && domain !== 'blank')
|
|
920
|
-
domains.add(domain);
|
|
921
|
-
}
|
|
922
|
-
catch { /* invalid URL */ }
|
|
923
|
-
}
|
|
924
|
-
}
|
|
925
|
-
catch { /* Firefox not responding */ }
|
|
926
|
-
await this.saveToHistory(task, Array.from(domains));
|
|
927
|
-
await this.closeFirefoxTabs(conn, task);
|
|
928
|
-
}
|
|
929
|
-
else {
|
|
930
|
-
try {
|
|
931
|
-
const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
|
|
932
|
-
for (const cdpId of Object.values(task.tabs)) {
|
|
933
|
-
const target = targetInfos.find((t) => t.targetId === cdpId);
|
|
934
|
-
if (target?.url) {
|
|
935
|
-
try {
|
|
936
|
-
const domain = new URL(target.url).hostname.replace(/^www\./, '');
|
|
937
|
-
if (domain && domain !== 'blank')
|
|
938
|
-
domains.add(domain);
|
|
939
|
-
}
|
|
940
|
-
catch {
|
|
941
|
-
// invalid URL
|
|
942
|
-
}
|
|
943
|
-
}
|
|
944
|
-
}
|
|
945
|
-
}
|
|
946
|
-
catch {
|
|
947
|
-
// CDP not responding
|
|
948
|
-
}
|
|
949
|
-
// Save to history before closing
|
|
950
|
-
await this.saveToHistory(task, Array.from(domains));
|
|
951
|
-
// Close task's tabs (not the window - it's shared, and not a tab the
|
|
952
|
-
// task merely borrowed - that one was open before the task existed).
|
|
953
|
-
await Promise.all(this.closeableTargetIds(task).map((cdpId) => conn.cdp.send('Target.closeTarget', { targetId: cdpId }).catch(() => {
|
|
954
|
-
// Tab already closed
|
|
955
|
-
})));
|
|
956
|
-
for (const cdpId of Object.values(task.tabs)) {
|
|
957
|
-
conn.sessionCache.delete(cdpId);
|
|
958
|
-
}
|
|
959
|
-
this.invalidateTargetCache(conn);
|
|
960
|
-
}
|
|
961
|
-
conn.tasks.delete(mapKey);
|
|
962
|
-
await this.saveTaskState(key, conn.tasks);
|
|
963
|
-
// Feed + usage rows are read by humans: bare profile name (RUSH-2709).
|
|
964
|
-
const bareProfile = conn.profile ?? parseConnectionKey(key).profile;
|
|
965
|
-
emit('browser.close', { profile: bareProfile, task: task.name });
|
|
966
|
-
void import('../analytics/usage-db.js').then(({ recordUsage }) => {
|
|
967
|
-
recordUsage({
|
|
968
|
-
kind: 'browser',
|
|
969
|
-
name: bareProfile,
|
|
970
|
-
event: 'close',
|
|
971
|
-
source: 'browser',
|
|
972
|
-
meta: { task: task.name },
|
|
973
|
-
});
|
|
974
|
-
}).catch(() => { });
|
|
975
|
-
if (conn.forkedFrom && conn.tasks.size === 0) {
|
|
976
|
-
if (conn.backend === 'bidi') {
|
|
977
|
-
conn.bidi.close();
|
|
978
|
-
if (conn.pid)
|
|
979
|
-
killChrome(conn.pid);
|
|
980
|
-
}
|
|
981
|
-
else if (conn.backend !== 'arc-native') {
|
|
982
|
-
conn.cdp.close();
|
|
983
|
-
killChrome(conn.pid);
|
|
984
|
-
}
|
|
985
|
-
conn.cleanup?.();
|
|
986
|
-
this.connections.delete(key);
|
|
987
|
-
clearProfileRuntime(key);
|
|
988
|
-
}
|
|
989
|
-
return { ok: true, profile: bareProfile };
|
|
990
|
-
}
|
|
991
|
-
}
|
|
992
|
-
return { ok: false };
|
|
993
|
-
}
|
|
994
|
-
async done(taskName) {
|
|
995
|
-
return this.stop(taskName);
|
|
996
|
-
}
|
|
997
|
-
async stopProfile(profileRef, opts = {}) {
|
|
998
|
-
// Consent gate — this is a fleet-remote destructive path (kills the
|
|
999
|
-
// profile's browser process and clears its runtime dir) that reached the
|
|
1000
|
-
// daemon without ever hitting resolveOrCreateTask's gate, since it is a
|
|
1001
|
-
// task-less stop. Same per-request marker rule as every other gated verb —
|
|
1002
|
-
// see remote-control.ts.
|
|
1003
|
-
assertRemoteControlAllowedForRequest(opts.fleetRemote, { actor: opts.actor });
|
|
1004
|
-
// Connections are keyed by the runtime key `<profile>@<device>` (see
|
|
1005
|
-
// start()) while callers pass the bare profile name (or, occasionally, an
|
|
1006
|
-
// exact key). A plain `connections.get(profileRef)` therefore missed every
|
|
1007
|
-
// real remote connection, so `cleanup()` never ran — leaving the SSH tunnel
|
|
1008
|
-
// and, on Windows, the WMI-spawned browser orphaned on the remote host after
|
|
1009
|
-
// every `browser stop --profile` (#559). Same single rule as findTask and
|
|
1010
|
-
// status: exact key, else every key belonging to that profile.
|
|
1011
|
-
const keys = [...this.connections.keys()].filter((k) => k === profileRef || keyBelongsToProfile(k, profileRef));
|
|
1012
|
-
for (const key of keys) {
|
|
1013
|
-
const conn = this.connections.get(key);
|
|
1014
|
-
if (!conn)
|
|
1015
|
-
continue;
|
|
1016
|
-
// Native Arc connections: close owned tabs but NEVER kill the Arc process.
|
|
1017
|
-
// The CLI does not own the Arc process — the user started it.
|
|
1018
|
-
if (conn.backend === 'arc-native') {
|
|
1019
|
-
for (const task of conn.tasks.values()) {
|
|
1020
|
-
await this.closeArcNativeTabs(conn, task);
|
|
1021
|
-
}
|
|
1022
|
-
this.connections.delete(key);
|
|
1023
|
-
clearProfileRuntime(key);
|
|
1024
|
-
continue;
|
|
1025
|
-
}
|
|
1026
|
-
// Firefox: close the BiDi socket and kill the Firefox WE launched (pid !=
|
|
1027
|
-
// 0). An attached Firefox the user started (pid 0) is left running — the
|
|
1028
|
-
// CLI does not own that process, same as Arc.
|
|
1029
|
-
if (conn.backend === 'bidi') {
|
|
1030
|
-
conn.bidi.close();
|
|
1031
|
-
if (conn.pid)
|
|
1032
|
-
killChrome(conn.pid);
|
|
1033
|
-
conn.cleanup?.();
|
|
1034
|
-
this.connections.delete(key);
|
|
1035
|
-
clearProfileRuntime(key);
|
|
1036
|
-
continue;
|
|
1037
|
-
}
|
|
1038
|
-
conn.cdp.close();
|
|
1039
|
-
killChrome(conn.pid);
|
|
1040
|
-
conn.cleanup?.();
|
|
1041
|
-
this.connections.delete(key);
|
|
1042
|
-
clearProfileRuntime(key);
|
|
1043
|
-
}
|
|
1044
|
-
// Kill stale processes and clean runtime dirs for every composite and
|
|
1045
|
-
// fork entry belonging to this profile (including `.N` forks left by
|
|
1046
|
-
// earlier daemon sessions that the connection loop above didn't cover).
|
|
1047
|
-
for (const dir of listProfileCacheDirs(parseConnectionKey(profileRef).profile)) {
|
|
1048
|
-
const dirName = path.basename(dir);
|
|
1049
|
-
const meta = readProfileRuntimeMeta(dirName);
|
|
1050
|
-
if (meta?.pid && meta.pid !== 0 && isProcessAlive(meta.pid, meta.command)) {
|
|
1051
|
-
killChrome(meta.pid);
|
|
1052
|
-
}
|
|
1053
|
-
clearProfileRuntime(dirName);
|
|
1054
|
-
}
|
|
1055
|
-
}
|
|
1056
|
-
async navigate(taskId, url, profileRef) {
|
|
1057
|
-
const { conn, task } = await this.findTask(taskId, profileRef);
|
|
1058
|
-
// Serialize the reopen/create section so two concurrent navigates for one
|
|
1059
|
-
// task don't both miss the "already open?" check and open duplicates.
|
|
1060
|
-
return this.runExclusive(`task:${task.profile}:${task.name}`, () => this.navigateLocked(conn, task, url));
|
|
1061
|
-
}
|
|
1062
|
-
async navigateLocked(conn, task, url) {
|
|
1063
|
-
this.maybeUpdateLabelFromUrl(conn, task, url);
|
|
1064
|
-
// Native Arc backend (PHNX-2399): route through the native driver instead of CDP.
|
|
1065
|
-
if (conn.backend === 'arc-native') {
|
|
1066
|
-
return this.navigateArcNative(conn, task, url);
|
|
1067
|
-
}
|
|
1068
|
-
// Firefox BiDi backend (PHNX-4043).
|
|
1069
|
-
if (conn.backend === 'bidi') {
|
|
1070
|
-
return this.navigateFirefox(conn, task, url);
|
|
1071
|
-
}
|
|
1072
|
-
requireCdp(conn, 'navigate');
|
|
1073
|
-
// Same-task reopen (PHNX-2399): the URL is already live in a tab this task
|
|
1074
|
-
// owns → reload THAT tab in place and keep its id, before the create/reuse
|
|
1075
|
-
// paths below. Chrome and Arc alike; Electron drives one window and never
|
|
1076
|
-
// has a second owned tab to match, so this is a no-op there.
|
|
1077
|
-
const reopened = await this.reopenOwnedTab(conn, task, url);
|
|
1078
|
-
if (reopened) {
|
|
1079
|
-
emit('browser.navigate', { profile: parseConnectionKey(task.profile).profile, task: task.name, url, tabId: reopened.tabId, created: false });
|
|
1080
|
-
return { tabId: reopened.tabId, url, created: false, refreshed: true, message: 'Tab already open—refreshed' };
|
|
1081
|
-
}
|
|
1082
|
-
// If we have a current tab, navigate in it (reuse)
|
|
1083
|
-
const currentShortId = task.currentTabId;
|
|
1084
|
-
if (currentShortId && task.tabs[currentShortId]) {
|
|
1085
|
-
const cdpTargetId = task.tabs[currentShortId];
|
|
1086
|
-
const sessionId = await this.getSessionId(conn, cdpTargetId);
|
|
1087
|
-
await conn.cdp.send('Page.navigate', { url }, sessionId);
|
|
1088
|
-
await this.saveTaskState(task.profile, conn.tasks);
|
|
1089
|
-
emit('browser.navigate', { profile: parseConnectionKey(task.profile).profile, task: task.name, url, tabId: currentShortId, created: false });
|
|
1090
|
-
return { tabId: currentShortId, url, created: false, refreshed: false };
|
|
1091
|
-
}
|
|
1092
|
-
// No current tab - create one
|
|
1093
|
-
if (conn.electron) {
|
|
1094
|
-
const cdpTargetId = conn.windowId;
|
|
1095
|
-
if (!cdpTargetId) {
|
|
1096
|
-
throw new Error('No existing tab to navigate in Electron app');
|
|
1097
|
-
}
|
|
1098
|
-
const shortId = generateShortId();
|
|
1099
|
-
const sessionId = await this.getSessionId(conn, cdpTargetId);
|
|
1100
|
-
await conn.cdp.send('Page.navigate', { url }, sessionId);
|
|
1101
|
-
task.tabs[shortId] = cdpTargetId;
|
|
1102
|
-
task.currentTabId = shortId;
|
|
1103
|
-
await this.saveTaskState(task.profile, conn.tasks);
|
|
1104
|
-
emit('browser.navigate', { profile: parseConnectionKey(task.profile).profile, task: task.name, url, tabId: shortId, created: true });
|
|
1105
|
-
return { tabId: shortId, url, created: true, refreshed: false };
|
|
1106
|
-
}
|
|
1107
|
-
// Arc exposes page targets and honors Page.navigate on them; what it cannot
|
|
1108
|
-
// survive is Target.createTarget (#2778). createPageTarget below therefore
|
|
1109
|
-
// refuses outright, so the document never gets shown AT ALL and callers fall
|
|
1110
|
-
// back to a raw `open` -- a new tab every call, which is the tab-spam #2779
|
|
1111
|
-
// set out to end. Reuse in place instead, the same shape as the Electron
|
|
1112
|
-
// branch above. Measured against a live Arc (#2786): 33 page targets,
|
|
1113
|
-
// Page.navigate reused one, tab count unchanged, no crash.
|
|
1114
|
-
if (conn.browserType === 'arc') {
|
|
1115
|
-
const reusable = await this.pickReusableTargetWithoutCreate(conn, url);
|
|
1116
|
-
if (reusable) {
|
|
1117
|
-
const shortId = generateShortId();
|
|
1118
|
-
const sessionId = await this.getSessionId(conn, reusable);
|
|
1119
|
-
// Check and CLAIM in one synchronous tick, with no await between them --
|
|
1120
|
-
// the ordering `adoptTabShowing` uses (service.ts: the `task.tabs[shortId]
|
|
1121
|
-
// !== targetId` re-check sits immediately before its `delete`). Checking
|
|
1122
|
-
// and then awaiting `Page.navigate` before writing leaves exactly the gap
|
|
1123
|
-
// the check exists to close: a concurrent navigate passes its own check in
|
|
1124
|
-
// that window and both tasks end up driving one tab.
|
|
1125
|
-
if (this.targetIsClaimed(conn, reusable)) {
|
|
1126
|
-
throw arcNotDrivableError(conn.profile);
|
|
1127
|
-
}
|
|
1128
|
-
task.tabs[shortId] = reusable;
|
|
1129
|
-
// Borrowed, not opened: this tab was in the browser before the task and
|
|
1130
|
-
// must survive `done`. See Task.borrowedTabs.
|
|
1131
|
-
task.borrowedTabs = [...(task.borrowedTabs ?? []), shortId];
|
|
1132
|
-
task.currentTabId = shortId;
|
|
1133
|
-
try {
|
|
1134
|
-
await conn.cdp.send('Page.navigate', { url }, sessionId);
|
|
1135
|
-
}
|
|
1136
|
-
catch (err) {
|
|
1137
|
-
// Release the claim we took optimistically, so a failed navigate does
|
|
1138
|
-
// not leave the task holding a tab it never drove.
|
|
1139
|
-
delete task.tabs[shortId];
|
|
1140
|
-
task.borrowedTabs = (task.borrowedTabs ?? []).filter((id) => id !== shortId);
|
|
1141
|
-
if (task.currentTabId === shortId)
|
|
1142
|
-
task.currentTabId = undefined;
|
|
1143
|
-
throw err;
|
|
1144
|
-
}
|
|
1145
|
-
this.invalidateTargetCache(conn);
|
|
1146
|
-
await this.saveTaskState(task.profile, conn.tasks);
|
|
1147
|
-
emit('browser.navigate', {
|
|
1148
|
-
profile: task.profile,
|
|
1149
|
-
task: task.name,
|
|
1150
|
-
url,
|
|
1151
|
-
tabId: shortId,
|
|
1152
|
-
created: false,
|
|
1153
|
-
});
|
|
1154
|
-
return { tabId: shortId, url, created: false, refreshed: false };
|
|
1155
|
-
}
|
|
1156
|
-
// Nothing safe to reuse -- fall through to the actionable refusal rather
|
|
1157
|
-
// than taking over a page the user is reading.
|
|
1158
|
-
}
|
|
1159
|
-
// Chrome: create new tab
|
|
1160
|
-
const result = await this.createPageTarget(conn, { url });
|
|
1161
|
-
const shortId = generateShortId();
|
|
1162
|
-
task.tabs[shortId] = result.targetId;
|
|
1163
|
-
task.currentTabId = shortId;
|
|
1164
|
-
this.invalidateTargetCache(conn);
|
|
1165
|
-
await this.saveTaskState(task.profile, conn.tasks);
|
|
1166
|
-
emit('browser.navigate', { profile: parseConnectionKey(task.profile).profile, task: task.name, url, tabId: shortId, created: true });
|
|
1167
|
-
return { tabId: shortId, url, created: true, refreshed: false };
|
|
1168
|
-
}
|
|
1169
|
-
async tabAdd(taskId, url, profileRef) {
|
|
1170
|
-
const { conn, task } = await this.findTask(taskId, profileRef);
|
|
1171
|
-
return this.runExclusive(`task:${task.profile}:${task.name}`, () => this.tabAddLocked(conn, task, url));
|
|
1172
|
-
}
|
|
1173
|
-
async tabAddLocked(conn, task, url) {
|
|
1174
|
-
if (conn.backend === 'arc-native') {
|
|
1175
|
-
return this.navigateArcNative(conn, task, url, true);
|
|
1176
|
-
}
|
|
1177
|
-
if (conn.backend === 'bidi') {
|
|
1178
|
-
return this.navigateFirefox(conn, task, url, true);
|
|
1179
|
-
}
|
|
1180
|
-
requireCdp(conn, 'createTab');
|
|
1181
|
-
if (conn.electron) {
|
|
1182
|
-
throw new Error('Electron apps do not support opening additional tabs');
|
|
1183
|
-
}
|
|
1184
|
-
// Same-task reopen (PHNX-2399): reopening a URL already live in one of this
|
|
1185
|
-
// task's own tabs refreshes THAT tab rather than opening a duplicate. The
|
|
1186
|
-
// no-match case below stays `tab add`'s intentional create-a-new-tab
|
|
1187
|
-
// semantics — distinct from `navigate`, which reuses the current tab.
|
|
1188
|
-
const reopened = await this.reopenOwnedTab(conn, task, url);
|
|
1189
|
-
if (reopened) {
|
|
1190
|
-
return { tabId: reopened.tabId, url, created: false, refreshed: true, message: 'Tab already open—refreshed' };
|
|
1191
|
-
}
|
|
1192
|
-
const result = await this.createPageTarget(conn, { url });
|
|
1193
|
-
const shortId = generateShortId();
|
|
1194
|
-
task.tabs[shortId] = result.targetId;
|
|
1195
|
-
task.currentTabId = shortId; // new tab becomes current
|
|
1196
|
-
this.invalidateTargetCache(conn);
|
|
1197
|
-
await this.saveTaskState(task.profile, conn.tasks);
|
|
1198
|
-
return { tabId: shortId, url, created: true, refreshed: false };
|
|
1199
|
-
}
|
|
1200
|
-
async tabFocus(taskId, tabHint) {
|
|
1201
|
-
const { conn, task } = await this.findTask(taskId);
|
|
1202
|
-
const resolvedTabId = await this.resolveTabHint(conn, task, tabHint);
|
|
1203
|
-
if (conn.backend === 'arc-native') {
|
|
1204
|
-
await this.runExclusive('arc-native:host-app', async () => {
|
|
1205
|
-
const ref = this.requireArcTask(task).tabs[resolvedTabId];
|
|
1206
|
-
if (!ref)
|
|
1207
|
-
throw new Error(`Tab ${resolvedTabId} not found`);
|
|
1208
|
-
await selectArcTab(ref);
|
|
1209
|
-
task.currentTabId = resolvedTabId;
|
|
1210
|
-
await this.saveTaskState(task.profile, conn.tasks);
|
|
1211
|
-
});
|
|
1212
|
-
return { tabId: resolvedTabId };
|
|
1213
|
-
}
|
|
1214
|
-
if (conn.backend === 'bidi') {
|
|
1215
|
-
await bidiActivate(conn.bidi, this.firefoxContext(task, resolvedTabId));
|
|
1216
|
-
task.currentTabId = resolvedTabId;
|
|
1217
|
-
await this.saveTaskState(task.profile, conn.tasks);
|
|
1218
|
-
return { tabId: resolvedTabId };
|
|
1219
|
-
}
|
|
1220
|
-
requireCdp(conn, 'enumerate');
|
|
1221
|
-
await conn.cdp.send('Target.activateTarget', { targetId: task.tabs[resolvedTabId] });
|
|
1222
|
-
task.currentTabId = resolvedTabId;
|
|
1223
|
-
await this.saveTaskState(task.profile, conn.tasks);
|
|
1224
|
-
return { tabId: resolvedTabId };
|
|
1225
|
-
}
|
|
1226
|
-
async tabList(taskId) {
|
|
1227
|
-
const { conn, task } = await this.findTask(taskId);
|
|
1228
|
-
if (conn.backend === 'arc-native') {
|
|
1229
|
-
return (await this.listArcTaskTabs(task)).map((tab) => ({ ...tab, current: tab.current === true }));
|
|
1230
|
-
}
|
|
1231
|
-
if (conn.backend === 'bidi') {
|
|
1232
|
-
return (await this.listFirefoxTaskTabs(conn, task)).map((tab) => ({
|
|
1233
|
-
id: tab.id, url: tab.url, title: tab.title, current: tab.current === true,
|
|
1234
|
-
}));
|
|
1235
|
-
}
|
|
1236
|
-
requireCdp(conn, 'enumerate');
|
|
1237
|
-
const targets = (await conn.cdp.send('Target.getTargets'));
|
|
1238
|
-
const tabs = [];
|
|
1239
|
-
for (const [shortId, cdpId] of Object.entries(task.tabs)) {
|
|
1240
|
-
const target = targets.targetInfos.find((t) => t.targetId === cdpId);
|
|
1241
|
-
if (target) {
|
|
1242
|
-
tabs.push({
|
|
1243
|
-
id: shortId,
|
|
1244
|
-
url: target.url,
|
|
1245
|
-
title: target.title,
|
|
1246
|
-
current: shortId === task.currentTabId,
|
|
1247
|
-
});
|
|
1248
|
-
}
|
|
1249
|
-
}
|
|
1250
|
-
return tabs;
|
|
1251
|
-
}
|
|
1252
|
-
/**
|
|
1253
|
-
* Every top-level tab open in the task's profile browser, the owner's tabs
|
|
1254
|
-
* included (`agents browser tabs --all`). Read-only: nothing is focused,
|
|
1255
|
-
* navigated, or closed. A tab an agents-cli task owns carries that task's
|
|
1256
|
-
* name and short id (so it can be addressed); any other tab carries the
|
|
1257
|
-
* browser's own id and no task. Same rows on every backend.
|
|
1258
|
-
*/
|
|
1259
|
-
async profileTabs(taskId) {
|
|
1260
|
-
const { conn, task } = await this.findTask(taskId);
|
|
1261
|
-
const owners = new Map();
|
|
1262
|
-
for (const [, owner] of conn.tasks) {
|
|
1263
|
-
const entries = conn.backend === 'arc-native'
|
|
1264
|
-
? Object.entries(owner.arcNative?.tabs ?? {}).map(([shortId, ref]) => [shortId, ref.tabId])
|
|
1265
|
-
: Object.entries(owner.tabs);
|
|
1266
|
-
for (const [shortId, nativeId] of entries)
|
|
1267
|
-
owners.set(nativeId, { owner, shortId });
|
|
1268
|
-
}
|
|
1269
|
-
const row = (nativeId, url, title) => {
|
|
1270
|
-
const owned = owners.get(nativeId);
|
|
1271
|
-
if (!owned)
|
|
1272
|
-
return { id: nativeId, url, title };
|
|
1273
|
-
return {
|
|
1274
|
-
id: owned.shortId,
|
|
1275
|
-
url,
|
|
1276
|
-
title,
|
|
1277
|
-
task: owned.owner.name,
|
|
1278
|
-
current: owned.owner === task && owned.shortId === task.currentTabId,
|
|
1279
|
-
};
|
|
1280
|
-
};
|
|
1281
|
-
if (conn.backend === 'arc-native') {
|
|
1282
|
-
const native = this.requireArcTask(task);
|
|
1283
|
-
const space = (await enumerateArcSpaces()).find((candidate) => candidate.windowId === native.windowId && candidate.spaceId === native.spaceId);
|
|
1284
|
-
if (!space) {
|
|
1285
|
-
throw new Error(`Arc Space "${native.spaceTitle}" is not open in its original window (${native.windowId}); ` +
|
|
1286
|
-
'open it in Arc and retry.');
|
|
1287
|
-
}
|
|
1288
|
-
return space.tabs.map((tab) => row(tab.tabId, tab.url, tab.title));
|
|
1289
|
-
}
|
|
1290
|
-
if (conn.backend === 'bidi') {
|
|
1291
|
-
const rows = [];
|
|
1292
|
-
for (const context of await bidiTopLevelContexts(conn.bidi)) {
|
|
1293
|
-
let title = '';
|
|
1294
|
-
try {
|
|
1295
|
-
title = String(await bidiEvaluate(conn.bidi, context.context, 'document.title'));
|
|
1296
|
-
}
|
|
1297
|
-
catch {
|
|
1298
|
-
/* about: pages or a mid-navigation context — url is enough */
|
|
1299
|
-
}
|
|
1300
|
-
rows.push(row(context.context, context.url, title));
|
|
1301
|
-
}
|
|
1302
|
-
return rows;
|
|
1303
|
-
}
|
|
1304
|
-
requireCdp(conn, 'enumerate');
|
|
1305
|
-
const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
|
|
1306
|
-
return targetInfos
|
|
1307
|
-
.filter((target) => target.type === 'page')
|
|
1308
|
-
.map((target) => row(target.targetId, target.url, target.title));
|
|
1309
|
-
}
|
|
1310
|
-
async resolveTabHint(conn, task, hint) {
|
|
1311
|
-
// Exact match
|
|
1312
|
-
if (task.tabs[hint])
|
|
1313
|
-
return hint;
|
|
1314
|
-
// Prefix match
|
|
1315
|
-
const byPrefix = Object.keys(task.tabs).filter((id) => id.startsWith(hint));
|
|
1316
|
-
if (byPrefix.length === 1)
|
|
1317
|
-
return byPrefix[0];
|
|
1318
|
-
if (byPrefix.length > 1) {
|
|
1319
|
-
throw new Error(`Ambiguous tab hint "${hint}" — matches ${byPrefix.length} tabs`);
|
|
1320
|
-
}
|
|
1321
|
-
// Native Arc addresses owned tabs only by the task's stable short id. URL
|
|
1322
|
-
// and title are display data and must never become fallback identities.
|
|
1323
|
-
if (conn.backend === 'arc-native')
|
|
1324
|
-
throw new Error(`Tab "${hint}" not found`);
|
|
1325
|
-
// Firefox tabs are addressed by the task's stable short id too.
|
|
1326
|
-
if (conn.backend === 'bidi')
|
|
1327
|
-
throw new Error(`Tab "${hint}" not found`);
|
|
1328
|
-
// URL substring match
|
|
1329
|
-
const targets = (await conn.cdp.send('Target.getTargets'));
|
|
1330
|
-
const matches = [];
|
|
1331
|
-
for (const [shortId, cdpId] of Object.entries(task.tabs)) {
|
|
1332
|
-
const target = targets.targetInfos.find((t) => t.targetId === cdpId);
|
|
1333
|
-
if (target && target.url.includes(hint)) {
|
|
1334
|
-
matches.push(shortId);
|
|
1335
|
-
}
|
|
1336
|
-
}
|
|
1337
|
-
if (matches.length === 1)
|
|
1338
|
-
return matches[0];
|
|
1339
|
-
if (matches.length > 1) {
|
|
1340
|
-
throw new Error(`Ambiguous tab hint "${hint}" — matches ${matches.length} tabs by URL`);
|
|
1341
|
-
}
|
|
1342
|
-
throw new Error(`Tab "${hint}" not found`);
|
|
1343
|
-
}
|
|
1344
|
-
resolveCurrentTab(task) {
|
|
1345
|
-
const tabIds = Object.keys(task.tabs);
|
|
1346
|
-
const id = task.currentTabId ?? tabIds[tabIds.length - 1];
|
|
1347
|
-
if (!id)
|
|
1348
|
-
throw new Error('No tabs open for this task');
|
|
1349
|
-
return id;
|
|
1350
|
-
}
|
|
1351
|
-
getCdpTargetId(task, shortId) {
|
|
1352
|
-
const cdpId = task.tabs[shortId];
|
|
1353
|
-
if (!cdpId)
|
|
1354
|
-
throw new Error(`Tab ${shortId} not found`);
|
|
1355
|
-
return cdpId;
|
|
1356
|
-
}
|
|
1357
|
-
async tabs(taskId, profileRef) {
|
|
1358
|
-
if (taskId) {
|
|
1359
|
-
const { conn, task } = await this.findTask(taskId, profileRef);
|
|
1360
|
-
if (conn.backend === 'arc-native')
|
|
1361
|
-
return this.listArcTaskTabs(task);
|
|
1362
|
-
if (conn.backend === 'bidi')
|
|
1363
|
-
return this.listFirefoxTaskTabs(conn, task);
|
|
1364
|
-
return this.getTabsForTask(conn.cdp, task);
|
|
1365
|
-
}
|
|
1366
|
-
const allTabs = [];
|
|
1367
|
-
for (const [, conn] of this.connections) {
|
|
1368
|
-
for (const [, task] of conn.tasks) {
|
|
1369
|
-
const tabs = conn.backend === 'arc-native'
|
|
1370
|
-
? await this.listArcTaskTabs(task)
|
|
1371
|
-
: conn.backend === 'bidi'
|
|
1372
|
-
? await this.listFirefoxTaskTabs(conn, task)
|
|
1373
|
-
: await this.getTabsForTask(conn.cdp, task);
|
|
1374
|
-
allTabs.push(...tabs);
|
|
1375
|
-
}
|
|
1376
|
-
}
|
|
1377
|
-
return allTabs;
|
|
1378
|
-
}
|
|
1379
|
-
async tabClose(taskId, tabHint) {
|
|
1380
|
-
const { conn, task } = await this.findTask(taskId);
|
|
1381
|
-
if (conn.backend === 'arc-native') {
|
|
1382
|
-
await this.runExclusive('arc-native:host-app', async () => {
|
|
1383
|
-
await this.reconcileArcCreateIntents(conn, task);
|
|
1384
|
-
const native = this.requireArcTask(task);
|
|
1385
|
-
const ids = tabHint === undefined
|
|
1386
|
-
? Object.keys(native.tabs)
|
|
1387
|
-
: [await this.resolveTabHint(conn, task, tabHint)];
|
|
1388
|
-
for (const shortId of ids) {
|
|
1389
|
-
const ref = native.tabs[shortId];
|
|
1390
|
-
if (ref)
|
|
1391
|
-
await closeArcTab(ref);
|
|
1392
|
-
delete native.tabs[shortId];
|
|
1393
|
-
delete task.tabs[shortId];
|
|
1394
|
-
delete task.refDescriptors?.[shortId];
|
|
1395
|
-
}
|
|
1396
|
-
if (!task.currentTabId || ids.includes(task.currentTabId)) {
|
|
1397
|
-
const remaining = Object.keys(native.tabs);
|
|
1398
|
-
task.currentTabId = remaining.at(-1);
|
|
1399
|
-
}
|
|
1400
|
-
await this.saveTaskState(task.profile, conn.tasks);
|
|
1401
|
-
});
|
|
1402
|
-
return;
|
|
1403
|
-
}
|
|
1404
|
-
if (conn.backend === 'bidi') {
|
|
1405
|
-
const borrowed = new Set(task.borrowedTabs ?? []);
|
|
1406
|
-
const ids = tabHint === undefined
|
|
1407
|
-
? Object.keys(task.tabs)
|
|
1408
|
-
: [await this.resolveTabHint(conn, task, tabHint)];
|
|
1409
|
-
for (const shortId of ids) {
|
|
1410
|
-
const context = task.tabs[shortId];
|
|
1411
|
-
// A borrowed tab is released, not closed — it outlives this task.
|
|
1412
|
-
if (context && !borrowed.has(shortId)) {
|
|
1413
|
-
try {
|
|
1414
|
-
await bidiCloseTab(conn.bidi, context);
|
|
1415
|
-
}
|
|
1416
|
-
catch { /* already closed */ }
|
|
1417
|
-
}
|
|
1418
|
-
delete task.tabs[shortId];
|
|
1419
|
-
delete task.refDescriptors?.[shortId];
|
|
1420
|
-
task.borrowedTabs = (task.borrowedTabs ?? []).filter((id) => id !== shortId);
|
|
1421
|
-
}
|
|
1422
|
-
if (!task.currentTabId || ids.includes(task.currentTabId)) {
|
|
1423
|
-
task.currentTabId = Object.keys(task.tabs).at(-1);
|
|
1424
|
-
}
|
|
1425
|
-
await this.saveTaskState(task.profile, conn.tasks);
|
|
1426
|
-
return;
|
|
1427
|
-
}
|
|
1428
|
-
requireCdp(conn, 'closeTab');
|
|
1429
|
-
if (tabHint !== undefined) {
|
|
1430
|
-
const shortId = await this.resolveTabHint(conn, task, tabHint);
|
|
1431
|
-
const cdpId = task.tabs[shortId];
|
|
1432
|
-
if (cdpId) {
|
|
1433
|
-
// A borrowed tab is released, not closed — it outlives this task.
|
|
1434
|
-
if (!(task.borrowedTabs ?? []).includes(shortId)) {
|
|
1435
|
-
await conn.cdp.send('Target.closeTarget', { targetId: cdpId });
|
|
1436
|
-
}
|
|
1437
|
-
conn.sessionCache.delete(cdpId);
|
|
1438
|
-
delete task.tabs[shortId];
|
|
1439
|
-
// Drop the borrow record with the tab it names, or it outlives every tab
|
|
1440
|
-
// it ever described and grows unbounded in the persisted task state.
|
|
1441
|
-
task.borrowedTabs = (task.borrowedTabs ?? []).filter((id) => id !== shortId);
|
|
1442
|
-
// Update currentTabId if we closed the current tab
|
|
1443
|
-
if (task.currentTabId === shortId) {
|
|
1444
|
-
const remaining = Object.keys(task.tabs);
|
|
1445
|
-
task.currentTabId = remaining.length > 0 ? remaining[remaining.length - 1] : undefined;
|
|
1446
|
-
}
|
|
1447
|
-
}
|
|
1448
|
-
}
|
|
1449
|
-
else {
|
|
1450
|
-
// Close all tabs the task opened; borrowed ones are only released.
|
|
1451
|
-
await Promise.all(this.closeableTargetIds(task).map((cdpId) => conn.cdp.send('Target.closeTarget', { targetId: cdpId }).catch(() => { })));
|
|
1452
|
-
for (const cdpId of Object.values(task.tabs)) {
|
|
1453
|
-
conn.sessionCache.delete(cdpId);
|
|
1454
|
-
}
|
|
1455
|
-
task.tabs = {};
|
|
1456
|
-
task.borrowedTabs = [];
|
|
1457
|
-
task.currentTabId = undefined;
|
|
1458
|
-
}
|
|
1459
|
-
this.invalidateTargetCache(conn);
|
|
1460
|
-
await this.saveTaskState(task.profile, conn.tasks);
|
|
1461
|
-
}
|
|
1462
|
-
async evaluate(taskId, tabHint, expression) {
|
|
1463
|
-
const { conn, task } = await this.findTask(taskId);
|
|
1464
|
-
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
1465
|
-
// Native Arc backend (PHNX-2399): synchronous JS only, isolated world.
|
|
1466
|
-
if (conn.backend === 'arc-native') {
|
|
1467
|
-
return this.evaluateArcNative(conn, task, shortId, expression);
|
|
1468
|
-
}
|
|
1469
|
-
// Firefox BiDi backend (PHNX-4043): async-capable, isolated realm.
|
|
1470
|
-
if (conn.backend === 'bidi') {
|
|
1471
|
-
return this.evaluateFirefox(conn, task, shortId, expression);
|
|
1472
|
-
}
|
|
1473
|
-
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
1474
|
-
const target = await this.getTarget(conn, cdpTargetId);
|
|
1475
|
-
if (!target) {
|
|
1476
|
-
throw new Error(`Tab ${shortId} not found`);
|
|
1477
|
-
}
|
|
1478
|
-
const sessionId = await this.getSessionId(conn, target.targetId);
|
|
1479
|
-
// `awaitPromise: true` lets callers write `evaluate '(async () => {...})()'`
|
|
1480
|
-
// and get the resolved value back instead of a stringified Promise. This
|
|
1481
|
-
// is essential for any flow that needs sub-step waits inside the page
|
|
1482
|
-
// (e.g. driving a multi-step modal where each step needs React to settle
|
|
1483
|
-
// before the next call). Without it, the shell-side workaround is to
|
|
1484
|
-
// chain N separate `evaluate` calls with `sleep` between them, which
|
|
1485
|
-
// races against the page's own state machine.
|
|
1486
|
-
//
|
|
1487
|
-
// `exceptionDetails` is surfaced as a thrown error so a rejected promise
|
|
1488
|
-
// or a thrown error inside the expression doesn't silently return `undefined`.
|
|
1489
|
-
const result = (await conn.cdp.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }, sessionId));
|
|
1490
|
-
if (result.exceptionDetails) {
|
|
1491
|
-
const ex = result.exceptionDetails;
|
|
1492
|
-
const msg = ex.exception?.description ??
|
|
1493
|
-
(typeof ex.exception?.value === 'string' ? ex.exception.value : undefined) ??
|
|
1494
|
-
ex.text ??
|
|
1495
|
-
'evaluate failed';
|
|
1496
|
-
throw new Error(msg);
|
|
1497
|
-
}
|
|
1498
|
-
return result.result.value;
|
|
1499
|
-
}
|
|
1500
|
-
async screenshot(taskId, tabHint, outputPath, quality = 'compressed') {
|
|
1501
|
-
const { conn, task, key: runtimeKey } = await this.findTask(taskId);
|
|
1502
|
-
// Native Arc backend: screenshots cannot select/activate the user's tab.
|
|
1503
|
-
if (conn.backend === 'arc-native') {
|
|
1504
|
-
throw new ArcNativeCapabilityError('screenshot', 'Screenshot is unavailable for native Arc automation. ' +
|
|
1505
|
-
'Screenshots require activating the tab, which would disrupt user selection. ' +
|
|
1506
|
-
'This task still belongs to the Arc profile. ' +
|
|
1507
|
-
'Use a Chromium-family browser for screenshot capability.');
|
|
1508
|
-
}
|
|
1509
|
-
// Firefox BiDi backend (PHNX-4043): captureScreenshot on the context.
|
|
1510
|
-
if (conn.backend === 'bidi') {
|
|
1511
|
-
return this.screenshotFirefox(conn, task, runtimeKey, tabHint, outputPath, quality);
|
|
1512
|
-
}
|
|
1513
|
-
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
1514
|
-
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
1515
|
-
const target = await this.getTarget(conn, cdpTargetId);
|
|
1516
|
-
if (!target) {
|
|
1517
|
-
throw new Error(`Tab ${shortId} not found`);
|
|
1518
|
-
}
|
|
1519
|
-
const sessionId = await this.getSessionId(conn, target.targetId);
|
|
1520
|
-
let buffer;
|
|
1521
|
-
let extension;
|
|
1522
|
-
if (quality === 'raw') {
|
|
1523
|
-
// Pixel-faithful PNG, no downscale. For archived QA evidence where
|
|
1524
|
-
// lossy JPEG would hide rendering bugs. Files run 0.5–3 MB.
|
|
1525
|
-
const { data } = (await conn.cdp.send('Page.captureScreenshot', { format: 'png' }, sessionId));
|
|
1526
|
-
buffer = Buffer.from(data, 'base64');
|
|
1527
|
-
extension = 'png';
|
|
1528
|
-
}
|
|
1529
|
-
else {
|
|
1530
|
-
// Default: JPEG quality 70, then iteratively downscale to keep the
|
|
1531
|
-
// file under 100 KB so chat-injected screenshots stay token-cheap.
|
|
1532
|
-
const { data } = (await conn.cdp.send('Page.captureScreenshot', { format: 'jpeg', quality: 70 }, sessionId));
|
|
1533
|
-
buffer = Buffer.from(data, 'base64');
|
|
1534
|
-
const MAX_SIZE = 100 * 1024;
|
|
1535
|
-
if (buffer.length > MAX_SIZE) {
|
|
1536
|
-
let q = 50;
|
|
1537
|
-
while (buffer.length > MAX_SIZE && q > 10) {
|
|
1538
|
-
const { data: resized } = (await conn.cdp.send('Page.captureScreenshot', { format: 'jpeg', quality: q }, sessionId));
|
|
1539
|
-
buffer = Buffer.from(resized, 'base64');
|
|
1540
|
-
q -= 10;
|
|
1541
|
-
}
|
|
1542
|
-
}
|
|
1543
|
-
extension = 'jpg';
|
|
1544
|
-
}
|
|
1545
|
-
const sessionsDir = getProfileSessionsDir(runtimeKey, task.name);
|
|
1546
|
-
const automaticPath = path.join(sessionsDir, `${Date.now()}.${extension}`);
|
|
1547
|
-
const finalPath = resolveScreenshotOutputPath(outputPath, automaticPath);
|
|
1548
|
-
await fs.promises.mkdir(path.dirname(finalPath), { recursive: true });
|
|
1549
|
-
await fs.promises.writeFile(finalPath, buffer);
|
|
1550
|
-
const dims = (extension === 'png' ? readPngDimensions(buffer) : readJpegDimensions(buffer)) ??
|
|
1551
|
-
{ width: 0, height: 0 };
|
|
1552
|
-
emit('browser.screenshot', {
|
|
1553
|
-
profile: parseConnectionKey(runtimeKey).profile,
|
|
1554
|
-
task: task.name,
|
|
1555
|
-
tabId: shortId,
|
|
1556
|
-
path: finalPath,
|
|
1557
|
-
bytes: buffer.length,
|
|
1558
|
-
width: dims.width,
|
|
1559
|
-
height: dims.height,
|
|
1560
|
-
quality,
|
|
1561
|
-
});
|
|
1562
|
-
return { path: finalPath, bytes: buffer.length, width: dims.width, height: dims.height };
|
|
1563
|
-
}
|
|
1564
|
-
/**
|
|
1565
|
-
* Export the current tab as a PDF via CDP `Page.printToPDF`. Reuses the
|
|
1566
|
-
* screenshot session + tab resolution so `--tab`, path sandboxing, and the
|
|
1567
|
-
* auto-path (`sessions/<task>/<ts>.pdf`) all behave identically to
|
|
1568
|
-
* `screenshot`. `printBackground: true` matches Chrome's default print flow
|
|
1569
|
-
* — without it, dark-mode pages render on a blank sheet.
|
|
1570
|
-
*/
|
|
1571
|
-
async printToPdf(taskId, tabHint, outputPath) {
|
|
1572
|
-
const { conn, task, key: runtimeKey } = await this.findTask(taskId);
|
|
1573
|
-
requireCdp(conn, 'pdf');
|
|
1574
|
-
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
1575
|
-
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
1576
|
-
const target = await this.getTarget(conn, cdpTargetId);
|
|
1577
|
-
if (!target) {
|
|
1578
|
-
throw new Error(`Tab ${shortId} not found`);
|
|
1579
|
-
}
|
|
1580
|
-
const sessionId = await this.getSessionId(conn, target.targetId);
|
|
1581
|
-
const { data } = (await conn.cdp.send('Page.printToPDF', { printBackground: true, preferCSSPageSize: true }, sessionId));
|
|
1582
|
-
const buffer = Buffer.from(data, 'base64');
|
|
1583
|
-
const sessionsDir = getProfileSessionsDir(runtimeKey, task.name);
|
|
1584
|
-
const automaticPath = path.join(sessionsDir, `${Date.now()}.pdf`);
|
|
1585
|
-
const finalPath = resolveScreenshotOutputPath(outputPath, automaticPath);
|
|
1586
|
-
await fs.promises.mkdir(path.dirname(finalPath), { recursive: true });
|
|
1587
|
-
await fs.promises.writeFile(finalPath, buffer);
|
|
1588
|
-
return { path: finalPath, bytes: buffer.length };
|
|
1589
|
-
}
|
|
1590
|
-
// ─── Recording ──────────────────────────────────────────────────────────────
|
|
1591
|
-
//
|
|
1592
|
-
// CDP `Page.startScreencast` emits a JPEG frame per `everyNthFrame`. We pipe
|
|
1593
|
-
// those frames into ffmpeg's stdin (image2pipe) and encode to a webm/vp9 file
|
|
1594
|
-
// under `sessions/<task>/recordings/`. A background watcher enforces the
|
|
1595
|
-
// duration + size caps so a forgotten recording can't fill the disk.
|
|
1596
|
-
recordings = new Map();
|
|
1597
|
-
async recordStart(taskId, tabHint, opts = {}) {
|
|
1598
|
-
if (this.recordings.has(taskId)) {
|
|
1599
|
-
throw new Error(`Task "${taskId}" is already recording. Call record stop first.`);
|
|
1600
|
-
}
|
|
1601
|
-
const { conn, task, key: runtimeKey } = await this.findTask(taskId);
|
|
1602
|
-
requireCdp(conn, 'recording');
|
|
1603
|
-
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
1604
|
-
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
1605
|
-
const target = await this.getTarget(conn, cdpTargetId);
|
|
1606
|
-
if (!target)
|
|
1607
|
-
throw new Error(`Tab ${shortId} not found`);
|
|
1608
|
-
const sessionId = await this.getSessionId(conn, target.targetId);
|
|
1609
|
-
const fps = opts.fps ?? 5;
|
|
1610
|
-
const durationSec = opts.duration ?? 60;
|
|
1611
|
-
const maxMb = opts.maxMb ?? 25;
|
|
1612
|
-
if (fps < 1 || fps > 30)
|
|
1613
|
-
throw new Error('--fps must be between 1 and 30');
|
|
1614
|
-
if (durationSec < 1 || durationSec > 3600)
|
|
1615
|
-
throw new Error('--duration must be between 1 and 3600 seconds');
|
|
1616
|
-
if (maxMb < 1 || maxMb > 500)
|
|
1617
|
-
throw new Error('--max-mb must be between 1 and 500');
|
|
1618
|
-
const recordingsDir = path.join(getProfileSessionsDir(runtimeKey, task.name), 'recordings');
|
|
1619
|
-
await fs.promises.mkdir(recordingsDir, { recursive: true });
|
|
1620
|
-
const outputPath = path.join(recordingsDir, `${Date.now()}.webm`);
|
|
1621
|
-
// Resolve ffmpeg lazily so non-recording paths don't pay the import cost.
|
|
1622
|
-
const [{ spawn }, ffmpegPath] = await Promise.all([
|
|
1623
|
-
import('child_process'),
|
|
1624
|
-
resolveFfmpeg(),
|
|
1625
|
-
]);
|
|
1626
|
-
// The frame pump below clocks the latest CDP paint into stdin at exactly
|
|
1627
|
-
// `fps`, including repeated frames while the page is static. Declare that
|
|
1628
|
-
// cadence to image2pipe so frame count / fps is the real capture duration.
|
|
1629
|
-
const ffmpeg = spawn(ffmpegPath, [
|
|
1630
|
-
'-loglevel', 'error',
|
|
1631
|
-
'-f', 'image2pipe',
|
|
1632
|
-
'-framerate', String(fps),
|
|
1633
|
-
'-i', '-',
|
|
1634
|
-
'-c:v', 'libvpx-vp9',
|
|
1635
|
-
'-b:v', '1M',
|
|
1636
|
-
'-pix_fmt', 'yuv420p',
|
|
1637
|
-
'-y',
|
|
1638
|
-
outputPath,
|
|
1639
|
-
], { stdio: ['pipe', 'ignore', 'pipe'], windowsHide: true });
|
|
1640
|
-
// Wait for the spawn to confirm before wiring CDP frames into a dead pipe.
|
|
1641
|
-
await new Promise((resolve, reject) => {
|
|
1642
|
-
const onError = (err) => {
|
|
1643
|
-
ffmpeg.off('spawn', onSpawn);
|
|
1644
|
-
reject(err);
|
|
1645
|
-
};
|
|
1646
|
-
const onSpawn = () => {
|
|
1647
|
-
ffmpeg.off('error', onError);
|
|
1648
|
-
resolve();
|
|
1649
|
-
};
|
|
1650
|
-
ffmpeg.once('error', onError);
|
|
1651
|
-
ffmpeg.once('spawn', onSpawn);
|
|
1652
|
-
});
|
|
1653
|
-
// Capture ffmpeg's own diagnostics (encoder error, bad codec, etc.) so a
|
|
1654
|
-
// failing encode is DIAGNOSABLE at recordStop instead of being discarded.
|
|
1655
|
-
// Cap the buffer so a chatty ffmpeg can't grow it unbounded.
|
|
1656
|
-
let stderrBuf = '';
|
|
1657
|
-
ffmpeg.stderr?.on('data', (d) => {
|
|
1658
|
-
stderrBuf += d.toString();
|
|
1659
|
-
if (stderrBuf.length > 64 * 1024)
|
|
1660
|
-
stderrBuf = stderrBuf.slice(-64 * 1024);
|
|
1661
|
-
});
|
|
1662
|
-
const ffmpegStderr = () => stderrBuf;
|
|
1663
|
-
ffmpeg.on('error', () => { });
|
|
1664
|
-
let frameCount = 0;
|
|
1665
|
-
let latestFrame;
|
|
1666
|
-
let stdinBlocked = false;
|
|
1667
|
-
let stdinError;
|
|
1668
|
-
ffmpeg.stdin?.on('drain', () => { stdinBlocked = false; });
|
|
1669
|
-
ffmpeg.stdin?.on('error', (error) => { stdinError = error; });
|
|
1670
|
-
const writeFrame = (frame) => {
|
|
1671
|
-
const stdin = ffmpeg.stdin;
|
|
1672
|
-
if (!stdin || stdin.destroyed || stdinError || stdinBlocked)
|
|
1673
|
-
return false;
|
|
1674
|
-
stdinBlocked = !stdin.write(frame);
|
|
1675
|
-
frameCount += 1;
|
|
1676
|
-
return true;
|
|
1677
|
-
};
|
|
1678
|
-
const frameIntervalMs = 1000 / fps;
|
|
1679
|
-
let resolveFirstFrame;
|
|
1680
|
-
const firstFrame = new Promise((resolve) => { resolveFirstFrame = resolve; });
|
|
1681
|
-
const frameHandler = (params, eventSessionId) => {
|
|
1682
|
-
if (eventSessionId !== sessionId)
|
|
1683
|
-
return;
|
|
1684
|
-
const p = params;
|
|
1685
|
-
try {
|
|
1686
|
-
latestFrame = Buffer.from(p.data, 'base64');
|
|
1687
|
-
if (frameCount === 0) {
|
|
1688
|
-
if (writeFrame(latestFrame))
|
|
1689
|
-
resolveFirstFrame();
|
|
1690
|
-
}
|
|
1691
|
-
}
|
|
1692
|
-
catch {
|
|
1693
|
-
// ffmpeg exited; ignore writes
|
|
1694
|
-
}
|
|
1695
|
-
// Must ack every frame or CDP stops sending.
|
|
1696
|
-
conn.cdp.send('Page.screencastFrameAck', { sessionId: p.sessionId }, sessionId).catch(() => { });
|
|
1697
|
-
};
|
|
1698
|
-
// CDP emits on paints, so a static page can legitimately produce only one
|
|
1699
|
-
// event in 15 seconds. Retain the latest real frame and clock it into the
|
|
1700
|
-
// encoder at --fps; new paints replace it, while quiet time still advances
|
|
1701
|
-
// the WebM timeline instead of collapsing to a one-frame, 0.04s artifact.
|
|
1702
|
-
const framePump = setInterval(() => {
|
|
1703
|
-
if (!latestFrame)
|
|
1704
|
-
return;
|
|
1705
|
-
try {
|
|
1706
|
-
writeFrame(latestFrame);
|
|
1707
|
-
}
|
|
1708
|
-
catch {
|
|
1709
|
-
// ffmpeg exited; recordStop reports its exit code and stderr.
|
|
1710
|
-
}
|
|
1711
|
-
}, frameIntervalMs);
|
|
1712
|
-
// Register BEFORE startScreencast. Chrome may emit its first frame before
|
|
1713
|
-
// the command response; missing that frame also misses its ack and stalls
|
|
1714
|
-
// the entire stream with ffmpeg's stdin still empty (PHNX-2600).
|
|
1715
|
-
conn.cdp.on('Page.screencastFrame', frameHandler);
|
|
1716
|
-
try {
|
|
1717
|
-
await conn.cdp.send('Page.enable', {}, sessionId);
|
|
1718
|
-
await conn.cdp.send('Page.startScreencast', {
|
|
1719
|
-
format: 'jpeg',
|
|
1720
|
-
quality: 60,
|
|
1721
|
-
// Chrome's frames are paint-driven, not a 30fps clock. Asking for
|
|
1722
|
-
// every third paint can suppress the only stored frame on a static
|
|
1723
|
-
// page, so receive every paint and enforce --fps when writing stdin.
|
|
1724
|
-
everyNthFrame: 1,
|
|
1725
|
-
maxFramesInFlight: 1,
|
|
1726
|
-
// A static/low-paint page may produce no new compositor frame after
|
|
1727
|
-
// startScreencast. Ask Chrome to send its stored last frame so the
|
|
1728
|
-
// first ack can bootstrap the stream instead of leaving ffmpeg stdin
|
|
1729
|
-
// empty forever (confirmed live against Chrome 151 / PHNX-2600).
|
|
1730
|
-
sendLastFrame: true,
|
|
1731
|
-
}, sessionId);
|
|
1732
|
-
await new Promise((resolve, reject) => {
|
|
1733
|
-
const timer = setTimeout(() => reject(new Error('Chrome produced no screencast frame within 5s; recording was not started.')), 5000);
|
|
1734
|
-
firstFrame.then(() => {
|
|
1735
|
-
clearTimeout(timer);
|
|
1736
|
-
resolve();
|
|
1737
|
-
});
|
|
1738
|
-
});
|
|
1739
|
-
}
|
|
1740
|
-
catch (error) {
|
|
1741
|
-
conn.cdp.off('Page.screencastFrame', frameHandler);
|
|
1742
|
-
clearInterval(framePump);
|
|
1743
|
-
try {
|
|
1744
|
-
await conn.cdp.send('Page.stopScreencast', {}, sessionId);
|
|
1745
|
-
}
|
|
1746
|
-
catch { /* session may be gone */ }
|
|
1747
|
-
try {
|
|
1748
|
-
ffmpeg.stdin?.end();
|
|
1749
|
-
}
|
|
1750
|
-
catch { /* process may already be gone */ }
|
|
1751
|
-
await new Promise((resolve) => {
|
|
1752
|
-
if (ffmpeg.exitCode !== null)
|
|
1753
|
-
return resolve();
|
|
1754
|
-
const timer = setTimeout(() => {
|
|
1755
|
-
try {
|
|
1756
|
-
ffmpeg.kill('SIGKILL');
|
|
1757
|
-
}
|
|
1758
|
-
catch { /* already gone */ }
|
|
1759
|
-
resolve();
|
|
1760
|
-
}, 1000);
|
|
1761
|
-
ffmpeg.once('exit', () => {
|
|
1762
|
-
clearTimeout(timer);
|
|
1763
|
-
resolve();
|
|
1764
|
-
});
|
|
1765
|
-
});
|
|
1766
|
-
await fs.promises.rm(outputPath, { force: true });
|
|
1767
|
-
throw error;
|
|
1768
|
-
}
|
|
1769
|
-
const durationMs = durationSec * 1000;
|
|
1770
|
-
const maxBytes = maxMb * 1024 * 1024;
|
|
1771
|
-
const state = {
|
|
1772
|
-
outputPath,
|
|
1773
|
-
startedAt: Date.now(),
|
|
1774
|
-
fps,
|
|
1775
|
-
durationMs,
|
|
1776
|
-
maxBytes,
|
|
1777
|
-
ffmpeg,
|
|
1778
|
-
ffmpegStderr,
|
|
1779
|
-
encoderError: () => stdinError,
|
|
1780
|
-
get frameCount() { return frameCount; },
|
|
1781
|
-
sessionId,
|
|
1782
|
-
conn,
|
|
1783
|
-
frameHandler,
|
|
1784
|
-
framePump,
|
|
1785
|
-
durationTimer: setTimeout(() => {
|
|
1786
|
-
this.recordStop(taskId, 'duration-cap').catch(() => { });
|
|
1787
|
-
}, durationMs),
|
|
1788
|
-
sizeCheckInterval: setInterval(async () => {
|
|
1789
|
-
try {
|
|
1790
|
-
const st = await fs.promises.stat(outputPath);
|
|
1791
|
-
if (st.size >= maxBytes) {
|
|
1792
|
-
await this.recordStop(taskId, 'size-cap');
|
|
1793
|
-
}
|
|
1794
|
-
}
|
|
1795
|
-
catch {
|
|
1796
|
-
// File may not exist yet
|
|
1797
|
-
}
|
|
1798
|
-
}, 1000),
|
|
1799
|
-
};
|
|
1800
|
-
this.recordings.set(taskId, state);
|
|
1801
|
-
return { path: outputPath, fps, durationCapSec: durationSec, maxMb };
|
|
1802
|
-
}
|
|
1803
|
-
async recordStop(taskId, reason = 'manual') {
|
|
1804
|
-
const activeTask = await this.findTask(taskId).catch(() => undefined);
|
|
1805
|
-
if (activeTask)
|
|
1806
|
-
requireCdp(activeTask.conn, 'recording');
|
|
1807
|
-
const rec = this.recordings.get(taskId);
|
|
1808
|
-
if (!rec) {
|
|
1809
|
-
throw new Error(`Task "${taskId}" is not currently recording`);
|
|
1810
|
-
}
|
|
1811
|
-
if (rec.stopReason) {
|
|
1812
|
-
// Already stopping (e.g. size-cap fired while user also called stop).
|
|
1813
|
-
// Wait for in-flight finalize.
|
|
1814
|
-
while (this.recordings.has(taskId)) {
|
|
1815
|
-
await new Promise((r) => setTimeout(r, 25));
|
|
1816
|
-
}
|
|
1817
|
-
}
|
|
1818
|
-
rec.stopReason = reason;
|
|
1819
|
-
clearTimeout(rec.durationTimer);
|
|
1820
|
-
clearInterval(rec.sizeCheckInterval);
|
|
1821
|
-
clearInterval(rec.framePump);
|
|
1822
|
-
rec.conn.cdp.off('Page.screencastFrame', rec.frameHandler);
|
|
1823
|
-
try {
|
|
1824
|
-
await rec.conn.cdp.send('Page.stopScreencast', {}, rec.sessionId);
|
|
1825
|
-
}
|
|
1826
|
-
catch {
|
|
1827
|
-
// session may already be gone
|
|
1828
|
-
}
|
|
1829
|
-
// Close ffmpeg stdin so it flushes the output file cleanly, and observe how
|
|
1830
|
-
// it exits. A non-zero exit means the encode failed — the recording must
|
|
1831
|
-
// NOT be reported as success. If ffmpeg hangs, KILL it (don't just abandon
|
|
1832
|
-
// the promise, which leaked the process) and treat the recording as failed.
|
|
1833
|
-
const finalize = await new Promise((resolve) => {
|
|
1834
|
-
let done = false;
|
|
1835
|
-
const settle = (r) => {
|
|
1836
|
-
if (done)
|
|
1837
|
-
return;
|
|
1838
|
-
done = true;
|
|
1839
|
-
clearTimeout(timer);
|
|
1840
|
-
resolve(r);
|
|
1841
|
-
};
|
|
1842
|
-
const timer = setTimeout(() => {
|
|
1843
|
-
try {
|
|
1844
|
-
rec.ffmpeg.kill('SIGKILL');
|
|
1845
|
-
}
|
|
1846
|
-
catch { /* already gone */ }
|
|
1847
|
-
settle({ code: null, timedOut: true });
|
|
1848
|
-
}, 5000);
|
|
1849
|
-
rec.ffmpeg.once('exit', (code) => settle({ code, timedOut: false }));
|
|
1850
|
-
try {
|
|
1851
|
-
rec.ffmpeg.stdin?.end();
|
|
1852
|
-
}
|
|
1853
|
-
catch {
|
|
1854
|
-
// stdin already closed; wait for exit or the hard timeout above.
|
|
1855
|
-
}
|
|
1856
|
-
});
|
|
1857
|
-
const durationMs = Date.now() - rec.startedAt;
|
|
1858
|
-
// Drop the recording from the map before any throw, so a failed stop
|
|
1859
|
-
// doesn't wedge the task in a permanent "already recording" state.
|
|
1860
|
-
this.recordings.delete(taskId);
|
|
1861
|
-
if (finalize.timedOut) {
|
|
1862
|
-
throw new Error(`ffmpeg did not exit within 5s while finalizing the recording; killed it. ` +
|
|
1863
|
-
`The recording at ${rec.outputPath} is incomplete.`);
|
|
1864
|
-
}
|
|
1865
|
-
if (finalize.code !== 0) {
|
|
1866
|
-
const err = rec.ffmpegStderr().trim();
|
|
1867
|
-
throw new Error(`ffmpeg exited abnormally (code ${finalize.code}) while finalizing the recording at ` +
|
|
1868
|
-
`${rec.outputPath}; the file is likely corrupt or empty.` +
|
|
1869
|
-
(err ? ` ffmpeg: ${err.slice(-800)}` : ''));
|
|
1870
|
-
}
|
|
1871
|
-
const encoderError = rec.encoderError?.();
|
|
1872
|
-
if (encoderError) {
|
|
1873
|
-
throw new Error(`ffmpeg stopped accepting recording frames at ${rec.outputPath}: ${encoderError.message}`);
|
|
1874
|
-
}
|
|
1875
|
-
if (rec.frameCount === 0) {
|
|
1876
|
-
throw new Error(`Chrome delivered no frames for the recording at ${rec.outputPath}; the output is empty.`);
|
|
1877
|
-
}
|
|
1878
|
-
let bytes = 0;
|
|
1879
|
-
try {
|
|
1880
|
-
const st = await fs.promises.stat(rec.outputPath);
|
|
1881
|
-
bytes = st.size;
|
|
1882
|
-
}
|
|
1883
|
-
catch {
|
|
1884
|
-
// ffmpeg exited 0 but the file is missing — still a failed recording.
|
|
1885
|
-
}
|
|
1886
|
-
return { path: rec.outputPath, bytes, durationMs, reason };
|
|
1887
|
-
}
|
|
1888
|
-
async recordStatus(taskId) {
|
|
1889
|
-
// The idle reaper calls this while deciding whether a task is stale. Do
|
|
1890
|
-
// not route an already-live task through findTask(), which stamps it as
|
|
1891
|
-
// active and defeats idle detection. A cold daemon may still rehydrate for
|
|
1892
|
-
// an explicit user query.
|
|
1893
|
-
// A status query, not an action: a backend that cannot record (Arc native,
|
|
1894
|
-
// Firefox BiDi) is truthfully "not recording". Throwing here took the
|
|
1895
|
-
// idle reaper down with it — one Firefox task parked reaping for every task.
|
|
1896
|
-
const live = this.findTaskByHandle(taskId) ?? await this.findTask(taskId).catch(() => undefined);
|
|
1897
|
-
if (live && (live.conn.backend === 'arc-native' || live.conn.backend === 'bidi'))
|
|
1898
|
-
return { recording: false };
|
|
1899
|
-
const rec = this.recordings.get(taskId);
|
|
1900
|
-
if (!rec)
|
|
1901
|
-
return { recording: false };
|
|
1902
|
-
return { recording: true, path: rec.outputPath, elapsedMs: Date.now() - rec.startedAt };
|
|
1903
|
-
}
|
|
1904
|
-
async refs(taskId, tabHint, opts = {}) {
|
|
1905
|
-
const { conn, task } = await this.findTask(taskId);
|
|
1906
|
-
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
1907
|
-
if (conn.backend === 'arc-native') {
|
|
1908
|
-
const result = parseArcRefsResult(await this.evaluateArcNative(conn, task, shortId, arcRefsExpression(opts)), opts);
|
|
1909
|
-
this.cacheRefDescriptors(task, shortId, result.nodeMap, result.opts);
|
|
1910
|
-
await this.saveTaskState(task.profile, conn.tasks);
|
|
1911
|
-
return result;
|
|
1912
|
-
}
|
|
1913
|
-
// Firefox BiDi (PHNX-4043): the same DOM-derived accessibility listing Arc
|
|
1914
|
-
// uses, evaluated over BiDi. selector-based refs, no CDP accessibility tree.
|
|
1915
|
-
if (conn.backend === 'bidi') {
|
|
1916
|
-
const result = parseArcRefsResult(await this.evaluateFirefox(conn, task, shortId, arcRefsExpression(opts)), opts);
|
|
1917
|
-
this.cacheRefDescriptors(task, shortId, result.nodeMap, result.opts);
|
|
1918
|
-
await this.saveTaskState(task.profile, conn.tasks);
|
|
1919
|
-
return result;
|
|
1920
|
-
}
|
|
1921
|
-
requireCdp(conn, 'enumerate');
|
|
1922
|
-
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
1923
|
-
const target = await this.getTarget(conn, cdpTargetId);
|
|
1924
|
-
if (!target)
|
|
1925
|
-
throw new Error(`Tab ${shortId} not found`);
|
|
1926
|
-
const sessionId = await this.getSessionId(conn, target.targetId);
|
|
1927
|
-
const result = await getRefs(conn.cdp, sessionId, opts);
|
|
1928
|
-
// Snapshot the stable descriptors AND the opts they were numbered against
|
|
1929
|
-
// so a later click/type can self-heal a drifted ref by rebuilding with the
|
|
1930
|
-
// same filter. `refs()` is the sole owner of this cache — actions read it,
|
|
1931
|
-
// never overwrite it. Persist to tasks.json so it survives a daemon
|
|
1932
|
-
// restart.
|
|
1933
|
-
this.cacheRefDescriptors(task, shortId, result.nodeMap, result.opts);
|
|
1934
|
-
await this.saveTaskState(task.profile, conn.tasks);
|
|
1935
|
-
return { refs: result.refs, nodeMap: result.nodeMap };
|
|
1936
|
-
}
|
|
1937
|
-
/** Record the last ref listing (descriptors + opts) for a tab into state. */
|
|
1938
|
-
cacheRefDescriptors(task, shortId, nodeMap, opts) {
|
|
1939
|
-
if (!task.refDescriptors)
|
|
1940
|
-
task.refDescriptors = {};
|
|
1941
|
-
task.refDescriptors[shortId] = { descriptors: describeRefs(nodeMap), opts };
|
|
1942
|
-
}
|
|
1943
|
-
/**
|
|
1944
|
-
* Re-resolve a caller-supplied ref against a freshly-built node map, healing
|
|
1945
|
-
* it back to the right element when the integer ref has drifted since the
|
|
1946
|
-
* cached `refs` listing. Shared by `click` and `type` so both self-heal
|
|
1947
|
-
* identically. Returns the ref to act on plus, when a heal occurred, the
|
|
1948
|
-
* {@link HealInfo} to surface. Throws when the cached element is gone.
|
|
1949
|
-
*/
|
|
1950
|
-
resolveHealedRef(snapshot, nodeMap, ref) {
|
|
1951
|
-
const cached = snapshot?.descriptors.find((d) => d.ref === ref);
|
|
1952
|
-
if (!cached)
|
|
1953
|
-
return { targetRef: ref };
|
|
1954
|
-
const fresh = nodeMap.get(ref);
|
|
1955
|
-
const stillMatches = fresh !== undefined &&
|
|
1956
|
-
fresh.role === cached.role &&
|
|
1957
|
-
fresh.name === cached.name &&
|
|
1958
|
-
(fresh.backendNodeId !== undefined || fresh.selector !== undefined);
|
|
1959
|
-
if (stillMatches)
|
|
1960
|
-
return { targetRef: ref };
|
|
1961
|
-
const newRef = healRef(cached, nodeMap);
|
|
1962
|
-
if (newRef === null) {
|
|
1963
|
-
throw new Error(`Ref ${ref} (${cached.role} "${cached.name}") could not be re-resolved: ` +
|
|
1964
|
-
`no matching element on the current page. Re-run 'browser refs' to ` +
|
|
1965
|
-
`refresh the ref numbers, or act by position with 'browser click --at X,Y'.`);
|
|
1966
|
-
}
|
|
1967
|
-
if (newRef === ref)
|
|
1968
|
-
return { targetRef: ref };
|
|
1969
|
-
console.error(`[browser] self-healed ref ${ref} -> ${newRef} (${cached.role} "${cached.name}") — ` +
|
|
1970
|
-
`cached descriptor re-matched after the ref drifted`);
|
|
1971
|
-
return { targetRef: newRef, healed: { from: ref, to: newRef, role: cached.role, name: cached.name } };
|
|
1972
|
-
}
|
|
1973
|
-
async click(taskId, ref, tabHint) {
|
|
1974
|
-
const { conn, task } = await this.findTask(taskId);
|
|
1975
|
-
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
1976
|
-
if (conn.backend === 'arc-native') {
|
|
1977
|
-
const snapshot = task.refDescriptors?.[shortId];
|
|
1978
|
-
const buildOpts = snapshot?.opts ?? { interactive: true, limit: 500 };
|
|
1979
|
-
const { nodeMap } = parseArcRefsResult(await this.evaluateArcNative(conn, task, shortId, arcRefsExpression(buildOpts)), buildOpts);
|
|
1980
|
-
const { targetRef, healed } = this.resolveHealedRef(snapshot, nodeMap, ref);
|
|
1981
|
-
const selector = nodeMap.get(targetRef)?.selector;
|
|
1982
|
-
if (!selector)
|
|
1983
|
-
throw new Error(`Ref ${ref} has no DOM selector`);
|
|
1984
|
-
await this.evaluateArcNative(conn, task, shortId, arcClickExpression(selector));
|
|
1985
|
-
return healed ? { healed } : {};
|
|
1986
|
-
}
|
|
1987
|
-
if (conn.backend === 'bidi') {
|
|
1988
|
-
const snapshot = task.refDescriptors?.[shortId];
|
|
1989
|
-
const buildOpts = snapshot?.opts ?? { interactive: true, limit: 500 };
|
|
1990
|
-
const { nodeMap } = parseArcRefsResult(await this.evaluateFirefox(conn, task, shortId, arcRefsExpression(buildOpts)), buildOpts);
|
|
1991
|
-
const { targetRef, healed } = this.resolveHealedRef(snapshot, nodeMap, ref);
|
|
1992
|
-
const selector = nodeMap.get(targetRef)?.selector;
|
|
1993
|
-
if (!selector)
|
|
1994
|
-
throw new Error(`Ref ${ref} has no DOM selector`);
|
|
1995
|
-
const coords = await this.firefoxRefCoords(conn, task, shortId, selector);
|
|
1996
|
-
const context = this.firefoxContext(task, shortId);
|
|
1997
|
-
await bidiClickAt(conn.bidi, context, coords.x, coords.y);
|
|
1998
|
-
return healed ? { healed } : {};
|
|
1999
|
-
}
|
|
2000
|
-
requireCdp(conn, 'click');
|
|
2001
|
-
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
2002
|
-
const target = await this.getTarget(conn, cdpTargetId);
|
|
2003
|
-
if (!target)
|
|
2004
|
-
throw new Error(`Tab ${shortId} not found`);
|
|
2005
|
-
const sessionId = await this.getSessionId(conn, target.targetId);
|
|
2006
|
-
// Rebuild the node map with the SAME opts the cached listing was numbered
|
|
2007
|
-
// against, so the caller's ref lands on the element they saw in `browser
|
|
2008
|
-
// refs`. Rebuilding with a different filter (the old interactive:false)
|
|
2009
|
-
// renumbers every ref and defeats self-heal on the second click. Default
|
|
2010
|
-
// to the user-facing interactive numbering when no listing was cached yet.
|
|
2011
|
-
const snapshot = task.refDescriptors?.[shortId];
|
|
2012
|
-
const buildOpts = snapshot?.opts ?? { interactive: true, limit: 500 };
|
|
2013
|
-
const { nodeMap } = await getRefs(conn.cdp, sessionId, buildOpts);
|
|
2014
|
-
// Self-heal: the integer ref is positional and drifts on re-render. If the
|
|
2015
|
-
// fresh node at this position no longer matches the cached descriptor,
|
|
2016
|
-
// re-resolve (by attrs/proximity-tie-broken role+name) BEFORE clicking the
|
|
2017
|
-
// wrong element. The cache is owned by refs() and NOT rewritten here — the
|
|
2018
|
-
// caller's ref numbers stay anchored to the listing they came from.
|
|
2019
|
-
const { targetRef, healed } = this.resolveHealedRef(snapshot, nodeMap, ref);
|
|
2020
|
-
const { x, y } = await resolveRefToCoords(conn.cdp, sessionId, nodeMap, targetRef);
|
|
2021
|
-
await clickAtCoords(conn.cdp, sessionId, x, y);
|
|
2022
|
-
return healed ? { healed } : {};
|
|
2023
|
-
}
|
|
2024
|
-
/**
|
|
2025
|
-
* Click raw viewport coordinates, bypassing ref resolution entirely. Backs
|
|
2026
|
-
* `browser click --at X,Y` — the escape hatch when the accessibility tree
|
|
2027
|
-
* exposes no usable ref (canvas apps, custom-drawn UI) and the caller has
|
|
2028
|
-
* located the target from a screenshot.
|
|
2029
|
-
*/
|
|
2030
|
-
async clickAt(taskId, x, y, tabHint) {
|
|
2031
|
-
const { conn, task } = await this.findTask(taskId);
|
|
2032
|
-
requireCdp(conn, 'trustedInput');
|
|
2033
|
-
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
2034
|
-
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
2035
|
-
const target = await this.getTarget(conn, cdpTargetId);
|
|
2036
|
-
if (!target)
|
|
2037
|
-
throw new Error(`Tab ${shortId} not found`);
|
|
2038
|
-
const sessionId = await this.getSessionId(conn, target.targetId);
|
|
2039
|
-
await clickAtCoords(conn.cdp, sessionId, x, y);
|
|
2040
|
-
}
|
|
2041
|
-
async type(taskId, ref, text, tabHint, clear) {
|
|
2042
|
-
const { conn, task } = await this.findTask(taskId);
|
|
2043
|
-
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
2044
|
-
if (conn.backend === 'arc-native') {
|
|
2045
|
-
const snapshot = task.refDescriptors?.[shortId];
|
|
2046
|
-
const buildOpts = snapshot?.opts ?? { interactive: true, limit: 500 };
|
|
2047
|
-
const { nodeMap } = parseArcRefsResult(await this.evaluateArcNative(conn, task, shortId, arcRefsExpression(buildOpts)), buildOpts);
|
|
2048
|
-
const { targetRef } = this.resolveHealedRef(snapshot, nodeMap, ref);
|
|
2049
|
-
const selector = nodeMap.get(targetRef)?.selector;
|
|
2050
|
-
if (!selector)
|
|
2051
|
-
throw new Error(`Ref ${ref} has no DOM selector`);
|
|
2052
|
-
await this.evaluateArcNative(conn, task, shortId, arcFillExpression(selector, text, clear ?? false));
|
|
2053
|
-
return;
|
|
2054
|
-
}
|
|
2055
|
-
if (conn.backend === 'bidi') {
|
|
2056
|
-
const snapshot = task.refDescriptors?.[shortId];
|
|
2057
|
-
const buildOpts = snapshot?.opts ?? { interactive: true, limit: 500 };
|
|
2058
|
-
const { nodeMap } = parseArcRefsResult(await this.evaluateFirefox(conn, task, shortId, arcRefsExpression(buildOpts)), buildOpts);
|
|
2059
|
-
const { targetRef } = this.resolveHealedRef(snapshot, nodeMap, ref);
|
|
2060
|
-
const selector = nodeMap.get(targetRef)?.selector;
|
|
2061
|
-
if (!selector)
|
|
2062
|
-
throw new Error(`Ref ${ref} has no DOM selector`);
|
|
2063
|
-
await this.evaluateFirefox(conn, task, shortId, arcFillExpression(selector, text, clear ?? false));
|
|
2064
|
-
return;
|
|
2065
|
-
}
|
|
2066
|
-
requireCdp(conn, 'type');
|
|
2067
|
-
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
2068
|
-
const target = await this.getTarget(conn, cdpTargetId);
|
|
2069
|
-
if (!target)
|
|
2070
|
-
throw new Error(`Tab ${shortId} not found`);
|
|
2071
|
-
const sessionId = await this.getSessionId(conn, target.targetId);
|
|
2072
|
-
// Same self-healing story as click(): rebuild against the cached listing's
|
|
2073
|
-
// opts so refs line up with what the user saw, then heal a drifted ref
|
|
2074
|
-
// before typing into the wrong field.
|
|
2075
|
-
const snapshot = task.refDescriptors?.[shortId];
|
|
2076
|
-
const buildOpts = snapshot?.opts ?? { interactive: true, limit: 500 };
|
|
2077
|
-
const { nodeMap } = await getRefs(conn.cdp, sessionId, buildOpts);
|
|
2078
|
-
const { targetRef } = this.resolveHealedRef(snapshot, nodeMap, ref);
|
|
2079
|
-
const node = nodeMap.get(targetRef);
|
|
2080
|
-
if (!node)
|
|
2081
|
-
throw new Error(`Ref ${ref} not found`);
|
|
2082
|
-
if (node.editor) {
|
|
2083
|
-
await typeEditorText(conn.cdp, sessionId, node, text, clear);
|
|
2084
|
-
}
|
|
2085
|
-
else {
|
|
2086
|
-
if (node.backendNodeId) {
|
|
2087
|
-
await focusNode(conn.cdp, sessionId, node.backendNodeId);
|
|
2088
|
-
}
|
|
2089
|
-
await typeText(conn.cdp, sessionId, text);
|
|
2090
|
-
}
|
|
2091
|
-
}
|
|
2092
|
-
async press(taskId, key, tabHint) {
|
|
2093
|
-
const { conn, task } = await this.findTask(taskId);
|
|
2094
|
-
requireCdp(conn, 'trustedInput');
|
|
2095
|
-
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
2096
|
-
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
2097
|
-
const target = await this.getTarget(conn, cdpTargetId);
|
|
2098
|
-
if (!target)
|
|
2099
|
-
throw new Error(`Tab ${shortId} not found`);
|
|
2100
|
-
const sessionId = await this.getSessionId(conn, target.targetId);
|
|
2101
|
-
await pressKey(conn.cdp, sessionId, key);
|
|
2102
|
-
}
|
|
2103
|
-
async hover(taskId, ref, tabHint) {
|
|
2104
|
-
const { conn, task } = await this.findTask(taskId);
|
|
2105
|
-
requireCdp(conn, 'trustedInput');
|
|
2106
|
-
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
2107
|
-
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
2108
|
-
const target = await this.getTarget(conn, cdpTargetId);
|
|
2109
|
-
if (!target)
|
|
2110
|
-
throw new Error(`Tab ${shortId} not found`);
|
|
2111
|
-
const sessionId = await this.getSessionId(conn, target.targetId);
|
|
2112
|
-
const { nodeMap } = await getRefs(conn.cdp, sessionId, { interactive: false, limit: 1000 });
|
|
2113
|
-
const { x, y } = await resolveRefToCoords(conn.cdp, sessionId, nodeMap, ref);
|
|
2114
|
-
await hoverAtCoords(conn.cdp, sessionId, x, y);
|
|
2115
|
-
}
|
|
2116
|
-
async scroll(taskId, deltaX, deltaY, atX, atY, tabHint) {
|
|
2117
|
-
const { conn, task } = await this.findTask(taskId);
|
|
2118
|
-
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
2119
|
-
if (conn.backend === 'arc-native') {
|
|
2120
|
-
if (atX !== undefined || atY !== undefined)
|
|
2121
|
-
throw new ArcNativeCapabilityError('trustedInput');
|
|
2122
|
-
await this.evaluateArcNative(conn, task, shortId, arcScrollExpression(deltaX, deltaY));
|
|
2123
|
-
return;
|
|
2124
|
-
}
|
|
2125
|
-
if (conn.backend === 'bidi') {
|
|
2126
|
-
await this.evaluateFirefox(conn, task, shortId, arcScrollExpression(deltaX, deltaY));
|
|
2127
|
-
return;
|
|
2128
|
-
}
|
|
2129
|
-
requireCdp(conn, 'scroll');
|
|
2130
|
-
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
2131
|
-
const target = await this.getTarget(conn, cdpTargetId);
|
|
2132
|
-
if (!target)
|
|
2133
|
-
throw new Error(`Tab ${shortId} not found`);
|
|
2134
|
-
const sessionId = await this.getSessionId(conn, target.targetId);
|
|
2135
|
-
await scrollAtCoords(conn.cdp, sessionId, atX ?? 0, atY ?? 0, deltaX, deltaY);
|
|
2136
|
-
}
|
|
2137
|
-
async upload(taskId, files, options) {
|
|
2138
|
-
const { conn, task } = await this.findTask(taskId);
|
|
2139
|
-
requireCdp(conn, 'upload');
|
|
2140
|
-
const shortId = options.tabHint
|
|
2141
|
-
? await this.resolveTabHint(conn, task, options.tabHint)
|
|
2142
|
-
: this.resolveCurrentTab(task);
|
|
2143
|
-
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
2144
|
-
const target = await this.getTarget(conn, cdpTargetId);
|
|
2145
|
-
if (!target)
|
|
2146
|
-
throw new Error(`Tab ${shortId} not found`);
|
|
2147
|
-
const sessionId = await this.getSessionId(conn, target.targetId);
|
|
2148
|
-
// Match the user-facing ref numbering from `agents browser refs` (which
|
|
2149
|
-
// defaults to interactive=true). The other action helpers in this file
|
|
2150
|
-
// use interactive=false historically, but that produces ref numbers the
|
|
2151
|
-
// user never sees — `--ref 1` then resolves to the RootWebArea instead of
|
|
2152
|
-
// the first interactive element. Match the listing the user actually saw.
|
|
2153
|
-
const { nodeMap } = await getRefs(conn.cdp, sessionId, { interactive: true, limit: 1000 });
|
|
2154
|
-
const mode = options.mode ?? 'auto';
|
|
2155
|
-
if (options.trigger !== undefined || mode === 'chooser') {
|
|
2156
|
-
const ref = options.trigger ?? options.ref;
|
|
2157
|
-
if (ref === undefined) {
|
|
2158
|
-
throw new Error('chooser mode requires --trigger <ref> (or --ref) pointing at the button that opens the file dialog');
|
|
2159
|
-
}
|
|
2160
|
-
const node = nodeMap.get(ref);
|
|
2161
|
-
if (!node)
|
|
2162
|
-
throw new Error(`Ref ${ref} not found`);
|
|
2163
|
-
await uploadViaFileChooser(conn.cdp, sessionId, { node, nodeMap }, files, options.timeout);
|
|
2164
|
-
return { mode: 'chooser' };
|
|
2165
|
-
}
|
|
2166
|
-
if (options.ref === undefined) {
|
|
2167
|
-
throw new Error('upload requires --ref <n> (target element) or --trigger <n> (button that opens chooser)');
|
|
2168
|
-
}
|
|
2169
|
-
const node = nodeMap.get(options.ref);
|
|
2170
|
-
if (!node)
|
|
2171
|
-
throw new Error(`Ref ${options.ref} not found`);
|
|
2172
|
-
if (!node.backendNodeId)
|
|
2173
|
-
throw new Error(`Ref ${options.ref} has no DOM node`);
|
|
2174
|
-
let resolved;
|
|
2175
|
-
if (mode === 'input') {
|
|
2176
|
-
resolved = 'input';
|
|
2177
|
-
}
|
|
2178
|
-
else if (mode === 'drop') {
|
|
2179
|
-
resolved = 'drop';
|
|
2180
|
-
}
|
|
2181
|
-
else {
|
|
2182
|
-
resolved = await detectUploadPattern(conn.cdp, sessionId, node.backendNodeId);
|
|
2183
|
-
}
|
|
2184
|
-
if (resolved === 'input') {
|
|
2185
|
-
await uploadToFileInput(conn.cdp, sessionId, node.backendNodeId, files);
|
|
2186
|
-
}
|
|
2187
|
-
else {
|
|
2188
|
-
await uploadToDropTarget(conn.cdp, sessionId, node.backendNodeId, files);
|
|
2189
|
-
}
|
|
2190
|
-
return { mode: resolved };
|
|
2191
|
-
}
|
|
2192
|
-
stageUpload(source) {
|
|
2193
|
-
return { path: stageUploadFile(source) };
|
|
2194
|
-
}
|
|
2195
|
-
/**
|
|
2196
|
-
* Status for one profile (bare name) or every live profile.
|
|
2197
|
-
*
|
|
2198
|
-
* `profileName` is a {@link ProfileName} — a BARE name, never a runtime key.
|
|
2199
|
-
* Every candidate is selected by the one rule ({@link keyBelongsToProfile}),
|
|
2200
|
-
* and a scoped query that finds no live connection falls through to disk, so
|
|
2201
|
-
* `status --profile comet-local` reports the LIVE `comet-local@<device>`
|
|
2202
|
-
* (and leftover `comet-local@endpoint-0` dirs) instead of the empty list
|
|
2203
|
-
* it used to return (RUSH-2709).
|
|
2204
|
-
*/
|
|
2205
|
-
async status(profileRef) {
|
|
2206
|
-
// Built before rehydration: a cold pass rehydrates every saved runtime dir
|
|
2207
|
-
// first, and each Arc one probed Arc on its own, spending the budget before
|
|
2208
|
-
// the snapshot existed. One enumeration and one liveness read cover the pass.
|
|
2209
|
-
const arc = {
|
|
2210
|
-
spaces: onceAsync(() => enumerateArcSpaces()),
|
|
2211
|
-
running: onceAsync(() => isArcRunning()),
|
|
2212
|
-
};
|
|
2213
|
-
// Reconnect live browsers after a daemon restart so status is not empty
|
|
2214
|
-
// while tasks.json still holds open work. Rehydration is per runtime dir:
|
|
2215
|
-
// one unreadable saved profile is reported below, never fatal to the pass.
|
|
2216
|
-
const rehydrateErrors = new Map();
|
|
2217
|
-
await this.rehydrateAllFromDisk(arc.running, (key, err) => {
|
|
2218
|
-
rehydrateErrors.set(key, err instanceof Error ? err.message : String(err));
|
|
2219
|
-
});
|
|
2220
|
-
// A caller who pasted a runtime key out of an older listing still gets the
|
|
2221
|
-
// profile it belongs to, rather than an empty result.
|
|
2222
|
-
const profileName = profileRef ? parseConnectionKey(profileRef).profile : undefined;
|
|
2223
|
-
const seenProfiles = new Set();
|
|
2224
|
-
const statuses = [];
|
|
2225
|
-
const candidates = profileName
|
|
2226
|
-
? [...this.connections.keys()].filter((k) => keyBelongsToProfile(k, profileName))
|
|
2227
|
-
: Array.from(this.connections.keys());
|
|
2228
|
-
for (const key of candidates) {
|
|
2229
|
-
// Read-only: a profile that cannot be read is reported as unreadable, not
|
|
2230
|
-
// allowed to abort the listing. Before this, a single stale Arc task made
|
|
2231
|
-
// `status` throw and EVERY profile vanished, including healthy ones.
|
|
2232
|
-
let status;
|
|
2233
|
-
try {
|
|
2234
|
-
status = await this.getProfileStatus(key, arc);
|
|
2235
|
-
}
|
|
2236
|
-
catch (err) {
|
|
2237
|
-
const parsed = parseConnectionKey(key);
|
|
2238
|
-
statuses.push({
|
|
2239
|
-
name: this.connections.get(key)?.profile ?? parsed.profile,
|
|
2240
|
-
endpoint: parsed.endpoint,
|
|
2241
|
-
key,
|
|
2242
|
-
running: false,
|
|
2243
|
-
tasks: [],
|
|
2244
|
-
unavailable: err instanceof Error ? err.message : String(err),
|
|
2245
|
-
});
|
|
2246
|
-
seenProfiles.add(parsed.profile);
|
|
2247
|
-
continue;
|
|
2248
|
-
}
|
|
2249
|
-
if (status) {
|
|
2250
|
-
statuses.push(status);
|
|
2251
|
-
seenProfiles.add(status.name);
|
|
2252
|
-
}
|
|
2253
|
-
}
|
|
2254
|
-
// Reported BEFORE the disk reconcile, and keyed by the runtime dir that
|
|
2255
|
-
// actually failed. Reconcile reads the same `tasks.json`, so without this
|
|
2256
|
-
// ordering one corrupt runtime produced two rows for the same profile: one
|
|
2257
|
-
// keyed here and one unkeyed from reconcile, which the dedupe then missed.
|
|
2258
|
-
for (const [key, message] of rehydrateErrors) {
|
|
2259
|
-
const parsed = parseConnectionKey(key);
|
|
2260
|
-
if (profileName && parsed.profile !== profileName)
|
|
2261
|
-
continue;
|
|
2262
|
-
if (statuses.some((status) => status.key === key))
|
|
2263
|
-
continue;
|
|
2264
|
-
statuses.push({
|
|
2265
|
-
name: parsed.profile,
|
|
2266
|
-
endpoint: parsed.endpoint,
|
|
2267
|
-
key,
|
|
2268
|
-
running: false,
|
|
2269
|
-
tasks: [],
|
|
2270
|
-
unavailable: message,
|
|
2271
|
-
});
|
|
2272
|
-
seenProfiles.add(parsed.profile);
|
|
2273
|
-
}
|
|
2274
|
-
// Disk reconcile reads `tasks.json` too, so it fails on exactly the same
|
|
2275
|
-
// corrupt file — report that profile rather than let it empty the table.
|
|
2276
|
-
const reconcile = async (name) => {
|
|
2277
|
-
const failures = new Map();
|
|
2278
|
-
let reconciled = null;
|
|
2279
|
-
try {
|
|
2280
|
-
reconciled = await this.reconcileFromDisk(name, (key, err) => {
|
|
2281
|
-
failures.set(key, err instanceof Error ? err.message : String(err));
|
|
2282
|
-
});
|
|
2283
|
-
}
|
|
2284
|
-
catch (err) {
|
|
2285
|
-
// Listing the profile's runtime dirs failed, so no single runtime is at
|
|
2286
|
-
// fault and none is invented: this is reported at profile level.
|
|
2287
|
-
statuses.push({
|
|
2288
|
-
name,
|
|
2289
|
-
running: false,
|
|
2290
|
-
tasks: [],
|
|
2291
|
-
unavailable: err instanceof Error ? err.message : String(err),
|
|
2292
|
-
});
|
|
2293
|
-
return;
|
|
2294
|
-
}
|
|
2295
|
-
if (reconciled)
|
|
2296
|
-
statuses.push(reconciled);
|
|
2297
|
-
// Keyed by the runtime dir that actually failed, so a profile with several
|
|
2298
|
-
// runtimes names the unreadable one rather than guessing at the first.
|
|
2299
|
-
for (const [key, message] of failures) {
|
|
2300
|
-
if (statuses.some((status) => status.key === key))
|
|
2301
|
-
continue;
|
|
2302
|
-
const parsed = parseConnectionKey(key);
|
|
2303
|
-
statuses.push({
|
|
2304
|
-
name,
|
|
2305
|
-
endpoint: parsed.endpoint,
|
|
2306
|
-
key,
|
|
2307
|
-
running: false,
|
|
2308
|
-
tasks: [],
|
|
2309
|
-
unavailable: message,
|
|
2310
|
-
});
|
|
2311
|
-
}
|
|
2312
|
-
};
|
|
2313
|
-
if (!profileName) {
|
|
2314
|
-
for (const profile of await listProfiles()) {
|
|
2315
|
-
if (seenProfiles.has(profile.name))
|
|
2316
|
-
continue;
|
|
2317
|
-
await reconcile(profile.name);
|
|
2318
|
-
}
|
|
2319
|
-
}
|
|
2320
|
-
else if (statuses.length === 0) {
|
|
2321
|
-
await reconcile(profileName);
|
|
2322
|
-
}
|
|
2323
|
-
return statuses;
|
|
2324
|
-
}
|
|
2325
|
-
/**
|
|
2326
|
-
* Rebuild a profile's status from its runtime dirs when no live connection is
|
|
2327
|
-
* registered — a daemon that never connected this profile, or a browser
|
|
2328
|
-
* started by an earlier daemon.
|
|
2329
|
-
*
|
|
2330
|
-
* Takes a BARE profile name and walks every runtime key that belongs to it
|
|
2331
|
-
* ({@link listProfileCacheDirs}), including the legacy pre-composite dir named
|
|
2332
|
-
* exactly the profile.
|
|
2333
|
-
*/
|
|
2334
|
-
async reconcileFromDisk(profileName,
|
|
2335
|
-
/**
|
|
2336
|
-
* Read-only callers pass a sink so an unreadable runtime dir is attributed
|
|
2337
|
-
* to the dir that actually failed, and the remaining dirs are still tried.
|
|
2338
|
-
* Callers without a sink keep the strict behaviour.
|
|
2339
|
-
*/
|
|
2340
|
-
onError) {
|
|
2341
|
-
const dirs = listProfileCacheDirs(profileName);
|
|
2342
|
-
for (const dir of dirs) {
|
|
2343
|
-
const key = asConnectionKey(path.basename(dir));
|
|
2344
|
-
try {
|
|
2345
|
-
const info = getRunningChromeInfo(key);
|
|
2346
|
-
if (!info)
|
|
2347
|
-
continue;
|
|
2348
|
-
return await this.profileStatusFromDisk(profileName, key, info.port, info.pid);
|
|
2349
|
-
}
|
|
2350
|
-
catch (err) {
|
|
2351
|
-
if (!onError)
|
|
2352
|
-
throw err;
|
|
2353
|
-
onError(key, err);
|
|
2354
|
-
}
|
|
2355
|
-
}
|
|
2356
|
-
return null;
|
|
2357
|
-
}
|
|
2358
|
-
async profileStatusFromDisk(profileName, runtimeKey, port, pid) {
|
|
2359
|
-
const profile = await getProfile(profileName);
|
|
2360
|
-
const tasks = this.loadTaskState(runtimeKey);
|
|
2361
|
-
const taskStatuses = [];
|
|
2362
|
-
for (const [, task] of tasks) {
|
|
2363
|
-
taskStatuses.push({
|
|
2364
|
-
id: task.id,
|
|
2365
|
-
name: task.name,
|
|
2366
|
-
label: task.label ?? task.name,
|
|
2367
|
-
tabCount: Object.keys(task.tabs).length,
|
|
2368
|
-
currentTabId: task.currentTabId,
|
|
2369
|
-
createdAt: task.createdAt,
|
|
2370
|
-
});
|
|
2371
|
-
}
|
|
2372
|
-
const configuredPort = profile ? extractConfiguredPort(profile) : undefined;
|
|
2373
|
-
return {
|
|
2374
|
-
// Bare name for the user; the endpoint travels in its own field.
|
|
2375
|
-
name: profileName,
|
|
2376
|
-
endpoint: parseConnectionKey(runtimeKey).endpoint,
|
|
2377
|
-
key: runtimeKey,
|
|
2378
|
-
running: true,
|
|
2379
|
-
port,
|
|
2380
|
-
pid,
|
|
2381
|
-
configuredPort: configuredPort !== port ? configuredPort : undefined,
|
|
2382
|
-
tasks: taskStatuses,
|
|
2383
|
-
};
|
|
2384
|
-
}
|
|
2385
|
-
// ─── Viewport & Device Emulation ──────────────────────────────────────────────
|
|
2386
|
-
async setViewport(taskId, width, height, options = {}) {
|
|
2387
|
-
const { conn, task } = await this.findTask(taskId);
|
|
2388
|
-
requireCdp(conn, 'viewport');
|
|
2389
|
-
const shortId = options.tabHint ? await this.resolveTabHint(conn, task, options.tabHint) : this.resolveCurrentTab(task);
|
|
2390
|
-
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
2391
|
-
const target = await this.getTarget(conn, cdpTargetId);
|
|
2392
|
-
if (!target)
|
|
2393
|
-
throw new Error(`Tab ${shortId} not found`);
|
|
2394
|
-
const sessionId = await this.getSessionId(conn, target.targetId);
|
|
2395
|
-
await conn.cdp.send('Emulation.setDeviceMetricsOverride', {
|
|
2396
|
-
width,
|
|
2397
|
-
height,
|
|
2398
|
-
deviceScaleFactor: options.deviceScaleFactor ?? 1,
|
|
2399
|
-
mobile: options.mobile ?? false,
|
|
2400
|
-
}, sessionId);
|
|
2401
|
-
}
|
|
2402
|
-
async setDevice(taskId, deviceName, tabHint) {
|
|
2403
|
-
const { getDevice } = await import('./devices.js');
|
|
2404
|
-
const device = getDevice(deviceName);
|
|
2405
|
-
if (!device) {
|
|
2406
|
-
const { listDevices } = await import('./devices.js');
|
|
2407
|
-
throw new Error(`Unknown device "${deviceName}". Available: ${listDevices().join(', ')}`);
|
|
2408
|
-
}
|
|
2409
|
-
await this.setViewport(taskId, device.width, device.height, {
|
|
2410
|
-
mobile: device.mobile,
|
|
2411
|
-
deviceScaleFactor: device.deviceScaleFactor,
|
|
2412
|
-
tabHint,
|
|
2413
|
-
});
|
|
2414
|
-
}
|
|
2415
|
-
// ─── Console & Errors ────────────────────────────────────────────────────────
|
|
2416
|
-
async enableRuntimeForSession(conn, sessionId) {
|
|
2417
|
-
const key = `${sessionId}:Runtime`;
|
|
2418
|
-
if (this.enabledSessions.get(sessionId)?.has('Runtime'))
|
|
2419
|
-
return;
|
|
2420
|
-
await conn.cdp.send('Runtime.enable', {}, sessionId);
|
|
2421
|
-
if (!this.enabledSessions.has(sessionId)) {
|
|
2422
|
-
this.enabledSessions.set(sessionId, new Set());
|
|
2423
|
-
}
|
|
2424
|
-
this.enabledSessions.get(sessionId).add('Runtime');
|
|
2425
|
-
conn.cdp.on('Runtime.consoleAPICalled', (params) => {
|
|
2426
|
-
if (params.sessionId !== sessionId)
|
|
2427
|
-
return;
|
|
2428
|
-
const taskId = this.findTaskBySession(conn, sessionId);
|
|
2429
|
-
if (!taskId)
|
|
2430
|
-
return;
|
|
2431
|
-
const entry = {
|
|
2432
|
-
level: params.type === 'warning' ? 'warn' : params.type,
|
|
2433
|
-
text: params.args?.map((a) => a.value ?? a.description ?? '').join(' ') || '',
|
|
2434
|
-
timestamp: Date.now(),
|
|
2435
|
-
url: params.stackTrace?.callFrames?.[0]?.url,
|
|
2436
|
-
line: params.stackTrace?.callFrames?.[0]?.lineNumber,
|
|
2437
|
-
};
|
|
2438
|
-
if (!this.consoleLogs.has(taskId))
|
|
2439
|
-
this.consoleLogs.set(taskId, []);
|
|
2440
|
-
const logs = this.consoleLogs.get(taskId);
|
|
2441
|
-
logs.push(entry);
|
|
2442
|
-
if (logs.length > 1000)
|
|
2443
|
-
logs.shift();
|
|
2444
|
-
});
|
|
2445
|
-
conn.cdp.on('Runtime.exceptionThrown', (params) => {
|
|
2446
|
-
if (params.sessionId !== sessionId)
|
|
2447
|
-
return;
|
|
2448
|
-
const taskId = this.findTaskBySession(conn, sessionId);
|
|
2449
|
-
if (!taskId)
|
|
2450
|
-
return;
|
|
2451
|
-
const ex = params.exceptionDetails;
|
|
2452
|
-
const entry = {
|
|
2453
|
-
message: ex.exception?.description || ex.text || 'Unknown error',
|
|
2454
|
-
stack: ex.stackTrace?.callFrames?.map((f) => ` at ${f.functionName || '<anonymous>'} (${f.url}:${f.lineNumber})`).join('\n'),
|
|
2455
|
-
timestamp: Date.now(),
|
|
2456
|
-
url: ex.url,
|
|
2457
|
-
line: ex.lineNumber,
|
|
2458
|
-
};
|
|
2459
|
-
if (!this.pageErrors.has(taskId))
|
|
2460
|
-
this.pageErrors.set(taskId, []);
|
|
2461
|
-
const errors = this.pageErrors.get(taskId);
|
|
2462
|
-
errors.push(entry);
|
|
2463
|
-
if (errors.length > 500)
|
|
2464
|
-
errors.shift();
|
|
2465
|
-
});
|
|
2466
|
-
}
|
|
2467
|
-
async getConsoleLogs(taskId, options = {}) {
|
|
2468
|
-
const { conn, task } = await this.findTask(taskId);
|
|
2469
|
-
requireCdp(conn, 'consoleCapture');
|
|
2470
|
-
const shortId = options.tabHint ? await this.resolveTabHint(conn, task, options.tabHint) : this.resolveCurrentTab(task);
|
|
2471
|
-
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
2472
|
-
const target = await this.getTarget(conn, cdpTargetId);
|
|
2473
|
-
if (!target)
|
|
2474
|
-
throw new Error(`Tab ${shortId} not found`);
|
|
2475
|
-
const sessionId = await this.getSessionId(conn, target.targetId);
|
|
2476
|
-
await this.enableRuntimeForSession(conn, sessionId);
|
|
2477
|
-
let logs = this.consoleLogs.get(taskId) || [];
|
|
2478
|
-
if (options.level) {
|
|
2479
|
-
logs = logs.filter((l) => l.level === options.level);
|
|
2480
|
-
}
|
|
2481
|
-
if (options.clear) {
|
|
2482
|
-
this.consoleLogs.set(taskId, []);
|
|
2483
|
-
}
|
|
2484
|
-
return logs;
|
|
2485
|
-
}
|
|
2486
|
-
async getErrors(taskId, options = {}) {
|
|
2487
|
-
const { conn, task } = await this.findTask(taskId);
|
|
2488
|
-
requireCdp(conn, 'consoleCapture');
|
|
2489
|
-
const shortId = options.tabHint ? await this.resolveTabHint(conn, task, options.tabHint) : this.resolveCurrentTab(task);
|
|
2490
|
-
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
2491
|
-
const target = await this.getTarget(conn, cdpTargetId);
|
|
2492
|
-
if (!target)
|
|
2493
|
-
throw new Error(`Tab ${shortId} not found`);
|
|
2494
|
-
const sessionId = await this.getSessionId(conn, target.targetId);
|
|
2495
|
-
await this.enableRuntimeForSession(conn, sessionId);
|
|
2496
|
-
const errors = this.pageErrors.get(taskId) || [];
|
|
2497
|
-
if (options.clear) {
|
|
2498
|
-
this.pageErrors.set(taskId, []);
|
|
2499
|
-
}
|
|
2500
|
-
return errors;
|
|
2501
|
-
}
|
|
2502
|
-
// ─── Network Requests ────────────────────────────────────────────────────────
|
|
2503
|
-
async enableNetworkForSession(conn, sessionId, taskId) {
|
|
2504
|
-
if (this.enabledSessions.get(sessionId)?.has('Network'))
|
|
2505
|
-
return;
|
|
2506
|
-
await conn.cdp.send('Network.enable', {}, sessionId);
|
|
2507
|
-
if (!this.enabledSessions.has(sessionId)) {
|
|
2508
|
-
this.enabledSessions.set(sessionId, new Set());
|
|
2509
|
-
}
|
|
2510
|
-
this.enabledSessions.get(sessionId).add('Network');
|
|
2511
|
-
const requestMap = new Map();
|
|
2512
|
-
conn.cdp.on('Network.requestWillBeSent', (params) => {
|
|
2513
|
-
if (params.sessionId !== sessionId)
|
|
2514
|
-
return;
|
|
2515
|
-
const req = {
|
|
2516
|
-
id: params.requestId,
|
|
2517
|
-
url: params.request.url,
|
|
2518
|
-
method: params.request.method,
|
|
2519
|
-
timestamp: Date.now(),
|
|
2520
|
-
};
|
|
2521
|
-
requestMap.set(params.requestId, req);
|
|
2522
|
-
if (!this.networkRequests.has(taskId))
|
|
2523
|
-
this.networkRequests.set(taskId, []);
|
|
2524
|
-
const reqs = this.networkRequests.get(taskId);
|
|
2525
|
-
reqs.push(req);
|
|
2526
|
-
if (reqs.length > 500)
|
|
2527
|
-
reqs.shift();
|
|
2528
|
-
});
|
|
2529
|
-
conn.cdp.on('Network.responseReceived', (params) => {
|
|
2530
|
-
if (params.sessionId !== sessionId)
|
|
2531
|
-
return;
|
|
2532
|
-
const req = requestMap.get(params.requestId);
|
|
2533
|
-
if (req) {
|
|
2534
|
-
req.status = params.response.status;
|
|
2535
|
-
req.mimeType = params.response.mimeType;
|
|
2536
|
-
}
|
|
2537
|
-
});
|
|
2538
|
-
}
|
|
2539
|
-
async getNetworkRequests(taskId, options = {}) {
|
|
2540
|
-
const { conn, task } = await this.findTask(taskId);
|
|
2541
|
-
requireCdp(conn, 'networkCapture');
|
|
2542
|
-
const shortId = options.tabHint ? await this.resolveTabHint(conn, task, options.tabHint) : this.resolveCurrentTab(task);
|
|
2543
|
-
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
2544
|
-
const target = await this.getTarget(conn, cdpTargetId);
|
|
2545
|
-
if (!target)
|
|
2546
|
-
throw new Error(`Tab ${shortId} not found`);
|
|
2547
|
-
const sessionId = await this.getSessionId(conn, target.targetId);
|
|
2548
|
-
await this.enableNetworkForSession(conn, sessionId, taskId);
|
|
2549
|
-
let requests = this.networkRequests.get(taskId) || [];
|
|
2550
|
-
if (options.filter) {
|
|
2551
|
-
const f = options.filter.toLowerCase();
|
|
2552
|
-
requests = requests.filter((r) => r.url.toLowerCase().includes(f));
|
|
2553
|
-
}
|
|
2554
|
-
if (options.clear) {
|
|
2555
|
-
this.networkRequests.set(taskId, []);
|
|
2556
|
-
}
|
|
2557
|
-
return requests;
|
|
2558
|
-
}
|
|
2559
|
-
async getResponseBody(taskId, urlPattern, options = {}) {
|
|
2560
|
-
const { conn, task } = await this.findTask(taskId);
|
|
2561
|
-
requireCdp(conn, 'networkCapture');
|
|
2562
|
-
const shortId = options.tabHint ? await this.resolveTabHint(conn, task, options.tabHint) : this.resolveCurrentTab(task);
|
|
2563
|
-
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
2564
|
-
const target = await this.getTarget(conn, cdpTargetId);
|
|
2565
|
-
if (!target)
|
|
2566
|
-
throw new Error(`Tab ${shortId} not found`);
|
|
2567
|
-
const sessionId = await this.getSessionId(conn, target.targetId);
|
|
2568
|
-
await this.enableNetworkForSession(conn, sessionId, taskId);
|
|
2569
|
-
const timeout = options.timeout ?? 30000;
|
|
2570
|
-
const maxChars = options.maxChars ?? 200000;
|
|
2571
|
-
const start = Date.now();
|
|
2572
|
-
const pattern = urlPattern.includes('*')
|
|
2573
|
-
? new RegExp(urlPattern.replace(/\*/g, '.*'), 'i')
|
|
2574
|
-
: null;
|
|
2575
|
-
while (Date.now() - start < timeout) {
|
|
2576
|
-
const requests = this.networkRequests.get(taskId) || [];
|
|
2577
|
-
const match = requests.find((r) => pattern ? pattern.test(r.url) : r.url.includes(urlPattern));
|
|
2578
|
-
if (match && match.status) {
|
|
2579
|
-
try {
|
|
2580
|
-
const { body, base64Encoded } = (await conn.cdp.send('Network.getResponseBody', { requestId: match.id }, sessionId));
|
|
2581
|
-
const text = base64Encoded ? Buffer.from(body, 'base64').toString('utf-8') : body;
|
|
2582
|
-
return text.slice(0, maxChars);
|
|
2583
|
-
}
|
|
2584
|
-
catch {
|
|
2585
|
-
// Request may have been evicted, continue waiting
|
|
2586
|
-
}
|
|
2587
|
-
}
|
|
2588
|
-
await new Promise((r) => setTimeout(r, 200));
|
|
2589
|
-
}
|
|
2590
|
-
throw new Error(`No response matching "${urlPattern}" within ${timeout}ms`);
|
|
2591
|
-
}
|
|
2592
|
-
// ─── App Logs (source-side JSONL) ───────────────────────────────────────────
|
|
2593
|
-
async getAppLogs(taskId, opts) {
|
|
2594
|
-
const { task } = await this.findTask(taskId);
|
|
2595
|
-
const baseProfileName = task.profile.split('@')[0];
|
|
2596
|
-
const profile = await getProfile(baseProfileName);
|
|
2597
|
-
if (!profile?.logDir) {
|
|
2598
|
-
throw new Error(`Profile '${task.profile}' has no logDir set`);
|
|
2599
|
-
}
|
|
2600
|
-
const logDir = expandHome(profile.logDir);
|
|
2601
|
-
const sources = opts.source ? [opts.source] : ['rush-app', 'rush-cli'];
|
|
2602
|
-
const since = opts.since ? parseSinceUntil(opts.since) : null;
|
|
2603
|
-
const until = opts.until ? parseSinceUntil(opts.until) : null;
|
|
2604
|
-
const tailN = since ? 100_000 : (opts.lines ?? 200);
|
|
2605
|
-
const raws = await Promise.all(sources.map(async (src) => {
|
|
2606
|
-
const prefix = BrowserService.SOURCE_PREFIX[src];
|
|
2607
|
-
if (!prefix)
|
|
2608
|
-
return '';
|
|
2609
|
-
if (profile.logHost) {
|
|
2610
|
-
return execSSH(profile.logHost, readNewestMatchingRemoteFileCommand(logDir, prefix, tailN));
|
|
2611
|
-
}
|
|
2612
|
-
return readNewestMatchingFile(logDir, prefix, tailN);
|
|
2613
|
-
}));
|
|
2614
|
-
const entries = raws
|
|
2615
|
-
.flatMap((r) => r.split('\n').filter(Boolean))
|
|
2616
|
-
.map((line) => {
|
|
2617
|
-
try {
|
|
2618
|
-
return JSON.parse(line);
|
|
2619
|
-
}
|
|
2620
|
-
catch {
|
|
2621
|
-
return { raw: line };
|
|
2622
|
-
}
|
|
2623
|
-
})
|
|
2624
|
-
.filter((e) => !opts.level || e.level === opts.level)
|
|
2625
|
-
.filter((e) => !opts.message || e.message === opts.message)
|
|
2626
|
-
.filter((e) => !opts.filter || JSON.stringify(e).includes(opts.filter))
|
|
2627
|
-
.filter((e) => !since || (e.timestamp && new Date(e.timestamp) >= since))
|
|
2628
|
-
.filter((e) => !until || (e.timestamp && new Date(e.timestamp) <= until))
|
|
2629
|
-
.sort((a, b) => new Date(a.timestamp ?? 0).getTime() - new Date(b.timestamp ?? 0).getTime());
|
|
2630
|
-
return since ? entries : entries.slice(-(opts.lines ?? 200));
|
|
2631
|
-
}
|
|
2632
|
-
// ─── Wait Conditions ─────────────────────────────────────────────────────────
|
|
2633
|
-
async wait(taskId, type, value, options = {}) {
|
|
2634
|
-
const timeout = options.timeout ?? 30000;
|
|
2635
|
-
const { conn, task } = await this.findTask(taskId);
|
|
2636
|
-
requireCdp(conn, 'asyncEvaluate');
|
|
2637
|
-
if (type === 'time') {
|
|
2638
|
-
await new Promise((r) => setTimeout(r, typeof value === 'number' ? value : parseInt(value, 10)));
|
|
2639
|
-
return;
|
|
2640
|
-
}
|
|
2641
|
-
const shortId = options.tabHint ? await this.resolveTabHint(conn, task, options.tabHint) : this.resolveCurrentTab(task);
|
|
2642
|
-
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
2643
|
-
const target = await this.getTarget(conn, cdpTargetId);
|
|
2644
|
-
if (!target)
|
|
2645
|
-
throw new Error(`Tab ${shortId} not found`);
|
|
2646
|
-
const sessionId = await this.getSessionId(conn, target.targetId);
|
|
2647
|
-
const start = Date.now();
|
|
2648
|
-
while (Date.now() - start < timeout) {
|
|
2649
|
-
let condition = false;
|
|
2650
|
-
if (type === 'selector') {
|
|
2651
|
-
const result = (await conn.cdp.send('Runtime.evaluate', { expression: `!!document.querySelector(${JSON.stringify(value)})`, returnByValue: true }, sessionId));
|
|
2652
|
-
condition = result.result.value === true;
|
|
2653
|
-
}
|
|
2654
|
-
else if (type === 'url') {
|
|
2655
|
-
const result = (await conn.cdp.send('Runtime.evaluate', { expression: 'location.href', returnByValue: true }, sessionId));
|
|
2656
|
-
const pattern = value.includes('*')
|
|
2657
|
-
? new RegExp(value.replace(/\*/g, '.*'), 'i')
|
|
2658
|
-
: null;
|
|
2659
|
-
condition = pattern ? pattern.test(result.result.value) : result.result.value.includes(value);
|
|
2660
|
-
}
|
|
2661
|
-
else if (type === 'function') {
|
|
2662
|
-
const result = (await conn.cdp.send('Runtime.evaluate', { expression: `!!(${value})`, returnByValue: true }, sessionId));
|
|
2663
|
-
condition = result.result.value === true;
|
|
2664
|
-
}
|
|
2665
|
-
else if (type === 'load') {
|
|
2666
|
-
const result = (await conn.cdp.send('Runtime.evaluate', { expression: 'document.readyState', returnByValue: true }, sessionId));
|
|
2667
|
-
if (value === 'domcontentloaded') {
|
|
2668
|
-
condition = result.result.value !== 'loading';
|
|
2669
|
-
}
|
|
2670
|
-
else if (value === 'load' || value === 'complete') {
|
|
2671
|
-
condition = result.result.value === 'complete';
|
|
2672
|
-
}
|
|
2673
|
-
else if (value === 'networkidle') {
|
|
2674
|
-
// Simplified: check if document is complete
|
|
2675
|
-
condition = result.result.value === 'complete';
|
|
2676
|
-
}
|
|
2677
|
-
}
|
|
2678
|
-
if (condition)
|
|
2679
|
-
return;
|
|
2680
|
-
await new Promise((r) => setTimeout(r, 100));
|
|
2681
|
-
}
|
|
2682
|
-
throw new Error(`Wait condition "${type}:${value}" not met within ${timeout}ms`);
|
|
2683
|
-
}
|
|
2684
|
-
// ─── Downloads ───────────────────────────────────────────────────────────────
|
|
2685
|
-
/**
|
|
2686
|
-
* Point the browser's default download destination at the profile's downloads
|
|
2687
|
-
* dir, browser-global, at connect time. Without this a download the agent never
|
|
2688
|
-
* explicitly routed (`browser download --path`) falls to Chromium's own default
|
|
2689
|
-
* — for an attached user browser, wherever that browser was last configured,
|
|
2690
|
-
* which is how downloads used to escape into random locations. Sent on the root
|
|
2691
|
-
* session (no sessionId) so every current and future tab inherits it. Best
|
|
2692
|
-
* effort: a remote CDP endpoint that doesn't expose the Browser domain must not
|
|
2693
|
-
* fail the whole connect.
|
|
2694
|
-
*/
|
|
2695
|
-
async applyDefaultDownloadBehavior(conn, key) {
|
|
2696
|
-
const downloadPath = getProfileDownloadsDir(key);
|
|
2697
|
-
try {
|
|
2698
|
-
await fs.promises.mkdir(downloadPath, { recursive: true });
|
|
2699
|
-
await conn.cdp.send('Browser.setDownloadBehavior', {
|
|
2700
|
-
behavior: 'allow',
|
|
2701
|
-
downloadPath,
|
|
2702
|
-
eventsEnabled: true,
|
|
2703
|
-
});
|
|
2704
|
-
}
|
|
2705
|
-
catch {
|
|
2706
|
-
// Best effort: a remote CDP endpoint (ssh://, ws(s)://) may not expose the
|
|
2707
|
-
// Browser domain. Downloads then keep the endpoint's own default; the connect
|
|
2708
|
-
// must still succeed.
|
|
2709
|
-
}
|
|
2710
|
-
}
|
|
2711
|
-
async setDownloadPath(taskId, downloadPath, tabHint) {
|
|
2712
|
-
const { conn, task, key: runtimeKey } = await this.findTask(taskId);
|
|
2713
|
-
requireCdp(conn, 'download');
|
|
2714
|
-
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
2715
|
-
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
2716
|
-
const target = await this.getTarget(conn, cdpTargetId);
|
|
2717
|
-
if (!target)
|
|
2718
|
-
throw new Error(`Tab ${shortId} not found`);
|
|
2719
|
-
const sessionId = await this.getSessionId(conn, target.targetId);
|
|
2720
|
-
// No explicit --path: fall back to the profile's downloads dir, the same
|
|
2721
|
-
// destination already set browser-global at connect (applyDefaultDownloadBehavior).
|
|
2722
|
-
const resolvedPath = downloadPath ?? getProfileDownloadsDir(runtimeKey);
|
|
2723
|
-
await fs.promises.mkdir(resolvedPath, { recursive: true });
|
|
2724
|
-
await conn.cdp.send('Browser.setDownloadBehavior', {
|
|
2725
|
-
behavior: 'allow',
|
|
2726
|
-
downloadPath: resolvedPath,
|
|
2727
|
-
eventsEnabled: true,
|
|
2728
|
-
}, sessionId);
|
|
2729
|
-
this.pendingDownloads.set(taskId, { path: resolvedPath, completed: false });
|
|
2730
|
-
conn.cdp.on('Browser.downloadProgress', (params) => {
|
|
2731
|
-
if (params.state === 'completed') {
|
|
2732
|
-
const dl = this.pendingDownloads.get(taskId);
|
|
2733
|
-
if (dl) {
|
|
2734
|
-
dl.completed = true;
|
|
2735
|
-
dl.filename = params.suggestedFilename;
|
|
2736
|
-
}
|
|
2737
|
-
}
|
|
2738
|
-
});
|
|
2739
|
-
return resolvedPath;
|
|
2740
|
-
}
|
|
2741
|
-
async waitForDownload(taskId, timeout = 60000) {
|
|
2742
|
-
const { conn } = await this.findTask(taskId);
|
|
2743
|
-
requireCdp(conn, 'download');
|
|
2744
|
-
const start = Date.now();
|
|
2745
|
-
while (Date.now() - start < timeout) {
|
|
2746
|
-
const dl = this.pendingDownloads.get(taskId);
|
|
2747
|
-
if (dl?.completed) {
|
|
2748
|
-
const fullPath = dl.filename ? `${dl.path}/${dl.filename}` : dl.path;
|
|
2749
|
-
this.pendingDownloads.delete(taskId);
|
|
2750
|
-
return fullPath;
|
|
2751
|
-
}
|
|
2752
|
-
await new Promise((r) => setTimeout(r, 200));
|
|
2753
|
-
}
|
|
2754
|
-
throw new Error(`Download not completed within ${timeout}ms`);
|
|
2755
|
-
}
|
|
2756
|
-
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
2757
|
-
findTaskBySession(conn, sessionId) {
|
|
2758
|
-
for (const [taskId, task] of conn.tasks) {
|
|
2759
|
-
for (const tabId of Object.values(task.tabs)) {
|
|
2760
|
-
if (conn.sessionCache.get(tabId) === sessionId) {
|
|
2761
|
-
return taskId;
|
|
2762
|
-
}
|
|
2763
|
-
}
|
|
2764
|
-
}
|
|
2765
|
-
return undefined;
|
|
2766
|
-
}
|
|
2767
|
-
async shutdown() {
|
|
2768
|
-
// Drain any in-flight recordings first so we don't orphan ffmpeg processes
|
|
2769
|
-
// or leak the duration/size-check timers when the daemon goes down.
|
|
2770
|
-
for (const [taskId, rec] of this.recordings) {
|
|
2771
|
-
clearTimeout(rec.durationTimer);
|
|
2772
|
-
clearInterval(rec.sizeCheckInterval);
|
|
2773
|
-
try {
|
|
2774
|
-
rec.conn.cdp.off('Page.screencastFrame', rec.frameHandler);
|
|
2775
|
-
}
|
|
2776
|
-
catch { /* socket may be gone */ }
|
|
2777
|
-
clearInterval(rec.framePump);
|
|
2778
|
-
try {
|
|
2779
|
-
rec.ffmpeg.stdin?.end();
|
|
2780
|
-
}
|
|
2781
|
-
catch { /* already closed */ }
|
|
2782
|
-
// Give ffmpeg up to 1s to flush; then SIGKILL.
|
|
2783
|
-
const exited = await new Promise((resolve) => {
|
|
2784
|
-
let done = false;
|
|
2785
|
-
rec.ffmpeg.once('exit', () => { done = true; resolve(true); });
|
|
2786
|
-
setTimeout(() => { if (!done)
|
|
2787
|
-
resolve(false); }, 1000);
|
|
2788
|
-
});
|
|
2789
|
-
if (!exited) {
|
|
2790
|
-
try {
|
|
2791
|
-
rec.ffmpeg.kill('SIGKILL');
|
|
2792
|
-
}
|
|
2793
|
-
catch { /* already dead */ }
|
|
2794
|
-
}
|
|
2795
|
-
this.recordings.delete(taskId);
|
|
2796
|
-
}
|
|
2797
|
-
for (const [, conn] of this.connections) {
|
|
2798
|
-
if (conn.backend === 'bidi')
|
|
2799
|
-
conn.bidi.close();
|
|
2800
|
-
else if (conn.backend !== 'arc-native')
|
|
2801
|
-
conn.cdp.close();
|
|
2802
|
-
conn.cleanup?.();
|
|
2803
|
-
}
|
|
2804
|
-
this.connections.clear();
|
|
2805
|
-
}
|
|
2806
|
-
findAvailableFork(baseKey) {
|
|
2807
|
-
for (const [name, conn] of this.connections) {
|
|
2808
|
-
if (conn.forkedFrom === baseKey && conn.tasks.size === 0) {
|
|
2809
|
-
return { name, conn };
|
|
2810
|
-
}
|
|
2811
|
-
}
|
|
2812
|
-
return null;
|
|
2813
|
-
}
|
|
2814
|
-
async forkElectronProfile(profile, baseKey) {
|
|
2815
|
-
let forkNum = 2;
|
|
2816
|
-
while (this.connections.has(asConnectionKey(`${baseKey}.${forkNum}`))) {
|
|
2817
|
-
forkNum++;
|
|
2818
|
-
}
|
|
2819
|
-
const forkName = asConnectionKey(`${baseKey}.${forkNum}`);
|
|
2820
|
-
const port = allocatePort();
|
|
2821
|
-
const chromeOpts = { ...profile.chrome, viewport: profile.viewport };
|
|
2822
|
-
const { pid, wsUrl } = await launchBrowser(forkName, profile.browser, port, chromeOpts, profile.secrets, profile.binary, profile.electron === true);
|
|
2823
|
-
const cdp = new CDPClient();
|
|
2824
|
-
await cdp.connect(wsUrl);
|
|
2825
|
-
await this.enableDomains(cdp);
|
|
2826
|
-
const connection = {
|
|
2827
|
-
backend: 'cdp',
|
|
2828
|
-
cdp,
|
|
2829
|
-
port,
|
|
2830
|
-
pid,
|
|
2831
|
-
electron: true,
|
|
2832
|
-
browserType: profile.browser,
|
|
2833
|
-
targetFilter: profile.targetFilter,
|
|
2834
|
-
key: forkName,
|
|
2835
|
-
profile: profile.name,
|
|
2836
|
-
forkedFrom: baseKey,
|
|
2837
|
-
tasks: new Map(),
|
|
2838
|
-
sessionCache: new Map(),
|
|
2839
|
-
};
|
|
2840
|
-
this.connections.set(forkName, connection);
|
|
2841
|
-
await this.applyDefaultDownloadBehavior(connection, forkName);
|
|
2842
|
-
return { forkName, connection };
|
|
2843
|
-
}
|
|
2844
|
-
/**
|
|
2845
|
-
* Connect to a profile at a specific endpoint preset. The caller has
|
|
2846
|
-
* already resolved the endpoint and built the `effectiveProfile` with
|
|
2847
|
-
* the per-endpoint binary/targetFilter overrides applied; we just use it.
|
|
2848
|
-
*
|
|
2849
|
-
* `key` is the runtime key (`<profile>@<device>`) and is what every on-disk
|
|
2850
|
-
* lookup uses. It is a SEPARATE argument because `effectiveProfile.name`
|
|
2851
|
-
* stays the bare, user-facing name (RUSH-2709).
|
|
2852
|
-
*/
|
|
2853
|
-
async connectProfile(effectiveProfile, target, key, opts = {}) {
|
|
2854
|
-
const existingInfo = getRunningChromeInfo(key);
|
|
2855
|
-
const tunnelled = opts.persistRemote || target.startsWith('ssh:');
|
|
2856
|
-
// Native Arc and Firefox BiDi speak their own transport, not CDP — never
|
|
2857
|
-
// route them through the CDP reuse branch (which would probe /json/version
|
|
2858
|
-
// and wipe the runtime meta the Firefox driver wrote). connectEndpoint owns
|
|
2859
|
-
// their attach-or-launch decision.
|
|
2860
|
-
const nativeTransport = target.startsWith('arc-native:') || target.startsWith('firefox-bidi:');
|
|
2861
|
-
if (tunnelled) {
|
|
2862
|
-
// A leftover local chrome-data / pid file under this key is the
|
|
2863
|
-
// pre-T2 logged-out browser on THIS box. Attaching localhost CDP
|
|
2864
|
-
// here is the original bug. Only a recorded SSH tunnel is ours to
|
|
2865
|
-
// reuse, and connectSSH already does that via isOwnTunnel.
|
|
2866
|
-
const meta = readProfileRuntimeMeta(key);
|
|
2867
|
-
if (meta && meta.kind !== 'tunnel') {
|
|
2868
|
-
clearProfileRuntime(key);
|
|
2869
|
-
}
|
|
2870
|
-
}
|
|
2871
|
-
else if (existingInfo && !nativeTransport) {
|
|
2872
|
-
try {
|
|
2873
|
-
const { wsUrl, browser } = await discoverBrowserWsUrl(existingInfo.port, 'localhost', effectiveProfile.name);
|
|
2874
|
-
verifyBrowserIdentity(browser, effectiveProfile.browser, existingInfo.port);
|
|
2875
|
-
const cdp = new CDPClient();
|
|
2876
|
-
await cdp.connect(wsUrl);
|
|
2877
|
-
await this.enableDomains(cdp);
|
|
2878
|
-
const tasks = this.loadTaskState(key);
|
|
2879
|
-
// The attach is now PROVEN: the stored port answered /json/version and
|
|
2880
|
-
// `verifyBrowserIdentity` matched the profile's browser family. Only here
|
|
2881
|
-
// may this service claim a browser the startup reaper deliberately left
|
|
2882
|
-
// alone — it rewrites `daemonPid` only, keeping the original launch's
|
|
2883
|
-
// metadata. An ambiguous identity throws above and adopts nothing.
|
|
2884
|
-
adoptProfileRuntimeOwner(key);
|
|
2885
|
-
return {
|
|
2886
|
-
backend: 'cdp',
|
|
2887
|
-
cdp,
|
|
2888
|
-
port: existingInfo.port,
|
|
2889
|
-
pid: existingInfo.pid,
|
|
2890
|
-
electron: effectiveProfile.electron,
|
|
2891
|
-
browserType: effectiveProfile.browser,
|
|
2892
|
-
targetFilter: effectiveProfile.targetFilter,
|
|
2893
|
-
tasks,
|
|
2894
|
-
sessionCache: new Map(),
|
|
2895
|
-
};
|
|
2896
|
-
}
|
|
2897
|
-
catch (err) {
|
|
2898
|
-
// pid file says a process is alive on that port, but nothing is
|
|
2899
|
-
// actually responding to CDP — most commonly because the user
|
|
2900
|
-
// changed the configured endpoint port after a previous launch,
|
|
2901
|
-
// or because the OS reused the pid for an unrelated process.
|
|
2902
|
-
// Wipe the stale runtime files and fall through to a fresh
|
|
2903
|
-
// connect against the profile's currently-configured endpoint.
|
|
2904
|
-
clearProfileRuntime(key);
|
|
2905
|
-
}
|
|
2906
|
-
}
|
|
2907
|
-
const conn = await this.connectEndpoint(effectiveProfile, target, key, opts);
|
|
2908
|
-
if (!conn) {
|
|
2909
|
-
throw new Error(`Could not connect to endpoint ${target} for profile "${effectiveProfile.name}"`);
|
|
2910
|
-
}
|
|
2911
|
-
return conn;
|
|
2912
|
-
}
|
|
2913
|
-
async connectEndpoint(profile, endpoint, key, opts = {}) {
|
|
2914
|
-
const url = new URL(endpoint);
|
|
2915
|
-
if (url.protocol === 'cdp:') {
|
|
2916
|
-
const conn = await connectLocal(endpoint, profile, key);
|
|
2917
|
-
await this.enableDomains(conn.cdp);
|
|
2918
|
-
return {
|
|
2919
|
-
backend: 'cdp',
|
|
2920
|
-
cdp: conn.cdp,
|
|
2921
|
-
port: conn.port,
|
|
2922
|
-
pid: conn.pid,
|
|
2923
|
-
electron: profile.electron,
|
|
2924
|
-
browserType: profile.browser,
|
|
2925
|
-
targetFilter: profile.targetFilter,
|
|
2926
|
-
tasks: conn.pid === 0 ? this.loadTaskState(key) : new Map(),
|
|
2927
|
-
sessionCache: new Map(),
|
|
2928
|
-
};
|
|
2929
|
-
}
|
|
2930
|
-
if (url.protocol === 'ssh:') {
|
|
2931
|
-
const conn = await connectSSH(endpoint, profile, key, {
|
|
2932
|
-
persistRemote: opts.persistRemote,
|
|
2933
|
-
});
|
|
2934
|
-
await this.enableDomains(conn.cdp);
|
|
2935
|
-
return {
|
|
2936
|
-
backend: 'cdp',
|
|
2937
|
-
cdp: conn.cdp,
|
|
2938
|
-
port: conn.port,
|
|
2939
|
-
pid: conn.pid,
|
|
2940
|
-
electron: profile.electron,
|
|
2941
|
-
browserType: profile.browser,
|
|
2942
|
-
targetFilter: profile.targetFilter,
|
|
2943
|
-
tasks: new Map(),
|
|
2944
|
-
sessionCache: new Map(),
|
|
2945
|
-
cleanup: conn.cleanup,
|
|
2946
|
-
};
|
|
2947
|
-
}
|
|
2948
|
-
if (url.protocol === 'wss:' || url.protocol === 'ws:') {
|
|
2949
|
-
const port = parseInt(url.port || (url.protocol === 'wss:' ? '443' : '80'), 10);
|
|
2950
|
-
const cdp = new CDPClient();
|
|
2951
|
-
try {
|
|
2952
|
-
await cdp.connect(endpoint);
|
|
2953
|
-
}
|
|
2954
|
-
catch {
|
|
2955
|
-
throw new BrowserCdpConnectionError(port, profile.name, url.hostname || 'localhost');
|
|
2956
|
-
}
|
|
2957
|
-
await this.enableDomains(cdp);
|
|
2958
|
-
return {
|
|
2959
|
-
backend: 'cdp',
|
|
2960
|
-
cdp,
|
|
2961
|
-
port: 0,
|
|
2962
|
-
pid: 0,
|
|
2963
|
-
electron: profile.electron,
|
|
2964
|
-
browserType: profile.browser,
|
|
2965
|
-
targetFilter: profile.targetFilter,
|
|
2966
|
-
tasks: this.loadTaskState(key),
|
|
2967
|
-
sessionCache: new Map(),
|
|
2968
|
-
};
|
|
2969
|
-
}
|
|
2970
|
-
if (url.protocol === 'http:' || url.protocol === 'https:') {
|
|
2971
|
-
const port = parseInt(url.port || (url.protocol === 'https:' ? '443' : '80'), 10);
|
|
2972
|
-
const { wsUrl, browser } = await discoverBrowserWsUrl(port, url.hostname, profile.name);
|
|
2973
|
-
verifyBrowserIdentity(browser, profile.browser, port, url.hostname);
|
|
2974
|
-
const cdp = new CDPClient();
|
|
2975
|
-
await cdp.connect(wsUrl);
|
|
2976
|
-
await this.enableDomains(cdp);
|
|
2977
|
-
return {
|
|
2978
|
-
backend: 'cdp',
|
|
2979
|
-
cdp,
|
|
2980
|
-
port,
|
|
2981
|
-
pid: 0,
|
|
2982
|
-
electron: profile.electron,
|
|
2983
|
-
browserType: profile.browser,
|
|
2984
|
-
targetFilter: profile.targetFilter,
|
|
2985
|
-
tasks: this.loadTaskState(key),
|
|
2986
|
-
sessionCache: new Map(),
|
|
2987
|
-
};
|
|
2988
|
-
}
|
|
2989
|
-
// Firefox BiDi backend (PHNX-4043): `firefox-bidi://127.0.0.1:<port>` connects
|
|
2990
|
-
// over a WebDriver BiDi WebSocket. The driver attaches to a Firefox already
|
|
2991
|
-
// serving the port, else launches one headless bound to the pinned profile
|
|
2992
|
-
// directory, else fails loud (Firefox is single-instance per profile).
|
|
2993
|
-
if (url.protocol === 'firefox-bidi:') {
|
|
2994
|
-
if (!profile.firefox) {
|
|
2995
|
-
throw new Error(`Firefox profile "${profile.name}" has no discovered profile metadata. ` +
|
|
2996
|
-
`Re-select it with: agents browser use ${profile.name}`);
|
|
2997
|
-
}
|
|
2998
|
-
const profileDir = profile.userDataDir;
|
|
2999
|
-
if (!profileDir) {
|
|
3000
|
-
throw new Error(`Firefox profile "${profile.name}" has no profile directory.`);
|
|
3001
|
-
}
|
|
3002
|
-
const port = parseInt(url.port || '9600', 10);
|
|
3003
|
-
const conn = await connectFirefox(profile, key, port, {
|
|
3004
|
-
profileDir,
|
|
3005
|
-
headless: profile.chrome?.headless,
|
|
3006
|
-
});
|
|
3007
|
-
return {
|
|
3008
|
-
backend: 'bidi',
|
|
3009
|
-
browserType: 'firefox',
|
|
3010
|
-
bidi: conn.bidi,
|
|
3011
|
-
firefoxProfile: profile.firefox,
|
|
3012
|
-
sessionId: conn.sessionId,
|
|
3013
|
-
profileDir,
|
|
3014
|
-
port: conn.port,
|
|
3015
|
-
pid: conn.pid,
|
|
3016
|
-
electron: false,
|
|
3017
|
-
targetFilter: profile.targetFilter,
|
|
3018
|
-
tasks: this.loadTaskState(key),
|
|
3019
|
-
sessionCache: new Map(),
|
|
3020
|
-
};
|
|
3021
|
-
}
|
|
3022
|
-
// Native Arc backend (PHNX-2399): `arc-native:` protocol connects through
|
|
3023
|
-
// Apple Events instead of CDP. No debugging port, no browser launch.
|
|
3024
|
-
if (url.protocol === 'arc-native:') {
|
|
3025
|
-
if (process.platform !== 'darwin') {
|
|
3026
|
-
throw new Error('Native Arc automation is only available on macOS');
|
|
3027
|
-
}
|
|
3028
|
-
if (!(await isArcRunning())) {
|
|
3029
|
-
throw new Error('Arc is not running. Start Arc normally, then retry.\n' +
|
|
3030
|
-
'Native Arc automation drives your existing browser — it does not launch one.');
|
|
3031
|
-
}
|
|
3032
|
-
if (!profile.arc) {
|
|
3033
|
-
throw new Error(`Native Arc profile "${profile.name}" has no stable native profile metadata. ` +
|
|
3034
|
-
`Re-select it with: agents browser use arc-<profile-id>`);
|
|
3035
|
-
}
|
|
3036
|
-
return {
|
|
3037
|
-
port: 0,
|
|
3038
|
-
pid: 0,
|
|
3039
|
-
electron: false,
|
|
3040
|
-
browserType: 'arc',
|
|
3041
|
-
targetFilter: profile.targetFilter,
|
|
3042
|
-
tasks: this.loadTaskState(key),
|
|
3043
|
-
sessionCache: new Map(),
|
|
3044
|
-
backend: 'arc-native',
|
|
3045
|
-
arcProfile: profile.arc,
|
|
3046
|
-
};
|
|
3047
|
-
}
|
|
3048
|
-
return null;
|
|
3049
|
-
}
|
|
3050
|
-
async enableDomains(cdp) {
|
|
3051
|
-
await cdp.send('Target.setDiscoverTargets', { discover: true });
|
|
3052
|
-
}
|
|
3053
|
-
/**
|
|
3054
|
-
* The single path that opens a new CDP page/window target. It exists so the
|
|
3055
|
-
* Arc guard lives in exactly one place: `Target.createTarget` crashes Arc
|
|
3056
|
-
* (verified, PR #2778), so an Arc connection throws a clear, actionable error
|
|
3057
|
-
* here instead of every call site re-checking. Every drivable Chromium-family
|
|
3058
|
-
* browser goes straight through.
|
|
3059
|
-
*/
|
|
3060
|
-
async createPageTarget(conn, params) {
|
|
3061
|
-
if (conn.browserType === 'arc') {
|
|
3062
|
-
throw arcNotDrivableError(conn.profile);
|
|
3063
|
-
}
|
|
3064
|
-
// Firefox opens tabs through the BiDi driver, never Target.createTarget.
|
|
3065
|
-
requireCdp(conn, 'createTab');
|
|
3066
|
-
return (await conn.cdp.send('Target.createTarget', params));
|
|
3067
|
-
}
|
|
3068
|
-
async getOrCreateWindow(conn) {
|
|
3069
|
-
// Already have a window for this profile?
|
|
3070
|
-
if (conn.windowId) {
|
|
3071
|
-
// Verify it still exists via CDP
|
|
3072
|
-
const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
|
|
3073
|
-
if (targetInfos.some((t) => t.targetId === conn.windowId && t.type === 'page')) {
|
|
3074
|
-
return conn.windowId;
|
|
3075
|
-
}
|
|
3076
|
-
// Window was closed, fall through to find/create new
|
|
3077
|
-
}
|
|
3078
|
-
// Check if browser already has a page target we can use
|
|
3079
|
-
const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
|
|
3080
|
-
const existing = pickWindowTarget(targetInfos, conn.targetFilter);
|
|
3081
|
-
if (existing) {
|
|
3082
|
-
conn.windowId = existing.targetId;
|
|
3083
|
-
return existing.targetId;
|
|
3084
|
-
}
|
|
3085
|
-
// If we have an explicit filter, `pickWindowTarget` returns undefined when nothing
|
|
3086
|
-
// matches. That almost always means the profile is misconfigured (typo in the
|
|
3087
|
-
// filter, target hasn't loaded yet, app version moved the URL). Falling through
|
|
3088
|
-
// to `Target.createTarget` would silently create an orphan tab the user can't see.
|
|
3089
|
-
// Surface the failure instead, with the candidate list so the fix is obvious.
|
|
3090
|
-
if (parseTargetFilter(conn.targetFilter)) {
|
|
3091
|
-
const candidates = targetInfos
|
|
3092
|
-
.filter((t) => t.type === 'page')
|
|
3093
|
-
.map((t) => ` - url=${t.url ?? ''} title=${t.title ?? ''}`)
|
|
3094
|
-
.join('\n');
|
|
3095
|
-
throw new Error(`Target filter ${JSON.stringify(conn.targetFilter)} matched no page target.\n` +
|
|
3096
|
-
`Available page targets:\n${candidates || ' (none)'}`);
|
|
3097
|
-
}
|
|
3098
|
-
// First ever use - create window
|
|
3099
|
-
const result = await this.createPageTarget(conn, {
|
|
3100
|
-
url: 'about:blank',
|
|
3101
|
-
newWindow: true,
|
|
3102
|
-
});
|
|
3103
|
-
conn.windowId = result.targetId;
|
|
3104
|
-
return result.targetId;
|
|
3105
|
-
}
|
|
3106
|
-
hasTaskNamed(name) {
|
|
3107
|
-
for (const conn of this.connections.values()) {
|
|
3108
|
-
if (this.lookupTaskOnConn(conn, name))
|
|
3109
|
-
return true;
|
|
3110
|
-
}
|
|
3111
|
-
return false;
|
|
3112
|
-
}
|
|
3113
|
-
/** The (connection, task) a handle names anywhere, or undefined. */
|
|
3114
|
-
findTaskByHandle(handle) {
|
|
3115
|
-
for (const conn of this.connections.values()) {
|
|
3116
|
-
const task = this.lookupTaskOnConn(conn, handle);
|
|
3117
|
-
if (task)
|
|
3118
|
-
return { conn, task };
|
|
3119
|
-
}
|
|
3120
|
-
return undefined;
|
|
3121
|
-
}
|
|
3122
|
-
/**
|
|
3123
|
-
* Same-name `start --task <name>` retry (PHNX-2399). When a task by that name
|
|
3124
|
-
* already exists this REUSES it — reopening the URL in the same tab (same id,
|
|
3125
|
-
* a real reload, the `Tab already open—refreshed` note) — but only after
|
|
3126
|
-
* proving it is genuinely the same task: same bare profile, same endpoint, and
|
|
3127
|
-
* a matching caller identity. A mismatch on any of those is a real conflict and
|
|
3128
|
-
* throws, so a different caller/profile/endpoint can never silently acquire
|
|
3129
|
-
* another task. Serialized by the `namedstart:` lock in {@link start}.
|
|
3130
|
-
*/
|
|
3131
|
-
async retryNamedStart(existing, profileName, routed, opts) {
|
|
3132
|
-
const { conn, task } = existing;
|
|
3133
|
-
const existingKey = conn.key ?? task.profile;
|
|
3134
|
-
// Profile + endpoint must match what this retry asked for.
|
|
3135
|
-
if (!keyBelongsToProfile(existingKey, profileName)) {
|
|
3136
|
-
throw new Error(actionable(`Task "${opts.taskName}" already exists on profile "${parseConnectionKey(existingKey).profile}", not "${profileName}".`, `Next: stop it first (agents browser done --task ${task.name}) or pick a different --task name.`));
|
|
3137
|
-
}
|
|
3138
|
-
const wantEndpoint = parseConnectionKey(routed.key).endpoint;
|
|
3139
|
-
const haveEndpoint = parseConnectionKey(existingKey).endpoint;
|
|
3140
|
-
if (opts.endpointName && wantEndpoint !== haveEndpoint) {
|
|
3141
|
-
throw new Error(actionable(`Task "${opts.taskName}" already exists on endpoint "${haveEndpoint}", not "${wantEndpoint}".`, `Next: stop it first (agents browser done --task ${task.name}) or pick a different --task name.`));
|
|
3142
|
-
}
|
|
3143
|
-
// Caller identity must match. Both identity-less (a human shell) is fine;
|
|
3144
|
-
// otherwise the SAME session/launch must own it — never another caller's.
|
|
3145
|
-
const caller = { sessionId: opts.sessionId, launchId: opts.launchId };
|
|
3146
|
-
const bothAnon = !task.sessionId && !task.launchId && !caller.sessionId && !caller.launchId;
|
|
3147
|
-
if (!bothAnon && !taskMatchesCaller(task, caller)) {
|
|
3148
|
-
throw new Error(actionable(`Task "${opts.taskName}" already exists and belongs to a different caller.`, `Next: stop it first (agents browser done --task ${task.name}) or pick a different --task name.`));
|
|
3149
|
-
}
|
|
3150
|
-
// Genuine same-task retry. When a URL is given, run the real navigate — which
|
|
3151
|
-
// reopens the same owned tab (refreshed) OR reuses the current tab for a
|
|
3152
|
-
// different URL (created:false, refreshed:false) — and carry its ACTUAL
|
|
3153
|
-
// result. With NO URL there is NO page operation: no reload, no counter
|
|
3154
|
-
// change; it is a pure task reuse (created/refreshed both false).
|
|
3155
|
-
let firstOpen;
|
|
3156
|
-
if (opts.url) {
|
|
3157
|
-
const nav = await this.navigate(task.name, opts.url, existingKey);
|
|
3158
|
-
firstOpen = { tabId: nav.tabId, created: nav.created, refreshed: nav.refreshed, message: nav.message };
|
|
3159
|
-
}
|
|
3160
|
-
else {
|
|
3161
|
-
firstOpen = { tabId: task.currentTabId, created: false, refreshed: false };
|
|
3162
|
-
}
|
|
3163
|
-
let skill;
|
|
3164
|
-
if (opts.url && !opts.skipDomainSkill) {
|
|
3165
|
-
const resolved = resolveDomainSkill(opts.url);
|
|
3166
|
-
if (resolved)
|
|
3167
|
-
skill = resolved;
|
|
3168
|
-
}
|
|
3169
|
-
return {
|
|
3170
|
-
task: task.id,
|
|
3171
|
-
name: task.name,
|
|
3172
|
-
tabId: firstOpen.tabId,
|
|
3173
|
-
profile: profileName,
|
|
3174
|
-
key: existingKey,
|
|
3175
|
-
device: routed.device,
|
|
3176
|
-
picked: routed.picked,
|
|
3177
|
-
skill,
|
|
3178
|
-
reused: true,
|
|
3179
|
-
firstOpen,
|
|
3180
|
-
};
|
|
3181
|
-
}
|
|
3182
|
-
/** Map key for a task on a connection (tasks are keyed by `name`). */
|
|
3183
|
-
taskMapKey(conn, task) {
|
|
3184
|
-
for (const [key, t] of conn.tasks) {
|
|
3185
|
-
if (t === task || t.id === task.id || t.name === task.name)
|
|
3186
|
-
return key;
|
|
3187
|
-
}
|
|
3188
|
-
return undefined;
|
|
3189
|
-
}
|
|
3190
|
-
/** Find a task by map key, id, or name on one connection. */
|
|
3191
|
-
lookupTaskOnConn(conn, handle) {
|
|
3192
|
-
const direct = conn.tasks.get(handle);
|
|
3193
|
-
if (direct)
|
|
3194
|
-
return direct;
|
|
3195
|
-
for (const task of conn.tasks.values()) {
|
|
3196
|
-
if (task.id === handle || task.name === handle)
|
|
3197
|
-
return task;
|
|
3198
|
-
}
|
|
3199
|
-
return undefined;
|
|
3200
|
-
}
|
|
3201
|
-
maybeUpdateLabelFromUrl(conn, task, url) {
|
|
3202
|
-
if (task.label && task.label !== 'untitled')
|
|
3203
|
-
return;
|
|
3204
|
-
const next = deriveTaskLabel({ url, existing: task.label });
|
|
3205
|
-
if (next === task.label)
|
|
3206
|
-
return;
|
|
3207
|
-
task.label = next;
|
|
3208
|
-
void this.saveTaskState(task.profile, conn.tasks).catch(() => { });
|
|
3209
|
-
}
|
|
3210
|
-
/**
|
|
3211
|
-
* Resolve a task for a page/close verb.
|
|
3212
|
-
*
|
|
3213
|
-
* - explicit `task` handle → look up (with disk rehydrate on RAM miss)
|
|
3214
|
-
* - no handle, exactly one live task for this caller → use it
|
|
3215
|
-
* - no handle, multiple for this caller → error listing them + `--task`
|
|
3216
|
-
* - no handle, zero for this caller, createIfMissing → start a new task
|
|
3217
|
-
* - no handle, zero, !createIfMissing → return null (caller reports "nothing to close")
|
|
3218
|
-
*/
|
|
3219
|
-
async resolveOrCreateTask(opts) {
|
|
3220
|
-
// Consent gate — the one chokepoint every drive/mutate verb passes through.
|
|
3221
|
-
// ipc.ts routes each PAGE_RESOLVE_VERB and CLOSE_VERB here via bindTask, so
|
|
3222
|
-
// gating at the top refuses a fleet-remote ATTACH (an explicit --task, or a
|
|
3223
|
-
// lone identity match) exactly as it already refused an implicit CREATE.
|
|
3224
|
-
// Gating only the create branch below left tab-add/navigate/done on an
|
|
3225
|
-
// already-running browser ungated, so a remote caller could drive the
|
|
3226
|
-
// owner's authenticated profile with consent off (RUSH-3064). Read the
|
|
3227
|
-
// per-request marker, never the daemon's env — see remote-control.ts.
|
|
3228
|
-
assertRemoteControlAllowedForRequest(opts.fleetRemote, { actor: opts.actor });
|
|
3229
|
-
if (opts.task) {
|
|
3230
|
-
const found = await this.findTask(opts.task, opts.profile);
|
|
3231
|
-
return { ...found, created: false };
|
|
3232
|
-
}
|
|
3233
|
-
// Serialize identity resolution + implicit creation per caller so two
|
|
3234
|
-
// concurrent first-use requests (e.g. two `navigate <url>` with no --task)
|
|
3235
|
-
// don't both see zero matches and both start a task. The `create:` namespace
|
|
3236
|
-
// is disjoint from the `task:` locks `navigate`/`tabAdd` take, so the nested
|
|
3237
|
-
// `start → navigate` (Arc/Electron) can never wait on a lock this frame holds
|
|
3238
|
-
// (PHNX-2399). Distinct profiles create distinct tasks, so profile is in the
|
|
3239
|
-
// key; identity-less human shells share one `anon` lane.
|
|
3240
|
-
const createKey = `create:${opts.sessionId ?? opts.launchId ?? 'anon'}:${opts.profile ?? 'default'}`;
|
|
3241
|
-
return this.runExclusive(createKey, () => this.resolveOrCreateByIdentity(opts));
|
|
3242
|
-
}
|
|
3243
|
-
async resolveOrCreateByIdentity(opts) {
|
|
3244
|
-
// After a daemon restart the connections map is empty while tasks.json still
|
|
3245
|
-
// holds live tasks. Rehydrate before identity matching so done/screenshot
|
|
3246
|
-
// without --task still find the caller's task.
|
|
3247
|
-
await this.rehydrateAllFromDisk();
|
|
3248
|
-
const caller = {
|
|
3249
|
-
sessionId: opts.sessionId,
|
|
3250
|
-
launchId: opts.launchId,
|
|
3251
|
-
};
|
|
3252
|
-
const matches = this.listTasksForCaller(caller);
|
|
3253
|
-
if (matches.length === 1) {
|
|
3254
|
-
const m = matches[0];
|
|
3255
|
-
await this.prepareTask(m.conn, m.task);
|
|
3256
|
-
await this.touchTask(m.conn, m.task);
|
|
3257
|
-
return { conn: m.conn, task: m.task, key: m.key, created: false };
|
|
3258
|
-
}
|
|
3259
|
-
if (matches.length > 1) {
|
|
3260
|
-
const lines = matches.map((m) => {
|
|
3261
|
-
const ageMs = Date.now() - m.task.createdAt;
|
|
3262
|
-
const age = ageMs < 60_000
|
|
3263
|
-
? `${Math.round(ageMs / 1000)}s`
|
|
3264
|
-
: `${Math.round(ageMs / 60_000)}m`;
|
|
3265
|
-
const label = m.task.label ?? m.task.name;
|
|
3266
|
-
const url = this.currentUrlHint(m.conn, m.task) ?? '-';
|
|
3267
|
-
return ` ${label} id=${m.task.name} url=${url} age=${age}`;
|
|
3268
|
-
});
|
|
3269
|
-
throw new Error(actionable(`Multiple browser tasks for this session — pass --task <name>:`, ...lines, `Next: agents browser status`));
|
|
3270
|
-
}
|
|
3271
|
-
// Zero matches for this caller.
|
|
3272
|
-
if (!opts.createIfMissing)
|
|
3273
|
-
return null;
|
|
3274
|
-
// The top-of-function consent gate already refused a fleet-remote create
|
|
3275
|
-
// here — before ensureDefaultBrowserProfile() below resolves a default — so
|
|
3276
|
-
// a refused request never touches the target machine. Since PHNX-3296 that
|
|
3277
|
-
// resolver never mints a profile: with no launchable default it throws, and
|
|
3278
|
-
// the throw surfaces to the caller exactly like any other start failure.
|
|
3279
|
-
// Implicit start on the default / named profile.
|
|
3280
|
-
let profileName = opts.profile;
|
|
3281
|
-
if (!profileName) {
|
|
3282
|
-
const { ensureDefaultBrowserProfile } = await import('./profiles.js');
|
|
3283
|
-
const detected = await ensureDefaultBrowserProfile();
|
|
3284
|
-
profileName = detected.name;
|
|
3285
|
-
}
|
|
3286
|
-
const started = await this.start(profileName, {
|
|
3287
|
-
fleetRemote: opts.fleetRemote,
|
|
3288
|
-
url: opts.url,
|
|
3289
|
-
actor: opts.actor,
|
|
3290
|
-
launchId: opts.launchId,
|
|
3291
|
-
sessionId: opts.sessionId,
|
|
3292
|
-
title: opts.title,
|
|
3293
|
-
});
|
|
3294
|
-
return {
|
|
3295
|
-
...(await this.findTask(started.name, started.profile)),
|
|
3296
|
-
created: true,
|
|
3297
|
-
picked: started.picked,
|
|
3298
|
-
device: started.device,
|
|
3299
|
-
// `start` already opened opts.url on this tab; the handler must not open
|
|
3300
|
-
// it a second time (a duplicate tab for tab-add; a redundant reload for
|
|
3301
|
-
// navigate). Carry the ACTUAL page result so created/refreshed are truthful.
|
|
3302
|
-
firstOpen: started.firstOpen,
|
|
3303
|
-
};
|
|
3304
|
-
}
|
|
3305
|
-
listTasksForCaller(caller) {
|
|
3306
|
-
const out = [];
|
|
3307
|
-
const hasIdentity = !!(caller.sessionId || caller.launchId);
|
|
3308
|
-
for (const [key, conn] of this.connections) {
|
|
3309
|
-
for (const task of conn.tasks.values()) {
|
|
3310
|
-
if (hasIdentity) {
|
|
3311
|
-
if (taskMatchesCaller(task, caller)) {
|
|
3312
|
-
out.push({ conn, task, key: conn.key ?? key });
|
|
3313
|
-
}
|
|
3314
|
-
}
|
|
3315
|
-
else {
|
|
3316
|
-
// No caller identity: only match tasks that also lack identity
|
|
3317
|
-
// (a human-driven shell). If there is exactly one live task total,
|
|
3318
|
-
// the single-task path still needs that listed — fall through below.
|
|
3319
|
-
if (!task.sessionId && !task.launchId) {
|
|
3320
|
-
out.push({ conn, task, key: conn.key ?? key });
|
|
3321
|
-
}
|
|
3322
|
-
}
|
|
3323
|
-
}
|
|
3324
|
-
}
|
|
3325
|
-
// No-identity caller with zero unscoped matches: if the daemon holds
|
|
3326
|
-
// exactly one live task that ALSO has no identity (a human-started one),
|
|
3327
|
-
// use it. Never steal a task stamped with another caller's session/launch.
|
|
3328
|
-
if (!hasIdentity && out.length === 0) {
|
|
3329
|
-
const unowned = this.listTasks().filter((t) => !t.task.sessionId && !t.task.launchId);
|
|
3330
|
-
if (unowned.length === 1) {
|
|
3331
|
-
const only = unowned[0];
|
|
3332
|
-
const conn = this.connections.get(only.profile) ??
|
|
3333
|
-
[...this.connections.entries()].find(([, c]) => c.tasks.has(only.task.name) || [...c.tasks.values()].includes(only.task))?.[1];
|
|
3334
|
-
if (conn) {
|
|
3335
|
-
out.push({ conn, task: only.task, key: only.profile });
|
|
3336
|
-
}
|
|
3337
|
-
}
|
|
3338
|
-
}
|
|
3339
|
-
return out;
|
|
3340
|
-
}
|
|
3341
|
-
currentUrlHint(conn, task) {
|
|
3342
|
-
const shortId = task.currentTabId;
|
|
3343
|
-
if (!shortId)
|
|
3344
|
-
return undefined;
|
|
3345
|
-
const cdpId = task.tabs[shortId];
|
|
3346
|
-
if (!cdpId)
|
|
3347
|
-
return undefined;
|
|
3348
|
-
const cached = conn.targetCache?.targets.find((t) => t.targetId === cdpId);
|
|
3349
|
-
return cached?.url;
|
|
3350
|
-
}
|
|
3351
|
-
/**
|
|
3352
|
-
* Mark a task as active, so the reaper's idle window measures time since the
|
|
3353
|
-
* last real use rather than time since `start` (RUSH-2622).
|
|
3354
|
-
*
|
|
3355
|
-
* Called from `findTask` — the single funnel every task-scoped operation
|
|
3356
|
-
* resolves through — rather than from each of the ~26 call sites, so a new
|
|
3357
|
-
* action added later cannot forget to stamp it.
|
|
3358
|
-
*
|
|
3359
|
-
* The in-memory stamp is what the reaper reads: it runs inside the same
|
|
3360
|
-
* daemon that owns these `Task` objects. The write to tasks.json exists only
|
|
3361
|
-
* so a daemon RESTART does not inherit a stale stamp and reap a task an agent
|
|
3362
|
-
* has been clicking through for the last half hour — `click`, `type`,
|
|
3363
|
-
* `evaluate`, and `screenshot` never call `saveTaskState` on their own, so
|
|
3364
|
-
* without this the on-disk stamp would sit at the last navigation. It is
|
|
3365
|
-
* coalesced to at most one write per profile per minute so a screenshot loop
|
|
3366
|
-
* does not become a write loop; the resulting on-disk stamp trails by at most
|
|
3367
|
-
* a minute, well inside the 30-minute idle window.
|
|
3368
|
-
*/
|
|
3369
|
-
async touchTask(conn, task) {
|
|
3370
|
-
const now = Date.now();
|
|
3371
|
-
task.lastActionAt = now;
|
|
3372
|
-
const last = this.lastTouchPersist.get(task.profile) ?? 0;
|
|
3373
|
-
if (now - last < BrowserService.TOUCH_PERSIST_INTERVAL_MS)
|
|
3374
|
-
return;
|
|
3375
|
-
this.lastTouchPersist.set(task.profile, now);
|
|
3376
|
-
try {
|
|
3377
|
-
await this.saveTaskState(task.profile, conn.tasks);
|
|
3378
|
-
}
|
|
3379
|
-
catch {
|
|
3380
|
-
// Durability here is best-effort — the in-memory stamp above is
|
|
3381
|
-
// authoritative for the running daemon — and an unwritable runtime dir
|
|
3382
|
-
// must not turn a working browser action into a failure. Same guard the
|
|
3383
|
-
// `recordBrowserSession` call in `start` uses for the same reason.
|
|
3384
|
-
}
|
|
3385
|
-
}
|
|
3386
|
-
async prepareTask(conn, task) {
|
|
3387
|
-
if (conn.backend !== 'arc-native')
|
|
3388
|
-
return;
|
|
3389
|
-
await this.runExclusive('arc-native:host-app', async () => {
|
|
3390
|
-
await this.reconcileArcCreateIntents(conn, task);
|
|
3391
|
-
const native = this.requireArcTask(task);
|
|
3392
|
-
if (native.profileId !== conn.arcProfile.profileId) {
|
|
3393
|
-
throw new Error(`Arc task profile ${JSON.stringify(native.profileId)} does not match connected profile ` +
|
|
3394
|
-
`${JSON.stringify(conn.arcProfile.profileId)}.`);
|
|
3395
|
-
}
|
|
3396
|
-
});
|
|
3397
|
-
}
|
|
3398
|
-
async findTask(taskId, profileRef) {
|
|
3399
|
-
if (profileRef) {
|
|
3400
|
-
// Accept bare profile names against composite connection keys.
|
|
3401
|
-
// One rule: an exact runtime key, else every key belonging to that bare
|
|
3402
|
-
// profile. Callers pass whichever they hold — the composite is theirs to
|
|
3403
|
-
// ignore (RUSH-2709).
|
|
3404
|
-
const keys = [...this.connections.keys()].filter((k) => k === profileRef || keyBelongsToProfile(k, profileRef));
|
|
3405
|
-
for (const key of keys) {
|
|
3406
|
-
const conn = this.connections.get(key);
|
|
3407
|
-
const task = this.lookupTaskOnConn(conn, taskId);
|
|
3408
|
-
if (task) {
|
|
3409
|
-
await this.prepareTask(conn, task);
|
|
3410
|
-
await this.touchTask(conn, task);
|
|
3411
|
-
return { conn, task, key: conn.key ?? key };
|
|
3412
|
-
}
|
|
3413
|
-
}
|
|
3414
|
-
// RAM miss → rehydrate from disk for this profile.
|
|
3415
|
-
const rehydrated = await this.rehydrateTaskFromDisk(taskId, profileRef);
|
|
3416
|
-
if (rehydrated) {
|
|
3417
|
-
await this.prepareTask(rehydrated.conn, rehydrated.task);
|
|
3418
|
-
await this.touchTask(rehydrated.conn, rehydrated.task);
|
|
3419
|
-
return rehydrated;
|
|
3420
|
-
}
|
|
3421
|
-
throw new Error(actionable(`Task "${taskId}" not found on profile "${profileRef}".`, `Next: agents browser status --profile ${parseConnectionKey(profileRef).profile}`));
|
|
3422
|
-
}
|
|
3423
|
-
for (const [key, conn] of this.connections) {
|
|
3424
|
-
const task = this.lookupTaskOnConn(conn, taskId);
|
|
3425
|
-
if (task) {
|
|
3426
|
-
await this.prepareTask(conn, task);
|
|
3427
|
-
await this.touchTask(conn, task);
|
|
3428
|
-
return { conn, task, key: conn.key ?? key };
|
|
3429
|
-
}
|
|
3430
|
-
}
|
|
3431
|
-
// RAM miss after a daemon restart: tasks.json still has the task and
|
|
3432
|
-
// meta.json still has the browser pid/port. Reconnect and adopt.
|
|
3433
|
-
const rehydrated = await this.rehydrateTaskFromDisk(taskId);
|
|
3434
|
-
if (rehydrated) {
|
|
3435
|
-
await this.prepareTask(rehydrated.conn, rehydrated.task);
|
|
3436
|
-
await this.touchTask(rehydrated.conn, rehydrated.task);
|
|
3437
|
-
return rehydrated;
|
|
3438
|
-
}
|
|
3439
|
-
const live = this.listTasks()
|
|
3440
|
-
.map(({ task }) => task.label ?? task.name)
|
|
3441
|
-
.join(', ');
|
|
3442
|
-
throw new Error(actionable(`Task "${taskId}" not found.`, `Active tasks: ${live || 'none'}`, `It may have been closed by the idle reaper (30 min).`, `Next: agents browser status | agents browser navigate <url>`));
|
|
3443
|
-
}
|
|
3444
|
-
/**
|
|
3445
|
-
* Soft-attach to an already-running browser for a runtime dir.
|
|
3446
|
-
*
|
|
3447
|
-
* Never launches a browser and never clears pid/port files on failure —
|
|
3448
|
-
* `connectProfile` does both, which made `status()` wipe disk state when
|
|
3449
|
-
* CDP was unreachable (CI / post-reaper) and then return an empty list.
|
|
3450
|
-
* Returns null when nothing is speaking CDP so callers can fall through
|
|
3451
|
-
* to disk reconcile.
|
|
3452
|
-
*/
|
|
3453
|
-
async attachRunningProfile(key, diskTasks,
|
|
3454
|
-
/**
|
|
3455
|
-
* Shared Arc liveness for one status pass; each saved Arc runtime used to
|
|
3456
|
-
* probe on its own. Callers with no pass to share pass nothing and probe
|
|
3457
|
-
* directly, as before.
|
|
3458
|
-
*/
|
|
3459
|
-
arcRunning) {
|
|
3460
|
-
const { profile: bare, endpoint: endpointFromKey } = parseConnectionKey(key);
|
|
3461
|
-
const profile = await getProfile(bare);
|
|
3462
|
-
if (!profile)
|
|
3463
|
-
return null;
|
|
3464
|
-
let resolved;
|
|
3465
|
-
try {
|
|
3466
|
-
// Legacy keys encode the preset (`endpoint-0`). New keys encode the
|
|
3467
|
-
// declaring device, which is not a preset name — resolve via registry.
|
|
3468
|
-
if (endpointFromKey && /^endpoint-\d+$/.test(endpointFromKey)) {
|
|
3469
|
-
resolved = resolveEndpoint(profile, endpointFromKey);
|
|
3470
|
-
}
|
|
3471
|
-
else {
|
|
3472
|
-
const routed = resolveBrowserTarget(bare);
|
|
3473
|
-
resolved = { name: routed.device, target: routed.target };
|
|
3474
|
-
}
|
|
3475
|
-
}
|
|
3476
|
-
catch {
|
|
3477
|
-
return null;
|
|
3478
|
-
}
|
|
3479
|
-
// Native Arc endpoints (PHNX-2399): reconnect via the native driver if Arc
|
|
3480
|
-
// is still running. No port, no CDP — just verify Arc is alive and restore
|
|
3481
|
-
// the task state.
|
|
3482
|
-
if (resolved.target.startsWith('arc-native:')) {
|
|
3483
|
-
if (process.platform !== 'darwin' || !(await (arcRunning ?? isArcRunning)()))
|
|
3484
|
-
return null;
|
|
3485
|
-
const tasks = this.loadTaskState(key);
|
|
3486
|
-
for (const [k, t] of diskTasks) {
|
|
3487
|
-
if (!tasks.has(k))
|
|
3488
|
-
tasks.set(k, t);
|
|
3489
|
-
}
|
|
3490
|
-
if (!profile.arc)
|
|
3491
|
-
return null;
|
|
3492
|
-
return {
|
|
3493
|
-
port: 0,
|
|
3494
|
-
pid: 0,
|
|
3495
|
-
electron: false,
|
|
3496
|
-
browserType: 'arc',
|
|
3497
|
-
targetFilter: profile.targetFilter,
|
|
3498
|
-
key,
|
|
3499
|
-
profile: bare,
|
|
3500
|
-
tasks,
|
|
3501
|
-
sessionCache: new Map(),
|
|
3502
|
-
backend: 'arc-native',
|
|
3503
|
-
arcProfile: profile.arc,
|
|
3504
|
-
};
|
|
3505
|
-
}
|
|
3506
|
-
// Firefox BiDi endpoints (PHNX-4043): a daemon restart dropped the BiDi
|
|
3507
|
-
// socket, but the Firefox we launched is still serving the port — reattach
|
|
3508
|
-
// by opening a fresh session, then merge the disk tasks. connectFirefox
|
|
3509
|
-
// attaches (never launches) when the port is already served. A failure
|
|
3510
|
-
// returns null like the CDP path, so the caller falls through to disk
|
|
3511
|
-
// reconcile instead of crashing.
|
|
3512
|
-
if (resolved.target.startsWith('firefox-bidi:')) {
|
|
3513
|
-
if (!profile.firefox || !profile.userDataDir)
|
|
3514
|
-
return null;
|
|
3515
|
-
const port = parseEndpointUrl(resolved.target)?.port ?? 9600;
|
|
3516
|
-
try {
|
|
3517
|
-
const ff = await connectFirefox(profile, key, port, { profileDir: profile.userDataDir });
|
|
3518
|
-
// A served port that accepted a fresh BiDi session is this profile's own
|
|
3519
|
-
// Firefox, so claim the record for this daemon (see the local-CDP claim
|
|
3520
|
-
// below for why adoption waits for a proven attach).
|
|
3521
|
-
adoptProfileRuntimeOwner(key);
|
|
3522
|
-
const tasks = this.loadTaskState(key);
|
|
3523
|
-
for (const [k, t] of diskTasks) {
|
|
3524
|
-
if (!tasks.has(k))
|
|
3525
|
-
tasks.set(k, t);
|
|
3526
|
-
}
|
|
3527
|
-
return {
|
|
3528
|
-
backend: 'bidi',
|
|
3529
|
-
browserType: 'firefox',
|
|
3530
|
-
bidi: ff.bidi,
|
|
3531
|
-
firefoxProfile: profile.firefox,
|
|
3532
|
-
sessionId: ff.sessionId,
|
|
3533
|
-
profileDir: profile.userDataDir,
|
|
3534
|
-
port: ff.port,
|
|
3535
|
-
pid: ff.pid,
|
|
3536
|
-
electron: false,
|
|
3537
|
-
targetFilter: profile.targetFilter,
|
|
3538
|
-
key,
|
|
3539
|
-
profile: bare,
|
|
3540
|
-
tasks,
|
|
3541
|
-
sessionCache: new Map(),
|
|
3542
|
-
};
|
|
3543
|
-
}
|
|
3544
|
-
catch {
|
|
3545
|
-
return null;
|
|
3546
|
-
}
|
|
3547
|
-
}
|
|
3548
|
-
const existingInfo = getRunningChromeInfo(key);
|
|
3549
|
-
const parsed = parseEndpointUrl(resolved.target);
|
|
3550
|
-
const port = existingInfo?.port ?? parsed?.port;
|
|
3551
|
-
if (port === undefined)
|
|
3552
|
-
return null;
|
|
3553
|
-
const host = parsed?.host && parsed.host !== 'localhost' ? parsed.host : 'localhost';
|
|
3554
|
-
// ssh:// endpoints: a daemon restart killed this box's tunnel, but the
|
|
3555
|
-
// browser is still running on the FAR side — so the caller's tasks are
|
|
3556
|
-
// valid, only the local hop is gone. Re-establish the tunnel and reconnect
|
|
3557
|
-
// CDP (connectSSH attaches to the already-running remote browser via
|
|
3558
|
-
// isOwnTunnel — it never launches one, so this stays a soft-attach), then
|
|
3559
|
-
// merge the disk tasks. Without this, a remote agent driving a browser host
|
|
3560
|
-
// after a restart got "Unknown browser task" for a tab that was still alive
|
|
3561
|
-
// (PHNX-2663). A failure returns null exactly like the local-CDP path, so
|
|
3562
|
-
// the caller falls through to disk reconcile rather than crashing.
|
|
3563
|
-
if (resolved.target.startsWith('ssh:')) {
|
|
3564
|
-
try {
|
|
3565
|
-
const conn = await connectSSH(resolved.target, profile, key, { persistRemote: true });
|
|
3566
|
-
await this.enableDomains(conn.cdp);
|
|
3567
|
-
const tasks = this.loadTaskState(key);
|
|
3568
|
-
for (const [k, t] of diskTasks) {
|
|
3569
|
-
if (!tasks.has(k))
|
|
3570
|
-
tasks.set(k, t);
|
|
3571
|
-
}
|
|
3572
|
-
return {
|
|
3573
|
-
backend: 'cdp',
|
|
3574
|
-
cdp: conn.cdp,
|
|
3575
|
-
port: conn.port,
|
|
3576
|
-
pid: conn.pid,
|
|
3577
|
-
electron: profile.electron,
|
|
3578
|
-
browserType: profile.browser,
|
|
3579
|
-
targetFilter: resolved.targetFilter ?? profile.targetFilter,
|
|
3580
|
-
key,
|
|
3581
|
-
profile: bare,
|
|
3582
|
-
tasks,
|
|
3583
|
-
sessionCache: new Map(),
|
|
3584
|
-
// Carry the tunnel teardown so removing this connection kills the
|
|
3585
|
-
// ssh hop — otherwise it leaks across the next restart (see the
|
|
3586
|
-
// `cleanup` docblock on ProfileConnection).
|
|
3587
|
-
cleanup: conn.cleanup,
|
|
3588
|
-
};
|
|
3589
|
-
}
|
|
3590
|
-
catch {
|
|
3591
|
-
return null;
|
|
3592
|
-
}
|
|
3593
|
-
}
|
|
3594
|
-
try {
|
|
3595
|
-
const { wsUrl, browser } = await discoverBrowserWsUrl(port, host, bare);
|
|
3596
|
-
verifyBrowserIdentity(browser, profile.browser, port, host);
|
|
3597
|
-
const cdp = new CDPClient();
|
|
3598
|
-
await cdp.connect(wsUrl);
|
|
3599
|
-
await this.enableDomains(cdp);
|
|
3600
|
-
// This is the path a replacement daemon takes to recover a browser its
|
|
3601
|
-
// predecessor launched, so it is where ownership actually transfers. Only
|
|
3602
|
-
// now — after `verifyBrowserIdentity` confirmed the browser family on the
|
|
3603
|
-
// recorded port and CDP is attached — is it proven that this record
|
|
3604
|
-
// describes a browser we are driving. `adoptProfileRuntimeOwner` rewrites
|
|
3605
|
-
// `daemonPid` alone; an ambiguous identity throws above and adopts
|
|
3606
|
-
// nothing, leaving the record (and the browser) untouched.
|
|
3607
|
-
adoptProfileRuntimeOwner(key);
|
|
3608
|
-
const tasks = this.loadTaskState(key);
|
|
3609
|
-
for (const [k, t] of diskTasks) {
|
|
3610
|
-
if (!tasks.has(k))
|
|
3611
|
-
tasks.set(k, t);
|
|
3612
|
-
}
|
|
3613
|
-
const conn = {
|
|
3614
|
-
backend: 'cdp',
|
|
3615
|
-
cdp,
|
|
3616
|
-
port,
|
|
3617
|
-
pid: existingInfo?.pid ?? 0,
|
|
3618
|
-
electron: profile.electron,
|
|
3619
|
-
browserType: profile.browser,
|
|
3620
|
-
targetFilter: resolved.targetFilter ?? profile.targetFilter,
|
|
3621
|
-
key,
|
|
3622
|
-
profile: bare,
|
|
3623
|
-
tasks,
|
|
3624
|
-
sessionCache: new Map(),
|
|
3625
|
-
};
|
|
3626
|
-
return conn;
|
|
3627
|
-
}
|
|
3628
|
-
catch {
|
|
3629
|
-
return null;
|
|
3630
|
-
}
|
|
3631
|
-
}
|
|
3632
|
-
/**
|
|
3633
|
-
* Reconnect every profile runtime dir that still has a live browser and
|
|
3634
|
-
* tasks.json entries. Used before identity-based resolution so a daemon
|
|
3635
|
-
* restart does not make the caller's tasks invisible.
|
|
3636
|
-
*/
|
|
3637
|
-
/**
|
|
3638
|
-
* Register a freshly-rehydrated connection, unless a concurrent rehydrate won
|
|
3639
|
-
* the race and already registered one for this key. `attachRunningProfile`
|
|
3640
|
-
* awaits an ssh-tunnel spawn / CDP connect, so two commands landing right
|
|
3641
|
-
* after a daemon restart can each build a connection for the same key; the
|
|
3642
|
-
* loser must be released instead of silently overwriting the winner (whose
|
|
3643
|
-
* `cleanup` would then never run).
|
|
3644
|
-
*
|
|
3645
|
-
* The subtlety is the SSH tunnel: when the loser's `connectSSH` landed after
|
|
3646
|
-
* the winner's tunnel had already bound the local port, `isOwnTunnel` makes it
|
|
3647
|
-
* **reuse** that same OS process (`drivers/ssh.ts`) — so both connections carry
|
|
3648
|
-
* the same `pid`/`port`. Calling the loser's `cleanup()` then does
|
|
3649
|
-
* `tunnel.kill()` on the SHARED process and breaks the winner's live CDP. So
|
|
3650
|
-
* when the loser shares the winner's tunnel we close ONLY the loser's own CDP
|
|
3651
|
-
* socket and leave the tunnel to the winner; only a loser that spawned its own
|
|
3652
|
-
* distinct tunnel gets the full `cleanup()` (else it would leak). Returns the
|
|
3653
|
-
* winning connection to use.
|
|
3654
|
-
*/
|
|
3655
|
-
registerRehydratedConnection(key, conn) {
|
|
3656
|
-
const existing = this.connections.get(key);
|
|
3657
|
-
if (!existing) {
|
|
3658
|
-
this.connections.set(key, conn);
|
|
3659
|
-
return conn;
|
|
3660
|
-
}
|
|
3661
|
-
const sharesTunnel = conn.pid !== 0 && conn.pid === existing.pid && conn.port === existing.port;
|
|
3662
|
-
if (conn.pid !== 0 && !sharesTunnel && conn.cleanup) {
|
|
3663
|
-
// Distinct tunnel — full teardown so this loser's tunnel doesn't leak.
|
|
3664
|
-
try {
|
|
3665
|
-
conn.cleanup();
|
|
3666
|
-
}
|
|
3667
|
-
catch { /* best effort — the winner stays live */ }
|
|
3668
|
-
}
|
|
3669
|
-
else {
|
|
3670
|
-
// Shared/borrowed tunnel (or a local connection with no tunnel): close
|
|
3671
|
-
// only our own client; killing the tunnel would break the winner.
|
|
3672
|
-
if (conn.backend === 'bidi') {
|
|
3673
|
-
try {
|
|
3674
|
-
conn.bidi.close();
|
|
3675
|
-
}
|
|
3676
|
-
catch { /* best effort */ }
|
|
3677
|
-
}
|
|
3678
|
-
else if (conn.backend !== 'arc-native') {
|
|
3679
|
-
try {
|
|
3680
|
-
conn.cdp.close();
|
|
3681
|
-
}
|
|
3682
|
-
catch { /* best effort */ }
|
|
3683
|
-
}
|
|
3684
|
-
}
|
|
3685
|
-
return existing;
|
|
3686
|
-
}
|
|
3687
|
-
async rehydrateAllFromDisk(arcRunning,
|
|
3688
|
-
/**
|
|
3689
|
-
* Read-only callers pass a sink to make rehydration per-runtime: one saved
|
|
3690
|
-
* dir that cannot be read (corrupt `tasks.json`, an Arc Apple Event that
|
|
3691
|
-
* times out) is reported through this instead of aborting the pass. Callers
|
|
3692
|
-
* without a sink keep the strict behaviour and the error propagates.
|
|
3693
|
-
*/
|
|
3694
|
-
onError) {
|
|
3695
|
-
const runtimeRoot = getBrowserRuntimeDir();
|
|
3696
|
-
let dirNames = [];
|
|
3697
|
-
try {
|
|
3698
|
-
dirNames = fs.readdirSync(runtimeRoot).filter((d) => {
|
|
3699
|
-
try {
|
|
3700
|
-
return fs.statSync(path.join(runtimeRoot, d)).isDirectory();
|
|
3701
|
-
}
|
|
3702
|
-
catch {
|
|
3703
|
-
return false;
|
|
3704
|
-
}
|
|
3705
|
-
});
|
|
3706
|
-
}
|
|
3707
|
-
catch {
|
|
3708
|
-
return;
|
|
3709
|
-
}
|
|
3710
|
-
for (const dirName of dirNames) {
|
|
3711
|
-
// A runtime dir name IS a connection key — that is what wrote it.
|
|
3712
|
-
const key = asConnectionKey(dirName);
|
|
3713
|
-
if (this.connections.has(key))
|
|
3714
|
-
continue;
|
|
3715
|
-
// Skip non-runtime dirs (e.g. profiles/)
|
|
3716
|
-
if (dirName === 'profiles' || dirName === 'sessions' || dirName === 'exports')
|
|
3717
|
-
continue;
|
|
3718
|
-
try {
|
|
3719
|
-
const tasks = this.loadTaskState(key);
|
|
3720
|
-
if (tasks.size === 0)
|
|
3721
|
-
continue;
|
|
3722
|
-
// Soft attach only — never launch / never clear pid files.
|
|
3723
|
-
const conn = await this.attachRunningProfile(key, tasks, arcRunning);
|
|
3724
|
-
if (!conn)
|
|
3725
|
-
continue;
|
|
3726
|
-
// A concurrent rehydrate may have registered one for this key while we
|
|
3727
|
-
// awaited the attach; keep the winner and tear our loser's tunnel down.
|
|
3728
|
-
const registered = this.registerRehydratedConnection(key, conn);
|
|
3729
|
-
if (registered !== conn)
|
|
3730
|
-
continue;
|
|
3731
|
-
try {
|
|
3732
|
-
if (conn.backend !== 'arc-native' && conn.backend !== 'bidi')
|
|
3733
|
-
await this.applyDefaultDownloadBehavior(conn, key);
|
|
3734
|
-
}
|
|
3735
|
-
catch {
|
|
3736
|
-
// Non-fatal for rehydrate.
|
|
3737
|
-
}
|
|
3738
|
-
}
|
|
3739
|
-
catch (err) {
|
|
3740
|
-
if (!onError)
|
|
3741
|
-
throw err;
|
|
3742
|
-
onError(key, err);
|
|
3743
|
-
}
|
|
3744
|
-
}
|
|
3745
|
-
}
|
|
3746
|
-
/**
|
|
3747
|
-
* On a RAM miss, scan profile runtime dirs for a tasks.json entry matching
|
|
3748
|
-
* `taskId`, soft-attach when CDP is still up, and register the connection.
|
|
3749
|
-
* Does not launch browsers or clear runtime files.
|
|
3750
|
-
*/
|
|
3751
|
-
async rehydrateTaskFromDisk(taskId, profileHint) {
|
|
3752
|
-
const runtimeRoot = getBrowserRuntimeDir();
|
|
3753
|
-
let dirNames = [];
|
|
3754
|
-
try {
|
|
3755
|
-
dirNames = fs.readdirSync(runtimeRoot).filter((d) => {
|
|
3756
|
-
try {
|
|
3757
|
-
return fs.statSync(path.join(runtimeRoot, d)).isDirectory();
|
|
3758
|
-
}
|
|
3759
|
-
catch {
|
|
3760
|
-
return false;
|
|
3761
|
-
}
|
|
3762
|
-
});
|
|
3763
|
-
}
|
|
3764
|
-
catch {
|
|
3765
|
-
return null;
|
|
3766
|
-
}
|
|
3767
|
-
if (profileHint) {
|
|
3768
|
-
dirNames = dirNames.filter((d) => d === profileHint || keyBelongsToProfile(d, profileHint));
|
|
3769
|
-
}
|
|
3770
|
-
for (const dirName of dirNames) {
|
|
3771
|
-
if (dirName === 'profiles' || dirName === 'sessions' || dirName === 'exports')
|
|
3772
|
-
continue;
|
|
3773
|
-
const key = asConnectionKey(dirName);
|
|
3774
|
-
const tasks = this.loadTaskState(key);
|
|
3775
|
-
let found;
|
|
3776
|
-
for (const task of tasks.values()) {
|
|
3777
|
-
if (task.id === taskId || task.name === taskId) {
|
|
3778
|
-
found = task;
|
|
3779
|
-
break;
|
|
3780
|
-
}
|
|
3781
|
-
}
|
|
3782
|
-
// Also allow map-key match
|
|
3783
|
-
if (!found)
|
|
3784
|
-
found = tasks.get(taskId);
|
|
3785
|
-
if (!found)
|
|
3786
|
-
continue;
|
|
3787
|
-
// Already connected under this key?
|
|
3788
|
-
let conn = this.connections.get(key);
|
|
3789
|
-
if (!conn) {
|
|
3790
|
-
const attached = await this.attachRunningProfile(key, tasks);
|
|
3791
|
-
if (!attached) {
|
|
3792
|
-
// CDP down — cannot rehydrate a live connection; caller will error.
|
|
3793
|
-
continue;
|
|
3794
|
-
}
|
|
3795
|
-
// Keep whichever connection a concurrent rehydrate registered first and
|
|
3796
|
-
// tear our loser's tunnel down instead of leaking it (see the helper).
|
|
3797
|
-
conn = this.registerRehydratedConnection(key, attached);
|
|
3798
|
-
if (conn === attached) {
|
|
3799
|
-
try {
|
|
3800
|
-
if (conn.backend !== 'arc-native' && conn.backend !== 'bidi')
|
|
3801
|
-
await this.applyDefaultDownloadBehavior(conn, key);
|
|
3802
|
-
}
|
|
3803
|
-
catch {
|
|
3804
|
-
// Non-fatal.
|
|
3805
|
-
}
|
|
3806
|
-
}
|
|
3807
|
-
}
|
|
3808
|
-
const task = this.lookupTaskOnConn(conn, taskId) ?? found;
|
|
3809
|
-
// Ensure the rehydrated task is on the connection map.
|
|
3810
|
-
if (!this.lookupTaskOnConn(conn, task.name)) {
|
|
3811
|
-
conn.tasks.set(task.name, task);
|
|
3812
|
-
}
|
|
3813
|
-
return { conn, task, key: conn.key ?? key };
|
|
3814
|
-
}
|
|
3815
|
-
return null;
|
|
3816
|
-
}
|
|
3817
|
-
async getTabsForTask(cdp, task) {
|
|
3818
|
-
const targets = (await cdp.send('Target.getTargets'));
|
|
3819
|
-
const tabs = [];
|
|
3820
|
-
for (const [shortId, cdpId] of Object.entries(task.tabs)) {
|
|
3821
|
-
const target = targets.targetInfos.find((t) => t.targetId === cdpId);
|
|
3822
|
-
if (target) {
|
|
3823
|
-
tabs.push({
|
|
3824
|
-
id: shortId,
|
|
3825
|
-
url: target.url,
|
|
3826
|
-
title: target.title,
|
|
3827
|
-
task: task.name,
|
|
3828
|
-
});
|
|
3829
|
-
}
|
|
3830
|
-
}
|
|
3831
|
-
return tabs;
|
|
3832
|
-
}
|
|
3833
|
-
/** Live status for ONE registered connection, addressed by its runtime key. */
|
|
3834
|
-
async getProfileStatus(key, arc) {
|
|
3835
|
-
const conn = this.connections.get(key);
|
|
3836
|
-
if (!conn)
|
|
3837
|
-
return null;
|
|
3838
|
-
if (conn.backend === 'arc-native') {
|
|
3839
|
-
// One snapshot and one liveness read for the whole pass, shared across
|
|
3840
|
-
// every task and every Arc profile (built in `status`).
|
|
3841
|
-
const spaces = arc?.spaces ?? onceAsync(() => enumerateArcSpaces());
|
|
3842
|
-
const running = arc?.running ?? onceAsync(() => isArcRunning());
|
|
3843
|
-
const tasks = [];
|
|
3844
|
-
for (const task of conn.tasks.values()) {
|
|
3845
|
-
// The moved-tab refusal is the ownership guarantee actions rely on and
|
|
3846
|
-
// stays strict. `status` only reads, so one stale task must not take down
|
|
3847
|
-
// its neighbours: report what disk knows plus the reason, and leave
|
|
3848
|
-
// `tabs` ABSENT rather than empty so nothing claims a moved tab is live.
|
|
3849
|
-
let tabs = [];
|
|
3850
|
-
let unavailable;
|
|
3851
|
-
try {
|
|
3852
|
-
tabs = await this.listArcTaskTabsFromSnapshot(task, spaces);
|
|
3853
|
-
}
|
|
3854
|
-
catch (err) {
|
|
3855
|
-
unavailable = err instanceof Error ? err.message : String(err);
|
|
3856
|
-
}
|
|
3857
|
-
const domains = tabs.flatMap((tab) => {
|
|
3858
|
-
try {
|
|
3859
|
-
const domain = new URL(tab.url).hostname.replace(/^www\./, '');
|
|
3860
|
-
return domain && domain !== 'blank' ? [domain] : [];
|
|
3861
|
-
}
|
|
3862
|
-
catch {
|
|
3863
|
-
return [];
|
|
3864
|
-
}
|
|
3865
|
-
});
|
|
3866
|
-
tasks.push({
|
|
3867
|
-
id: task.id,
|
|
3868
|
-
name: task.name,
|
|
3869
|
-
label: task.label ?? task.name,
|
|
3870
|
-
tabCount: Object.keys(task.tabs).length,
|
|
3871
|
-
currentTabId: task.currentTabId,
|
|
3872
|
-
createdAt: task.createdAt,
|
|
3873
|
-
tabs: tabs.length ? tabs : undefined,
|
|
3874
|
-
domains: [...new Set(domains)],
|
|
3875
|
-
unavailable,
|
|
3876
|
-
});
|
|
3877
|
-
}
|
|
3878
|
-
const parsed = parseConnectionKey(key);
|
|
3879
|
-
return {
|
|
3880
|
-
name: conn.profile ?? parsed.profile,
|
|
3881
|
-
endpoint: parsed.endpoint,
|
|
3882
|
-
key,
|
|
3883
|
-
running: await running(),
|
|
3884
|
-
port: 0,
|
|
3885
|
-
pid: 0,
|
|
3886
|
-
tasks,
|
|
3887
|
-
};
|
|
3888
|
-
}
|
|
3889
|
-
if (conn.backend === 'bidi') {
|
|
3890
|
-
const tasks = [];
|
|
3891
|
-
for (const task of conn.tasks.values()) {
|
|
3892
|
-
let tabs = [];
|
|
3893
|
-
try {
|
|
3894
|
-
tabs = await this.listFirefoxTaskTabs(conn, task);
|
|
3895
|
-
}
|
|
3896
|
-
catch { /* Firefox down */ }
|
|
3897
|
-
const domains = tabs.flatMap((tab) => {
|
|
3898
|
-
try {
|
|
3899
|
-
const domain = new URL(tab.url).hostname.replace(/^www\./, '');
|
|
3900
|
-
return domain && domain !== 'blank' ? [domain] : [];
|
|
3901
|
-
}
|
|
3902
|
-
catch {
|
|
3903
|
-
return [];
|
|
3904
|
-
}
|
|
3905
|
-
});
|
|
3906
|
-
tasks.push({
|
|
3907
|
-
id: task.id,
|
|
3908
|
-
name: task.name,
|
|
3909
|
-
label: task.label ?? task.name,
|
|
3910
|
-
tabCount: Object.keys(task.tabs).length,
|
|
3911
|
-
currentTabId: task.currentTabId,
|
|
3912
|
-
createdAt: task.createdAt,
|
|
3913
|
-
tabs: tabs.length ? tabs.map((t) => ({ id: t.id, url: t.url, title: t.title, current: t.current })) : undefined,
|
|
3914
|
-
domains: domains.length ? [...new Set(domains)] : undefined,
|
|
3915
|
-
});
|
|
3916
|
-
}
|
|
3917
|
-
const parsed = parseConnectionKey(key);
|
|
3918
|
-
return {
|
|
3919
|
-
name: conn.profile ?? parsed.profile,
|
|
3920
|
-
endpoint: parsed.endpoint,
|
|
3921
|
-
key,
|
|
3922
|
-
running: conn.bidi.isOpen,
|
|
3923
|
-
port: conn.port,
|
|
3924
|
-
pid: conn.pid,
|
|
3925
|
-
tasks,
|
|
3926
|
-
};
|
|
3927
|
-
}
|
|
3928
|
-
// Fetch all targets once for efficiency
|
|
3929
|
-
let targets = [];
|
|
3930
|
-
try {
|
|
3931
|
-
const result = (await conn.cdp.send('Target.getTargets'));
|
|
3932
|
-
targets = result.targetInfos;
|
|
3933
|
-
}
|
|
3934
|
-
catch {
|
|
3935
|
-
// CDP not responding, fall back to metadata only
|
|
3936
|
-
}
|
|
3937
|
-
const tasks = [];
|
|
3938
|
-
for (const [, task] of conn.tasks) {
|
|
3939
|
-
const tabs = [];
|
|
3940
|
-
const domainSet = new Set();
|
|
3941
|
-
for (const [shortId, cdpId] of Object.entries(task.tabs)) {
|
|
3942
|
-
const target = targets.find((t) => t.targetId === cdpId);
|
|
3943
|
-
if (target) {
|
|
3944
|
-
tabs.push({
|
|
3945
|
-
id: shortId,
|
|
3946
|
-
url: target.url,
|
|
3947
|
-
title: target.title,
|
|
3948
|
-
current: shortId === task.currentTabId,
|
|
3949
|
-
});
|
|
3950
|
-
try {
|
|
3951
|
-
const domain = new URL(target.url).hostname.replace(/^www\./, '');
|
|
3952
|
-
if (domain && domain !== 'blank')
|
|
3953
|
-
domainSet.add(domain);
|
|
3954
|
-
}
|
|
3955
|
-
catch {
|
|
3956
|
-
// invalid URL
|
|
3957
|
-
}
|
|
3958
|
-
}
|
|
3959
|
-
}
|
|
3960
|
-
tasks.push({
|
|
3961
|
-
id: task.id,
|
|
3962
|
-
name: task.name,
|
|
3963
|
-
label: task.label ?? task.name,
|
|
3964
|
-
tabCount: Object.keys(task.tabs).length,
|
|
3965
|
-
currentTabId: task.currentTabId,
|
|
3966
|
-
createdAt: task.createdAt,
|
|
3967
|
-
tabs: tabs.length > 0 ? tabs : undefined,
|
|
3968
|
-
domains: domainSet.size > 0 ? Array.from(domainSet) : undefined,
|
|
3969
|
-
});
|
|
3970
|
-
}
|
|
3971
|
-
const parsed = parseConnectionKey(key);
|
|
3972
|
-
const bare = conn.profile ?? parsed.profile;
|
|
3973
|
-
const profile = await getProfile(bare);
|
|
3974
|
-
const configuredPort = profile ? extractConfiguredPort(profile) : undefined;
|
|
3975
|
-
return {
|
|
3976
|
-
name: bare,
|
|
3977
|
-
endpoint: parsed.endpoint,
|
|
3978
|
-
key,
|
|
3979
|
-
running: true,
|
|
3980
|
-
port: conn.port,
|
|
3981
|
-
pid: conn.pid,
|
|
3982
|
-
configuredPort: configuredPort !== conn.port ? configuredPort : undefined,
|
|
3983
|
-
tasks,
|
|
3984
|
-
};
|
|
3985
|
-
}
|
|
3986
|
-
async getTarget(conn, tabId) {
|
|
3987
|
-
const now = Date.now();
|
|
3988
|
-
if (!conn.targetCache || now - conn.targetCache.ts > 1000) {
|
|
3989
|
-
const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
|
|
3990
|
-
conn.targetCache = { targets: targetInfos, ts: now };
|
|
3991
|
-
}
|
|
3992
|
-
return conn.targetCache.targets.find((target) => target.targetId === tabId);
|
|
3993
|
-
}
|
|
3994
|
-
async getSessionId(conn, tabId) {
|
|
3995
|
-
const cachedSessionId = conn.sessionCache.get(tabId);
|
|
3996
|
-
if (cachedSessionId) {
|
|
3997
|
-
return cachedSessionId;
|
|
3998
|
-
}
|
|
3999
|
-
const { sessionId } = (await conn.cdp.send('Target.attachToTarget', {
|
|
4000
|
-
targetId: tabId,
|
|
4001
|
-
flatten: true,
|
|
4002
|
-
}));
|
|
4003
|
-
// Inject a stealth shim before any page script runs. Chromium exposes
|
|
4004
|
-
// navigator.webdriver = true whenever a remote-debug transport is attached;
|
|
4005
|
-
// Cloudflare Turnstile, hCaptcha, and similar bot checks read it first.
|
|
4006
|
-
//
|
|
4007
|
-
// Only attach-to-running profiles (conn.pid === 0 — Comet / Arc / Brave the
|
|
4008
|
-
// user launched themselves) need this. Browsers agents-cli spawns already
|
|
4009
|
-
// carry the --disable-blink-features=AutomationControlled launch flag, which
|
|
4010
|
-
// makes navigator.webdriver a native Navigator.prototype getter returning
|
|
4011
|
-
// false — indistinguishable from an untouched browser. Injecting on top of
|
|
4012
|
-
// that is actively harmful: it defines an OWN getter on the instance, and an
|
|
4013
|
-
// own `webdriver` descriptor (native lives on the prototype) returning
|
|
4014
|
-
// `undefined` (native returns `false`) is itself a tampering signal that
|
|
4015
|
-
// bot.sannysoft.com and similar tests flag as "WebDriver present".
|
|
4016
|
-
//
|
|
4017
|
-
// When we do inject (attach mode), mirror native semantics exactly: define
|
|
4018
|
-
// on Navigator.prototype and return false, so no own descriptor leaks and
|
|
4019
|
-
// the value matches a real browser. Non-page targets (workers, service
|
|
4020
|
-
// workers) reject these calls; swallow the error and keep going.
|
|
4021
|
-
if (conn.pid === 0) {
|
|
4022
|
-
try {
|
|
4023
|
-
await conn.cdp.send('Page.enable', {}, sessionId);
|
|
4024
|
-
await conn.cdp.send('Page.addScriptToEvaluateOnNewDocument', {
|
|
4025
|
-
source: "Object.defineProperty(Navigator.prototype,'webdriver',{get:()=>false,configurable:true});",
|
|
4026
|
-
}, sessionId);
|
|
4027
|
-
}
|
|
4028
|
-
catch {
|
|
4029
|
-
// Target doesn't support Page domain — nothing to inject.
|
|
4030
|
-
}
|
|
4031
|
-
}
|
|
4032
|
-
conn.sessionCache.set(tabId, sessionId);
|
|
4033
|
-
return sessionId;
|
|
4034
|
-
}
|
|
4035
|
-
invalidateTargetCache(conn) {
|
|
4036
|
-
conn.targetCache = undefined;
|
|
4037
|
-
}
|
|
4038
|
-
async saveTaskState(key, tasks) {
|
|
4039
|
-
// tasks.json holds EVERY task on this connection, so two tasks persisting
|
|
4040
|
-
// concurrently (the per-task locks are disjoint, and `touchTask`/label writes
|
|
4041
|
-
// fire outside any task lock) would interleave writes of the same file and
|
|
4042
|
-
// could truncate or corrupt it. Serialize the whole file write per runtime
|
|
4043
|
-
// key — a `persist:` namespace disjoint from the `task:`/`create:` locks, so a
|
|
4044
|
-
// save issued while holding a task lock never deadlocks (PHNX-2399).
|
|
4045
|
-
await this.runExclusive(`persist:${key}`, async () => {
|
|
4046
|
-
const runtimeDir = getProfileRuntimeDir(key);
|
|
4047
|
-
await fs.promises.mkdir(runtimeDir, { recursive: true });
|
|
4048
|
-
const state = Object.fromEntries(tasks);
|
|
4049
|
-
// Write to a temp sibling then rename, so a reader never sees a half-written
|
|
4050
|
-
// file even if the process dies mid-write (rename is atomic on one fs).
|
|
4051
|
-
const target = path.join(runtimeDir, 'tasks.json');
|
|
4052
|
-
const tmp = `${target}.${process.pid}.tmp`;
|
|
4053
|
-
await fs.promises.writeFile(tmp, JSON.stringify(state, null, 2));
|
|
4054
|
-
await fs.promises.rename(tmp, target);
|
|
4055
|
-
});
|
|
4056
|
-
}
|
|
4057
|
-
loadTaskState(key) {
|
|
4058
|
-
const runtimeDir = getProfileRuntimeDir(key);
|
|
4059
|
-
const tasksFile = path.join(runtimeDir, 'tasks.json');
|
|
4060
|
-
if (!fs.existsSync(tasksFile)) {
|
|
4061
|
-
return new Map();
|
|
4062
|
-
}
|
|
4063
|
-
const state = JSON.parse(fs.readFileSync(tasksFile, 'utf-8'));
|
|
4064
|
-
const tasks = new Map();
|
|
4065
|
-
let needsMigration = false;
|
|
4066
|
-
for (const [key, raw] of Object.entries(state)) {
|
|
4067
|
-
const task = raw;
|
|
4068
|
-
// Migrate old format (tabIds array) to new format (tabs object)
|
|
4069
|
-
if (Array.isArray(task.tabIds) && !task.tabs) {
|
|
4070
|
-
needsMigration = true;
|
|
4071
|
-
const tabs = {};
|
|
4072
|
-
for (const cdpId of task.tabIds) {
|
|
4073
|
-
const shortId = generateShortId();
|
|
4074
|
-
tabs[shortId] = cdpId;
|
|
4075
|
-
}
|
|
4076
|
-
const tabIds = Object.keys(tabs);
|
|
4077
|
-
tasks.set(key, {
|
|
4078
|
-
id: task.id,
|
|
4079
|
-
name: task.name || key,
|
|
4080
|
-
profile: asConnectionKey(task.profile),
|
|
4081
|
-
tabs,
|
|
4082
|
-
currentTabId: tabIds.length > 0 ? tabIds[tabIds.length - 1] : undefined,
|
|
4083
|
-
createdAt: task.createdAt,
|
|
4084
|
-
lastActionAt: task.lastActionAt ?? task.createdAt,
|
|
4085
|
-
pid: task.pid,
|
|
4086
|
-
});
|
|
4087
|
-
}
|
|
4088
|
-
else {
|
|
4089
|
-
const loaded = task;
|
|
4090
|
-
// Tasks persisted before RUSH-2622 carry no lastActionAt. Normalize on
|
|
4091
|
-
// read so every in-memory task has one: its own createdAt is the last
|
|
4092
|
-
// moment we can prove the task did anything.
|
|
4093
|
-
if (typeof loaded.lastActionAt !== 'number') {
|
|
4094
|
-
loaded.lastActionAt = loaded.createdAt;
|
|
4095
|
-
}
|
|
4096
|
-
tasks.set(key, loaded);
|
|
4097
|
-
}
|
|
4098
|
-
}
|
|
4099
|
-
// Save migrated data back to disk
|
|
4100
|
-
if (needsMigration) {
|
|
4101
|
-
const migratedState = Object.fromEntries(tasks);
|
|
4102
|
-
fs.writeFileSync(tasksFile, JSON.stringify(migratedState, null, 2));
|
|
4103
|
-
}
|
|
4104
|
-
return tasks;
|
|
4105
|
-
}
|
|
4106
|
-
async saveToHistory(task, domains) {
|
|
4107
|
-
const historyDir = getBrowserRuntimeDir();
|
|
4108
|
-
await fs.promises.mkdir(historyDir, { recursive: true });
|
|
4109
|
-
const historyFile = path.join(historyDir, 'history.json');
|
|
4110
|
-
let history = [];
|
|
4111
|
-
if (fs.existsSync(historyFile)) {
|
|
4112
|
-
try {
|
|
4113
|
-
history = JSON.parse(fs.readFileSync(historyFile, 'utf-8'));
|
|
4114
|
-
}
|
|
4115
|
-
catch {
|
|
4116
|
-
// Corrupted file, start fresh
|
|
4117
|
-
}
|
|
4118
|
-
}
|
|
4119
|
-
history.unshift({
|
|
4120
|
-
id: task.id,
|
|
4121
|
-
name: task.name,
|
|
4122
|
-
// `task.profile` is the runtime key; history is a user-facing table, so
|
|
4123
|
-
// it carries the bare name (RUSH-2709).
|
|
4124
|
-
profile: parseConnectionKey(task.profile).profile,
|
|
4125
|
-
createdAt: task.createdAt,
|
|
4126
|
-
endedAt: Date.now(),
|
|
4127
|
-
domains,
|
|
4128
|
-
tabCount: Object.keys(task.tabs).length,
|
|
4129
|
-
...(task.owner ? { owner: task.owner } : {}), // carry who launched it (RUSH-2020)
|
|
4130
|
-
});
|
|
4131
|
-
// Keep only last 50 entries
|
|
4132
|
-
history = history.slice(0, 50);
|
|
4133
|
-
await fs.promises.writeFile(historyFile, JSON.stringify(history, null, 2));
|
|
4134
|
-
}
|
|
4135
|
-
/**
|
|
4136
|
-
* The cached connection for `key`, or undefined — evicting it first if the
|
|
4137
|
-
* browser died underneath us.
|
|
4138
|
-
*
|
|
4139
|
-
* A browser killed externally (Cmd-Q, crash, a user clearing the profile by
|
|
4140
|
-
* hand) leaves a closed WebSocket in the map. Without this the next
|
|
4141
|
-
* `cdp.send` throws "CDP connection not open" and there is no recovery short
|
|
4142
|
-
* of killing the daemon.
|
|
4143
|
-
*/
|
|
4144
|
-
async reuseHealthyConnection(key) {
|
|
4145
|
-
const conn = this.connections.get(key);
|
|
4146
|
-
if (!conn)
|
|
4147
|
-
return undefined;
|
|
4148
|
-
if (await isConnHealthy(conn))
|
|
4149
|
-
return conn;
|
|
4150
|
-
if (conn.backend === 'bidi') {
|
|
4151
|
-
try {
|
|
4152
|
-
conn.bidi.close();
|
|
4153
|
-
}
|
|
4154
|
-
catch { /* already closed */ }
|
|
4155
|
-
}
|
|
4156
|
-
else if (conn.backend !== 'arc-native') {
|
|
4157
|
-
try {
|
|
4158
|
-
conn.cdp.close();
|
|
4159
|
-
}
|
|
4160
|
-
catch { /* already closed */ }
|
|
4161
|
-
}
|
|
4162
|
-
conn.cleanup?.();
|
|
4163
|
-
this.connections.delete(key);
|
|
4164
|
-
return undefined;
|
|
4165
|
-
}
|
|
4166
|
-
/**
|
|
4167
|
-
* Reuse a live connection for this profile: the new `<name>@<device>` key
|
|
4168
|
-
* first, then any leftover `<name>@endpoint-N` key still in the map so a
|
|
4169
|
-
* browser started before the key collapse is not abandoned.
|
|
4170
|
-
*/
|
|
4171
|
-
async reuseProfileConnection(profileName, preferred, local) {
|
|
4172
|
-
const preferredConn = await this.reuseHealthyConnection(preferred);
|
|
4173
|
-
if (preferredConn)
|
|
4174
|
-
return { conn: preferredConn, key: preferred };
|
|
4175
|
-
// Leftover `@endpoint-N` connections are THIS machine's pre-T2 local
|
|
4176
|
-
// browser. Reusing them on a tunnelled resolve is the original bug.
|
|
4177
|
-
if (!local)
|
|
4178
|
-
return undefined;
|
|
4179
|
-
for (const key of [...this.connections.keys()]) {
|
|
4180
|
-
if (key === preferred)
|
|
4181
|
-
continue;
|
|
4182
|
-
if (!isLegacyEndpointKey(key, profileName))
|
|
4183
|
-
continue;
|
|
4184
|
-
if (parseConnectionKey(key).fork !== undefined)
|
|
4185
|
-
continue;
|
|
4186
|
-
const conn = await this.reuseHealthyConnection(key);
|
|
4187
|
-
if (conn)
|
|
4188
|
-
return { conn, key };
|
|
4189
|
-
}
|
|
4190
|
-
return undefined;
|
|
4191
|
-
}
|
|
4192
|
-
// ---------------------------------------------------------------------------
|
|
4193
|
-
// Native Arc backend helpers (PHNX-2399)
|
|
4194
|
-
// ---------------------------------------------------------------------------
|
|
4195
|
-
/**
|
|
4196
|
-
* Bind a task to the profile's Space in the visible Arc window that holds it.
|
|
4197
|
-
* The Space is the profile (PHNX-2399); only its live window id is looked up.
|
|
4198
|
-
*/
|
|
4199
|
-
async resolveArcSpace(profile) {
|
|
4200
|
-
const live = (await enumerateArcSpaces()).filter((space) => space.spaceId === profile.spaceId);
|
|
4201
|
-
if (live.length !== 1) {
|
|
4202
|
-
throw new Error(live.length === 0
|
|
4203
|
-
? `Arc Space ${JSON.stringify(profile.spaceTitle)} (${profile.spaceId}) is not open in a visible Arc window.`
|
|
4204
|
-
: `Arc Space ${JSON.stringify(profile.spaceTitle)} is open in more than one window; refusing an ambiguous native address.`);
|
|
4205
|
-
}
|
|
4206
|
-
return {
|
|
4207
|
-
windowId: live[0].windowId,
|
|
4208
|
-
spaceId: live[0].spaceId,
|
|
4209
|
-
spaceTitle: profile.spaceTitle,
|
|
4210
|
-
};
|
|
4211
|
-
}
|
|
4212
|
-
requireArcTask(task) {
|
|
4213
|
-
const native = task.arcNative;
|
|
4214
|
-
if (!native || typeof native.profileId !== 'string' || typeof native.windowId !== 'string' ||
|
|
4215
|
-
typeof native.spaceId !== 'string' || !native.tabs || typeof native.tabs !== 'object') {
|
|
4216
|
-
throw new Error(`Task ${JSON.stringify(task.name)} has no valid durable Arc identity.`);
|
|
4217
|
-
}
|
|
4218
|
-
for (const [shortId, ref] of Object.entries(native.tabs)) {
|
|
4219
|
-
if (!ref || typeof ref.windowId !== 'string' || typeof ref.spaceId !== 'string' ||
|
|
4220
|
-
typeof ref.tabId !== 'string' || ref.windowId !== native.windowId || ref.spaceId !== native.spaceId) {
|
|
4221
|
-
throw new Error(`Owned Arc tab ${JSON.stringify(shortId)} has no stable id in its original window/Space.`);
|
|
4222
|
-
}
|
|
4223
|
-
}
|
|
4224
|
-
for (const [shortId, intent] of Object.entries(native.createIntents ?? {})) {
|
|
4225
|
-
if (!intent || intent.tabId !== shortId || typeof intent.markerUrl !== 'string' ||
|
|
4226
|
-
!isArcCreateMarker(intent.markerUrl) ||
|
|
4227
|
-
typeof intent.targetUrl !== 'string' || typeof intent.createdAt !== 'number') {
|
|
4228
|
-
throw new Error(`Arc creation intent ${JSON.stringify(shortId)} is invalid; refusing recovery.`);
|
|
4229
|
-
}
|
|
4230
|
-
if (intent.ref && (intent.ref.windowId !== native.windowId || intent.ref.spaceId !== native.spaceId ||
|
|
4231
|
-
typeof intent.ref.tabId !== 'string')) {
|
|
4232
|
-
throw new Error(`Arc creation intent ${JSON.stringify(shortId)} left its original window/Space.`);
|
|
4233
|
-
}
|
|
4234
|
-
}
|
|
4235
|
-
return native;
|
|
4236
|
-
}
|
|
4237
|
-
async reconcileArcCreateIntents(conn, task) {
|
|
4238
|
-
const native = this.requireArcTask(task);
|
|
4239
|
-
for (const intent of Object.values(native.createIntents ?? {})) {
|
|
4240
|
-
let ref = intent.ref;
|
|
4241
|
-
if (ref) {
|
|
4242
|
-
const live = await resolveArcTab(ref);
|
|
4243
|
-
if (!live) {
|
|
4244
|
-
throw new Error(`Owned Arc tab ${intent.tabId} left its original window/Space while creation was incomplete; refusing to adopt it elsewhere.`);
|
|
4245
|
-
}
|
|
4246
|
-
if (live.url === intent.targetUrl) {
|
|
4247
|
-
native.tabs[intent.tabId] = ref;
|
|
4248
|
-
task.tabs[intent.tabId] = ref.tabId;
|
|
4249
|
-
if (intent.previousTabId) {
|
|
4250
|
-
await restoreArcSelection(ref, intent.previousTabId);
|
|
4251
|
-
}
|
|
4252
|
-
delete native.createIntents?.[intent.tabId];
|
|
4253
|
-
await this.saveTaskState(task.profile, conn.tasks);
|
|
4254
|
-
continue;
|
|
4255
|
-
}
|
|
4256
|
-
if (live.url !== intent.markerUrl) {
|
|
4257
|
-
throw new Error(`Owned Arc tab ${intent.tabId} changed outside this task during creation; refusing to navigate it.`);
|
|
4258
|
-
}
|
|
4259
|
-
}
|
|
4260
|
-
else {
|
|
4261
|
-
const originalSpace = (await enumerateArcSpaces()).find((space) => space.windowId === native.windowId && space.spaceId === native.spaceId);
|
|
4262
|
-
if (!originalSpace) {
|
|
4263
|
-
throw new Error('The original Arc window/Space for this task is no longer present.');
|
|
4264
|
-
}
|
|
4265
|
-
const markerMatches = originalSpace.tabs.filter((tab) => tab.url === intent.markerUrl);
|
|
4266
|
-
if (markerMatches.length > 1) {
|
|
4267
|
-
throw new Error(`Arc creation marker ${JSON.stringify(intent.markerUrl)} is not unique in the original Space.`);
|
|
4268
|
-
}
|
|
4269
|
-
if (markerMatches[0]) {
|
|
4270
|
-
ref = markerMatches[0];
|
|
4271
|
-
}
|
|
4272
|
-
else {
|
|
4273
|
-
try {
|
|
4274
|
-
ref = await createArcTab({ windowId: native.windowId, spaceId: native.spaceId }, intent.markerUrl);
|
|
4275
|
-
}
|
|
4276
|
-
catch (error) {
|
|
4277
|
-
// Arc selects the Space it was asked to create in even when the
|
|
4278
|
-
// creation itself fails. Put the owner back on their tab before
|
|
4279
|
-
// surfacing the error, so a failed agent verb never leaves them
|
|
4280
|
-
// staring at the wrong Space.
|
|
4281
|
-
if (intent.previousTabId) {
|
|
4282
|
-
await selectWindowTab(native.windowId, intent.previousTabId).catch(() => undefined);
|
|
4283
|
-
}
|
|
4284
|
-
throw error;
|
|
4285
|
-
}
|
|
4286
|
-
}
|
|
4287
|
-
intent.ref = ref;
|
|
4288
|
-
native.tabs[intent.tabId] = ref;
|
|
4289
|
-
task.tabs[intent.tabId] = ref.tabId;
|
|
4290
|
-
await this.saveTaskState(task.profile, conn.tasks);
|
|
4291
|
-
}
|
|
4292
|
-
await navigateArcTab(ref, intent.targetUrl);
|
|
4293
|
-
if (intent.previousTabId) {
|
|
4294
|
-
await restoreArcSelection(ref, intent.previousTabId);
|
|
4295
|
-
}
|
|
4296
|
-
delete native.createIntents?.[intent.tabId];
|
|
4297
|
-
task.currentTabId = intent.tabId;
|
|
4298
|
-
await this.saveTaskState(task.profile, conn.tasks);
|
|
4299
|
-
}
|
|
4300
|
-
}
|
|
4301
|
-
async createArcOwnedTab(conn, task, url) {
|
|
4302
|
-
const native = this.requireArcTask(task);
|
|
4303
|
-
const tabId = generateShortId();
|
|
4304
|
-
const markerUrl = arcCreateMarker();
|
|
4305
|
-
const originalSpace = (await enumerateArcSpaces()).find((space) => space.windowId === native.windowId && space.spaceId === native.spaceId);
|
|
4306
|
-
if (!originalSpace)
|
|
4307
|
-
throw new Error('The original Arc window/Space for this task is no longer present.');
|
|
4308
|
-
native.createIntents ??= {};
|
|
4309
|
-
native.createIntents[tabId] = {
|
|
4310
|
-
tabId,
|
|
4311
|
-
markerUrl,
|
|
4312
|
-
targetUrl: url,
|
|
4313
|
-
createdAt: Date.now(),
|
|
4314
|
-
previousTabId: originalSpace.activeTabId,
|
|
4315
|
-
};
|
|
4316
|
-
await this.saveTaskState(task.profile, conn.tasks);
|
|
4317
|
-
await this.reconcileArcCreateIntents(conn, task);
|
|
4318
|
-
return tabId;
|
|
4319
|
-
}
|
|
4320
|
-
async navigateArcNative(conn, task, url, addTab = false) {
|
|
4321
|
-
return this.runExclusive('arc-native:host-app', async () => {
|
|
4322
|
-
await this.reconcileArcCreateIntents(conn, task);
|
|
4323
|
-
const native = this.requireArcTask(task);
|
|
4324
|
-
if (!addTab) {
|
|
4325
|
-
for (const [shortId, ref] of Object.entries(native.tabs)) {
|
|
4326
|
-
const live = await resolveArcTab(ref);
|
|
4327
|
-
if (live && canonicalTabUrl(live.url) === canonicalTabUrl(url)) {
|
|
4328
|
-
await navigateArcTab(ref, url);
|
|
4329
|
-
task.currentTabId = shortId;
|
|
4330
|
-
await this.saveTaskState(task.profile, conn.tasks);
|
|
4331
|
-
return { tabId: shortId, url, created: false, refreshed: true, message: 'Tab already open—refreshed' };
|
|
4332
|
-
}
|
|
4333
|
-
}
|
|
4334
|
-
}
|
|
4335
|
-
const currentId = task.currentTabId;
|
|
4336
|
-
if (!addTab && currentId) {
|
|
4337
|
-
const ref = native.tabs[currentId];
|
|
4338
|
-
if (!ref || !(await resolveArcTab(ref))) {
|
|
4339
|
-
throw new Error(`Owned Arc tab ${currentId} is missing from its original window/Space; refusing to adopt a moved tab.`);
|
|
4340
|
-
}
|
|
4341
|
-
await navigateArcTab(ref, url);
|
|
4342
|
-
return { tabId: currentId, url, created: false, refreshed: false };
|
|
4343
|
-
}
|
|
4344
|
-
const tabId = await this.createArcOwnedTab(conn, task, url);
|
|
4345
|
-
return { tabId, url, created: true, refreshed: false };
|
|
4346
|
-
});
|
|
4347
|
-
}
|
|
4348
|
-
/**
|
|
4349
|
-
* Execute JavaScript in a native Arc tab. Sync JS only, isolated world —
|
|
4350
|
-
* async/promise evaluation fails honestly with an AppleScript error.
|
|
4351
|
-
*/
|
|
4352
|
-
async evaluateArcNative(conn, task, shortId, expression) {
|
|
4353
|
-
const ref = this.requireArcTask(task).tabs[shortId];
|
|
4354
|
-
if (!ref || !(await resolveArcTab(ref))) {
|
|
4355
|
-
throw new Error(`Owned Arc tab ${shortId} is missing from its original window/Space.`);
|
|
4356
|
-
}
|
|
4357
|
-
return executeJavaScript(ref, expression);
|
|
4358
|
-
}
|
|
4359
|
-
/**
|
|
4360
|
-
* Resolve a task's owned Arc tabs against one shared snapshot of every
|
|
4361
|
-
* window/space/tab, rather than asking Arc per tab as `listArcTaskTabs` does.
|
|
4362
|
-
*
|
|
4363
|
-
* Same ownership rule as the live path — `requireArcTask`, then the recorded
|
|
4364
|
-
* ref must be present in its original window and Space. The snapshot changes
|
|
4365
|
-
* how often Arc is asked, never what counts as owned. Actions keep the live
|
|
4366
|
-
* path, because a snapshot can go stale before it is acted on.
|
|
4367
|
-
*/
|
|
4368
|
-
async listArcTaskTabsFromSnapshot(task, spaces) {
|
|
4369
|
-
// Before the snapshot on purpose: a ref naming a window its task does not
|
|
4370
|
-
// own is a moved tab on the record alone, so it needs no round trip and
|
|
4371
|
-
// reports the right reason even when Arc is unreachable.
|
|
4372
|
-
const native = this.requireArcTask(task);
|
|
4373
|
-
const snapshot = await spaces();
|
|
4374
|
-
const tabs = [];
|
|
4375
|
-
for (const [shortId, ref] of Object.entries(native.tabs)) {
|
|
4376
|
-
const live = snapshot
|
|
4377
|
-
.find((space) => space.windowId === ref.windowId && space.spaceId === ref.spaceId)
|
|
4378
|
-
?.tabs.find((tab) => tab.tabId === ref.tabId);
|
|
4379
|
-
if (!live) {
|
|
4380
|
-
throw new Error(`Owned Arc tab ${shortId} is missing from its original window/Space; refusing to adopt a moved tab.`);
|
|
4381
|
-
}
|
|
4382
|
-
tabs.push({
|
|
4383
|
-
id: shortId,
|
|
4384
|
-
url: live.url,
|
|
4385
|
-
title: live.title,
|
|
4386
|
-
task: task.name,
|
|
4387
|
-
current: shortId === task.currentTabId,
|
|
4388
|
-
});
|
|
4389
|
-
}
|
|
4390
|
-
return tabs;
|
|
4391
|
-
}
|
|
4392
|
-
async listArcTaskTabs(task) {
|
|
4393
|
-
const native = this.requireArcTask(task);
|
|
4394
|
-
const tabs = [];
|
|
4395
|
-
for (const [shortId, ref] of Object.entries(native.tabs)) {
|
|
4396
|
-
const live = await resolveArcTab(ref);
|
|
4397
|
-
if (!live) {
|
|
4398
|
-
throw new Error(`Owned Arc tab ${shortId} is missing from its original window/Space; refusing to adopt a moved tab.`);
|
|
4399
|
-
}
|
|
4400
|
-
tabs.push({
|
|
4401
|
-
id: shortId,
|
|
4402
|
-
url: live.url,
|
|
4403
|
-
title: live.title,
|
|
4404
|
-
task: task.name,
|
|
4405
|
-
current: shortId === task.currentTabId,
|
|
4406
|
-
});
|
|
4407
|
-
}
|
|
4408
|
-
return tabs;
|
|
4409
|
-
}
|
|
4410
|
-
/**
|
|
4411
|
-
* Close native Arc tabs owned by a task. Only closes tabs the task created
|
|
4412
|
-
* (not borrowed ones). Never kills Arc. Identifies tabs only by stable ids in
|
|
4413
|
-
* the original window and Space.
|
|
4414
|
-
*/
|
|
4415
|
-
async closeArcNativeTabs(conn, task) {
|
|
4416
|
-
await this.runExclusive('arc-native:host-app', async () => {
|
|
4417
|
-
await this.reconcileArcCreateIntents(conn, task);
|
|
4418
|
-
const native = this.requireArcTask(task);
|
|
4419
|
-
for (const [shortId, ref] of Object.entries(native.tabs)) {
|
|
4420
|
-
await closeArcTab(ref);
|
|
4421
|
-
delete native.tabs[shortId];
|
|
4422
|
-
delete task.tabs[shortId];
|
|
4423
|
-
delete task.refDescriptors?.[shortId];
|
|
4424
|
-
}
|
|
4425
|
-
task.currentTabId = undefined;
|
|
4426
|
-
});
|
|
4427
|
-
}
|
|
4428
|
-
// ---------------------------------------------------------------------------
|
|
4429
|
-
// Firefox WebDriver BiDi backend helpers (PHNX-4043)
|
|
4430
|
-
// ---------------------------------------------------------------------------
|
|
4431
|
-
/** The BiDi browsing-context id a task owns for `shortId`. */
|
|
4432
|
-
firefoxContext(task, shortId) {
|
|
4433
|
-
const context = task.tabs[shortId];
|
|
4434
|
-
if (!context)
|
|
4435
|
-
throw new Error(`Tab ${shortId} not found`);
|
|
4436
|
-
return context;
|
|
4437
|
-
}
|
|
4438
|
-
/** The context ids currently live in this Firefox (a killed tab drops out). */
|
|
4439
|
-
async firefoxLiveContexts(conn) {
|
|
4440
|
-
const contexts = await bidiTopLevelContexts(conn.bidi);
|
|
4441
|
-
return new Set(contexts.map((c) => c.context));
|
|
4442
|
-
}
|
|
4443
|
-
/**
|
|
4444
|
-
* Same-task reopen for Firefox (mirrors {@link reopenOwnedTab}): the URL is
|
|
4445
|
-
* already live in one of this task's own tabs → reload THAT tab in place and
|
|
4446
|
-
* keep its id, rather than opening a duplicate. Borrowed tabs are excluded —
|
|
4447
|
-
* Firefox never borrows, but the guard keeps parity. Returns the retained
|
|
4448
|
-
* short id, or undefined when nothing matches.
|
|
4449
|
-
*/
|
|
4450
|
-
async reopenOwnedFirefoxTab(conn, task, url) {
|
|
4451
|
-
const borrowed = new Set(task.borrowedTabs ?? []);
|
|
4452
|
-
// Read each live tab's URL from browsingContext.getTree metadata rather than
|
|
4453
|
-
// evaluating location.href per tab: the same source the CDP path reads
|
|
4454
|
-
// (Target.getTargets), so a mid-navigation or just-closed sibling tab can
|
|
4455
|
-
// never throw a BiDi "no such frame" and fail an unrelated navigate. A tab
|
|
4456
|
-
// absent from the tree is simply not a reopen candidate.
|
|
4457
|
-
const liveUrlByContext = new Map((await bidiTopLevelContexts(conn.bidi)).map((c) => [c.context, c.url]));
|
|
4458
|
-
const wanted = canonicalTabUrl(url);
|
|
4459
|
-
for (const [shortId, context] of Object.entries(task.tabs)) {
|
|
4460
|
-
if (borrowed.has(shortId))
|
|
4461
|
-
continue;
|
|
4462
|
-
const liveUrl = liveUrlByContext.get(context);
|
|
4463
|
-
if (liveUrl === undefined)
|
|
4464
|
-
continue; // registered but gone from the browser
|
|
4465
|
-
if (canonicalTabUrl(liveUrl) === wanted) {
|
|
4466
|
-
await bidiReload(conn.bidi, context);
|
|
4467
|
-
task.currentTabId = shortId;
|
|
4468
|
-
await this.saveTaskState(task.profile, conn.tasks);
|
|
4469
|
-
return { tabId: shortId };
|
|
4470
|
-
}
|
|
4471
|
-
}
|
|
4472
|
-
return undefined;
|
|
4473
|
-
}
|
|
4474
|
-
async navigateFirefox(conn, task, url, addTab = false) {
|
|
4475
|
-
if (!addTab) {
|
|
4476
|
-
const reopened = await this.reopenOwnedFirefoxTab(conn, task, url);
|
|
4477
|
-
if (reopened) {
|
|
4478
|
-
emit('browser.navigate', { profile: parseConnectionKey(task.profile).profile, task: task.name, url, tabId: reopened.tabId, created: false });
|
|
4479
|
-
return { tabId: reopened.tabId, url, created: false, refreshed: true, message: 'Tab already open—refreshed' };
|
|
4480
|
-
}
|
|
4481
|
-
// Reuse the current tab when there is one — navigate() semantics.
|
|
4482
|
-
const currentShortId = task.currentTabId;
|
|
4483
|
-
if (currentShortId && task.tabs[currentShortId]) {
|
|
4484
|
-
const context = task.tabs[currentShortId];
|
|
4485
|
-
const live = await this.firefoxLiveContexts(conn);
|
|
4486
|
-
if (live.has(context)) {
|
|
4487
|
-
await bidiNavigate(conn.bidi, context, url);
|
|
4488
|
-
await this.saveTaskState(task.profile, conn.tasks);
|
|
4489
|
-
emit('browser.navigate', { profile: parseConnectionKey(task.profile).profile, task: task.name, url, tabId: currentShortId, created: false });
|
|
4490
|
-
return { tabId: currentShortId, url, created: false, refreshed: false };
|
|
4491
|
-
}
|
|
4492
|
-
}
|
|
4493
|
-
}
|
|
4494
|
-
else {
|
|
4495
|
-
const reopened = await this.reopenOwnedFirefoxTab(conn, task, url);
|
|
4496
|
-
if (reopened) {
|
|
4497
|
-
return { tabId: reopened.tabId, url, created: false, refreshed: true, message: 'Tab already open—refreshed' };
|
|
4498
|
-
}
|
|
4499
|
-
}
|
|
4500
|
-
// Open a fresh tab.
|
|
4501
|
-
const context = await bidiCreateTab(conn.bidi);
|
|
4502
|
-
await bidiNavigate(conn.bidi, context, url);
|
|
4503
|
-
const shortId = generateShortId();
|
|
4504
|
-
task.tabs[shortId] = context;
|
|
4505
|
-
task.currentTabId = shortId;
|
|
4506
|
-
await this.saveTaskState(task.profile, conn.tasks);
|
|
4507
|
-
emit('browser.navigate', { profile: parseConnectionKey(task.profile).profile, task: task.name, url, tabId: shortId, created: true });
|
|
4508
|
-
return { tabId: shortId, url, created: true, refreshed: false };
|
|
4509
|
-
}
|
|
4510
|
-
async evaluateFirefox(conn, task, shortId, expression) {
|
|
4511
|
-
const context = this.firefoxContext(task, shortId);
|
|
4512
|
-
const live = await this.firefoxLiveContexts(conn);
|
|
4513
|
-
if (!live.has(context))
|
|
4514
|
-
throw new Error(`Tab ${shortId} is no longer open in Firefox.`);
|
|
4515
|
-
return bidiEvaluate(conn.bidi, context, expression);
|
|
4516
|
-
}
|
|
4517
|
-
async listFirefoxTaskTabs(conn, task) {
|
|
4518
|
-
const live = await bidiTopLevelContexts(conn.bidi);
|
|
4519
|
-
const byId = new Map(live.map((c) => [c.context, c]));
|
|
4520
|
-
const tabs = [];
|
|
4521
|
-
for (const [shortId, context] of Object.entries(task.tabs)) {
|
|
4522
|
-
const info = byId.get(context);
|
|
4523
|
-
if (!info)
|
|
4524
|
-
continue; // tab closed out from under the task — drop it silently.
|
|
4525
|
-
let title = '';
|
|
4526
|
-
try {
|
|
4527
|
-
title = String(await bidiEvaluate(conn.bidi, context, 'document.title'));
|
|
4528
|
-
}
|
|
4529
|
-
catch {
|
|
4530
|
-
/* about: pages or a mid-navigation context — url is enough */
|
|
4531
|
-
}
|
|
4532
|
-
tabs.push({ id: shortId, url: info.url, title, task: task.name, current: shortId === task.currentTabId });
|
|
4533
|
-
}
|
|
4534
|
-
return tabs;
|
|
4535
|
-
}
|
|
4536
|
-
/**
|
|
4537
|
-
* Close every non-borrowed tab a Firefox task owns. Never kills the Firefox
|
|
4538
|
-
* process (that is `stopProfile`'s job) — a killed browser would take the
|
|
4539
|
-
* user's other tabs with it. A tab already gone is skipped.
|
|
4540
|
-
*/
|
|
4541
|
-
async closeFirefoxTabs(conn, task) {
|
|
4542
|
-
const borrowed = new Set(task.borrowedTabs ?? []);
|
|
4543
|
-
const live = await this.firefoxLiveContexts(conn);
|
|
4544
|
-
for (const [shortId, context] of Object.entries(task.tabs)) {
|
|
4545
|
-
if (borrowed.has(shortId))
|
|
4546
|
-
continue;
|
|
4547
|
-
if (live.has(context)) {
|
|
4548
|
-
try {
|
|
4549
|
-
await bidiCloseTab(conn.bidi, context);
|
|
4550
|
-
}
|
|
4551
|
-
catch { /* already closed */ }
|
|
4552
|
-
}
|
|
4553
|
-
}
|
|
4554
|
-
}
|
|
4555
|
-
/**
|
|
4556
|
-
* Viewport-center coordinates of a selector-addressed element in a Firefox
|
|
4557
|
-
* tab, scrolled into view first — the (x, y) `input.performActions` clicks.
|
|
4558
|
-
* Throws when the element is gone or has zero size (off-screen / display:none).
|
|
4559
|
-
*/
|
|
4560
|
-
async firefoxRefCoords(conn, task, shortId, selector) {
|
|
4561
|
-
const expr = `(() => {
|
|
4562
|
-
const el = document.querySelector(${JSON.stringify(selector)});
|
|
4563
|
-
if (!el) throw new Error('DOM ref is missing');
|
|
4564
|
-
el.scrollIntoView({ block: 'center', inline: 'center' });
|
|
4565
|
-
const r = el.getBoundingClientRect();
|
|
4566
|
-
if (r.width === 0 && r.height === 0) throw new Error('DOM ref has no layout box');
|
|
4567
|
-
return JSON.stringify({ x: r.left + r.width / 2, y: r.top + r.height / 2 });
|
|
4568
|
-
})()`;
|
|
4569
|
-
const raw = await this.evaluateFirefox(conn, task, shortId, expr);
|
|
4570
|
-
const parsed = JSON.parse(String(raw));
|
|
4571
|
-
return parsed;
|
|
4572
|
-
}
|
|
4573
|
-
/**
|
|
4574
|
-
* Screenshot a Firefox tab over BiDi. Same output contract as the CDP path:
|
|
4575
|
-
* `raw` is a pixel-faithful PNG; the default is JPEG iteratively downscaled
|
|
4576
|
-
* under 100 KB so chat-injected screenshots stay token-cheap.
|
|
4577
|
-
*/
|
|
4578
|
-
async screenshotFirefox(conn, task, runtimeKey, tabHint, outputPath, quality) {
|
|
4579
|
-
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
4580
|
-
const context = this.firefoxContext(task, shortId);
|
|
4581
|
-
const live = await this.firefoxLiveContexts(conn);
|
|
4582
|
-
if (!live.has(context))
|
|
4583
|
-
throw new Error(`Tab ${shortId} is no longer open in Firefox.`);
|
|
4584
|
-
let buffer;
|
|
4585
|
-
let extension;
|
|
4586
|
-
if (quality === 'raw') {
|
|
4587
|
-
buffer = await bidiScreenshot(conn.bidi, context, { type: 'image/png' });
|
|
4588
|
-
extension = 'png';
|
|
4589
|
-
}
|
|
4590
|
-
else {
|
|
4591
|
-
buffer = await bidiScreenshot(conn.bidi, context, { type: 'image/jpeg', quality: 0.7 });
|
|
4592
|
-
const MAX_SIZE = 100 * 1024;
|
|
4593
|
-
let q = 0.5;
|
|
4594
|
-
while (buffer.length > MAX_SIZE && q > 0.1) {
|
|
4595
|
-
buffer = await bidiScreenshot(conn.bidi, context, { type: 'image/jpeg', quality: q });
|
|
4596
|
-
q -= 0.1;
|
|
4597
|
-
}
|
|
4598
|
-
extension = 'jpg';
|
|
4599
|
-
}
|
|
4600
|
-
const sessionsDir = getProfileSessionsDir(runtimeKey, task.name);
|
|
4601
|
-
const automaticPath = path.join(sessionsDir, `${Date.now()}.${extension}`);
|
|
4602
|
-
const finalPath = resolveScreenshotOutputPath(outputPath, automaticPath);
|
|
4603
|
-
await fs.promises.mkdir(path.dirname(finalPath), { recursive: true });
|
|
4604
|
-
await fs.promises.writeFile(finalPath, buffer);
|
|
4605
|
-
const dims = (extension === 'png' ? readPngDimensions(buffer) : readJpegDimensions(buffer)) ??
|
|
4606
|
-
{ width: 0, height: 0 };
|
|
4607
|
-
emit('browser.screenshot', {
|
|
4608
|
-
profile: parseConnectionKey(runtimeKey).profile,
|
|
4609
|
-
task: task.name,
|
|
4610
|
-
tabId: shortId,
|
|
4611
|
-
path: finalPath,
|
|
4612
|
-
bytes: buffer.length,
|
|
4613
|
-
width: dims.width,
|
|
4614
|
-
height: dims.height,
|
|
4615
|
-
quality,
|
|
4616
|
-
});
|
|
4617
|
-
return { path: finalPath, bytes: buffer.length, width: dims.width, height: dims.height };
|
|
4618
|
-
}
|
|
4619
|
-
/** Connect a profile at `target`, register it under `key`, and arm downloads. */
|
|
4620
|
-
async openConnection(profile, target, key, profileName, opts = {}) {
|
|
4621
|
-
const conn = await this.connectProfile(profile, target, key, opts);
|
|
4622
|
-
conn.key = key;
|
|
4623
|
-
conn.profile = profileName;
|
|
4624
|
-
this.connections.set(key, conn);
|
|
4625
|
-
// Download behavior configuration is CDP-specific; skip for native Arc + Firefox.
|
|
4626
|
-
if (conn.backend !== 'arc-native' && conn.backend !== 'bidi') {
|
|
4627
|
-
await this.applyDefaultDownloadBehavior(conn, key);
|
|
4628
|
-
}
|
|
4629
|
-
return conn;
|
|
4630
|
-
}
|
|
4631
|
-
/**
|
|
4632
|
-
* Open a tab for a HUMAN to read, bound to no task.
|
|
4633
|
-
*
|
|
4634
|
-
* That is the entire difference from {@link start}, and it is the point. The
|
|
4635
|
-
* abandoned-task reaper closes a task's tabs when the calling session dies or
|
|
4636
|
-
* after the idle window, but it "deliberately never touches a tab that is not
|
|
4637
|
-
* in `task.tabs`" (hygiene.ts) — so a viewer tab must not be in one, or the
|
|
4638
|
-
* artifact the user is reading vanishes when the agent that rendered it exits.
|
|
4639
|
-
*
|
|
4640
|
-
* Consequently `stop`/`done` do not close it either, which is correct: it is
|
|
4641
|
-
* the user's tab now.
|
|
4642
|
-
*/
|
|
4643
|
-
async showUrl(profileName, url, opts = {}) {
|
|
4644
|
-
// Same consent gate as start(): this opens a browser, so a fleet-remote
|
|
4645
|
-
// caller needs the target machine's opt-in.
|
|
4646
|
-
assertRemoteControlAllowedForRequest(opts.fleetRemote, { actor: opts.actor });
|
|
4647
|
-
const routed = resolveBrowserTarget(profileName, {
|
|
4648
|
-
endpointName: opts.endpointName,
|
|
4649
|
-
probe: opts.probe,
|
|
4650
|
-
});
|
|
4651
|
-
if (!routed.local && routed.commandDispatch) {
|
|
4652
|
-
throw new Error(`Native Arc profile "${profileName}" is owned by ${routed.device}; dispatch the complete browser command there.`);
|
|
4653
|
-
}
|
|
4654
|
-
const key = routed.key;
|
|
4655
|
-
const effectiveProfile = routed.profile;
|
|
4656
|
-
const reused = await this.reuseProfileConnection(profileName, key, routed.local);
|
|
4657
|
-
let conn = reused?.conn;
|
|
4658
|
-
const effectiveKey = reused?.key ?? key;
|
|
4659
|
-
if (!conn) {
|
|
4660
|
-
adoptLegacyRuntimeIfLocal(routed.local, profileName, key, getBrowserRuntimeDir());
|
|
4661
|
-
conn = await this.openConnection(effectiveProfile, routed.target, key, profileName, { persistRemote: !routed.local });
|
|
4662
|
-
}
|
|
4663
|
-
if (conn.backend === 'arc-native') {
|
|
4664
|
-
throw new ArcNativeCapabilityError('show', 'Task-less viewer tabs are unavailable for native Arc because creation intent cannot be bound to an owning task.');
|
|
4665
|
-
}
|
|
4666
|
-
if (conn.backend === 'bidi') {
|
|
4667
|
-
throw new FirefoxCapabilityError('show', 'Task-less viewer tabs are unavailable for a headless-automation Firefox profile. ' +
|
|
4668
|
-
'Use a Chromium-family profile, or open the URL in your OS browser.');
|
|
4669
|
-
}
|
|
4670
|
-
// createPageTarget refuses Arc with an actionable error rather than crashing it.
|
|
4671
|
-
const created = await this.createPageTarget(conn, { url });
|
|
4672
|
-
this.invalidateTargetCache(conn);
|
|
4673
|
-
return {
|
|
4674
|
-
profile: profileName,
|
|
4675
|
-
key: effectiveKey,
|
|
4676
|
-
tabId: created.targetId,
|
|
4677
|
-
device: routed.device,
|
|
4678
|
-
picked: routed.picked,
|
|
4679
|
-
};
|
|
4680
|
-
}
|
|
4681
|
-
async getHistory(limit = 10) {
|
|
4682
|
-
const historyFile = path.join(getBrowserRuntimeDir(), 'history.json');
|
|
4683
|
-
if (!fs.existsSync(historyFile))
|
|
4684
|
-
return [];
|
|
4685
|
-
try {
|
|
4686
|
-
const history = JSON.parse(fs.readFileSync(historyFile, 'utf-8'));
|
|
4687
|
-
return history.slice(0, limit);
|
|
4688
|
-
}
|
|
4689
|
-
catch {
|
|
4690
|
-
return [];
|
|
4691
|
-
}
|
|
4692
|
-
}
|
|
4693
|
-
}
|