pi-agent-browser-native 0.6.7 → 0.6.9
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 +28 -0
- package/README.md +4 -2
- package/dist/extensions/agent-browser/lib/electron/cleanup.js +5 -5
- package/dist/extensions/agent-browser/lib/electron/launch.js +77 -23
- package/dist/extensions/agent-browser/lib/orchestration/browser-run/diagnostics.js +2 -2
- package/dist/extensions/agent-browser/lib/orchestration/browser-run/managed-session-daemon-policy.js +26 -3
- package/dist/extensions/agent-browser/lib/orchestration/browser-run/prepare.js +3 -1
- package/dist/extensions/agent-browser/lib/orchestration/browser-run/process-output.js +3 -1
- package/dist/extensions/agent-browser/lib/orchestration/browser-run/session-state.js +2 -1
- package/dist/extensions/agent-browser/lib/orchestration/electron-host/index.js +4 -1
- package/dist/extensions/agent-browser/lib/playbook.js +1 -1
- package/dist/extensions/agent-browser/lib/process.js +5 -106
- package/dist/extensions/agent-browser/lib/results/action-recommendations.js +5 -2
- package/dist/extensions/agent-browser/lib/results/envelope.js +5 -3
- package/dist/extensions/agent-browser/lib/results/next-actions.js +8 -0
- package/dist/extensions/agent-browser/lib/results/presentation/batch.js +2 -1
- package/dist/extensions/agent-browser/lib/results/presentation/errors.js +10 -2
- package/dist/extensions/agent-browser/lib/results/presentation.js +9 -2
- package/dist/extensions/agent-browser/lib/temp.js +14 -0
- package/docs/ARCHITECTURE.md +2 -2
- package/docs/COMMAND_REFERENCE.md +7 -3
- package/docs/ELECTRON.md +8 -4
- package/docs/RELEASE.md +2 -0
- package/docs/SUPPORT_MATRIX.md +6 -3
- package/docs/TOOL_CONTRACT.md +9 -7
- package/package.json +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.9 - 2026-09-08
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- Include redacted stdout/stderr tails in failed Electron startup diagnostics and visible errors (#128). Capture uses private regular files inside the isolated profile, with the last 4096 bytes read per stream; this is not a lifetime disk limit. Preserve the profile and logs when failed-startup process cleanup cannot finish, without changing normal quit cleanup.
|
|
8
|
+
|
|
9
|
+
- Restore ordinary browser access to a tracked Electron app after Pi reload or resume by checking its live debug endpoint and the named upstream connection. Keep the app, profile, and session intact; unrelated or replaced connections still fail verification.
|
|
10
|
+
|
|
11
|
+
### Validation
|
|
12
|
+
|
|
13
|
+
- Verified startup output, reload continuity and quit cleanup with genuine Electron through the official Pi SDK on native macOS and Ubuntu. Native Windows was waived and not run; permanent release gates are unchanged.
|
|
14
|
+
|
|
15
|
+
## 0.6.8 - 2026-09-07
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- Add session- and namespace-aware `inspect-overlay-state` recovery for direct, semantic, raw `find` (including `nth` and default-click), and batched/job clicks that upstream rejects because another element covers the target's click point. These failures remain `upstream-error`; the wrapper recommends refreshing refs for inspection without retrying the blocked click or guessing a dismiss control. Thanks to @MDGChamomile for #147.
|
|
20
|
+
|
|
21
|
+
- Replace Windows PowerShell argument forwarding with `cross-spawn` at the shared process boundary, preserving empty operands, literal doublequotes, the selected `PATH` shim and upstream architecture selection. Remove command reordering and the old empty-argument/namespace workarounds; POSIX keeps native Node `spawn`. Thanks to @MaartenDMT (#109) and @dagve11 (#134) for their reports and proposed fixes.
|
|
22
|
+
|
|
23
|
+
### Documentation
|
|
24
|
+
|
|
25
|
+
- Clarify that recording destinations are reserved within one Pi process; concurrent processes must use unique paths because different sessions can overwrite the same file (#110).
|
|
26
|
+
|
|
27
|
+
### Validation
|
|
28
|
+
|
|
29
|
+
- Retain runnable Windows argv and failure/lifecycle contracts in the local and platform-target gates. Native macOS and Ubuntu validate the POSIX process path; PowerShell Legacy diagnostics reproduced both old corruptions but do not validate the replacement Windows transport. Native Windows/`cmd.exe` was unavailable and was not run under the task-specific waiver; permanent release requirements remain intact.
|
|
30
|
+
|
|
3
31
|
## 0.6.7 - 2026-09-07
|
|
4
32
|
|
|
5
33
|
### Fixed
|
package/README.md
CHANGED
|
@@ -135,6 +135,8 @@ Then install this Pi package:
|
|
|
135
135
|
pi install npm:pi-agent-browser-native
|
|
136
136
|
```
|
|
137
137
|
|
|
138
|
+
After updating `pi-agent-browser-native`, fully quit and restart Pi before using the updated tools. `/reload` can retain previously loaded compiled JavaScript even after `dist/` is rebuilt, so it is not a reliable way to pick up package updates.
|
|
139
|
+
|
|
138
140
|
Start Pi and ask for a browser action:
|
|
139
141
|
|
|
140
142
|
```text
|
|
@@ -449,7 +451,7 @@ Typical pitfalls:
|
|
|
449
451
|
- If the failure is `selector-not-found`, the wrapper may take one fresh snapshot and add `Current snapshot ref fallback` when that snapshot has exact visible role/name matches for the failed `find` / `semanticAction` target. Non-fill targets can include direct `try-current-visible-ref*` next actions, and semantic click misses can still add bounded `Agent-browser candidate fallbacks` such as `button`/`link` role retries for `text` clicks. `semanticAction` does not expose `uncheck` while upstream `find ... uncheck` is not runtime-supported; use raw `args: ["uncheck", <selector-or-ref>]` after a stable selector or fresh snapshot ref. For semantic `fill` misses on desktop or host-controlled rich inputs, prefer `details.richInputRecovery`: refresh refs, choose the current editable `@ref`, focus or click it, then use `keyboard type` for framework-controlled editors that require real key events. `keyboard inserttext` is paste-like and can change a DOM value without updating application state, so use it only with separate application-state verification. Direct contenteditable fills are verified with `get text` when snapshot metadata proves the target is contenteditable; if replacement did not happen, `details.fillVerification` warns before any submit step. Those recovery nextActions do not copy the fill text and do not press `Enter` or submit; only submit when the user flow explicitly calls for it (same contract link).
|
|
450
452
|
- A successful upstream `click` is not proof that the web app handled the event or changed state. For top-level non-Electron direct clicks on `xpath=` targets and eligible current `@e…` refs, the wrapper may fail the tool with `details.clickDispatch` and a `Click dispatch diagnostic` line when upstream reported success but no trusted DOM event reached the resolved target. Raw `find … click` locator calls are not probed because the wrapper has no concrete element before upstream resolves the locator, and document-level probes can falsely fail frame-scoped clicks. `@e…` ref click probes are limited to current snapshot refs with accessible role `button`, `checkbox`, `menuitem`, `radio`, `switch`, or `tab`, with a unique role/name in both the saved snapshot and the live candidates. Duplicate-name refs remain upstream-owned; snapshot order is not target identity. Use the suggested `inspect-click-dispatch-miss` / `retry-click-after-dispatch-miss` next actions instead of assuming the click mutated the page; when `details.clickDispatch.scrollContainer` is present, use `scroll-target-into-view-after-dispatch-miss` first. When the task depends on a mutation, follow `inspect-after-mutation` / `pageChangeSummary` evidence with a wait, URL/text check, or fresh snapshot before trusting the result; if the target still did not change, retry with a current visible ref or stable selector and report the workflow issue instead of silently continuing. For static local fixtures where the user only needs to exercise app code, an explicit `eval --stdin` programmatic click such as `document.querySelector("#demo").click()` can be a diagnostic workaround, but treat it as an untrusted scripted activation rather than proof a real user click works, and never use it to bypass user instructions. Respect explicit user stop boundaries yourself: if the user says to stop before order/post/purchase/submit, gather evidence on that page and do not click the final action. The wrapper does not parse broad prompt text into business-intent action blocks; `details.promptGuard` is reserved for concrete artifact-before-close checks.
|
|
451
453
|
- A successful upstream `click` can deliver no input at all on some sites. Reproduced against `https://www.saucedemo.com/` with upstream `agent-browser` 0.34.0 and no wrapper involved: when every CLI invocation in the flow is spaced ~0.6s apart, the React add-to-cart click reports success while **zero** DOM events reach a capture-phase listener and the cart badge never updates (0/5 runs succeeded, versus 4/5 with no spacing). A single idle gap is harmless (5/5); only sustained spacing across the flow triggers it. In that state a scripted `document.querySelector(...).click()` still works and updates the badge, so the page and its handlers are fine and the input never arrives; retries, `scrollintoview`, headed mode, and re-navigation do not recover it. This looks site-specific — React TodoMVC and react.dev were unaffected under the same pacing and command count. Wrapper calls do more per-command work than raw CLI calls, so they sit in the slow regime more often. Running the interaction steps as one `batch` keeps them inside a single upstream process and succeeded 5/5; prefer `batch` for consecutive real-app click flows, and treat a click whose `pageChangeSummary`/`navigationSummary` shows no change as unproven. When a `wait --url` or `assertUrl` times out after a suspected missed click, use the `fresh-session-after-url-wait-timeout` next action (`sessionMode: "fresh"` + `open about:blank`): replace about:blank with the target URL and replay the flow as one batch in a fresh session instead of retrying the wait.
|
|
452
|
-
- A successful `snapshot -i` can surface `Possible overlay blockers` immediately when refs already contain strong dialog/alertdialog evidence plus close/dismiss controls. If a **top-level** `@e…`/`ref=` click succeeds (unified command `click`, not a `batch` step), upstream reports `data.clicked`, and `details.navigationSummary.url` stays on the same tab URL under the same normalization as ref preflight (fragment-insensitive), the wrapper may take one extra `snapshot -i` and add `Possible overlay blockers` with `details.overlayBlockers` (`candidates`, `summary`, optional `snapshot` refresh for refs) plus session-aware `inspect-overlay-state` / bounded `try-overlay-blocker-candidate-*` next actions when that snapshot shows strong modal context (`dialog` / `alertdialog`) and close/dismiss-like controls. Page-wide words like privacy, sign in, or banner alone do not trigger this diagnostic. The unchanged-URL check compares the prior pinned tab target with `details.navigationSummary.url`; CSS selector clicks do not run this overlay probe. Also skipped when tab correction or about-blank recovery already ran on that result.
|
|
454
|
+
- A successful `snapshot -i` can surface `Possible overlay blockers` immediately when refs already contain strong dialog/alertdialog evidence plus close/dismiss controls. If a **top-level** `@e…`/`ref=` click succeeds (unified command `click`, not a `batch` step), upstream reports `data.clicked`, and `details.navigationSummary.url` stays on the same tab URL under the same normalization as ref preflight (fragment-insensitive), the wrapper may take one extra `snapshot -i` and add `Possible overlay blockers` with `details.overlayBlockers` (`candidates`, `summary`, optional `snapshot` refresh for refs) plus session-aware `inspect-overlay-state` / bounded `try-overlay-blocker-candidate-*` next actions when that snapshot shows strong modal context (`dialog` / `alertdialog`) and close/dismiss-like controls. Page-wide words like privacy, sign in, or banner alone do not trigger this diagnostic. The unchanged-URL check compares the prior pinned tab target with `details.navigationSummary.url`; CSS selector clicks do not run this overlay probe. Also skipped when tab correction or about-blank recovery already ran on that result. Separately, when upstream rejects a click because another element covers the target's click point (`is covered by` … `at its click point`), the wrapper keeps `failureCategory: "upstream-error"` and adds only the session-aware `inspect-overlay-state` snapshot action; it does not retry the blocked click or guess a dismiss control without fresh snapshot evidence. This covers direct `click`, `semanticAction` click, raw `find` clicks (including `nth` and an omitted action that defaults to click), and failed `batch`/`job` rows; it does not diagnose silent input-dispatch failures.
|
|
453
455
|
- If `get text <selector>` reads a non-ref, non-simple-id CSS selector with multiple matches or a hidden first match while visible matches exist, including successful `batch` steps, the wrapper may add `Selector text visibility warning`, `details.selectorTextVisibility` (plus `selectorTextVisibilityAll` for multiple batched warnings), and `inspect-visible-text-candidates` next actions; the warning names the matching `details.nextActions` id. Prefer a visible `@ref`, a scoped selector, or a targeted `eval --stdin` over hidden tab content.
|
|
454
456
|
- In wrapper-tracked attached Electron sessions, broad selectors such as `body`, `html`, `main`, or `[role=application]` may read the whole app shell. The wrapper may add `Broad Electron get text selector warning`, `details.electronGetTextScopeWarning`, and `snapshot-for-electron-text-scope`; ordinary browser pages do not qualify without Electron launch provenance. Prefer `snapshot -i`, a current `@ref`, or a narrower panel selector.
|
|
455
457
|
|
|
@@ -505,7 +507,7 @@ For desktop Electron apps, use top-level `electron` to avoid hand-building the d
|
|
|
505
507
|
{ "electron": { "action": "cleanup", "launchId": "electron-…" } }
|
|
506
508
|
```
|
|
507
509
|
|
|
508
|
-
`electron.list` has no configurable timeout. Other Electron actions accept nested `electron.timeoutMs`; `electron.probe.timeoutMs` bounds each underlying read subprocess when dense desktop apps need a shorter or longer probe budget (omit for the normal tool subprocess default). `electron.cleanup.timeoutMs` applies separately to upstream `close` and the initial process-exit wait, not the whole teardown, and defaults to the implicit session close budget unless overridden; if the managed-session close step succeeds but host cleanup is partial, later default browser calls still rotate away from that closed wrapper-managed session. `electron.status.timeoutMs`
|
|
510
|
+
`electron.list` has no configurable timeout. Other Electron actions accept nested `electron.timeoutMs`; `electron.probe.timeoutMs` bounds each underlying read subprocess when dense desktop apps need a shorter or longer probe budget (omit for the normal tool subprocess default). `electron.cleanup.timeoutMs` applies separately to upstream `close` and the initial process-exit wait, not the whole teardown, and defaults to the implicit session close budget unless overridden; if the managed-session close step succeeds but host cleanup is partial, later default browser calls still rotate away from that closed wrapper-managed session. `electron.status.timeoutMs` applies to managed-session title/URL reads and any `get cdp-url` read needed to verify a restored connection. Pass `electron.probe.launchId` when you want the probe tied to a wrapper-tracked launch instead of only the current managed session. Launch/status/probe results show both `launchId` (for status/cleanup/probe) and `sessionName` (for browser `snapshot`/`tab` commands); if the managed session drifts to `about:blank` while wrapper status still sees a live renderer, Electron-specific mismatch warnings and `status`/`probe`/`reattach`/`snapshot` next actions replace generic tab guidance. `/reload` preserves the current branch-visible active Electron launch and its isolated temp `userDataDir` for continuity, and cleans off-branch owned Electron launches. First reuse after reload/resume checks the live app's saved debug endpoint and the exact named upstream connection without reconnecting or resetting page refs; if cleanup is partial and skips or fails profile removal, the generic temp sweep preserves that `userDataDir` across reload, quit, later temp cleanup, process exit, and stale temp-root pruning after restart. If the app process/debug port dies after a successful-looking mutation, the wrapper reports `details.electronPostCommandHealth` and fails with `tab-drift` instead of quietly continuing on `about:blank`. Failed startups expose redacted stdout/stderr tails in visible errors and `details.electron.failure.diagnostics`, alongside PID, profile, DevToolsActivePort, and timing evidence. Each tail reads at most 4096 source bytes; private capture files follow the profile lifecycle, not a lifetime disk cap. See [`docs/ELECTRON.md`](docs/ELECTRON.md#failure-categories-and-recovery).
|
|
509
511
|
|
|
510
512
|
Explicit-ID `electron.status` labels historical cleaned launch records; default and `all: true` selection exclude them. Current PID/port liveness stays independent of cleanup history. `details.electron.statuses[].userDataDirState` freshly measures only the tracked profile path: `present` (including dangling symlinks), `absent` (ENOENT), or `unknown` (other `lstat` errors), not whether all app residue is gone.
|
|
511
513
|
|
|
@@ -24,15 +24,15 @@ function isPidAlive(pid) {
|
|
|
24
24
|
return code === "EPERM" ? true : false;
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
|
-
async function isPortAlive(port) {
|
|
28
|
-
const version = parseCdpVersion(await fetchCdpJson(`http://127.0.0.1:${port}/json/version
|
|
27
|
+
async function isPortAlive(port, signal) {
|
|
28
|
+
const version = parseCdpVersion(await fetchCdpJson(`http://127.0.0.1:${port}/json/version`, signal));
|
|
29
29
|
if (!version)
|
|
30
30
|
return { targets: [] };
|
|
31
|
-
const targets = parseCdpTargets(await fetchCdpJson(`http://127.0.0.1:${port}/json/list
|
|
31
|
+
const targets = parseCdpTargets(await fetchCdpJson(`http://127.0.0.1:${port}/json/list`, signal));
|
|
32
32
|
return { targets, version };
|
|
33
33
|
}
|
|
34
|
-
export async function inspectElectronLaunchStatus(record) {
|
|
35
|
-
const cdp = await isPortAlive(record.port);
|
|
34
|
+
export async function inspectElectronLaunchStatus(record, signal) {
|
|
35
|
+
const cdp = await isPortAlive(record.port, signal);
|
|
36
36
|
let userDataDirState;
|
|
37
37
|
try {
|
|
38
38
|
await lstat(record.userDataDir);
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { readFile, rm } from "node:fs/promises";
|
|
4
|
-
import { dirname } from "node:path";
|
|
3
|
+
import { open, readFile, rm } from "node:fs/promises";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
5
|
import { fetchCdpJson, parseCdpTargets, parseCdpVersion, } from "./cdp.js";
|
|
6
6
|
import { discoverElectronApps, inspectElectronAppPath, inspectElectronExecutablePath, } from "./discovery.js";
|
|
7
|
-
import { createSecureTempDirectory } from "../temp.js";
|
|
7
|
+
import { createSecureTempDirectory, preserveSecureTempDirectory } from "../temp.js";
|
|
8
8
|
export const ELECTRON_LAUNCH_RECORD_VERSION = 1;
|
|
9
9
|
export const ELECTRON_LAUNCH_DEFAULT_TIMEOUT_MS = 15_000;
|
|
10
10
|
export const ELECTRON_LAUNCH_MAX_TIMEOUT_MS = 120_000;
|
|
@@ -12,6 +12,8 @@ const DEVTOOLS_ACTIVE_PORT_FILE = "DevToolsActivePort";
|
|
|
12
12
|
export const ELECTRON_PROFILE_DIR_PREFIX = "electron-profile-";
|
|
13
13
|
const ELECTRON_DEFAULT_APP_ARGS = ["--disable-extensions", "--no-first-run", "--no-default-browser-check"];
|
|
14
14
|
const ELECTRON_DEVTOOLS_POLL_INTERVAL_MS = 100;
|
|
15
|
+
// ponytail: bound failure reads, not lifetime log growth; noisy long-lived apps need rotation if that becomes a problem.
|
|
16
|
+
const ELECTRON_OUTPUT_TAIL_BYTES = 4096;
|
|
15
17
|
function normalizeTimeoutMs(timeoutMs) {
|
|
16
18
|
if (!Number.isSafeInteger(timeoutMs) || (timeoutMs ?? 0) <= 0)
|
|
17
19
|
return ELECTRON_LAUNCH_DEFAULT_TIMEOUT_MS;
|
|
@@ -284,40 +286,90 @@ export async function launchElectronApp(options) {
|
|
|
284
286
|
let exitCode = null;
|
|
285
287
|
let exitSignal = null;
|
|
286
288
|
const args = buildLaunchArgs(userDataDir, appArgs);
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
289
|
+
let child;
|
|
290
|
+
let outputCaptured = false;
|
|
291
|
+
const outputFiles = [];
|
|
292
|
+
try {
|
|
293
|
+
for (const stream of ["stdout", "stderr"])
|
|
294
|
+
outputFiles.push(await open(join(userDataDir, `${stream}.log`), "wx", 0o600));
|
|
295
|
+
options.signal?.throwIfAborted();
|
|
296
|
+
child = spawn(target.executablePath, args, {
|
|
297
|
+
cwd: dirname(target.executablePath),
|
|
298
|
+
detached: process.platform !== "win32",
|
|
299
|
+
stdio: ["ignore", outputFiles[0].fd, outputFiles[1].fd],
|
|
300
|
+
});
|
|
301
|
+
outputCaptured = true;
|
|
302
|
+
child.once("error", (error) => {
|
|
303
|
+
spawnError = error;
|
|
304
|
+
});
|
|
305
|
+
child.once("exit", (code, signal) => {
|
|
306
|
+
exitCode = code;
|
|
307
|
+
exitSignal = signal;
|
|
308
|
+
});
|
|
309
|
+
child.unref();
|
|
310
|
+
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
spawnError = error instanceof Error ? error : new Error(String(error));
|
|
313
|
+
}
|
|
314
|
+
finally {
|
|
315
|
+
for (const file of outputFiles) {
|
|
316
|
+
await file.close().catch((error) => {
|
|
317
|
+
cleanupError = [cleanupError, `Output handle close failed: ${error instanceof Error ? error.message : String(error)}`].filter(Boolean).join("; ");
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
}
|
|
300
321
|
const buildFailureDiagnostics = (options = {}) => ({
|
|
301
322
|
cdpVersionReached: options.cdpVersionReached,
|
|
302
323
|
devToolsActivePort: options.devToolsActivePort,
|
|
303
324
|
elapsedMs: Math.max(0, Date.now() - startedAtMs),
|
|
304
325
|
exitCode,
|
|
305
326
|
exitSignal,
|
|
306
|
-
outputCaptured
|
|
307
|
-
pid: child
|
|
308
|
-
pidAlive: isLaunchChildPidAlive(child),
|
|
327
|
+
outputCaptured,
|
|
328
|
+
pid: child?.pid,
|
|
329
|
+
pidAlive: child ? isLaunchChildPidAlive(child) : undefined,
|
|
309
330
|
port: options.port ?? options.devToolsActivePort?.port,
|
|
310
331
|
timeoutMs,
|
|
311
332
|
userDataDir,
|
|
312
333
|
});
|
|
313
334
|
const fail = async (reason, detail, diagnosticOptions) => {
|
|
314
335
|
const diagnostics = buildFailureDiagnostics(diagnosticOptions);
|
|
315
|
-
const processCleanupError = await terminateLaunchChild(child);
|
|
336
|
+
const processCleanupError = child ? await terminateLaunchChild(child) : undefined;
|
|
337
|
+
const outputLines = [];
|
|
338
|
+
if (outputCaptured) {
|
|
339
|
+
for (const stream of ["stdout", "stderr"]) {
|
|
340
|
+
let file;
|
|
341
|
+
try {
|
|
342
|
+
file = await open(join(userDataDir, `${stream}.log`), "r");
|
|
343
|
+
const { size } = await file.stat();
|
|
344
|
+
const buffer = Buffer.alloc(Math.min(size, ELECTRON_OUTPUT_TAIL_BYTES));
|
|
345
|
+
const { bytesRead } = await file.read(buffer, 0, buffer.length, Math.max(0, size - buffer.length));
|
|
346
|
+
diagnostics[`${stream}Tail`] = buffer.subarray(0, bytesRead).toString("utf8");
|
|
347
|
+
diagnostics[`${stream}Truncated`] = size > buffer.length;
|
|
348
|
+
}
|
|
349
|
+
catch (error) {
|
|
350
|
+
diagnostics[`${stream}Error`] = error instanceof Error ? error.message : String(error);
|
|
351
|
+
}
|
|
352
|
+
finally {
|
|
353
|
+
await file?.close().catch((error) => {
|
|
354
|
+
diagnostics[`${stream}Error`] = [diagnostics[`${stream}Error`], `Output reader close failed: ${error instanceof Error ? error.message : String(error)}`].filter(Boolean).join("; ");
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
const tail = diagnostics[`${stream}Tail`];
|
|
358
|
+
if (tail !== undefined)
|
|
359
|
+
outputLines.push(`App ${stream}${diagnostics[`${stream}Truncated`] ? ` (last ${ELECTRON_OUTPUT_TAIL_BYTES} bytes)` : ""}: ${tail || "(empty)"}`);
|
|
360
|
+
if (diagnostics[`${stream}Error`])
|
|
361
|
+
outputLines.push(`App ${stream} capture error: ${diagnostics[`${stream}Error`]}`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
316
364
|
try {
|
|
317
|
-
|
|
365
|
+
if (processCleanupError)
|
|
366
|
+
await preserveSecureTempDirectory(userDataDir);
|
|
367
|
+
else
|
|
368
|
+
await rm(userDataDir, { force: true, recursive: true });
|
|
318
369
|
}
|
|
319
370
|
catch (error) {
|
|
320
|
-
|
|
371
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
372
|
+
cleanupError = [cleanupError, processCleanupError ? `Profile preservation failed: ${message}` : message].filter(Boolean).join("; ");
|
|
321
373
|
}
|
|
322
374
|
cleanupError = [processCleanupError, cleanupError].filter((value) => value !== undefined).join("; ") || undefined;
|
|
323
375
|
return {
|
|
@@ -326,13 +378,15 @@ export async function launchElectronApp(options) {
|
|
|
326
378
|
appArgs,
|
|
327
379
|
cleanupError,
|
|
328
380
|
diagnostics,
|
|
329
|
-
error: launchFailureMessage(reason, target, detail),
|
|
381
|
+
error: [launchFailureMessage(reason, target, detail), ...outputLines].join("\n"),
|
|
330
382
|
reason,
|
|
331
383
|
target,
|
|
332
384
|
userDataDir,
|
|
333
385
|
},
|
|
334
386
|
};
|
|
335
387
|
};
|
|
388
|
+
if (!child)
|
|
389
|
+
return fail(options.signal?.aborted ? "aborted" : "spawn-error", spawnError?.message);
|
|
336
390
|
const portResult = await pollDevToolsActivePort({
|
|
337
391
|
deadlineMs,
|
|
338
392
|
getChildExit: () => ({ code: exitCode, signal: exitSignal }),
|
|
@@ -4,7 +4,7 @@ import { isCloseCommand, isOpenNavigationCommand } from "../../command-taxonomy.
|
|
|
4
4
|
import { boundElectronProbeString } from "../../electron/cdp.js";
|
|
5
5
|
import { executableExistsOnPath } from "../../executable-path.js";
|
|
6
6
|
import { formatSessionArtifactRetentionSummary } from "../../results/artifact-manifest.js";
|
|
7
|
-
import { buildNextToolAction, withOptionalSessionArgs } from "../../results/next-actions.js";
|
|
7
|
+
import { buildInspectOverlayStateAction, buildNextToolAction, withOptionalSessionArgs } from "../../results/next-actions.js";
|
|
8
8
|
import { buildVisibleRefFallbackDiagnosticFromSnapshot, getVisibleRefFallbackTarget } from "../../results/selector-recovery.js";
|
|
9
9
|
import { extractRefSnapshotFromData, isAboutBlankUrl, normalizeComparableUrl } from "../../session-page-state.js";
|
|
10
10
|
import { extractUpstreamCommandTokens, parseWaitCommandTokens, redactInvocationArgs, redactSensitiveText } from "../../runtime.js";
|
|
@@ -266,7 +266,7 @@ export function formatOverlayBlockerText(diagnostic) {
|
|
|
266
266
|
return ["Possible overlay blockers:", ...diagnostic.candidates.map((candidate) => `- ${candidate.ref}${candidate.role ? ` ${candidate.role}` : ""}${candidate.name ? ` ${JSON.stringify(candidate.name)}` : ""}: ${candidate.reason}`)].join("\n");
|
|
267
267
|
}
|
|
268
268
|
export function buildOverlayBlockerNextActions(options) {
|
|
269
|
-
return [
|
|
269
|
+
return [buildInspectOverlayStateAction(options.sessionName), ...options.diagnostic.candidates.map((candidate, index) => ({ id: `try-overlay-blocker-candidate-${index + 1}`, params: { args: withOptionalSessionArgs(options.sessionName, candidate.args) }, reason: candidate.reason, safety: "Only click this if the candidate is clearly a close/dismiss control for an overlay that blocks the intended workflow.", tool: "agent_browser" }))];
|
|
270
270
|
}
|
|
271
271
|
export function collectSnapshotOverlayBlockerDiagnostic(data) {
|
|
272
272
|
const candidates = getOverlayBlockerCandidates(data);
|
package/dist/extensions/agent-browser/lib/orchestration/browser-run/managed-session-daemon-policy.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { rm } from "node:fs/promises";
|
|
2
|
+
import { getAgentBrowserSessionIdentityKey } from "../../argv-grammar.js";
|
|
3
|
+
import { inspectElectronLaunchStatus } from "../../electron/cleanup.js";
|
|
2
4
|
import { acquireManagedSessionPolicyLock } from "../../managed-session-policy-lock.js";
|
|
3
|
-
import { pruneOwnedManagedSessionRestoreSnapshots, resolveExplicitAutosaveInterval, } from "../../managed-session-restore.js";
|
|
5
|
+
import { pruneOwnedManagedSessionRestoreSnapshots, resolveExplicitAutosaveInterval, withOwnedManagedSessionContext, } from "../../managed-session-restore.js";
|
|
4
6
|
import { isManagedSessionRestoreKey } from "../../managed-session-storage.js";
|
|
5
7
|
import { isRecord } from "../../parsing.js";
|
|
6
8
|
import { getAgentBrowserProcessEnvironment } from "../../process-environment.js";
|
|
7
|
-
import { runAgentBrowserProcess } from "../../process.js";
|
|
9
|
+
import { runAgentBrowserProcess, withAttachedBrowserSessionContext } from "../../process.js";
|
|
8
10
|
import { getAgentBrowserErrorText, parseAgentBrowserEnvelope } from "../../results/envelope.js";
|
|
9
11
|
import { redactInvocationArgs } from "../../runtime.js";
|
|
12
|
+
import { runSessionCommandData } from "./session-state.js";
|
|
10
13
|
const MANAGED_SESSION_DAEMON_INSPECTION_TIMEOUT_MS = 35_000;
|
|
11
14
|
const RUNNING_HEADED_AUTOSAVE_POLICY_CHANGE_ERROR = "AGENT_BROWSER_AUTOSAVE_INTERVAL_MS cannot change a running wrapper-owned headed session's launch-time periodic autosave interval. Close that session first, then retry with sessionMode: \"fresh\" so the new daemon starts with the requested interval.";
|
|
12
15
|
export function getRunningHeadedAutosavePolicyChangeError(recordedInterval, closeCommand = false) {
|
|
@@ -49,6 +52,23 @@ export async function inspectManagedSessionDaemon(options) {
|
|
|
49
52
|
await rm(processResult.stdoutSpillPath, { force: true }).catch(() => undefined);
|
|
50
53
|
}
|
|
51
54
|
}
|
|
55
|
+
async function verifyRestoredElectronAttachment(context, record, signal, timeoutMs) {
|
|
56
|
+
const cwd = context.cwd;
|
|
57
|
+
if (!cwd || !record?.webSocketDebuggerUrl || !record.sessionName || record.cleanupState === "cleaned"
|
|
58
|
+
|| getAgentBrowserSessionIdentityKey(record.sessionName, record.namespace) !== getAgentBrowserSessionIdentityKey(context.sessionName, context.namespace))
|
|
59
|
+
return false;
|
|
60
|
+
const status = await inspectElectronLaunchStatus(record, signal);
|
|
61
|
+
if (signal?.aborted || status.pidAlive !== true || status.userDataDirState !== "present"
|
|
62
|
+
|| status.version?.webSocketDebuggerUrl !== record.webSocketDebuggerUrl)
|
|
63
|
+
return false;
|
|
64
|
+
// This metadata probe must not grant daemon provenance just by spawning.
|
|
65
|
+
const connection = await withAttachedBrowserSessionContext(true, () => withOwnedManagedSessionContext(undefined, () => runSessionCommandData({
|
|
66
|
+
args: ["get", "cdp-url"], cwd, env: getHeadedManagedAutosaveEnv(context.headedManagedAutosaveInterval), namespace: context.namespace, pinNamespace: true,
|
|
67
|
+
sessionName: context.sessionName, signal, timeoutMs,
|
|
68
|
+
})));
|
|
69
|
+
return !signal?.aborted && isRecord(connection) && typeof connection.cdpUrl === "string"
|
|
70
|
+
&& (connection.cdpUrl === record.webSocketDebuggerUrl || status.targets.some((target) => target.webSocketDebuggerUrl === connection.cdpUrl));
|
|
71
|
+
}
|
|
52
72
|
export async function acquireOwnedManagedSessionDaemonPolicy(options) {
|
|
53
73
|
const { context, signal } = options;
|
|
54
74
|
if (!context.cwd)
|
|
@@ -81,7 +101,7 @@ export async function acquireOwnedManagedSessionDaemonPolicy(options) {
|
|
|
81
101
|
return { daemonStatus: daemon.status, lock };
|
|
82
102
|
}
|
|
83
103
|
const stickyDisabled = context.restoreState.isDisabled(context.sessionName, context.namespace);
|
|
84
|
-
|
|
104
|
+
let hasKnownDaemonRestoreKey = context.restoreState.hasDaemonRestoreKey(context.sessionName, context.namespace);
|
|
85
105
|
const knownDaemonRestoreKey = context.restoreState.getDaemonRestoreKey(context.sessionName, context.namespace);
|
|
86
106
|
const requestedDaemonRestoreKey = context.restoreDecision === "enabled" && stickyDisabled
|
|
87
107
|
? knownDaemonRestoreKey ?? null
|
|
@@ -93,6 +113,9 @@ export async function acquireOwnedManagedSessionDaemonPolicy(options) {
|
|
|
93
113
|
};
|
|
94
114
|
}
|
|
95
115
|
const restoreDisabledPolicyNeedsProvenance = stickyDisabled || context.restoreDecision !== "enabled";
|
|
116
|
+
if (daemon.status === "active" && restoreDisabledPolicyNeedsProvenance && !hasKnownDaemonRestoreKey && daemon.restoreKey === requestedDaemonRestoreKey) {
|
|
117
|
+
hasKnownDaemonRestoreKey = await verifyRestoredElectronAttachment(context, options.electronLaunchRecord, signal, options.electronVerificationTimeoutMs);
|
|
118
|
+
}
|
|
96
119
|
const activePolicyMatches = daemon.status === "active"
|
|
97
120
|
&& (!restoreDisabledPolicyNeedsProvenance || hasKnownDaemonRestoreKey)
|
|
98
121
|
&& daemon.restoreKey === requestedDaemonRestoreKey;
|
|
@@ -22,7 +22,7 @@ import { buildOwnedManagedSessionRestoreContext, resolveExplicitAutosaveInterval
|
|
|
22
22
|
import { getAgentBrowserProcessEnvironment } from "../../process-environment.js";
|
|
23
23
|
import { getExplicitSessionPageVerificationRequirement, getPageTargetValidationError, } from "../../page-target-validation.js";
|
|
24
24
|
import { acquireOwnedManagedSessionDaemonPolicy, getRunningHeadedAutosavePolicyChangeError } from "./managed-session-daemon-policy.js";
|
|
25
|
-
import { buildManagedSessionOutcome, buildSessionDetailFields, buildStaleRefPreflight, getSessionContextKey, extractStringResultField, ensureSessionTabTarget, getGuardedRefUsage, getTraceOwnerGuardMessage, runSessionCommandData, shouldPinSessionTabForCommand, } from "./session-state.js";
|
|
25
|
+
import { buildManagedSessionOutcome, buildSessionDetailFields, buildStaleRefPreflight, getSessionContextKey, findElectronLaunchRecordForSession, extractStringResultField, ensureSessionTabTarget, getGuardedRefUsage, getTraceOwnerGuardMessage, runSessionCommandData, shouldPinSessionTabForCommand, } from "./session-state.js";
|
|
26
26
|
import { getUpstreamEffectiveBatchSteps, parseBatchStdinJsonArray } from "../batch-stdin.js";
|
|
27
27
|
import { buildElectronHostFailureResult, formatAgentBrowserNextActionsText, getElectronLaunchFailureCategory, redactRecoveryHint } from "./final-result.js";
|
|
28
28
|
import { prepareClickDispatchProbe } from "./click-dispatch.js";
|
|
@@ -508,6 +508,8 @@ export async function prepareBrowserRun(options) {
|
|
|
508
508
|
const closeCommand = isCloseCommand(executionPlan.commandInfo.command);
|
|
509
509
|
const policy = await acquireOwnedManagedSessionDaemonPolicy({
|
|
510
510
|
context: ownedManagedSession,
|
|
511
|
+
electronLaunchRecord: findElectronLaunchRecordForSession(executionPlan.sessionName, state.electronLaunchRecords, executionPlan.namespace),
|
|
512
|
+
electronVerificationTimeoutMs: params.timeoutMs,
|
|
511
513
|
mode: closeCommand ? "close" : "reuse",
|
|
512
514
|
signal,
|
|
513
515
|
});
|
|
@@ -12,7 +12,7 @@ import { analyzeQaPresetResults, analyzeQaPresetTimeout, buildQaCompactFailureTe
|
|
|
12
12
|
import { applyNetworkRouteRecords, buildNetworkRouteDiagnostics } from "../../results/network-routes.js";
|
|
13
13
|
import { buildToolPresentation } from "../../results/presentation.js";
|
|
14
14
|
import { compactLargePresentationOutput } from "../../results/presentation/large-output.js";
|
|
15
|
-
import { getAgentBrowserErrorText, parseAgentBrowserEnvelope } from "../../results/envelope.js";
|
|
15
|
+
import { extractEnvelopeErrorText, getAgentBrowserErrorText, parseAgentBrowserEnvelope } from "../../results/envelope.js";
|
|
16
16
|
import { omitUpstreamLifecycle } from "../../results/presentation/common.js";
|
|
17
17
|
import { getClipboardWritePayloadCandidates, redactClipboardPermissionEcho, redactClipboardPermissionErrorValue } from "../../results/presentation/errors.js";
|
|
18
18
|
import { shouldCaptureSemanticActionNavigationSummary } from "../../results/presentation/semantic-action.js";
|
|
@@ -637,6 +637,8 @@ export async function processBrowserOutput(input) {
|
|
|
637
637
|
}
|
|
638
638
|
}
|
|
639
639
|
let errorText = getAgentBrowserErrorText({ aborted: processResult.aborted, command: prepared.executionPlan.commandInfo.command, effectiveArgs: prepared.redactedProcessArgs, envelope: presentationEnvelope, exitCode: processResult.exitCode, parseError, plainTextInspection, staleRefArgs: getStaleRefArgs(prepared.commandTokens, prepared.runtimeToolStdin), spawnError: processResult.spawnError, stderr: processResult.stderr, timedOut: processResult.timedOut, timeoutMs: processResult.timeoutMs, wrapperRecoveryHint: buildWrapperRecoveryHint({ sessionTabCorrection }) });
|
|
640
|
+
if (errorText && presentationEnvelope?.success === false && extractEnvelopeErrorText(presentationEnvelope.error) === undefined)
|
|
641
|
+
presentationEnvelope = { ...presentationEnvelope, error: errorText };
|
|
640
642
|
if (errorText) {
|
|
641
643
|
const clipboardWritePayloadCandidates = getClipboardWritePayloadCandidates(prepared.commandTokens);
|
|
642
644
|
errorText = redactClipboardPermissionEcho(prepared.executionPlan.commandInfo, errorText);
|
|
@@ -483,12 +483,13 @@ function selectAnySessionTargetTab(options) {
|
|
|
483
483
|
return selection ? { ...selection, ...(targetTitle ? { targetTitle } : {}), targetUrl } : undefined;
|
|
484
484
|
}
|
|
485
485
|
export async function runSessionCommandData(options) {
|
|
486
|
-
const { args, cwd, namespace, pinNamespace, sessionName, signal, stdin, throwOnFailure, timeoutMs } = options;
|
|
486
|
+
const { args, cwd, env, namespace, pinNamespace, sessionName, signal, stdin, throwOnFailure, timeoutMs } = options;
|
|
487
487
|
if (!sessionName)
|
|
488
488
|
return undefined;
|
|
489
489
|
const processResult = await runAgentBrowserProcess({
|
|
490
490
|
args: ["--json", ...(namespace !== undefined || pinNamespace ? ["--namespace", namespace ?? ""] : []), "--session", sessionName, ...args],
|
|
491
491
|
cwd,
|
|
492
|
+
env,
|
|
492
493
|
signal,
|
|
493
494
|
stdin,
|
|
494
495
|
timeoutMs,
|
|
@@ -414,7 +414,7 @@ async function withOwnedElectronManagedSessionPolicy(options, run) {
|
|
|
414
414
|
throw new ElectronManagedSessionPolicyError("Electron helper could not establish wrapper ownership for its managed session.");
|
|
415
415
|
let policy;
|
|
416
416
|
try {
|
|
417
|
-
policy = await acquireOwnedManagedSessionDaemonPolicy({ context, signal: options.signal });
|
|
417
|
+
policy = await acquireOwnedManagedSessionDaemonPolicy({ context, electronLaunchRecord: options.electronLaunchRecord, electronVerificationTimeoutMs: options.timeoutMs, signal: options.signal });
|
|
418
418
|
}
|
|
419
419
|
catch (error) {
|
|
420
420
|
throw new ElectronManagedSessionPolicyError(error instanceof Error ? error.message : String(error), { cause: error });
|
|
@@ -675,6 +675,7 @@ async function handleElectronHostInputInContext(options) {
|
|
|
675
675
|
const sessionKey = getSessionPageStateKey(record.sessionName, record.namespace) ?? record.sessionName;
|
|
676
676
|
return collectOwnedElectronManagedSessionTarget({
|
|
677
677
|
cwd,
|
|
678
|
+
electronLaunchRecord: record,
|
|
678
679
|
headedManagedAutosaveDisabled: ownedManagedSessions.get(sessionKey)?.headedManagedAutosaveDisabled,
|
|
679
680
|
headedManagedAutosaveInterval: ownedManagedSessions.get(sessionKey)?.headedManagedAutosaveInterval,
|
|
680
681
|
namespace: record.namespace,
|
|
@@ -750,12 +751,14 @@ async function handleElectronHostInputInContext(options) {
|
|
|
750
751
|
const probe = await withOwnedElectronManagedSessionPolicy({
|
|
751
752
|
args: ["snapshot", "-i"],
|
|
752
753
|
cwd,
|
|
754
|
+
electronLaunchRecord: launchRecord,
|
|
753
755
|
headedManagedAutosaveDisabled,
|
|
754
756
|
headedManagedAutosaveInterval,
|
|
755
757
|
namespace: probeNamespace,
|
|
756
758
|
restoreState: managedSessionRestoreState,
|
|
757
759
|
sessionName: probeSessionName,
|
|
758
760
|
signal,
|
|
761
|
+
timeoutMs: compiledElectron.timeoutMs,
|
|
759
762
|
}, async () => await collectElectronProbe({ cwd, namespace: probeNamespace, sessionName: probeSessionName, signal, timeoutMs: compiledElectron.timeoutMs }));
|
|
760
763
|
const managedSession = {
|
|
761
764
|
sessionName: probe.sessionName,
|
|
@@ -53,7 +53,7 @@ export const SHARED_BROWSER_PLAYBOOK_GUIDELINES = [
|
|
|
53
53
|
"On dashboards with nested scroll containers, verify scroll with a screenshot or fresh snapshot -i; if the viewport did not move, details.data.scrolled may be false/noMovement true and you should prefer scrollintoview <@ref> or target the actual scrollable region with scroll <selector> <dir> [px|percent]. For native selects, use select <selector> <value...> (or semanticAction/job select) instead of clicking option refs; for custom comboboxes, a click/semanticAction may only focus the field, so re-snapshot and use keyboard type <text> for focused input, press ArrowDown or press Enter, or visible option refs. Raw type requires both <selector> and <text>.",
|
|
54
54
|
"When using eval --stdin, scope checks and actions to the target element or route whenever possible instead of relying on broad page-wide text heuristics.",
|
|
55
55
|
"When using eval --stdin for extraction, pass the JavaScript through the native tool stdin field, not as an extra args token after --stdin, and return the value you want instead of relying on console.log as the primary result channel. Prefer plain expressions like ({ title: document.title }) or explicitly invoked functions like (() => ({ title: document.title }))(); use outputPath when the eval/get/snapshot data should be saved as a durable local file, but never reuse a screenshot, download, recording, or other browser artifact destination as outputPath. If a function-shaped snippet returns {}, details.evalStdinHint may warn that the function was serialized instead of called. Local file pages and caller-selected output paths are supported when upstream allows them. If get text on a broad CSS selector surfaces details.selectorTextVisibility or selectorTextVisibilityAll, prefer a visible @ref, a more specific selector, or the inspect-visible-text-candidates nextAction over hidden tab content.",
|
|
56
|
-
"When details.pageChangeSummary is present, use changeType and summary as a compact signal for navigation, DOM mutation, confirmations, or artifacts; when nextActionIds is set, match those ids to entries in details.nextActions (or per-step nextActions inside batch) for concrete follow-up payloads instead of inferring from prose alone. If details.clickDispatch reports a click-dispatch miss, refresh/inspect/retry the real click first; for static local fixtures only, an explicit eval --stdin programmatic .click() can exercise app handlers, but treat it as an untrusted scripted workaround and never use it to bypass stop-before-submit/order/purchase boundaries. If a no-navigation click surfaces details.overlayBlockers, inspect the fresh snapshot evidence before using a close/dismiss candidate nextAction; ordinary page chrome without dialog/alertdialog evidence should not trigger this diagnostic.",
|
|
56
|
+
"When details.pageChangeSummary is present, use changeType and summary as a compact signal for navigation, DOM mutation, confirmations, or artifacts; when nextActionIds is set, match those ids to entries in details.nextActions (or per-step nextActions inside batch) for concrete follow-up payloads instead of inferring from prose alone. If details.clickDispatch reports a click-dispatch miss, refresh/inspect/retry the real click first; for static local fixtures only, an explicit eval --stdin programmatic .click() can exercise app handlers, but treat it as an untrusted scripted workaround and never use it to bypass stop-before-submit/order/purchase boundaries. If an upstream click failure says the target is covered by another element at the target's click point, use the inspect-overlay-state nextAction to refresh refs and inspect the blocker before deciding whether to retry; do not blindly repeat the blocked click. If a no-navigation click surfaces details.overlayBlockers, inspect the fresh snapshot evidence before using a close/dismiss candidate nextAction; ordinary page chrome without dialog/alertdialog evidence should not trigger this diagnostic.",
|
|
57
57
|
"When commands save or spill files (screenshots, downloads, PDFs, traces, recordings, HAR, large snapshot spills), use the user's exact requested paths when given and treat paths as provisional until details.artifactVerification shows every row verified: branch on missingCount, pendingCount, unverifiedCount, per-entry state, and optional limitation before downstream file use or PASS/FAIL reporting.",
|
|
58
58
|
"For evidence-only screenshots, QA captures, or other audit artifacts, save to an explicit path and branch on details.artifactVerification plus details.artifacts before reporting PASS/FAIL; do not require vision review of inline image attachments unless the user asked for visual inspection.",
|
|
59
59
|
"Respect explicit user stop boundaries yourself. When the surrounding authenticated employee or automation context is explicitly unattended/auto-approved, ordinary non-destructive form submissions within the requested flow may proceed without separate confirmation. Still require explicit authorization for purchases, production-control actions, destructive or irreversible actions, and account, security, or privacy changes. The wrapper does not infer broad business intent from prompt text; details.promptGuard is reserved for concrete artifact-before-close checks.",
|
|
@@ -3,9 +3,9 @@ import { spawn } from "node:child_process";
|
|
|
3
3
|
import { lstat, mkdir, readdir, readlink, stat } from "node:fs/promises";
|
|
4
4
|
import { dirname, isAbsolute, join } from "node:path";
|
|
5
5
|
import { env as processEnv, platform as processPlatform } from "node:process";
|
|
6
|
+
import { spawn as crossSpawn } from "cross-spawn";
|
|
6
7
|
import { parseArgvDescriptor } from "./argv-descriptor.js";
|
|
7
|
-
import {
|
|
8
|
-
import { extractExplicitSessionName, getFlagName, GLOBAL_BOOLEAN_FLAGS_WITH_OPTIONAL_VALUES, GLOBAL_VALUE_FLAGS, optionalGlobalValueFlagConsumesNext, resolveAgentBrowserNamespace, } from "./argv-grammar.js";
|
|
8
|
+
import { extractExplicitSessionName, resolveAgentBrowserNamespace } from "./argv-grammar.js";
|
|
9
9
|
import { commitManagedSessionRestoreSuppression, getManagedSessionRestoreEnv, getManagedSessionRestoreProtectedEnv, getOwnedManagedSessionCompatibilityEnv, getOwnedManagedSessionNamespaceEnv, isOwnedManagedSessionTarget, validateManagedSessionRestoreContextForSpawn, } from "./managed-session-restore.js";
|
|
10
10
|
import { getPageTargetValidationError, } from "./page-target-validation.js";
|
|
11
11
|
import { getImplicitSessionIdleTimeoutMs } from "./runtime.js";
|
|
@@ -26,114 +26,19 @@ export const SAFE_AGENT_BROWSER_OPERATION_TIMEOUT_MS = 25_000;
|
|
|
26
26
|
const DEFAULT_AGENT_BROWSER_PROCESS_TIMEOUT_MS = 35_000;
|
|
27
27
|
/** Grace period after `exit` before resolving when `close` is delayed by inherited stdio handles. */
|
|
28
28
|
const EXIT_STDIO_GRACE_MS = 100;
|
|
29
|
-
const WINDOWS_AGENT_BROWSER_MISSING_MARKER = "PI_AGENT_BROWSER_COMMAND_NOT_FOUND:agent-browser.cmd";
|
|
30
29
|
const attachedBrowserSessionContext = new AsyncLocalStorage();
|
|
31
|
-
const WINDOWS_COMMANDS_WITH_ADJACENT_SUBCOMMAND = new Set([
|
|
32
|
-
"auth", "clipboard", "cookies", "dashboard", "device", "dialog", "diff", "find", "get", "is", "keyboard",
|
|
33
|
-
"mouse", "network", "plugin", "profiler", "react", "record", "session", "set", "skills", "state", "storage",
|
|
34
|
-
"stream", "tab", "trace", "webmcp", "window",
|
|
35
|
-
]);
|
|
36
30
|
export function withAttachedBrowserSessionContext(preserve, run) {
|
|
37
31
|
return attachedBrowserSessionContext.run(preserve || attachedBrowserSessionContext.getStore() === true, run);
|
|
38
32
|
}
|
|
39
|
-
export function getWindowsExplicitDefaultNamespaceEnv(args, parentNamespace, platform = processPlatform) {
|
|
40
|
-
return platform === "win32" && resolveAgentBrowserNamespace(args, parentNamespace) === ""
|
|
41
|
-
? { AGENT_BROWSER_NAMESPACE: "" }
|
|
42
|
-
: {};
|
|
43
|
-
}
|
|
44
33
|
function appendTail(text, addition, maxChars) {
|
|
45
34
|
const combined = text + addition;
|
|
46
35
|
return combined.length <= maxChars ? combined : combined.slice(combined.length - maxChars);
|
|
47
36
|
}
|
|
48
|
-
function quoteWindowsPowerShellArg(value) {
|
|
49
|
-
return `'${value.replace(/'/g, "''")}'`;
|
|
50
|
-
}
|
|
51
|
-
/** Exported for unit tests that lock Windows launcher argv ordering. */
|
|
52
|
-
export function reorderWindowsLeadingGlobalArgs(args) {
|
|
53
|
-
const leadingGlobals = [];
|
|
54
|
-
for (let index = 0; index < args.length; index += 1) {
|
|
55
|
-
const token = args[index];
|
|
56
|
-
if (isKnownCommandToken(token)) {
|
|
57
|
-
if (index === 0)
|
|
58
|
-
return args;
|
|
59
|
-
const firstPositional = args[index + 1];
|
|
60
|
-
return WINDOWS_COMMANDS_WITH_ADJACENT_SUBCOMMAND.has(token) && firstPositional && !firstPositional.startsWith("-")
|
|
61
|
-
? [token, firstPositional, ...leadingGlobals, ...args.slice(index + 2)]
|
|
62
|
-
: [token, ...leadingGlobals, ...args.slice(index + 1)];
|
|
63
|
-
}
|
|
64
|
-
if (!token.startsWith("-"))
|
|
65
|
-
return args;
|
|
66
|
-
if (token.startsWith("--restore=")) {
|
|
67
|
-
leadingGlobals.push(token);
|
|
68
|
-
continue;
|
|
69
|
-
}
|
|
70
|
-
if (token === "--restore") {
|
|
71
|
-
const value = args[index + 1];
|
|
72
|
-
if (optionalGlobalValueFlagConsumesNext(token, value)) {
|
|
73
|
-
leadingGlobals.push(`--restore=${value}`);
|
|
74
|
-
index += 1;
|
|
75
|
-
}
|
|
76
|
-
else {
|
|
77
|
-
leadingGlobals.push(token);
|
|
78
|
-
}
|
|
79
|
-
continue;
|
|
80
|
-
}
|
|
81
|
-
if (token.includes("="))
|
|
82
|
-
return args;
|
|
83
|
-
const flag = getFlagName(token);
|
|
84
|
-
if (GLOBAL_BOOLEAN_FLAGS_WITH_OPTIONAL_VALUES.has(flag)) {
|
|
85
|
-
leadingGlobals.push(token);
|
|
86
|
-
if (["true", "false"].includes(args[index + 1] ?? "")) {
|
|
87
|
-
leadingGlobals.push(args[index + 1]);
|
|
88
|
-
index += 1;
|
|
89
|
-
}
|
|
90
|
-
continue;
|
|
91
|
-
}
|
|
92
|
-
if (GLOBAL_VALUE_FLAGS.includes(flag)) {
|
|
93
|
-
const value = args[index + 1];
|
|
94
|
-
if (value === undefined)
|
|
95
|
-
return args;
|
|
96
|
-
// PowerShell -> .cmd drops empty argv values. Planning rejects empty
|
|
97
|
-
// caller --args; keep this defensive skip so an unexpected empty value
|
|
98
|
-
// cannot turn the next flag into its accidental value on native Windows.
|
|
99
|
-
if (value === "" && (flag === "--args" || flag === "--namespace")) {
|
|
100
|
-
index += 1;
|
|
101
|
-
continue;
|
|
102
|
-
}
|
|
103
|
-
leadingGlobals.push(token, value);
|
|
104
|
-
index += 1;
|
|
105
|
-
continue;
|
|
106
|
-
}
|
|
107
|
-
return args;
|
|
108
|
-
}
|
|
109
|
-
return args;
|
|
110
|
-
}
|
|
111
37
|
export function prepareAgentBrowserSpawnArgs(args, wrapperCompatibilityUserAgent, preserveAttachedBrowserSession = false) {
|
|
112
38
|
if (preserveAttachedBrowserSession || !wrapperCompatibilityUserAgent)
|
|
113
39
|
return args;
|
|
114
40
|
return ["--args", `--user-agent=${wrapperCompatibilityUserAgent.replaceAll(/[\r\n,]/g, "")}`, ...args];
|
|
115
41
|
}
|
|
116
|
-
export function buildAgentBrowserSpawnCommand(args, platform = processPlatform) {
|
|
117
|
-
if (platform !== "win32") {
|
|
118
|
-
return { command: "agent-browser", args };
|
|
119
|
-
}
|
|
120
|
-
const invocationArgs = reorderWindowsLeadingGlobalArgs(args).map(quoteWindowsPowerShellArg).join(" ");
|
|
121
|
-
const commandLine = [
|
|
122
|
-
"$agentBrowser = Get-Command agent-browser.cmd -ErrorAction SilentlyContinue;",
|
|
123
|
-
`if (-not $agentBrowser) { [Console]::Error.WriteLine('${WINDOWS_AGENT_BROWSER_MISSING_MARKER}'); exit 127 };`,
|
|
124
|
-
`& $agentBrowser.Source ${invocationArgs}`.trimEnd(),
|
|
125
|
-
].join(" ");
|
|
126
|
-
return { command: "powershell.exe", args: ["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", commandLine] };
|
|
127
|
-
}
|
|
128
|
-
export function isWindowsAgentBrowserCommandMissing(stderr) {
|
|
129
|
-
const normalized = stderr.toLowerCase();
|
|
130
|
-
return normalized.includes(WINDOWS_AGENT_BROWSER_MISSING_MARKER.toLowerCase()) || (normalized.includes("agent-browser.cmd") && (normalized.includes("commandnotfoundexception") ||
|
|
131
|
-
normalized.includes("not recognized as the name of a cmdlet") ||
|
|
132
|
-
normalized.includes("not recognized as an internal or external command")));
|
|
133
|
-
}
|
|
134
|
-
export function shouldCommitManagedRestoreAfterWindowsProcess(input) {
|
|
135
|
-
return !input.spawnError && !(input.exitCode !== 0 && isWindowsAgentBrowserCommandMissing(input.stderr));
|
|
136
|
-
}
|
|
137
42
|
function terminateSpawnedChild(child, signal) {
|
|
138
43
|
if (processPlatform === "win32" && child.pid) {
|
|
139
44
|
const killer = spawn("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" });
|
|
@@ -431,7 +336,6 @@ export async function runAgentBrowserProcess(options) {
|
|
|
431
336
|
...env,
|
|
432
337
|
...getManagedSessionRestoreProtectedEnv(managedSessionRestoreOptions, managedSessionRestoreEnv),
|
|
433
338
|
...getOwnedManagedSessionNamespaceEnv(managedSessionRestoreOptions),
|
|
434
|
-
...getWindowsExplicitDefaultNamespaceEnv(args, parentEnv.AGENT_BROWSER_NAMESPACE),
|
|
435
339
|
...ownedManagedSessionCompatibilityEnv,
|
|
436
340
|
};
|
|
437
341
|
const explicitSocketDir = processOverrides[AGENT_BROWSER_SOCKET_DIR_ENV];
|
|
@@ -536,13 +440,8 @@ export async function runAgentBrowserProcess(options) {
|
|
|
536
440
|
if (stdoutSpillHandle) {
|
|
537
441
|
await stdoutSpillHandle.close().catch(() => undefined);
|
|
538
442
|
}
|
|
539
|
-
|
|
540
|
-
if (processPlatform === "win32" && !windowsMissingBinary && !spawnError)
|
|
443
|
+
if (processPlatform === "win32" && !spawnError) {
|
|
541
444
|
agentBrowserStarted = true;
|
|
542
|
-
if (windowsMissingBinary && !spawnError) {
|
|
543
|
-
spawnError = Object.assign(new Error("spawn agent-browser ENOENT"), { code: "ENOENT" });
|
|
544
|
-
}
|
|
545
|
-
else if (processPlatform === "win32" && shouldCommitManagedRestoreAfterWindowsProcess({ exitCode, spawnError, stderr })) {
|
|
546
445
|
commitManagedSessionRestoreSuppression(managedSessionRestoreOptions);
|
|
547
446
|
}
|
|
548
447
|
if (!spawnError && stdoutSpillError) {
|
|
@@ -569,8 +468,8 @@ export async function runAgentBrowserProcess(options) {
|
|
|
569
468
|
resolve({ aborted: false, agentBrowserStarted: false, exitCode: 1, spawnError: new Error(spawnPolicyError), stderr: "", stdout: "", timedOut: false });
|
|
570
469
|
return;
|
|
571
470
|
}
|
|
572
|
-
const
|
|
573
|
-
const child =
|
|
471
|
+
const spawnBrowser = processPlatform === "win32" ? crossSpawn : spawn;
|
|
472
|
+
const child = spawnBrowser("agent-browser", prepareAgentBrowserSpawnArgs(args, ownedManagedSessionCompatibilityEnv.AGENT_BROWSER_USER_AGENT, preserveAttachedBrowserSession), {
|
|
574
473
|
cwd,
|
|
575
474
|
env: childEnv,
|
|
576
475
|
stdio: ["pipe", "pipe", "pipe"],
|