pi-agent-browser-native 0.2.42 → 0.2.44

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 (39) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +14 -9
  3. package/docs/COMMAND_REFERENCE.md +9 -10
  4. package/docs/RELEASE.md +10 -4
  5. package/docs/SUPPORT_MATRIX.md +7 -6
  6. package/docs/TOOL_CONTRACT.md +27 -24
  7. package/docs/platform-smoke.md +13 -8
  8. package/extensions/agent-browser/index.ts +71 -2
  9. package/extensions/agent-browser/lib/input-modes/params.ts +1 -1
  10. package/extensions/agent-browser/lib/input-modes/types.ts +1 -1
  11. package/extensions/agent-browser/lib/navigation-policy.ts +95 -0
  12. package/extensions/agent-browser/lib/orchestration/browser-run/diagnostics.ts +2 -7
  13. package/extensions/agent-browser/lib/orchestration/browser-run/final-result.ts +1 -0
  14. package/extensions/agent-browser/lib/orchestration/browser-run/prepare.ts +2 -2
  15. package/extensions/agent-browser/lib/orchestration/browser-run/process-output.ts +103 -12
  16. package/extensions/agent-browser/lib/orchestration/browser-run/session-state.ts +20 -3
  17. package/extensions/agent-browser/lib/orchestration/browser-run/types.ts +6 -1
  18. package/extensions/agent-browser/lib/playbook.ts +4 -4
  19. package/extensions/agent-browser/lib/results/action-recommendations.ts +15 -0
  20. package/extensions/agent-browser/lib/results/contracts.ts +17 -0
  21. package/extensions/agent-browser/lib/results/network-routes.ts +80 -0
  22. package/extensions/agent-browser/lib/results/network.ts +10 -2
  23. package/extensions/agent-browser/lib/results/presentation/artifacts.ts +14 -0
  24. package/extensions/agent-browser/lib/results/presentation/batch.ts +36 -13
  25. package/extensions/agent-browser/lib/results/presentation/diagnostics.ts +154 -16
  26. package/extensions/agent-browser/lib/results/presentation/errors.ts +62 -2
  27. package/extensions/agent-browser/lib/results/presentation/semantic-action.ts +2 -4
  28. package/extensions/agent-browser/lib/results/presentation.ts +31 -1
  29. package/extensions/agent-browser/lib/results/selector-recovery.ts +11 -3
  30. package/extensions/agent-browser/lib/results/shared.ts +1 -0
  31. package/extensions/agent-browser/lib/results.ts +3 -0
  32. package/extensions/agent-browser/lib/runtime.ts +6 -0
  33. package/package.json +4 -4
  34. package/platform-smoke.config.mjs +10 -1
  35. package/scripts/doctor.mjs +70 -1
  36. package/scripts/platform-smoke/crabbox-runner.mjs +57 -29
  37. package/scripts/platform-smoke/doctor.mjs +22 -9
  38. package/scripts/platform-smoke/targets.mjs +58 -21
  39. package/scripts/platform-smoke.mjs +1 -0
@@ -14,6 +14,7 @@ import { detectConfirmationRequired } from "./confirmation.js";
14
14
  import type {
15
15
  AgentBrowserEnvelope,
16
16
  AgentBrowserNextAction,
17
+ NetworkRouteDiagnostic,
17
18
  SessionArtifactManifest,
18
19
  ToolPresentation,
19
20
  } from "./contracts.js";
@@ -29,7 +30,9 @@ import {
29
30
  extractImagePath,
30
31
  formatArtifactMetadataLines,
31
32
  formatArtifactSummary,
33
+ formatMissingArtifactFailureText,
32
34
  getSavedFileDetails,
35
+ hasMissingFileArtifact,
33
36
  isManifestFileArtifact,
34
37
  type ArtifactRequestContext,
35
38
  } from "./presentation/artifacts.js";
@@ -37,7 +40,9 @@ import { buildBatchPresentation, isAgentBrowserBatchResultArray } from "./presen
37
40
  import { getPresentationPaths } from "./presentation/content.js";
38
41
  import {
39
42
  buildNetworkRequestsNextActions,
43
+ buildStreamNextActions,
40
44
  enrichStreamStatusData,
45
+ formatNetworkRouteDiagnosticsText,
41
46
  redactPresentationData,
42
47
  } from "./presentation/diagnostics.js";
43
48
  import { buildErrorPresentation } from "./presentation/errors.js";
@@ -71,6 +76,8 @@ export async function buildToolPresentation(options: {
71
76
  cwd: string;
72
77
  envelope?: AgentBrowserEnvelope;
73
78
  errorText?: string;
79
+ networkRouteDiagnostics?: NetworkRouteDiagnostic[];
80
+ networkRoutes?: import("./contracts.js").NetworkRouteRecord[];
74
81
  persistentArtifactStore?: PersistentSessionArtifactStore;
75
82
  sessionName?: string;
76
83
  }): Promise<ToolPresentation> {
@@ -83,6 +90,8 @@ export async function buildToolPresentation(options: {
83
90
  cwd,
84
91
  envelope,
85
92
  errorText,
93
+ networkRouteDiagnostics,
94
+ networkRoutes,
86
95
  persistentArtifactStore,
87
96
  sessionName,
88
97
  } = options;
@@ -108,6 +117,7 @@ export async function buildToolPresentation(options: {
108
117
  buildNestedToolPresentation: buildToolPresentation,
109
118
  cwd,
110
119
  data,
120
+ networkRoutes,
111
121
  persistentArtifactStore,
112
122
  sessionName,
113
123
  summary,
@@ -124,6 +134,11 @@ export async function buildToolPresentation(options: {
124
134
  };
125
135
  }
126
136
 
137
+ if (networkRouteDiagnostics && networkRouteDiagnostics.length > 0 && presentation.content[0]?.type === "text") {
138
+ const diagnosticText = formatNetworkRouteDiagnosticsText(networkRouteDiagnostics);
139
+ if (diagnosticText) presentation.content[0] = { ...presentation.content[0], text: `${diagnosticText}\n\n${presentation.content[0].text}` };
140
+ presentation.networkRouteDiagnostics = networkRouteDiagnostics;
141
+ }
127
142
  if (artifacts.length > 0 && !presentation.artifacts) {
128
143
  presentation.artifacts = artifacts;
129
144
  }
@@ -161,6 +176,19 @@ export async function buildToolPresentation(options: {
161
176
  ) ?? presentationWithManifest.artifactVerification;
162
177
 
163
178
  const confirmationRequired = detectConfirmationRequired(data);
179
+ const missingArtifactFailureText = formatMissingArtifactFailureText(presentationWithManifest.artifacts);
180
+ if (missingArtifactFailureText && hasMissingFileArtifact(presentationWithManifest.artifacts)) {
181
+ presentationWithManifest.resultCategory = "failure";
182
+ presentationWithManifest.failureCategory = "artifact-missing";
183
+ presentationWithManifest.successCategory = undefined;
184
+ presentationWithManifest.summary = missingArtifactFailureText;
185
+ if (presentationWithManifest.content[0]?.type === "text") {
186
+ presentationWithManifest.content[0] = { ...presentationWithManifest.content[0], text: `${missingArtifactFailureText}\n\n${presentationWithManifest.content[0].text}` };
187
+ } else {
188
+ presentationWithManifest.content.unshift({ type: "text", text: missingArtifactFailureText });
189
+ }
190
+ }
191
+
164
192
  if (!presentationWithManifest.resultCategory) {
165
193
  const categoryDetails = buildAgentBrowserResultCategoryDetails({
166
194
  artifacts: presentationWithManifest.artifacts,
@@ -199,12 +227,14 @@ export async function buildToolPresentation(options: {
199
227
  successCategory: presentationWithManifest.successCategory,
200
228
  });
201
229
  const networkNextActions = commandInfo.command === "network" && commandInfo.subcommand === "requests" && presentationWithManifest.resultCategory === "success"
202
- ? buildNetworkRequestsNextActions(data, sessionName)
230
+ ? buildNetworkRequestsNextActions(data, sessionName, presentationWithManifest.networkRouteDiagnostics)
203
231
  : undefined;
232
+ const streamNextActions = presentationWithManifest.resultCategory === "success" ? buildStreamNextActions(commandInfo, data, sessionName) : undefined;
204
233
  presentationWithManifest.nextActions = mergeNextActions(
205
234
  presentationWithManifest.nextActions,
206
235
  genericNextActions,
207
236
  networkNextActions,
237
+ streamNextActions,
208
238
  );
209
239
  presentationWithManifest.pageChangeSummary = presentationWithManifest.pageChangeSummary ?? buildPageChangeSummary({
210
240
  artifacts: presentationWithManifest.artifacts,
@@ -3,7 +3,7 @@
3
3
  * Responsibilities: Parse find/semantic action targets, match current snapshot refs, build public diagnostics, text, and safe nextActions.
4
4
  * Scope: Selector recovery policy only; subprocess snapshot probing and result orchestration stay in the extension entrypoint.
5
5
  * Usage: The extension entrypoint supplies command tokens plus snapshot data after a selector-not-found failure.
6
- * Invariants/Assumptions: Fill recovery must never echo or auto-submit the user-provided fill text; keyboard insertion remains a separate explicit action.
6
+ * Invariants/Assumptions: Public fill recovery must never echo or auto-submit the user-provided fill text; guarded semanticAction fill pre-resolution may execute only one exact current editable ref.
7
7
  */
8
8
 
9
9
  import { isRecord } from "../parsing.js";
@@ -199,14 +199,22 @@ export interface VisibleRefActionResolution {
199
199
  }
200
200
 
201
201
  export function resolveVisibleRefActionFromSnapshot(options: {
202
+ allowFill?: boolean;
202
203
  compiledAction: SelectorRecoveryCompiledAction;
203
204
  snapshotData: unknown;
204
205
  }): VisibleRefActionResolution | undefined {
205
206
  const target = getFindVisibleRefFallbackTarget(options.compiledAction.args, { allowLeadingDashFillText: true });
206
- if (!target || target.action === "fill" || target.action === "select") return undefined;
207
+ if (!target || target.action === "select") return undefined;
207
208
  const snapshot = extractRefSnapshotFromData(options.snapshotData);
208
209
  if (!snapshot) return undefined;
209
- const candidate = getVisibleRefFallbackCandidates(target, options.snapshotData).find((item) => item.args !== undefined);
210
+ const candidates = getVisibleRefFallbackCandidates(target, options.snapshotData);
211
+ if (target.action === "fill") {
212
+ if (!options.allowFill || candidates.length !== 1 || target.text === undefined) return undefined;
213
+ const [candidate] = candidates;
214
+ if (!candidate || candidate.editableEvidence === false || !EDITABLE_CONTROL_ROLES.has(candidate.role.toLowerCase())) return undefined;
215
+ return { args: ["fill", candidate.ref, target.text], snapshot };
216
+ }
217
+ const candidate = candidates.find((item) => item.args !== undefined);
210
218
  if (!candidate?.args) return undefined;
211
219
  return { args: candidate.args, snapshot };
212
220
  }
@@ -13,6 +13,7 @@ export * from "./artifact-manifest.js";
13
13
  export * from "./artifact-state.js";
14
14
  export * from "./editable-ref-evidence.js";
15
15
  export * from "./network.js";
16
+ export * from "./network-routes.js";
16
17
  export * from "./next-actions.js";
17
18
  export * from "./recovery-actions.js";
18
19
  export * from "./recovery-next-actions.js";
@@ -8,6 +8,7 @@
8
8
 
9
9
  export { getAgentBrowserErrorText, parseAgentBrowserEnvelope } from "./results/envelope.js";
10
10
  export { buildToolPresentation } from "./results/presentation.js";
11
+ export { applyNetworkRouteRecords, buildNetworkRouteDiagnostics } from "./results/network-routes.js";
11
12
  export {
12
13
  buildAgentBrowserResultCategoryDetails,
13
14
  classifyAgentBrowserFailureCategory,
@@ -30,6 +31,8 @@ export type {
30
31
  AgentBrowserResultCategory,
31
32
  AgentBrowserResultCategoryDetails,
32
33
  AgentBrowserSuccessCategory,
34
+ NetworkRouteDiagnostic,
35
+ NetworkRouteRecord,
33
36
  FileArtifactKind,
34
37
  FileArtifactMetadata,
35
38
  ToolPresentation,
@@ -355,6 +355,12 @@ export function redactInvocationArgs(args: string[]): string[] {
355
355
  redacted[commandStartIndex + 4] = "[REDACTED]";
356
356
  }
357
357
 
358
+ if (commandStartIndex !== undefined && args[commandStartIndex] === "clipboard" && args[commandStartIndex + 1] === "write") {
359
+ for (let index = commandStartIndex + 2; index < redacted.length; index += 1) {
360
+ redacted[index] = "[REDACTED]";
361
+ }
362
+ }
363
+
358
364
  return redacted;
359
365
  }
360
366
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-agent-browser-native",
3
- "version": "0.2.42",
3
+ "version": "0.2.44",
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)",
@@ -62,9 +62,9 @@
62
62
  "typebox": "*"
63
63
  },
64
64
  "devDependencies": {
65
- "@earendil-works/pi-ai": "^0.78.0",
66
- "@earendil-works/pi-coding-agent": "^0.78.0",
67
- "@earendil-works/pi-tui": "^0.78.0",
65
+ "@earendil-works/pi-ai": "^0.78.1",
66
+ "@earendil-works/pi-coding-agent": "^0.78.1",
67
+ "@earendil-works/pi-tui": "^0.78.1",
68
68
  "@types/node": "^25.6.1",
69
69
  "tsx": "^4.21.0",
70
70
  "typebox": "^1.1.38",
@@ -8,11 +8,20 @@ export default {
8
8
  artifactRoot: ".artifacts/platform-smoke",
9
9
  requiredTargets: ["macos", "ubuntu", "windows-native"],
10
10
  requiredSuites: ["platform-build", "browser-dogfood-smoke"],
11
+ supportedTargets: ["macos", "ubuntu", "windows-native"],
11
12
  requiredCrabbox: {
12
13
  install: "Homebrew package or PLATFORM_SMOKE_CRABBOX override",
13
- minVersion: "0.24.0",
14
+ minVersion: "0.26.0",
15
+ },
16
+ macos: {
17
+ host: "localhost",
18
+ port: 22,
14
19
  },
15
20
  ubuntuContainerImage: "pi-agent-browser-native-platform:node24-agent-browser0.27.1",
21
+ windowsParallels: {
22
+ sourceVm: "pi-extension-windows-template",
23
+ snapshot: "crabbox-ready",
24
+ },
16
25
  nodeValidationMajor: 22,
17
26
  agentBrowserVersion: CAPABILITY_BASELINE.targetVersion,
18
27
  };
@@ -22,6 +22,7 @@ const PACKAGE_NAME = "pi-agent-browser-native";
22
22
  const REPO_URL_FRAGMENT = "github.com/fitchmultz/pi-agent-browser-native";
23
23
  const EXTENSION_ENTRYPOINT = "extensions/agent-browser/index.ts";
24
24
  const EXPECTED_VERSION = CAPABILITY_BASELINE.targetVersion;
25
+ const RECOMMENDED_PI_VERSION = "0.78.1";
25
26
  const DEFAULT_AGENT_DIR = resolve(homedir(), ".pi/agent");
26
27
  const THIS_PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
27
28
 
@@ -29,6 +30,27 @@ export function normalizeAgentBrowserVersion(output) {
29
30
  return String(output ?? "").trim().replace(/^agent-browser\s+/, "");
30
31
  }
31
32
 
33
+ export function normalizePiVersion(output) {
34
+ return String(output ?? "").trim().replace(/^pi\s+/, "");
35
+ }
36
+
37
+ function parseVersionParts(version) {
38
+ const match = String(version ?? "").match(/^(\d+)\.(\d+)\.(\d+)(?:\b|[-+])/);
39
+ if (!match) return undefined;
40
+ return match.slice(1).map((part) => Number.parseInt(part, 10));
41
+ }
42
+
43
+ function versionAtLeast(actual, minimum) {
44
+ const actualParts = parseVersionParts(actual);
45
+ const minimumParts = parseVersionParts(minimum);
46
+ if (!actualParts || !minimumParts) return undefined;
47
+ for (let index = 0; index < minimumParts.length; index += 1) {
48
+ if (actualParts[index] > minimumParts[index]) return true;
49
+ if (actualParts[index] < minimumParts[index]) return false;
50
+ }
51
+ return true;
52
+ }
53
+
32
54
  function printHelp() {
33
55
  console.log(`pi-agent-browser-doctor
34
56
 
@@ -45,7 +67,8 @@ Options:
45
67
  Checks:
46
68
  1. agent-browser is installed on PATH.
47
69
  2. agent-browser --version matches the package capability baseline.
48
- 3. Pi settings and repo-local autoload locations do not point at multiple active pi-agent-browser-native sources.
70
+ 3. pi --version is at least the recommended Pi floor for this release.
71
+ 4. Pi settings and repo-local autoload locations do not point at multiple active pi-agent-browser-native sources.
49
72
 
50
73
  Examples:
51
74
  pi-agent-browser-doctor
@@ -101,6 +124,11 @@ async function defaultRunAgentBrowser(args) {
101
124
  return `${stdout}${stderr}`;
102
125
  }
103
126
 
127
+ async function defaultRunPi(args) {
128
+ const { stdout, stderr } = await execFile("pi", args, { maxBuffer: 1024 * 1024 });
129
+ return `${stdout}${stderr}`;
130
+ }
131
+
104
132
  async function defaultPathExists(path) {
105
133
  try {
106
134
  await access(path);
@@ -270,6 +298,43 @@ async function collectRepoLocalSources({ cwd, pathExists }) {
270
298
  return sources;
271
299
  }
272
300
 
301
+ async function checkPiVersion({ runPi }) {
302
+ try {
303
+ const rawOutput = await runPi(["--version"]);
304
+ const version = normalizePiVersion(rawOutput);
305
+ const supported = versionAtLeast(version, RECOMMENDED_PI_VERSION);
306
+ if (supported === false) {
307
+ return {
308
+ status: "warn",
309
+ title: `Pi ${RECOMMENDED_PI_VERSION} or newer is recommended; found ${version || "<empty>"}.`,
310
+ lines: [
311
+ "This package does not hard-pin Pi 0.78.1, but this release was audited against Pi 0.78.1 extension/package behavior.",
312
+ "Update Pi before release validation or lifecycle debugging if you see tool routing, /reload, exact-session, or package-install differences.",
313
+ ],
314
+ };
315
+ }
316
+ if (supported === undefined) {
317
+ return {
318
+ status: "warn",
319
+ title: `Could not parse pi --version output: ${version || "<empty>"}.`,
320
+ lines: [`Pi ${RECOMMENDED_PI_VERSION} or newer is recommended for this release's validation baseline.`],
321
+ };
322
+ }
323
+ return { status: "pass", title: `Pi version is within the recommended baseline: ${version}`, lines: [] };
324
+ } catch (error) {
325
+ const code = error && typeof error === "object" ? error.code : undefined;
326
+ return {
327
+ status: "warn",
328
+ title: "Could not inspect pi --version.",
329
+ lines: [
330
+ `Pi ${RECOMMENDED_PI_VERSION} or newer is recommended for this release's validation baseline, but it is not hard-pinned as a runtime requirement.`,
331
+ "Make sure the same shell that launches pi can run `pi --version` when debugging lifecycle or package-install behavior.",
332
+ code && code !== "ENOENT" ? `Spawn error: ${String(code)}` : undefined,
333
+ ].filter(Boolean),
334
+ };
335
+ }
336
+ }
337
+
273
338
  async function checkAgentBrowserVersion({ runAgentBrowser }) {
274
339
  try {
275
340
  const rawOutput = await runAgentBrowser(["--version"]);
@@ -358,6 +423,7 @@ export async function evaluateDoctor(options = {}) {
358
423
  const readText = options.readText ?? ((path) => readFile(path, "utf8"));
359
424
  const pathExists = options.pathExists ?? defaultPathExists;
360
425
  const runAgentBrowser = options.runAgentBrowser ?? defaultRunAgentBrowser;
426
+ const runPi = options.runPi ?? defaultRunPi;
361
427
  const checks = [];
362
428
  const failures = [];
363
429
  const warnings = [];
@@ -366,6 +432,9 @@ export async function evaluateDoctor(options = {}) {
366
432
  checks.push(versionCheck);
367
433
  if (versionCheck.status === "fail") failures.push(versionCheck);
368
434
 
435
+ const piVersionCheck = await checkPiVersion({ runPi });
436
+ checks.push(piVersionCheck);
437
+
369
438
  if (!options.skipSourceCheck) {
370
439
  const sourceCheck = await checkPiSources({ cwd, agentDir, settingsPaths, readText, pathExists });
371
440
  checks.push(sourceCheck);
@@ -14,49 +14,77 @@ function packageSlug(config = {}) {
14
14
  return process.env.PLATFORM_SMOKE_PACKAGE_SLUG || config.packageName || "pi-agent-browser-native";
15
15
  }
16
16
 
17
- export function buildTargetBaseArgs(targetName, config = {}) {
17
+ export function describeTarget(targetName, config = {}) {
18
+ const slug = packageSlug(config);
18
19
  switch (targetName) {
19
20
  case "macos": {
20
21
  const user = env("PLATFORM_SMOKE_MAC_USER") || env("USER");
21
- const host = env("PLATFORM_SMOKE_MAC_HOST") || "localhost";
22
- const workRoot = env("PLATFORM_SMOKE_MAC_WORK_ROOT") || `/Users/${user}/crabbox/${packageSlug(config)}`;
23
- return [
24
- "--provider", "ssh",
25
- "--target", "macos",
26
- "--static-host", host,
27
- "--static-user", user,
28
- "--static-port", "22",
29
- "--static-work-root", workRoot,
30
- ];
22
+ const host = env("PLATFORM_SMOKE_MAC_HOST") || config.macos?.host || "localhost";
23
+ const port = String(env("PLATFORM_SMOKE_MAC_PORT") || config.macos?.port || 22);
24
+ const workRoot = env("PLATFORM_SMOKE_MAC_WORK_ROOT") || config.macos?.workRoot || `/Users/${user}/crabbox/${slug}`;
25
+ return {
26
+ provider: "ssh",
27
+ crabboxTarget: "macos",
28
+ shell: "posix",
29
+ workRoot,
30
+ args: [
31
+ "--provider", "ssh",
32
+ "--target", "macos",
33
+ "--static-host", host,
34
+ "--static-user", user,
35
+ "--static-port", port,
36
+ "--static-work-root", workRoot,
37
+ ],
38
+ };
31
39
  }
32
40
  case "ubuntu": {
33
41
  const image = env("PLATFORM_SMOKE_UBUNTU_IMAGE") || config.ubuntuContainerImage || "pi-agent-browser-native-platform:node24-agent-browser0.27.1";
34
- return [
35
- "--provider", "local-container",
36
- "--target", "linux",
37
- "--local-container-image", image,
38
- ];
42
+ return {
43
+ provider: "local-container",
44
+ crabboxTarget: "linux",
45
+ shell: "posix",
46
+ image,
47
+ workRoot: config.localContainer?.workRoot || "/work/crabbox",
48
+ args: [
49
+ "--provider", "local-container",
50
+ "--target", "linux",
51
+ "--local-container-image", image,
52
+ ],
53
+ };
39
54
  }
40
55
  case "windows-native": {
41
- const vm = env("PLATFORM_SMOKE_WINDOWS_VM") || "pi-extension-windows-template";
42
- const snapshot = env("PLATFORM_SMOKE_WINDOWS_SNAPSHOT") || "crabbox-ready";
43
- const user = env("PLATFORM_SMOKE_WINDOWS_USER") || env("USER");
44
- const workRoot = env("PLATFORM_SMOKE_WINDOWS_WORK_ROOT") || `C:\\crabbox\\${packageSlug(config)}`;
45
- return [
46
- "--provider", "parallels",
47
- "--target", "windows",
48
- "--windows-mode", "normal",
49
- "--parallels-source", vm,
50
- "--parallels-source-snapshot", snapshot,
51
- "--parallels-user", user,
52
- "--parallels-work-root", workRoot,
53
- ];
56
+ const vm = env("PLATFORM_SMOKE_WINDOWS_VM") || config.windowsParallels?.sourceVm || "pi-extension-windows-template";
57
+ const snapshot = env("PLATFORM_SMOKE_WINDOWS_SNAPSHOT") || config.windowsParallels?.snapshot || "crabbox-ready";
58
+ const user = env("PLATFORM_SMOKE_WINDOWS_USER") || config.windowsParallels?.user || env("USER");
59
+ const workRoot = env("PLATFORM_SMOKE_WINDOWS_WORK_ROOT") || config.windowsParallels?.workRoot || `C:\\crabbox\\${slug}`;
60
+ return {
61
+ provider: "parallels",
62
+ crabboxTarget: "windows",
63
+ shell: "powershell",
64
+ workRoot,
65
+ windowsMode: "normal",
66
+ sourceVm: vm,
67
+ snapshot,
68
+ args: [
69
+ "--provider", "parallels",
70
+ "--target", "windows",
71
+ "--windows-mode", "normal",
72
+ "--parallels-source", vm,
73
+ "--parallels-source-snapshot", snapshot,
74
+ "--parallels-user", user,
75
+ "--parallels-work-root", workRoot,
76
+ ],
77
+ };
54
78
  }
55
79
  default:
56
80
  throw new Error(`unknown platform smoke target: ${targetName}`);
57
81
  }
58
82
  }
59
83
 
84
+ export function buildTargetBaseArgs(targetName, config = {}) {
85
+ return describeTarget(targetName, config).args;
86
+ }
87
+
60
88
  export function leaseIdFor(targetName, slug) {
61
89
  if (targetName === "macos") return "static_localhost";
62
90
  return slug;
@@ -105,6 +105,17 @@ function checkForbiddenProjectFiles(failures) {
105
105
  }
106
106
 
107
107
  function crabboxProviders(cbox) {
108
+ const jsonOutput = silent(cbox, ["providers", "--json"]);
109
+ if (jsonOutput) {
110
+ try {
111
+ const parsed = JSON.parse(jsonOutput);
112
+ if (Array.isArray(parsed)) return parsed.map((provider) => provider.name ?? provider.id ?? provider.provider).filter(Boolean);
113
+ if (Array.isArray(parsed.providers)) return parsed.providers.map((provider) => provider.name ?? provider.id ?? provider.provider).filter(Boolean);
114
+ if (typeof parsed === "object" && parsed) return Object.keys(parsed.providers ?? parsed);
115
+ } catch {
116
+ // Fall through to text parsing for older or non-JSON provider output.
117
+ }
118
+ }
108
119
  const output = silent(cbox, ["providers"]);
109
120
  if (!output) return [];
110
121
  return output.split(/\r?\n/)
@@ -212,9 +223,10 @@ export async function runDoctor(config) {
212
223
  const ubuntuImage = env("PLATFORM_SMOKE_UBUNTU_IMAGE") || config?.ubuntuContainerImage || "pi-agent-browser-native-platform:node24-agent-browser0.27.1";
213
224
  checkCrabboxProvider(cbox, ["--provider", "local-container", "--local-container-image", ubuntuImage], "ubuntu local-container", failures);
214
225
  const macUser = env("PLATFORM_SMOKE_MAC_USER") || env("USER");
215
- const macHost = env("PLATFORM_SMOKE_MAC_HOST") || "localhost";
216
- const macRoot = env("PLATFORM_SMOKE_MAC_WORK_ROOT") || `/Users/${macUser}/crabbox/${packageName}`;
217
- checkCrabboxProvider(cbox, ["--provider", "ssh", "--target", "macos", "--static-host", macHost, "--static-user", macUser, "--static-port", "22", "--static-work-root", macRoot], "macOS ssh", failures);
226
+ const macHost = env("PLATFORM_SMOKE_MAC_HOST") || config?.macos?.host || "localhost";
227
+ const macPort = String(env("PLATFORM_SMOKE_MAC_PORT") || config?.macos?.port || 22);
228
+ const macRoot = env("PLATFORM_SMOKE_MAC_WORK_ROOT") || config?.macos?.workRoot || `/Users/${macUser}/crabbox/${packageName}`;
229
+ checkCrabboxProvider(cbox, ["--provider", "ssh", "--target", "macos", "--static-host", macHost, "--static-user", macUser, "--static-port", macPort, "--static-work-root", macRoot], "macOS ssh", failures);
218
230
  }
219
231
 
220
232
  console.log("\n── Docker / Ubuntu ──");
@@ -226,8 +238,9 @@ export async function runDoctor(config) {
226
238
 
227
239
  console.log("\n── macOS SSH ──");
228
240
  const sshUser = env("PLATFORM_SMOKE_MAC_USER") || env("USER");
229
- const sshHost = env("PLATFORM_SMOKE_MAC_HOST") || "localhost";
230
- const sshProbe = shell(`ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=no ${sshUser}@${sshHost} 'node --version && npm --version && git --version && agent-browser --version'`);
241
+ const sshHost = env("PLATFORM_SMOKE_MAC_HOST") || config?.macos?.host || "localhost";
242
+ const sshPort = String(env("PLATFORM_SMOKE_MAC_PORT") || config?.macos?.port || 22);
243
+ const sshProbe = shell(`ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=no -p ${sshPort} ${sshUser}@${sshHost} 'node --version && npm --version && git --version && agent-browser --version'`);
231
244
  if (sshProbe) {
232
245
  ok(`SSH ${sshUser}@${sshHost}: ${sshProbe.split(/\r?\n/).join(" | ")}`);
233
246
  if (agentBrowserVersion && !sshProbe.includes(agentBrowserVersion)) fail(`macOS SSH agent-browser does not match expected ${agentBrowserVersion}`, failures);
@@ -241,10 +254,10 @@ export async function runDoctor(config) {
241
254
  fail("prlctl not found", failures);
242
255
  } else {
243
256
  ok("prlctl found");
244
- const vmName = env("PLATFORM_SMOKE_WINDOWS_VM") || "pi-extension-windows-template";
245
- const snapshot = env("PLATFORM_SMOKE_WINDOWS_SNAPSHOT") || "crabbox-ready";
246
- const user = env("PLATFORM_SMOKE_WINDOWS_USER") || env("USER");
247
- const workRoot = env("PLATFORM_SMOKE_WINDOWS_WORK_ROOT") || `C:\\crabbox\\${packageName}`;
257
+ const vmName = env("PLATFORM_SMOKE_WINDOWS_VM") || config?.windowsParallels?.sourceVm || "pi-extension-windows-template";
258
+ const snapshot = env("PLATFORM_SMOKE_WINDOWS_SNAPSHOT") || config?.windowsParallels?.snapshot || "crabbox-ready";
259
+ const user = env("PLATFORM_SMOKE_WINDOWS_USER") || config?.windowsParallels?.user || env("USER");
260
+ const workRoot = env("PLATFORM_SMOKE_WINDOWS_WORK_ROOT") || config?.windowsParallels?.workRoot || `C:\\crabbox\\${packageName}`;
248
261
  const list = shell("prlctl list -a --no-header 2>/dev/null");
249
262
  if (!list) {
250
263
  fail("prlctl list returned no VMs", failures);