pi-agent-browser-native 0.2.0 → 0.2.2

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.
@@ -1,9 +1,9 @@
1
1
  /**
2
- * Purpose: Build safe, deterministic agent-browser invocations for the pi-agent-browser extension.
3
- * Responsibilities: Validate raw tool arguments, derive implicit session names from the pi session identity, resolve implicit-session timeout/state helpers, detect explicit session usage, and build the effective CLI argument list passed to the upstream agent-browser binary.
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 implicit `--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";
@@ -23,6 +23,17 @@ const LEGACY_BASH_ALLOW_PATTERNS = [
23
23
  /\bagent-browser\s+--(?:help|version)\b/i,
24
24
  /\bdebug(?:ging)?\b.*\b(?:agent[_ -]?browser|agent_browser|browser integration)\b/i,
25
25
  ];
26
+ const BROWSER_PROMPT_PATTERNS = [
27
+ /\b(?:agent[_ -]?browser|browser automation|eval\s+--stdin|screenshot|snapshot|tab\s+list)\b/i,
28
+ /\bbrowser\b.*\b(?:automation|click|fill|navigate|open|page|screenshot|site|snapshot|tab|url|visit|web(?:site| page)?)\b/i,
29
+ /\b(?:browse|click|fill|login|navigate|open|visit)\b.*\b(?:https?:\/\/\S+|page|site|tab|url|web(?:site| page)?)\b/i,
30
+ ];
31
+ const INSPECTION_FLAGS = new Set(["--help", "-h", "--version", "-V"]);
32
+ const SENSITIVE_VALUE_FLAGS = new Set(["--headers", "--proxy"]);
33
+ const SENSITIVE_QUERY_PARAM_PATTERN =
34
+ /^(?: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;
35
+ const SENSITIVE_FIELD_NAME_PATTERN =
36
+ /^(?: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
37
 
27
38
  const GLOBAL_FLAGS_WITH_VALUES = new Set([
28
39
  "--session",
@@ -49,6 +60,8 @@ const GLOBAL_FLAGS_WITH_VALUES = new Set([
49
60
  ]);
50
61
  const SHELL_OPERATOR_TOKENS = new Set(["&&", "||", "|", ";", ">", ">>", "<"]);
51
62
  const MAX_PROJECT_SLUG_LENGTH = 24;
63
+ const SESSION_NAME_CWD_HASH_LENGTH = 8;
64
+ const SESSION_NAME_SESSION_ID_LENGTH = 12;
52
65
 
53
66
  export interface CommandInfo {
54
67
  command?: string;
@@ -64,9 +77,19 @@ export interface SessionRecoveryHint {
64
77
  recommendedSessionMode: "fresh";
65
78
  }
66
79
 
80
+ export interface InvalidValueFlagDetails {
81
+ flag: string;
82
+ index: number;
83
+ reason: "missing-value" | "unexpected-flag";
84
+ receivedToken?: string;
85
+ }
86
+
67
87
  export interface ExecutionPlan {
68
88
  commandInfo: CommandInfo;
69
89
  effectiveArgs: string[];
90
+ invalidValueFlag?: InvalidValueFlagDetails;
91
+ managedSessionName?: string;
92
+ plainTextInspection: boolean;
70
93
  recoveryHint?: SessionRecoveryHint;
71
94
  sessionName?: string;
72
95
  startupScopedFlags: string[];
@@ -74,10 +97,152 @@ export interface ExecutionPlan {
74
97
  validationError?: string;
75
98
  }
76
99
 
100
+ export interface ManagedSessionState {
101
+ active: boolean;
102
+ replacedSessionName?: string;
103
+ sessionName: string;
104
+ }
105
+
106
+ export interface RestoredManagedSessionState extends ManagedSessionState {
107
+ freshSessionOrdinal: number;
108
+ }
109
+
77
110
  export interface PromptPolicy {
78
111
  allowLegacyAgentBrowserBash: boolean;
79
112
  }
80
113
 
114
+ function isRecord(value: unknown): value is Record<string, unknown> {
115
+ return typeof value === "object" && value !== null;
116
+ }
117
+
118
+ function isStringArray(value: unknown): value is string[] {
119
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
120
+ }
121
+
122
+ function shouldRedactQueryParam(name: string): boolean {
123
+ return SENSITIVE_QUERY_PARAM_PATTERN.test(name);
124
+ }
125
+
126
+ function redactUrlToken(token: string): string {
127
+ let parsed: URL;
128
+ try {
129
+ parsed = new URL(token);
130
+ } catch {
131
+ return token;
132
+ }
133
+
134
+ if (!["http:", "https:", "ws:", "wss:"].includes(parsed.protocol)) {
135
+ return token;
136
+ }
137
+
138
+ if (parsed.username.length > 0) {
139
+ parsed.username = "[REDACTED]";
140
+ }
141
+ if (parsed.password.length > 0) {
142
+ parsed.password = "[REDACTED]";
143
+ }
144
+
145
+ for (const [name] of parsed.searchParams) {
146
+ if (shouldRedactQueryParam(name)) {
147
+ parsed.searchParams.set(name, "[REDACTED]");
148
+ }
149
+ }
150
+
151
+ const hashText = parsed.hash.startsWith("#") ? parsed.hash.slice(1) : parsed.hash;
152
+ if (hashText.includes("=")) {
153
+ const hashParams = new URLSearchParams(hashText);
154
+ let mutated = false;
155
+ for (const [name] of hashParams) {
156
+ if (shouldRedactQueryParam(name)) {
157
+ hashParams.set(name, "[REDACTED]");
158
+ mutated = true;
159
+ }
160
+ }
161
+ if (mutated) {
162
+ parsed.hash = `#${hashParams.toString()}`;
163
+ }
164
+ }
165
+
166
+ return parsed.toString();
167
+ }
168
+
169
+ function redactLooseUrlMatches(text: string): string {
170
+ return text.replace(/\b(?:https?|wss?):\/\/[^\s"'`<>\])]+/g, (match) => redactUrlToken(match));
171
+ }
172
+
173
+ export function redactSensitiveText(text: string): string {
174
+ return redactLooseUrlMatches(text)
175
+ .replace(/\b(Bearer)\s+[^\s",]+/gi, "$1 [REDACTED]")
176
+ .replace(/\b(Basic)\s+[^\s",]+/gi, "$1 [REDACTED]");
177
+ }
178
+
179
+ export function redactSensitiveValue(value: unknown): unknown {
180
+ if (typeof value === "string") {
181
+ return redactSensitiveText(value);
182
+ }
183
+ if (Array.isArray(value)) {
184
+ return value.map((item) => redactSensitiveValue(item));
185
+ }
186
+ if (!isRecord(value)) {
187
+ return value;
188
+ }
189
+ return Object.fromEntries(
190
+ Object.entries(value).map(([key, entryValue]) => {
191
+ if (SENSITIVE_FIELD_NAME_PATTERN.test(key)) {
192
+ return [key, "[REDACTED]"];
193
+ }
194
+ return [key, redactSensitiveValue(entryValue)];
195
+ }),
196
+ );
197
+ }
198
+
199
+ function redactFlagValue(flag: string, value: string): string {
200
+ if (SENSITIVE_VALUE_FLAGS.has(flag)) {
201
+ return "[REDACTED]";
202
+ }
203
+ return redactUrlToken(value);
204
+ }
205
+
206
+ export function redactInvocationArgs(args: string[]): string[] {
207
+ const redacted: string[] = [];
208
+ let pendingValueFlag: string | undefined;
209
+
210
+ for (const token of args) {
211
+ if (pendingValueFlag) {
212
+ redacted.push(redactFlagValue(pendingValueFlag, token));
213
+ pendingValueFlag = undefined;
214
+ continue;
215
+ }
216
+
217
+ const normalizedToken = token.split("=", 1)[0] ?? token;
218
+ if (SENSITIVE_VALUE_FLAGS.has(normalizedToken)) {
219
+ if (token.includes("=")) {
220
+ redacted.push(`${normalizedToken}=[REDACTED]`);
221
+ } else {
222
+ redacted.push(token);
223
+ pendingValueFlag = normalizedToken;
224
+ }
225
+ continue;
226
+ }
227
+
228
+ redacted.push(redactUrlToken(token));
229
+ }
230
+
231
+ return redacted;
232
+ }
233
+
234
+ export function shouldAppendBrowserSystemPrompt(prompt: string): boolean {
235
+ const normalizedPrompt = prompt.trim();
236
+ if (normalizedPrompt.length === 0) {
237
+ return false;
238
+ }
239
+ return BROWSER_PROMPT_PATTERNS.some((pattern) => pattern.test(normalizedPrompt));
240
+ }
241
+
242
+ export function isPlainTextInspectionArgs(args: string[]): boolean {
243
+ return args.some((token) => INSPECTION_FLAGS.has(token));
244
+ }
245
+
81
246
  export function hasUsableBraveApiKey(apiKey: string | null | undefined = process.env[BRAVE_API_KEY_ENV]): boolean {
82
247
  return typeof apiKey === "string" && apiKey.trim().length > 0;
83
248
  }
@@ -105,25 +270,106 @@ export function getImplicitSessionCloseTimeoutMs(env: NodeJS.ProcessEnv = proces
105
270
  return parseTimeoutMs(env[IMPLICIT_SESSION_CLOSE_TIMEOUT_ENV], 0) ?? DEFAULT_IMPLICIT_SESSION_CLOSE_TIMEOUT_MS;
106
271
  }
107
272
 
108
- export function resolveImplicitSessionActiveState(options: {
273
+ export function resolveManagedSessionState(options: {
109
274
  command?: string;
275
+ managedSessionName?: string;
110
276
  priorActive: boolean;
277
+ priorSessionName: string;
111
278
  succeeded: boolean;
112
- usedImplicitSession: boolean;
113
- }): boolean {
114
- const { command, priorActive, succeeded, usedImplicitSession } = options;
115
- if (!usedImplicitSession) return priorActive;
116
- if (command === "close") {
117
- return succeeded ? false : priorActive;
279
+ }): ManagedSessionState {
280
+ const { command, managedSessionName, priorActive, priorSessionName, succeeded } = options;
281
+ if (!managedSessionName) {
282
+ return { active: priorActive, sessionName: priorSessionName };
283
+ }
284
+ if (command === "close" && managedSessionName === priorSessionName) {
285
+ return { active: succeeded ? false : priorActive, sessionName: priorSessionName };
286
+ }
287
+ if (!succeeded) {
288
+ return { active: priorActive, sessionName: priorSessionName };
289
+ }
290
+ return {
291
+ active: true,
292
+ replacedSessionName: priorActive && priorSessionName !== managedSessionName ? priorSessionName : undefined,
293
+ sessionName: managedSessionName,
294
+ };
295
+ }
296
+
297
+ function isRestorableManagedSessionName(sessionName: string, fallbackSessionName: string): boolean {
298
+ return sessionName === fallbackSessionName || sessionName.startsWith(`${fallbackSessionName}-fresh-`);
299
+ }
300
+
301
+ export function restoreManagedSessionStateFromBranch(
302
+ branch: unknown[],
303
+ fallbackSessionName: string,
304
+ ): RestoredManagedSessionState {
305
+ let restoredState: ManagedSessionState = {
306
+ active: false,
307
+ sessionName: fallbackSessionName,
308
+ };
309
+ let freshSessionOrdinal = 0;
310
+
311
+ for (const entry of branch) {
312
+ if (!isRecord(entry) || entry.type !== "message") {
313
+ continue;
314
+ }
315
+ const message = isRecord(entry.message) ? entry.message : undefined;
316
+ if (!message || message.toolName !== "agent_browser") {
317
+ continue;
318
+ }
319
+ const details = isRecord(message.details) ? message.details : undefined;
320
+ if (!details) {
321
+ continue;
322
+ }
323
+ const args = isStringArray(details.args) ? details.args : [];
324
+ if (isPlainTextInspectionArgs(args)) {
325
+ continue;
326
+ }
327
+
328
+ const explicitSessionName = extractExplicitSessionName(args);
329
+ const sessionName = typeof details.sessionName === "string" ? details.sessionName : undefined;
330
+ const sessionMode = details.sessionMode === "fresh" || details.sessionMode === "auto" ? details.sessionMode : undefined;
331
+ const usedImplicitSession = details.usedImplicitSession === true;
332
+ const managedSessionName =
333
+ !explicitSessionName &&
334
+ sessionName &&
335
+ isRestorableManagedSessionName(sessionName, fallbackSessionName) &&
336
+ (usedImplicitSession || sessionMode === "fresh")
337
+ ? sessionName
338
+ : undefined;
339
+ if (!managedSessionName) {
340
+ continue;
341
+ }
342
+
343
+ const messageIsError = typeof message.isError === "boolean" ? message.isError : undefined;
344
+ const exitCode = typeof details.exitCode === "number" ? details.exitCode : undefined;
345
+ const succeeded = messageIsError === undefined ? exitCode === undefined || exitCode === 0 : !messageIsError;
346
+ const command = typeof details.command === "string" ? details.command : parseCommandInfo(args).command;
347
+ restoredState = resolveManagedSessionState({
348
+ command,
349
+ managedSessionName,
350
+ priorActive: restoredState.active,
351
+ priorSessionName: restoredState.sessionName,
352
+ succeeded,
353
+ });
354
+ if (succeeded && sessionMode === "fresh") {
355
+ freshSessionOrdinal += 1;
356
+ }
118
357
  }
119
- if (!command) return priorActive;
120
- return priorActive || succeeded;
358
+
359
+ return {
360
+ ...restoredState,
361
+ freshSessionOrdinal,
362
+ };
121
363
  }
122
364
 
123
365
  export function createEphemeralSessionSeed(): string {
124
366
  return randomUUID();
125
367
  }
126
368
 
369
+ function createCwdHash(cwd: string): string {
370
+ return createHash("sha256").update(`cwd:${cwd}`).digest("hex").slice(0, SESSION_NAME_CWD_HASH_LENGTH);
371
+ }
372
+
127
373
  export function createImplicitSessionName(
128
374
  sessionId: string | undefined,
129
375
  cwd: string,
@@ -135,13 +381,25 @@ export function createImplicitSessionName(
135
381
  .replace(/[^a-z0-9]+/g, "-")
136
382
  .replace(/^-+|-+$/g, "")
137
383
  .slice(0, MAX_PROJECT_SLUG_LENGTH) || "project";
138
- const stableSessionId = sessionId?.replace(/-/g, "").slice(0, 12);
384
+ const cwdHash = createCwdHash(cwd);
385
+ const stableSessionId = sessionId?.replace(/-/g, "").slice(0, SESSION_NAME_SESSION_ID_LENGTH);
139
386
  if (stableSessionId && stableSessionId.length > 0) {
140
- return `piab-${slug}-${stableSessionId}`;
387
+ return `piab-${slug}-${stableSessionId}-${cwdHash}`;
141
388
  }
142
389
 
143
- const digest = createHash("sha256").update(`ephemeral:${cwd}:${ephemeralSeed}`).digest("hex").slice(0, 12);
144
- return `piab-${slug}-${digest}`;
390
+ const digest = createHash("sha256")
391
+ .update(`ephemeral:${cwd}:${ephemeralSeed}`)
392
+ .digest("hex")
393
+ .slice(0, SESSION_NAME_SESSION_ID_LENGTH);
394
+ return `piab-${slug}-${digest}-${cwdHash}`;
395
+ }
396
+
397
+ export function createFreshSessionName(baseSessionName: string, ephemeralSeed: string, ordinal: number): string {
398
+ const suffix = createHash("sha256")
399
+ .update(`fresh:${baseSessionName}:${ephemeralSeed}:${ordinal}`)
400
+ .digest("hex")
401
+ .slice(0, 10);
402
+ return `${baseSessionName}-fresh-${suffix}`;
145
403
  }
146
404
 
147
405
  export function validateToolArgs(args: string[]): string | undefined {
@@ -157,6 +415,54 @@ export function validateToolArgs(args: string[]): string | undefined {
157
415
  return undefined;
158
416
  }
159
417
 
418
+ function getInvalidValueFlagDetails(args: string[]): InvalidValueFlagDetails | undefined {
419
+ for (const [index, token] of args.entries()) {
420
+ if (!token.startsWith("-")) {
421
+ continue;
422
+ }
423
+ const normalizedToken = token.split("=", 1)[0] ?? token;
424
+ if (!GLOBAL_FLAGS_WITH_VALUES.has(normalizedToken)) {
425
+ continue;
426
+ }
427
+ if (token.includes("=")) {
428
+ const value = token.slice(token.indexOf("=") + 1).trim();
429
+ if (value.length === 0) {
430
+ return {
431
+ flag: normalizedToken,
432
+ index,
433
+ reason: "missing-value",
434
+ };
435
+ }
436
+ continue;
437
+ }
438
+ const receivedToken = args[index + 1];
439
+ if (receivedToken === undefined) {
440
+ return {
441
+ flag: normalizedToken,
442
+ index,
443
+ reason: "missing-value",
444
+ };
445
+ }
446
+ if (receivedToken.startsWith("-")) {
447
+ return {
448
+ flag: normalizedToken,
449
+ index,
450
+ reason: "unexpected-flag",
451
+ receivedToken,
452
+ };
453
+ }
454
+ continue;
455
+ }
456
+ return undefined;
457
+ }
458
+
459
+ function formatInvalidValueFlagError(details: InvalidValueFlagDetails): string {
460
+ if (details.reason === "unexpected-flag" && details.receivedToken) {
461
+ return `Flag \`${details.flag}\` requires a value, but received \`${details.receivedToken}\` instead. Pass a non-flag value immediately after \`${details.flag}\`.`;
462
+ }
463
+ return `Flag \`${details.flag}\` requires a value immediately after it. Pass a non-flag token like \`${details.flag} demo\`.`;
464
+ }
465
+
160
466
  function hasFlagToken(args: string[], flag: string): boolean {
161
467
  return args.some((token) => token === flag || token.startsWith(`${flag}=`));
162
468
  }
@@ -213,35 +519,72 @@ export function getLatestUserPrompt(branch: unknown[]): string {
213
519
 
214
520
  export function buildExecutionPlan(
215
521
  args: string[],
216
- options: { implicitSessionActive: boolean; implicitSessionName: string; sessionMode: SessionMode },
522
+ options: {
523
+ freshSessionName: string;
524
+ managedSessionActive: boolean;
525
+ managedSessionName: string;
526
+ sessionMode: SessionMode;
527
+ },
217
528
  ): ExecutionPlan {
529
+ const invalidValueFlag = getInvalidValueFlagDetails(args);
530
+ const startupScopedFlags = getStartupScopedFlags(args);
531
+ const plainTextInspection = isPlainTextInspectionArgs(args);
218
532
  const commandInfo = parseCommandInfo(args);
533
+ const effectiveArgs = plainTextInspection ? [...args] : args.includes("--json") ? [] : ["--json"];
534
+ if (invalidValueFlag) {
535
+ return {
536
+ commandInfo: {},
537
+ effectiveArgs,
538
+ invalidValueFlag,
539
+ plainTextInspection: false,
540
+ startupScopedFlags: [],
541
+ usedImplicitSession: false,
542
+ validationError: formatInvalidValueFlagError(invalidValueFlag),
543
+ };
544
+ }
545
+
546
+ if (plainTextInspection) {
547
+ return {
548
+ commandInfo,
549
+ effectiveArgs,
550
+ plainTextInspection,
551
+ startupScopedFlags,
552
+ usedImplicitSession: false,
553
+ };
554
+ }
555
+
219
556
  const explicitSessionName = extractExplicitSessionName(args);
220
- const startupScopedFlags = getStartupScopedFlags(args);
221
- const effectiveArgs = args.includes("--json") ? [] : ["--json"];
557
+ const shouldCreateFreshManagedSession =
558
+ !explicitSessionName && options.sessionMode === "fresh" && commandInfo.command !== undefined && commandInfo.command !== "close";
559
+ let managedSessionName: string | undefined;
222
560
  let recoveryHint: SessionRecoveryHint | undefined;
223
561
  let sessionName = explicitSessionName;
224
562
  let usedImplicitSession = false;
225
563
  let validationError: string | undefined;
226
564
 
227
565
  if (!explicitSessionName && options.sessionMode === "auto") {
228
- if (options.implicitSessionActive && startupScopedFlags.length > 0) {
566
+ if (options.managedSessionActive && startupScopedFlags.length > 0) {
229
567
  recoveryHint = {
230
568
  exampleArgs: args,
231
569
  exampleParams: { args, sessionMode: "fresh" },
232
570
  reason:
233
- "Startup-scoped flags like --profile, --session-name, and --cdp need a fresh upstream launch once the implicit session is already active.",
571
+ "Startup-scoped flags like --profile, --session-name, and --cdp need a fresh upstream launch once the extension-managed session is already active.",
234
572
  recommendedSessionMode: "fresh",
235
573
  };
236
574
  validationError = [
237
- `The current implicit agent-browser session is already running, so startup-scoped flags ${startupScopedFlags.join(", ")} would be ignored by upstream agent-browser.`,
575
+ `The current extension-managed agent-browser session is already running, so startup-scoped flags ${startupScopedFlags.join(", ")} would be ignored by upstream agent-browser.`,
238
576
  "Retry this call with `sessionMode: \"fresh\"` to force a fresh upstream launch, or pass an explicit `--session ...` if you want to name the new session yourself.",
239
577
  ].join(" ");
240
578
  } else {
241
- effectiveArgs.push("--session", options.implicitSessionName);
242
- sessionName = options.implicitSessionName;
579
+ effectiveArgs.push("--session", options.managedSessionName);
580
+ managedSessionName = options.managedSessionName;
581
+ sessionName = options.managedSessionName;
243
582
  usedImplicitSession = true;
244
583
  }
584
+ } else if (shouldCreateFreshManagedSession) {
585
+ effectiveArgs.push("--session", options.freshSessionName);
586
+ managedSessionName = options.freshSessionName;
587
+ sessionName = options.freshSessionName;
245
588
  }
246
589
 
247
590
  effectiveArgs.push(...args);
@@ -249,6 +592,8 @@ export function buildExecutionPlan(
249
592
  return {
250
593
  commandInfo,
251
594
  effectiveArgs,
595
+ managedSessionName,
596
+ plainTextInspection,
252
597
  recoveryHint,
253
598
  sessionName,
254
599
  startupScopedFlags,
@@ -280,4 +625,3 @@ export function parseCommandInfo(args: string[]): CommandInfo {
280
625
 
281
626
  return { command: commands[0], subcommand: commands[1] };
282
627
  }
283
-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-agent-browser-native",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "pi extension that exposes agent-browser as a native tool for browser automation",
5
5
  "type": "module",
6
6
  "author": "Mitch Fultz (https://github.com/fitchmultz)",