pi-agent-browser-native 0.2.1 → 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 +20 -2
- package/README.md +9 -3
- package/docs/ARCHITECTURE.md +9 -5
- package/docs/RELEASE.md +11 -2
- package/docs/REQUIREMENTS.md +6 -2
- package/docs/TOOL_CONTRACT.md +22 -5
- package/extensions/agent-browser/index.ts +386 -88
- package/extensions/agent-browser/lib/process.ts +1 -1
- 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 +400 -7
- package/extensions/agent-browser/lib/temp.ts +94 -12
- package/package.json +1 -1
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Purpose: Build safe, deterministic agent-browser invocations for the pi-agent-browser extension.
|
|
3
|
-
* Responsibilities: Validate raw tool arguments, derive extension-managed session names from the pi session identity,
|
|
2
|
+
* Purpose: Build safe, deterministic agent-browser invocations and persisted session state for the pi-agent-browser extension.
|
|
3
|
+
* Responsibilities: Validate raw tool arguments, derive extension-managed session names from the pi session identity, restore managed-session state from persisted tool details, redact sensitive invocation text, classify browser-oriented prompts, and build the effective CLI argument list passed to the upstream agent-browser binary.
|
|
4
4
|
* Scope: Pure runtime-planning helpers only; no subprocess execution or filesystem access lives here.
|
|
5
5
|
* Usage: Imported by the extension entrypoint and unit tests before spawning the upstream CLI.
|
|
6
|
-
* Invariants/Assumptions: The wrapper stays thin, preserves upstream command vocabulary, and only injects `--json` plus an extension-managed `--session` when appropriate.
|
|
6
|
+
* Invariants/Assumptions: The wrapper stays thin, preserves upstream command vocabulary, keeps plain-text inspection stateless, and only injects `--json` plus an extension-managed `--session` when appropriate.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
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";
|
|
@@ -23,6 +25,17 @@ const LEGACY_BASH_ALLOW_PATTERNS = [
|
|
|
23
25
|
/\bagent-browser\s+--(?:help|version)\b/i,
|
|
24
26
|
/\bdebug(?:ging)?\b.*\b(?:agent[_ -]?browser|agent_browser|browser integration)\b/i,
|
|
25
27
|
];
|
|
28
|
+
const BROWSER_PROMPT_PATTERNS = [
|
|
29
|
+
/\b(?:agent[_ -]?browser|browser automation|eval\s+--stdin|screenshot|snapshot|tab\s+list)\b/i,
|
|
30
|
+
/\bbrowser\b.*\b(?:automation|click|fill|navigate|open|page|screenshot|site|snapshot|tab|url|visit|web(?:site| page)?)\b/i,
|
|
31
|
+
/\b(?:browse|click|fill|login|navigate|open|visit)\b.*\b(?:https?:\/\/\S+|page|site|tab|url|web(?:site| page)?)\b/i,
|
|
32
|
+
];
|
|
33
|
+
const INSPECTION_FLAGS = new Set(["--help", "-h", "--version", "-V"]);
|
|
34
|
+
const SENSITIVE_VALUE_FLAGS = new Set(["--headers", "--proxy"]);
|
|
35
|
+
const SENSITIVE_QUERY_PARAM_PATTERN =
|
|
36
|
+
/^(?:access(?:_|-)?token|api(?:_|-)?key|auth|authorization|bearer|client(?:_|-)?secret|code|cookie|id(?:_|-)?token|key|pass(?:word)?|refresh(?:_|-)?token|secret|session(?:_|-)?id|sig(?:nature)?|token)$/i;
|
|
37
|
+
const SENSITIVE_FIELD_NAME_PATTERN =
|
|
38
|
+
/^(?:access(?:_|-)?token|api(?:_|-)?key|auth(?:orization)?|bearer|client(?:_|-)?secret|cookie|id(?:_|-)?token|pass(?:word)?|proxy(?:_|-)?authorization|refresh(?:_|-)?token|secret|session(?:_|-)?id|set(?:_|-)?cookie|sig(?:nature)?|token|x(?:_|-)?api(?:_|-)?key)$/i;
|
|
26
39
|
|
|
27
40
|
const GLOBAL_FLAGS_WITH_VALUES = new Set([
|
|
28
41
|
"--session",
|
|
@@ -46,7 +59,21 @@ const GLOBAL_FLAGS_WITH_VALUES = new Set([
|
|
|
46
59
|
"--color-scheme",
|
|
47
60
|
"--device",
|
|
48
61
|
"--port",
|
|
62
|
+
"--args",
|
|
63
|
+
"--user-agent",
|
|
64
|
+
"--allowed-domains",
|
|
65
|
+
"--action-policy",
|
|
66
|
+
"--confirm-actions",
|
|
67
|
+
"--max-output",
|
|
68
|
+
"--model",
|
|
49
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";
|
|
50
77
|
const SHELL_OPERATOR_TOKENS = new Set(["&&", "||", "|", ";", ">", ">>", "<"]);
|
|
51
78
|
const MAX_PROJECT_SLUG_LENGTH = 24;
|
|
52
79
|
const SESSION_NAME_CWD_HASH_LENGTH = 8;
|
|
@@ -73,11 +100,24 @@ export interface InvalidValueFlagDetails {
|
|
|
73
100
|
receivedToken?: string;
|
|
74
101
|
}
|
|
75
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
|
+
|
|
76
114
|
export interface ExecutionPlan {
|
|
77
115
|
commandInfo: CommandInfo;
|
|
116
|
+
compatibilityWorkaround?: CompatibilityWorkaround;
|
|
78
117
|
effectiveArgs: string[];
|
|
79
118
|
invalidValueFlag?: InvalidValueFlagDetails;
|
|
80
119
|
managedSessionName?: string;
|
|
120
|
+
plainTextInspection: boolean;
|
|
81
121
|
recoveryHint?: SessionRecoveryHint;
|
|
82
122
|
sessionName?: string;
|
|
83
123
|
startupScopedFlags: string[];
|
|
@@ -91,10 +131,146 @@ export interface ManagedSessionState {
|
|
|
91
131
|
sessionName: string;
|
|
92
132
|
}
|
|
93
133
|
|
|
134
|
+
export interface RestoredManagedSessionState extends ManagedSessionState {
|
|
135
|
+
freshSessionOrdinal: number;
|
|
136
|
+
}
|
|
137
|
+
|
|
94
138
|
export interface PromptPolicy {
|
|
95
139
|
allowLegacyAgentBrowserBash: boolean;
|
|
96
140
|
}
|
|
97
141
|
|
|
142
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
143
|
+
return typeof value === "object" && value !== null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function isStringArray(value: unknown): value is string[] {
|
|
147
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function shouldRedactQueryParam(name: string): boolean {
|
|
151
|
+
return SENSITIVE_QUERY_PARAM_PATTERN.test(name);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function redactUrlToken(token: string): string {
|
|
155
|
+
let parsed: URL;
|
|
156
|
+
try {
|
|
157
|
+
parsed = new URL(token);
|
|
158
|
+
} catch {
|
|
159
|
+
return token;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (!["http:", "https:", "ws:", "wss:"].includes(parsed.protocol)) {
|
|
163
|
+
return token;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (parsed.username.length > 0) {
|
|
167
|
+
parsed.username = "[REDACTED]";
|
|
168
|
+
}
|
|
169
|
+
if (parsed.password.length > 0) {
|
|
170
|
+
parsed.password = "[REDACTED]";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
for (const [name] of parsed.searchParams) {
|
|
174
|
+
if (shouldRedactQueryParam(name)) {
|
|
175
|
+
parsed.searchParams.set(name, "[REDACTED]");
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const hashText = parsed.hash.startsWith("#") ? parsed.hash.slice(1) : parsed.hash;
|
|
180
|
+
if (hashText.includes("=")) {
|
|
181
|
+
const hashParams = new URLSearchParams(hashText);
|
|
182
|
+
let mutated = false;
|
|
183
|
+
for (const [name] of hashParams) {
|
|
184
|
+
if (shouldRedactQueryParam(name)) {
|
|
185
|
+
hashParams.set(name, "[REDACTED]");
|
|
186
|
+
mutated = true;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (mutated) {
|
|
190
|
+
parsed.hash = `#${hashParams.toString()}`;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return parsed.toString();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function redactLooseUrlMatches(text: string): string {
|
|
198
|
+
return text.replace(/\b(?:https?|wss?):\/\/[^\s"'`<>\])]+/g, (match) => redactUrlToken(match));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function redactSensitiveText(text: string): string {
|
|
202
|
+
return redactLooseUrlMatches(text)
|
|
203
|
+
.replace(/\b(Bearer)\s+[^\s",]+/gi, "$1 [REDACTED]")
|
|
204
|
+
.replace(/\b(Basic)\s+[^\s",]+/gi, "$1 [REDACTED]");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function redactSensitiveValue(value: unknown): unknown {
|
|
208
|
+
if (typeof value === "string") {
|
|
209
|
+
return redactSensitiveText(value);
|
|
210
|
+
}
|
|
211
|
+
if (Array.isArray(value)) {
|
|
212
|
+
return value.map((item) => redactSensitiveValue(item));
|
|
213
|
+
}
|
|
214
|
+
if (!isRecord(value)) {
|
|
215
|
+
return value;
|
|
216
|
+
}
|
|
217
|
+
return Object.fromEntries(
|
|
218
|
+
Object.entries(value).map(([key, entryValue]) => {
|
|
219
|
+
if (SENSITIVE_FIELD_NAME_PATTERN.test(key)) {
|
|
220
|
+
return [key, "[REDACTED]"];
|
|
221
|
+
}
|
|
222
|
+
return [key, redactSensitiveValue(entryValue)];
|
|
223
|
+
}),
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function redactFlagValue(flag: string, value: string): string {
|
|
228
|
+
if (SENSITIVE_VALUE_FLAGS.has(flag)) {
|
|
229
|
+
return "[REDACTED]";
|
|
230
|
+
}
|
|
231
|
+
return redactUrlToken(value);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function redactInvocationArgs(args: string[]): string[] {
|
|
235
|
+
const redacted: string[] = [];
|
|
236
|
+
let pendingValueFlag: string | undefined;
|
|
237
|
+
|
|
238
|
+
for (const token of args) {
|
|
239
|
+
if (pendingValueFlag) {
|
|
240
|
+
redacted.push(redactFlagValue(pendingValueFlag, token));
|
|
241
|
+
pendingValueFlag = undefined;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const normalizedToken = token.split("=", 1)[0] ?? token;
|
|
246
|
+
if (SENSITIVE_VALUE_FLAGS.has(normalizedToken)) {
|
|
247
|
+
if (token.includes("=")) {
|
|
248
|
+
redacted.push(`${normalizedToken}=[REDACTED]`);
|
|
249
|
+
} else {
|
|
250
|
+
redacted.push(token);
|
|
251
|
+
pendingValueFlag = normalizedToken;
|
|
252
|
+
}
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
redacted.push(redactUrlToken(token));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return redacted;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function shouldAppendBrowserSystemPrompt(prompt: string): boolean {
|
|
263
|
+
const normalizedPrompt = prompt.trim();
|
|
264
|
+
if (normalizedPrompt.length === 0) {
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
return BROWSER_PROMPT_PATTERNS.some((pattern) => pattern.test(normalizedPrompt));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function isPlainTextInspectionArgs(args: string[]): boolean {
|
|
271
|
+
return args.some((token) => INSPECTION_FLAGS.has(token));
|
|
272
|
+
}
|
|
273
|
+
|
|
98
274
|
export function hasUsableBraveApiKey(apiKey: string | null | undefined = process.env[BRAVE_API_KEY_ENV]): boolean {
|
|
99
275
|
return typeof apiKey === "string" && apiKey.trim().length > 0;
|
|
100
276
|
}
|
|
@@ -146,6 +322,74 @@ export function resolveManagedSessionState(options: {
|
|
|
146
322
|
};
|
|
147
323
|
}
|
|
148
324
|
|
|
325
|
+
function isRestorableManagedSessionName(sessionName: string, fallbackSessionName: string): boolean {
|
|
326
|
+
return sessionName === fallbackSessionName || sessionName.startsWith(`${fallbackSessionName}-fresh-`);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function restoreManagedSessionStateFromBranch(
|
|
330
|
+
branch: unknown[],
|
|
331
|
+
fallbackSessionName: string,
|
|
332
|
+
): RestoredManagedSessionState {
|
|
333
|
+
let restoredState: ManagedSessionState = {
|
|
334
|
+
active: false,
|
|
335
|
+
sessionName: fallbackSessionName,
|
|
336
|
+
};
|
|
337
|
+
let freshSessionOrdinal = 0;
|
|
338
|
+
|
|
339
|
+
for (const entry of branch) {
|
|
340
|
+
if (!isRecord(entry) || entry.type !== "message") {
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
const message = isRecord(entry.message) ? entry.message : undefined;
|
|
344
|
+
if (!message || message.toolName !== "agent_browser") {
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
const details = isRecord(message.details) ? message.details : undefined;
|
|
348
|
+
if (!details) {
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
const args = isStringArray(details.args) ? details.args : [];
|
|
352
|
+
if (isPlainTextInspectionArgs(args)) {
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const explicitSessionName = extractExplicitSessionName(args);
|
|
357
|
+
const sessionName = typeof details.sessionName === "string" ? details.sessionName : undefined;
|
|
358
|
+
const sessionMode = details.sessionMode === "fresh" || details.sessionMode === "auto" ? details.sessionMode : undefined;
|
|
359
|
+
const usedImplicitSession = details.usedImplicitSession === true;
|
|
360
|
+
const managedSessionName =
|
|
361
|
+
!explicitSessionName &&
|
|
362
|
+
sessionName &&
|
|
363
|
+
isRestorableManagedSessionName(sessionName, fallbackSessionName) &&
|
|
364
|
+
(usedImplicitSession || sessionMode === "fresh")
|
|
365
|
+
? sessionName
|
|
366
|
+
: undefined;
|
|
367
|
+
if (!managedSessionName) {
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const messageIsError = typeof message.isError === "boolean" ? message.isError : undefined;
|
|
372
|
+
const exitCode = typeof details.exitCode === "number" ? details.exitCode : undefined;
|
|
373
|
+
const succeeded = messageIsError === undefined ? exitCode === undefined || exitCode === 0 : !messageIsError;
|
|
374
|
+
const command = typeof details.command === "string" ? details.command : parseCommandInfo(args).command;
|
|
375
|
+
restoredState = resolveManagedSessionState({
|
|
376
|
+
command,
|
|
377
|
+
managedSessionName,
|
|
378
|
+
priorActive: restoredState.active,
|
|
379
|
+
priorSessionName: restoredState.sessionName,
|
|
380
|
+
succeeded,
|
|
381
|
+
});
|
|
382
|
+
if (succeeded && sessionMode === "fresh") {
|
|
383
|
+
freshSessionOrdinal += 1;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return {
|
|
388
|
+
...restoredState,
|
|
389
|
+
freshSessionOrdinal,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
149
393
|
export function createEphemeralSessionSeed(): string {
|
|
150
394
|
return randomUUID();
|
|
151
395
|
}
|
|
@@ -251,6 +495,96 @@ function hasFlagToken(args: string[], flag: string): boolean {
|
|
|
251
495
|
return args.some((token) => token === flag || token.startsWith(`${flag}=`));
|
|
252
496
|
}
|
|
253
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
|
+
|
|
254
588
|
export function extractExplicitSessionName(args: string[]): string | undefined {
|
|
255
589
|
for (const [index, token] of args.entries()) {
|
|
256
590
|
if (token === "--session") {
|
|
@@ -310,24 +644,37 @@ export function buildExecutionPlan(
|
|
|
310
644
|
sessionMode: SessionMode;
|
|
311
645
|
},
|
|
312
646
|
): ExecutionPlan {
|
|
313
|
-
const effectiveArgs = args.includes("--json") ? [] : ["--json"];
|
|
314
647
|
const invalidValueFlag = getInvalidValueFlagDetails(args);
|
|
648
|
+
const startupScopedFlags = getStartupScopedFlags(args);
|
|
649
|
+
const plainTextInspection = isPlainTextInspectionArgs(args);
|
|
650
|
+
const commandInfo = parseCommandInfo(args);
|
|
651
|
+
const effectiveArgs = plainTextInspection ? [...args] : args.includes("--json") ? [] : ["--json"];
|
|
315
652
|
if (invalidValueFlag) {
|
|
316
653
|
return {
|
|
317
654
|
commandInfo: {},
|
|
318
655
|
effectiveArgs,
|
|
319
656
|
invalidValueFlag,
|
|
657
|
+
plainTextInspection: false,
|
|
320
658
|
startupScopedFlags: [],
|
|
321
659
|
usedImplicitSession: false,
|
|
322
660
|
validationError: formatInvalidValueFlagError(invalidValueFlag),
|
|
323
661
|
};
|
|
324
662
|
}
|
|
325
663
|
|
|
326
|
-
|
|
664
|
+
if (plainTextInspection) {
|
|
665
|
+
return {
|
|
666
|
+
commandInfo,
|
|
667
|
+
effectiveArgs,
|
|
668
|
+
plainTextInspection,
|
|
669
|
+
startupScopedFlags,
|
|
670
|
+
usedImplicitSession: false,
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
|
|
327
674
|
const explicitSessionName = extractExplicitSessionName(args);
|
|
328
|
-
const startupScopedFlags = getStartupScopedFlags(args);
|
|
329
675
|
const shouldCreateFreshManagedSession =
|
|
330
676
|
!explicitSessionName && options.sessionMode === "fresh" && commandInfo.command !== undefined && commandInfo.command !== "close";
|
|
677
|
+
const compatibilityWorkaround = getCompatibilityWorkaround(args, commandInfo);
|
|
331
678
|
let managedSessionName: string | undefined;
|
|
332
679
|
let recoveryHint: SessionRecoveryHint | undefined;
|
|
333
680
|
let sessionName = explicitSessionName;
|
|
@@ -359,12 +706,17 @@ export function buildExecutionPlan(
|
|
|
359
706
|
sessionName = options.freshSessionName;
|
|
360
707
|
}
|
|
361
708
|
|
|
709
|
+
if (compatibilityWorkaround) {
|
|
710
|
+
effectiveArgs.push("--user-agent", getDefaultHeadlessCompatUserAgent());
|
|
711
|
+
}
|
|
362
712
|
effectiveArgs.push(...args);
|
|
363
713
|
|
|
364
714
|
return {
|
|
365
715
|
commandInfo,
|
|
716
|
+
compatibilityWorkaround,
|
|
366
717
|
effectiveArgs,
|
|
367
718
|
managedSessionName,
|
|
719
|
+
plainTextInspection,
|
|
368
720
|
recoveryHint,
|
|
369
721
|
sessionName,
|
|
370
722
|
startupScopedFlags,
|
|
@@ -373,6 +725,48 @@ export function buildExecutionPlan(
|
|
|
373
725
|
};
|
|
374
726
|
}
|
|
375
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
|
+
|
|
376
770
|
export function parseCommandInfo(args: string[]): CommandInfo {
|
|
377
771
|
const commands: string[] = [];
|
|
378
772
|
|
|
@@ -396,4 +790,3 @@ export function parseCommandInfo(args: string[]): CommandInfo {
|
|
|
396
790
|
|
|
397
791
|
return { command: commands[0], subcommand: commands[1] };
|
|
398
792
|
}
|
|
399
|
-
|
|
@@ -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