pi-agent-browser-native 0.2.38 → 0.2.40

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.
@@ -0,0 +1,446 @@
1
+ /**
2
+ * Purpose: Load pi-agent-browser-native package configuration from Pi-scoped global, project, or explicit paths.
3
+ * Responsibilities: Resolve config paths, validate the v1 JSON shape, merge layers, classify credential sources, resolve secrets without exposing values, and provide redacted status for tools/CLIs.
4
+ * Scope: Package-owned configuration only; browser command execution and web-search API calls live in focused modules.
5
+ * Usage: The extension reads this at startup to decide optional tool registration; scripts/config.mjs mirrors the file locations for user setup.
6
+ * Invariants/Assumptions: Raw project-local plaintext credentials are unsafe and rejected; command credentials are resolved lazily at execution time.
7
+ */
8
+
9
+ import { exec as execCallback } from "node:child_process";
10
+ import { readFileSync } from "node:fs";
11
+ import { readFile } from "node:fs/promises";
12
+ import { homedir } from "node:os";
13
+ import { join, resolve } from "node:path";
14
+ import { promisify } from "node:util";
15
+
16
+ const exec = promisify(execCallback);
17
+
18
+ export const AGENT_BROWSER_CONFIG_ENV = "PI_AGENT_BROWSER_CONFIG";
19
+ export const BRAVE_API_KEY_ENV = "BRAVE_API_KEY";
20
+ export const CONFIG_RELATIVE_PATH = [".pi", "config", "pi-agent-browser-native", "config.json"] as const;
21
+ export const GLOBAL_CONFIG_RELATIVE_PATH = [".pi", "config", "pi-agent-browser-native", "config.json"] as const;
22
+ export const SECRET_COMMAND_TIMEOUT_MS = 15_000;
23
+
24
+ export type BrowserDefaultProfilePolicy = "explicit-only" | "authenticated-only" | "always";
25
+ export type AgentBrowserConfigScope = "global" | "project" | "override" | "env-fallback";
26
+ export type CredentialSourceKind = "literal" | "env" | "command";
27
+
28
+ export interface BrowserDefaultProfileConfig {
29
+ name: string;
30
+ policy?: BrowserDefaultProfilePolicy;
31
+ }
32
+
33
+ export interface AgentBrowserConfig {
34
+ version?: 1;
35
+ webSearch?: {
36
+ braveApiKey?: string;
37
+ };
38
+ browser?: {
39
+ defaultProfile?: BrowserDefaultProfileConfig;
40
+ defaultLaunchArgs?: string[];
41
+ };
42
+ }
43
+
44
+ export interface ConfigLayer {
45
+ config: AgentBrowserConfig;
46
+ path: string;
47
+ scope: Exclude<AgentBrowserConfigScope, "env-fallback">;
48
+ }
49
+
50
+ export interface CredentialSource {
51
+ kind: CredentialSourceKind;
52
+ rawValue: string;
53
+ scope: AgentBrowserConfigScope;
54
+ }
55
+
56
+ export interface AgentBrowserConfigState {
57
+ browserDefaultProfile?: Required<BrowserDefaultProfileConfig>;
58
+ config: AgentBrowserConfig;
59
+ credentialSource?: CredentialSource;
60
+ errors: string[];
61
+ layers: ConfigLayer[];
62
+ paths: {
63
+ global: string;
64
+ project: string;
65
+ override?: string;
66
+ };
67
+ warnings: string[];
68
+ }
69
+
70
+ export interface ResolvedCredential {
71
+ source: CredentialSource;
72
+ value: string;
73
+ }
74
+
75
+ function isRecord(value: unknown): value is Record<string, unknown> {
76
+ return typeof value === "object" && value !== null && !Array.isArray(value);
77
+ }
78
+
79
+ function hasOwn(value: Record<string, unknown>, key: string): boolean {
80
+ return Object.prototype.hasOwnProperty.call(value, key);
81
+ }
82
+
83
+ export function getGlobalAgentBrowserConfigPath(env: NodeJS.ProcessEnv = process.env): string {
84
+ const home = env.HOME?.trim() || env.USERPROFILE?.trim() || homedir();
85
+ return join(home, ...GLOBAL_CONFIG_RELATIVE_PATH);
86
+ }
87
+
88
+ export function getProjectAgentBrowserConfigPath(cwd = process.cwd()): string {
89
+ return resolve(cwd, ...CONFIG_RELATIVE_PATH);
90
+ }
91
+
92
+ export function getAgentBrowserConfigPaths(options: { cwd?: string; env?: NodeJS.ProcessEnv } = {}) {
93
+ const env = options.env ?? process.env;
94
+ const override = env[AGENT_BROWSER_CONFIG_ENV]?.trim();
95
+ return {
96
+ global: getGlobalAgentBrowserConfigPath(env),
97
+ project: getProjectAgentBrowserConfigPath(options.cwd),
98
+ override: override ? resolve(override) : undefined,
99
+ };
100
+ }
101
+
102
+ function mergeConfig(base: AgentBrowserConfig, override: AgentBrowserConfig): AgentBrowserConfig {
103
+ return {
104
+ ...base,
105
+ ...override,
106
+ browser: {
107
+ ...(base.browser ?? {}),
108
+ ...(override.browser ?? {}),
109
+ defaultProfile: override.browser?.defaultProfile ?? base.browser?.defaultProfile,
110
+ },
111
+ webSearch: {
112
+ ...(base.webSearch ?? {}),
113
+ ...(override.webSearch ?? {}),
114
+ },
115
+ };
116
+ }
117
+
118
+ function validateString(value: unknown, path: string, errors: string[]): string | undefined {
119
+ if (value === undefined) return undefined;
120
+ if (typeof value !== "string") {
121
+ errors.push(`${path} must be a string.`);
122
+ return undefined;
123
+ }
124
+ return value;
125
+ }
126
+
127
+ function validateStringArray(value: unknown, path: string, errors: string[]): string[] | undefined {
128
+ if (value === undefined) return undefined;
129
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) {
130
+ errors.push(`${path} must be an array of strings.`);
131
+ return undefined;
132
+ }
133
+ return value;
134
+ }
135
+
136
+ function validateBrowserDefaultProfile(value: unknown, path: string, errors: string[]): BrowserDefaultProfileConfig | undefined {
137
+ if (value === undefined) return undefined;
138
+ if (!isRecord(value)) {
139
+ errors.push(`${path} must be an object.`);
140
+ return undefined;
141
+ }
142
+ const name = validateString(value.name, `${path}.name`, errors)?.trim();
143
+ if (!name) {
144
+ errors.push(`${path}.name must not be blank.`);
145
+ return undefined;
146
+ }
147
+ const rawPolicy = validateString(value.policy, `${path}.policy`, errors);
148
+ const policy = rawPolicy ?? "authenticated-only";
149
+ if (!(["explicit-only", "authenticated-only", "always"] as const).includes(policy as BrowserDefaultProfilePolicy)) {
150
+ errors.push(`${path}.policy must be one of explicit-only, authenticated-only, always.`);
151
+ return undefined;
152
+ }
153
+ return { name, policy: policy as BrowserDefaultProfilePolicy };
154
+ }
155
+
156
+ function validateConfig(value: unknown, path: string, scope: ConfigLayer["scope"], errors: string[], warnings: string[]): AgentBrowserConfig | undefined {
157
+ if (!isRecord(value)) {
158
+ errors.push(`${path} must contain a JSON object.`);
159
+ return undefined;
160
+ }
161
+ if (value.version !== undefined && value.version !== 1) {
162
+ errors.push(`${path}.version must be 1 when present.`);
163
+ }
164
+ const config: AgentBrowserConfig = value.version === 1 ? { version: 1 } : {};
165
+
166
+ if (value.webSearch !== undefined) {
167
+ if (!isRecord(value.webSearch)) {
168
+ errors.push(`${path}.webSearch must be an object.`);
169
+ } else {
170
+ const braveApiKey = validateString(value.webSearch.braveApiKey, `${path}.webSearch.braveApiKey`, errors);
171
+ if (braveApiKey !== undefined) {
172
+ config.webSearch = { braveApiKey };
173
+ if (scope === "project" && !isProjectSafeCredentialValue(braveApiKey)) {
174
+ errors.push(`${path}.webSearch.braveApiKey must be exactly $ENV_VAR or ${"${ENV_VAR}"} in project-local config; plaintext, interpolation literals, malformed env references, and command-backed project secrets are not allowed.`);
175
+ }
176
+ }
177
+ }
178
+ }
179
+
180
+ if (value.browser !== undefined) {
181
+ if (!isRecord(value.browser)) {
182
+ errors.push(`${path}.browser must be an object.`);
183
+ } else {
184
+ config.browser = {};
185
+ const defaultProfile = validateBrowserDefaultProfile(value.browser.defaultProfile, `${path}.browser.defaultProfile`, errors);
186
+ if (defaultProfile) config.browser.defaultProfile = defaultProfile;
187
+ const defaultLaunchArgs = validateStringArray(value.browser.defaultLaunchArgs, `${path}.browser.defaultLaunchArgs`, errors);
188
+ if (defaultLaunchArgs) {
189
+ config.browser.defaultLaunchArgs = defaultLaunchArgs;
190
+ warnings.push(`${path}.browser.defaultLaunchArgs is recorded for future use; current releases do not auto-inject default launch args.`);
191
+ }
192
+ }
193
+ }
194
+
195
+ for (const key of Object.keys(value)) {
196
+ if (!["version", "webSearch", "browser"].includes(key)) {
197
+ warnings.push(`${path}.${key} is not a recognized pi-agent-browser-native config field and was ignored.`);
198
+ }
199
+ }
200
+ return config;
201
+ }
202
+
203
+ function parseConfigLayer(raw: string, path: string, scope: ConfigLayer["scope"], errors: string[], warnings: string[]): ConfigLayer | undefined {
204
+ let parsed: unknown;
205
+ try {
206
+ parsed = JSON.parse(raw);
207
+ } catch (error) {
208
+ errors.push(`Could not parse ${scope} config ${path}: ${error instanceof Error ? error.message : String(error)}`);
209
+ return undefined;
210
+ }
211
+ const config = validateConfig(parsed, path, scope, errors, warnings);
212
+ return config ? { config, path, scope } : undefined;
213
+ }
214
+
215
+ async function readConfigLayer(path: string, scope: ConfigLayer["scope"], errors: string[], warnings: string[]): Promise<ConfigLayer | undefined> {
216
+ let raw: string;
217
+ try {
218
+ raw = await readFile(path, "utf8");
219
+ } catch (error) {
220
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
221
+ return undefined;
222
+ }
223
+ errors.push(`Could not read ${scope} config ${path}: ${error instanceof Error ? error.message : String(error)}`);
224
+ return undefined;
225
+ }
226
+ return parseConfigLayer(raw, path, scope, errors, warnings);
227
+ }
228
+
229
+ function readConfigLayerSync(path: string, scope: ConfigLayer["scope"], errors: string[], warnings: string[]): ConfigLayer | undefined {
230
+ let raw: string;
231
+ try {
232
+ raw = readFileSync(path, "utf8");
233
+ } catch (error) {
234
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
235
+ return undefined;
236
+ }
237
+ errors.push(`Could not read ${scope} config ${path}: ${error instanceof Error ? error.message : String(error)}`);
238
+ return undefined;
239
+ }
240
+ return parseConfigLayer(raw, path, scope, errors, warnings);
241
+ }
242
+
243
+ export function isPlaintextCredentialValue(rawValue: string): boolean {
244
+ const trimmed = rawValue.trim();
245
+ return Boolean(trimmed) && !trimmed.startsWith("!") && !trimmed.startsWith("$");
246
+ }
247
+
248
+ export function isProjectSafeCredentialValue(rawValue: string): boolean {
249
+ const trimmed = rawValue.trim();
250
+ return /^\$[A-Za-z_][A-Za-z0-9_]*$/.test(trimmed) || /^\$\{[A-Za-z_][A-Za-z0-9_]*\}$/.test(trimmed);
251
+ }
252
+
253
+ export function classifyCredentialSource(rawValue: string, scope: AgentBrowserConfigScope): CredentialSource | undefined {
254
+ const trimmed = rawValue.trim();
255
+ if (!trimmed) return undefined;
256
+ if (trimmed.startsWith("!")) return { kind: "command", rawValue: trimmed, scope };
257
+ if (trimmed.includes("$")) return { kind: "env", rawValue: trimmed, scope };
258
+ return { kind: "literal", rawValue: trimmed, scope };
259
+ }
260
+
261
+ function getBrowserDefaultProfile(config: AgentBrowserConfig): Required<BrowserDefaultProfileConfig> | undefined {
262
+ const profile = config.browser?.defaultProfile;
263
+ if (!profile?.name.trim()) return undefined;
264
+ return { name: profile.name.trim(), policy: profile.policy ?? "authenticated-only" };
265
+ }
266
+
267
+ function buildConfigState(options: {
268
+ env: NodeJS.ProcessEnv;
269
+ layers: ConfigLayer[];
270
+ mergedConfig: AgentBrowserConfig;
271
+ paths: AgentBrowserConfigState["paths"];
272
+ errors: string[];
273
+ warnings: string[];
274
+ }): AgentBrowserConfigState {
275
+ let credentialScope: AgentBrowserConfigScope = "global";
276
+ for (let index = options.layers.length - 1; index >= 0; index -= 1) {
277
+ const layer = options.layers[index];
278
+ if (layer?.config.webSearch?.braveApiKey !== undefined) {
279
+ credentialScope = layer.scope;
280
+ break;
281
+ }
282
+ }
283
+ let credentialSource = options.mergedConfig.webSearch?.braveApiKey === undefined
284
+ ? undefined
285
+ : classifyCredentialSource(options.mergedConfig.webSearch.braveApiKey, credentialScope);
286
+ if (!credentialSource && options.env[BRAVE_API_KEY_ENV]?.trim()) {
287
+ credentialSource = { kind: "literal", rawValue: options.env[BRAVE_API_KEY_ENV] ?? "", scope: "env-fallback" };
288
+ }
289
+ return {
290
+ browserDefaultProfile: getBrowserDefaultProfile(options.mergedConfig),
291
+ config: options.mergedConfig,
292
+ credentialSource,
293
+ errors: options.errors,
294
+ layers: options.layers,
295
+ paths: options.paths,
296
+ warnings: options.warnings,
297
+ };
298
+ }
299
+
300
+ export async function loadAgentBrowserConfig(options: { cwd?: string; env?: NodeJS.ProcessEnv } = {}): Promise<AgentBrowserConfigState> {
301
+ const env = options.env ?? process.env;
302
+ const paths = getAgentBrowserConfigPaths({ cwd: options.cwd, env });
303
+ const errors: string[] = [];
304
+ const warnings: string[] = [];
305
+ const layerCandidates = [
306
+ { path: paths.global, scope: "global" as const },
307
+ { path: paths.project, scope: "project" as const },
308
+ ...(paths.override ? [{ path: paths.override, scope: "override" as const }] : []),
309
+ ];
310
+ const layers: ConfigLayer[] = [];
311
+ let mergedConfig: AgentBrowserConfig = {};
312
+ for (const candidate of layerCandidates) {
313
+ const layer = await readConfigLayer(candidate.path, candidate.scope, errors, warnings);
314
+ if (!layer) continue;
315
+ layers.push(layer);
316
+ mergedConfig = mergeConfig(mergedConfig, layer.config);
317
+ }
318
+ return buildConfigState({ env, errors, layers, mergedConfig, paths, warnings });
319
+ }
320
+
321
+ export function loadAgentBrowserConfigSync(options: { cwd?: string; env?: NodeJS.ProcessEnv } = {}): AgentBrowserConfigState {
322
+ const env = options.env ?? process.env;
323
+ const paths = getAgentBrowserConfigPaths({ cwd: options.cwd, env });
324
+ const errors: string[] = [];
325
+ const warnings: string[] = [];
326
+ const layerCandidates = [
327
+ { path: paths.global, scope: "global" as const },
328
+ { path: paths.project, scope: "project" as const },
329
+ ...(paths.override ? [{ path: paths.override, scope: "override" as const }] : []),
330
+ ];
331
+ const layers: ConfigLayer[] = [];
332
+ let mergedConfig: AgentBrowserConfig = {};
333
+ for (const candidate of layerCandidates) {
334
+ const layer = readConfigLayerSync(candidate.path, candidate.scope, errors, warnings);
335
+ if (!layer) continue;
336
+ layers.push(layer);
337
+ mergedConfig = mergeConfig(mergedConfig, layer.config);
338
+ }
339
+ return buildConfigState({ env, errors, layers, mergedConfig, paths, warnings });
340
+ }
341
+
342
+ function resolveEnvInterpolations(rawValue: string, env: NodeJS.ProcessEnv): string | undefined {
343
+ let output = "";
344
+ for (let index = 0; index < rawValue.length; index += 1) {
345
+ const char = rawValue[index];
346
+ if (char !== "$") {
347
+ output += char;
348
+ continue;
349
+ }
350
+ const next = rawValue[index + 1];
351
+ if (next === "$") {
352
+ output += "$";
353
+ index += 1;
354
+ continue;
355
+ }
356
+ if (next === "!") {
357
+ output += "!";
358
+ index += 1;
359
+ continue;
360
+ }
361
+ let name = "";
362
+ if (next === "{") {
363
+ const end = rawValue.indexOf("}", index + 2);
364
+ if (end === -1) return undefined;
365
+ name = rawValue.slice(index + 2, end);
366
+ index = end;
367
+ } else {
368
+ const match = rawValue.slice(index + 1).match(/^([A-Za-z_][A-Za-z0-9_]*)/);
369
+ if (!match) {
370
+ output += "$";
371
+ continue;
372
+ }
373
+ name = match[1] ?? "";
374
+ index += name.length;
375
+ }
376
+ if (!name) return undefined;
377
+ const value = env[name];
378
+ if (value === undefined) return undefined;
379
+ output += value;
380
+ }
381
+ return output;
382
+ }
383
+
384
+ async function resolveCommandCredential(rawValue: string, signal?: AbortSignal): Promise<string | undefined> {
385
+ const command = rawValue.slice(1).trim();
386
+ if (!command) return undefined;
387
+ try {
388
+ const result = await exec(command, {
389
+ signal,
390
+ timeout: SECRET_COMMAND_TIMEOUT_MS,
391
+ maxBuffer: 1024 * 1024,
392
+ });
393
+ const value = result.stdout.trim();
394
+ return value.length > 0 ? value : undefined;
395
+ } catch (error) {
396
+ if (signal?.aborted) throw error;
397
+ throw new Error("Credential command failed without exposing command output. Check pi-agent-browser-config web-search status and the configured secret manager command.");
398
+ }
399
+ }
400
+
401
+ export async function resolveCredentialSource(
402
+ source: CredentialSource | undefined,
403
+ options: { env?: NodeJS.ProcessEnv; signal?: AbortSignal } = {},
404
+ ): Promise<ResolvedCredential | undefined> {
405
+ if (!source) return undefined;
406
+ let value: string | undefined;
407
+ if (source.kind === "command") {
408
+ value = await resolveCommandCredential(source.rawValue, options.signal);
409
+ } else if (source.kind === "env") {
410
+ value = resolveEnvInterpolations(source.rawValue, options.env ?? process.env)?.trim();
411
+ } else {
412
+ value = source.rawValue.trim();
413
+ }
414
+ return value ? { source, value } : undefined;
415
+ }
416
+
417
+ export async function resolveBraveApiKey(
418
+ state: AgentBrowserConfigState,
419
+ options: { env?: NodeJS.ProcessEnv; signal?: AbortSignal } = {},
420
+ ): Promise<ResolvedCredential | undefined> {
421
+ return resolveCredentialSource(state.credentialSource, options);
422
+ }
423
+
424
+ export function canRegisterWebSearchTool(state: AgentBrowserConfigState, env: NodeJS.ProcessEnv = process.env): boolean {
425
+ if (!state.credentialSource || state.errors.length > 0) return false;
426
+ if (state.credentialSource.kind === "command") return true;
427
+ if (state.credentialSource.kind === "env") return Boolean(resolveEnvInterpolations(state.credentialSource.rawValue, env)?.trim());
428
+ return Boolean(state.credentialSource.rawValue.trim());
429
+ }
430
+
431
+ export async function hasResolvableCredentialSource(
432
+ state: AgentBrowserConfigState,
433
+ options: { env?: NodeJS.ProcessEnv } = {},
434
+ ): Promise<boolean> {
435
+ if (!state.credentialSource || state.errors.length > 0) return false;
436
+ if (state.credentialSource.kind === "command") return true;
437
+ return Boolean((await resolveCredentialSource(state.credentialSource, options))?.value);
438
+ }
439
+
440
+ export function getCredentialSourceSummary(source: CredentialSource | undefined): string {
441
+ if (!source) return "not configured";
442
+ if (source.kind === "command") return `configured via command (${source.scope})`;
443
+ if (source.kind === "env") return `configured via environment interpolation (${source.scope})`;
444
+ if (source.scope === "env-fallback") return `configured via ${BRAVE_API_KEY_ENV} environment fallback`;
445
+ return `configured as plaintext ${source.scope} value [redacted]`;
446
+ }
@@ -30,7 +30,7 @@ export const QUICK_START_GUIDELINES = [
30
30
  ] as const;
31
31
 
32
32
  export const BRAVE_SEARCH_PROMPT_GUIDELINE =
33
- "When a non-empty BRAVE_API_KEY is available in the current environment, prefer the Brave Search API via bash/curl to discover specific destination URLs, then open the chosen URL with agent_browser instead of browsing a search engine results page just to find the target.";
33
+ "When agent_browser_web_search is available, use it for live web search when current or external web information would help; use agent_browser when the task needs browser interaction, page inspection, screenshots, authenticated/profile content, or DOM work.";
34
34
 
35
35
  export const SHARED_BROWSER_PLAYBOOK_GUIDELINES = [
36
36
  "Standard workflow: open the page, snapshot -i, interact using current @refs from that snapshot, and re-snapshot after navigation, scrolling, rerendering, or other major DOM changes because refs are page-scoped; the wrapper fails mutation-prone stale/recycled refs before upstream can silently target a different current-page element.",
@@ -51,7 +51,7 @@ export const SHARED_BROWSER_PLAYBOOK_GUIDELINES = [
51
51
  "For Electron desktop apps, prefer top-level electron for wrapper-owned discovery, isolated launch, status, compact probe, and cleanup: list first, treat likely-sensitive annotations as hints rather than enforcement, launch with the default snapshot handoff unless handoff: \"tabs\" is the safer diagnostic starting point, use electron.probe or snapshot -i/qa.attached for current-session state, and always cleanup the returned launchId when done. electron.launch uses an isolated temporary profile; it does not reuse the app's normal signed-in profile or attach to an already-running authenticated app. For signed-in local app state, host-launch the normal app with --remote-debugging-port when appropriate, then use raw args connect <port|url>; after connect, inspect tab list, select the stable tab id such as tab t2, then run a condition wait or snapshot -i before using refs. close commands (`close`, `quit`, or `exit`) only close the browser/CDP session; leave manually launched app shutdown, profile cleanup, and explicit artifacts to the host owner.",
52
52
  "For provider or specialized app workflows, load version-matched upstream guidance with skills get agentcore|electron|slack|dogfood|vercel-sandbox through the native tool; add --full when you need references/templates, and use skills get --all only for broad skill audits. Provider launches such as -p ios, --provider browserbase/kernel/browseruse/browserless/agentcore, and iOS --device are upstream-owned setup paths; use sessionMode fresh when switching providers and expect external credentials or local Appium/Xcode setup to be required.",
53
53
  "For dialogs and frames, use dialog status/accept/dismiss and frame <selector|main> through native args; when --confirm-actions produces a pending confirmation, use details.nextActions or exact confirm <id> / deny <id> calls instead of inventing ids.",
54
- "If a session lands on the wrong page or tab, an interaction changes origin unexpectedly, or an open call returns blocked, blank, or otherwise unexpected results, use tab list / tab <tab-id-or-label> / snapshot -i to recover state before retrying different URLs or fallback strategies. For headed demos, put --headed on the first launch with sessionMode=fresh and verify with screenshot/tab/get-url evidence because tool success cannot prove the OS window is visible to the user. For desktop readiness, prefer real conditions first: wait --text, wait --url, wait --fn, wait --load <state>, wait --download, or qa.attached; for disappearance checks in agent-browser 0.27.0, use wait --fn predicates instead of stale upstream-help examples like wait <selector> --state hidden. Use electron.probe/status for wrapper-owned launch health or target mismatch. Fixed waits are a last resort, must stay below the wrapper IPC budget (wait 30000 is intentionally blocked), and a successful payload like \"waited\":\"timeout\" means elapsed time only—verify completion with an observed condition, fresh snapshot, or screenshot.",
54
+ "If a session lands on the wrong page or tab, an interaction changes origin unexpectedly, or an open call returns blocked, blank, or otherwise unexpected results, use tab list / tab <tab-id-or-label> / snapshot -i to recover state before retrying different URLs or fallback strategies. For headed demos, put --headed on the first launch with sessionMode=fresh and verify with screenshot/tab/get-url evidence because tool success cannot prove the OS window is visible to the user. For desktop readiness, prefer real conditions first: wait --text, wait --url, wait --fn, wait --load <state>, wait --download, or qa.attached; for disappearance checks in agent-browser 0.27.1, use wait --fn predicates instead of stale upstream-help examples like wait <selector> --state hidden. Use electron.probe/status for wrapper-owned launch health or target mismatch. Fixed waits are a last resort, must stay below the wrapper IPC budget (wait 30000 is intentionally blocked), and a successful payload like \"waited\":\"timeout\" means elapsed time only—verify completion with an observed condition, fresh snapshot, or screenshot.",
55
55
  "For feed, timeline, or inbox reading tasks, focus on the main timeline/list region and read the first item there rather than unrelated composer or sidebar content.",
56
56
  "For read-only browsing tasks, prefer extracting the answer from the current snapshot, structured ref labels, or eval --stdin on the current page before navigating away. Only click into media viewers, detail routes, or new pages when the current view does not contain the needed information.",
57
57
  "For downloads, prefer download <selector> <path> when an element click should save a file. Do not rely on click alone when you need the downloaded file on disk.",
@@ -105,11 +105,25 @@ export const RUNTIME_PROMPT_GUIDELINES = [
105
105
  "For extraction, prefer get title/url/text/html/value/attr/count or eval --stdin with plain expression, not console.log. Batch three or more known refs/selectors (e.g. [[\"get\",\"text\",\"@e1\"],[\"get\",\"text\",\"@e2\"]]); selector visibility warnings → visible @refs/nextActions.",
106
106
  ] as const;
107
107
 
108
- export function buildToolPromptGuidelines(options: { includeBraveSearch: boolean; docs?: { readmePath: string; commandReferencePath: string; toolContractPath: string } }): string[] {
108
+ export function buildBrowserDefaultProfileGuideline(profile: { name: string; policy: "explicit-only" | "authenticated-only" | "always" } | undefined): string | undefined {
109
+ if (!profile || profile.policy === "explicit-only") return undefined;
110
+ if (profile.policy === "always") {
111
+ return `Agent-browser config sets browser.defaultProfile.name to ${JSON.stringify(profile.name)} with policy always; use --profile ${JSON.stringify(profile.name)} with sessionMode:fresh when a fresh browser launch should use the configured profile, and treat profile content as model-visible user data.`;
112
+ }
113
+ return `Agent-browser config sets browser.defaultProfile.name to ${JSON.stringify(profile.name)}; for signed-in/account-specific browser tasks, start with --profile ${JSON.stringify(profile.name)} plus sessionMode:fresh unless the user asks for a different profile.`;
114
+ }
115
+
116
+ export function buildToolPromptGuidelines(options: {
117
+ browserDefaultProfile?: { name: string; policy: "explicit-only" | "authenticated-only" | "always" };
118
+ docs?: { readmePath: string; commandReferencePath: string; toolContractPath: string };
119
+ includeBraveSearch: boolean;
120
+ }): string[] {
121
+ const browserDefaultProfileGuideline = buildBrowserDefaultProfileGuideline(options.browserDefaultProfile);
109
122
  return [
110
123
  ...TOOL_PROMPT_GUIDELINES_PREFIX,
111
124
  ...(options.docs ? [buildInstalledDocsGuideline(options.docs)] : []),
112
125
  ...RUNTIME_PROMPT_GUIDELINES,
126
+ ...(browserDefaultProfileGuideline ? [browserDefaultProfileGuideline] : []),
113
127
  ...(options.includeBraveSearch ? [BRAVE_SEARCH_PROMPT_GUIDELINE] : []),
114
128
  TOOL_PROMPT_GUIDELINES_SUFFIX[0],
115
129
  TOOL_PROMPT_GUIDELINES_SUFFIX[1],
@@ -1,15 +1,16 @@
1
1
  /**
2
2
  * Purpose: Execute the upstream agent-browser binary for the pi-agent-browser extension.
3
- * Responsibilities: Spawn the agent-browser subprocess without a shell, forward a curated environment surface, stream optional stdin, bound in-memory output buffering, spill oversized stdout safely to a private temp file under a disk budget, and honor abort signals.
3
+ * Responsibilities: Spawn the agent-browser subprocess, forward a curated environment surface, stream optional stdin, bound in-memory output buffering, spill oversized stdout safely to a private temp file under a disk budget, and honor abort signals.
4
4
  * Scope: Process execution only; argument planning, output formatting, and pi tool registration live elsewhere.
5
5
  * Usage: Called by the extension tool after argument validation and session planning are complete.
6
- * Invariants/Assumptions: The binary name is always `agent-browser`, the wrapper never shells out, and callers handle semantic success/error interpretation.
6
+ * Invariants/Assumptions: The binary name is always `agent-browser`; Windows routes through PowerShell to invoke npm launchers with escaped argv; callers handle semantic success/error interpretation.
7
7
  */
8
8
 
9
9
  import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
10
10
  import { chmod, mkdir } from "node:fs/promises";
11
11
  import { env as processEnv, platform as processPlatform } from "node:process";
12
12
 
13
+ import { GLOBAL_BOOLEAN_FLAGS_WITH_OPTIONAL_VALUES, GLOBAL_VALUE_FLAGS, getFlagName } from "./argv-grammar.js";
13
14
  import { openSecureTempFile, writeSecureTempChunk } from "./temp.js";
14
15
 
15
16
  const MAX_BUFFERED_STDOUT_BYTES = 512 * 1_024;
@@ -107,6 +108,52 @@ function appendTail(text: string, addition: string, maxChars: number): string {
107
108
  return combined.length <= maxChars ? combined : combined.slice(combined.length - maxChars);
108
109
  }
109
110
 
111
+ function quoteWindowsPowerShellArg(value: string): string {
112
+ return `'${value.replace(/'/g, "''")}'`;
113
+ }
114
+
115
+ const WINDOWS_LEADING_GLOBAL_VALUE_FLAGS = new Set<string>(GLOBAL_VALUE_FLAGS);
116
+
117
+ /** Exported for unit tests that lock Windows launcher argv ordering. */
118
+ export function reorderWindowsLeadingGlobalArgs(args: string[]): string[] {
119
+ const leadingGlobals: string[] = [];
120
+ let index = 0;
121
+ while (index < args.length && args[index]?.startsWith("-")) {
122
+ const token = args[index];
123
+ const flagName = getFlagName(token);
124
+ leadingGlobals.push(token);
125
+ index += 1;
126
+ if (WINDOWS_LEADING_GLOBAL_VALUE_FLAGS.has(flagName) && !token.includes("=") && index < args.length) {
127
+ leadingGlobals.push(args[index]);
128
+ index += 1;
129
+ continue;
130
+ }
131
+ if (GLOBAL_BOOLEAN_FLAGS_WITH_OPTIONAL_VALUES.has(flagName) && ["true", "false"].includes(args[index] ?? "")) {
132
+ leadingGlobals.push(args[index]);
133
+ index += 1;
134
+ }
135
+ }
136
+ if (leadingGlobals.length === 0 || index >= args.length) return args;
137
+ return [args[index], ...leadingGlobals, ...args.slice(index + 1)];
138
+ }
139
+
140
+ function buildAgentBrowserSpawnCommand(args: string[]): { command: string; args: string[] } {
141
+ if (processPlatform !== "win32") {
142
+ return { command: "agent-browser", args };
143
+ }
144
+ const commandLine = ["&", "agent-browser", ...reorderWindowsLeadingGlobalArgs(args).map(quoteWindowsPowerShellArg)].join(" ");
145
+ return { command: "powershell.exe", args: ["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", commandLine] };
146
+ }
147
+
148
+ function terminateSpawnedChild(child: ChildProcessWithoutNullStreams, signal: NodeJS.Signals): void {
149
+ if (processPlatform === "win32" && child.pid) {
150
+ const killer = spawn("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" });
151
+ killer.on("error", () => undefined);
152
+ killer.unref();
153
+ }
154
+ child.kill(signal);
155
+ }
156
+
110
157
  /** Exported for unit tests that lock subprocess exit-code precedence. */
111
158
  export function resolveSpawnedChildExitCode(input: {
112
159
  closeCode?: number | null;
@@ -234,17 +281,27 @@ async function ensureAgentBrowserSocketDir(socketDir: string): Promise<boolean>
234
281
  }
235
282
  }
236
283
 
284
+ function getChildEnvName(name: string): string | undefined {
285
+ if (processPlatform === "win32") {
286
+ const upperName = name.toUpperCase();
287
+ if (INHERITED_ENV_NAMES.has(upperName)) return upperName;
288
+ return INHERITED_ENV_PREFIXES.some((prefix) => upperName.startsWith(prefix)) ? upperName : undefined;
289
+ }
290
+ if (INHERITED_ENV_NAMES.has(name) || INHERITED_ENV_PREFIXES.some((prefix) => name.startsWith(prefix))) {
291
+ return name;
292
+ }
293
+ return undefined;
294
+ }
295
+
237
296
  export function buildAgentBrowserProcessEnv(
238
297
  baseEnv: NodeJS.ProcessEnv = processEnv,
239
298
  overrides: NodeJS.ProcessEnv | undefined = undefined,
240
299
  ): NodeJS.ProcessEnv {
241
300
  const childEnv: NodeJS.ProcessEnv = {};
242
301
  for (const [name, value] of Object.entries(baseEnv)) {
243
- if (
244
- value !== undefined &&
245
- (INHERITED_ENV_NAMES.has(name) || INHERITED_ENV_PREFIXES.some((prefix) => name.startsWith(prefix)))
246
- ) {
247
- childEnv[name] = value;
302
+ const childName = getChildEnvName(name);
303
+ if (value !== undefined && childName) {
304
+ childEnv[childName] = value;
248
305
  }
249
306
  }
250
307
 
@@ -254,10 +311,11 @@ export function buildAgentBrowserProcessEnv(
254
311
  }
255
312
 
256
313
  for (const [name, value] of Object.entries(overrides)) {
314
+ const childName = getChildEnvName(name) ?? name;
257
315
  if (value === undefined) {
258
- delete childEnv[name];
316
+ delete childEnv[childName];
259
317
  } else {
260
- childEnv[name] = value;
318
+ childEnv[childName] = value;
261
319
  }
262
320
  }
263
321
  clampUpstreamDefaultTimeout(childEnv);
@@ -371,7 +429,8 @@ export async function runAgentBrowserProcess(options: {
371
429
  });
372
430
  };
373
431
 
374
- const child = spawn("agent-browser", args, {
432
+ const spawnCommand = buildAgentBrowserSpawnCommand(args);
433
+ const child = spawn(spawnCommand.command, spawnCommand.args, {
375
434
  cwd,
376
435
  env: buildAgentBrowserProcessEnv(processEnv, effectiveEnv),
377
436
  stdio: ["pipe", "pipe", "pipe"],
@@ -384,15 +443,15 @@ export async function runAgentBrowserProcess(options: {
384
443
  } else {
385
444
  timedOut = true;
386
445
  }
387
- child.kill("SIGTERM");
446
+ terminateSpawnedChild(child, "SIGTERM");
388
447
  killTimer = setTimeout(() => {
389
- child.kill("SIGKILL");
448
+ terminateSpawnedChild(child, "SIGKILL");
390
449
  }, 2_000);
391
450
  };
392
451
  const recordStdinError = (error: unknown) => {
393
452
  const stdinError = error instanceof Error ? error : new Error(String(error));
394
453
  const errorCode = (stdinError as NodeJS.ErrnoException).code;
395
- if (errorCode === "EPIPE" || errorCode === "ERR_STREAM_DESTROYED") {
454
+ if (errorCode === "EPIPE" || errorCode === "EOF" || errorCode === "ERR_STREAM_DESTROYED") {
396
455
  return;
397
456
  }
398
457
  if (!spawnError) {