pi-agent-browser-native 0.6.8 → 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 +12 -0
- package/README.md +3 -1
- 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/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/session-state.js +2 -1
- package/dist/extensions/agent-browser/lib/orchestration/electron-host/index.js +4 -1
- package/dist/extensions/agent-browser/lib/temp.js +14 -0
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/COMMAND_REFERENCE.md +4 -2
- package/docs/ELECTRON.md +8 -4
- package/docs/RELEASE.md +2 -0
- package/docs/SUPPORT_MATRIX.md +1 -1
- package/docs/TOOL_CONTRACT.md +4 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
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
|
+
|
|
3
15
|
## 0.6.8 - 2026-09-07
|
|
4
16
|
|
|
5
17
|
### 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
|
|
@@ -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 }),
|
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
|
});
|
|
@@ -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,
|
|
@@ -319,6 +319,20 @@ async function assertSecureTempRootBudget(tempRoot, additionalBytes) {
|
|
|
319
319
|
throw new Error(`pi-agent-browser temp spill budget exceeded (${nextBytes} bytes > ${maxBytes} byte limit).`);
|
|
320
320
|
}
|
|
321
321
|
}
|
|
322
|
+
export async function preserveSecureTempDirectory(path) {
|
|
323
|
+
await enqueueTempMutation(async () => {
|
|
324
|
+
const childPath = resolve(path);
|
|
325
|
+
const tempRoot = dirname(childPath);
|
|
326
|
+
if (!ownedTempRoots.has(tempRoot) || !getProtectedTempChildName(tempRoot, childPath) || !(await stat(childPath)).isDirectory()) {
|
|
327
|
+
throw new Error(`Cannot preserve ${path}; expected an existing child directory of a currently owned temp root.`);
|
|
328
|
+
}
|
|
329
|
+
protectedTempChildren.add(childPath);
|
|
330
|
+
await persistProtectedTempChildren(tempRoot, new Set([childPath]));
|
|
331
|
+
if (!getPersistedProtectedChildPaths(tempRoot, await readTempRootOwnershipMarker(tempRoot)).has(childPath)) {
|
|
332
|
+
throw new Error(`Could not persist temp directory preservation for ${path}.`);
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
}
|
|
322
336
|
export async function cleanupSecureTempArtifacts(options = {}) {
|
|
323
337
|
await enqueueTempMutation(async () => {
|
|
324
338
|
const tempRoot = await sessionTempRootPromise?.catch(() => undefined);
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -169,7 +169,7 @@ Practical policy:
|
|
|
169
169
|
- keep process-owned cleanup registries for extension-managed sessions and wrapper-launched Electron records separate from the current branch-visible view; `session_tree` restore and wrapper-owned browser commands are serialized with managed-session work, while caller-owned explicit-session commands are serialized by process-local queues keyed to effective canonical namespace/session across prepare helpers (explicit namespace argv overrides inherited `AGENT_BROWSER_NAMESPACE`, including an explicit empty default) and main execution. macOS and Windows additionally normalize and case-fold namespace and session components to match case-insensitive daemon identity. Different caller-owned identities remain concurrent, except namespace-scoped `close --all` drains and exclusively barriers managed plus matching caller-owned work before clearing global namespace state; nested helpers never re-enter the outer queue, policy/route/artifact deltas merge across unrelated managed-state commits, and a separate branch-restore generation guard prevents stale completions from overwriting newer branch-visible state; aggregate artifact results use monotonic revisions so transcript replay cannot lose a concurrently completed entry. Branch switches still must not drop resources the current Pi process owns and must keep fresh-session allocation monotonic
|
|
170
170
|
- record successful `connect`, `--cdp`, enabled `--auto-connect`, environment-configured CDP/auto-connect, and wrapper Electron attachment identities in branch-visible state. First-use and later content-bearing calls live-check `get url` because attached targets can drift outside Pi. Caller config, file access, launch arguments, and environment pass through unchanged; only wrapper-injected compatibility launch arguments are omitted on active attachments. A terminal successful close removes the marker; a close followed by a later step whose lifecycle reports a browser launch preserves it, while a non-launching diagnostic such as `stream status` leaves the close terminal
|
|
171
171
|
- when a successful close targets the current extension-managed session, including an explicit `--session <current> close` or an `electron.cleanup` managed-session step, clear page/ref state, mark that session inactive, untrack cleanup ownership, and rotate the next default auto call to a fresh wrapper-generated session name rather than reusing the closed name
|
|
172
|
-
- on non-quit shutdown such as `/reload`, close off-branch owned managed sessions and off-branch owned Electron launches before clearing process-local ownership, but preserve the current branch-visible active managed session and Electron launch plus that launch's isolated `userDataDir` so reload continuity still works from the active transcript branch
|
|
172
|
+
- on non-quit shutdown such as `/reload`, close off-branch owned managed sessions and off-branch owned Electron launches before clearing process-local ownership, but preserve the current branch-visible active managed session and Electron launch plus that launch's isolated `userDataDir` so reload continuity still works from the active transcript branch. First reuse of a restored Electron attachment checks the recorded namespace/session, live PID/profile presence, and exact browser WebSocket identity against current CDP metadata, then uses upstream `get cdp-url` to verify that the named daemon still targets that browser or one of its current pages. The metadata probe cannot commit daemon provenance merely by spawning; only a matching result permits the existing policy to record it. Ordinary calls, status, and probe share this path under the existing lock. Generic restore-disabled sessions, mismatched connections, quit, and off-branch cleanup are unchanged
|
|
173
173
|
- expose still-owned off-branch Electron launch records to `electron.status { launchId }`, `electron.status { all: true }`, `electron.probe { launchId }`, and `electron.cleanup`, while leaving default `electron.probe` scoped to the current managed session
|
|
174
174
|
- if an unnamed fresh launch replaces an active extension-managed session, best-effort close the old managed session after the switch succeeds; `managedSessionOutcome.replacedSessionClosed` records whether that cleanup succeeded, and a failed close keeps the older identity wrapper-owned across transcript resume for explicit follow-up or cleanup
|
|
175
175
|
- expose `details.browserWindow` and one visible login handoff only when a successful first/fresh local wrapper-managed headed result, including `batch`, is not an attachment and has upstream `lifecycle.effectiveLaunch.browserLaunched: true` and a `created`/`replaced` managed-session outcome. Keep `visibility: "unverified"`: this is launch evidence, never a claim about the user's OS desktop
|
|
@@ -14,6 +14,8 @@ Provide a local, repo-readable command reference for the native `agent_browser`
|
|
|
14
14
|
|
|
15
15
|
This project intentionally blocks normal `agent-browser` bash usage in most agent sessions, so the agent still needs an accessible local equivalent of the upstream command surface. This document is the durable reference the agent can read inside the repository without calling the binary directly.
|
|
16
16
|
|
|
17
|
+
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.
|
|
18
|
+
|
|
17
19
|
## Upstream baseline
|
|
18
20
|
|
|
19
21
|
<!-- agent-browser-capability-baseline:start upstream-baseline -->
|
|
@@ -461,13 +463,13 @@ Typical lifecycle:
|
|
|
461
463
|
{ "electron": { "action": "cleanup", "launchId": "electron-…" } }
|
|
462
464
|
```
|
|
463
465
|
|
|
464
|
-
`electron.status` and `electron.cleanup` take either `launchId`, **`all: true`** (literal boolean) to walk every active wrapper-tracked launch (including dead, failed, or partial records, but excluding cleaned records), or neither when exactly one active launch exists—never both `launchId` and `all`. They can target the current branch-visible launch plus still-owned off-branch launch records by `launchId`; default no-arg calls are intentionally ambiguous when more than one active launch is owned. `/reload` preserves the current branch-visible active Electron launch and its isolated temp `userDataDir` for continuity, and cleans off-branch owned Electron launches; 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. `electron.list` has no configurable timeout and rejects both top-level and nested `timeoutMs`. For `electron.launch`, nested `timeoutMs` sets host CDP readiness polling to a **15s** default and **120s** cap after target discovery; upstream attach and handoff use separate subprocess budgets. Optional `timeoutMs` on **`status`** applies to managed-session `get url
|
|
466
|
+
`electron.status` and `electron.cleanup` take either `launchId`, **`all: true`** (literal boolean) to walk every active wrapper-tracked launch (including dead, failed, or partial records, but excluding cleaned records), or neither when exactly one active launch exists—never both `launchId` and `all`. They can target the current branch-visible launch plus still-owned off-branch launch records by `launchId`; default no-arg calls are intentionally ambiguous when more than one active launch is owned. `/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 with `get cdp-url`, 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. `electron.list` has no configurable timeout and rejects both top-level and nested `timeoutMs`. For `electron.launch`, nested `timeoutMs` sets host CDP readiness polling to a **15s** default and **120s** cap after target discovery; upstream attach and handoff use separate subprocess budgets. Optional `timeoutMs` on **`status`** applies to each managed-session `get url` / `get title` read and any `get cdp-url` read needed to verify a restored connection (localhost CDP probes stay on a short fixed fetch budget). On **`cleanup`**, it is applied separately to upstream `close` and the initial host process-exit wait, not to the entire teardown; debug-port checks have fixed fetch budgets and profile removal has no configurable deadline; when omitted it follows the implicit session close default (**5s** unless `PI_AGENT_BROWSER_IMPLICIT_SESSION_CLOSE_TIMEOUT_MS` overrides). A successful managed-session close step retires that wrapper-managed session even when host process/profile cleanup remains partial. On **`probe`**, it bounds each underlying upstream read subprocess—omit it to use the normal tool subprocess default, or raise it on slow desktops.
|
|
465
467
|
|
|
466
468
|
Explicit-ID `electron.status` labels a cleaned record as historical while measuring PID/port liveness independently. `details.electron.statuses[].userDataDirState` freshly reports the tracked profile path as `present`, `absent` (only ENOENT), or `unknown` (other native `lstat` errors); dangling symlinks are present. This is not an audit of all app residue and does not change stored launch records or cleanup ownership.
|
|
467
469
|
|
|
468
470
|
`launch.handoff` defaults to `"snapshot"`, which attaches through upstream `connect`, lists targets, and captures a current `snapshot -i` in one call. Snapshot handoff retries briefly when the first Electron snapshot has no refs; if it still reports no refs, run `snapshot -i` once more before assuming the app is blank. Use `handoff: "tabs"` as the safer diagnostic starting point when you only need target discovery and do not want to snapshot app content yet, or `handoff: "connect"` when you want to attach first and run your own follow-up commands. `targetType` defaults to `"page"`; use `"webview"` or `"any"` for apps that expose useful webviews. When a matching CDP target exposes a WebSocket URL, launch connects to that target; otherwise it falls back to the browser port.
|
|
469
471
|
|
|
470
|
-
After launch, prefer the exact `details.nextActions` payloads when present: `status-electron-launch` checks liveness, `probe-electron-launch` runs compact diagnostics for a tracked launch, `snapshot-electron-session` refreshes current refs, `list-electron-tabs` inspects targets, and `cleanup-electron-launch` removes the wrapper-owned process/profile when the run is done. If
|
|
472
|
+
After launch, prefer the exact `details.nextActions` payloads when present: `status-electron-launch` checks liveness, `probe-electron-launch` runs compact diagnostics for a tracked launch, `snapshot-electron-session` refreshes current refs, `list-electron-tabs` inspects targets, and `cleanup-electron-launch` removes the wrapper-owned process/profile when the run is done. If startup fails, inspect the redacted stdout/stderr tails in visible error text and `details.electron.failure.diagnostics`, plus PID, wrapper profile, `DevToolsActivePort`, and timing evidence before retrying. Tails read at most 4096 source bytes per stream; private log files follow profile cleanup/preservation and are not lifetime-size-capped. If status/probe detects a session or target mismatch, follow `reattach-electron-launch` or a fresh snapshot action before using old refs. If a click/fill/type looks successful but the Electron PID or debug port dies, the wrapper now fails the result with `details.electronPostCommandHealth` and same-launch status/probe/cleanup next actions instead of leaving the agent on `about:blank`. If cleanup is partial (`failureCategory: "cleanup-failed"`), inspect `details.electron.cleanup.results` and use `retry-electron-cleanup` only for the same `launchId`.
|
|
471
473
|
|
|
472
474
|
Manual path for externally launched apps: if you started the Electron app yourself with a debug port or DevTools URL, skip the wrapper lifecycle and attach directly with upstream `connect`. In this path you own app shutdown and profile cleanup; do not use `electron.cleanup`. close commands (`close`, `quit`, or `exit`) only close the browser/CDP session and do not quit the manually launched app or remove explicit artifacts.
|
|
473
475
|
|
package/docs/ELECTRON.md
CHANGED
|
@@ -200,7 +200,7 @@ Closes the tracked managed session, stops only the wrapper-tracked process, veri
|
|
|
200
200
|
|
|
201
201
|
For manual launches, close commands (`close`, `quit`, or `exit`) only close the browser/CDP session. Close the app yourself and clean its profile/temp files with normal host tools.
|
|
202
202
|
|
|
203
|
-
On Pi `quit`, active wrapper-owned Electron launches are best-effort cleaned. On `/reload`, the current branch-visible active Electron launch and its isolated temp `userDataDir` are preserved for continuity while off-branch owned Electron launches are cleaned before process-local ownership is cleared. If cleanup is partial and skips or fails `user-data-dir` removal because the process or debug port is still live, the generic temp sweep preserves that profile path across reload, quit, repeated temp cleanup, process-exit cleanup, and stale temp-root pruning after restart rather than deleting it out from under the remaining host resource. If `electron.cleanup` closes the attached managed session but host process/profile cleanup is partial, later default browser calls still rotate away from that closed wrapper-managed session. Stale restored records (PID gone, port dead) are **reported** instead of guessed at or killed.
|
|
203
|
+
On Pi `quit`, active wrapper-owned Electron launches are best-effort cleaned. On `/reload`, the current branch-visible active Electron launch and its isolated temp `userDataDir` are preserved for continuity while off-branch owned Electron launches are cleaned before process-local ownership is cleared. First reuse after reload/resume checks that the PID and profile are present, the saved browser WebSocket endpoint still matches the live app, and upstream `get cdp-url` points to that browser or one of its current targets. Ordinary browser calls, status reads, and probes share this check under the existing session lock; no reconnect or ref reset is needed. The `get cdp-url` read honors the caller's `timeoutMs` and cancellation, while localhost CDP requests keep their short fixed fetch budgets. Missing or mismatched evidence does not grant reuse, and generic restore-disabled sessions keep their existing rules. If cleanup is partial and skips or fails `user-data-dir` removal because the process or debug port is still live, the generic temp sweep preserves that profile path across reload, quit, repeated temp cleanup, process-exit cleanup, and stale temp-root pruning after restart rather than deleting it out from under the remaining host resource. If `electron.cleanup` closes the attached managed session but host process/profile cleanup is partial, later default browser calls still rotate away from that closed wrapper-managed session. Stale restored records (PID gone, port dead) are **reported** instead of guessed at or killed.
|
|
204
204
|
|
|
205
205
|
### `timeoutMs` by action (quick reference)
|
|
206
206
|
|
|
@@ -209,9 +209,9 @@ On Pi `quit`, active wrapper-owned Electron launches are best-effort cleaned. On
|
|
|
209
209
|
| Action | What `timeoutMs` covers when set | Typical default when omitted |
|
|
210
210
|
| --- | --- | --- |
|
|
211
211
|
| `launch` | Host-side wait for `DevToolsActivePort` and CDP readiness | **15 s**, hard-capped at **120 s** (`normalizeTimeoutMs` in `extensions/agent-browser/lib/electron/launch.ts`) |
|
|
212
|
-
| `status` | Each optional managed-session `get url` / `get title` subprocess
|
|
212
|
+
| `status` | Each optional managed-session `get url` / `get title` subprocess, including `get cdp-url` when verifying a restored connection | Normal wrapper subprocess budget (**35 s**, or `PI_AGENT_BROWSER_PROCESS_TIMEOUT_MS`); localhost CDP probes use **1000 ms** each (`ELECTRON_CDP_FETCH_TIMEOUT_MS` in `extensions/agent-browser/lib/electron/cdp.ts`) |
|
|
213
213
|
| `cleanup` | Applied separately to managed-session `close` and the initial tracked-process exit wait; not a deadline for debug-port checks or profile removal | `PI_AGENT_BROWSER_IMPLICIT_SESSION_CLOSE_TIMEOUT_MS` when set, else **5000 ms** (`getImplicitSessionCloseTimeoutMs` in `extensions/agent-browser/lib/runtime.ts`, passed through `cleanupTrackedElectronHostLaunches` in `extensions/agent-browser/lib/orchestration/electron-host/index.ts`) |
|
|
214
|
-
| `probe` | **Each** upstream read
|
|
214
|
+
| `probe` | **Each** upstream read: optional `get cdp-url` verification, then `get url`, `get title`, focused `eval --stdin`, `tab list`, and `snapshot -i` | Same wrapper subprocess default (**35 s**, or `PI_AGENT_BROWSER_PROCESS_TIMEOUT_MS`, from `getAgentBrowserProcessTimeoutMs` in `extensions/agent-browser/lib/process.ts`) |
|
|
215
215
|
|
|
216
216
|
## `qa.attached` — current-session smoke check
|
|
217
217
|
|
|
@@ -311,6 +311,10 @@ Policy mismatches fail with `failureCategory: "policy-blocked"` and `details.ele
|
|
|
311
311
|
| `cleanup-failed` | Cleanup only partially succeeded | Inspect `details.electron.cleanup.results[].steps` for remaining process/port/profile state; `retry-electron-cleanup` references the same `launchId` |
|
|
312
312
|
| `stale-ref` | `@e…` ref reused after a navigation/rerender | Take a fresh `snapshot -i` (or follow `refresh-electron-refs-after-rerender` when the wrapper appends it) |
|
|
313
313
|
|
|
314
|
+
Failed startup diagnostics include `outputCaptured`, `stdoutTail` / `stderrTail`, and `stdoutTruncated` / `stderrTruncated`. Each tail reads at most the last **4096 source bytes** before UTF-8 decoding and normal credential redaction, and appears in both visible failure text and structured details. Empty output is reported explicitly; `stdoutError` / `stderrError` report capture-read or close errors without replacing the original startup reason, exit status, or cleanup warning.
|
|
315
|
+
|
|
316
|
+
The app writes to mode-0600 `stdout.log` and `stderr.log` inside its isolated profile. These are regular files, not pipes to Pi, so retained apps can keep writing after reload or host exit. Logs follow profile preservation and removal; **the read limit is not a lifetime disk limit**. If failed-startup process cleanup cannot finish, the profile and logs are protected from general temp cleanup. Any failure to persist that protection appears alongside the original `failure.cleanupError`; in-memory protection remains. Use the reported PID and profile path to resolve that failed cleanup before removing files.
|
|
317
|
+
|
|
314
318
|
Single-instance Electron behavior is a common cause of `timeout` and `upstream-error`. Many Electron apps enforce a single running instance and silently drop a second invocation's `--remote-debugging-port` flag. If the app is already running without a debug port, quit it first or use the manual host-launch path against the existing instance instead.
|
|
315
319
|
|
|
316
320
|
## Troubleshooting
|
|
@@ -319,7 +323,7 @@ Single-instance Electron behavior is a common cause of `timeout` and `upstream-e
|
|
|
319
323
|
- The app is enforcing single-instance; quit the running copy first, then retry.
|
|
320
324
|
- The app may have moved its Electron framework directory; pass `executablePath` explicitly.
|
|
321
325
|
- `timeoutMs` is too short for a heavy app; raise it (`launch.timeoutMs` is bounded but generous).
|
|
322
|
-
- Read `details.electron.failure.diagnostics
|
|
326
|
+
- Read the redacted stdout/stderr tails in the failure text or `details.electron.failure.diagnostics` first; dependency and startup errors often explain the failure. `DevToolsActivePort`, port number, PID liveness, and timing provide the remaining context.
|
|
323
327
|
|
|
324
328
|
### `electron.list` returns nothing
|
|
325
329
|
- On Linux, the binary may be a custom rebrand without `chrome_*.pak` siblings, an AppImage without a `.desktop` entry, or a statically linked fork. Pass `executablePath` directly.
|
package/docs/RELEASE.md
CHANGED
|
@@ -316,6 +316,8 @@ Recommended configured-source lifecycle follow-up:
|
|
|
316
316
|
|
|
317
317
|
## Post-publish install validation
|
|
318
318
|
|
|
319
|
+
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.
|
|
320
|
+
|
|
319
321
|
After publishing a release, validate the package-first path in isolation. `npm run verify -- release` includes the deterministic fake-binary packaged execution gate and the pre-publish Crabbox platform matrix, but it does not replace a real-browser installed-package smoke against the published npm package:
|
|
320
322
|
|
|
321
323
|
```bash
|
package/docs/SUPPORT_MATRIX.md
CHANGED
|
@@ -71,7 +71,7 @@ Current summary:
|
|
|
71
71
|
|
|
72
72
|
Contributor fixes #133/#152 remove unused prompt suffix entries without changing runtime guidance and diagnose bare `--no-sandbox` only in the command slot or navigation option positions. Native `--args` values and literal operands remain intact; batch checks use raw effective rows without treating row-local `--args` as a launch setting. `test/agent-browser.chromium-args.test.ts` covers pre-dispatch rejection, literal/flag-value controls, inspection, raw/stdin batch precedence and dispatcher outcomes. Existing prompt/grammar checks remain; source checks do not replace native-product gates.
|
|
73
73
|
|
|
74
|
-
Electron diagnostics (RQ-0096, #128) keep list timeout rejection truthful, label explicit-ID cleaned records as historical without changing active selection or actions, and measure the tracked profile path with native `lstat` (`present` / ENOENT-only `absent` / `unknown`). Existing Electron discovery/lifecycle tests cover current liveness independently of cleanup history, native path errors and dangling symlinks, transcript replay, and unchanged cleanup ownership. Failed-
|
|
74
|
+
Electron diagnostics (RQ-0096, #128) keep list timeout rejection truthful, label explicit-ID cleaned records as historical without changing active selection or actions, and measure the tracked profile path with native `lstat` (`present` / ENOENT-only `absent` / `unknown`). Existing Electron discovery/lifecycle tests cover current liveness independently of cleanup history, native path errors and dangling symlinks, transcript replay, and unchanged cleanup ownership. Failed-startup diagnostics include redacted last-4096-byte stdout/stderr tails in visible text and structured details, using private profile-local regular files. Regression tests cover exact tails, empty output, capture/spawn errors and closed native file handles; a real spawned fixture with injected kill denial proves profile/log preservation through temp cleanup and host exit, including persistence failure reporting. The shared daemon policy verifies a restored Electron attachment's live browser endpoint and named upstream `get cdp-url` before ordinary calls, status, or probe reuse; `test/agent-browser.extension-ref-guards.test.ts` covers successful reuse and repeated rejection of replaced app, connection, and namespace identities without weakening generic restore-disabled rules. These checks do not replace native-app, Pi lifecycle, package, or live-site gates.
|
|
75
75
|
|
|
76
76
|
Artifact diagnostics (#124/#127) use a shared pre-dispatch mkdir-error boundary, preserve raw batch argv/precedence, recommend absolute raw artifact paths, recognize image headers rather than filename MIME guesses, retain known requested/reported paths, and warn once for dispatched recording page transitions on success or failure. `test/agent-browser.artifact-diagnostics.test.ts` covers registered filesystem failures, real image bytes and misleading suffixes, the inline bound, native macOS path aliases, recording/ref continuity and unreached-row negatives; `test/agent-browser.presentation-artifacts-batch.test.ts` retains artifact/persistence coverage. These source regressions do not qualify daemon-cwd differences or affected-filesystem timestamp behavior (#118), or replace native/Pi/package/live-site gates.
|
|
77
77
|
|
package/docs/TOOL_CONTRACT.md
CHANGED
|
@@ -461,7 +461,7 @@ Validation and defaults:
|
|
|
461
461
|
- `allow` and `deny` are optional caller-owned policy lists. Entries match app name, bundle id, desktop id, app path, or executable path by substring. If `allow` is set, the target must match it; `deny` wins on conflict. With neither list, launch is permitted.
|
|
462
462
|
- `electron.status` / `electron.cleanup` accept optional `all` only as the boolean literal `true` to include every active wrapper-tracked launch (including dead, failed, or partial records, but excluding cleaned records); `all` and `launchId` cannot both be set. Status and cleanup use the same runtime wrapper-tracked scope: current branch-visible records plus still-owned off-branch records. Default no-argument status/cleanup is intentionally ambiguous when more than one active launch is in that merged scope; pass `launchId` or `all: true`.
|
|
463
463
|
- `electron.launch.timeoutMs` sets the host CDP readiness polling budget: **15000 ms** by default, capped at **120000 ms** (`normalizeTimeoutMs` in `extensions/agent-browser/lib/electron/launch.ts`). Its clock starts after target discovery and policy checks, before creating the isolated profile. Discovery has no configurable deadline; upstream attach and handoff use separate subprocess budgets.
|
|
464
|
-
- `status.timeoutMs` applies to each managed-session `get url` / `get title` subprocess for mismatch diagnostics. `probe.timeoutMs` applies to each upstream read (`get url`, `get title`, `eval --stdin`, `tab list`, `snapshot -i`). Their default wrapper budget is **35000 ms**, overridden by `PI_AGENT_BROWSER_PROCESS_TIMEOUT_MS` (`getAgentBrowserProcessTimeoutMs` in `extensions/agent-browser/lib/process.ts`). Localhost CDP HTTP probes use a fixed **1000 ms** each (`ELECTRON_CDP_FETCH_TIMEOUT_MS` in `extensions/agent-browser/lib/electron/cdp.ts`). Profile-path inspection has no configurable timeout.
|
|
464
|
+
- `status.timeoutMs` applies to each managed-session `get url` / `get title` subprocess for mismatch diagnostics, including `get cdp-url` when verifying a restored connection. `probe.timeoutMs` applies to each upstream read (optional `get cdp-url` verification, then `get url`, `get title`, `eval --stdin`, `tab list`, `snapshot -i`). Their default wrapper budget is **35000 ms**, overridden by `PI_AGENT_BROWSER_PROCESS_TIMEOUT_MS` (`getAgentBrowserProcessTimeoutMs` in `extensions/agent-browser/lib/process.ts`). Localhost CDP HTTP probes use a fixed **1000 ms** each (`ELECTRON_CDP_FETCH_TIMEOUT_MS` in `extensions/agent-browser/lib/electron/cdp.ts`). Profile-path inspection has no configurable timeout.
|
|
465
465
|
- `cleanup.timeoutMs` is applied separately to the managed-session `close` subprocess and the initial host process-exit wait, not one combined deadline. It defaults to `PI_AGENT_BROWSER_IMPLICIT_SESSION_CLOSE_TIMEOUT_MS` or **5000 ms** (`getImplicitSessionCloseTimeoutMs` in `extensions/agent-browser/lib/runtime.ts`). Restored-PID verification and the later force-kill wait each have separate **1000 ms** limits; debug-port checks use the fixed CDP fetch budget, and profile removal has no configurable deadline.
|
|
466
466
|
- Non-Electron targets are rejected as a correctness failure; the wrapper does not blindly launch arbitrary executables as Electron.
|
|
467
467
|
|
|
@@ -472,7 +472,7 @@ Safety defaults and ownership:
|
|
|
472
472
|
- Remote debugging exposes app contents to the attached browser tool. The wrapper gives isolation defaults and optional `allow` / `deny`; the user still owns the decision to launch or attach to a sensitive desktop app.
|
|
473
473
|
- `electron.list` may annotate apps as likely sensitive (`sensitivity.level: "likely-sensitive"`, categories such as `notes`, `chat`, `mail`, `developer-workspace`, or `passwords-auth`) and print `[likely sensitive: …]`. These annotations are non-blocking hints, not enforcement; caller-owned `allow` / `deny` policy still controls launch decisions.
|
|
474
474
|
- Cleanup is wrapper-owned **only** for records created by `electron.launch`. `electron.cleanup` never targets manually launched apps, externally supplied debug ports, or arbitrary Electron processes. Explicit screenshots/downloads/HARs/traces remain host-file cleanup, not Electron cleanup. If `electron.cleanup` closes the upstream managed session but process/profile cleanup remains partial, later shutdown cleanup does not close that managed session a second time; retry cleanup focuses on the remaining host resources.
|
|
475
|
-
- On Pi `quit`, active wrapper-owned Electron launches are best-effort cleaned. On `/reload`, current branch-visible active Electron launches are preserved for reload continuity, including their isolated `userDataDir` profile directories, while off-branch owned launches are cleaned before process-local ownership is cleared. If cleanup is partial and deliberately skips or fails `user-data-dir` removal because the process or debug port is still live, generic temp cleanup preserves that profile path across reload, quit, later temp sweeps, process-exit cleanup, and stale temp-root pruning after restart instead of deleting it underneath the remaining host resource. Stale restored records are reported instead of guessed/killed when the wrapper lacks a live child process.
|
|
475
|
+
- On Pi `quit`, active wrapper-owned Electron launches are best-effort cleaned. On `/reload`, current branch-visible active Electron launches are preserved for reload continuity, including their isolated `userDataDir` profile directories, while off-branch owned launches are cleaned before process-local ownership is cleared. First reuse after reload/resume verifies the recorded namespace/session, live PID/profile presence, and saved browser WebSocket endpoint; native `get cdp-url` must match that browser or one of its current targets. Ordinary browser calls, status reads, and probes share the existing locked check, without reconnecting or resetting refs. The `get cdp-url` read uses the caller's `timeoutMs` and cancellation; localhost CDP fetch budgets are unchanged. Failed checks do not grant reuse or relax the generic restore-disabled-session policy. If cleanup is partial and deliberately skips or fails `user-data-dir` removal because the process or debug port is still live, generic temp cleanup preserves that profile path across reload, quit, later temp sweeps, process-exit cleanup, and stale temp-root pruning after restart instead of deleting it underneath the remaining host resource. Stale restored records are reported instead of guessed/killed when the wrapper lacks a live child process.
|
|
476
476
|
|
|
477
477
|
Details fields:
|
|
478
478
|
|
|
@@ -519,7 +519,7 @@ Details fields:
|
|
|
519
519
|
Action-specific `details.electron` fields:
|
|
520
520
|
|
|
521
521
|
- `list`: `{ action: "list", status: "succeeded", apps, platform, query?, maxResults, skippedCount, omittedCount?, sensitiveAppCount?, profileIsolation }`. Each app is platform-tagged and may include `name`, `bundleId`, `desktopId`, `appPath`, `executablePath`, `icon`, `packageSource`, and non-blocking `sensitivity` metadata depending on platform/discovery source.
|
|
522
|
-
- `launch`: `{ action: "launch", status, launch, targets?, version?, handoff?, cleanup?, identifiers?, profileIsolation }`. `profileIsolation` states that wrapper launches use a new temporary profile, do not reuse existing signed-in app state, and do not attach to already-running authenticated apps; it also includes host debug-launch guidance for the separate normal-app attach path. `identifiers` repeats the launch-scoped `launchId` and attached `sessionName` so agents distinguish Electron lifecycle actions from browser session/tab actions. `launch.cleanupState` is one of `"active"`, `"cleaned"`, `"dead"`, `"failed"`, or `"partial"`. Failed launches expose `details.electron.failure.diagnostics` when available, including `pid` / `pidAlive`, wrapper `userDataDir`, elapsed/timeout timing, `DevToolsActivePort` file state, discovered port, and whether CDP `/json/version` was reached.
|
|
522
|
+
- `launch`: `{ action: "launch", status, launch, targets?, version?, handoff?, cleanup?, identifiers?, profileIsolation }`. `profileIsolation` states that wrapper launches use a new temporary profile, do not reuse existing signed-in app state, and do not attach to already-running authenticated apps; it also includes host debug-launch guidance for the separate normal-app attach path. `identifiers` repeats the launch-scoped `launchId` and attached `sessionName` so agents distinguish Electron lifecycle actions from browser session/tab actions. `launch.cleanupState` is one of `"active"`, `"cleaned"`, `"dead"`, `"failed"`, or `"partial"`. Failed launches expose `details.electron.failure.diagnostics` when available, including `pid` / `pidAlive`, wrapper `userDataDir`, elapsed/timeout timing, `DevToolsActivePort` file state, discovered port, and whether CDP `/json/version` was reached. `outputCaptured` reports whether stdout/stderr capture was configured; readable streams include `stdoutTail` / `stderrTail` (empty strings for empty output) and `stdoutTruncated` / `stderrTruncated`. Each tail reads at most the last 4096 source bytes before UTF-8 decoding and credential redaction; the same tails appear in visible error text. Optional `stdoutError` / `stderrError` report read/close failures without hiding the primary failure. Capture files are mode-0600 `stdout.log` / `stderr.log` inside the isolated profile and follow its lifecycle; file growth is not capped by the tail-read limit. Failed-startup termination errors preserve the profile/logs through generic cleanup and host exit. Persistence failures join `failure.cleanupError` while in-memory protection remains.
|
|
523
523
|
- `status`: `{ action: "status", status: "succeeded", launches, statuses, targets, identifiers?, identifierList?, managedSession?, managedSessions?, sessionMismatch?, sessionMismatches? }`, where each status includes the tracked `launchId`, `cleanupState`, independently measured port/pid liveness, bounded CDP targets, and fresh `userDataDirState`: `"present"`, `"absent"`, or `"unknown"`. Native `lstat` success means present (including dangling symlinks); only ENOENT means absent, and other filesystem errors mean unknown. This measures the tracked profile path, not all app residue, and is not stored in `ElectronLaunchRecord`. Explicit-ID status labels cleaned records as historical; default and `all: true` exclude them. Mismatch fields explain when the current managed session or tab does not match a live wrapper launch target.
|
|
524
524
|
- `cleanup`: `{ action: "cleanup", status: "succeeded" | "partial", cleanup: { partial, records, results } }`. Partial cleanup is a failed tool result with `failureCategory: "cleanup-failed"` and retry next actions. Cleanup steps may include `managed-session`, `process`, `debug-port`, and `user-data-dir`; managed-session close failures are reported while host-owned process/profile cleanup still runs.
|
|
525
525
|
- `probe`: `{ action: "probe", status: "succeeded" | "partial", probe, probeContext, identifiers?, sessionMismatch?, statusTargets?, launchStatus? }`. `probeContext` records whether the probe inspected the current managed session or a specific `launchId`. `probe` includes bounded `title`, `url`, `focusedElement`, `activeTab`, `tabs`, compact `snapshot` metadata (`refCount`, `refIds`, optional text preview and omission counts), `errors?`, and `summary`. When launch status is known, `launchStatus.userDataDirState` carries the same fresh profile-path measurement as `status`, and visible probe output includes debug-port/pid liveness so `about:blank` plus a dead wrapper launch is unmistakable. It also updates the normal session target/ref tracking when a snapshot is collected.
|
|
@@ -908,7 +908,7 @@ Additional structured fields can appear when relevant:
|
|
|
908
908
|
|
|
909
909
|
Recording destinations are reserved within one Pi process, not across processes. Use unique paths for concurrent Pi processes: different explicit sessions can overwrite one file even when both `record stop` results are verified. Upstream’s same-session `record start` guard does not reserve the filename across other sessions.
|
|
910
910
|
- `savedFilePath` / `savedFile` for direct `download`, `pdf`, and `wait --download` / `wait -d` saved-file workflows when a host file path is reported or wrapper-verified. Batch results preserve the same fields on the relevant `batchSteps` entry. These fields are metadata only until `artifactVerification` verifies the file. For simple loopback `download <selector> <path>` anchors with a non-ref selector, `details.downloadRecovery.method: "direct-anchor-fetch"` means the wrapper resolved the anchor URL in-session and saved the in-page HTTP(S) response directly to the requested path before using upstream's click/download fallback; non-loopback/profile downloads stay upstream-owned so external provider behavior is preserved.
|
|
911
|
-
- `batchSteps[].artifacts` for per-step artifacts in `batch` output; top-level `artifacts` and `artifactManifest` coalesce an earlier pending recording into the later saved, missing, or stale terminal result for the same namespace/session identity. `record restart` includes both the previous recording it finalized (or an explicit missing/stale failure) and the new pending recording; missing/stale terminal rows retire the prior pending manifest row. A successful later `close` / `quit` / `exit` represents an earlier unfinalized pending recording as `status: "missing"` / `subcommand: "close-abandoned"`, clears its stop action, and updates aggregate verification/manifest state consistently; a later successful `record stop` replaces that intermediate abandoned row with its saved artifact. Close also resets ref/page/network-route state produced by earlier rows; later lifecycle-proven browser launches, including `record stop`, can rebuild that state without triggering stale pre-close `about:blank` recovery, explicitly non-launching diagnostics cannot, and unknown later rows stay conservatively active. Per-step history remains unchanged. When any later call on the same namespace/session fails while a recording remains pending, `nextActions` combines its normal recovery with exact `stop-pending-recording` args and visible cleanup guidance; the same applies at top level when a later batch step fails. After reload in a non-Git checkout or with managed restore disabled, a live daemon without current-instance provenance cannot accept a stop. That policy refusal includes `managedSessionCleanupOnlyReason: "restore-disabled-daemon-without-provenance"` plus the exact `sessionName`/`namespace`, including on implicit calls. It replaces the impossible stop with `close-pending-recording`, an exact close without `sessionMode: "fresh"`. Close retires the recording as `close-abandoned`; any file it leaves is unverified. Same-instance recordings and supported durable-Git reloads still use stop and normal WebM verification.
|
|
911
|
+
- `batchSteps[].artifacts` for per-step artifacts in `batch` output; top-level `artifacts` and `artifactManifest` coalesce an earlier pending recording into the later saved, missing, or stale terminal result for the same namespace/session identity. `record restart` includes both the previous recording it finalized (or an explicit missing/stale failure) and the new pending recording; missing/stale terminal rows retire the prior pending manifest row. A successful later `close` / `quit` / `exit` represents an earlier unfinalized pending recording as `status: "missing"` / `subcommand: "close-abandoned"`, clears its stop action, and updates aggregate verification/manifest state consistently; a later successful `record stop` replaces that intermediate abandoned row with its saved artifact. Close also resets ref/page/network-route state produced by earlier rows; later lifecycle-proven browser launches, including `record stop`, can rebuild that state without triggering stale pre-close `about:blank` recovery, explicitly non-launching diagnostics cannot, and unknown later rows stay conservatively active. Per-step history remains unchanged. When any later call on the same namespace/session fails while a recording remains pending, `nextActions` combines its normal recovery with exact `stop-pending-recording` args and visible cleanup guidance; the same applies at top level when a later batch step fails. After reload in a non-Git checkout or with managed restore disabled, a live daemon without current-instance provenance cannot accept a stop. A tracked Electron attachment can rebuild that proof through the live debug-endpoint check described above; generic restore-disabled sessions cannot. That policy refusal includes `managedSessionCleanupOnlyReason: "restore-disabled-daemon-without-provenance"` plus the exact `sessionName`/`namespace`, including on implicit calls. It replaces the impossible stop with `close-pending-recording`, an exact close without `sessionMode: "fresh"`. Close retires the recording as `close-abandoned`; any file it leaves is unverified. Same-instance recordings and supported durable-Git reloads still use stop and normal WebM verification.
|
|
912
912
|
- `artifactVerification` for a normalized verification summary on the unified result and on each successful `batchSteps[]` row (failed batch steps omit artifact rows). Top-level `batch` verification rolls up all step file artifacts; each step’s summary reflects that step’s nested tool presentation (including its spill paths and manifest slice). It reports `verified`, `verifiedCount`, `missingCount`, `pendingCount`, `unverifiedCount`, and `artifacts[]` entries with `path`, optional `absolutePath`, optional `requestedPath`, `kind` (a normal file artifact kind or `"spill"` for manifest-backed rows), optional `mediaType`, optional `exists`, optional `sizeBytes`, optional `updatedAtMs`, optional `status`, optional `retentionState` / `storageScope` on manifest-derived rows, `state` (`verified`, `missing`, `pending`, or `unverified`), and optional `limitation` (human-readable lifecycle or retention context, for example pending `record start` / `record restart`, missing, stale, or otherwise unverified files, ephemeral spill files, or evicted persisted spills). The summary `verified` boolean is true only when every entry is `verified`. `record start` / `record restart` are `pending` until `record stop`; `state load` may mention a path in command output but is not a saved artifact row.
|
|
913
913
|
- `fullOutputPath` / `fullOutputPaths` when parse-valid large snapshot output or other oversized tool output is compacted and spilled to a private file; persisted sessions keep that path under a private session-scoped artifact directory with a bounded per-session budget so it survives reload/resume without unbounded growth. Malformed oversized upstream output is discarded after parsing, is omitted from `details.stdout`, and reports `fullOutputUnavailable` instead of creating a parse-failure spill.
|
|
914
914
|
- `artifactManifest` for a bounded, metadata-only inventory of recent session artifacts. Entries include path metadata, optional canonical `namespace` plus `session` lifecycle identity, artifact `kind`, source `command`/`subcommand` when safe, `storageScope` (`persistent-session`, `process-temp`, or `explicit-path`), and `retentionState` (`live`, `ephemeral`, `missing`, or `evicted`). The default recent window is 100 entries and can be configured with `PI_AGENT_BROWSER_SESSION_ARTIFACT_MANIFEST_MAX_ENTRIES`. A successful session close retires only that exact namespace/session identity's pending recording rows; the separate active reservation index remains authoritative even if this bounded display inventory evicts them. Only the newest pending recording row per namespace/session identity remains live in the manifest. The manifest must not store command args, output contents, headers, DOM snapshots, or downloaded file contents.
|
package/package.json
CHANGED