pi-agent-browser-native 0.2.12 → 0.2.13

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.
@@ -6,11 +6,15 @@
6
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
+ import { execFile } from "node:child_process";
9
10
  import { randomBytes } from "node:crypto";
10
11
  import { rmSync } from "node:fs";
11
12
  import { chmod, mkdir, mkdtemp, open, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
12
13
  import { tmpdir } from "node:os";
13
14
  import { dirname, join } from "node:path";
15
+ import { promisify } from "node:util";
16
+
17
+ import { isRecord, parsePositiveInteger } from "./parsing.js";
14
18
 
15
19
  const TEMP_ROOT_PREFIX = "pi-agent-browser-";
16
20
  const TEMP_ROOT_MARKER_FILE_NAME = ".pi-agent-browser-owner.json";
@@ -22,6 +26,8 @@ const DEFAULT_TEMP_ROOT_MAX_BYTES = 32 * 1_024 * 1_024;
22
26
  const SESSION_ARTIFACT_MAX_BYTES_ENV = "PI_AGENT_BROWSER_SESSION_ARTIFACT_MAX_BYTES";
23
27
  const DEFAULT_SESSION_ARTIFACT_MAX_BYTES = 32 * 1_024 * 1_024;
24
28
  const SESSION_ARTIFACTS_ROOT_DIR_NAME = ".pi-agent-browser-artifacts";
29
+ const PROCESS_START_IDENTITY_TIMEOUT_MS = 1_000;
30
+ const execFileAsync = promisify(execFile);
25
31
 
26
32
  export interface PersistentSessionArtifactStore {
27
33
  protectedPaths?: readonly string[];
@@ -29,40 +35,64 @@ export interface PersistentSessionArtifactStore {
29
35
  sessionId: string;
30
36
  }
31
37
 
38
+ export interface PersistentSessionArtifactEviction {
39
+ mtimeMs: number;
40
+ path: string;
41
+ sizeBytes: number;
42
+ }
43
+
44
+ export interface PersistentSessionArtifactWriteResult {
45
+ evictedArtifacts: PersistentSessionArtifactEviction[];
46
+ path: string;
47
+ }
48
+
32
49
  interface TempRootOwnershipRecord {
33
50
  createdAtMs: number;
34
51
  kind: string;
52
+ leaseUpdatedAtMs?: number;
53
+ ownerPid?: number;
54
+ ownerProcessStartIdentity?: string;
35
55
  ownerUid?: number;
36
56
  version: number;
37
57
  }
38
58
 
59
+ interface TempRootOwnershipMarkerOptions {
60
+ createdAtMs?: number;
61
+ leaseUpdatedAtMs?: number;
62
+ ownerPid?: number;
63
+ ownerProcessStartIdentity?: string;
64
+ }
65
+
66
+ type ProcessLiveness = "alive" | "dead" | "unknown";
67
+
39
68
  let sessionTempRootPromise: Promise<string> | undefined;
40
69
  let exitCleanupRegistered = false;
41
70
  let tempMutationQueue = Promise.resolve();
42
71
  const ownedTempRoots = new Set<string>();
43
72
 
44
- function isRecord(value: unknown): value is Record<string, unknown> {
45
- return typeof value === "object" && value !== null;
46
- }
47
-
48
73
  function getCurrentProcessUid(): number | undefined {
49
74
  return typeof process.getuid === "function" ? process.getuid() : undefined;
50
75
  }
51
76
 
52
- function parsePositiveInteger(rawValue: string | undefined): number | undefined {
53
- if (typeof rawValue !== "string") return undefined;
54
- const normalizedValue = rawValue.trim();
55
- if (!/^\d+$/.test(normalizedValue)) return undefined;
56
- const parsedValue = Number(normalizedValue);
57
- if (!Number.isSafeInteger(parsedValue) || parsedValue <= 0) return undefined;
58
- return parsedValue;
77
+ function isPositiveFiniteNumber(value: unknown): value is number {
78
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
59
79
  }
60
80
 
61
81
  function isTempRootOwnershipRecord(value: unknown): value is TempRootOwnershipRecord {
62
82
  if (!isRecord(value)) return false;
63
83
  if (value.kind !== TEMP_ROOT_MARKER_KIND || value.version !== TEMP_ROOT_MARKER_VERSION) return false;
64
- if (typeof value.createdAtMs !== "number" || !Number.isFinite(value.createdAtMs) || value.createdAtMs <= 0) return false;
65
- return value.ownerUid === undefined || typeof value.ownerUid === "number";
84
+ if (!isPositiveFiniteNumber(value.createdAtMs)) return false;
85
+ if (value.leaseUpdatedAtMs !== undefined && !isPositiveFiniteNumber(value.leaseUpdatedAtMs)) return false;
86
+ if (value.ownerPid !== undefined) {
87
+ if (typeof value.ownerPid !== "number" || !Number.isSafeInteger(value.ownerPid) || value.ownerPid <= 0) return false;
88
+ }
89
+ if (value.ownerProcessStartIdentity !== undefined) {
90
+ if (typeof value.ownerProcessStartIdentity !== "string" || value.ownerProcessStartIdentity.trim() === "") return false;
91
+ }
92
+ if (value.ownerUid !== undefined) {
93
+ if (typeof value.ownerUid !== "number" || !Number.isSafeInteger(value.ownerUid) || value.ownerUid < 0) return false;
94
+ }
95
+ return true;
66
96
  }
67
97
 
68
98
  function getTempArtifactByteLength(content: string | Uint8Array): number {
@@ -107,11 +137,33 @@ async function readTempRootOwnershipMarker(tempRoot: string): Promise<TempRootOw
107
137
  }
108
138
  }
109
139
 
110
- export async function writeSecureTempRootOwnershipMarker(tempRoot: string, createdAtMs = Date.now()): Promise<string> {
140
+ async function getProcessStartIdentity(pid: number | undefined): Promise<string | undefined> {
141
+ if (pid === undefined) return undefined;
142
+ if (!Number.isSafeInteger(pid) || pid <= 0) return undefined;
143
+ try {
144
+ const { stdout } = await execFileAsync("ps", ["-p", String(pid), "-o", "lstart="], {
145
+ timeout: PROCESS_START_IDENTITY_TIMEOUT_MS,
146
+ });
147
+ const identity = stdout.trim().replace(/\s+/g, " ");
148
+ return identity || undefined;
149
+ } catch {
150
+ return undefined;
151
+ }
152
+ }
153
+
154
+ export async function writeSecureTempRootOwnershipMarker(
155
+ tempRoot: string,
156
+ options: TempRootOwnershipMarkerOptions = {},
157
+ ): Promise<string> {
111
158
  const markerPath = join(tempRoot, TEMP_ROOT_MARKER_FILE_NAME);
159
+ const createdAtMs = options.createdAtMs ?? Date.now();
160
+ const ownerPid = options.ownerPid ?? process.pid;
112
161
  const markerRecord: TempRootOwnershipRecord = {
113
162
  createdAtMs,
114
163
  kind: TEMP_ROOT_MARKER_KIND,
164
+ leaseUpdatedAtMs: options.leaseUpdatedAtMs ?? createdAtMs,
165
+ ownerPid,
166
+ ownerProcessStartIdentity: options.ownerProcessStartIdentity ?? (await getProcessStartIdentity(ownerPid)),
115
167
  ownerUid: getCurrentProcessUid(),
116
168
  version: TEMP_ROOT_MARKER_VERSION,
117
169
  };
@@ -120,6 +172,54 @@ export async function writeSecureTempRootOwnershipMarker(tempRoot: string, creat
120
172
  return markerPath;
121
173
  }
122
174
 
175
+ async function refreshSecureTempRootLease(tempRoot: string): Promise<void> {
176
+ const markerPath = join(tempRoot, TEMP_ROOT_MARKER_FILE_NAME);
177
+ const ownershipMarker = await readTempRootOwnershipMarker(tempRoot);
178
+ if (!ownershipMarker) return;
179
+ if (ownershipMarker.ownerPid !== process.pid) return;
180
+ const currentUid = getCurrentProcessUid();
181
+ if (currentUid !== undefined && ownershipMarker.ownerUid !== undefined && ownershipMarker.ownerUid !== currentUid) return;
182
+ const currentProcessStartIdentity = await getProcessStartIdentity(process.pid);
183
+ if (
184
+ ownershipMarker.ownerProcessStartIdentity !== undefined &&
185
+ currentProcessStartIdentity !== undefined &&
186
+ ownershipMarker.ownerProcessStartIdentity !== currentProcessStartIdentity
187
+ ) {
188
+ return;
189
+ }
190
+ const refreshedMarker: TempRootOwnershipRecord = {
191
+ ...ownershipMarker,
192
+ leaseUpdatedAtMs: Date.now(),
193
+ ownerPid: process.pid,
194
+ ownerProcessStartIdentity: currentProcessStartIdentity ?? ownershipMarker.ownerProcessStartIdentity,
195
+ ownerUid: currentUid,
196
+ };
197
+ await writeFile(markerPath, JSON.stringify(refreshedMarker, null, 2), { encoding: "utf8", mode: 0o600 });
198
+ await chmod(markerPath, 0o600).catch(() => undefined);
199
+ }
200
+
201
+ async function getMarkerOwnerLiveness(ownershipMarker: TempRootOwnershipRecord): Promise<ProcessLiveness> {
202
+ const pid = ownershipMarker.ownerPid;
203
+ if (pid === undefined) return "unknown";
204
+ try {
205
+ process.kill(pid, 0);
206
+ } catch (error) {
207
+ const errorWithCode = error as NodeJS.ErrnoException;
208
+ if (errorWithCode.code === "ESRCH") return "dead";
209
+ if (errorWithCode.code !== "EPERM") return "unknown";
210
+ }
211
+
212
+ const currentProcessStartIdentity = await getProcessStartIdentity(pid);
213
+ if (
214
+ ownershipMarker.ownerProcessStartIdentity !== undefined &&
215
+ currentProcessStartIdentity !== undefined &&
216
+ ownershipMarker.ownerProcessStartIdentity === currentProcessStartIdentity
217
+ ) {
218
+ return "alive";
219
+ }
220
+ return "dead";
221
+ }
222
+
123
223
  async function pruneStaleTempRoots(currentTempRoot: string | undefined): Promise<void> {
124
224
  const entries = await readdir(tmpdir(), { withFileTypes: true }).catch(() => []);
125
225
  const cutoffTime = Date.now() - STALE_TEMP_ROOT_MAX_AGE_MS;
@@ -141,9 +241,12 @@ async function pruneStaleTempRoots(currentTempRoot: string | undefined): Promise
141
241
  ) {
142
242
  return;
143
243
  }
244
+ const staleTimestampMs = ownershipMarker.leaseUpdatedAtMs ?? ownershipMarker.createdAtMs;
245
+ if (staleTimestampMs >= cutoffTime) return;
246
+ if ((await getMarkerOwnerLiveness(ownershipMarker)) === "alive") return;
144
247
 
145
248
  const stats = await stat(path).catch(() => undefined);
146
- if (!stats?.isDirectory() || stats.mtimeMs >= cutoffTime) return;
249
+ if (!stats?.isDirectory()) return;
147
250
  await rm(path, { force: true, recursive: true }).catch(() => undefined);
148
251
  }),
149
252
  );
@@ -205,23 +308,25 @@ async function prunePersistentSessionArtifactsToBudget(
205
308
  sessionArtifactDir: string,
206
309
  additionalBytes: number,
207
310
  protectedPaths: ReadonlySet<string>,
208
- ): Promise<void> {
209
- if (additionalBytes <= 0) return;
311
+ ): Promise<PersistentSessionArtifactEviction[]> {
312
+ if (additionalBytes <= 0) return [];
210
313
  const maxBytes = getPersistentSessionArtifactMaxBytes();
211
314
  let files = await listArtifactFiles(sessionArtifactDir);
212
315
  let totalBytes = files.reduce((total, file) => total + file.size, 0);
213
316
  if (totalBytes + additionalBytes <= maxBytes) {
214
- return;
317
+ return [];
215
318
  }
319
+ const evictedArtifacts: PersistentSessionArtifactEviction[] = [];
216
320
  files = files.sort((left, right) => left.mtimeMs - right.mtimeMs || left.path.localeCompare(right.path));
217
321
  for (const file of files) {
218
322
  if (protectedPaths.has(file.path)) {
219
323
  continue;
220
324
  }
221
325
  await rm(file.path, { force: true }).catch(() => undefined);
326
+ evictedArtifacts.push({ mtimeMs: file.mtimeMs, path: file.path, sizeBytes: file.size });
222
327
  totalBytes -= file.size;
223
328
  if (totalBytes + additionalBytes <= maxBytes) {
224
- return;
329
+ return evictedArtifacts;
225
330
  }
226
331
  }
227
332
  throw new Error(`pi-agent-browser persisted spill budget exceeded (${totalBytes + additionalBytes} bytes > ${maxBytes} byte limit).`);
@@ -241,6 +346,7 @@ async function getSessionTempRoot(): Promise<string> {
241
346
  }
242
347
 
243
348
  const tempRoot = await sessionTempRootPromise;
349
+ await refreshSecureTempRootLease(tempRoot).catch(() => undefined);
244
350
  await pruneStaleTempRoots(tempRoot).catch(() => undefined);
245
351
  return tempRoot;
246
352
  }
@@ -259,7 +365,9 @@ export async function writeSecureTempChunk(options: {
259
365
  }): Promise<void> {
260
366
  const { content, fileHandle, path } = options;
261
367
  await enqueueTempMutation(async () => {
262
- await assertSecureTempRootBudget(dirname(path), getTempArtifactByteLength(content));
368
+ const tempRoot = dirname(path);
369
+ await refreshSecureTempRootLease(tempRoot).catch(() => undefined);
370
+ await assertSecureTempRootBudget(tempRoot, getTempArtifactByteLength(content));
263
371
  await fileHandle.appendFile(content);
264
372
  });
265
373
  }
@@ -287,11 +395,11 @@ export async function writePersistentSessionArtifactFile(options: {
287
395
  prefix: string;
288
396
  store: PersistentSessionArtifactStore;
289
397
  suffix: string;
290
- }): Promise<string> {
398
+ }): Promise<PersistentSessionArtifactWriteResult> {
291
399
  const { content, prefix, store, suffix } = options;
292
400
  return await enqueueTempMutation(async () => {
293
401
  const artifactDir = await ensurePersistentSessionArtifactDir(store);
294
- await prunePersistentSessionArtifactsToBudget(
402
+ const evictedArtifacts = await prunePersistentSessionArtifactsToBudget(
295
403
  artifactDir,
296
404
  getTempArtifactByteLength(content),
297
405
  new Set((store.protectedPaths ?? []).filter((path) => dirname(path) === artifactDir)),
@@ -306,7 +414,7 @@ export async function writePersistentSessionArtifactFile(options: {
306
414
  } finally {
307
415
  await fileHandle.close().catch(() => undefined);
308
416
  }
309
- return path;
417
+ return { evictedArtifacts, path };
310
418
  });
311
419
  }
312
420
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-agent-browser-native",
3
- "version": "0.2.12",
3
+ "version": "0.2.13",
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)",
@@ -26,8 +26,13 @@
26
26
  "engines": {
27
27
  "node": ">=20.6.0"
28
28
  },
29
+ "bin": {
30
+ "pi-agent-browser-doctor": "scripts/doctor.mjs"
31
+ },
29
32
  "files": [
30
33
  "extensions",
34
+ "scripts/doctor.mjs",
35
+ "scripts/agent-browser-capability-baseline.mjs",
31
36
  "README.md",
32
37
  "CHANGELOG.md",
33
38
  "LICENSE",
@@ -56,12 +61,10 @@
56
61
  "basic-ftp": "5.3.0"
57
62
  },
58
63
  "scripts": {
59
- "typecheck": "tsc --noEmit",
64
+ "docs": "node ./scripts/project.mjs docs",
65
+ "doctor": "node ./scripts/doctor.mjs",
60
66
  "test": "tsx --test test/**/*.test.ts",
61
- "pack:dry-run": "npm pack --json --dry-run",
62
- "verify": "npm run typecheck && npm run test",
63
- "verify:package": "node ./scripts/verify-package.mjs",
64
- "verify:release": "npm run verify && npm run verify:package"
67
+ "verify": "node ./scripts/project.mjs verify"
65
68
  },
66
69
  "packageManager": "npm@10.9.8"
67
70
  }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Purpose: Define the canonical upstream agent-browser capability baseline targeted by this package.
3
+ * Responsibilities: Store the target upstream version, sampled help commands, and verifier/doc token expectations in one importable metadata object.
4
+ * Scope: Versioned capability metadata only; it does not execute agent-browser or validate documentation by itself.
5
+ * Usage: Imported by command-reference verifier, generated docs checker, and tests when upstream agent-browser is re-baselined.
6
+ * Invariants/Assumptions: This package targets the current installed upstream agent-browser only and does not keep compatibility shims for older versions.
7
+ */
8
+
9
+ export const CAPABILITY_BASELINE_SOURCE = "scripts/agent-browser-capability-baseline.mjs";
10
+ export const COMMAND_REFERENCE_DOC_PATH = "docs/COMMAND_REFERENCE.md";
11
+ export const CAPABILITY_BASELINE_BLOCK_MARKER_PREFIX = "agent-browser-capability-baseline";
12
+ export const COMMAND_REFERENCE_BASELINE_BLOCK_IDS = Object.freeze(["upstream-baseline", "capability-token-baseline"]);
13
+
14
+ export const CAPABILITY_BASELINE = Object.freeze({
15
+ targetVersion: "0.26.0",
16
+ helpCommands: Object.freeze([
17
+ Object.freeze({ label: "root help", args: Object.freeze(["--help"]) }),
18
+ Object.freeze({ label: "tab help", args: Object.freeze(["tab", "--help"]) }),
19
+ Object.freeze({ label: "snapshot help", args: Object.freeze(["snapshot", "--help"]) }),
20
+ Object.freeze({ label: "wait help", args: Object.freeze(["wait", "--help"]) }),
21
+ ]),
22
+ docRequiredTokens: Object.freeze([
23
+ "skills list",
24
+ "skills get core --full",
25
+ "keyboard type <text>",
26
+ "scroll <dir> [px]",
27
+ "scrollintoview <sel>",
28
+ "connect <port|url>",
29
+ "is <what> <selector>",
30
+ "find <locator> <value> <action>",
31
+ "mouse <action> [args]",
32
+ "set <setting> [value]",
33
+ "network <action>",
34
+ "cookies [get|set|clear]",
35
+ "storage <local|session>",
36
+ "diff snapshot",
37
+ "trace start|stop [path]",
38
+ "profiler start|stop [path]",
39
+ "record start <path> [url]",
40
+ "console [--clear]",
41
+ "errors [--clear]",
42
+ "highlight <sel>",
43
+ "inspect",
44
+ "clipboard <op> [text]",
45
+ "stream enable [--port <n>]",
46
+ "auth save <name>",
47
+ "confirm <id>",
48
+ "deny <id>",
49
+ "chat <message>",
50
+ "dashboard start --port <n>",
51
+ "install --with-deps",
52
+ "upgrade",
53
+ "doctor [--fix]",
54
+ "profiles",
55
+ "snapshot -i --urls",
56
+ "snapshot --urls",
57
+ "wait --download [path]",
58
+ "tab new --label <name> [url]",
59
+ "--action-policy <path>",
60
+ "--confirm-actions <list>",
61
+ "--engine <name>",
62
+ "AGENT_BROWSER_CONFIG",
63
+ ]),
64
+ upstreamExpectations: Object.freeze([
65
+ Object.freeze({ token: "skills", help: "root help" }),
66
+ Object.freeze({ token: "keyboard", help: "root help" }),
67
+ Object.freeze({ token: "scroll", help: "root help" }),
68
+ Object.freeze({ token: "scrollintoview", help: "root help" }),
69
+ Object.freeze({ token: "connect", help: "root help" }),
70
+ Object.freeze({ token: "is", help: "root help" }),
71
+ Object.freeze({ token: "find", help: "root help" }),
72
+ Object.freeze({ token: "mouse", help: "root help" }),
73
+ Object.freeze({ token: "set", help: "root help" }),
74
+ Object.freeze({ token: "network", help: "root help" }),
75
+ Object.freeze({ token: "cookies [get|set|clear]", help: "root help" }),
76
+ Object.freeze({ token: "storage", help: "root help" }),
77
+ Object.freeze({ token: "diff snapshot", help: "root help" }),
78
+ Object.freeze({ token: "trace start|stop [path]", help: "root help" }),
79
+ Object.freeze({ token: "profiler start|stop [path]", help: "root help" }),
80
+ Object.freeze({ token: "record start <path> [url]", help: "root help" }),
81
+ Object.freeze({ token: "console [--clear]", help: "root help" }),
82
+ Object.freeze({ token: "errors [--clear]", help: "root help" }),
83
+ Object.freeze({ token: "highlight <sel>", help: "root help" }),
84
+ Object.freeze({ token: "inspect", help: "root help" }),
85
+ Object.freeze({ token: "clipboard <op> [text]", help: "root help" }),
86
+ Object.freeze({ token: "stream enable [--port <n>]", help: "root help" }),
87
+ Object.freeze({ token: "auth save <name>", help: "root help" }),
88
+ Object.freeze({ token: "confirm <id>", help: "root help" }),
89
+ Object.freeze({ token: "deny <id>", help: "root help" }),
90
+ Object.freeze({ token: "chat <message>", help: "root help" }),
91
+ Object.freeze({ token: "dashboard start --port <n>", help: "root help" }),
92
+ Object.freeze({ token: "install --with-deps", help: "root help" }),
93
+ Object.freeze({ token: "upgrade", help: "root help" }),
94
+ Object.freeze({ token: "doctor [--fix]", help: "root help" }),
95
+ Object.freeze({ token: "profiles", help: "root help" }),
96
+ Object.freeze({ token: "-u, --urls", help: "snapshot help" }),
97
+ Object.freeze({ token: "--download [path]", help: "wait help" }),
98
+ Object.freeze({ token: "new --label <name> [url]", help: "tab help" }),
99
+ ]),
100
+ });
101
+
102
+ export function expectedVersionLabel() {
103
+ return `agent-browser ${CAPABILITY_BASELINE.targetVersion}`;
104
+ }