pi-agent-browser-native 0.2.2 → 0.2.3
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 +8 -0
- package/README.md +6 -1
- package/docs/ARCHITECTURE.md +8 -5
- package/docs/RELEASE.md +11 -2
- package/docs/REQUIREMENTS.md +5 -1
- package/docs/TOOL_CONTRACT.md +7 -4
- package/extensions/agent-browser/index.ts +305 -41
- package/extensions/agent-browser/lib/results/presentation.ts +92 -6
- package/extensions/agent-browser/lib/results/snapshot.ts +15 -6
- package/extensions/agent-browser/lib/runtime.ts +165 -0
- package/extensions/agent-browser/lib/temp.ts +94 -12
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.3 - 2026-04-13
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- direct headless local Chrome launches to `chatgpt.com` and `chat.openai.com` now inject a normal Chrome user agent when the caller did not explicitly choose one, keeping authenticated ChatGPT/OpenAI browsing working without forcing `--headed` or `--auto-connect`
|
|
7
|
+
- profiled `open` / `goto` / `navigate` calls now best-effort switch back to the page that was just opened when restored profile tabs steal focus during launch, reducing confusing cross-tab drift in profile-backed sessions
|
|
8
|
+
- command parsing now treats additional value-taking global flags like `--user-agent`, `--args`, `--allowed-domains`, `--action-policy`, and related launch options as launch metadata instead of accidentally parsing their values as subcommands
|
|
9
|
+
- README, requirements, architecture notes, and tool-contract docs now describe the new headless ChatGPT/OpenAI compatibility behavior and the profiled-tab focus recovery path
|
|
10
|
+
|
|
3
11
|
## 0.2.2 - 2026-04-12
|
|
4
12
|
|
|
5
13
|
### Fixed
|
package/README.md
CHANGED
|
@@ -178,6 +178,8 @@ Validated workflow examples:
|
|
|
178
178
|
- click a link and confirm the destination title
|
|
179
179
|
- use an explicit `--session` across multiple tool calls
|
|
180
180
|
- use an explicit `--profile` and verify persisted browser storage across restarts
|
|
181
|
+
- open `chatgpt.com` headlessly with `--profile Default` without forcing `--headed` or `--auto-connect`
|
|
182
|
+
- verify `/reload` and full restart + `/resume` keep following the same implicit managed browser session
|
|
181
183
|
- run `batch` with JSON via `stdin`
|
|
182
184
|
- run `eval --stdin`
|
|
183
185
|
- take a screenshot with inline attachment support
|
|
@@ -188,9 +190,12 @@ Inspection commands like `agent_browser --help` and `--version` are always suppo
|
|
|
188
190
|
Current cautions:
|
|
189
191
|
- passing `--profile` is an explicit upstream choice; this extension does not add its own profile-cloning or isolation layer
|
|
190
192
|
- startup-scoped flags like `--profile`, `--session-name`, and `--cdp` are for the first command that launches a session; if the implicit session is already active, retry that call with `sessionMode: "fresh"` or provide an explicit `--session ...` for the new launch
|
|
191
|
-
- implicit `piab-*` sessions are extension-managed convenience sessions; they
|
|
193
|
+
- implicit `piab-*` sessions are extension-managed convenience sessions; they stay alive across `pi` shutdown/reload so later default calls can keep following the active managed browser on `/reload` or `/resume`, rely on the configured idle timeout to reduce stale background daemons, store persisted-session large snapshot spill files under a private session-scoped artifact directory with a bounded per-session budget so `details.fullOutputPath` survives reload/resume without unbounded growth, and still clean up process-private temp spill artifacts on shutdown
|
|
192
194
|
- `sessionMode: "fresh"` without an explicit `--session` rotates that extension-managed session to the new browser so later auto calls keep using it
|
|
195
|
+
- for direct headless local Chrome launches to `chatgpt.com` and `chat.openai.com`, the extension injects a normal Chrome user agent when the caller did not explicitly provide `--user-agent`; this keeps the default headless workflow usable without forcing `--headed` or `--auto-connect`
|
|
196
|
+
- after profiled `open` calls, the extension best-effort re-selects the tab that matches the returned page URL when restored profile tabs steal focus during launch
|
|
193
197
|
- explicit caller-provided `--session` values are treated as user-managed and are not auto-closed by the extension
|
|
198
|
+
- explicit caller-provided `--user-agent` values win over the ChatGPT/OpenAI compatibility workaround
|
|
194
199
|
- tool progress/details redact sensitive invocation values such as `--headers`, proxy credentials, and auth-bearing URL parameters before echoing them back into Pi
|
|
195
200
|
|
|
196
201
|
### Switching from public browsing to a fresh profile/debug launch
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -78,17 +78,18 @@ V1 ownership rule:
|
|
|
78
78
|
- implicit auto-generated sessions are extension-managed convenience sessions
|
|
79
79
|
- unnamed `sessionMode: "fresh"` launches rotate that extension-managed session to a new upstream browser
|
|
80
80
|
- explicit/user-managed sessions are not auto-managed by default
|
|
81
|
-
- extension-managed sessions should be reusable during an active `pi` session
|
|
81
|
+
- extension-managed sessions should be reusable during an active `pi` session and across `/reload` / `/resume`, while still being cleaned up predictably
|
|
82
82
|
|
|
83
83
|
Practical policy:
|
|
84
|
-
-
|
|
85
|
-
-
|
|
86
|
-
- clean up private temp spill artifacts
|
|
84
|
+
- preserve the current extension-managed session across normal `pi` shutdown/reload so persisted sessions can keep following the live browser after `/reload` or `/resume`
|
|
85
|
+
- set an idle timeout on extension-managed sessions so abandoned daemons self-clean after inactivity
|
|
86
|
+
- clean up process-private temp spill artifacts on shutdown, but keep persisted-session snapshot spill files in a private session-scoped artifact directory with a bounded per-session budget so `details.fullOutputPath` stays usable after reload/resume without unbounded growth
|
|
87
87
|
- reconstruct the current extension-managed session from persisted tool details on resume/reload so later default calls keep following the active managed browser
|
|
88
88
|
- if an unnamed fresh launch replaces an active extension-managed session, best-effort close the old managed session after the switch succeeds
|
|
89
89
|
- leave explicit caller-provided `--session` choices alone unless the caller closes them explicitly
|
|
90
|
+
- after profiled `open` / `goto` / `navigate` calls, verify the active tab still matches the returned page URL and best-effort switch back when restored profile tabs steal focus
|
|
90
91
|
|
|
91
|
-
This is primarily about ownership clarity and avoiding surprise, not adding a heavy safety wrapper. If the extension invented the session, the extension should
|
|
92
|
+
This is primarily about ownership clarity and avoiding surprise, not adding a heavy safety wrapper. If the extension invented the session, the extension should own its lifecycle without breaking reload/resume semantics. If the caller explicitly chose the upstream session model, the extension should stay out of the way.
|
|
92
93
|
|
|
93
94
|
### Launch flags
|
|
94
95
|
|
|
@@ -97,6 +98,8 @@ The extension should surface that clearly and avoid hidden restart behavior in v
|
|
|
97
98
|
|
|
98
99
|
That means explicit startup-scoping flags like `--profile`, `--session-name`, and `--cdp` should remain explicit upstream choices instead of being wrapped in extra hidden restart or cloning logic.
|
|
99
100
|
|
|
101
|
+
The wrapper may still apply narrow compatibility normalizations when observed behavior justifies them and the result remains thin, local, and opt-out. For example, if a specific site starts rejecting the default local headless Chrome user agent while the same flow works with a normal Chrome UA, the extension may inject a domain-specific fallback UA only when the caller did not already choose `--user-agent`, `--headed`, `--cdp`, `--auto-connect`, or a provider-backed launch.
|
|
102
|
+
|
|
100
103
|
If the implicit session is already active and one of those startup-scoped flags appears again while `sessionMode` is still `"auto"`, the extension should fail clearly instead of silently sending a command shape that upstream would ignore.
|
|
101
104
|
|
|
102
105
|
That failure should include a structured recovery hint pointing to `sessionMode: "fresh"` as the first-line fix, while still allowing an explicit `--session` when the caller wants to name the new upstream session.
|
package/docs/RELEASE.md
CHANGED
|
@@ -57,13 +57,21 @@ Before publishing, also validate the explicit local-checkout path:
|
|
|
57
57
|
2. Launch `pi --no-extensions -e .` from this repository root.
|
|
58
58
|
3. Confirm the checkout extension loads from `extensions/agent-browser/index.ts`.
|
|
59
59
|
4. Run a smoke prompt that exercises `agent_browser`.
|
|
60
|
+
5. Validate managed-session continuity with both `/reload` and a full restart + `/resume`.
|
|
60
61
|
|
|
61
|
-
Example prompt:
|
|
62
|
+
Example smoke prompt:
|
|
62
63
|
|
|
63
64
|
```text
|
|
64
65
|
Use the agent_browser tool to open https://react.dev and then take an interactive snapshot.
|
|
65
66
|
```
|
|
66
67
|
|
|
68
|
+
Recommended lifecycle follow-up:
|
|
69
|
+
|
|
70
|
+
1. Open a page with the implicit managed session and confirm the title.
|
|
71
|
+
2. Run `/reload`, then ask for `snapshot -i` and confirm the same page is still active.
|
|
72
|
+
3. Exit `pi`, relaunch it against the same session file or use `/resume`, then ask for `snapshot -i` again and confirm the same page is still active.
|
|
73
|
+
4. Open a large page that compacts its snapshot output and confirm `details.fullOutputPath` still exists after the restart/resume flow.
|
|
74
|
+
|
|
67
75
|
## Post-publish install validation
|
|
68
76
|
|
|
69
77
|
After publishing a release, validate the package-first install path explicitly:
|
|
@@ -73,7 +81,7 @@ pi install npm:pi-agent-browser-native@<version>
|
|
|
73
81
|
pi -e npm:pi-agent-browser-native@<version>
|
|
74
82
|
```
|
|
75
83
|
|
|
76
|
-
Then confirm `pi` exposes the native `agent_browser` tool
|
|
84
|
+
Then confirm `pi` exposes the native `agent_browser` tool, that a basic `open` + `snapshot -i` flow works, and that `/reload` plus restart/`/resume` keep following the same implicit managed browser session.
|
|
77
85
|
|
|
78
86
|
## Release notes checklist
|
|
79
87
|
|
|
@@ -83,4 +91,5 @@ Before publishing:
|
|
|
83
91
|
- confirm README install guidance still leads with the package-first flow
|
|
84
92
|
- confirm the explicit local-checkout instructions still work for pre-release validation
|
|
85
93
|
- rerun `npm run verify:release`
|
|
94
|
+
- manually exercise `/reload` and full restart + `/resume` continuity in local checkout validation
|
|
86
95
|
- publish only after the tarball contents match expectations
|
package/docs/REQUIREMENTS.md
CHANGED
|
@@ -71,7 +71,8 @@ Define the product requirements and constraints for `pi-agent-browser-native`.
|
|
|
71
71
|
|
|
72
72
|
- The primary confidence path is a real `pi` session driven in `tmux`.
|
|
73
73
|
- For local checkout validation, launch `pi --no-extensions -e .` from the repository root so only the checkout copy loads.
|
|
74
|
-
-
|
|
74
|
+
- Validate both `/reload` and a full `pi` restart with `/resume` when changes touch managed-session continuity, reload behavior, or persisted artifact paths.
|
|
75
|
+
- Prefer full `pi` restart over `/reload` when validating extension changes beyond a quick reload smoke check.
|
|
75
76
|
- Use `/resume` when needed after restart.
|
|
76
77
|
- Keep testing broader than a single smoke site like `example.com`.
|
|
77
78
|
- Maintain a concrete release/package verification workflow in `docs/RELEASE.md` and matching repository scripts.
|
|
@@ -84,6 +85,7 @@ The design should comfortably support workflows such as:
|
|
|
84
85
|
- web research
|
|
85
86
|
- using browser UIs for other LLMs such as ChatGPT, Grok, Gemini, and Claude
|
|
86
87
|
- isolated authenticated browser sessions
|
|
88
|
+
- headless authenticated ChatGPT/OpenAI browsing without forcing `--headed` or `--auto-connect`
|
|
87
89
|
- upstream profile/debug workflows without adding a local profile-cloning layer in this package
|
|
88
90
|
|
|
89
91
|
## Implications for the implementation
|
|
@@ -94,6 +96,8 @@ The design should comfortably support workflows such as:
|
|
|
94
96
|
- User-facing docs belong in `README.md` and the canonical published files under `docs/`.
|
|
95
97
|
- Agent workflow and deeper testing procedures can stay in `AGENTS.md`, but published docs must not depend on that file being present.
|
|
96
98
|
- Keep mitigations for legacy-skill coexistence simple; do not add extra moving parts unless observed behavior justifies them.
|
|
99
|
+
- Prefer narrow, evidence-backed compatibility mitigations over broad stealth layers when a specific upstream site starts rejecting the default headless launch fingerprint.
|
|
100
|
+
- Preserve the page that a profiled `open` just navigated to; if restored profile tabs steal focus during launch, the wrapper should best-effort switch back to the returned page URL before handing control back to the agent.
|
|
97
101
|
|
|
98
102
|
## Open design questions
|
|
99
103
|
|
package/docs/TOOL_CONTRACT.md
CHANGED
|
@@ -141,13 +141,13 @@ Additional structured fields can appear when relevant:
|
|
|
141
141
|
- `batchFailure` and `batchSteps` for `batch` rendering, including mixed-success runs
|
|
142
142
|
- `navigationSummary` for navigation-style commands like `click`, `back`, `forward`, and `reload`
|
|
143
143
|
- `imagePath` / `imagePaths` for screenshots and batched image outputs
|
|
144
|
-
- `fullOutputPath` / `fullOutputPaths` when large snapshot output is compacted and spilled to a private
|
|
144
|
+
- `fullOutputPath` / `fullOutputPaths` when large snapshot 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
|
|
145
145
|
- `sessionRecoveryHint` when startup-scoped flags need `sessionMode: "fresh"`
|
|
146
146
|
- `inspection: true` plus `stdout` for successful plain-text inspection commands like `--help` and `--version`
|
|
147
147
|
|
|
148
148
|
When the tool echoes `args` or `effectiveArgs` back into Pi, sensitive values such as `--headers`, proxy credentials, and auth-bearing URL parameters should be redacted first.
|
|
149
149
|
|
|
150
|
-
For oversized snapshots, details should switch to a compact metadata object and include `fullOutputPath` pointing at a private
|
|
150
|
+
For oversized snapshots, details should switch to a compact metadata object and include `fullOutputPath` pointing at a private JSON spill file with the full upstream snapshot payload. Persisted sessions should keep that spill file under a private session-scoped artifact directory so the path remains usable after reload/restart, with the oldest persisted spill files evicted as needed to stay within the per-session budget.
|
|
151
151
|
|
|
152
152
|
## High-value result rendering
|
|
153
153
|
|
|
@@ -156,6 +156,7 @@ For oversized snapshots, details should switch to a compact metadata object and
|
|
|
156
156
|
Worth doing in v1:
|
|
157
157
|
- screenshots → inline image attachment
|
|
158
158
|
- snapshots → origin + ref count + main-content-first compact preview, with the raw snapshot spill path kept in `details.fullOutputPath` when the inline result would otherwise be too large
|
|
159
|
+
- extraction-style commands like `eval --stdin` and `get title` → scalar-first text with lightweight origin context when available
|
|
159
160
|
- navigation actions like `click`, `back`, `forward`, and `reload` → lightweight post-action title/url summary when available
|
|
160
161
|
- tab lists → compact summary/table
|
|
161
162
|
- stream status → enabled/connected/port summary
|
|
@@ -173,16 +174,18 @@ If `agent-browser` is not on `PATH`, fail with a message that:
|
|
|
173
174
|
- derive the base implicit session name from the official `pi` session id plus a cwd hash so same-named checkouts do not collide
|
|
174
175
|
- respect explicit upstream `--session` with minimal interference
|
|
175
176
|
- treat the extension-managed session as convenience state owned by the wrapper
|
|
176
|
-
-
|
|
177
|
+
- preserve the current extension-managed session across normal `pi` shutdown/reload so persisted sessions can keep following the live browser on `/reload` or `/resume`
|
|
177
178
|
- set an idle timeout on extension-managed sessions so abandoned daemons eventually self-clean
|
|
178
|
-
- clean up private temp spill artifacts
|
|
179
|
+
- clean up process-private temp spill artifacts on shutdown, while keeping persisted-session snapshot spill files in a private session-scoped artifact directory so `details.fullOutputPath` survives reload/restart and the oldest spill files are evicted if the per-session artifact budget is exceeded
|
|
179
180
|
- reconstruct the current extension-managed session from persisted tool details on resume/reload so later default calls keep following the active managed browser
|
|
180
181
|
- when an unnamed `sessionMode: "fresh"` launch succeeds, make it the new extension-managed session so later default calls keep using it
|
|
181
182
|
- if that unnamed fresh launch replaced an already-active managed session, best-effort close the old managed session after the switch succeeds
|
|
182
183
|
- treat explicit caller-provided `--session` choices as user-managed
|
|
183
184
|
- pass explicit `--profile` straight through to upstream `agent-browser`; no profile-cloning or isolation layer is added in v1
|
|
185
|
+
- after profiled `open` / `goto` / `navigate`, if upstream leaves a restored profile tab active instead of the page that was just opened, best-effort switch back to the tab whose URL matches the returned open result before returning control to the agent
|
|
184
186
|
- treat successful plain-text inspection commands like `--help` and `--version` as stateless: do not inject the implicit managed session and do not let those calls claim the managed-session slot
|
|
185
187
|
- if startup-scoped flags like `--profile`, `--session-name`, or `--cdp` are supplied after the implicit session is already active while `sessionMode` is `"auto"`, return a validation error with a structured recovery hint that recommends `sessionMode: "fresh"`
|
|
188
|
+
- for direct headless local Chrome launches to `chatgpt.com` / `chat.openai.com`, allow a narrow compatibility fallback that injects a normal Chrome `--user-agent` only when the caller did not explicitly provide one and did not choose `--headed`, `--cdp`, `--auto-connect`, or a provider-backed launch
|
|
186
189
|
|
|
187
190
|
## Non-goals
|
|
188
191
|
|
|
@@ -16,6 +16,7 @@ import { buildToolPresentation, getAgentBrowserErrorText, parseAgentBrowserEnvel
|
|
|
16
16
|
import {
|
|
17
17
|
buildExecutionPlan,
|
|
18
18
|
buildPromptPolicy,
|
|
19
|
+
chooseOpenResultTabCorrection,
|
|
19
20
|
createEphemeralSessionSeed,
|
|
20
21
|
createFreshSessionName,
|
|
21
22
|
createImplicitSessionName,
|
|
@@ -30,8 +31,10 @@ import {
|
|
|
30
31
|
resolveManagedSessionState,
|
|
31
32
|
shouldAppendBrowserSystemPrompt,
|
|
32
33
|
validateToolArgs,
|
|
34
|
+
type CompatibilityWorkaround,
|
|
35
|
+
type OpenResultTabCorrection,
|
|
33
36
|
} from "./lib/runtime.js";
|
|
34
|
-
import { cleanupSecureTempArtifacts } from "./lib/temp.js";
|
|
37
|
+
import { cleanupSecureTempArtifacts, type PersistentSessionArtifactStore } from "./lib/temp.js";
|
|
35
38
|
|
|
36
39
|
const DEFAULT_SESSION_MODE = "auto" as const;
|
|
37
40
|
|
|
@@ -96,15 +99,184 @@ function buildInvocationPreview(effectiveArgs: string[]): string {
|
|
|
96
99
|
return preview.length > 120 ? `${preview.slice(0, 117)}...` : preview;
|
|
97
100
|
}
|
|
98
101
|
|
|
99
|
-
const
|
|
100
|
-
const
|
|
101
|
-
const DIRECT_AGENT_BROWSER_BASH_PATTERN = new RegExp(
|
|
102
|
-
String.raw`(^|[\s;&|])${AGENT_BROWSER_BASH_PREFIX}${AGENT_BROWSER_BASH_EXECUTABLE}(?=\s|$)`,
|
|
103
|
-
);
|
|
104
|
-
const HARMLESS_AGENT_BROWSER_INSPECTION_PATTERN = /(command\s+-v|which|type\s+-P)\s+agent-browser\b/;
|
|
102
|
+
const DIRECT_AGENT_BROWSER_EXECUTABLE_PATTERN = /^(?:[.~]|\.\.?|\/)?(?:[^\s;&|]+\/)?agent-browser$/;
|
|
103
|
+
const HARMLESS_AGENT_BROWSER_INSPECTION_PATTERN = /^\s*(?:command\s+-v|which|type\s+-P)\s+agent-browser\s*$/;
|
|
105
104
|
|
|
105
|
+
type ShellQuoteState = 'double' | 'single' | undefined;
|
|
106
|
+
|
|
107
|
+
function isShellAssignmentToken(token: string): boolean {
|
|
108
|
+
return /^[A-Za-z_][A-Za-z0-9_]*=/.test(token);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function stripOuterQuotes(token: string): string {
|
|
112
|
+
if (token.length >= 2 && ((token.startsWith('"') && token.endsWith('"')) || (token.startsWith("'") && token.endsWith("'")))) {
|
|
113
|
+
return token.slice(1, -1);
|
|
114
|
+
}
|
|
115
|
+
return token;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function segmentLaunchesAgentBrowser(tokens: string[]): boolean {
|
|
119
|
+
let index = 0;
|
|
120
|
+
while (index < tokens.length && isShellAssignmentToken(tokens[index])) {
|
|
121
|
+
index += 1;
|
|
122
|
+
}
|
|
123
|
+
if (index >= tokens.length) {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let executableToken = tokens[index];
|
|
128
|
+
if (executableToken === 'env') {
|
|
129
|
+
index += 1;
|
|
130
|
+
while (index < tokens.length && isShellAssignmentToken(tokens[index])) {
|
|
131
|
+
index += 1;
|
|
132
|
+
}
|
|
133
|
+
executableToken = tokens[index] ?? '';
|
|
134
|
+
}
|
|
135
|
+
if (executableToken === 'npx' || executableToken === 'bunx') {
|
|
136
|
+
index += 1;
|
|
137
|
+
while (index < tokens.length && tokens[index].startsWith('-')) {
|
|
138
|
+
index += 1;
|
|
139
|
+
}
|
|
140
|
+
executableToken = tokens[index] ?? '';
|
|
141
|
+
}
|
|
142
|
+
if (executableToken === 'pnpm' || executableToken === 'yarn') {
|
|
143
|
+
index += 1;
|
|
144
|
+
if (tokens[index] !== 'dlx') {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
index += 1;
|
|
148
|
+
while (index < tokens.length && tokens[index].startsWith('-')) {
|
|
149
|
+
index += 1;
|
|
150
|
+
}
|
|
151
|
+
executableToken = tokens[index] ?? '';
|
|
152
|
+
}
|
|
153
|
+
return DIRECT_AGENT_BROWSER_EXECUTABLE_PATTERN.test(executableToken);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Best-effort detection for common direct launches only. This is an ergonomics guard,
|
|
157
|
+
// not a general-purpose bash parser or security boundary.
|
|
106
158
|
function looksLikeDirectAgentBrowserBash(command: string): boolean {
|
|
107
|
-
|
|
159
|
+
let currentToken = '';
|
|
160
|
+
let quoteState: ShellQuoteState;
|
|
161
|
+
let awaitingHeredocDelimiter: { stripTabs: boolean } | undefined;
|
|
162
|
+
let pendingHeredoc: { delimiter: string; stripTabs: boolean } | undefined;
|
|
163
|
+
let pendingHeredocLine = '';
|
|
164
|
+
let segmentTokens: string[] = [];
|
|
165
|
+
|
|
166
|
+
const acceptToken = (token: string) => {
|
|
167
|
+
if (token.length === 0) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (awaitingHeredocDelimiter) {
|
|
171
|
+
pendingHeredoc = {
|
|
172
|
+
delimiter: stripOuterQuotes(token),
|
|
173
|
+
stripTabs: awaitingHeredocDelimiter.stripTabs,
|
|
174
|
+
};
|
|
175
|
+
awaitingHeredocDelimiter = undefined;
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
segmentTokens.push(token);
|
|
179
|
+
};
|
|
180
|
+
const flushToken = () => {
|
|
181
|
+
acceptToken(currentToken);
|
|
182
|
+
currentToken = '';
|
|
183
|
+
};
|
|
184
|
+
const flushSegment = () => {
|
|
185
|
+
const launchesAgentBrowser = segmentLaunchesAgentBrowser(segmentTokens);
|
|
186
|
+
segmentTokens = [];
|
|
187
|
+
return launchesAgentBrowser;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
191
|
+
const char = command[index];
|
|
192
|
+
if (pendingHeredoc) {
|
|
193
|
+
if (char === '\n') {
|
|
194
|
+
const candidate = pendingHeredoc.stripTabs ? pendingHeredocLine.replace(/^\t+/, '') : pendingHeredocLine;
|
|
195
|
+
if (candidate === pendingHeredoc.delimiter) {
|
|
196
|
+
pendingHeredoc = undefined;
|
|
197
|
+
}
|
|
198
|
+
pendingHeredocLine = '';
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
pendingHeredocLine += char;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (quoteState === 'single') {
|
|
206
|
+
currentToken += char;
|
|
207
|
+
if (char === "'") {
|
|
208
|
+
quoteState = undefined;
|
|
209
|
+
}
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (quoteState === 'double') {
|
|
213
|
+
currentToken += char;
|
|
214
|
+
if (char === '\\' && index + 1 < command.length) {
|
|
215
|
+
currentToken += command[index + 1];
|
|
216
|
+
index += 1;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (char === '"') {
|
|
220
|
+
quoteState = undefined;
|
|
221
|
+
}
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (char === "'" || char === '"') {
|
|
225
|
+
currentToken += char;
|
|
226
|
+
quoteState = char === "'" ? 'single' : 'double';
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (char === '\\' && index + 1 < command.length) {
|
|
230
|
+
currentToken += char;
|
|
231
|
+
currentToken += command[index + 1];
|
|
232
|
+
index += 1;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (char === '\n') {
|
|
236
|
+
flushToken();
|
|
237
|
+
if (flushSegment()) {
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (/\s/.test(char)) {
|
|
243
|
+
flushToken();
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
const threeCharOperator = command.slice(index, index + 3);
|
|
247
|
+
if (threeCharOperator === '<<-') {
|
|
248
|
+
flushToken();
|
|
249
|
+
awaitingHeredocDelimiter = { stripTabs: true };
|
|
250
|
+
index += 2;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
const twoCharOperator = command.slice(index, index + 2);
|
|
254
|
+
if (twoCharOperator === '<<') {
|
|
255
|
+
flushToken();
|
|
256
|
+
awaitingHeredocDelimiter = { stripTabs: false };
|
|
257
|
+
index += 1;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (twoCharOperator === '&&' || twoCharOperator === '||') {
|
|
261
|
+
flushToken();
|
|
262
|
+
if (flushSegment()) {
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
index += 1;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (char === '|' || char === ';' || char === '&') {
|
|
269
|
+
flushToken();
|
|
270
|
+
if (flushSegment()) {
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
currentToken += char;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
flushToken();
|
|
279
|
+
return flushSegment();
|
|
108
280
|
}
|
|
109
281
|
|
|
110
282
|
function isHarmlessAgentBrowserInspectionCommand(command: string): boolean {
|
|
@@ -142,41 +314,53 @@ function extractStringResultField(data: unknown, fieldName: "title" | "url"): st
|
|
|
142
314
|
return text.length > 0 ? text : undefined;
|
|
143
315
|
}
|
|
144
316
|
|
|
145
|
-
async function
|
|
317
|
+
async function runSessionCommandData(options: {
|
|
318
|
+
args: string[];
|
|
146
319
|
cwd: string;
|
|
147
320
|
sessionName?: string;
|
|
148
321
|
signal?: AbortSignal;
|
|
149
|
-
}): Promise<
|
|
150
|
-
const { cwd, sessionName, signal } = options;
|
|
322
|
+
}): Promise<unknown | undefined> {
|
|
323
|
+
const { args, cwd, sessionName, signal } = options;
|
|
151
324
|
if (!sessionName) return undefined;
|
|
152
325
|
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
326
|
+
const processResult = await runAgentBrowserProcess({
|
|
327
|
+
args: ["--json", "--session", sessionName, ...args],
|
|
328
|
+
cwd,
|
|
329
|
+
signal,
|
|
330
|
+
});
|
|
331
|
+
if (processResult.aborted || processResult.spawnError || processResult.exitCode !== 0) {
|
|
332
|
+
return undefined;
|
|
333
|
+
}
|
|
334
|
+
const parsed = await parseAgentBrowserEnvelope({
|
|
335
|
+
stdout: processResult.stdout,
|
|
336
|
+
stdoutPath: processResult.stdoutSpillPath,
|
|
337
|
+
});
|
|
338
|
+
try {
|
|
339
|
+
if (parsed.parseError || parsed.envelope?.success === false) {
|
|
160
340
|
return undefined;
|
|
161
341
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
try {
|
|
167
|
-
if (parsed.parseError || parsed.envelope?.success === false) {
|
|
168
|
-
return undefined;
|
|
169
|
-
}
|
|
170
|
-
return extractStringResultField(parsed.envelope?.data, fieldName);
|
|
171
|
-
} finally {
|
|
172
|
-
if (processResult.stdoutSpillPath) {
|
|
173
|
-
await rm(processResult.stdoutSpillPath, { force: true }).catch(() => undefined);
|
|
174
|
-
}
|
|
342
|
+
return parsed.envelope?.data;
|
|
343
|
+
} finally {
|
|
344
|
+
if (processResult.stdoutSpillPath) {
|
|
345
|
+
await rm(processResult.stdoutSpillPath, { force: true }).catch(() => undefined);
|
|
175
346
|
}
|
|
176
|
-
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
177
349
|
|
|
178
|
-
|
|
179
|
-
|
|
350
|
+
async function collectNavigationSummary(options: {
|
|
351
|
+
cwd: string;
|
|
352
|
+
sessionName?: string;
|
|
353
|
+
signal?: AbortSignal;
|
|
354
|
+
}): Promise<NavigationSummary | undefined> {
|
|
355
|
+
const { cwd, sessionName, signal } = options;
|
|
356
|
+
const title = extractStringResultField(
|
|
357
|
+
await runSessionCommandData({ args: ["get", "title"], cwd, sessionName, signal }),
|
|
358
|
+
"title",
|
|
359
|
+
);
|
|
360
|
+
const url = extractStringResultField(
|
|
361
|
+
await runSessionCommandData({ args: ["get", "url"], cwd, sessionName, signal }),
|
|
362
|
+
"url",
|
|
363
|
+
);
|
|
180
364
|
if (!title && !url) return undefined;
|
|
181
365
|
return { title, url };
|
|
182
366
|
}
|
|
@@ -188,6 +372,43 @@ function mergeNavigationSummaryIntoData(data: unknown, navigationSummary: Naviga
|
|
|
188
372
|
return { navigationSummary, result: data };
|
|
189
373
|
}
|
|
190
374
|
|
|
375
|
+
async function collectOpenResultTabCorrection(options: {
|
|
376
|
+
cwd: string;
|
|
377
|
+
sessionName?: string;
|
|
378
|
+
signal?: AbortSignal;
|
|
379
|
+
targetTitle?: string;
|
|
380
|
+
targetUrl?: string;
|
|
381
|
+
}): Promise<OpenResultTabCorrection | undefined> {
|
|
382
|
+
const { cwd, sessionName, signal, targetTitle, targetUrl } = options;
|
|
383
|
+
const tabData = await runSessionCommandData({ args: ["tab", "list"], cwd, sessionName, signal });
|
|
384
|
+
if (!isRecord(tabData) || !Array.isArray(tabData.tabs)) {
|
|
385
|
+
return undefined;
|
|
386
|
+
}
|
|
387
|
+
const tabs = tabData.tabs.filter(isRecord).map((tab) => ({
|
|
388
|
+
active: tab.active === true,
|
|
389
|
+
index: typeof tab.index === "number" ? tab.index : undefined,
|
|
390
|
+
title: typeof tab.title === "string" ? tab.title : undefined,
|
|
391
|
+
url: typeof tab.url === "string" ? tab.url : undefined,
|
|
392
|
+
}));
|
|
393
|
+
return chooseOpenResultTabCorrection({ tabs, targetTitle, targetUrl });
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function applyOpenResultTabCorrection(options: {
|
|
397
|
+
correction: OpenResultTabCorrection;
|
|
398
|
+
cwd: string;
|
|
399
|
+
sessionName?: string;
|
|
400
|
+
signal?: AbortSignal;
|
|
401
|
+
}): Promise<OpenResultTabCorrection | undefined> {
|
|
402
|
+
const { correction, cwd, sessionName, signal } = options;
|
|
403
|
+
const result = await runSessionCommandData({
|
|
404
|
+
args: ["tab", String(correction.selectedIndex)],
|
|
405
|
+
cwd,
|
|
406
|
+
sessionName,
|
|
407
|
+
signal,
|
|
408
|
+
});
|
|
409
|
+
return result === undefined ? undefined : correction;
|
|
410
|
+
}
|
|
411
|
+
|
|
191
412
|
function buildSharedBrowserPlaybookGuidelines(hasBraveApiKey: boolean): string[] {
|
|
192
413
|
return [
|
|
193
414
|
SHARED_BROWSER_PLAYBOOK_GUIDELINES[0],
|
|
@@ -209,6 +430,22 @@ function buildSessionDetailFields(sessionName: string | undefined, usedImplicitS
|
|
|
209
430
|
return sessionName ? { sessionName, usedImplicitSession } : {};
|
|
210
431
|
}
|
|
211
432
|
|
|
433
|
+
function getPersistentSessionArtifactStore(ctx: {
|
|
434
|
+
sessionManager: {
|
|
435
|
+
getSessionDir?: () => string;
|
|
436
|
+
getSessionFile?: () => string | undefined;
|
|
437
|
+
getSessionId: () => string | undefined;
|
|
438
|
+
};
|
|
439
|
+
}): PersistentSessionArtifactStore | undefined {
|
|
440
|
+
const sessionFile = typeof ctx.sessionManager.getSessionFile === "function" ? ctx.sessionManager.getSessionFile() : undefined;
|
|
441
|
+
const sessionDir = typeof ctx.sessionManager.getSessionDir === "function" ? ctx.sessionManager.getSessionDir() : undefined;
|
|
442
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
443
|
+
if (!sessionFile || !sessionDir || !sessionId) {
|
|
444
|
+
return undefined;
|
|
445
|
+
}
|
|
446
|
+
return { sessionDir, sessionId };
|
|
447
|
+
}
|
|
448
|
+
|
|
212
449
|
function redactRecoveryHint(recoveryHint: {
|
|
213
450
|
exampleArgs: string[];
|
|
214
451
|
exampleParams: { args: string[]; sessionMode: "fresh" };
|
|
@@ -267,13 +504,6 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
|
|
|
267
504
|
});
|
|
268
505
|
|
|
269
506
|
pi.on("session_shutdown", async () => {
|
|
270
|
-
if (managedSessionActive) {
|
|
271
|
-
await closeManagedSession({
|
|
272
|
-
cwd: managedSessionCwd,
|
|
273
|
-
sessionName: managedSessionName,
|
|
274
|
-
timeoutMs: implicitSessionCloseTimeoutMs,
|
|
275
|
-
});
|
|
276
|
-
}
|
|
277
507
|
managedSessionActive = false;
|
|
278
508
|
await cleanupSecureTempArtifacts();
|
|
279
509
|
});
|
|
@@ -332,6 +562,7 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
|
|
|
332
562
|
});
|
|
333
563
|
const redactedEffectiveArgs = redactInvocationArgs(executionPlan.effectiveArgs);
|
|
334
564
|
const redactedRecoveryHint = redactRecoveryHint(executionPlan.recoveryHint);
|
|
565
|
+
const compatibilityWorkaround: CompatibilityWorkaround | undefined = executionPlan.compatibilityWorkaround;
|
|
335
566
|
if (executionPlan.managedSessionName === freshSessionName) {
|
|
336
567
|
freshSessionOrdinal += 1;
|
|
337
568
|
}
|
|
@@ -354,6 +585,7 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
|
|
|
354
585
|
onUpdate?.({
|
|
355
586
|
content: [{ type: "text", text: `Running agent-browser ${buildInvocationPreview(redactedEffectiveArgs)}` }],
|
|
356
587
|
details: {
|
|
588
|
+
compatibilityWorkaround,
|
|
357
589
|
effectiveArgs: redactedEffectiveArgs,
|
|
358
590
|
sessionMode,
|
|
359
591
|
...buildSessionDetailFields(executionPlan.sessionName, executionPlan.usedImplicitSession),
|
|
@@ -374,6 +606,7 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
|
|
|
374
606
|
content: [{ type: "text", text: errorText }],
|
|
375
607
|
details: {
|
|
376
608
|
args: redactedArgs,
|
|
609
|
+
compatibilityWorkaround,
|
|
377
610
|
effectiveArgs: redactedEffectiveArgs,
|
|
378
611
|
sessionMode,
|
|
379
612
|
spawnError: processResult.spawnError.message,
|
|
@@ -410,6 +643,34 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
|
|
|
410
643
|
}
|
|
411
644
|
}
|
|
412
645
|
|
|
646
|
+
let openResultTabCorrection: OpenResultTabCorrection | undefined;
|
|
647
|
+
if (
|
|
648
|
+
succeeded &&
|
|
649
|
+
executionPlan.sessionName &&
|
|
650
|
+
params.args.some((token) => token === "--profile" || token.startsWith("--profile=")) &&
|
|
651
|
+
(executionPlan.commandInfo.command === "goto" ||
|
|
652
|
+
executionPlan.commandInfo.command === "navigate" ||
|
|
653
|
+
executionPlan.commandInfo.command === "open")
|
|
654
|
+
) {
|
|
655
|
+
const targetTitle = extractStringResultField(parsed.envelope?.data, "title");
|
|
656
|
+
const targetUrl = extractStringResultField(parsed.envelope?.data, "url");
|
|
657
|
+
const plannedTabCorrection = await collectOpenResultTabCorrection({
|
|
658
|
+
cwd: ctx.cwd,
|
|
659
|
+
sessionName: executionPlan.sessionName,
|
|
660
|
+
signal,
|
|
661
|
+
targetTitle,
|
|
662
|
+
targetUrl,
|
|
663
|
+
});
|
|
664
|
+
if (plannedTabCorrection) {
|
|
665
|
+
openResultTabCorrection = await applyOpenResultTabCorrection({
|
|
666
|
+
correction: plannedTabCorrection,
|
|
667
|
+
cwd: ctx.cwd,
|
|
668
|
+
sessionName: executionPlan.sessionName,
|
|
669
|
+
signal,
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
413
674
|
const priorManagedSessionCwd = managedSessionCwd;
|
|
414
675
|
const managedSessionState = resolveManagedSessionState({
|
|
415
676
|
command: executionPlan.commandInfo.command,
|
|
@@ -459,6 +720,7 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
|
|
|
459
720
|
cwd: ctx.cwd,
|
|
460
721
|
envelope: presentationEnvelope,
|
|
461
722
|
errorText,
|
|
723
|
+
persistentArtifactStore: getPersistentSessionArtifactStore(ctx),
|
|
462
724
|
});
|
|
463
725
|
const redactedContent = presentation.content.map((item) =>
|
|
464
726
|
item.type === "text" ? { ...item, text: redactSensitiveText(item.text) } : item,
|
|
@@ -471,11 +733,13 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
|
|
|
471
733
|
batchFailure: redactSensitiveValue(presentation.batchFailure),
|
|
472
734
|
batchSteps: redactSensitiveValue(presentation.batchSteps),
|
|
473
735
|
command: executionPlan.commandInfo.command,
|
|
736
|
+
compatibilityWorkaround,
|
|
474
737
|
subcommand: executionPlan.commandInfo.subcommand,
|
|
475
738
|
data: redactSensitiveValue(presentation.data),
|
|
476
739
|
error: plainTextInspection ? undefined : redactSensitiveValue(parsed.envelope?.error),
|
|
477
740
|
inspection: plainTextInspection || undefined,
|
|
478
741
|
navigationSummary: redactSensitiveValue(navigationSummary),
|
|
742
|
+
openResultTabCorrection: redactSensitiveValue(openResultTabCorrection),
|
|
479
743
|
effectiveArgs: redactedEffectiveArgs,
|
|
480
744
|
exitCode: processResult.exitCode,
|
|
481
745
|
fullOutputPath: presentation.fullOutputPath,
|
|
@@ -10,6 +10,7 @@ import { readFile, stat } from "node:fs/promises";
|
|
|
10
10
|
import { resolve } from "node:path";
|
|
11
11
|
|
|
12
12
|
import { parseCommandInfo, type CommandInfo } from "../runtime.js";
|
|
13
|
+
import { type PersistentSessionArtifactStore } from "../temp.js";
|
|
13
14
|
import { buildSnapshotPresentation, formatRawSnapshotText, formatSnapshotSummary } from "./snapshot.js";
|
|
14
15
|
import {
|
|
15
16
|
type AgentBrowserBatchResult,
|
|
@@ -106,6 +107,63 @@ function getScreenshotSummary(data: Record<string, unknown>): string | undefined
|
|
|
106
107
|
return typeof data.path === "string" ? `Saved image: ${data.path}` : undefined;
|
|
107
108
|
}
|
|
108
109
|
|
|
110
|
+
function getScalarExtractionResult(data: Record<string, unknown>): string | undefined {
|
|
111
|
+
const { result } = data;
|
|
112
|
+
if (typeof result === "string") {
|
|
113
|
+
return result.trim().length > 0 ? result : undefined;
|
|
114
|
+
}
|
|
115
|
+
if (typeof result === "number" || typeof result === "boolean") {
|
|
116
|
+
return String(result);
|
|
117
|
+
}
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function getExtractionOrigin(data: Record<string, unknown>): string | undefined {
|
|
122
|
+
if (typeof data.origin === "string" && data.origin.trim().length > 0) {
|
|
123
|
+
return data.origin.trim();
|
|
124
|
+
}
|
|
125
|
+
if (typeof data.url === "string" && data.url.trim().length > 0) {
|
|
126
|
+
return data.url.trim();
|
|
127
|
+
}
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function formatGetSummaryLabel(subcommand: string | undefined): string {
|
|
132
|
+
if (!subcommand) {
|
|
133
|
+
return "Get result";
|
|
134
|
+
}
|
|
135
|
+
if (subcommand.toLowerCase() === "url") {
|
|
136
|
+
return "URL";
|
|
137
|
+
}
|
|
138
|
+
return `${subcommand.slice(0, 1).toUpperCase()}${subcommand.slice(1)}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function formatExtractionSummary(commandInfo: CommandInfo, data: Record<string, unknown>): string | undefined {
|
|
142
|
+
const scalarResult = getScalarExtractionResult(data);
|
|
143
|
+
if (!scalarResult) {
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
if (commandInfo.command === "get") {
|
|
147
|
+
return `${formatGetSummaryLabel(commandInfo.subcommand)}: ${scalarResult.split("\n", 1)[0] ?? scalarResult}`;
|
|
148
|
+
}
|
|
149
|
+
if (commandInfo.command === "eval") {
|
|
150
|
+
return `Eval result: ${scalarResult.split("\n", 1)[0] ?? scalarResult}`;
|
|
151
|
+
}
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function formatExtractionText(commandInfo: CommandInfo, data: Record<string, unknown>): string | undefined {
|
|
156
|
+
if (commandInfo.command !== "get" && commandInfo.command !== "eval") {
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
const scalarResult = getScalarExtractionResult(data);
|
|
160
|
+
if (!scalarResult) {
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
const origin = getExtractionOrigin(data);
|
|
164
|
+
return origin && origin !== scalarResult ? `${scalarResult}\n\nOrigin: ${origin}` : scalarResult;
|
|
165
|
+
}
|
|
166
|
+
|
|
109
167
|
function isNavigationObservableCommand(command: string | undefined): boolean {
|
|
110
168
|
return command !== undefined && NAVIGATION_SUMMARY_COMMANDS.has(command);
|
|
111
169
|
}
|
|
@@ -207,8 +265,9 @@ async function buildBatchStepPresentation(options: {
|
|
|
207
265
|
cwd: string;
|
|
208
266
|
index: number;
|
|
209
267
|
item: AgentBrowserBatchResult;
|
|
268
|
+
persistentArtifactStore?: PersistentSessionArtifactStore;
|
|
210
269
|
}): Promise<{ details: BatchStepPresentationDetails; presentation: ToolPresentation }> {
|
|
211
|
-
const { cwd, index, item } = options;
|
|
270
|
+
const { cwd, index, item, persistentArtifactStore } = options;
|
|
212
271
|
const command = isStringArray(item.command) ? item.command : undefined;
|
|
213
272
|
const commandText = formatBatchStepCommand(command, index);
|
|
214
273
|
|
|
@@ -236,6 +295,7 @@ async function buildBatchStepPresentation(options: {
|
|
|
236
295
|
commandInfo: parseCommandInfo(command ?? []),
|
|
237
296
|
cwd,
|
|
238
297
|
envelope: { data: item.result, success: true },
|
|
298
|
+
persistentArtifactStore,
|
|
239
299
|
});
|
|
240
300
|
const fullOutputPaths = getPresentationPaths({
|
|
241
301
|
primaryPath: presentation.fullOutputPath,
|
|
@@ -268,12 +328,28 @@ async function buildBatchStepPresentation(options: {
|
|
|
268
328
|
async function buildBatchPresentation(options: {
|
|
269
329
|
cwd: string;
|
|
270
330
|
data: AgentBrowserBatchResult[];
|
|
331
|
+
persistentArtifactStore?: PersistentSessionArtifactStore;
|
|
271
332
|
summary: string;
|
|
272
333
|
}): Promise<ToolPresentation> {
|
|
273
|
-
const { cwd, data, summary } = options;
|
|
334
|
+
const { cwd, data, persistentArtifactStore, summary } = options;
|
|
274
335
|
const steps: Array<{ details: BatchStepPresentationDetails; presentation: ToolPresentation }> = [];
|
|
336
|
+
const protectedPersistentPaths: string[] = [];
|
|
275
337
|
for (const [index, item] of data.entries()) {
|
|
276
|
-
|
|
338
|
+
const step = await buildBatchStepPresentation({
|
|
339
|
+
cwd,
|
|
340
|
+
index,
|
|
341
|
+
item,
|
|
342
|
+
persistentArtifactStore: persistentArtifactStore
|
|
343
|
+
? { ...persistentArtifactStore, protectedPaths: protectedPersistentPaths }
|
|
344
|
+
: undefined,
|
|
345
|
+
});
|
|
346
|
+
steps.push(step);
|
|
347
|
+
protectedPersistentPaths.push(
|
|
348
|
+
...getPresentationPaths({
|
|
349
|
+
primaryPath: step.presentation.fullOutputPath,
|
|
350
|
+
secondaryPaths: step.presentation.fullOutputPaths,
|
|
351
|
+
}),
|
|
352
|
+
);
|
|
277
353
|
}
|
|
278
354
|
|
|
279
355
|
const batchFailure = getBatchFailureDetails(steps);
|
|
@@ -354,6 +430,10 @@ function formatSummary(commandInfo: CommandInfo, data: unknown): string {
|
|
|
354
430
|
if (commandInfo.command === "screenshot" && typeof data.path === "string") {
|
|
355
431
|
return `Screenshot saved: ${data.path}`;
|
|
356
432
|
}
|
|
433
|
+
const extractionSummary = formatExtractionSummary(commandInfo, data);
|
|
434
|
+
if (extractionSummary) {
|
|
435
|
+
return extractionSummary;
|
|
436
|
+
}
|
|
357
437
|
const pageSummary = getPageSummary(data);
|
|
358
438
|
if (pageSummary) {
|
|
359
439
|
return pageSummary.split("\n", 1)[0] ?? "agent-browser result";
|
|
@@ -404,6 +484,11 @@ function formatContentText(commandInfo: CommandInfo, data: unknown): string {
|
|
|
404
484
|
if (screenshotSummary) return screenshotSummary;
|
|
405
485
|
}
|
|
406
486
|
|
|
487
|
+
const extractionText = formatExtractionText(commandInfo, data);
|
|
488
|
+
if (extractionText) {
|
|
489
|
+
return extractionText;
|
|
490
|
+
}
|
|
491
|
+
|
|
407
492
|
const pageSummary = getPageSummary(data);
|
|
408
493
|
if (pageSummary) {
|
|
409
494
|
return pageSummary;
|
|
@@ -459,8 +544,9 @@ export async function buildToolPresentation(options: {
|
|
|
459
544
|
cwd: string;
|
|
460
545
|
envelope?: AgentBrowserEnvelope;
|
|
461
546
|
errorText?: string;
|
|
547
|
+
persistentArtifactStore?: PersistentSessionArtifactStore;
|
|
462
548
|
}): Promise<ToolPresentation> {
|
|
463
|
-
const { commandInfo, cwd, envelope, errorText } = options;
|
|
549
|
+
const { commandInfo, cwd, envelope, errorText, persistentArtifactStore } = options;
|
|
464
550
|
if (errorText) {
|
|
465
551
|
return {
|
|
466
552
|
content: [{ type: "text", text: errorText }],
|
|
@@ -472,9 +558,9 @@ export async function buildToolPresentation(options: {
|
|
|
472
558
|
const summary = formatSummary(commandInfo, data);
|
|
473
559
|
const presentation =
|
|
474
560
|
commandInfo.command === "batch" && Array.isArray(data)
|
|
475
|
-
? await buildBatchPresentation({ cwd, data: data as AgentBrowserBatchResult[], summary })
|
|
561
|
+
? await buildBatchPresentation({ cwd, data: data as AgentBrowserBatchResult[], persistentArtifactStore, summary })
|
|
476
562
|
: commandInfo.command === "snapshot" && isRecord(data)
|
|
477
|
-
? await buildSnapshotPresentation(data)
|
|
563
|
+
? await buildSnapshotPresentation(data, persistentArtifactStore)
|
|
478
564
|
: {
|
|
479
565
|
content: [{ type: "text" as const, text: formatContentText(commandInfo, data) }],
|
|
480
566
|
data,
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Invariants/Assumptions: Snapshot compaction should stay helpful even if upstream snapshot text formatting shifts, so structured parsing is best-effort and always has a resilient raw-outline fallback.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { writeSecureTempFile } from "../temp.js";
|
|
9
|
+
import { type PersistentSessionArtifactStore, writePersistentSessionArtifactFile, writeSecureTempFile } from "../temp.js";
|
|
10
10
|
import { type ToolPresentation, compareRefIds, countLines, isRecord, normalizeWhitespace, truncateText } from "./shared.js";
|
|
11
11
|
|
|
12
12
|
const SNAPSHOT_INLINE_MAX_CHARS = 6_000;
|
|
@@ -463,12 +463,18 @@ function canUseStructuredSnapshotPreview(snapshotLines: SnapshotLine[], refEntri
|
|
|
463
463
|
);
|
|
464
464
|
}
|
|
465
465
|
|
|
466
|
-
async function writeSnapshotSpillFile(
|
|
467
|
-
|
|
466
|
+
async function writeSnapshotSpillFile(
|
|
467
|
+
data: Record<string, unknown>,
|
|
468
|
+
persistentArtifactStore: PersistentSessionArtifactStore | undefined,
|
|
469
|
+
): Promise<string> {
|
|
470
|
+
const options = {
|
|
468
471
|
content: JSON.stringify(data, null, 2),
|
|
469
472
|
prefix: SNAPSHOT_SPILL_FILE_PREFIX,
|
|
470
473
|
suffix: ".json",
|
|
471
|
-
}
|
|
474
|
+
};
|
|
475
|
+
return persistentArtifactStore
|
|
476
|
+
? await writePersistentSessionArtifactFile({ ...options, store: persistentArtifactStore })
|
|
477
|
+
: await writeSecureTempFile(options);
|
|
472
478
|
}
|
|
473
479
|
|
|
474
480
|
export function formatSnapshotSummary(data: Record<string, unknown>): string {
|
|
@@ -487,7 +493,10 @@ export function formatRawSnapshotText(data: Record<string, unknown>): string {
|
|
|
487
493
|
return `Origin: ${origin}\nRefs: ${refs}\n\n${snapshot}`;
|
|
488
494
|
}
|
|
489
495
|
|
|
490
|
-
export async function buildSnapshotPresentation(
|
|
496
|
+
export async function buildSnapshotPresentation(
|
|
497
|
+
data: Record<string, unknown>,
|
|
498
|
+
persistentArtifactStore: PersistentSessionArtifactStore | undefined = undefined,
|
|
499
|
+
): Promise<ToolPresentation> {
|
|
491
500
|
const summary = formatSnapshotSummary(data);
|
|
492
501
|
const rawText = formatRawSnapshotText(data);
|
|
493
502
|
if (!shouldCompactSnapshot(rawText, data)) {
|
|
@@ -501,7 +510,7 @@ export async function buildSnapshotPresentation(data: Record<string, unknown>):
|
|
|
501
510
|
let fullOutputPath: string | undefined;
|
|
502
511
|
let spillErrorText: string | undefined;
|
|
503
512
|
try {
|
|
504
|
-
fullOutputPath = await writeSnapshotSpillFile(data);
|
|
513
|
+
fullOutputPath = await writeSnapshotSpillFile(data, persistentArtifactStore);
|
|
505
514
|
} catch (error) {
|
|
506
515
|
spillErrorText = error instanceof Error ? error.message : String(error);
|
|
507
516
|
}
|
|
@@ -10,6 +10,8 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
10
10
|
import { basename } from "node:path";
|
|
11
11
|
|
|
12
12
|
const STARTUP_SCOPED_FLAGS = ["--cdp", "--profile", "--session-name"] as const;
|
|
13
|
+
const OPEN_COMMANDS = new Set(["goto", "navigate", "open"]);
|
|
14
|
+
const OPENAI_HEADLESS_COMPAT_HOSTS = new Set(["chat.openai.com", "chatgpt.com"]);
|
|
13
15
|
const BRAVE_API_KEY_ENV = "BRAVE_API_KEY";
|
|
14
16
|
const AGENT_BROWSER_IDLE_TIMEOUT_ENV = "AGENT_BROWSER_IDLE_TIMEOUT_MS";
|
|
15
17
|
const IMPLICIT_SESSION_IDLE_TIMEOUT_ENV = "PI_AGENT_BROWSER_IMPLICIT_SESSION_IDLE_TIMEOUT_MS";
|
|
@@ -57,7 +59,21 @@ const GLOBAL_FLAGS_WITH_VALUES = new Set([
|
|
|
57
59
|
"--color-scheme",
|
|
58
60
|
"--device",
|
|
59
61
|
"--port",
|
|
62
|
+
"--args",
|
|
63
|
+
"--user-agent",
|
|
64
|
+
"--allowed-domains",
|
|
65
|
+
"--action-policy",
|
|
66
|
+
"--confirm-actions",
|
|
67
|
+
"--max-output",
|
|
68
|
+
"--model",
|
|
60
69
|
]);
|
|
70
|
+
const DEFAULT_HEADLESS_COMPAT_USER_AGENT_BY_PLATFORM: Partial<Record<NodeJS.Platform, string>> = {
|
|
71
|
+
darwin: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
|
|
72
|
+
linux: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
|
|
73
|
+
win32: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
|
|
74
|
+
};
|
|
75
|
+
const FALLBACK_HEADLESS_COMPAT_USER_AGENT =
|
|
76
|
+
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36";
|
|
61
77
|
const SHELL_OPERATOR_TOKENS = new Set(["&&", "||", "|", ";", ">", ">>", "<"]);
|
|
62
78
|
const MAX_PROJECT_SLUG_LENGTH = 24;
|
|
63
79
|
const SESSION_NAME_CWD_HASH_LENGTH = 8;
|
|
@@ -84,8 +100,20 @@ export interface InvalidValueFlagDetails {
|
|
|
84
100
|
receivedToken?: string;
|
|
85
101
|
}
|
|
86
102
|
|
|
103
|
+
export interface CompatibilityWorkaround {
|
|
104
|
+
id: "chatgpt-headless-user-agent";
|
|
105
|
+
reason: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface OpenResultTabCorrection {
|
|
109
|
+
selectedIndex: number;
|
|
110
|
+
targetTitle?: string;
|
|
111
|
+
targetUrl: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
87
114
|
export interface ExecutionPlan {
|
|
88
115
|
commandInfo: CommandInfo;
|
|
116
|
+
compatibilityWorkaround?: CompatibilityWorkaround;
|
|
89
117
|
effectiveArgs: string[];
|
|
90
118
|
invalidValueFlag?: InvalidValueFlagDetails;
|
|
91
119
|
managedSessionName?: string;
|
|
@@ -467,6 +495,96 @@ function hasFlagToken(args: string[], flag: string): boolean {
|
|
|
467
495
|
return args.some((token) => token === flag || token.startsWith(`${flag}=`));
|
|
468
496
|
}
|
|
469
497
|
|
|
498
|
+
function getFlagValue(args: string[], flag: string): string | undefined {
|
|
499
|
+
for (const [index, token] of args.entries()) {
|
|
500
|
+
if (token === flag) {
|
|
501
|
+
return args[index + 1];
|
|
502
|
+
}
|
|
503
|
+
if (token.startsWith(`${flag}=`)) {
|
|
504
|
+
return token.slice(flag.length + 1);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return undefined;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function isBooleanFlagEnabled(args: string[], flag: string): boolean {
|
|
511
|
+
for (const [index, token] of args.entries()) {
|
|
512
|
+
if (token === flag) {
|
|
513
|
+
const nextToken = args[index + 1]?.trim().toLowerCase();
|
|
514
|
+
if (nextToken === "false") {
|
|
515
|
+
return false;
|
|
516
|
+
}
|
|
517
|
+
return true;
|
|
518
|
+
}
|
|
519
|
+
if (token.startsWith(`${flag}=`)) {
|
|
520
|
+
return token.slice(flag.length + 1).trim().toLowerCase() !== "false";
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return false;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function normalizeComparableUrl(url: string): string | undefined {
|
|
527
|
+
const normalizedUrl = url.trim();
|
|
528
|
+
if (normalizedUrl.length === 0) {
|
|
529
|
+
return undefined;
|
|
530
|
+
}
|
|
531
|
+
try {
|
|
532
|
+
const parsedUrl = new URL(normalizedUrl);
|
|
533
|
+
parsedUrl.hash = "";
|
|
534
|
+
return parsedUrl.toString();
|
|
535
|
+
} catch {
|
|
536
|
+
return undefined;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function parseComparableNavigationUrl(url: string): URL | undefined {
|
|
541
|
+
try {
|
|
542
|
+
return new URL(url);
|
|
543
|
+
} catch {
|
|
544
|
+
try {
|
|
545
|
+
return new URL(`https://${url}`);
|
|
546
|
+
} catch {
|
|
547
|
+
return undefined;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function getDefaultHeadlessCompatUserAgent(platform: NodeJS.Platform = process.platform): string {
|
|
553
|
+
return DEFAULT_HEADLESS_COMPAT_USER_AGENT_BY_PLATFORM[platform] ?? FALLBACK_HEADLESS_COMPAT_USER_AGENT;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function getCompatibilityWorkaround(args: string[], commandInfo: CommandInfo): CompatibilityWorkaround | undefined {
|
|
557
|
+
if (!commandInfo.command || !OPEN_COMMANDS.has(commandInfo.command) || !commandInfo.subcommand) {
|
|
558
|
+
return undefined;
|
|
559
|
+
}
|
|
560
|
+
if (hasFlagToken(args, "--user-agent")) {
|
|
561
|
+
return undefined;
|
|
562
|
+
}
|
|
563
|
+
if (isBooleanFlagEnabled(args, "--headed")) {
|
|
564
|
+
return undefined;
|
|
565
|
+
}
|
|
566
|
+
if (hasFlagToken(args, "--cdp") || hasFlagToken(args, "--provider") || hasFlagToken(args, "-p") || hasFlagToken(args, "--auto-connect")) {
|
|
567
|
+
return undefined;
|
|
568
|
+
}
|
|
569
|
+
const engine = getFlagValue(args, "--engine");
|
|
570
|
+
if (engine && engine !== "chrome") {
|
|
571
|
+
return undefined;
|
|
572
|
+
}
|
|
573
|
+
const parsedTargetUrl = parseComparableNavigationUrl(commandInfo.subcommand);
|
|
574
|
+
if (!parsedTargetUrl || !["http:", "https:"].includes(parsedTargetUrl.protocol)) {
|
|
575
|
+
return undefined;
|
|
576
|
+
}
|
|
577
|
+
const hostname = parsedTargetUrl.hostname.toLowerCase();
|
|
578
|
+
if (!OPENAI_HEADLESS_COMPAT_HOSTS.has(hostname)) {
|
|
579
|
+
return undefined;
|
|
580
|
+
}
|
|
581
|
+
return {
|
|
582
|
+
id: "chatgpt-headless-user-agent",
|
|
583
|
+
reason:
|
|
584
|
+
"OpenAI web properties currently challenge the default headless Chrome user agent; inject a normal Chrome user agent to preserve the default headless workflow without requiring headed mode or auto-connect.",
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
470
588
|
export function extractExplicitSessionName(args: string[]): string | undefined {
|
|
471
589
|
for (const [index, token] of args.entries()) {
|
|
472
590
|
if (token === "--session") {
|
|
@@ -556,6 +674,7 @@ export function buildExecutionPlan(
|
|
|
556
674
|
const explicitSessionName = extractExplicitSessionName(args);
|
|
557
675
|
const shouldCreateFreshManagedSession =
|
|
558
676
|
!explicitSessionName && options.sessionMode === "fresh" && commandInfo.command !== undefined && commandInfo.command !== "close";
|
|
677
|
+
const compatibilityWorkaround = getCompatibilityWorkaround(args, commandInfo);
|
|
559
678
|
let managedSessionName: string | undefined;
|
|
560
679
|
let recoveryHint: SessionRecoveryHint | undefined;
|
|
561
680
|
let sessionName = explicitSessionName;
|
|
@@ -587,10 +706,14 @@ export function buildExecutionPlan(
|
|
|
587
706
|
sessionName = options.freshSessionName;
|
|
588
707
|
}
|
|
589
708
|
|
|
709
|
+
if (compatibilityWorkaround) {
|
|
710
|
+
effectiveArgs.push("--user-agent", getDefaultHeadlessCompatUserAgent());
|
|
711
|
+
}
|
|
590
712
|
effectiveArgs.push(...args);
|
|
591
713
|
|
|
592
714
|
return {
|
|
593
715
|
commandInfo,
|
|
716
|
+
compatibilityWorkaround,
|
|
594
717
|
effectiveArgs,
|
|
595
718
|
managedSessionName,
|
|
596
719
|
plainTextInspection,
|
|
@@ -602,6 +725,48 @@ export function buildExecutionPlan(
|
|
|
602
725
|
};
|
|
603
726
|
}
|
|
604
727
|
|
|
728
|
+
export function chooseOpenResultTabCorrection(options: {
|
|
729
|
+
activeTabIndex?: number;
|
|
730
|
+
tabs: Array<{ active?: boolean; index?: number; title?: string; url?: string }>;
|
|
731
|
+
targetTitle?: string;
|
|
732
|
+
targetUrl?: string;
|
|
733
|
+
}): OpenResultTabCorrection | undefined {
|
|
734
|
+
const normalizedTargetUrl =
|
|
735
|
+
typeof options.targetUrl === "string" ? normalizeComparableUrl(options.targetUrl) : undefined;
|
|
736
|
+
if (!normalizedTargetUrl) {
|
|
737
|
+
return undefined;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const tabsWithIndices = options.tabs.map((tab, index) => ({
|
|
741
|
+
...tab,
|
|
742
|
+
index: typeof tab.index === "number" ? tab.index : index,
|
|
743
|
+
}));
|
|
744
|
+
const activeTab =
|
|
745
|
+
tabsWithIndices.find((tab) => tab.active === true) ??
|
|
746
|
+
(typeof options.activeTabIndex === "number" ? tabsWithIndices.find((tab) => tab.index === options.activeTabIndex) : undefined);
|
|
747
|
+
if (activeTab && normalizeComparableUrl(activeTab.url ?? "") === normalizedTargetUrl) {
|
|
748
|
+
return undefined;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const matchingTabs = tabsWithIndices.filter((tab) => normalizeComparableUrl(tab.url ?? "") === normalizedTargetUrl);
|
|
752
|
+
if (matchingTabs.length === 0) {
|
|
753
|
+
return undefined;
|
|
754
|
+
}
|
|
755
|
+
const trimmedTargetTitle = typeof options.targetTitle === "string" ? options.targetTitle.trim() : "";
|
|
756
|
+
const titledMatch =
|
|
757
|
+
trimmedTargetTitle.length === 0
|
|
758
|
+
? undefined
|
|
759
|
+
: matchingTabs.find((tab) => typeof tab.title === "string" && tab.title.trim() === trimmedTargetTitle);
|
|
760
|
+
const selectedTab = titledMatch ?? matchingTabs[0];
|
|
761
|
+
return selectedTab.index === undefined
|
|
762
|
+
? undefined
|
|
763
|
+
: {
|
|
764
|
+
selectedIndex: selectedTab.index,
|
|
765
|
+
targetTitle: trimmedTargetTitle.length > 0 ? trimmedTargetTitle : undefined,
|
|
766
|
+
targetUrl: normalizedTargetUrl,
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
|
|
605
770
|
export function parseCommandInfo(args: string[]): CommandInfo {
|
|
606
771
|
const commands: string[] = [];
|
|
607
772
|
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Purpose: Create private temporary files for the pi-agent-browser extension without leaking artifacts broadly on disk.
|
|
3
|
-
* Responsibilities: Maintain a process-private temp root, stamp explicit ownership markers, enforce an aggregate temp-artifact disk budget, create securely permissioned temp files, prune explicitly owned stale temp roots from prior runs, and best-effort clean all owned roots on process exit.
|
|
4
|
-
* Scope:
|
|
2
|
+
* Purpose: Create private temporary and persisted spill files for the pi-agent-browser extension without leaking artifacts broadly on disk.
|
|
3
|
+
* Responsibilities: Maintain a process-private temp root, stamp explicit ownership markers, enforce an aggregate temp-artifact disk budget, create securely permissioned temp files, create session-scoped persisted spill files for resumable sessions, prune explicitly owned stale temp roots from prior runs, and best-effort clean all owned roots on process exit.
|
|
4
|
+
* Scope: Artifact lifecycle helpers only; callers decide what data to write and when to delete or retain long-lived references.
|
|
5
5
|
* Usage: Imported by result/process helpers when they need secure spill files instead of world-readable shared tmp paths.
|
|
6
|
-
* Invariants/Assumptions: Temp artifacts live under the OS temp directory, each active run uses a dedicated 0700 directory, files are created with exclusive 0600 permissions, and stale pruning only touches roots with an explicit pi-agent-browser ownership marker.
|
|
6
|
+
* Invariants/Assumptions: Temp artifacts live under the OS temp directory, each active run uses a dedicated 0700 directory, files are created with exclusive 0600 permissions, session-scoped persisted artifacts stay under the pi session directory, and stale pruning only touches roots with an explicit pi-agent-browser ownership marker.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { randomBytes } from "node:crypto";
|
|
10
10
|
import { rmSync } from "node:fs";
|
|
11
|
-
import { chmod, mkdtemp, open, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
11
|
+
import { chmod, mkdir, mkdtemp, open, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
12
12
|
import { tmpdir } from "node:os";
|
|
13
13
|
import { dirname, join } from "node:path";
|
|
14
14
|
|
|
@@ -19,6 +19,15 @@ const TEMP_ROOT_MARKER_VERSION = 1;
|
|
|
19
19
|
const STALE_TEMP_ROOT_MAX_AGE_MS = 24 * 60 * 60 * 1_000;
|
|
20
20
|
const TEMP_ROOT_MAX_BYTES_ENV = "PI_AGENT_BROWSER_TEMP_ROOT_MAX_BYTES";
|
|
21
21
|
const DEFAULT_TEMP_ROOT_MAX_BYTES = 32 * 1_024 * 1_024;
|
|
22
|
+
const SESSION_ARTIFACT_MAX_BYTES_ENV = "PI_AGENT_BROWSER_SESSION_ARTIFACT_MAX_BYTES";
|
|
23
|
+
const DEFAULT_SESSION_ARTIFACT_MAX_BYTES = 32 * 1_024 * 1_024;
|
|
24
|
+
const SESSION_ARTIFACTS_ROOT_DIR_NAME = ".pi-agent-browser-artifacts";
|
|
25
|
+
|
|
26
|
+
export interface PersistentSessionArtifactStore {
|
|
27
|
+
protectedPaths?: readonly string[];
|
|
28
|
+
sessionDir: string;
|
|
29
|
+
sessionId: string;
|
|
30
|
+
}
|
|
22
31
|
|
|
23
32
|
interface TempRootOwnershipRecord {
|
|
24
33
|
createdAtMs: number;
|
|
@@ -69,18 +78,23 @@ function enqueueTempMutation<T>(task: () => Promise<T>): Promise<T> {
|
|
|
69
78
|
return nextTask;
|
|
70
79
|
}
|
|
71
80
|
|
|
72
|
-
async function
|
|
73
|
-
const entries = await readdir(
|
|
74
|
-
|
|
81
|
+
async function listArtifactFiles(directory: string, excludedNames: ReadonlySet<string> = new Set()): Promise<Array<{ mtimeMs: number; path: string; size: number }>> {
|
|
82
|
+
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
|
|
83
|
+
const files: Array<{ mtimeMs: number; path: string; size: number }> = [];
|
|
75
84
|
for (const entry of entries) {
|
|
76
|
-
if (!entry.isFile() || entry.name
|
|
77
|
-
const path = join(
|
|
85
|
+
if (!entry.isFile() || excludedNames.has(entry.name)) continue;
|
|
86
|
+
const path = join(directory, entry.name);
|
|
78
87
|
const stats = await stat(path).catch(() => undefined);
|
|
79
88
|
if (stats?.isFile()) {
|
|
80
|
-
|
|
89
|
+
files.push({ mtimeMs: stats.mtimeMs, path, size: stats.size });
|
|
81
90
|
}
|
|
82
91
|
}
|
|
83
|
-
return
|
|
92
|
+
return files;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function getTempRootArtifactBytes(tempRoot: string): Promise<number> {
|
|
96
|
+
const files = await listArtifactFiles(tempRoot, new Set([TEMP_ROOT_MARKER_FILE_NAME]));
|
|
97
|
+
return files.reduce((totalBytes, file) => totalBytes + file.size, 0);
|
|
84
98
|
}
|
|
85
99
|
|
|
86
100
|
async function readTempRootOwnershipMarker(tempRoot: string): Promise<TempRootOwnershipRecord | undefined> {
|
|
@@ -153,6 +167,10 @@ export function getSecureTempRootMaxBytes(env: NodeJS.ProcessEnv = process.env):
|
|
|
153
167
|
return parsePositiveInteger(env[TEMP_ROOT_MAX_BYTES_ENV]) ?? DEFAULT_TEMP_ROOT_MAX_BYTES;
|
|
154
168
|
}
|
|
155
169
|
|
|
170
|
+
export function getPersistentSessionArtifactMaxBytes(env: NodeJS.ProcessEnv = process.env): number {
|
|
171
|
+
return parsePositiveInteger(env[SESSION_ARTIFACT_MAX_BYTES_ENV]) ?? DEFAULT_SESSION_ARTIFACT_MAX_BYTES;
|
|
172
|
+
}
|
|
173
|
+
|
|
156
174
|
async function assertSecureTempRootBudget(tempRoot: string, additionalBytes: number): Promise<void> {
|
|
157
175
|
if (additionalBytes <= 0) return;
|
|
158
176
|
const currentBytes = await getTempRootArtifactBytes(tempRoot);
|
|
@@ -173,6 +191,42 @@ export async function cleanupSecureTempArtifacts(): Promise<void> {
|
|
|
173
191
|
});
|
|
174
192
|
}
|
|
175
193
|
|
|
194
|
+
async function ensurePersistentSessionArtifactDir(store: PersistentSessionArtifactStore): Promise<string> {
|
|
195
|
+
const rootDir = join(store.sessionDir, SESSION_ARTIFACTS_ROOT_DIR_NAME);
|
|
196
|
+
const sessionDir = join(rootDir, store.sessionId);
|
|
197
|
+
await mkdir(rootDir, { recursive: true, mode: 0o700 });
|
|
198
|
+
await chmod(rootDir, 0o700).catch(() => undefined);
|
|
199
|
+
await mkdir(sessionDir, { recursive: true, mode: 0o700 });
|
|
200
|
+
await chmod(sessionDir, 0o700).catch(() => undefined);
|
|
201
|
+
return sessionDir;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function prunePersistentSessionArtifactsToBudget(
|
|
205
|
+
sessionArtifactDir: string,
|
|
206
|
+
additionalBytes: number,
|
|
207
|
+
protectedPaths: ReadonlySet<string>,
|
|
208
|
+
): Promise<void> {
|
|
209
|
+
if (additionalBytes <= 0) return;
|
|
210
|
+
const maxBytes = getPersistentSessionArtifactMaxBytes();
|
|
211
|
+
let files = await listArtifactFiles(sessionArtifactDir);
|
|
212
|
+
let totalBytes = files.reduce((total, file) => total + file.size, 0);
|
|
213
|
+
if (totalBytes + additionalBytes <= maxBytes) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
files = files.sort((left, right) => left.mtimeMs - right.mtimeMs || left.path.localeCompare(right.path));
|
|
217
|
+
for (const file of files) {
|
|
218
|
+
if (protectedPaths.has(file.path)) {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
await rm(file.path, { force: true }).catch(() => undefined);
|
|
222
|
+
totalBytes -= file.size;
|
|
223
|
+
if (totalBytes + additionalBytes <= maxBytes) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
throw new Error(`pi-agent-browser persisted spill budget exceeded (${totalBytes + additionalBytes} bytes > ${maxBytes} byte limit).`);
|
|
228
|
+
}
|
|
229
|
+
|
|
176
230
|
async function getSessionTempRoot(): Promise<string> {
|
|
177
231
|
if (!sessionTempRootPromise) {
|
|
178
232
|
sessionTempRootPromise = (async () => {
|
|
@@ -228,6 +282,34 @@ export async function writeSecureTempFile(options: {
|
|
|
228
282
|
return path;
|
|
229
283
|
}
|
|
230
284
|
|
|
285
|
+
export async function writePersistentSessionArtifactFile(options: {
|
|
286
|
+
content: string | Uint8Array;
|
|
287
|
+
prefix: string;
|
|
288
|
+
store: PersistentSessionArtifactStore;
|
|
289
|
+
suffix: string;
|
|
290
|
+
}): Promise<string> {
|
|
291
|
+
const { content, prefix, store, suffix } = options;
|
|
292
|
+
return await enqueueTempMutation(async () => {
|
|
293
|
+
const artifactDir = await ensurePersistentSessionArtifactDir(store);
|
|
294
|
+
await prunePersistentSessionArtifactsToBudget(
|
|
295
|
+
artifactDir,
|
|
296
|
+
getTempArtifactByteLength(content),
|
|
297
|
+
new Set((store.protectedPaths ?? []).filter((path) => dirname(path) === artifactDir)),
|
|
298
|
+
);
|
|
299
|
+
const path = join(artifactDir, `${prefix}-${randomBytes(8).toString("hex")}${suffix}`);
|
|
300
|
+
const fileHandle = await open(path, "wx", 0o600);
|
|
301
|
+
try {
|
|
302
|
+
await fileHandle.writeFile(content);
|
|
303
|
+
} catch (error) {
|
|
304
|
+
await rm(path, { force: true }).catch(() => undefined);
|
|
305
|
+
throw error;
|
|
306
|
+
} finally {
|
|
307
|
+
await fileHandle.close().catch(() => undefined);
|
|
308
|
+
}
|
|
309
|
+
return path;
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
231
313
|
export async function getSecureTempDebugState(): Promise<{ currentTempRoot?: string; ownedTempRoots: string[] }> {
|
|
232
314
|
return {
|
|
233
315
|
currentTempRoot: await sessionTempRootPromise?.catch(() => undefined),
|
package/package.json
CHANGED