pi-agent-browser-native 0.1.5 → 0.2.0

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,6 +1,6 @@
1
1
  /**
2
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, detect explicit session usage, and build the effective CLI argument list passed to the upstream agent-browser binary.
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.
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
6
  * Invariants/Assumptions: The wrapper stays thin, preserves upstream command vocabulary, and only injects `--json` plus an implicit `--session` when appropriate.
@@ -11,13 +11,11 @@ import { basename } from "node:path";
11
11
 
12
12
  const STARTUP_SCOPED_FLAGS = ["--cdp", "--profile", "--session-name"] as const;
13
13
  const BRAVE_API_KEY_ENV = "BRAVE_API_KEY";
14
- const INSPECTION_ALLOW_PATTERNS = [
15
- /\bagent[_ -]?browser\s+--(?:help|version)\b/i,
16
- /\bagent[_ -]?browser\b.*\b(?:help|version|docs?|documentation|tool contract|tool guidance|tool description)\b/i,
17
- /\b(?:help|version|docs?|documentation|tool contract|tool guidance|tool description)\b.*\bagent[_ -]?browser\b/i,
18
- /\bdebug(?:ging)?\b.*\b(?:agent[_ -]?browser|agent_browser|browser integration)\b/i,
19
- /\bwhy\s+(?:isn't|is not|doesn't|does not)\b.*\b(?:agent[_ -]?browser|agent_browser)\b/i,
20
- ];
14
+ const AGENT_BROWSER_IDLE_TIMEOUT_ENV = "AGENT_BROWSER_IDLE_TIMEOUT_MS";
15
+ const IMPLICIT_SESSION_IDLE_TIMEOUT_ENV = "PI_AGENT_BROWSER_IMPLICIT_SESSION_IDLE_TIMEOUT_MS";
16
+ const IMPLICIT_SESSION_CLOSE_TIMEOUT_ENV = "PI_AGENT_BROWSER_IMPLICIT_SESSION_CLOSE_TIMEOUT_MS";
17
+ const DEFAULT_IMPLICIT_SESSION_IDLE_TIMEOUT_MS = 15 * 60 * 1000;
18
+ const DEFAULT_IMPLICIT_SESSION_CLOSE_TIMEOUT_MS = 5_000;
21
19
  const LEGACY_BASH_ALLOW_PATTERNS = [
22
20
  /\b(?:bash-oriented workflow|bash workflow)\b/i,
23
21
  /\b(?:use|via|through|with)\s+bash\b/i,
@@ -50,13 +48,6 @@ const GLOBAL_FLAGS_WITH_VALUES = new Set([
50
48
  "--port",
51
49
  ]);
52
50
  const SHELL_OPERATOR_TOKENS = new Set(["&&", "||", "|", ";", ">", ">>", "<"]);
53
- const IMAGE_EXTENSION_TO_MIME_TYPE: Record<string, string> = {
54
- ".gif": "image/gif",
55
- ".jpeg": "image/jpeg",
56
- ".jpg": "image/jpeg",
57
- ".png": "image/png",
58
- ".webp": "image/webp",
59
- };
60
51
  const MAX_PROJECT_SLUG_LENGTH = 24;
61
52
 
62
53
  export interface CommandInfo {
@@ -64,9 +55,19 @@ export interface CommandInfo {
64
55
  subcommand?: string;
65
56
  }
66
57
 
58
+ export type SessionMode = "auto" | "fresh";
59
+
60
+ export interface SessionRecoveryHint {
61
+ exampleArgs: string[];
62
+ exampleParams: { args: string[]; sessionMode: "fresh" };
63
+ reason: string;
64
+ recommendedSessionMode: "fresh";
65
+ }
66
+
67
67
  export interface ExecutionPlan {
68
68
  commandInfo: CommandInfo;
69
69
  effectiveArgs: string[];
70
+ recoveryHint?: SessionRecoveryHint;
70
71
  sessionName?: string;
71
72
  startupScopedFlags: string[];
72
73
  usedImplicitSession: boolean;
@@ -74,7 +75,6 @@ export interface ExecutionPlan {
74
75
  }
75
76
 
76
77
  export interface PromptPolicy {
77
- allowAgentBrowserInspection: boolean;
78
78
  allowLegacyAgentBrowserBash: boolean;
79
79
  }
80
80
 
@@ -82,6 +82,44 @@ export function hasUsableBraveApiKey(apiKey: string | null | undefined = process
82
82
  return typeof apiKey === "string" && apiKey.trim().length > 0;
83
83
  }
84
84
 
85
+ function parseTimeoutMs(rawValue: string | undefined, minimumValue: number): number | undefined {
86
+ if (typeof rawValue !== "string") return undefined;
87
+ const normalizedValue = rawValue.trim();
88
+ if (!/^\d+$/.test(normalizedValue)) return undefined;
89
+ const parsedValue = Number(normalizedValue);
90
+ if (!Number.isSafeInteger(parsedValue) || parsedValue < minimumValue) {
91
+ return undefined;
92
+ }
93
+ return parsedValue;
94
+ }
95
+
96
+ export function getImplicitSessionIdleTimeoutMs(env: NodeJS.ProcessEnv = process.env): string {
97
+ return String(
98
+ parseTimeoutMs(env[IMPLICIT_SESSION_IDLE_TIMEOUT_ENV], 0) ??
99
+ parseTimeoutMs(env[AGENT_BROWSER_IDLE_TIMEOUT_ENV], 0) ??
100
+ DEFAULT_IMPLICIT_SESSION_IDLE_TIMEOUT_MS,
101
+ );
102
+ }
103
+
104
+ export function getImplicitSessionCloseTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
105
+ return parseTimeoutMs(env[IMPLICIT_SESSION_CLOSE_TIMEOUT_ENV], 0) ?? DEFAULT_IMPLICIT_SESSION_CLOSE_TIMEOUT_MS;
106
+ }
107
+
108
+ export function resolveImplicitSessionActiveState(options: {
109
+ command?: string;
110
+ priorActive: boolean;
111
+ 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;
118
+ }
119
+ if (!command) return priorActive;
120
+ return priorActive || succeeded;
121
+ }
122
+
85
123
  export function createEphemeralSessionSeed(): string {
86
124
  return randomUUID();
87
125
  }
@@ -141,7 +179,6 @@ export function getStartupScopedFlags(args: string[]): string[] {
141
179
 
142
180
  export function buildPromptPolicy(prompt: string): PromptPolicy {
143
181
  return {
144
- allowAgentBrowserInspection: INSPECTION_ALLOW_PATTERNS.some((pattern) => pattern.test(prompt)),
145
182
  allowLegacyAgentBrowserBash: LEGACY_BASH_ALLOW_PATTERNS.some((pattern) => pattern.test(prompt)),
146
183
  };
147
184
  }
@@ -176,21 +213,29 @@ export function getLatestUserPrompt(branch: unknown[]): string {
176
213
 
177
214
  export function buildExecutionPlan(
178
215
  args: string[],
179
- options: { implicitSessionActive: boolean; implicitSessionName: string; useActiveSession: boolean },
216
+ options: { implicitSessionActive: boolean; implicitSessionName: string; sessionMode: SessionMode },
180
217
  ): ExecutionPlan {
181
218
  const commandInfo = parseCommandInfo(args);
182
219
  const explicitSessionName = extractExplicitSessionName(args);
183
220
  const startupScopedFlags = getStartupScopedFlags(args);
184
221
  const effectiveArgs = args.includes("--json") ? [] : ["--json"];
222
+ let recoveryHint: SessionRecoveryHint | undefined;
185
223
  let sessionName = explicitSessionName;
186
224
  let usedImplicitSession = false;
187
225
  let validationError: string | undefined;
188
226
 
189
- if (!explicitSessionName && options.useActiveSession) {
227
+ if (!explicitSessionName && options.sessionMode === "auto") {
190
228
  if (options.implicitSessionActive && startupScopedFlags.length > 0) {
229
+ recoveryHint = {
230
+ exampleArgs: args,
231
+ exampleParams: { args, sessionMode: "fresh" },
232
+ reason:
233
+ "Startup-scoped flags like --profile, --session-name, and --cdp need a fresh upstream launch once the implicit session is already active.",
234
+ recommendedSessionMode: "fresh",
235
+ };
191
236
  validationError = [
192
237
  `The current implicit agent-browser session is already running, so startup-scoped flags ${startupScopedFlags.join(", ")} would be ignored by upstream agent-browser.`,
193
- "Reuse the existing implicit session without those flags, or start a fresh upstream session explicitly with `--session ...` (or `useActiveSession: false`) for a new launch.",
238
+ "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.",
194
239
  ].join(" ");
195
240
  } else {
196
241
  effectiveArgs.push("--session", options.implicitSessionName);
@@ -204,6 +249,7 @@ export function buildExecutionPlan(
204
249
  return {
205
250
  commandInfo,
206
251
  effectiveArgs,
252
+ recoveryHint,
207
253
  sessionName,
208
254
  startupScopedFlags,
209
255
  usedImplicitSession,
@@ -235,7 +281,3 @@ export function parseCommandInfo(args: string[]): CommandInfo {
235
281
  return { command: commands[0], subcommand: commands[1] };
236
282
  }
237
283
 
238
- export function getImageMimeType(filePath: string): string | undefined {
239
- const extension = filePath.toLowerCase().slice(filePath.lastIndexOf("."));
240
- return IMAGE_EXTENSION_TO_MIME_TYPE[extension];
241
- }
@@ -1,26 +1,115 @@
1
1
  /**
2
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, prune stale temp roots from prior runs, create securely permissioned temp files, and best-effort clean the current run's temp root on process exit.
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
4
  * Scope: Temporary artifact lifecycle only; callers decide what data to write and when to delete 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, the active run uses a dedicated 0700 directory, and files are created with exclusive 0600 permissions.
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.
7
7
  */
8
8
 
9
9
  import { randomBytes } from "node:crypto";
10
- import { chmod, mkdtemp, open, readdir, rm, stat } from "node:fs/promises";
11
10
  import { rmSync } from "node:fs";
11
+ import { chmod, mkdtemp, open, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
12
12
  import { tmpdir } from "node:os";
13
- import { join } from "node:path";
13
+ import { dirname, join } from "node:path";
14
14
 
15
15
  const TEMP_ROOT_PREFIX = "pi-agent-browser-";
16
+ const TEMP_ROOT_MARKER_FILE_NAME = ".pi-agent-browser-owner.json";
17
+ const TEMP_ROOT_MARKER_KIND = "pi-agent-browser-temp-root";
18
+ const TEMP_ROOT_MARKER_VERSION = 1;
16
19
  const STALE_TEMP_ROOT_MAX_AGE_MS = 24 * 60 * 60 * 1_000;
20
+ const TEMP_ROOT_MAX_BYTES_ENV = "PI_AGENT_BROWSER_TEMP_ROOT_MAX_BYTES";
21
+ const DEFAULT_TEMP_ROOT_MAX_BYTES = 32 * 1_024 * 1_024;
22
+
23
+ interface TempRootOwnershipRecord {
24
+ createdAtMs: number;
25
+ kind: string;
26
+ ownerUid?: number;
27
+ version: number;
28
+ }
17
29
 
18
30
  let sessionTempRootPromise: Promise<string> | undefined;
19
31
  let exitCleanupRegistered = false;
32
+ let tempMutationQueue = Promise.resolve();
33
+ const ownedTempRoots = new Set<string>();
34
+
35
+ function isRecord(value: unknown): value is Record<string, unknown> {
36
+ return typeof value === "object" && value !== null;
37
+ }
38
+
39
+ function getCurrentProcessUid(): number | undefined {
40
+ return typeof process.getuid === "function" ? process.getuid() : undefined;
41
+ }
42
+
43
+ function parsePositiveInteger(rawValue: string | undefined): number | undefined {
44
+ if (typeof rawValue !== "string") return undefined;
45
+ const normalizedValue = rawValue.trim();
46
+ if (!/^\d+$/.test(normalizedValue)) return undefined;
47
+ const parsedValue = Number(normalizedValue);
48
+ if (!Number.isSafeInteger(parsedValue) || parsedValue <= 0) return undefined;
49
+ return parsedValue;
50
+ }
51
+
52
+ function isTempRootOwnershipRecord(value: unknown): value is TempRootOwnershipRecord {
53
+ if (!isRecord(value)) return false;
54
+ if (value.kind !== TEMP_ROOT_MARKER_KIND || value.version !== TEMP_ROOT_MARKER_VERSION) return false;
55
+ if (typeof value.createdAtMs !== "number" || !Number.isFinite(value.createdAtMs) || value.createdAtMs <= 0) return false;
56
+ return value.ownerUid === undefined || typeof value.ownerUid === "number";
57
+ }
58
+
59
+ function getTempArtifactByteLength(content: string | Uint8Array): number {
60
+ return typeof content === "string" ? Buffer.byteLength(content) : content.byteLength;
61
+ }
62
+
63
+ function enqueueTempMutation<T>(task: () => Promise<T>): Promise<T> {
64
+ const nextTask = tempMutationQueue.then(task, task);
65
+ tempMutationQueue = nextTask.then(
66
+ () => undefined,
67
+ () => undefined,
68
+ );
69
+ return nextTask;
70
+ }
71
+
72
+ async function getTempRootArtifactBytes(tempRoot: string): Promise<number> {
73
+ const entries = await readdir(tempRoot, { withFileTypes: true }).catch(() => []);
74
+ let totalBytes = 0;
75
+ for (const entry of entries) {
76
+ if (!entry.isFile() || entry.name === TEMP_ROOT_MARKER_FILE_NAME) continue;
77
+ const path = join(tempRoot, entry.name);
78
+ const stats = await stat(path).catch(() => undefined);
79
+ if (stats?.isFile()) {
80
+ totalBytes += stats.size;
81
+ }
82
+ }
83
+ return totalBytes;
84
+ }
85
+
86
+ async function readTempRootOwnershipMarker(tempRoot: string): Promise<TempRootOwnershipRecord | undefined> {
87
+ try {
88
+ const markerText = await readFile(join(tempRoot, TEMP_ROOT_MARKER_FILE_NAME), "utf8");
89
+ const parsed = JSON.parse(markerText) as unknown;
90
+ return isTempRootOwnershipRecord(parsed) ? parsed : undefined;
91
+ } catch {
92
+ return undefined;
93
+ }
94
+ }
95
+
96
+ export async function writeSecureTempRootOwnershipMarker(tempRoot: string, createdAtMs = Date.now()): Promise<string> {
97
+ const markerPath = join(tempRoot, TEMP_ROOT_MARKER_FILE_NAME);
98
+ const markerRecord: TempRootOwnershipRecord = {
99
+ createdAtMs,
100
+ kind: TEMP_ROOT_MARKER_KIND,
101
+ ownerUid: getCurrentProcessUid(),
102
+ version: TEMP_ROOT_MARKER_VERSION,
103
+ };
104
+ await writeFile(markerPath, JSON.stringify(markerRecord, null, 2), { encoding: "utf8", flag: "wx", mode: 0o600 });
105
+ await chmod(markerPath, 0o600).catch(() => undefined);
106
+ return markerPath;
107
+ }
20
108
 
21
109
  async function pruneStaleTempRoots(currentTempRoot: string | undefined): Promise<void> {
22
110
  const entries = await readdir(tmpdir(), { withFileTypes: true }).catch(() => []);
23
111
  const cutoffTime = Date.now() - STALE_TEMP_ROOT_MAX_AGE_MS;
112
+ const currentUid = getCurrentProcessUid();
24
113
 
25
114
  await Promise.all(
26
115
  entries
@@ -28,30 +117,60 @@ async function pruneStaleTempRoots(currentTempRoot: string | undefined): Promise
28
117
  .map(async (entry) => {
29
118
  const path = join(tmpdir(), entry.name);
30
119
  if (path === currentTempRoot) return;
120
+
121
+ const ownershipMarker = await readTempRootOwnershipMarker(path);
122
+ if (!ownershipMarker) return;
123
+ if (
124
+ currentUid !== undefined &&
125
+ ownershipMarker.ownerUid !== undefined &&
126
+ ownershipMarker.ownerUid !== currentUid
127
+ ) {
128
+ return;
129
+ }
130
+
31
131
  const stats = await stat(path).catch(() => undefined);
32
- if (!stats || stats.mtimeMs >= cutoffTime) return;
132
+ if (!stats?.isDirectory() || stats.mtimeMs >= cutoffTime) return;
33
133
  await rm(path, { force: true, recursive: true }).catch(() => undefined);
34
134
  }),
35
135
  );
36
136
  }
37
137
 
38
- function registerExitCleanup(tempRoot: string): void {
138
+ function registerExitCleanup(): void {
39
139
  if (exitCleanupRegistered) return;
40
140
  exitCleanupRegistered = true;
41
141
  process.once("exit", () => {
42
- try {
43
- rmSync(tempRoot, { force: true, recursive: true });
44
- } catch {
45
- // Best-effort cleanup only.
142
+ for (const tempRoot of ownedTempRoots) {
143
+ try {
144
+ rmSync(tempRoot, { force: true, recursive: true });
145
+ } catch {
146
+ // Best-effort cleanup only.
147
+ }
46
148
  }
47
149
  });
48
150
  }
49
151
 
152
+ export function getSecureTempRootMaxBytes(env: NodeJS.ProcessEnv = process.env): number {
153
+ return parsePositiveInteger(env[TEMP_ROOT_MAX_BYTES_ENV]) ?? DEFAULT_TEMP_ROOT_MAX_BYTES;
154
+ }
155
+
156
+ async function assertSecureTempRootBudget(tempRoot: string, additionalBytes: number): Promise<void> {
157
+ if (additionalBytes <= 0) return;
158
+ const currentBytes = await getTempRootArtifactBytes(tempRoot);
159
+ const maxBytes = getSecureTempRootMaxBytes();
160
+ const nextBytes = currentBytes + additionalBytes;
161
+ if (nextBytes > maxBytes) {
162
+ throw new Error(`pi-agent-browser temp spill budget exceeded (${nextBytes} bytes > ${maxBytes} byte limit).`);
163
+ }
164
+ }
165
+
50
166
  export async function cleanupSecureTempArtifacts(): Promise<void> {
51
- const tempRoot = await sessionTempRootPromise?.catch(() => undefined);
52
- sessionTempRootPromise = undefined;
53
- if (!tempRoot) return;
54
- await rm(tempRoot, { force: true, recursive: true }).catch(() => undefined);
167
+ await enqueueTempMutation(async () => {
168
+ const tempRoot = await sessionTempRootPromise?.catch(() => undefined);
169
+ sessionTempRootPromise = undefined;
170
+ if (!tempRoot) return;
171
+ ownedTempRoots.delete(tempRoot);
172
+ await rm(tempRoot, { force: true, recursive: true }).catch(() => undefined);
173
+ });
55
174
  }
56
175
 
57
176
  async function getSessionTempRoot(): Promise<string> {
@@ -60,7 +179,9 @@ async function getSessionTempRoot(): Promise<string> {
60
179
  await pruneStaleTempRoots(undefined);
61
180
  const tempRoot = await mkdtemp(join(tmpdir(), TEMP_ROOT_PREFIX));
62
181
  await chmod(tempRoot, 0o700).catch(() => undefined);
63
- registerExitCleanup(tempRoot);
182
+ await writeSecureTempRootOwnershipMarker(tempRoot);
183
+ ownedTempRoots.add(tempRoot);
184
+ registerExitCleanup();
64
185
  return tempRoot;
65
186
  })();
66
187
  }
@@ -77,6 +198,18 @@ export async function openSecureTempFile(prefix: string, suffix: string): Promis
77
198
  return { fileHandle, path };
78
199
  }
79
200
 
201
+ export async function writeSecureTempChunk(options: {
202
+ content: string | Uint8Array;
203
+ fileHandle: Awaited<ReturnType<typeof open>>;
204
+ path: string;
205
+ }): Promise<void> {
206
+ const { content, fileHandle, path } = options;
207
+ await enqueueTempMutation(async () => {
208
+ await assertSecureTempRootBudget(dirname(path), getTempArtifactByteLength(content));
209
+ await fileHandle.writeFile(content);
210
+ });
211
+ }
212
+
80
213
  export async function writeSecureTempFile(options: {
81
214
  content: string | Uint8Array;
82
215
  prefix: string;
@@ -85,9 +218,19 @@ export async function writeSecureTempFile(options: {
85
218
  const { content, prefix, suffix } = options;
86
219
  const { fileHandle, path } = await openSecureTempFile(prefix, suffix);
87
220
  try {
88
- await fileHandle.writeFile(content);
221
+ await writeSecureTempChunk({ content, fileHandle, path });
222
+ } catch (error) {
223
+ await rm(path, { force: true }).catch(() => undefined);
224
+ throw error;
89
225
  } finally {
90
226
  await fileHandle.close();
91
227
  }
92
228
  return path;
93
229
  }
230
+
231
+ export async function getSecureTempDebugState(): Promise<{ currentTempRoot?: string; ownedTempRoots: string[] }> {
232
+ return {
233
+ currentTempRoot: await sessionTempRootPromise?.catch(() => undefined),
234
+ ownedTempRoots: [...ownedTempRoots].sort(),
235
+ };
236
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-agent-browser-native",
3
- "version": "0.1.5",
3
+ "version": "0.2.0",
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)",