pi-agent-browser-native 0.2.34 → 0.2.36

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +25 -15
  3. package/docs/ARCHITECTURE.md +19 -13
  4. package/docs/COMMAND_REFERENCE.md +274 -44
  5. package/docs/ELECTRON.md +3 -3
  6. package/docs/RELEASE.md +11 -11
  7. package/docs/REQUIREMENTS.md +5 -5
  8. package/docs/SUPPORT_MATRIX.md +43 -24
  9. package/docs/TOOL_CONTRACT.md +50 -30
  10. package/extensions/agent-browser/index.ts +518 -2402
  11. package/extensions/agent-browser/lib/argv-descriptor.ts +90 -0
  12. package/extensions/agent-browser/lib/argv-grammar.ts +128 -0
  13. package/extensions/agent-browser/lib/command-policy.ts +71 -0
  14. package/extensions/agent-browser/lib/command-taxonomy.ts +336 -0
  15. package/extensions/agent-browser/lib/electron/cleanup.ts +1 -0
  16. package/extensions/agent-browser/lib/executable-path.ts +19 -0
  17. package/extensions/agent-browser/lib/input-modes/params.ts +6 -6
  18. package/extensions/agent-browser/lib/orchestration/batch-stdin.ts +65 -0
  19. package/extensions/agent-browser/lib/orchestration/browser-run/browser-action-model.ts +154 -0
  20. package/extensions/agent-browser/lib/orchestration/browser-run/click-dispatch.ts +149 -0
  21. package/extensions/agent-browser/lib/orchestration/browser-run/diagnostics.ts +56 -30
  22. package/extensions/agent-browser/lib/orchestration/browser-run/final-result.ts +13 -3
  23. package/extensions/agent-browser/lib/orchestration/browser-run/index.ts +33 -27
  24. package/extensions/agent-browser/lib/orchestration/browser-run/prepare.ts +48 -22
  25. package/extensions/agent-browser/lib/orchestration/browser-run/process-output.ts +39 -10
  26. package/extensions/agent-browser/lib/orchestration/browser-run/prompt-guards.ts +93 -0
  27. package/extensions/agent-browser/lib/orchestration/browser-run/session-state.ts +98 -124
  28. package/extensions/agent-browser/lib/orchestration/browser-run/types.ts +40 -1
  29. package/extensions/agent-browser/lib/orchestration/electron-host/index.ts +860 -0
  30. package/extensions/agent-browser/lib/playbook.ts +10 -10
  31. package/extensions/agent-browser/lib/prompt-policy.ts +122 -0
  32. package/extensions/agent-browser/lib/results/action-recommendations.ts +3 -23
  33. package/extensions/agent-browser/lib/results/presentation/navigation.ts +2 -34
  34. package/extensions/agent-browser/lib/runtime.ts +93 -227
  35. package/extensions/agent-browser/lib/session-page-state.ts +31 -14
  36. package/extensions/agent-browser/lib/temp.ts +148 -23
  37. package/package.json +4 -4
  38. package/scripts/agent-browser-capability-baseline.mjs +198 -1
@@ -1,6 +1,6 @@
1
1
  /**
2
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.
3
+ * Responsibilities: Maintain a process-private temp root, stamp explicit ownership/protected-child 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 without deleting protected children, and best-effort clean all owned roots on process exit.
4
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
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.
@@ -8,10 +8,10 @@
8
8
 
9
9
  import { execFile } from "node:child_process";
10
10
  import { randomBytes } from "node:crypto";
11
- import { rmSync } from "node:fs";
11
+ import { existsSync, readdirSync, rmSync } from "node:fs";
12
12
  import { chmod, mkdir, mkdtemp, open, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
13
13
  import { tmpdir } from "node:os";
14
- import { dirname, join } from "node:path";
14
+ import { basename, dirname, join, resolve } from "node:path";
15
15
  import { promisify } from "node:util";
16
16
 
17
17
  import { isRecord, parsePositiveInteger } from "./parsing.js";
@@ -53,6 +53,7 @@ interface TempRootOwnershipRecord {
53
53
  ownerPid?: number;
54
54
  ownerProcessStartIdentity?: string;
55
55
  ownerUid?: number;
56
+ protectedChildNames?: readonly string[];
56
57
  version: number;
57
58
  }
58
59
 
@@ -69,6 +70,7 @@ let sessionTempRootPromise: Promise<string> | undefined;
69
70
  let exitCleanupRegistered = false;
70
71
  let tempMutationQueue = Promise.resolve();
71
72
  const ownedTempRoots = new Set<string>();
73
+ const protectedTempChildren = new Set<string>();
72
74
 
73
75
  function getCurrentProcessUid(): number | undefined {
74
76
  return typeof process.getuid === "function" ? process.getuid() : undefined;
@@ -78,6 +80,13 @@ function isPositiveFiniteNumber(value: unknown): value is number {
78
80
  return typeof value === "number" && Number.isFinite(value) && value > 0;
79
81
  }
80
82
 
83
+ function isProtectedTempChildName(value: unknown): value is string {
84
+ if (typeof value !== "string") return false;
85
+ if (value === "" || value === "." || value === ".." || value === TEMP_ROOT_MARKER_FILE_NAME) return false;
86
+ if (value.includes("/") || value.includes("\\")) return false;
87
+ return basename(value) === value;
88
+ }
89
+
81
90
  function isTempRootOwnershipRecord(value: unknown): value is TempRootOwnershipRecord {
82
91
  if (!isRecord(value)) return false;
83
92
  if (value.kind !== TEMP_ROOT_MARKER_KIND || value.version !== TEMP_ROOT_MARKER_VERSION) return false;
@@ -92,6 +101,10 @@ function isTempRootOwnershipRecord(value: unknown): value is TempRootOwnershipRe
92
101
  if (value.ownerUid !== undefined) {
93
102
  if (typeof value.ownerUid !== "number" || !Number.isSafeInteger(value.ownerUid) || value.ownerUid < 0) return false;
94
103
  }
104
+ if (value.protectedChildNames !== undefined) {
105
+ if (!Array.isArray(value.protectedChildNames)) return false;
106
+ if (!value.protectedChildNames.every(isProtectedTempChildName)) return false;
107
+ }
95
108
  return true;
96
109
  }
97
110
 
@@ -137,6 +150,82 @@ async function readTempRootOwnershipMarker(tempRoot: string): Promise<TempRootOw
137
150
  }
138
151
  }
139
152
 
153
+ function getProtectedTempChildName(tempRoot: string, childPath: string): string | undefined {
154
+ const normalizedTempRoot = resolve(tempRoot);
155
+ const normalizedChildPath = resolve(childPath);
156
+ if (dirname(normalizedChildPath) !== normalizedTempRoot) return undefined;
157
+ const childName = basename(normalizedChildPath);
158
+ return isProtectedTempChildName(childName) ? childName : undefined;
159
+ }
160
+
161
+ function normalizeProtectedChildNames(names: Iterable<string>): string[] {
162
+ return [...new Set([...names].filter(isProtectedTempChildName))].sort();
163
+ }
164
+
165
+ function getPersistedProtectedChildPaths(tempRoot: string, ownershipMarker: TempRootOwnershipRecord | undefined): Set<string> {
166
+ const normalizedTempRoot = resolve(tempRoot);
167
+ return new Set((ownershipMarker?.protectedChildNames ?? []).map((childName) => resolve(join(normalizedTempRoot, childName))));
168
+ }
169
+
170
+ async function writeTempRootOwnershipMarkerRecord(
171
+ tempRoot: string,
172
+ markerRecord: TempRootOwnershipRecord,
173
+ options: { flag?: "wx" } = {},
174
+ ): Promise<string> {
175
+ const markerPath = join(tempRoot, TEMP_ROOT_MARKER_FILE_NAME);
176
+ await writeFile(markerPath, JSON.stringify(markerRecord, null, 2), {
177
+ encoding: "utf8",
178
+ flag: options.flag,
179
+ mode: 0o600,
180
+ });
181
+ await chmod(markerPath, 0o600).catch(() => undefined);
182
+ return markerPath;
183
+ }
184
+
185
+ async function persistProtectedTempChildren(tempRoot: string, protectedChildren: ReadonlySet<string>): Promise<void> {
186
+ if (protectedChildren.size === 0) return;
187
+ const ownershipMarker = await readTempRootOwnershipMarker(tempRoot);
188
+ if (!ownershipMarker) return;
189
+ const childNames = normalizeProtectedChildNames([
190
+ ...(ownershipMarker.protectedChildNames ?? []),
191
+ ...[...protectedChildren]
192
+ .map((path) => getProtectedTempChildName(tempRoot, path))
193
+ .filter((childName): childName is string => childName !== undefined),
194
+ ]);
195
+ if (childNames.length === 0) return;
196
+ await writeTempRootOwnershipMarkerRecord(tempRoot, {
197
+ ...ownershipMarker,
198
+ leaseUpdatedAtMs: Date.now(),
199
+ protectedChildNames: childNames,
200
+ });
201
+ }
202
+
203
+ async function getExistingProtectedChildren(
204
+ tempRoot: string,
205
+ protectedChildren: ReadonlySet<string>,
206
+ ): Promise<Set<string>> {
207
+ const normalizedTempRoot = resolve(tempRoot);
208
+ const existingChildren = new Set<string>();
209
+ for (const path of protectedChildren) {
210
+ const normalizedPath = resolve(path);
211
+ if (dirname(normalizedPath) !== normalizedTempRoot) continue;
212
+ if (await stat(normalizedPath).then((stats) => stats.isDirectory(), () => false)) {
213
+ existingChildren.add(normalizedPath);
214
+ }
215
+ }
216
+ return existingChildren;
217
+ }
218
+
219
+ async function removeTempRootChildrenExcept(tempRoot: string, protectedChildren: ReadonlySet<string>): Promise<void> {
220
+ const entries = await readdir(tempRoot, { withFileTypes: true }).catch(() => []);
221
+ await Promise.all(entries.map(async (entry) => {
222
+ if (entry.name === TEMP_ROOT_MARKER_FILE_NAME) return;
223
+ const entryPath = join(tempRoot, entry.name);
224
+ if (protectedChildren.has(resolve(entryPath))) return;
225
+ await rm(entryPath, { force: true, recursive: true }).catch(() => undefined);
226
+ }));
227
+ }
228
+
140
229
  async function getProcessStartIdentity(pid: number | undefined): Promise<string | undefined> {
141
230
  if (pid === undefined) return undefined;
142
231
  if (!Number.isSafeInteger(pid) || pid <= 0) return undefined;
@@ -155,7 +244,6 @@ export async function writeSecureTempRootOwnershipMarker(
155
244
  tempRoot: string,
156
245
  options: TempRootOwnershipMarkerOptions = {},
157
246
  ): Promise<string> {
158
- const markerPath = join(tempRoot, TEMP_ROOT_MARKER_FILE_NAME);
159
247
  const createdAtMs = options.createdAtMs ?? Date.now();
160
248
  const ownerPid = options.ownerPid ?? process.pid;
161
249
  const markerRecord: TempRootOwnershipRecord = {
@@ -167,13 +255,10 @@ export async function writeSecureTempRootOwnershipMarker(
167
255
  ownerUid: getCurrentProcessUid(),
168
256
  version: TEMP_ROOT_MARKER_VERSION,
169
257
  };
170
- await writeFile(markerPath, JSON.stringify(markerRecord, null, 2), { encoding: "utf8", flag: "wx", mode: 0o600 });
171
- await chmod(markerPath, 0o600).catch(() => undefined);
172
- return markerPath;
258
+ return await writeTempRootOwnershipMarkerRecord(tempRoot, markerRecord, { flag: "wx" });
173
259
  }
174
260
 
175
261
  async function refreshSecureTempRootLease(tempRoot: string): Promise<void> {
176
- const markerPath = join(tempRoot, TEMP_ROOT_MARKER_FILE_NAME);
177
262
  const ownershipMarker = await readTempRootOwnershipMarker(tempRoot);
178
263
  if (!ownershipMarker) return;
179
264
  if (ownershipMarker.ownerPid !== process.pid) return;
@@ -194,8 +279,7 @@ async function refreshSecureTempRootLease(tempRoot: string): Promise<void> {
194
279
  ownerProcessStartIdentity: currentProcessStartIdentity ?? ownershipMarker.ownerProcessStartIdentity,
195
280
  ownerUid: currentUid,
196
281
  };
197
- await writeFile(markerPath, JSON.stringify(refreshedMarker, null, 2), { encoding: "utf8", mode: 0o600 });
198
- await chmod(markerPath, 0o600).catch(() => undefined);
282
+ await writeTempRootOwnershipMarkerRecord(tempRoot, refreshedMarker);
199
283
  }
200
284
 
201
285
  async function getMarkerOwnerLiveness(ownershipMarker: TempRootOwnershipRecord): Promise<ProcessLiveness> {
@@ -210,14 +294,10 @@ async function getMarkerOwnerLiveness(ownershipMarker: TempRootOwnershipRecord):
210
294
  }
211
295
 
212
296
  const currentProcessStartIdentity = await getProcessStartIdentity(pid);
213
- if (
214
- ownershipMarker.ownerProcessStartIdentity !== undefined &&
215
- currentProcessStartIdentity !== undefined &&
216
- ownershipMarker.ownerProcessStartIdentity === currentProcessStartIdentity
217
- ) {
218
- return "alive";
297
+ if (ownershipMarker.ownerProcessStartIdentity === undefined || currentProcessStartIdentity === undefined) {
298
+ return "unknown";
219
299
  }
220
- return "dead";
300
+ return ownershipMarker.ownerProcessStartIdentity === currentProcessStartIdentity ? "alive" : "dead";
221
301
  }
222
302
 
223
303
  async function pruneStaleTempRoots(currentTempRoot: string | undefined): Promise<void> {
@@ -243,22 +323,52 @@ async function pruneStaleTempRoots(currentTempRoot: string | undefined): Promise
243
323
  }
244
324
  const staleTimestampMs = ownershipMarker.leaseUpdatedAtMs ?? ownershipMarker.createdAtMs;
245
325
  if (staleTimestampMs >= cutoffTime) return;
246
- if ((await getMarkerOwnerLiveness(ownershipMarker)) === "alive") return;
326
+ // Preserve roots when owner liveness cannot be proven; safe cleanup beats deleting another live process's files.
327
+ if ((await getMarkerOwnerLiveness(ownershipMarker)) !== "dead") return;
247
328
 
248
329
  const stats = await stat(path).catch(() => undefined);
249
330
  if (!stats?.isDirectory()) return;
331
+ const protectedChildren = await getExistingProtectedChildren(
332
+ path,
333
+ getPersistedProtectedChildPaths(path, ownershipMarker),
334
+ );
335
+ if (protectedChildren.size > 0) {
336
+ await removeTempRootChildrenExcept(path, protectedChildren);
337
+ return;
338
+ }
250
339
  await rm(path, { force: true, recursive: true }).catch(() => undefined);
251
340
  }),
252
341
  );
253
342
  }
254
343
 
344
+ function getProtectedChildrenForRoot(tempRoot: string): Set<string> {
345
+ const normalizedTempRoot = resolve(tempRoot);
346
+ return new Set(
347
+ [...protectedTempChildren].filter((path) => dirname(path) === normalizedTempRoot && existsSync(path)),
348
+ );
349
+ }
350
+
351
+ function removeTempRootChildrenExceptSync(tempRoot: string, protectedChildren: ReadonlySet<string>): void {
352
+ for (const entry of readdirSync(tempRoot, { withFileTypes: true })) {
353
+ if (entry.name === TEMP_ROOT_MARKER_FILE_NAME) continue;
354
+ const entryPath = join(tempRoot, entry.name);
355
+ if (protectedChildren.has(resolve(entryPath))) continue;
356
+ rmSync(entryPath, { force: true, recursive: true });
357
+ }
358
+ }
359
+
255
360
  function registerExitCleanup(): void {
256
361
  if (exitCleanupRegistered) return;
257
362
  exitCleanupRegistered = true;
258
363
  process.once("exit", () => {
259
364
  for (const tempRoot of ownedTempRoots) {
260
365
  try {
261
- rmSync(tempRoot, { force: true, recursive: true });
366
+ const protectedChildren = getProtectedChildrenForRoot(tempRoot);
367
+ if (protectedChildren.size === 0) {
368
+ rmSync(tempRoot, { force: true, recursive: true });
369
+ } else {
370
+ removeTempRootChildrenExceptSync(tempRoot, protectedChildren);
371
+ }
262
372
  } catch {
263
373
  // Best-effort cleanup only.
264
374
  }
@@ -284,13 +394,28 @@ async function assertSecureTempRootBudget(tempRoot: string, additionalBytes: num
284
394
  }
285
395
  }
286
396
 
287
- export async function cleanupSecureTempArtifacts(): Promise<void> {
397
+ export async function cleanupSecureTempArtifacts(options: { preservePaths?: readonly string[] } = {}): Promise<void> {
288
398
  await enqueueTempMutation(async () => {
289
399
  const tempRoot = await sessionTempRootPromise?.catch(() => undefined);
290
- sessionTempRootPromise = undefined;
291
400
  if (!tempRoot) return;
292
- ownedTempRoots.delete(tempRoot);
293
- await rm(tempRoot, { force: true, recursive: true }).catch(() => undefined);
401
+ const normalizedTempRoot = resolve(tempRoot);
402
+ for (const path of options.preservePaths ?? []) {
403
+ const childName = getProtectedTempChildName(normalizedTempRoot, path);
404
+ if (childName) protectedTempChildren.add(resolve(join(normalizedTempRoot, childName)));
405
+ }
406
+ const preservedChildren = await getExistingProtectedChildren(normalizedTempRoot, protectedTempChildren);
407
+ for (const path of protectedTempChildren) {
408
+ if (dirname(path) === normalizedTempRoot && !preservedChildren.has(path)) protectedTempChildren.delete(path);
409
+ }
410
+ if (preservedChildren.size === 0) {
411
+ sessionTempRootPromise = undefined;
412
+ ownedTempRoots.delete(tempRoot);
413
+ await rm(tempRoot, { force: true, recursive: true }).catch(() => undefined);
414
+ return;
415
+ }
416
+ await persistProtectedTempChildren(tempRoot, preservedChildren);
417
+ await removeTempRootChildrenExcept(tempRoot, preservedChildren);
418
+ await refreshSecureTempRootLease(tempRoot).catch(() => undefined);
294
419
  });
295
420
  }
296
421
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-agent-browser-native",
3
- "version": "0.2.34",
3
+ "version": "0.2.36",
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)",
@@ -56,9 +56,9 @@
56
56
  "typebox": "*"
57
57
  },
58
58
  "devDependencies": {
59
- "@earendil-works/pi-ai": "^0.75.4",
60
- "@earendil-works/pi-coding-agent": "^0.75.4",
61
- "@earendil-works/pi-tui": "^0.75.4",
59
+ "@earendil-works/pi-ai": "^0.76.0",
60
+ "@earendil-works/pi-coding-agent": "^0.76.0",
61
+ "@earendil-works/pi-tui": "^0.76.0",
62
62
  "@types/node": "^25.6.1",
63
63
  "tsx": "^4.21.0",
64
64
  "typebox": "^1.1.38",