@moretea-labs/forge 1.5.1 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (434) hide show
  1. package/AGENTS.md +2 -0
  2. package/CHANGELOG.md +46 -0
  3. package/CLAUDE.md +2 -0
  4. package/DESIGN.md +7 -2
  5. package/README.md +4 -4
  6. package/README.zh-CN.md +2 -2
  7. package/SKILL.md +2 -2
  8. package/THIRD_PARTY_NOTICES.md +1 -1
  9. package/agents/openai.yaml +1 -1
  10. package/assets/hooks/hook-input.sh +11 -8
  11. package/assets/hooks/lib/session-state.sh +10 -7
  12. package/assets/hooks/lib/workflow-state.sh +13 -177
  13. package/assets/hooks/post-tool-observer.sh +8 -2
  14. package/assets/hooks/pre-edit-guard.sh +8 -39
  15. package/assets/hooks/prompt-guard.sh +7 -83
  16. package/assets/hooks/session-start-context.sh +56 -1
  17. package/assets/partials/05-workflow.partial.md +1 -1
  18. package/assets/partials-agents/03-orchestration.partial.md +1 -1
  19. package/assets/plugin-registry.v1.json +54 -3
  20. package/assets/reference-configs/document-generation.md +1 -1
  21. package/assets/reference-configs/harness-overview.md +1 -1
  22. package/assets/reference-configs/hook-operations.md +1 -1
  23. package/assets/reference-configs/sprint-contracts.md +3 -3
  24. package/assets/skill-version.json +2 -2
  25. package/assets/templates/contract.template.md +2 -7
  26. package/assets/templates/helpers/capture-plan.sh +2 -2
  27. package/assets/templates/helpers/check-task-workflow.sh +4 -4
  28. package/assets/templates/helpers/contract-worktree.sh +1 -21
  29. package/assets/templates/helpers/ensure-task-workflow.sh +13 -38
  30. package/assets/templates/helpers/migrate-project-template.sh +22 -45
  31. package/assets/templates/helpers/new-plan.sh +1 -1
  32. package/assets/templates/helpers/plan-to-todo.sh +12 -37
  33. package/assets/templates/helpers/ship-worktrees.sh +2 -29
  34. package/assets/templates/helpers/sprint-backlog.sh +6 -6
  35. package/assets/templates/helpers/verify-contract.sh +1 -111
  36. package/assets/templates/helpers/verify-sprint.sh +3 -180
  37. package/assets/templates/plan.template.md +1 -1
  38. package/assets/templates/review.template.md +9 -34
  39. package/assets/workflow-contract.v1.json +10 -19
  40. package/docs/README.md +10 -10
  41. package/docs/architecture/CURRENT.md +170 -0
  42. package/docs/architecture/EVOLUTION.md +42 -0
  43. package/docs/architecture/current/README.md +4 -156
  44. package/docs/architecture/decisions/20260803-release-identity-binding-and-exit-policy.md +2 -2
  45. package/docs/architecture/decisions/20260823-immediate-controller-relay.md +53 -0
  46. package/docs/architecture/decisions/20260826-browser-runtime-v2.md +73 -0
  47. package/docs/architecture/history/README.md +7 -0
  48. package/docs/architecture/{chatgpt-handoff-facade.md → history/chatgpt-handoff-facade.md} +4 -0
  49. package/docs/architecture/{global-hook-runtime.md → history/global-hook-runtime.md} +7 -3
  50. package/docs/architecture/{ios-semantic-automation-provider-v2.md → history/ios-semantic-automation-provider-v2.md} +6 -2
  51. package/docs/architecture/history.md +3 -34
  52. package/docs/architecture/index.md +15 -98
  53. package/docs/architecture/modules/controller-runtime/transport-lifecycle.md +1 -1
  54. package/docs/architecture/reviews/2026-08-22-semantic-harness-convergence-acceptance.md +39 -0
  55. package/docs/architecture/versions/1.6.md +38 -0
  56. package/docs/architecture/versions/1.7.md +41 -0
  57. package/docs/forge-chatgpt-browser-engine.md +2 -2
  58. package/docs/forge-chatgpt-mcp-setup.md +4 -28
  59. package/docs/forge-chatgpt-supervised-automation.md +3 -3
  60. package/docs/forge-github-issue-launcher.md +1 -1
  61. package/docs/forge-runtime-self-healing-loop.md +1 -1
  62. package/docs/operations/controller-browser-plugin.md +38 -22
  63. package/docs/operations/controller-performance-and-502.md +124 -0
  64. package/docs/operations/controller-reliability-runbook.md +1 -1
  65. package/docs/operations/features.md +3 -3
  66. package/docs/operations/github-repository.md +1 -1
  67. package/docs/operations/homebrew.md +2 -2
  68. package/docs/operations/mcp-tool-exposure.md +5 -3
  69. package/docs/tutorials/01-install-and-start.md +1 -1
  70. package/docs/tutorials/01-install-and-start.zh-CN.md +1 -1
  71. package/docs/wiki/Architecture.md +6 -6
  72. package/docs/wiki/Implementation.md +2 -2
  73. package/docs/wiki/Installation.md +1 -1
  74. package/docs/wiki/Integrations.md +2 -2
  75. package/docs/wiki/Quick-Start.md +1 -1
  76. package/docs/wiki/Work-Lifecycle.md +1 -1
  77. package/package.json +29 -10
  78. package/scripts/activate-source-baseline.ts +17 -13
  79. package/scripts/benchmark-background-check-overlap.ts +228 -0
  80. package/scripts/benchmark-mcp-host.sh +117 -0
  81. package/scripts/benchmark-mcp-transport.ts +170 -0
  82. package/scripts/benchmark-quality-harness.ts +146 -0
  83. package/scripts/benchmark-route-session-concurrency.ts +207 -6
  84. package/scripts/benchmark-thin-harness-mcp-e2e.ts +7 -1
  85. package/scripts/benchmark-typescript-navigation.ts +186 -0
  86. package/scripts/benchmark-work-efficiency.ts +334 -0
  87. package/scripts/capture-plan.sh +3 -3
  88. package/scripts/check-agent-tooling.sh +8 -35
  89. package/scripts/check-architecture-sync.sh +17 -86
  90. package/scripts/check-mcp-compatibility.ts +14 -0
  91. package/scripts/check-public-docs.sh +14 -1
  92. package/scripts/check-runtime-architecture.mjs +107 -64
  93. package/scripts/check-stable-baseline.ts +92 -0
  94. package/scripts/check-task-workflow.sh +4 -4
  95. package/scripts/contract-worktree.sh +1 -21
  96. package/scripts/ensure-task-workflow.sh +22 -55
  97. package/scripts/host-temporary-cloud-mcp-e2e.sh +265 -0
  98. package/scripts/install-standalone-recovery.ts +24 -0
  99. package/scripts/lib/project-init-lib.sh +15 -44
  100. package/scripts/migrate-chatgpt-automation-schedules.ts +64 -0
  101. package/scripts/migrate-project-template.sh +23 -1179
  102. package/scripts/new-plan.sh +1 -1
  103. package/scripts/plan-to-todo.sh +11 -36
  104. package/scripts/run-governed-gate.ts +2 -1
  105. package/scripts/ship-worktrees.sh +2 -29
  106. package/scripts/smoke-ios-physical-device.ts +4 -2
  107. package/scripts/smoke-runtime-recovery.ts +1 -35
  108. package/scripts/smoke-schedule-engine.ts +1 -20
  109. package/scripts/sprint-backlog.sh +1 -1
  110. package/scripts/verify-contract.sh +1 -111
  111. package/scripts/verify-forge-runtime.sh +0 -1
  112. package/scripts/verify-sprint.sh +3 -180
  113. package/scripts/workflow-contract.ts +1 -5
  114. package/src/cli/commands/chatgpt.ts +31 -2
  115. package/src/cli/commands/init-hook.ts +3 -3
  116. package/src/cli/commands/mcp.ts +9 -5
  117. package/src/cli/commands/plugin.ts +37 -10
  118. package/src/cli/commands/recovery.ts +25 -3
  119. package/src/cli/commands/runtime.ts +55 -3
  120. package/src/cli/commands/security.ts +12 -1
  121. package/src/cli/commands/setup-profile.ts +31 -21
  122. package/src/cli/controller/check-runner.ts +106 -3
  123. package/src/cli/controller/composite-result.ts +1 -1
  124. package/src/cli/controller/context/focus.ts +51 -0
  125. package/src/cli/controller/context/instruction-context.ts +114 -0
  126. package/src/cli/controller/context/known-paths.ts +76 -0
  127. package/src/cli/controller/context/query-planning.ts +110 -0
  128. package/src/cli/controller/context/rule-resolution.ts +532 -0
  129. package/src/cli/controller/context/source-materializer.ts +278 -0
  130. package/src/cli/controller/context/structural.ts +16 -0
  131. package/src/cli/controller/context/types.ts +286 -0
  132. package/src/cli/controller/context-pack.ts +898 -430
  133. package/src/cli/controller/launch-agents.ts +77 -2
  134. package/src/cli/controller/operational-plan.ts +2 -2
  135. package/src/cli/controller/work-mode.ts +6 -4
  136. package/src/cli/editing/edit-session.ts +60 -7
  137. package/src/cli/github/github.ts +133 -0
  138. package/src/cli/hook/prompt-guard-decision.ts +17 -18
  139. package/src/cli/hook/prompt-intents.ts +1 -1
  140. package/src/cli/installer/managed-entries.ts +15 -1
  141. package/src/cli/local-bridge/console-automations.ts +225 -67
  142. package/src/cli/local-bridge/console-view-models.ts +3 -35
  143. package/src/cli/local-bridge/facade-api.ts +2 -217
  144. package/src/cli/local-bridge/frontend/src/App.tsx +3 -3
  145. package/src/cli/local-bridge/frontend/src/features/automations/AutomationsView.tsx +153 -3
  146. package/src/cli/local-bridge/frontend/src/features/capabilities/CapabilitiesView.tsx +37 -4
  147. package/src/cli/local-bridge/frontend/src/features/overview/OverviewView.tsx +183 -3
  148. package/src/cli/local-bridge/frontend/src/features/repositories/RepositoriesView.tsx +1 -1
  149. package/src/cli/local-bridge/frontend/src/features/work/WorkView.tsx +4 -3
  150. package/src/cli/local-bridge/frontend/src/lib/api.ts +18 -11
  151. package/src/cli/local-bridge/frontend/src/lib/routes.ts +1 -1
  152. package/src/cli/local-bridge/frontend/src/shell/CommandBar.tsx +1 -1
  153. package/src/cli/local-bridge/frontend/src/shell/Sidebar.tsx +4 -3
  154. package/src/cli/local-bridge/frontend/src/styles.css +27 -11
  155. package/src/cli/local-bridge/frontend/src/types.ts +8 -7
  156. package/src/cli/local-bridge/job-store.ts +1 -490
  157. package/src/cli/local-bridge/server.ts +29 -532
  158. package/src/cli/local-bridge/ui-dist/app.css +1 -1
  159. package/src/cli/local-bridge/ui-dist/app.js +5101 -4967
  160. package/src/cli/mcp/auth.ts +5 -5
  161. package/src/cli/mcp/instructions.ts +3 -2
  162. package/src/cli/mcp/legacy-tool-service.ts +0 -189
  163. package/src/cli/mcp/multi-repository.ts +16 -5
  164. package/src/cli/mcp/oauth.ts +175 -26
  165. package/src/cli/mcp/paths.ts +8 -2
  166. package/src/cli/mcp/repository-tools.ts +356 -101
  167. package/src/cli/mcp/server.ts +625 -54
  168. package/src/cli/mcp/tool-capability-cache.ts +1 -1
  169. package/src/cli/mcp/toolset-selection.ts +1 -1
  170. package/src/cli/mcp/toolset.ts +42 -5
  171. package/src/cli/mcp/transports/http.ts +125 -15
  172. package/src/cli/mcp/transports/session-registry.ts +67 -5
  173. package/src/cli/mcp/types.ts +3 -3
  174. package/src/cli/repositories/command-classifier.ts +209 -14
  175. package/src/cli/repositories/command-executor.ts +55 -521
  176. package/src/cli/repositories/command-process.ts +108 -0
  177. package/src/cli/repositories/command-scope.ts +99 -41
  178. package/src/cli/repositories/controller-home.ts +7 -8
  179. package/src/cli/repositories/git-command-executor.ts +2 -0
  180. package/src/cli/repositories/locks.ts +53 -16
  181. package/src/cli/repositories/repository-snapshot.ts +121 -0
  182. package/src/cli/repositories/safe-patch.ts +6 -4
  183. package/src/cli/repositories/selected-path-actions.ts +17 -9
  184. package/src/cli/repositories/structured-git.ts +110 -10
  185. package/src/cli/repository/inspector.ts +565 -62
  186. package/src/cli/repository/session-cache.ts +58 -17
  187. package/src/cli/v81-entry.ts +1 -4
  188. package/src/effects/fs-transaction.ts +85 -17
  189. package/src/runtime/context/codegraph-read-provider.ts +233 -2
  190. package/src/runtime/context/codegraph-sidecar.cjs +74 -40
  191. package/src/runtime/context/generic-lsp-provider.ts +355 -0
  192. package/src/runtime/context/lsp-client.ts +283 -0
  193. package/src/runtime/context/semantic-navigation.ts +275 -0
  194. package/src/runtime/context/swift-navigation.ts +565 -0
  195. package/src/runtime/context/typescript-navigation.ts +226 -0
  196. package/src/runtime/control-plane/dispatch-priority.ts +28 -0
  197. package/src/runtime/control-plane/execution/direct-edit-work-completion.ts +196 -2
  198. package/src/runtime/control-plane/execution/edit-validation-coordinator.ts +86 -24
  199. package/src/runtime/control-plane/execution/execution-identity.ts +65 -14
  200. package/src/runtime/control-plane/execution/session-store.ts +18 -0
  201. package/src/runtime/control-plane/execution/validation.ts +15 -2
  202. package/src/runtime/control-plane/execution/verification-evidence.ts +58 -3
  203. package/src/runtime/control-plane/execution/work-handle-store.ts +27 -2
  204. package/src/runtime/control-plane/execution/work-lifecycle-audit.ts +258 -0
  205. package/src/runtime/control-plane/execution/work-remote-delivery.ts +215 -0
  206. package/src/runtime/control-plane/execution/work-task-receipt.ts +54 -11
  207. package/src/runtime/control-plane/execution/work-terminal-cleanup.ts +175 -8
  208. package/src/runtime/control-plane/execution/work-validation-reconciler.ts +28 -21
  209. package/src/runtime/control-plane/execution/work-verification-snapshot.ts +87 -9
  210. package/src/runtime/control-plane/facade/capability-registry.ts +129 -25
  211. package/src/runtime/control-plane/facade/controller-round-relay.ts +991 -0
  212. package/src/runtime/control-plane/facade/controller-session-store.ts +144 -14
  213. package/src/runtime/control-plane/facade/goal-workloop-access.ts +9 -2
  214. package/src/runtime/control-plane/facade/goal-workloop.ts +885 -105
  215. package/src/runtime/control-plane/facade/index.ts +1 -0
  216. package/src/runtime/control-plane/facade/plan-contract-store.ts +211 -26
  217. package/src/runtime/control-plane/facade/requirement-board.ts +24 -8
  218. package/src/runtime/control-plane/facade/semantic-admission.ts +205 -0
  219. package/src/runtime/control-plane/facade/suggested-actions.ts +5 -1
  220. package/src/runtime/control-plane/facade/types.ts +55 -11
  221. package/src/runtime/control-plane/facade/work-contract-store.ts +204 -73
  222. package/src/runtime/control-plane/generated-cache-retention.ts +271 -0
  223. package/src/runtime/control-plane/global-scheduler/config.ts +44 -0
  224. package/src/runtime/control-plane/global-scheduler/dispatch-capacity.ts +80 -0
  225. package/src/runtime/control-plane/global-scheduler/durable-admission.ts +46 -0
  226. package/src/runtime/control-plane/global-scheduler/maintenance.ts +145 -0
  227. package/src/runtime/control-plane/global-scheduler/projection-refresh.ts +84 -0
  228. package/src/runtime/control-plane/global-scheduler/reconciliation.ts +3 -3
  229. package/src/runtime/control-plane/global-scheduler/scheduler.ts +273 -542
  230. package/src/runtime/control-plane/global-scheduler/source-scan.ts +59 -0
  231. package/src/runtime/control-plane/global-scheduler/state.ts +88 -0
  232. package/src/runtime/control-plane/global-scheduler/worker-attachment.ts +57 -0
  233. package/src/runtime/control-plane/global-scheduler/worker-exit-decision.ts +38 -0
  234. package/src/runtime/control-plane/global-scheduler/worker-exit-reconciler.ts +175 -0
  235. package/src/runtime/control-plane/global-scheduler/worker-launch.ts +111 -0
  236. package/src/runtime/control-plane/global-scheduler/worker-lifecycle-store.ts +64 -0
  237. package/src/runtime/control-plane/global-scheduler/worker-lifecycle.ts +150 -0
  238. package/src/runtime/control-plane/global-scheduler/worker-process.ts +119 -0
  239. package/src/runtime/control-plane/global-scheduler/worker-stderr.ts +60 -0
  240. package/src/runtime/control-plane/launcher/chatgpt-work-binding-store.ts +50 -0
  241. package/src/runtime/control-plane/launcher/chatgpt-work-continuation.ts +642 -51
  242. package/src/runtime/control-plane/launcher/launch-reservation-store.ts +40 -0
  243. package/src/runtime/control-plane/launcher/thin-launcher.ts +194 -10
  244. package/src/runtime/control-plane/persistence/controller-home-migration.ts +487 -0
  245. package/src/runtime/control-plane/persistence/requirement-store.ts +15 -11
  246. package/src/runtime/control-plane/persistence/sqlite-store.ts +33 -2
  247. package/src/runtime/control-plane/release-retention.ts +62 -2
  248. package/src/runtime/control-plane/routing/route-policy.ts +7 -16
  249. package/src/runtime/control-plane/runtime-status-client.ts +0 -2
  250. package/src/runtime/diagnostics/mcp-timing.ts +19 -0
  251. package/src/runtime/diagnostics/mcp-transport-benchmark.ts +129 -0
  252. package/src/runtime/diagnostics/performance.ts +24 -1
  253. package/src/runtime/evidence/event-ledger.ts +1 -1
  254. package/src/runtime/execution/jobs/store.ts +58 -4
  255. package/src/runtime/execution/jobs/types.ts +1 -1
  256. package/src/runtime/execution/managed-workspace.ts +203 -1
  257. package/src/runtime/execution/process-runtime/check-facade.ts +52 -14
  258. package/src/runtime/execution/process-runtime/check-result.ts +63 -2
  259. package/src/runtime/execution/process-runtime/check-scheduling.ts +114 -0
  260. package/src/runtime/execution/process-runtime/command-facade.ts +346 -40
  261. package/src/runtime/execution/process-runtime/gc.ts +98 -12
  262. package/src/runtime/execution/process-runtime/lightweight-managed.ts +1184 -0
  263. package/src/runtime/execution/process-runtime/persisted-check.ts +57 -6
  264. package/src/runtime/execution/process-runtime/resource-claims.ts +97 -8
  265. package/src/runtime/execution/process-runtime/runtime.ts +59 -4
  266. package/src/runtime/execution/process-runtime/store.ts +7 -0
  267. package/src/runtime/execution/process-runtime/types.ts +2 -0
  268. package/src/runtime/execution/thin-harness/async-process.ts +12 -2
  269. package/src/runtime/execution/thin-harness/search-worker.ts +54 -5
  270. package/src/runtime/execution/workers/executor.ts +0 -48
  271. package/src/runtime/gateway/mcp/execution-tools.ts +1219 -126
  272. package/src/runtime/gateway/mcp/process-tools.ts +83 -37
  273. package/src/runtime/gateway/mcp/router.ts +185 -83
  274. package/src/runtime/gateway/mcp/runtime-tool-definitions.ts +546 -0
  275. package/src/runtime/gateway/mcp/runtime-tools.ts +2555 -1616
  276. package/src/runtime/model-clients/deepseek-adapter.ts +0 -32
  277. package/src/runtime/plugins/app-store-connect-adapter.ts +123 -19
  278. package/src/runtime/plugins/browser-adapter.ts +2925 -318
  279. package/src/runtime/plugins/browser-automation-service.ts +10 -0
  280. package/src/runtime/plugins/browser-handoff-host.ts +6 -31
  281. package/src/runtime/plugins/browser-handoff.ts +92 -5
  282. package/src/runtime/plugins/browser-macos-bridge.ts +586 -45
  283. package/src/runtime/plugins/browser-node-bridge.ts +6 -34
  284. package/src/runtime/plugins/browser-provider-registry.ts +109 -0
  285. package/src/runtime/plugins/browser-runtime-contract.ts +152 -0
  286. package/src/runtime/plugins/browser-runtime.ts +183 -0
  287. package/src/runtime/plugins/browser-session-authority.ts +332 -0
  288. package/src/runtime/plugins/capability-authorization-grants.ts +354 -0
  289. package/src/runtime/plugins/desktop-operator-registration.ts +15 -12
  290. package/src/runtime/plugins/external-adapter.ts +324 -3
  291. package/src/runtime/plugins/external-registration.ts +84 -5
  292. package/src/runtime/plugins/external-unix-socket.ts +7 -1
  293. package/src/runtime/plugins/first-party-registry.ts +4 -2
  294. package/src/runtime/plugins/github-adapter.ts +125 -0
  295. package/src/runtime/plugins/gmail-adapter.ts +28 -2
  296. package/src/runtime/plugins/ios/agent-device-provider.ts +1 -1
  297. package/src/runtime/plugins/ios/agent-device-typed-provider.ts +52 -9
  298. package/src/runtime/plugins/ios/remote-xpc-hid.ts +1171 -0
  299. package/src/runtime/plugins/ios-adapter.ts +106 -16
  300. package/src/runtime/plugins/ios-agent-device.ts +234 -64
  301. package/src/runtime/plugins/ios-physical-device.ts +1143 -392
  302. package/src/runtime/plugins/lightweight-action.ts +153 -0
  303. package/src/runtime/plugins/local-system-adapter.ts +125 -8
  304. package/src/runtime/plugins/macos-capability-broker.ts +55 -9
  305. package/src/runtime/plugins/plugin-action-sidecar.ts +59 -0
  306. package/src/runtime/plugins/plugin-management-adapter.ts +181 -0
  307. package/src/runtime/plugins/resend-adapter.ts +1 -1
  308. package/src/runtime/plugins/store.ts +366 -24
  309. package/src/runtime/plugins/types.ts +39 -2
  310. package/src/runtime/plugins/xiaohongshu-publish.ts +515 -0
  311. package/src/runtime/projections/git-status-sampler.ts +16 -16
  312. package/src/runtime/projections/materialized-view.ts +28 -10
  313. package/src/runtime/recovery/actions.ts +0 -10
  314. package/src/runtime/recovery/classifier.ts +0 -9
  315. package/src/runtime/recovery/maintenance-executor.ts +415 -18
  316. package/src/runtime/recovery/probe.ts +0 -1
  317. package/src/runtime/recovery/types.ts +0 -1
  318. package/src/runtime/root/mcp-transport.ts +180 -10
  319. package/src/runtime/root/package-connector-service.ts +186 -8
  320. package/src/runtime/root/package-runtime-release.ts +143 -17
  321. package/src/runtime/root/package-runtime-service.ts +430 -22
  322. package/src/runtime/root/release-manifest.ts +7 -2
  323. package/src/runtime/root/release-materialize.ts +162 -19
  324. package/src/runtime/root/release-store.ts +48 -11
  325. package/src/runtime/root/runtime.ts +23 -5
  326. package/src/runtime/root/service.ts +100 -27
  327. package/src/runtime/root/types.ts +2 -0
  328. package/src/runtime/safe-tooling/index.ts +0 -1
  329. package/src/runtime/safe-tooling/ios-development.ts +321 -63
  330. package/src/runtime/safe-tooling/types.ts +0 -30
  331. package/src/runtime/shared/trusted-executable.ts +62 -0
  332. package/src/runtime/shared/trusted-node-executable.ts +48 -0
  333. package/src/runtime/standalone-recovery/core.ts +702 -94
  334. package/src/runtime/standalone-recovery/entry.ts +99 -23
  335. package/src/runtime/standalone-recovery/installer.ts +19 -3
  336. package/src/runtime/standalone-recovery/service-labels.ts +2 -0
  337. package/src/runtime/standalone-recovery/watchdog-heartbeat.ts +160 -0
  338. package/src/runtime/workflow/schedules/browser-probe.ts +258 -0
  339. package/src/runtime/workflow/schedules/chatgpt-automation-migration.ts +40 -0
  340. package/src/runtime/workflow/schedules/engine.ts +509 -80
  341. package/src/runtime/workflow/schedules/github-issue-watch.ts +158 -0
  342. package/src/runtime/workflow/schedules/settlement.ts +48 -4
  343. package/src/runtime/workflow/schedules/store.ts +131 -10
  344. package/src/runtime/workflow/schedules/types.ts +6 -3
  345. package/src/runtime/workflow/schedules/work-continuation.ts +120 -34
  346. package/src/testing/test-governance.ts +8 -40
  347. package/assets/reference-configs/heartbeat-triage.md +0 -57
  348. package/assets/templates/helpers/harness-trace-grade.sh +0 -150
  349. package/assets/templates/helpers/heartbeat-triage.sh +0 -361
  350. package/assets/templates/helpers/maintenance-triage.sh +0 -167
  351. package/bin/forge-desktop-helper.mjs +0 -204
  352. package/docs/architecture/current/approved-target-architecture.zh-CN.md +0 -1501
  353. package/docs/architecture/current/architecture-invariants.md +0 -331
  354. package/docs/architecture/current/automation-and-schedule-engine.md +0 -453
  355. package/docs/architecture/current/codegraph-context-provider.md +0 -226
  356. package/docs/architecture/current/control-plane-authority-inventory.md +0 -94
  357. package/docs/architecture/current/controller-repository-decoupling.md +0 -148
  358. package/docs/architecture/current/dispatch-and-agent-strategy.md +0 -354
  359. package/docs/architecture/current/entity-model.md +0 -592
  360. package/docs/architecture/current/external-plugin-governance.md +0 -396
  361. package/docs/architecture/current/failure-recovery.md +0 -447
  362. package/docs/architecture/current/governance.md +0 -213
  363. package/docs/architecture/current/human-interaction-plane.md +0 -87
  364. package/docs/architecture/current/implementation-status.md +0 -128
  365. package/docs/architecture/current/job-and-run-lifecycle.md +0 -413
  366. package/docs/architecture/current/legacy-governance.md +0 -58
  367. package/docs/architecture/current/local-workspace-target-grants.md +0 -70
  368. package/docs/architecture/current/migration-roadmap.md +0 -105
  369. package/docs/architecture/current/multi-repository-execution.md +0 -371
  370. package/docs/architecture/current/operations-runbook.md +0 -45
  371. package/docs/architecture/current/peer-supercontroller-runtime.md +0 -32
  372. package/docs/architecture/current/personal-assistant-plugin-baseline.md +0 -206
  373. package/docs/architecture/current/repository-access-modes.md +0 -102
  374. package/docs/architecture/current/route-policy.md +0 -15
  375. package/docs/architecture/current/route-session-concurrency.md +0 -85
  376. package/docs/architecture/current/runtime-architecture-simplification.md +0 -214
  377. package/docs/architecture/current/runtime-directory-map.md +0 -49
  378. package/docs/architecture/current/runtime-health-and-resource-lifecycle.md +0 -85
  379. package/docs/architecture/current/scheduler-and-resource-claims.md +0 -422
  380. package/docs/architecture/current/session-aware-execution-and-authorization.md +0 -77
  381. package/docs/architecture/current/system-overview.md +0 -144
  382. package/docs/architecture/current/target-requirements-traceability.md +0 -202
  383. package/docs/architecture/current/thin-harness-governance-policy.md +0 -32
  384. package/docs/architecture/current/thin-harness-v1.md +0 -311
  385. package/docs/architecture/current/verification-and-release-gates.md +0 -391
  386. package/docs/forge-autonomous-goal-loop.md +0 -9
  387. package/docs/forge-provider-configuration.md +0 -7
  388. package/docs/operations/chatgpt-personal-assistant-runtime.md +0 -83
  389. package/docs/operations/controller-desktop-plugin.md +0 -112
  390. package/docs/operations/gmail-assistant-routines.md +0 -186
  391. package/docs/operations/personal-assistant-operator-guide.md +0 -61
  392. package/docs/operations/provider-configuration.md +0 -31
  393. package/scripts/harness-trace-grade.sh +0 -150
  394. package/scripts/heartbeat-triage.sh +0 -361
  395. package/scripts/maintenance-triage.sh +0 -167
  396. package/src/cli/local-bridge/frontend/src/features/settings/SettingsView.tsx +0 -2
  397. package/src/cli/repositories/goal-registry.ts +0 -239
  398. package/src/runtime/assistant/action-proposals.ts +0 -279
  399. package/src/runtime/assistant/intent.ts +0 -395
  400. package/src/runtime/assistant/model-provider.ts +0 -460
  401. package/src/runtime/assistant/openapi.ts +0 -223
  402. package/src/runtime/assistant/policy.ts +0 -89
  403. package/src/runtime/assistant/readiness.ts +0 -258
  404. package/src/runtime/assistant/routine-runtime.ts +0 -477
  405. package/src/runtime/assistant/schedule-binding.ts +0 -171
  406. package/src/runtime/assistant/standing-grants.ts +0 -375
  407. package/src/runtime/assistant/store.ts +0 -243
  408. package/src/runtime/assistant/types.ts +0 -135
  409. package/src/runtime/control-plane/goal-loop/config-facade.ts +0 -700
  410. package/src/runtime/control-plane/goal-loop/config-store.ts +0 -570
  411. package/src/runtime/control-plane/goal-loop/config-types.ts +0 -213
  412. package/src/runtime/control-plane/goal-loop/executor-router.ts +0 -109
  413. package/src/runtime/control-plane/goal-loop/failure-taxonomy.ts +0 -149
  414. package/src/runtime/control-plane/goal-loop/goal-contract-store.ts +0 -304
  415. package/src/runtime/control-plane/goal-loop/goal-loop-engine.ts +0 -1001
  416. package/src/runtime/control-plane/goal-loop/handoff-packet.ts +0 -160
  417. package/src/runtime/control-plane/goal-loop/index.ts +0 -13
  418. package/src/runtime/control-plane/goal-loop/local-tools.ts +0 -290
  419. package/src/runtime/control-plane/goal-loop/policy-gates.ts +0 -203
  420. package/src/runtime/control-plane/goal-loop/provider-dispatch.ts +0 -93
  421. package/src/runtime/control-plane/goal-loop/provider-registry.ts +0 -545
  422. package/src/runtime/control-plane/goal-loop/types.ts +0 -378
  423. package/src/runtime/control-plane/startup-recovery.ts +0 -198
  424. package/src/runtime/gateway/mcp/resource-policy.ts +0 -186
  425. package/src/runtime/personal-assistant/gmail-triage-manager.ts +0 -231
  426. package/src/runtime/personal-assistant/reporting-runtime.ts +0 -344
  427. package/src/runtime/personal-assistant/triage-runtime.ts +0 -748
  428. package/src/runtime/plugins/desktop-adapter.ts +0 -374
  429. package/src/runtime/safe-tooling/browser-safe.ts +0 -133
  430. package/src/runtime/workflow/findings/store.ts +0 -204
  431. package/src/runtime/workflow/findings/types.ts +0 -58
  432. package/src/runtime/workflow/portfolio/engine.ts +0 -20
  433. package/src/runtime/workflow/portfolio/store.ts +0 -185
  434. package/src/runtime/workflow/portfolio/types.ts +0 -29
@@ -1,10 +1,12 @@
1
1
  import { createHash, randomUUID } from 'crypto';
2
2
  import { createRequire } from 'module';
3
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
4
- import { basename, dirname, join, relative, resolve } from 'path';
3
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'fs';
4
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'path';
5
+ import { readBrowserBinding } from '../../cli/chatgpt-browser/binding';
5
6
  import type {
6
7
  AssistantPluginActionDescriptor,
7
8
  AssistantPluginActionExecutionInput,
9
+ AssistantPluginAuthorizationContext,
8
10
  AssistantPluginCapability,
9
11
  AssistantPluginHealth,
10
12
  AssistantPluginManifest,
@@ -12,12 +14,34 @@ import type {
12
14
  } from './types';
13
15
  import { AssistantPluginError, toAssistantPluginError } from './errors';
14
16
  import { executeBrowserActionThroughNode, shouldUseBrowserNodeBridge } from './browser-node-bridge';
17
+ import { BrowserProviderUnavailableBeforeActionError } from './browser-provider-registry';
18
+ import {
19
+ ALL_BROWSER_PROVIDER_CAPABILITIES,
20
+ executeBrowserRuntimeAction,
21
+ invalidateBrowserRuntime,
22
+ } from './browser-runtime';
23
+ import { getExternalPluginAdapter } from './external-adapter';
24
+ import {
25
+ currentBrowserSessionAuthorityContext,
26
+ DEFAULT_BROWSER_SESSION_LIST_LIMIT,
27
+ findBrowserSession as findBrowserSessionAuthority,
28
+ listAllBrowserSessionsForRepository,
29
+ MAX_BROWSER_SESSION_LIST_LIMIT,
30
+ saveBrowserSession as saveBrowserSessionAuthority,
31
+ tombstoneBrowserSession,
32
+ withBrowserSessionAuthorityContext,
33
+ } from './browser-session-authority';
15
34
  import {
16
35
  closeMacOsBrowserOwnedTab,
17
36
  createMacOsBrowserOwnedPage,
18
37
  reattachMacOsBrowserOwnedPage,
19
38
  discoverMacOsBrowserAttachment,
39
+ invalidateMacOsBrowserPageHandle,
40
+ invalidateMacOsBrowserPageHandles,
41
+ macOsBrowserPageHandleStale,
20
42
  getMacOsBrowserAttachObservation,
43
+ listMacOsBrowserTabs,
44
+ readMacOsBrowserOwnedTabMetadata,
21
45
  macOsActiveBrowserAttachSupported,
22
46
  macOsBrowserJavaScriptAutomationDisabled,
23
47
  type MacOsBrowserAttachAttempt,
@@ -29,9 +53,12 @@ import {
29
53
  assertBrowserSessionAvailable,
30
54
  cancelBrowserHandoff,
31
55
  getBrowserHandoff,
56
+ isRuntimeManagedBrowserHandoff,
32
57
  listBrowserHandoffs,
58
+ resolveRuntimeManagedBrowserHandoff,
33
59
  resumeBrowserHandoff,
34
60
  startBrowserHandoff,
61
+ startRuntimeManagedBrowserHandoff,
35
62
  } from './browser-handoff';
36
63
 
37
64
  const BROWSER_PLUGIN_ID = 'browser';
@@ -69,7 +96,6 @@ interface BrowserPluginConfig {
69
96
  nativeAttachMode?: BrowserNativeAttachMode;
70
97
  nativeBrowserCandidates?: MacOsBrowserProduct[];
71
98
  defaultTimeoutMs?: number;
72
- allowedDomains?: string[];
73
99
  }
74
100
 
75
101
  interface BrowserTabResumeState {
@@ -93,6 +119,7 @@ interface BrowserSessionConnectionState {
93
119
  endpoint?: string;
94
120
  browserVersion?: string;
95
121
  browserProduct?: MacOsBrowserProduct;
122
+ nativeBrowserCandidates?: MacOsBrowserProduct[];
96
123
  fallback?: BrowserConnectionFallback;
97
124
  tab?: BrowserTabResumeState;
98
125
  sessionResume?: BrowserSessionResumeDiagnostic;
@@ -108,6 +135,30 @@ interface BrowserSessionState {
108
135
  browser?: BrowserSessionConnectionState;
109
136
  }
110
137
 
138
+ type BrowserSessionLiveness = 'live' | 'unverified' | 'dead';
139
+
140
+ type BrowserSessionInventoryItem = {
141
+ session: BrowserSessionState;
142
+ liveness: BrowserSessionLiveness;
143
+ evidence: 'runtime_session_binding' | 'runtime_owner_token' | 'runtime_bound_page_missing' | 'runtime_context_unavailable' | 'runtime_owner_token_not_observed' | 'provider_unverified' | 'native_tab_live' | 'native_tab_missing' | 'native_tab_invalid' | 'native_tab_invalid_closed' | 'native_tab_invalid_close_failed' | 'native_inventory_unavailable';
144
+ pruned?: boolean;
145
+ cleanupError?: string;
146
+ };
147
+
148
+ type BrowserSessionInventory = {
149
+ sessions: BrowserSessionInventoryItem[];
150
+ scannedSessionCount: number;
151
+ savedSessionCount: number;
152
+ liveManagedSessionCount: number;
153
+ liveNativeSessionCount: number;
154
+ unverifiedSessionCount: number;
155
+ deadManagedSessionCount: number;
156
+ deadNativeSessionCount: number;
157
+ closedInvalidNativeSessionCount: number;
158
+ failedNativeCleanupCount: number;
159
+ prunedSessionCount: number;
160
+ };
161
+
111
162
  interface BrowserActionTarget {
112
163
  sessionId: string;
113
164
  url: string;
@@ -130,7 +181,6 @@ type BrowserContextLike = {
130
181
  pages(): PageLike[];
131
182
  newPage(): Promise<PageLike>;
132
183
  close(): Promise<void>;
133
- route(pattern: string, handler: (route: RouteLike) => Promise<void> | void): Promise<void>;
134
184
  };
135
185
 
136
186
  type BrowserLike = {
@@ -140,6 +190,20 @@ type BrowserLike = {
140
190
  disconnect?(): Promise<void> | void;
141
191
  };
142
192
 
193
+ type FrameLike = {
194
+ url(): string;
195
+ name(): string;
196
+ evaluate<T>(expression: string | ((...args: unknown[]) => unknown), arg?: unknown): Promise<T>;
197
+ frameElement?(): Promise<{ boundingBox(): Promise<{ x: number; y: number; width: number; height: number } | null> }>;
198
+ };
199
+
200
+ type BrowserDownloadLike = {
201
+ suggestedFilename(): string;
202
+ saveAs(path: string): Promise<void>;
203
+ failure?(): Promise<string | null>;
204
+ delete?(): Promise<void>;
205
+ };
206
+
143
207
  type PageLike = {
144
208
  goto(url: string, options?: Record<string, unknown>): Promise<unknown>;
145
209
  reload?(options?: Record<string, unknown>): Promise<unknown>;
@@ -162,17 +226,24 @@ type PageLike = {
162
226
  uncheck?(selector: string, options?: Record<string, unknown>): Promise<void>;
163
227
  waitForSelector(selector: string, options?: Record<string, unknown>): Promise<unknown>;
164
228
  waitForLoadState?(state?: string, options?: Record<string, unknown>): Promise<void>;
229
+ waitForEvent?(event: 'download', options?: Record<string, unknown>): Promise<BrowserDownloadLike>;
230
+ frames?(): FrameLike[];
231
+ mainFrame?(): FrameLike;
165
232
  locator?(selector: string): { screenshot(options?: Record<string, unknown>): Promise<Buffer> };
166
233
  on?(event: string, handler: (...args: unknown[]) => void): void;
167
234
  bringToFront?(): Promise<void>;
168
- keyboard?: { press(key: string): Promise<void> };
169
- };
170
-
171
- type RequestLike = { url(): string };
172
- type RouteLike = {
173
- request(): RequestLike;
174
- continue(): Promise<void>;
175
- abort(errorCode?: string): Promise<void>;
235
+ foregroundState?(): Promise<{ frontmost: boolean; active: boolean }>;
236
+ trustedInput?(input: Record<string, unknown>): Promise<void>;
237
+ mouse?: {
238
+ click(x: number, y: number, options?: { button?: 'left' | 'middle' | 'right'; clickCount?: number }): Promise<void>;
239
+ move(x: number, y: number, options?: { steps?: number }): Promise<void>;
240
+ wheel(deltaX: number, deltaY: number): Promise<void>;
241
+ down(options?: { button?: 'left' | 'middle' | 'right' }): Promise<void>;
242
+ up(options?: { button?: 'left' | 'middle' | 'right' }): Promise<void>;
243
+ };
244
+ keyboard?: { press(key: string): Promise<void>; insertText?(text: string): Promise<void> };
245
+ setInputFiles?: (selector: string, files: string | string[]) => Promise<void>;
246
+ close?(): Promise<void>;
176
247
  };
177
248
 
178
249
  type PlaywrightRuntime = {
@@ -184,29 +255,73 @@ type PlaywrightRuntime = {
184
255
 
185
256
  interface BrowserPluginRuntimeHooks {
186
257
  now(): string;
187
- moduleAvailable(name: string): boolean;
188
- loadPlaywright(): PlaywrightRuntime;
258
+ moduleAvailable(name: string, repoRoot?: string): boolean;
259
+ loadPlaywright(repoRoot?: string): PlaywrightRuntime;
189
260
  fetchJson(url: string, timeoutMs: number): Promise<unknown>;
261
+ activateNativeBrowserApplication(input: AssistantPluginActionExecutionInput, product: MacOsBrowserProduct): Promise<void>;
262
+ }
263
+
264
+ const NATIVE_BROWSER_BUNDLE_IDS: Record<MacOsBrowserProduct, string> = {
265
+ chrome: 'com.google.Chrome',
266
+ vivaldi: 'com.vivaldi.Vivaldi',
267
+ };
268
+
269
+ async function activateNativeBrowserApplicationViaDesktopOperator(
270
+ input: AssistantPluginActionExecutionInput,
271
+ product: MacOsBrowserProduct,
272
+ ): Promise<void> {
273
+ const desktopOperator = getExternalPluginAdapter(input.controllerHome, 'desktop_operator');
274
+ if (!desktopOperator) {
275
+ throw new AssistantPluginError(
276
+ 'PLUGIN_BROWSER_NATIVE_FOREGROUND_ACTIVATOR_UNAVAILABLE',
277
+ 'Native browser foreground activation requires the registered Forge Desktop Operator.',
278
+ { retryable: true, details: { browserProduct: product } },
279
+ );
280
+ }
281
+ await desktopOperator.executeAction({
282
+ controllerHome: input.controllerHome,
283
+ repoId: input.repoId,
284
+ repoRoot: input.repoRoot,
285
+ pluginId: 'desktop_operator',
286
+ actionId: 'desktop_session_open',
287
+ requestId: `${input.requestId}:native-browser-foreground:${product}`,
288
+ args: { bundle_id: NATIVE_BROWSER_BUNDLE_IDS[product], launch: false, activate: true },
289
+ origin: input.origin,
290
+ jobId: input.jobId,
291
+ timeoutMs: input.timeoutMs,
292
+ signal: input.signal,
293
+ deadlineAtMs: input.deadlineAtMs,
294
+ });
190
295
  }
191
296
 
192
297
  const defaultRuntimeHooks: BrowserPluginRuntimeHooks = {
193
298
  now: () => new Date().toISOString(),
194
- moduleAvailable: (name: string) => {
195
- try {
196
- createRequire(import.meta.url).resolve(name);
197
- return true;
198
- } catch {
199
- return false;
299
+ moduleAvailable: (name: string, repoRoot?: string) => {
300
+ const anchors = [repoRoot ? join(repoRoot, 'package.json') : undefined, import.meta.url]
301
+ .filter((value): value is string => Boolean(value));
302
+ for (const anchor of anchors) {
303
+ try {
304
+ createRequire(anchor).resolve(name);
305
+ return true;
306
+ } catch {
307
+ // Try the next trusted dependency anchor.
308
+ }
200
309
  }
310
+ return false;
201
311
  },
202
- loadPlaywright: () => {
203
- try {
204
- return createRequire(import.meta.url)('playwright') as PlaywrightRuntime;
205
- } catch {
206
- throw new AssistantPluginError('PLUGIN_DEPENDENCY_MISSING', 'Browser plugin requires playwright. Run bun install before using browser actions.', {
207
- retryable: false,
208
- });
312
+ loadPlaywright: (repoRoot?: string) => {
313
+ const anchors = [repoRoot ? join(repoRoot, 'package.json') : undefined, import.meta.url]
314
+ .filter((value): value is string => Boolean(value));
315
+ for (const anchor of anchors) {
316
+ try {
317
+ return createRequire(anchor)('playwright') as PlaywrightRuntime;
318
+ } catch {
319
+ // Try the next trusted dependency anchor.
320
+ }
209
321
  }
322
+ throw new AssistantPluginError('PLUGIN_DEPENDENCY_MISSING', 'Browser plugin requires playwright. Run bun install before using browser actions.', {
323
+ retryable: false,
324
+ });
210
325
  },
211
326
  fetchJson: async (url: string, timeoutMs: number) => {
212
327
  const controller = new AbortController();
@@ -219,11 +334,21 @@ const defaultRuntimeHooks: BrowserPluginRuntimeHooks = {
219
334
  clearTimeout(timer);
220
335
  }
221
336
  },
337
+ activateNativeBrowserApplication: activateNativeBrowserApplicationViaDesktopOperator,
222
338
  };
223
339
 
224
340
  let runtimeHooks: BrowserPluginRuntimeHooks = { ...defaultRuntimeHooks };
225
341
  let runtimeHooksCustomized = false;
226
342
 
343
+ interface ManagedBrowserContextState {
344
+ repoRoot: string;
345
+ profilePath: string;
346
+ context: BrowserContextLike;
347
+ pagesBySession: Map<string, PageLike>;
348
+ }
349
+
350
+ const managedBrowserContexts = new Map<string, Promise<ManagedBrowserContextState>>();
351
+
227
352
  export function setBrowserPluginRuntimeHooksForTest(hooks: Partial<BrowserPluginRuntimeHooks>): void {
228
353
  runtimeHooks = { ...defaultRuntimeHooks, ...hooks };
229
354
  runtimeHooksCustomized = true;
@@ -232,6 +357,7 @@ export function setBrowserPluginRuntimeHooksForTest(hooks: Partial<BrowserPlugin
232
357
  export function resetBrowserPluginRuntimeHooksForTest(): void {
233
358
  runtimeHooks = { ...defaultRuntimeHooks };
234
359
  runtimeHooksCustomized = false;
360
+ managedBrowserContexts.clear();
235
361
  }
236
362
 
237
363
  function now(): string {
@@ -246,6 +372,13 @@ function positiveNumber(value: unknown, fallback: number): number {
246
372
  return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.trunc(value) : fallback;
247
373
  }
248
374
 
375
+ function requiredFiniteNumber(value: unknown, name: string, min = -100_000, max = 100_000): number {
376
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < min || value > max) {
377
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', `${name} must be a finite number between ${min} and ${max}.`, { retryable: false });
378
+ }
379
+ return value;
380
+ }
381
+
249
382
  function browserProfileMode(value: unknown): BrowserProfileMode | undefined {
250
383
  return value === 'repo_local' || value === 'custom' ? value : undefined;
251
384
  }
@@ -280,13 +413,6 @@ function browserProductList(value: unknown): MacOsBrowserProduct[] | undefined {
280
413
  return normalized.length > 0 ? Array.from(new Set(normalized)) : undefined;
281
414
  }
282
415
 
283
- function stringArray(value: unknown): string[] | undefined {
284
- if (!Array.isArray(value)) return undefined;
285
- const normalized = value
286
- .map((entry) => String(entry).trim().toLowerCase())
287
- .filter(Boolean);
288
- return normalized.length > 0 ? Array.from(new Set(normalized)) : undefined;
289
- }
290
416
 
291
417
  function stringList(value: unknown): string[] | undefined {
292
418
  if (!Array.isArray(value)) return undefined;
@@ -347,7 +473,7 @@ function normalizeConfig(raw: Partial<BrowserPluginConfig>): BrowserPluginConfig
347
473
  schemaVersion: 1,
348
474
  enabled: raw.enabled === true,
349
475
  provider: 'playwright',
350
- browserMode: browserMode(raw.browserMode) ?? 'managed_persistent',
476
+ browserMode: browserMode(raw.browserMode) ?? 'attach_preferred',
351
477
  profileMode: browserProfileMode(raw.profileMode) ?? (normalizedProfileDir ? 'custom' : 'repo_local'),
352
478
  profileDir: normalizedProfileDir,
353
479
  profileDirectory: stringValue(raw.profileDirectory),
@@ -360,9 +486,8 @@ function normalizeConfig(raw: Partial<BrowserPluginConfig>): BrowserPluginConfig
360
486
  : undefined,
361
487
  cdpAttachFallback: browserCdpAttachFallback(raw.cdpAttachFallback) ?? 'fail_closed',
362
488
  nativeAttachMode: browserNativeAttachMode(raw.nativeAttachMode) ?? 'auto',
363
- nativeBrowserCandidates: browserProductList(raw.nativeBrowserCandidates) ?? ['vivaldi', 'chrome'],
489
+ nativeBrowserCandidates: browserProductList(raw.nativeBrowserCandidates) ?? ['chrome', 'vivaldi'],
364
490
  defaultTimeoutMs: typeof raw.defaultTimeoutMs === 'number' ? positiveNumber(raw.defaultTimeoutMs, DEFAULT_TIMEOUT_MS) : undefined,
365
- allowedDomains: stringArray(raw.allowedDomains),
366
491
  };
367
492
  }
368
493
 
@@ -379,13 +504,124 @@ function writeJson(path: string, value: unknown): void {
379
504
  writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, 'utf-8');
380
505
  }
381
506
 
507
+ function writeAtomicJson(path: string, value: unknown): void {
508
+ mkdirSync(dirname(path), { recursive: true });
509
+ const tempPath = join(dirname(path), `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
510
+ try {
511
+ writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, 'utf-8');
512
+ renameSync(tempPath, path);
513
+ } finally {
514
+ rmSync(tempPath, { force: true });
515
+ }
516
+ }
517
+
518
+ function readBrowserSessionJson(path: string): BrowserSessionState | undefined {
519
+ let raw: string;
520
+ try {
521
+ raw = readFileSync(path, 'utf-8');
522
+ } catch (error) {
523
+ if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') return undefined;
524
+ throw new AssistantPluginError('PLUGIN_BROWSER_SESSION_STATE_READ_FAILED', 'Saved browser session metadata could not be read.', {
525
+ retryable: true,
526
+ details: { fileName: basename(path), cause: error instanceof Error ? error.message : String(error) },
527
+ });
528
+ }
529
+ try {
530
+ return JSON.parse(raw) as BrowserSessionState;
531
+ } catch (error) {
532
+ throw new AssistantPluginError('PLUGIN_BROWSER_SESSION_STATE_CORRUPT', 'Saved browser session metadata is malformed; refusing to treat corrupt state as a missing session.', {
533
+ retryable: false,
534
+ details: { fileName: basename(path), cause: error instanceof Error ? error.message : String(error) },
535
+ });
536
+ }
537
+ }
538
+
539
+ function legacyChatgptBindingConfig(repoRoot: string): Partial<BrowserPluginConfig> {
540
+ const binding = readBrowserBinding(repoRoot).binding;
541
+ if (!binding) return {};
542
+ return {
543
+ profileMode: 'custom',
544
+ profileDir: binding.profileDir,
545
+ profileDirectory: binding.profileDirectory,
546
+ browserChannel: browserChannel(binding.browserChannel) ?? 'chromium',
547
+ };
548
+ }
549
+
382
550
  function loadConfig(repoRoot: string): BrowserPluginConfig {
383
- return normalizeConfig(readJson<Partial<BrowserPluginConfig>>(configPath(repoRoot)) ?? {});
551
+ const persisted = readJson<Partial<BrowserPluginConfig>>(configPath(repoRoot));
552
+ if (!persisted) return normalizeConfig(legacyChatgptBindingConfig(repoRoot));
553
+ const merged: Partial<BrowserPluginConfig> = { ...legacyChatgptBindingConfig(repoRoot), ...persisted };
554
+ if (browserProfileMode(persisted.profileMode) === 'repo_local') {
555
+ merged.profileDir = undefined;
556
+ merged.profileDirectory = undefined;
557
+ }
558
+ return normalizeConfig(merged);
559
+ }
560
+
561
+ async function resolveBrowserPluginAuthorizationContextInternal(
562
+ input: AssistantPluginActionExecutionInput,
563
+ ): Promise<AssistantPluginAuthorizationContext | undefined> {
564
+ const action = actions().find((entry) => entry.actionId === input.actionId);
565
+ if (!action || action.confirmation !== 'authorization' || !action.scopes.includes('browser.interact')) return undefined;
566
+
567
+ const persistedConfig = loadConfig(input.repoRoot);
568
+ const sessionId = stringValue(input.args.session_id);
569
+ const authorizationBrowserMode = parseBrowserModeInput(input.args.browser_mode) ?? persistedConfig.browserMode;
570
+ if (!sessionId || authorizationBrowserMode === 'isolated') return undefined;
571
+ const session = findSession(input.repoRoot, sessionId);
572
+ const config = effectiveBrowserActionConfig(persistedConfig, input.args, session);
573
+ const connection = session?.browser;
574
+ if (!session || !connection || connection.activeMode === 'isolated') return undefined;
575
+
576
+ let origin: string;
577
+ try {
578
+ const destinationUrl = input.actionId === 'navigate' ? stringValue(input.args.url) : undefined;
579
+ const parsed = new URL(destinationUrl ?? session.url);
580
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return undefined;
581
+ origin = parsed.origin.toLowerCase();
582
+ } catch {
583
+ return undefined;
584
+ }
585
+
586
+ const profile = selectedProfile(config, input.repoRoot, connection.activeMode, sessionId);
587
+ const providerId = connection.browserProduct ?? connection.provider;
588
+ const identity = {
589
+ provider: connection.provider,
590
+ browserProduct: connection.browserProduct,
591
+ activeMode: connection.activeMode,
592
+ endpoint: connection.endpoint,
593
+ origin,
594
+ browserChannel: config.browserChannel,
595
+ executablePath: config.executablePath ? resolveConfiguredPath(input.repoRoot, config.executablePath) : undefined,
596
+ profileMode: config.profileMode,
597
+ selectedProfilePath: profile.selectedProfilePath,
598
+ profileDirectory: profile.profileDirectory,
599
+ cdpAttachFallback: config.cdpAttachFallback,
600
+ nativeAttachMode: config.nativeAttachMode,
601
+ nativeBrowserCandidates: config.nativeBrowserCandidates,
602
+ };
603
+ return {
604
+ target: {
605
+ kind: 'browser-origin',
606
+ id: `${providerId}@${origin}`,
607
+ identityFingerprint: createHash('sha256').update(JSON.stringify(identity)).digest('hex'),
608
+ },
609
+ expiresInMinutes: 30 * 24 * 60,
610
+ };
611
+ }
612
+
613
+ export async function resolveBrowserPluginAuthorizationContext(
614
+ input: AssistantPluginActionExecutionInput,
615
+ ): Promise<AssistantPluginAuthorizationContext | undefined> {
616
+ return await withBrowserSessionAuthorityContext(
617
+ { controllerHome: input.controllerHome, repoId: input.repoId },
618
+ () => resolveBrowserPluginAuthorizationContextInternal(input),
619
+ );
384
620
  }
385
621
 
386
- export function readBrowserPluginConfiguration(repoRoot: string): { enabled: boolean; allowedDomains: string[] } {
622
+ export function readBrowserPluginConfiguration(repoRoot: string): { enabled: boolean } {
387
623
  const config = loadConfig(repoRoot);
388
- return { enabled: config.enabled, allowedDomains: [...(config.allowedDomains ?? [])] };
624
+ return { enabled: config.enabled };
389
625
  }
390
626
 
391
627
  function saveConfig(repoRoot: string, patch: Partial<BrowserPluginConfig>): BrowserPluginConfig {
@@ -398,12 +634,59 @@ function sessionPath(repoRoot: string, sessionId: string): string {
398
634
  return join(stateDir(repoRoot, 'sessions'), `${sessionId.replace(/[^a-zA-Z0-9_-]/g, '_')}.json`);
399
635
  }
400
636
 
401
- function saveSession(repoRoot: string, session: BrowserSessionState): void {
402
- writeJson(sessionPath(repoRoot, session.sessionId), session);
637
+ function saveSession(repoRoot: string, session: BrowserSessionState): BrowserSessionState {
638
+ const authority = currentBrowserSessionAuthorityContext();
639
+ if (!authority) {
640
+ writeAtomicJson(sessionPath(repoRoot, session.sessionId), session);
641
+ return session;
642
+ }
643
+ const saved = saveBrowserSessionAuthority<BrowserSessionState>(authority.controllerHome, authority.repoId, repoRoot, session);
644
+ if (saved.sessionId === session.sessionId || !saved.browser?.sessionResume) return saved;
645
+ return {
646
+ ...saved,
647
+ browser: {
648
+ ...saved.browser,
649
+ sessionResume: { ...saved.browser.sessionResume, sessionId: saved.sessionId },
650
+ },
651
+ };
403
652
  }
404
653
 
405
654
  function findSession(repoRoot: string, sessionId: string): BrowserSessionState | undefined {
406
- return readJson<BrowserSessionState>(sessionPath(repoRoot, sessionId));
655
+ const authority = currentBrowserSessionAuthorityContext();
656
+ return authority
657
+ ? findBrowserSessionAuthority<BrowserSessionState>(authority.controllerHome, authority.repoId, repoRoot, sessionId)
658
+ : readBrowserSessionJson(sessionPath(repoRoot, sessionId));
659
+ }
660
+
661
+ function listSavedBrowserSessions(repoRoot: string): BrowserSessionState[] {
662
+ const authority = currentBrowserSessionAuthorityContext();
663
+ if (authority) {
664
+ return listAllBrowserSessionsForRepository<BrowserSessionState>(authority.controllerHome, authority.repoId, repoRoot);
665
+ }
666
+ const root = stateDir(repoRoot, 'sessions');
667
+ let names: string[];
668
+ try {
669
+ names = readdirSync(root);
670
+ } catch (error) {
671
+ if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') return [];
672
+ throw new AssistantPluginError('PLUGIN_BROWSER_SESSION_STATE_READ_FAILED', 'Saved browser session directory could not be read.', {
673
+ retryable: true,
674
+ details: { cause: error instanceof Error ? error.message : String(error) },
675
+ });
676
+ }
677
+ return names
678
+ .filter((name) => name.endsWith('.json'))
679
+ .map((name) => readBrowserSessionJson(join(root, name)))
680
+ .filter((session): session is BrowserSessionState => Boolean(session));
681
+ }
682
+
683
+ function removeSession(repoRoot: string, sessionId: string): void {
684
+ const authority = currentBrowserSessionAuthorityContext();
685
+ if (authority) {
686
+ tombstoneBrowserSession(authority.controllerHome, authority.repoId, repoRoot, sessionId);
687
+ return;
688
+ }
689
+ rmSync(sessionPath(repoRoot, sessionId), { force: true });
407
690
  }
408
691
 
409
692
  function loadSession(repoRoot: string, sessionId: string): BrowserSessionState {
@@ -414,43 +697,66 @@ function loadSession(repoRoot: string, sessionId: string): BrowserSessionState {
414
697
  return state;
415
698
  }
416
699
 
417
- function normalizedUrl(value: unknown): string {
700
+ function parsedAbsoluteUrl(value: unknown): { raw: string; parsed: URL } {
418
701
  const raw = stringValue(value);
419
702
  if (!raw) throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'url is required.', { retryable: false });
420
- let parsed: URL;
421
703
  try {
422
- parsed = new URL(raw);
704
+ return { raw, parsed: new URL(raw) };
423
705
  } catch {
424
706
  throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'url must be absolute.', { retryable: false });
425
707
  }
426
- if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
427
- throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'Only http and https URLs are supported.', { retryable: false });
428
- }
708
+ }
709
+
710
+ function unsupportedUrlProtocol(value: unknown, raw: string, protocol: string): never {
711
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', `Only http and https URLs are supported; observed protocol: ${protocol}`, {
712
+ retryable: false,
713
+ details: {
714
+ observedProtocol: protocol,
715
+ inputType: typeof value,
716
+ inputLength: raw.length,
717
+ },
718
+ });
719
+ }
720
+
721
+ function normalizedUrl(value: unknown): string {
722
+ const { raw, parsed } = parsedAbsoluteUrl(value);
723
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') unsupportedUrlProtocol(value, raw, parsed.protocol);
429
724
  return parsed.toString();
430
725
  }
431
726
 
432
- function isRemoteHttpUrl(url: string): boolean {
433
- try {
434
- const parsed = new URL(url);
435
- return parsed.protocol === 'http:' || parsed.protocol === 'https:';
436
- } catch {
437
- return false;
438
- }
727
+ function normalizedExplicitNativeAdoptionUrl(value: unknown): string {
728
+ const { raw, parsed } = parsedAbsoluteUrl(value);
729
+ if (!['https:', 'http:', 'chrome:', 'vivaldi:'].includes(parsed.protocol)) unsupportedUrlProtocol(value, raw, parsed.protocol);
730
+ return parsed.toString();
731
+ }
732
+
733
+ function browserInternalUrlProduct(value: unknown): MacOsBrowserProduct | undefined {
734
+ const { parsed } = parsedAbsoluteUrl(value);
735
+ return parsed.protocol === 'chrome:' ? 'chrome' : parsed.protocol === 'vivaldi:' ? 'vivaldi' : undefined;
736
+ }
737
+
738
+ function explicitNativeAdoptionUrlsMatch(requestedValue: unknown, observedValue: unknown, product: MacOsBrowserProduct): boolean {
739
+ const requestedUrl = normalizedExplicitNativeAdoptionUrl(requestedValue);
740
+ const observedUrl = normalizedExplicitNativeAdoptionUrl(observedValue);
741
+ if (comparableUrl(requestedUrl) === comparableUrl(observedUrl)) return true;
742
+ return product === 'vivaldi'
743
+ && requestedUrl === 'vivaldi:bookmarks'
744
+ && comparableUrl(observedUrl) === comparableUrl('chrome://bookmarks/');
439
745
  }
440
746
 
441
- function isUrlAllowed(url: string, config: BrowserPluginConfig): boolean {
442
- if (!config.allowedDomains || config.allowedDomains.length === 0) return true;
443
- if (!isRemoteHttpUrl(url)) return true;
444
- const hostname = new URL(url).hostname.toLowerCase();
445
- return config.allowedDomains.some((domain) => hostname === domain || hostname.endsWith(`.${domain}`));
747
+ function isExplicitNativeUserOwnedSession(session: BrowserSessionState): boolean {
748
+ return session.browser?.provider === 'macos-apple-events'
749
+ && session.browser.tab?.ownership === 'user_owned'
750
+ && (session.browser.browserProduct === 'chrome' || session.browser.browserProduct === 'vivaldi');
446
751
  }
447
752
 
448
- function assertUrlAllowed(url: string, config: BrowserPluginConfig): void {
449
- if (isUrlAllowed(url, config)) return;
450
- const hostname = new URL(url).hostname.toLowerCase();
451
- throw new AssistantPluginError('PLUGIN_POLICY_BLOCKED', `Domain is not allowed: ${hostname}`, { retryable: false });
753
+ function normalizedSavedSessionUrl(session: BrowserSessionState): string {
754
+ return isExplicitNativeUserOwnedSession(session)
755
+ ? normalizedExplicitNativeAdoptionUrl(session.url)
756
+ : normalizedUrl(session.url);
452
757
  }
453
758
 
759
+
454
760
  function waitUntil(value: unknown): WaitUntil {
455
761
  return value === 'load' || value === 'networkidle' || value === 'domcontentloaded' ? value : 'domcontentloaded';
456
762
  }
@@ -501,6 +807,35 @@ function parseNativeBrowserCandidatesInput(value: unknown): MacOsBrowserProduct[
501
807
  throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'native_browser_candidates must contain chrome and/or vivaldi.', { retryable: false });
502
808
  }
503
809
 
810
+ function effectiveBrowserActionConfig(
811
+ config: BrowserPluginConfig,
812
+ args: Record<string, unknown>,
813
+ existingSession?: BrowserSessionState,
814
+ ): BrowserPluginConfig {
815
+ const explicitBrowserMode = parseBrowserModeInput(args.browser_mode);
816
+ const explicitNativeBrowserCandidates = parseNativeBrowserCandidatesInput(args.native_browser_candidates);
817
+ const existingNativeProduct = existingSession?.browser?.provider === 'macos-apple-events'
818
+ ? existingSession.browser.browserProduct
819
+ : undefined;
820
+ if (existingNativeProduct && explicitNativeBrowserCandidates && !explicitNativeBrowserCandidates.includes(existingNativeProduct)) {
821
+ throw new AssistantPluginError(
822
+ 'PLUGIN_ACTION_ARGUMENT_INVALID',
823
+ 'native_browser_candidates cannot switch browser products for an existing native browser session.',
824
+ { retryable: false, details: { sessionId: existingSession?.sessionId, browserProduct: existingNativeProduct, nativeBrowserCandidates: explicitNativeBrowserCandidates } },
825
+ );
826
+ }
827
+ const savedNativeBrowserCandidates = existingSession?.browser?.provider === 'macos-apple-events'
828
+ ? existingSession.browser.nativeBrowserCandidates ?? (existingNativeProduct ? [existingNativeProduct] : undefined)
829
+ : undefined;
830
+ return {
831
+ ...config,
832
+ browserMode: explicitBrowserMode ?? existingSession?.browser?.mode ?? config.browserMode,
833
+ cdpAttachFallback: parseCdpAttachFallbackInput(args.cdp_attach_fallback) ?? config.cdpAttachFallback,
834
+ nativeAttachMode: parseNativeAttachModeInput(args.native_attach_mode) ?? config.nativeAttachMode,
835
+ nativeBrowserCandidates: explicitNativeBrowserCandidates ?? savedNativeBrowserCandidates ?? config.nativeBrowserCandidates,
836
+ };
837
+ }
838
+
504
839
  function cdpEndpoints(config: BrowserPluginConfig): string[] {
505
840
  return Array.from(new Set([
506
841
  ...(config.cdpEndpoint ? [config.cdpEndpoint] : []),
@@ -561,9 +896,6 @@ function validateConfig(config: BrowserPluginConfig): string[] {
561
896
 
562
897
  function configWarnings(config: BrowserPluginConfig): string[] {
563
898
  const warnings: string[] = [];
564
- if (!config.allowedDomains || config.allowedDomains.length === 0) {
565
- warnings.push('allowedDomains is empty; browser actions can target any domain.');
566
- }
567
899
  if (config.profileMode === 'custom') {
568
900
  warnings.push('Custom profile mode uses the configured browser profile directly. If the browser reports the profile is in use, fully close the matching browser instance first.');
569
901
  if (!config.executablePath && (config.browserChannel ?? 'chromium') === 'chromium') {
@@ -608,11 +940,34 @@ function requiredString(value: unknown, field: string): string {
608
940
  return normalized;
609
941
  }
610
942
 
943
+ function openShadowSelectorParts(selector: string): string[] {
944
+ return selector.split(/\s*>>>\s*/).map((part) => part.trim()).filter(Boolean);
945
+ }
946
+
947
+ function openShadowSelectorExpression(selector: string, all = false): string {
948
+ const parts = openShadowSelectorParts(selector);
949
+ if (parts.length <= 1) {
950
+ return all
951
+ ? `Array.from(document.querySelectorAll(${JSON.stringify(selector)}))`
952
+ : `document.querySelector(${JSON.stringify(selector)})`;
953
+ }
954
+ return `(() => {
955
+ const parts = ${JSON.stringify(parts)};
956
+ let root = document;
957
+ for (let index = 0; index < parts.length - 1; index += 1) {
958
+ const host = root.querySelector(parts[index]);
959
+ if (!host || !host.shadowRoot) return ${all ? '[]' : 'null'};
960
+ root = host.shadowRoot;
961
+ }
962
+ return ${all ? 'Array.from(root.querySelectorAll(parts[parts.length - 1]))' : 'root.querySelector(parts[parts.length - 1])'};
963
+ })()`;
964
+ }
965
+
611
966
  function scriptText(selector?: string): string {
612
967
  if (!selector) {
613
968
  return 'document.body ? document.body.innerText : (document.documentElement ? document.documentElement.textContent : "")';
614
969
  }
615
- return `(() => { const el = document.querySelector(${JSON.stringify(selector)}); return el ? (el.innerText || el.textContent || '') : ''; })()`;
970
+ return `(() => { const el = ${openShadowSelectorExpression(selector)}; return el ? (el.innerText || el.textContent || '') : ''; })()`;
616
971
  }
617
972
 
618
973
  function resolveActionTarget(repoRoot: string, args: Record<string, unknown>): BrowserActionTarget {
@@ -623,9 +978,10 @@ function resolveActionTarget(repoRoot: string, args: Record<string, unknown>): B
623
978
  }
624
979
  if (providedSessionId) {
625
980
  const existingSession = loadSession(repoRoot, providedSessionId);
626
- const sessionUrl = normalizedUrl(existingSession.url);
981
+ const nativeUserOwned = isExplicitNativeUserOwnedSession(existingSession);
982
+ const sessionUrl = normalizedSavedSessionUrl(existingSession);
627
983
  if (directUrl) {
628
- const explicitUrl = normalizedUrl(directUrl);
984
+ const explicitUrl = nativeUserOwned ? normalizedExplicitNativeAdoptionUrl(directUrl) : normalizedUrl(directUrl);
629
985
  if (explicitUrl !== sessionUrl) {
630
986
  throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'url does not match the saved session.', { retryable: false });
631
987
  }
@@ -658,6 +1014,27 @@ function screenshotFilePath(repoRoot: string, actionId: string, sessionId: strin
658
1014
  return join(screenshotDir, `${Date.now()}-${actionLabel}-${sessionLabel}-${digest}.png`);
659
1015
  }
660
1016
 
1017
+ function safeDownloadArtifactName(requestedName: string | undefined, browserSuggestedName: string | undefined): string {
1018
+ const fallback = `download-${Date.now()}`;
1019
+ const raw = (requestedName || browserSuggestedName || fallback).trim();
1020
+ const leaf = basename(raw) || fallback;
1021
+ const sanitized = leaf.replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 180);
1022
+ return sanitized && sanitized !== '.' && sanitized !== '..' ? sanitized : fallback;
1023
+ }
1024
+
1025
+ function isBlockedExecutableArtifactName(name: string | undefined): boolean {
1026
+ return Boolean(name && /\.(exe|dmg|pkg|sh|bat|cmd|app)$/i.test(basename(name)));
1027
+ }
1028
+
1029
+ function uniqueDownloadArtifactPath(downloadDir: string, fileName: string): string {
1030
+ const initial = join(downloadDir, fileName);
1031
+ if (!existsSync(initial)) return initial;
1032
+ const dot = fileName.lastIndexOf('.');
1033
+ const stem = dot > 0 ? fileName.slice(0, dot) : fileName;
1034
+ const ext = dot > 0 ? fileName.slice(dot) : '';
1035
+ return join(downloadDir, `${stem}-${Date.now()}${ext}`);
1036
+ }
1037
+
661
1038
  async function captureActionScreenshot(page: PageLike, repoRoot: string, actionId: string, sessionId: string, url: string): Promise<BrowserActionScreenshot | undefined> {
662
1039
  const path = screenshotFilePath(repoRoot, actionId, sessionId, url);
663
1040
  const bytes = (await page.screenshot({ path, fullPage: true })).length;
@@ -668,6 +1045,10 @@ function responseWithWarnings(base: Record<string, unknown>, warnings: string[])
668
1045
  return warnings.length > 0 ? { ...base, warnings } : base;
669
1046
  }
670
1047
 
1048
+ function browserResultProvider(provider: BrowserSessionConnectionState['provider'] | BrowserConnectionSummary['provider'] | undefined): 'playwright' | 'macos-apple-events' {
1049
+ return provider === 'macos-apple-events' ? 'macos-apple-events' : 'playwright';
1050
+ }
1051
+
671
1052
  function interactionResult(
672
1053
  actionId: string,
673
1054
  session: BrowserSessionState,
@@ -677,7 +1058,7 @@ function interactionResult(
677
1058
  extra: Record<string, unknown> = {},
678
1059
  ): Record<string, unknown> {
679
1060
  return responseWithWarnings({
680
- provider: 'playwright',
1061
+ provider: browserResultProvider(session.browser?.provider),
681
1062
  session,
682
1063
  url: session.url,
683
1064
  title: session.title,
@@ -695,17 +1076,6 @@ async function delay(ms: number): Promise<void> {
695
1076
  await new Promise((resolve) => setTimeout(resolve, ms));
696
1077
  }
697
1078
 
698
- async function applyDomainGuard(context: BrowserContextLike, config: BrowserPluginConfig): Promise<void> {
699
- if (!config.allowedDomains || config.allowedDomains.length === 0) return;
700
- await context.route('**/*', async (route) => {
701
- const url = route.request().url();
702
- if (isUrlAllowed(url, config)) {
703
- await route.continue();
704
- return;
705
- }
706
- await route.abort('blockedbyclient');
707
- });
708
- }
709
1079
 
710
1080
  function selectedProfile(config: BrowserPluginConfig, repoRoot: string, activeMode: BrowserMode = 'managed_persistent', sessionId?: string): BrowserProfileSelection {
711
1081
  if (activeMode === 'isolated') {
@@ -730,7 +1100,7 @@ function selectedProfile(config: BrowserPluginConfig, repoRoot: string, activeMo
730
1100
  };
731
1101
  }
732
1102
 
733
- type BrowserTabMatchReason = 'owned_token' | 'saved_url_title' | 'saved_url' | 'exact_url' | 'blank' | 'new_page';
1103
+ type BrowserTabMatchReason = 'owned_token' | 'recovered_tab' | 'saved_url_title' | 'saved_url' | 'exact_url' | 'blank' | 'new_page';
734
1104
 
735
1105
  const BROWSER_OWNER_PREFIX = 'forge-browser-owned:';
736
1106
 
@@ -778,6 +1148,7 @@ interface BrowserConnectionSummary {
778
1148
  endpoint?: string;
779
1149
  browserVersion?: string;
780
1150
  browserProduct?: MacOsBrowserProduct;
1151
+ nativeBrowserCandidates?: MacOsBrowserProduct[];
781
1152
  fallback?: BrowserConnectionFallback;
782
1153
  profile: {
783
1154
  profileMode?: BrowserProfileMode;
@@ -789,6 +1160,62 @@ interface BrowserConnectionSummary {
789
1160
  sessionResume?: BrowserSessionResumeDiagnostic;
790
1161
  }
791
1162
 
1163
+ async function establishAuthoritativeNativeForeground(
1164
+ input: AssistantPluginActionExecutionInput,
1165
+ page: PageLike,
1166
+ connection: BrowserConnectionSummary,
1167
+ target: BrowserActionTarget,
1168
+ waitMs: number,
1169
+ ): Promise<{ frontmost: boolean; active: boolean }> {
1170
+ if (!page.bringToFront || !page.foregroundState) {
1171
+ throw new AssistantPluginError(
1172
+ 'PLUGIN_BROWSER_ACTIVATION_UNSUPPORTED',
1173
+ 'The native browser provider cannot verify exact-tab foreground authority.',
1174
+ { retryable: false, details: { sessionId: target.sessionId, browserProduct: connection.browserProduct } },
1175
+ );
1176
+ }
1177
+
1178
+ await page.bringToFront();
1179
+ await delay(waitMs);
1180
+ let state = await page.foregroundState();
1181
+ if (state.frontmost && state.active) return state;
1182
+
1183
+ const product = connection.browserProduct;
1184
+ if (!product) {
1185
+ throw new AssistantPluginError(
1186
+ 'PLUGIN_BROWSER_ACTIVATION_FAILED',
1187
+ 'Native browser activation could not identify the browser product for system foreground recovery.',
1188
+ { retryable: true, details: { sessionId: target.sessionId, ...state } },
1189
+ );
1190
+ }
1191
+
1192
+ try {
1193
+ await runtimeHooks.activateNativeBrowserApplication(input, product);
1194
+ } catch (error) {
1195
+ const causeCode = error instanceof AssistantPluginError ? error.code : undefined;
1196
+ const cause = error instanceof Error ? error.message : String(error);
1197
+ throw new AssistantPluginError(
1198
+ 'PLUGIN_BROWSER_ACTIVATION_FAILED',
1199
+ 'Native browser exact-tab activation could not establish macOS system foreground authority through Forge Desktop Operator.',
1200
+ { retryable: true, details: { sessionId: target.sessionId, browserProduct: product, ...state, ...(causeCode ? { causeCode } : {}), cause: cause.slice(0, 500) } },
1201
+ );
1202
+ }
1203
+
1204
+ // App-level activation is intentionally separate from tab selection. Re-assert the exact
1205
+ // saved tab after the app becomes frontmost, then verify both authorities together.
1206
+ await page.bringToFront();
1207
+ await delay(waitMs);
1208
+ state = await page.foregroundState();
1209
+ if (!state.frontmost || !state.active) {
1210
+ throw new AssistantPluginError(
1211
+ 'PLUGIN_BROWSER_ACTIVATION_FAILED',
1212
+ 'Native browser activation did not make the exact saved tab authoritative system foreground state.',
1213
+ { retryable: true, details: { sessionId: target.sessionId, browserProduct: product, ...state } },
1214
+ );
1215
+ }
1216
+ return state;
1217
+ }
1218
+
792
1219
  interface BrowserOpenHandle {
793
1220
  page: PageLike;
794
1221
  diagnostics: PageDiagnostics;
@@ -848,6 +1275,172 @@ function comparableUrl(value: string): string {
848
1275
  }
849
1276
  }
850
1277
 
1278
+ export function nativeReplacementUrlMatchesTarget(requestedUrl: string, actualUrl: string): boolean {
1279
+ if (comparableUrl(actualUrl) === comparableUrl(requestedUrl)) return true;
1280
+ try {
1281
+ const requested = new URL(requestedUrl);
1282
+ const actual = new URL(actualUrl);
1283
+ const requestedIsOriginRoot = (requested.protocol === 'https:' || requested.protocol === 'http:')
1284
+ && requested.pathname === '/'
1285
+ && requested.search === ''
1286
+ && requested.hash === '';
1287
+ return requestedIsOriginRoot && actual.origin === requested.origin;
1288
+ } catch {
1289
+ return false;
1290
+ }
1291
+ }
1292
+
1293
+ export async function settleNativeCreatedPageIdentity(
1294
+ page: {
1295
+ waitForLoadState: (state?: string, options?: Record<string, unknown>) => Promise<void>;
1296
+ identity: () => Promise<{ url: string; title: string }>;
1297
+ },
1298
+ state: string,
1299
+ timeoutMs: number,
1300
+ ): Promise<{ url: string; title: string }> {
1301
+ await page.waitForLoadState(state, { timeout: timeoutMs, requireHttpUrl: true });
1302
+ return await page.identity();
1303
+ }
1304
+
1305
+ export function nativeReplacementMismatchDiagnostic(input: {
1306
+ requestedUrl: string;
1307
+ actualUrl: string;
1308
+ previousUrl?: string;
1309
+ obsoleteRef?: MacOsBrowserTabRef;
1310
+ replacementRef?: MacOsBrowserTabRef;
1311
+ }): {
1312
+ sameTargetOrigin: boolean;
1313
+ samePreviousOrigin: boolean;
1314
+ samePreviousUrl: boolean;
1315
+ sameTabRef: boolean;
1316
+ actualScheme: string;
1317
+ } {
1318
+ let actualScheme = 'invalid';
1319
+ try {
1320
+ const protocol = new URL(input.actualUrl).protocol.replace(/:$/, '').toLowerCase();
1321
+ actualScheme = /^[a-z][a-z0-9+.-]*$/.test(protocol) ? protocol : 'invalid';
1322
+ } catch {
1323
+ // Keep the diagnostic bounded and non-sensitive when the observed URL is malformed.
1324
+ }
1325
+ const previousUrl = input.previousUrl?.trim();
1326
+ return {
1327
+ sameTargetOrigin: sameOrigin(input.actualUrl, input.requestedUrl),
1328
+ samePreviousOrigin: previousUrl ? sameOrigin(input.actualUrl, previousUrl) : false,
1329
+ samePreviousUrl: previousUrl ? comparableUrl(input.actualUrl) === comparableUrl(previousUrl) : false,
1330
+ sameTabRef: Boolean(input.obsoleteRef && input.replacementRef
1331
+ && input.obsoleteRef.windowId === input.replacementRef.windowId
1332
+ && input.obsoleteRef.tabId === input.replacementRef.tabId),
1333
+ actualScheme,
1334
+ };
1335
+ }
1336
+
1337
+ function sameOrigin(left: string, right: string): boolean {
1338
+ try {
1339
+ return new URL(left).origin === new URL(right).origin;
1340
+ } catch {
1341
+ return false;
1342
+ }
1343
+ }
1344
+
1345
+ function stableBrowserEntityKey(value: string): string | undefined {
1346
+ try {
1347
+ const url = new URL(value);
1348
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') return undefined;
1349
+ const segments = url.pathname.split('/').filter(Boolean);
1350
+ for (let index = 0; index + 1 < segments.length; index += 1) {
1351
+ if ((segments[index] ?? '').toLowerCase() !== 'apps') continue;
1352
+ const id = segments[index + 1]?.trim();
1353
+ if (id) return `${url.origin}/apps/${id}`;
1354
+ }
1355
+ return undefined;
1356
+ } catch {
1357
+ return undefined;
1358
+ }
1359
+ }
1360
+
1361
+ function selectReusableNativeTab(
1362
+ referenceUrls: string[],
1363
+ inventory: Awaited<ReturnType<typeof listMacOsBrowserTabs>>,
1364
+ preferredWindowId?: string,
1365
+ ): {
1366
+ candidate?: (typeof inventory.tabs)[number];
1367
+ match: 'exact_url' | 'stable_entity' | 'none' | 'ambiguous';
1368
+ candidateCount: number;
1369
+ } {
1370
+ const normalizedReferences = [...new Set(referenceUrls.filter(Boolean).map(comparableUrl))];
1371
+ const entityKeys = new Set(normalizedReferences.map(stableBrowserEntityKey).filter((value): value is string => Boolean(value)));
1372
+ const scored = inventory.tabs.map((candidate) => {
1373
+ const normalizedCandidate = comparableUrl(candidate.url);
1374
+ const entityKey = stableBrowserEntityKey(candidate.url);
1375
+ const matchScore = normalizedReferences.includes(normalizedCandidate)
1376
+ ? 100
1377
+ : entityKey && entityKeys.has(entityKey)
1378
+ ? 50
1379
+ : 0;
1380
+ const score = matchScore === 0
1381
+ ? 0
1382
+ : matchScore + (preferredWindowId && candidate.windowId === preferredWindowId ? 10 : 0) + (candidate.active ? 1 : 0);
1383
+ return { candidate, score, matchScore };
1384
+ });
1385
+ const maxScore = scored.reduce((max, entry) => Math.max(max, entry.score), 0);
1386
+ if (maxScore === 0) return { match: 'none', candidateCount: 0 };
1387
+ const matches = scored.filter((entry) => entry.score === maxScore);
1388
+ if (matches.length !== 1) return { match: 'ambiguous', candidateCount: matches.length };
1389
+ return { candidate: matches[0]!.candidate, match: matches[0]!.matchScore === 100 ? 'exact_url' : 'stable_entity', candidateCount: 1 };
1390
+ }
1391
+
1392
+ function selectLiveForgeOwnedNativeTab(
1393
+ repoRoot: string,
1394
+ targetUrl: string,
1395
+ product: MacOsBrowserProduct,
1396
+ inventory: Awaited<ReturnType<typeof listMacOsBrowserTabs>>,
1397
+ ): {
1398
+ session?: BrowserSessionState;
1399
+ candidate?: (typeof inventory.tabs)[number];
1400
+ match: 'exact_url' | 'same_origin' | 'none' | 'ambiguous';
1401
+ candidateCount: number;
1402
+ } {
1403
+ const liveByRef = new Map(inventory.tabs.map((tab) => [`${tab.windowId}:${tab.tabId}`, tab]));
1404
+ const candidates = new Map<string, { session: BrowserSessionState; candidate: (typeof inventory.tabs)[number] }>();
1405
+ for (const session of listSavedBrowserSessions(repoRoot)) {
1406
+ const browser = session.browser;
1407
+ const tab = browser?.tab;
1408
+ if (browser?.provider !== 'macos-apple-events' || browser.browserProduct !== product || tab?.ownership !== 'plugin_owned') continue;
1409
+ if (!tab.windowId || !tab.tabId) continue;
1410
+ const key = `${tab.windowId}:${tab.tabId}`;
1411
+ const live = liveByRef.get(key);
1412
+ if (!live) continue;
1413
+ const previous = candidates.get(key);
1414
+ if (!previous || session.updatedAt > previous.session.updatedAt) candidates.set(key, { session, candidate: live });
1415
+ }
1416
+
1417
+ const values = [...candidates.values()];
1418
+ const exact = values.filter(({ candidate }) => comparableUrl(candidate.url) === comparableUrl(targetUrl));
1419
+ const choose = (matches: typeof values, match: 'exact_url' | 'same_origin') => {
1420
+ if (matches.length === 1) return { ...matches[0], match, candidateCount: 1 } as const;
1421
+ const active = matches.filter(({ candidate }) => candidate.active);
1422
+ if (active.length === 1) return { ...active[0], match, candidateCount: matches.length } as const;
1423
+ return { match: 'ambiguous' as const, candidateCount: matches.length };
1424
+ };
1425
+ if (exact.length > 0) return choose(exact, 'exact_url');
1426
+
1427
+ const sameOriginCandidates = values.filter(({ candidate }) => sameOrigin(candidate.url, targetUrl));
1428
+ if (sameOriginCandidates.length > 0) return choose(sameOriginCandidates, 'same_origin');
1429
+ return { match: 'none', candidateCount: 0 };
1430
+ }
1431
+
1432
+ function selectExactNativeTab(
1433
+ targetUrl: string,
1434
+ inventory: Awaited<ReturnType<typeof listMacOsBrowserTabs>>,
1435
+ ): { candidate?: (typeof inventory.tabs)[number]; match: 'exact_url' | 'none' | 'ambiguous'; candidateCount: number } {
1436
+ const matches = inventory.tabs.filter((candidate) => comparableUrl(candidate.url) === comparableUrl(targetUrl));
1437
+ if (matches.length === 0) return { match: 'none', candidateCount: 0 };
1438
+ if (matches.length === 1) return { candidate: matches[0], match: 'exact_url', candidateCount: 1 };
1439
+ const active = matches.filter((candidate) => candidate.active);
1440
+ if (active.length === 1) return { candidate: active[0], match: 'exact_url', candidateCount: matches.length };
1441
+ return { match: 'ambiguous', candidateCount: matches.length };
1442
+ }
1443
+
851
1444
  function isBlankPage(url: string): boolean {
852
1445
  return !url || url === 'about:blank' || url === 'chrome://newtab/';
853
1446
  }
@@ -1024,6 +1617,7 @@ function sessionConnectionFromSummary(connection: BrowserConnectionSummary): Bro
1024
1617
  endpoint: connection.endpoint,
1025
1618
  browserVersion: connection.browserVersion,
1026
1619
  browserProduct: connection.browserProduct,
1620
+ nativeBrowserCandidates: connection.nativeBrowserCandidates,
1027
1621
  fallback: connection.fallback,
1028
1622
  tab: connection.tab,
1029
1623
  sessionResume: connection.sessionResume,
@@ -1089,27 +1683,302 @@ function launchOptionsForRepo(repoRoot: string, config: BrowserPluginConfig, pro
1089
1683
  };
1090
1684
  }
1091
1685
 
1092
- async function openManagedContext(
1686
+ function managedContextKey(profile: BrowserProfileSelection): string {
1687
+ return resolve(profile.selectedProfilePath);
1688
+ }
1689
+
1690
+ async function evictManagedContext(key: string): Promise<void> {
1691
+ const pending = managedBrowserContexts.get(key);
1692
+ managedBrowserContexts.delete(key);
1693
+ if (!pending) return;
1694
+ try {
1695
+ const state = await pending;
1696
+ await state.context.close().catch(() => undefined);
1697
+ } catch {
1698
+ // Failed launches are already unusable; removing the cache entry is enough.
1699
+ }
1700
+ }
1701
+
1702
+ async function closeManagedContextsForRepo(repoRoot: string, options: { strict?: boolean } = {}): Promise<void> {
1703
+ const canonicalRoot = resolve(repoRoot);
1704
+ for (const [key, pending] of [...managedBrowserContexts.entries()]) {
1705
+ let state: ManagedBrowserContextState;
1706
+ try {
1707
+ state = await pending;
1708
+ } catch {
1709
+ managedBrowserContexts.delete(key);
1710
+ continue;
1711
+ }
1712
+ if (state.repoRoot !== canonicalRoot) continue;
1713
+ try {
1714
+ await state.context.close();
1715
+ managedBrowserContexts.delete(key);
1716
+ } catch (error) {
1717
+ if (options.strict === true) {
1718
+ throw new AssistantPluginError('PLUGIN_BROWSER_MANAGED_CONTEXT_CLOSE_FAILED', 'Managed browser context could not be closed; retaining Runtime binding and session metadata for a safe retry.', {
1719
+ retryable: true, details: { profilePath: state.profilePath, cause: error instanceof Error ? error.message : String(error) },
1720
+ });
1721
+ }
1722
+ managedBrowserContexts.delete(key);
1723
+ }
1724
+ }
1725
+ }
1726
+
1727
+ async function managedContextState(
1093
1728
  runtime: PlaywrightRuntime,
1094
1729
  repoRoot: string,
1095
1730
  config: BrowserPluginConfig,
1096
- target: BrowserActionTarget,
1097
- args: Record<string, unknown>,
1098
- activeMode: BrowserMode,
1099
- requestedMode: BrowserMode,
1100
- fallback?: BrowserConnectionFallback,
1101
- ): Promise<BrowserOpenHandle> {
1102
- const profile = selectedProfile(config, repoRoot, activeMode, target.sessionId);
1103
- assertBrowserProfileAvailable(repoRoot, profile.selectedProfilePath);
1104
- mkdirSync(profile.profileDir, { recursive: true });
1105
- const context = await runtime.chromium.launchPersistentContext(profile.profileDir, launchOptionsForRepo(repoRoot, config, profile));
1106
- await applyDomainGuard(context, config);
1107
- const selected = await selectPage(context, target);
1108
- const timeout = positiveNumber(args.timeout_ms, config.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS);
1109
- let response: { status?: () => number } | null | undefined;
1110
- if (selected.matchedBy === 'new_page' || selected.matchedBy === 'blank' || comparableUrl(selected.page.url()) !== comparableUrl(target.url)) {
1731
+ profile: BrowserProfileSelection,
1732
+ ): Promise<ManagedBrowserContextState> {
1733
+ const key = managedContextKey(profile);
1734
+ const existing = managedBrowserContexts.get(key);
1735
+ if (existing) {
1736
+ try {
1737
+ const state = await existing;
1738
+ state.context.pages();
1739
+ return state;
1740
+ } catch {
1741
+ managedBrowserContexts.delete(key);
1742
+ }
1743
+ }
1744
+ const launch = (async () => {
1745
+ const context = await runtime.chromium.launchPersistentContext(profile.profileDir, launchOptionsForRepo(repoRoot, config, profile));
1746
+ return {
1747
+ repoRoot: resolve(repoRoot),
1748
+ profilePath: key,
1749
+ context,
1750
+ pagesBySession: new Map<string, PageLike>(),
1751
+ } satisfies ManagedBrowserContextState;
1752
+ })();
1753
+ managedBrowserContexts.set(key, launch);
1754
+ try {
1755
+ return await launch;
1756
+ } catch (error) {
1757
+ if (managedBrowserContexts.get(key) === launch) managedBrowserContexts.delete(key);
1758
+ throw error;
1759
+ }
1760
+ }
1761
+
1762
+ async function markManagedPageOwner(page: PageLike, ownerToken: string): Promise<void> {
1763
+ await page.evaluate((_token) => {
1764
+ if (typeof window !== 'undefined') window.name = String(_token ?? '');
1765
+ }, ownerToken).catch(() => undefined);
1766
+ }
1767
+
1768
+ async function selectManagedSessionPage(
1769
+ state: ManagedBrowserContextState,
1770
+ target: BrowserActionTarget,
1771
+ options: { allowReplacement?: boolean } = {},
1772
+ ) {
1773
+ const pages = state.context.pages();
1774
+ const inventory = await inventoryTabs(state.context);
1775
+ const ownerToken = ownerTokenForSession(target.sessionId);
1776
+ const mapped = state.pagesBySession.get(target.sessionId);
1777
+ if (mapped && pages.includes(mapped)) {
1778
+ const index = pages.indexOf(mapped);
1779
+ await markManagedPageOwner(mapped, ownerToken);
1780
+ return {
1781
+ page: mapped,
1782
+ matchedBy: 'owned_token' as const,
1783
+ inventory,
1784
+ selectedEntry: inventory[index],
1785
+ sessionResume: {
1786
+ sessionId: target.sessionId,
1787
+ status: 'matched' as const,
1788
+ reason: 'Reused the live managed page bound to this browser session.',
1789
+ savedTab: target.existingSession?.browser?.tab,
1790
+ },
1791
+ ownerToken,
1792
+ };
1793
+ }
1794
+ state.pagesBySession.delete(target.sessionId);
1795
+
1796
+ const ownerIndex = inventory.findIndex((entry) => entry.ownerToken === ownerToken);
1797
+ if (ownerIndex >= 0) {
1798
+ const page = pages[ownerIndex];
1799
+ if (page) {
1800
+ state.pagesBySession.set(target.sessionId, page);
1801
+ return {
1802
+ page,
1803
+ matchedBy: 'owned_token' as const,
1804
+ inventory,
1805
+ selectedEntry: inventory[ownerIndex],
1806
+ sessionResume: {
1807
+ sessionId: target.sessionId,
1808
+ status: 'matched' as const,
1809
+ reason: 'Rediscovered the managed page from its stable session owner marker.',
1810
+ savedTab: target.existingSession?.browser?.tab,
1811
+ },
1812
+ ownerToken,
1813
+ };
1814
+ }
1815
+ }
1816
+
1817
+ if (target.existingSession && options.allowReplacement === false) {
1818
+ throw new AssistantPluginError(
1819
+ 'PLUGIN_BROWSER_SESSION_STATE_LOST',
1820
+ 'Saved managed browser page no longer exists; refusing to create or claim a replacement page for this existing-session action.',
1821
+ {
1822
+ retryable: false,
1823
+ details: { sessionId: target.sessionId, provider: 'playwright-persistent-context' },
1824
+ },
1825
+ );
1826
+ }
1827
+
1828
+ const blankIndex = inventory.findIndex((entry) => isBlankPage(entry.url) && !entry.ownerToken);
1829
+ const page = blankIndex >= 0 && pages[blankIndex] ? pages[blankIndex]! : await state.context.newPage();
1830
+ const matchedBy: BrowserTabMatchReason = blankIndex >= 0 ? 'blank' : 'new_page';
1831
+ await markManagedPageOwner(page, ownerToken);
1832
+ state.pagesBySession.set(target.sessionId, page);
1833
+ return {
1834
+ page,
1835
+ matchedBy,
1836
+ inventory,
1837
+ selectedEntry: blankIndex >= 0 ? inventory[blankIndex] : undefined,
1838
+ sessionResume: target.existingSession?.browser?.tab
1839
+ ? {
1840
+ sessionId: target.sessionId,
1841
+ status: 'stale_tab' as const,
1842
+ reason: 'The previously owned managed page no longer exists; created one replacement page for this session only.',
1843
+ savedTab: target.existingSession.browser.tab,
1844
+ }
1845
+ : {
1846
+ sessionId: target.sessionId,
1847
+ status: 'no_saved_tab' as const,
1848
+ reason: matchedBy === 'blank'
1849
+ ? 'Claimed one unowned blank page for this managed browser session.'
1850
+ : 'Created one dedicated managed page for this browser session.',
1851
+ },
1852
+ ownerToken,
1853
+ };
1854
+ }
1855
+
1856
+ async function runtimeManagedPageForSession(
1857
+ repoRoot: string,
1858
+ config: BrowserPluginConfig,
1859
+ session: BrowserSessionState,
1860
+ ): Promise<{ page: PageLike; profile: BrowserProfileSelection } | undefined> {
1861
+ if (session.browser?.provider !== 'playwright-persistent-context' || session.browser.activeMode !== 'managed_persistent') return undefined;
1862
+ const profile = selectedProfile(config, repoRoot, 'managed_persistent', session.sessionId);
1863
+ const pending = managedBrowserContexts.get(managedContextKey(profile));
1864
+ if (!pending) return undefined;
1865
+ let state: ManagedBrowserContextState;
1866
+ try {
1867
+ state = await pending;
1868
+ } catch {
1869
+ return undefined;
1870
+ }
1871
+ let page = state.pagesBySession.get(session.sessionId);
1872
+ if (page && state.context.pages().includes(page)) return { page, profile };
1873
+ state.pagesBySession.delete(session.sessionId);
1874
+ const expectedOwnerToken = session.browser.tab?.ownerToken ?? ownerTokenForSession(session.sessionId);
1875
+ const inventory = await inventoryTabs(state.context).catch(() => [] as BrowserTabInventoryEntry[]);
1876
+ const index = inventory.findIndex((entry) => entry.ownerToken === expectedOwnerToken);
1877
+ page = index >= 0 ? state.context.pages()[index] : undefined;
1878
+ if (!page) return undefined;
1879
+ state.pagesBySession.set(session.sessionId, page);
1880
+ return { page, profile };
1881
+ }
1882
+
1883
+ async function closeManagedSessionPage(repoRoot: string, config: BrowserPluginConfig, session: BrowserSessionState): Promise<boolean> {
1884
+ if (session.browser?.provider !== 'playwright-persistent-context' || session.browser.activeMode !== 'managed_persistent') return false;
1885
+ const profile = selectedProfile(config, repoRoot, 'managed_persistent', session.sessionId);
1886
+ const pending = managedBrowserContexts.get(managedContextKey(profile));
1887
+ if (!pending) return false;
1888
+ let state: ManagedBrowserContextState;
1889
+ try {
1890
+ state = await pending;
1891
+ } catch {
1892
+ return false;
1893
+ }
1894
+ let page = state.pagesBySession.get(session.sessionId);
1895
+ if (!page) {
1896
+ const expectedOwnerToken = session.browser.tab?.ownerToken ?? ownerTokenForSession(session.sessionId);
1897
+ const inventory = await inventoryTabs(state.context).catch(() => [] as BrowserTabInventoryEntry[]);
1898
+ const index = inventory.findIndex((entry) => entry.ownerToken === expectedOwnerToken);
1899
+ if (index >= 0) page = state.context.pages()[index];
1900
+ }
1901
+ if (!page || typeof page.close !== 'function') {
1902
+ state.pagesBySession.delete(session.sessionId);
1903
+ return false;
1904
+ }
1905
+ await page.close();
1906
+ state.pagesBySession.delete(session.sessionId);
1907
+ return true;
1908
+ }
1909
+
1910
+ async function openManagedContext(
1911
+ runtime: PlaywrightRuntime,
1912
+ repoRoot: string,
1913
+ config: BrowserPluginConfig,
1914
+ target: BrowserActionTarget,
1915
+ args: Record<string, unknown>,
1916
+ activeMode: BrowserMode,
1917
+ requestedMode: BrowserMode,
1918
+ fallback?: BrowserConnectionFallback,
1919
+ ): Promise<BrowserOpenHandle> {
1920
+ const profile = selectedProfile(config, repoRoot, activeMode, target.sessionId);
1921
+ const persistentKey = managedContextKey(profile);
1922
+ const requireExistingResource = args.__forge_require_existing_resource === true;
1923
+ const allowManagedSessionRehydrate = requireExistingResource && args.__forge_allow_managed_session_rehydrate === true;
1924
+ const managedContextWasMissing = !managedBrowserContexts.has(persistentKey);
1925
+ if (requireExistingResource && target.existingSession && activeMode === 'managed_persistent') {
1926
+ const savedConnection = target.existingSession.browser;
1927
+ if (savedConnection?.provider !== 'playwright-persistent-context'
1928
+ || savedConnection.activeMode !== 'managed_persistent') {
1929
+ throw new AssistantPluginError(
1930
+ 'PLUGIN_BROWSER_SESSION_STATE_LOST',
1931
+ 'Saved browser session belongs to a different browser provider/mode; refusing to migrate it into a replacement managed browser/page for this existing-session action.',
1932
+ {
1933
+ retryable: false,
1934
+ details: {
1935
+ sessionId: target.sessionId,
1936
+ savedProvider: savedConnection?.provider,
1937
+ savedActiveMode: savedConnection?.activeMode,
1938
+ requestedActiveMode: activeMode,
1939
+ },
1940
+ },
1941
+ );
1942
+ }
1943
+ if (managedContextWasMissing && !allowManagedSessionRehydrate) {
1944
+ throw new AssistantPluginError(
1945
+ 'PLUGIN_BROWSER_SESSION_STATE_LOST',
1946
+ 'The managed browser context for this saved session is no longer live; refusing to launch a replacement browser/page for this existing-session action.',
1947
+ { retryable: false, details: { sessionId: target.sessionId, provider: 'playwright-persistent-context' } },
1948
+ );
1949
+ }
1950
+ }
1951
+ assertBrowserProfileAvailable(repoRoot, profile.selectedProfilePath);
1952
+ mkdirSync(profile.profileDir, { recursive: true });
1953
+ let context: BrowserContextLike;
1954
+ let selected: Awaited<ReturnType<typeof selectManagedSessionPage>> | Awaited<ReturnType<typeof selectPage>>;
1955
+ let managedState: ManagedBrowserContextState | undefined;
1956
+ if (activeMode === 'managed_persistent') {
1957
+ managedState = await managedContextState(runtime, repoRoot, config, profile);
1958
+ context = managedState.context;
1959
+ try {
1960
+ selected = await selectManagedSessionPage(managedState, target, { allowReplacement: !requireExistingResource });
1961
+ } catch (error) {
1962
+ if (requireExistingResource) {
1963
+ if (allowManagedSessionRehydrate && managedContextWasMissing) await evictManagedContext(persistentKey);
1964
+ throw error;
1965
+ }
1966
+ await evictManagedContext(persistentKey);
1967
+ assertBrowserProfileAvailable(repoRoot, profile.selectedProfilePath);
1968
+ managedState = await managedContextState(runtime, repoRoot, config, profile);
1969
+ context = managedState.context;
1970
+ selected = await selectManagedSessionPage(managedState, target);
1971
+ }
1972
+ } else {
1973
+ context = await runtime.chromium.launchPersistentContext(profile.profileDir, launchOptionsForRepo(repoRoot, config, profile));
1974
+ selected = await selectPage(context, target);
1975
+ }
1976
+ const timeout = positiveNumber(args.timeout_ms, config.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS);
1977
+ let response: { status?: () => number } | null | undefined;
1978
+ if (selected.matchedBy === 'new_page' || selected.matchedBy === 'blank' || comparableUrl(selected.page.url()) !== comparableUrl(target.url)) {
1111
1979
  response = await selected.page.goto(target.url, { waitUntil: waitUntil(args.wait_until), timeout }) as { status?: () => number } | null | undefined;
1112
1980
  }
1981
+ if (managedState && 'ownerToken' in selected) await markManagedPageOwner(selected.page, selected.ownerToken);
1113
1982
  const diagnostics = attachDiagnostics(selected.page);
1114
1983
  diagnostics.navigation = {
1115
1984
  url: target.url,
@@ -1129,13 +1998,16 @@ async function openManagedContext(
1129
1998
  },
1130
1999
  tabInventory: selected.inventory,
1131
2000
  sessionResume: selected.sessionResume,
1132
- }, normalizedUrl(selected.page.url()), title, selected.matchedBy);
2001
+ }, normalizedUrl(selected.page.url()), title, selected.matchedBy,
2002
+ managedState && 'ownerToken' in selected
2003
+ ? { ownership: 'plugin_owned', ownerToken: selected.ownerToken }
2004
+ : undefined);
1133
2005
  return {
1134
2006
  page: selected.page,
1135
2007
  diagnostics,
1136
2008
  connection,
1137
2009
  close: async () => {
1138
- await context.close().catch(() => undefined);
2010
+ if (activeMode === 'isolated') await context.close().catch(() => undefined);
1139
2011
  },
1140
2012
  };
1141
2013
  }
@@ -1168,7 +2040,6 @@ async function openAttachedContext(
1168
2040
  try {
1169
2041
  const context = browser.contexts()[0] ?? (browser.newContext ? await browser.newContext() : undefined);
1170
2042
  if (!context) throw new Error('CDP connection did not expose a browser context.');
1171
- await applyDomainGuard(context, config);
1172
2043
  const selected = await selectPage(context, target, { allowBlank: false, allowNewPage: false, requireOwnedToken: true });
1173
2044
  const timeout = positiveNumber(args.timeout_ms, config.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS);
1174
2045
  let response: { status?: () => number } | null | undefined;
@@ -1223,6 +2094,11 @@ async function openAttachedContext(
1223
2094
  return { attempts };
1224
2095
  }
1225
2096
 
2097
+ function nativeBackgroundNavigationRequiresReplacement(error: unknown): boolean {
2098
+ const message = error instanceof Error ? error.message : String(error);
2099
+ return message.includes('BROWSER_AUTOMATION_BACKGROUND_NAVIGATION_REQUIRES_REPLACEMENT');
2100
+ }
2101
+
1226
2102
  async function openNativeAttachedContext(
1227
2103
  repoRoot: string,
1228
2104
  config: BrowserPluginConfig,
@@ -1230,17 +2106,46 @@ async function openNativeAttachedContext(
1230
2106
  args: Record<string, unknown>,
1231
2107
  ): Promise<{ handle?: BrowserOpenHandle; attempts: MacOsBrowserAttachAttempt[] }> {
1232
2108
  if (config.nativeAttachMode === 'disabled') return { attempts: [] };
2109
+ if (!target.existingSession && !stringValue(args.session_id)) {
2110
+ await inspectNativeOwnedSessions(repoRoot, config, listSavedSessions(repoRoot), { pruneDead: true });
2111
+ }
1233
2112
  const timeout = positiveNumber(args.timeout_ms, config.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS);
1234
2113
  const savedTab = target.existingSession?.browser?.tab;
2114
+ const savedOwnership = savedTab?.ownership;
2115
+ let effectiveOwnership = savedOwnership;
2116
+ let recoveredTabTitle = '';
2117
+ let recoveredTabUrl: string | undefined;
1235
2118
  const savedProduct = target.existingSession?.browser?.provider === 'macos-apple-events'
1236
- && savedTab?.ownership === 'plugin_owned'
2119
+ && savedTab
2120
+ && (savedOwnership === 'plugin_owned' || savedOwnership === 'user_owned')
1237
2121
  && typeof savedTab.windowId === 'string'
1238
2122
  && typeof savedTab.tabId === 'string'
1239
2123
  ? target.existingSession.browser.browserProduct
1240
2124
  : undefined;
2125
+ if (args.__forge_require_existing_resource === true
2126
+ && target.existingSession?.browser?.provider === 'macos-apple-events'
2127
+ && !savedProduct) {
2128
+ throw new AssistantPluginError(
2129
+ 'PLUGIN_BROWSER_SESSION_STATE_LOST',
2130
+ 'Saved native browser session lacks a complete stable browser/tab identity; refusing to create or adopt a replacement tab for this existing-session action.',
2131
+ {
2132
+ retryable: false,
2133
+ details: {
2134
+ sessionId: target.sessionId,
2135
+ provider: target.existingSession.browser.provider,
2136
+ browserProduct: target.existingSession.browser.browserProduct,
2137
+ ownership: savedOwnership,
2138
+ windowId: savedTab?.windowId,
2139
+ tabId: savedTab?.tabId,
2140
+ },
2141
+ },
2142
+ );
2143
+ }
1241
2144
  let page: PageLike | undefined;
1242
2145
  let discovered: Awaited<ReturnType<typeof discoverMacOsBrowserAttachment>> | undefined;
1243
- let sessionResume: BrowserSessionResumeDiagnostic;
2146
+ let sessionResume: BrowserSessionResumeDiagnostic = savedTab
2147
+ ? { sessionId: target.sessionId, status: 'stale_tab', reason: 'Saved native tab has not yet been reattached.', savedTab }
2148
+ : { sessionId: target.sessionId, status: 'no_saved_tab', reason: 'No saved native tab exists yet.' };
1244
2149
  let matchedBy: BrowserTabMatchReason = 'new_page';
1245
2150
 
1246
2151
  if (savedProduct && savedTab && typeof savedTab.windowId === 'string' && typeof savedTab.tabId === 'string') {
@@ -1249,10 +2154,71 @@ async function openNativeAttachedContext(
1249
2154
  const reattached = await reattachMacOsBrowserOwnedPage(savedProduct, ref, timeout);
1250
2155
  page = reattached.page as unknown as PageLike;
1251
2156
  discovered = { attachment: reattached.attachment, attempts: reattached.attachment.attempts };
1252
- matchedBy = 'owned_token';
1253
- sessionResume = { sessionId: target.sessionId, status: 'matched', reason: 'Reattached directly to the saved plugin-owned macOS browser tab.', savedTab };
1254
- } catch {
1255
- sessionResume = { sessionId: target.sessionId, status: 'stale_tab', reason: 'Saved plugin-owned macOS tab no longer exists; one replacement tab was created.', savedTab };
2157
+ matchedBy = savedOwnership === 'user_owned' ? 'saved_url' : 'owned_token';
2158
+ sessionResume = {
2159
+ sessionId: target.sessionId,
2160
+ status: 'matched',
2161
+ reason: savedOwnership === 'user_owned'
2162
+ ? 'Reattached directly to the explicitly adopted user-owned macOS browser tab.'
2163
+ : 'Reattached directly to the saved plugin-owned macOS browser tab.',
2164
+ savedTab,
2165
+ };
2166
+ } catch (error) {
2167
+ if (error instanceof AssistantPluginError && error.code === 'PLUGIN_BROWSER_NATIVE_TAB_IDENTITY_UNPROVEN') {
2168
+ throw error;
2169
+ }
2170
+ if (savedOwnership === 'user_owned') {
2171
+ throw new AssistantPluginError(
2172
+ 'PLUGIN_BROWSER_SESSION_STATE_LOST',
2173
+ 'Explicitly adopted user-owned macOS browser tab no longer exists; refusing to silently switch to another user tab.',
2174
+ {
2175
+ retryable: false,
2176
+ details: { sessionId: target.sessionId, browserProduct: savedProduct, ownership: savedOwnership, windowId: savedTab.windowId, tabId: savedTab.tabId, cause: error instanceof Error ? error.message : String(error) },
2177
+ },
2178
+ );
2179
+ }
2180
+ let inventory: Awaited<ReturnType<typeof listMacOsBrowserTabs>> | undefined;
2181
+ let selection: ReturnType<typeof selectReusableNativeTab> | undefined;
2182
+ let recoveryFailure: string | undefined;
2183
+ try {
2184
+ inventory = await listMacOsBrowserTabs(savedProduct, timeout);
2185
+ selection = selectReusableNativeTab(
2186
+ [savedTab.url, target.existingSession?.url ?? '', target.url],
2187
+ inventory,
2188
+ savedTab.windowId,
2189
+ );
2190
+ if (selection.candidate) {
2191
+ const recovered = await reattachMacOsBrowserOwnedPage(savedProduct, { windowId: selection.candidate.windowId, tabId: selection.candidate.tabId }, timeout);
2192
+ page = recovered.page as unknown as PageLike;
2193
+ discovered = { attachment: recovered.attachment, attempts: recovered.attachment.attempts };
2194
+ matchedBy = 'recovered_tab';
2195
+ effectiveOwnership = 'user_owned';
2196
+ recoveredTabTitle = selection.candidate.title;
2197
+ recoveredTabUrl = selection.candidate.url;
2198
+ sessionResume = {
2199
+ sessionId: target.sessionId,
2200
+ status: 'matched',
2201
+ reason: selection.match === 'exact_url'
2202
+ ? 'Saved plugin-owned tab disappeared; rebound safely to the unique currently open tab with the same URL and preserved it as user-owned.'
2203
+ : 'Saved plugin-owned tab disappeared; rebound safely to the unique currently open tab for the same stable app entity and preserved its current page as user-owned.',
2204
+ savedTab,
2205
+ };
2206
+ }
2207
+ } catch (recoveryError) {
2208
+ recoveryFailure = recoveryError instanceof Error ? recoveryError.message : String(recoveryError);
2209
+ }
2210
+ if (!page) {
2211
+ sessionResume = {
2212
+ sessionId: target.sessionId,
2213
+ status: 'stale_tab',
2214
+ reason: recoveryFailure
2215
+ ? `Saved plugin-owned macOS browser tab disappeared and current-tab recovery failed (${recoveryFailure}); creating one replacement plugin-owned tab.`
2216
+ : selection?.match === 'ambiguous'
2217
+ ? 'Saved plugin-owned macOS browser tab disappeared and multiple reusable current tabs matched; refusing to guess between user tabs and creating one replacement plugin-owned tab.'
2218
+ : 'Saved plugin-owned macOS browser tab disappeared and no uniquely reusable current tab matched; creating one replacement plugin-owned tab.',
2219
+ savedTab,
2220
+ };
2221
+ }
1256
2222
  }
1257
2223
  } else {
1258
2224
  sessionResume = savedTab
@@ -1261,49 +2227,219 @@ async function openNativeAttachedContext(
1261
2227
  }
1262
2228
 
1263
2229
  if (!discovered?.attachment) {
1264
- const candidates = savedProduct ? [savedProduct, ...(config.nativeBrowserCandidates ?? ['vivaldi', 'chrome']).filter((product) => product !== savedProduct)] : (config.nativeBrowserCandidates ?? ['vivaldi', 'chrome']);
2230
+ const candidates = savedProduct ? [savedProduct] : (config.nativeBrowserCandidates ?? ['vivaldi', 'chrome']);
1265
2231
  discovered = await discoverMacOsBrowserAttachment(candidates, Math.min(timeout, MAX_CDP_DISCOVERY_TIMEOUT_MS));
1266
2232
  }
1267
2233
  if (!discovered.attachment) return { attempts: discovered.attempts };
2234
+ let activeAttachment = discovered.attachment;
1268
2235
 
1269
2236
  let createdThisCallRef: MacOsBrowserTabRef | undefined;
1270
- let replacedRef: MacOsBrowserTabRef | undefined;
2237
+ let navigationUsedReplacement = false;
2238
+ const navigateNativePageWithReplacement = async (candidate: PageLike): Promise<PageLike> => {
2239
+ const obsoleteRef = (candidate as unknown as { tabRef?: () => MacOsBrowserTabRef | undefined }).tabRef?.();
2240
+ try {
2241
+ await candidate.goto(target.url, { waitUntil: waitUntil(args.wait_until), timeout });
2242
+ return candidate;
2243
+ } catch (error) {
2244
+ if (!nativeBackgroundNavigationRequiresReplacement(error) || effectiveOwnership === 'user_owned' || !obsoleteRef) throw error;
2245
+ const replacement = await createMacOsBrowserOwnedPage(activeAttachment, target.url, timeout) as unknown as PageLike;
2246
+ const replacementRef = (replacement as unknown as { tabRef?: () => MacOsBrowserTabRef | undefined }).tabRef?.();
2247
+ if (!replacementRef) {
2248
+ throw new AssistantPluginError('PLUGIN_BROWSER_NATIVE_OWNERSHIP_MISSING', 'Native replacement tab did not return a stable plugin-owned tab reference.', { retryable: true });
2249
+ }
2250
+ try {
2251
+ const identity = await settleNativeCreatedPageIdentity(
2252
+ replacement as unknown as {
2253
+ waitForLoadState: (state?: string, options?: Record<string, unknown>) => Promise<void>;
2254
+ identity: () => Promise<{ url: string; title: string }>;
2255
+ },
2256
+ waitUntil(args.wait_until),
2257
+ timeout,
2258
+ );
2259
+ if (!nativeReplacementUrlMatchesTarget(target.url, identity.url)) {
2260
+ const diagnostic = nativeReplacementMismatchDiagnostic({
2261
+ requestedUrl: target.url,
2262
+ actualUrl: identity.url,
2263
+ previousUrl: target.existingSession?.url ?? candidate.url(),
2264
+ obsoleteRef,
2265
+ replacementRef,
2266
+ });
2267
+ const diagnosticText = Object.entries(diagnostic)
2268
+ .map(([key, value]) => `${key}=${String(value)}`)
2269
+ .join(',');
2270
+ throw new AssistantPluginError(
2271
+ 'PLUGIN_BROWSER_NATIVE_REPLACEMENT_MISMATCH',
2272
+ `Native replacement tab did not reach the requested URL; preserving the original plugin-owned tab. diagnostic=${diagnosticText}`,
2273
+ { retryable: true, details: diagnostic },
2274
+ );
2275
+ }
2276
+ } catch (replacementError) {
2277
+ await closeMacOsBrowserOwnedTab(activeAttachment.metadata.product, replacementRef, timeout).catch(() => undefined);
2278
+ throw replacementError;
2279
+ }
2280
+ try {
2281
+ await closeMacOsBrowserOwnedTab(activeAttachment.metadata.product, obsoleteRef, timeout);
2282
+ } catch (closeError) {
2283
+ await closeMacOsBrowserOwnedTab(activeAttachment.metadata.product, replacementRef, timeout).catch(() => undefined);
2284
+ throw closeError;
2285
+ }
2286
+ createdThisCallRef = replacementRef;
2287
+ navigationUsedReplacement = true;
2288
+ matchedBy = 'owned_token';
2289
+ effectiveOwnership = 'plugin_owned';
2290
+ sessionResume = {
2291
+ sessionId: target.sessionId,
2292
+ status: 'matched',
2293
+ reason: 'Browser broker required replacement for background cross-URL navigation; created and verified a replacement plugin-owned tab before closing the obsolete tab.',
2294
+ savedTab,
2295
+ };
2296
+ return replacement;
2297
+ }
2298
+ };
1271
2299
  try {
2300
+ if (!page && !savedTab && !stringValue(args.session_id)) {
2301
+ let inventory: Awaited<ReturnType<typeof listMacOsBrowserTabs>> | undefined;
2302
+ try {
2303
+ inventory = await listMacOsBrowserTabs(activeAttachment.metadata.product, timeout);
2304
+ } catch (error) {
2305
+ if (!nativeTabInventoryUnsupported(error)) throw error;
2306
+ }
2307
+ if (inventory) {
2308
+ const ownedSelection = selectLiveForgeOwnedNativeTab(repoRoot, target.url, activeAttachment.metadata.product, inventory);
2309
+ if (ownedSelection.match === 'ambiguous') {
2310
+ throw new AssistantPluginError(
2311
+ 'PLUGIN_BROWSER_REUSABLE_TAB_AMBIGUOUS',
2312
+ 'Multiple live Forge-owned browser tabs can satisfy this sessionless navigation; refusing to open another duplicate tab.',
2313
+ { retryable: false, details: { targetUrl: target.url, candidateCount: ownedSelection.candidateCount } },
2314
+ );
2315
+ }
2316
+ if (ownedSelection.candidate) {
2317
+ if (ownedSelection.session) {
2318
+ target.sessionId = ownedSelection.session.sessionId;
2319
+ target.existingSession = ownedSelection.session;
2320
+ }
2321
+ const rebound = await reattachMacOsBrowserOwnedPage(
2322
+ activeAttachment.metadata.product,
2323
+ { windowId: ownedSelection.candidate.windowId, tabId: ownedSelection.candidate.tabId },
2324
+ timeout,
2325
+ );
2326
+ page = rebound.page as unknown as PageLike;
2327
+ discovered = { attachment: rebound.attachment, attempts: rebound.attachment.attempts };
2328
+ activeAttachment = rebound.attachment;
2329
+ matchedBy = 'owned_token';
2330
+ effectiveOwnership = 'plugin_owned';
2331
+ sessionResume = {
2332
+ sessionId: target.sessionId,
2333
+ status: 'matched',
2334
+ reason: ownedSelection.match === 'exact_url'
2335
+ ? 'No session id was supplied; reused the unique live Forge-owned native tab already at the target URL.'
2336
+ : 'No session id was supplied; reused the unique live Forge-owned native tab for the same origin and navigated it in place.',
2337
+ savedTab: ownedSelection.session?.browser?.tab,
2338
+ };
2339
+ } else {
2340
+ const exactSelection = selectExactNativeTab(target.url, inventory);
2341
+ if (exactSelection.match === 'ambiguous') {
2342
+ throw new AssistantPluginError(
2343
+ 'PLUGIN_BROWSER_REUSABLE_TAB_AMBIGUOUS',
2344
+ 'Multiple existing browser tabs already match the target URL; refusing to open another duplicate tab.',
2345
+ { retryable: false, details: { targetUrl: target.url, candidateCount: exactSelection.candidateCount } },
2346
+ );
2347
+ }
2348
+ if (exactSelection.candidate) {
2349
+ const rebound = await reattachMacOsBrowserOwnedPage(
2350
+ activeAttachment.metadata.product,
2351
+ { windowId: exactSelection.candidate.windowId, tabId: exactSelection.candidate.tabId },
2352
+ timeout,
2353
+ );
2354
+ page = rebound.page as unknown as PageLike;
2355
+ discovered = { attachment: rebound.attachment, attempts: rebound.attachment.attempts };
2356
+ activeAttachment = rebound.attachment;
2357
+ matchedBy = 'exact_url';
2358
+ effectiveOwnership = 'user_owned';
2359
+ recoveredTabTitle = exactSelection.candidate.title;
2360
+ recoveredTabUrl = exactSelection.candidate.url;
2361
+ sessionResume = {
2362
+ sessionId: target.sessionId,
2363
+ status: 'matched',
2364
+ reason: 'No session id was supplied; reused the unique existing native browser tab with the exact target URL instead of opening a duplicate.',
2365
+ };
2366
+ }
2367
+ }
2368
+ }
2369
+ }
1272
2370
  if (!page) {
1273
- page = await createMacOsBrowserOwnedPage(discovered.attachment, target.url, timeout) as unknown as PageLike;
2371
+ page = await createMacOsBrowserOwnedPage(activeAttachment, target.url, timeout) as unknown as PageLike;
1274
2372
  createdThisCallRef = (page as unknown as { tabRef?: () => MacOsBrowserTabRef | undefined }).tabRef?.();
2373
+ const liveIdentity = await settleNativeCreatedPageIdentity(
2374
+ page as unknown as {
2375
+ waitForLoadState: (state?: string, options?: Record<string, unknown>) => Promise<void>;
2376
+ identity: () => Promise<{ url: string; title: string }>;
2377
+ },
2378
+ waitUntil(args.wait_until),
2379
+ timeout,
2380
+ ).catch(() => undefined);
2381
+ if (!liveIdentity?.url || isBlankPage(liveIdentity.url) || comparableUrl(liveIdentity.url) !== comparableUrl(target.url)) {
2382
+ page = await navigateNativePageWithReplacement(page);
2383
+ }
1275
2384
  }
1276
- if (comparableUrl(page.url()) !== comparableUrl(target.url)) {
2385
+ const explicitUserOwnedUrlMatch = effectiveOwnership === 'user_owned'
2386
+ && savedProduct
2387
+ && explicitNativeAdoptionUrlsMatch(target.url, page.url(), savedProduct);
2388
+ if (comparableUrl(page.url()) !== comparableUrl(target.url) && !explicitUserOwnedUrlMatch) {
1277
2389
  const currentRef = (page as unknown as { tabRef?: () => MacOsBrowserTabRef | undefined }).tabRef?.();
1278
- if (!currentRef) {
1279
- await page.goto(target.url, { waitUntil: waitUntil(args.wait_until), timeout });
2390
+ if (effectiveOwnership === 'user_owned') {
2391
+ if (matchedBy === 'recovered_tab') {
2392
+ sessionResume = { sessionId: target.sessionId, status: 'matched', reason: 'Recovered current user tab was already within the same saved app entity; preserved its current URL instead of navigating it.', savedTab };
2393
+ } else {
2394
+ throw new AssistantPluginError(
2395
+ 'PLUGIN_BROWSER_ADOPTED_TAB_URL_MISMATCH',
2396
+ 'Explicitly adopted user-owned tab no longer matches the saved session URL; refusing to navigate, replace, or close the user tab.',
2397
+ { retryable: true, details: { sessionId: target.sessionId, expectedUrl: target.url, actualUrl: page.url(), windowId: savedTab?.windowId, tabId: savedTab?.tabId } },
2398
+ );
2399
+ }
2400
+ }
2401
+ const sessionTargetUnchanged = Boolean(target.existingSession?.url) && comparableUrl(target.url) === comparableUrl(target.existingSession?.url ?? '');
2402
+ const preserveOwnedSameOriginDrift = matchedBy === 'owned_token' && sessionTargetUnchanged && sameOrigin(page.url(), target.url);
2403
+ if (matchedBy === 'recovered_tab' && effectiveOwnership === 'user_owned') {
2404
+ // Safe recovery adopts an already-open tab. Its current page is authoritative and must not be navigated.
2405
+ } else if (preserveOwnedSameOriginDrift) {
2406
+ sessionResume = { sessionId: target.sessionId, status: 'matched', reason: 'Plugin-owned tab navigated within the same origin; preserved the live tab and refreshed its session URL.', savedTab };
1280
2407
  } else {
1281
- const replacement = await createMacOsBrowserOwnedPage(discovered.attachment, target.url, timeout) as unknown as PageLike;
1282
- const createdRef = (replacement as unknown as { tabRef?: () => MacOsBrowserTabRef | undefined }).tabRef?.();
1283
- if (!createdRef) throw new AssistantPluginError('PLUGIN_BROWSER_NATIVE_OWNERSHIP_MISSING', 'Replacement native browser tab did not return a stable plugin-owned tab reference.', { retryable: true });
1284
- replacedRef = currentRef;
1285
- createdThisCallRef = createdRef;
1286
- page = replacement;
1287
- matchedBy = 'new_page';
1288
- sessionResume = { sessionId: target.sessionId, status: 'matched', reason: 'Reattached to the saved session by replacing its previous plugin-owned background tab.', savedTab };
2408
+ page = await navigateNativePageWithReplacement(page);
2409
+ if (currentRef && !navigationUsedReplacement) {
2410
+ matchedBy = 'owned_token';
2411
+ sessionResume = { sessionId: target.sessionId, status: 'matched', reason: 'Navigated the existing plugin-owned tab in place and preserved its stable tab identity.', savedTab };
2412
+ }
1289
2413
  }
1290
2414
  }
1291
- if (matchedBy !== 'owned_token' && page.waitForLoadState) {
2415
+ const explicitInternalUserSession = Boolean(
2416
+ target.existingSession
2417
+ && isExplicitNativeUserOwnedSession(target.existingSession)
2418
+ && browserInternalUrlProduct(target.url) === savedProduct,
2419
+ );
2420
+ if (matchedBy !== 'owned_token' && page.waitForLoadState && !explicitInternalUserSession) {
1292
2421
  await page.waitForLoadState(waitUntil(args.wait_until), { timeout });
1293
2422
  }
1294
- const pageUrl = normalizedUrl(page.url());
1295
- assertUrlAllowed(pageUrl, config);
2423
+ const observedPageUrl = matchedBy === 'recovered_tab' && recoveredTabUrl ? recoveredTabUrl : page.url();
2424
+ const pageUrl = explicitInternalUserSession
2425
+ ? normalizedExplicitNativeAdoptionUrl(observedPageUrl)
2426
+ : normalizedUrl(observedPageUrl);
1296
2427
  const nativeRef = (page as unknown as { tabRef?: () => MacOsBrowserTabRef | undefined }).tabRef?.();
1297
2428
  if (!nativeRef) throw new AssistantPluginError('PLUGIN_BROWSER_NATIVE_OWNERSHIP_MISSING', 'Native browser did not return a stable plugin-owned tab reference.', { retryable: true });
1298
- const title = matchedBy === 'owned_token' ? (target.existingSession?.title ?? '') : '';
2429
+ const title = matchedBy === 'owned_token' ? (target.existingSession?.title ?? '') : matchedBy === 'recovered_tab' ? recoveredTabTitle : '';
1299
2430
  const inventory: BrowserTabInventoryEntry[] = [{ index: 0, key: tabKey(pageUrl, title), url: pageUrl, title }];
1300
- const metadata = discovered.attachment.metadata;
2431
+ const metadata = activeAttachment.metadata;
2432
+ // The live exact-tab URL is authoritative after a safe native rebind. Keep the
2433
+ // action-local target synchronized so read/query responses and the persisted
2434
+ // session report the same observed URL in this round.
2435
+ target.url = pageUrl;
1301
2436
  const connection = refreshConnectionTab({
1302
2437
  requestedMode: 'attach_preferred',
1303
2438
  mode: 'attach_preferred',
1304
2439
  provider: 'macos-apple-events',
1305
2440
  attached: true,
1306
2441
  browserProduct: metadata.product,
2442
+ nativeBrowserCandidates: config.nativeBrowserCandidates,
1307
2443
  profile: {
1308
2444
  profileMode: config.profileMode,
1309
2445
  profileDirectory: config.profileDirectory,
@@ -1312,15 +2448,16 @@ async function openNativeAttachedContext(
1312
2448
  tabInventory: inventory,
1313
2449
  sessionResume,
1314
2450
  }, pageUrl, title, matchedBy, {
1315
- ownership: 'plugin_owned', windowId: nativeRef.windowId, tabId: nativeRef.tabId,
2451
+ ownership: effectiveOwnership === 'user_owned' ? 'user_owned' : 'plugin_owned',
2452
+ windowId: nativeRef.windowId,
2453
+ tabId: nativeRef.tabId,
1316
2454
  });
1317
2455
  const diagnostics: PageDiagnostics = { consoleErrors: [], failedRequests: [], navigation: { url: pageUrl } };
1318
- if (replacedRef) await closeMacOsBrowserOwnedTab(metadata.product, replacedRef, timeout);
1319
2456
  createdThisCallRef = undefined;
1320
2457
  return { attempts: discovered.attempts, handle: { page, diagnostics, connection, close: async () => undefined } };
1321
2458
  } catch (error) {
1322
2459
  if (createdThisCallRef) {
1323
- await closeMacOsBrowserOwnedTab(discovered.attachment.metadata.product, createdThisCallRef, timeout).catch(() => undefined);
2460
+ await closeMacOsBrowserOwnedTab(activeAttachment.metadata.product, createdThisCallRef, timeout).catch(() => undefined);
1324
2461
  }
1325
2462
  throw error;
1326
2463
  }
@@ -1334,15 +2471,43 @@ async function openBrowser(
1334
2471
  ): Promise<BrowserOpenHandle> {
1335
2472
  const requestedMode = config.browserMode ?? 'managed_persistent';
1336
2473
  if (requestedMode === 'attach_preferred') {
1337
- const attached = cdpEndpoints(config).length > 0 && runtimeHooks.moduleAvailable('playwright')
1338
- ? await openAttachedContext(runtimeHooks.loadPlaywright(), repoRoot, config, target, args)
2474
+ const requireExistingResource = args.__forge_require_existing_resource === true;
2475
+ const existingProvider = target.existingSession?.browser?.provider;
2476
+ const attached = cdpEndpoints(config).length > 0 && runtimeHooks.moduleAvailable('playwright', repoRoot)
2477
+ ? await openAttachedContext(runtimeHooks.loadPlaywright(repoRoot), repoRoot, config, target, args)
1339
2478
  : { attempts: [] as CdpAttachAttempt[] };
1340
2479
  if (attached.handle) return attached.handle;
2480
+ if (requireExistingResource && existingProvider === 'playwright-cdp') {
2481
+ throw new AssistantPluginError(
2482
+ 'PLUGIN_BROWSER_SESSION_STATE_LOST',
2483
+ 'Saved CDP browser page is no longer attachable; refusing to fall back to another browser or create a replacement page.',
2484
+ { retryable: false, details: { sessionId: target.sessionId, provider: existingProvider } },
2485
+ );
2486
+ }
2487
+ if (requireExistingResource && existingProvider === 'playwright-persistent-context') {
2488
+ if (!runtimeHooks.moduleAvailable('playwright', repoRoot)) {
2489
+ throw new AssistantPluginError('PLUGIN_BROWSER_DEPENDENCY_UNAVAILABLE', 'Managed browser mode requires Playwright.', { retryable: false });
2490
+ }
2491
+ return openManagedContext(runtimeHooks.loadPlaywright(repoRoot), repoRoot, config, target, args, 'managed_persistent', requestedMode, {
2492
+ policy: config.cdpAttachFallback ?? 'fail_closed',
2493
+ from: 'attach_preferred',
2494
+ to: 'managed_persistent',
2495
+ reason: 'Reusing the existing managed fallback session without probing or creating a different browser resource.',
2496
+ attempts: attached.attempts,
2497
+ });
2498
+ }
1341
2499
 
1342
2500
  const native = await openNativeAttachedContext(repoRoot, config, target, args);
1343
2501
  if (native.handle) return native.handle;
2502
+ if (requireExistingResource) {
2503
+ throw new AssistantPluginError(
2504
+ 'PLUGIN_BROWSER_SESSION_STATE_LOST',
2505
+ 'Saved browser resource is no longer attachable; refusing to create a replacement tab/page for this existing-session action.',
2506
+ { retryable: false, details: { sessionId: target.sessionId, provider: existingProvider } },
2507
+ );
2508
+ }
1344
2509
 
1345
- const fallbackPolicy = config.cdpAttachFallback ?? 'managed_persistent';
2510
+ const fallbackPolicy = config.cdpAttachFallback ?? 'fail_closed';
1346
2511
  const cdpReason = attached.attempts.map((attempt) => `${attempt.endpoint}: ${attempt.error ?? 'unavailable'}`).join('; ')
1347
2512
  || 'No CDP endpoint candidates were configured.';
1348
2513
  const nativeReason = native.attempts.map((attempt) => `${attempt.appName}: ${attempt.error ?? attempt.status}`).join('; ')
@@ -1359,10 +2524,10 @@ async function openBrowser(
1359
2524
  },
1360
2525
  });
1361
2526
  }
1362
- if (!runtimeHooks.moduleAvailable('playwright')) {
2527
+ if (!runtimeHooks.moduleAvailable('playwright', repoRoot)) {
1363
2528
  throw new AssistantPluginError('PLUGIN_BROWSER_DEPENDENCY_UNAVAILABLE', `Managed browser fallback requires Playwright, but native/CDP attach was unavailable. ${reason}`, { retryable: false });
1364
2529
  }
1365
- return openManagedContext(runtimeHooks.loadPlaywright(), repoRoot, config, target, args, 'managed_persistent', requestedMode, {
2530
+ return openManagedContext(runtimeHooks.loadPlaywright(repoRoot), repoRoot, config, target, args, 'managed_persistent', requestedMode, {
1366
2531
  policy: fallbackPolicy,
1367
2532
  from: 'attach_preferred',
1368
2533
  to: 'managed_persistent',
@@ -1372,10 +2537,10 @@ async function openBrowser(
1372
2537
  });
1373
2538
  }
1374
2539
 
1375
- if (!runtimeHooks.moduleAvailable('playwright')) {
2540
+ if (!runtimeHooks.moduleAvailable('playwright', repoRoot)) {
1376
2541
  throw new AssistantPluginError('PLUGIN_BROWSER_DEPENDENCY_UNAVAILABLE', 'Managed browser mode requires Playwright.', { retryable: false });
1377
2542
  }
1378
- return openManagedContext(runtimeHooks.loadPlaywright(), repoRoot, config, target, args, requestedMode, requestedMode);
2543
+ return openManagedContext(runtimeHooks.loadPlaywright(repoRoot), repoRoot, config, target, args, requestedMode, requestedMode);
1379
2544
  }
1380
2545
 
1381
2546
  async function withPage<T>(
@@ -1384,24 +2549,49 @@ async function withPage<T>(
1384
2549
  target: BrowserActionTarget,
1385
2550
  args: Record<string, unknown>,
1386
2551
  run: (page: PageLike, diagnostics: PageDiagnostics, connection: BrowserConnectionSummary) => Promise<T>,
1387
- options: { persistSession?: boolean } = {},
2552
+ options: { persistSession?: boolean; requireExistingResource?: boolean; pruneStaleSessionMetadata?: boolean } = {},
1388
2553
  ): Promise<T> {
1389
- assertUrlAllowed(target.url, config);
1390
2554
  const retries = Math.min(Math.max(positiveNumber(args.retries, 1), 1), 3);
1391
2555
  let lastError: unknown;
1392
2556
  for (let attempt = 1; attempt <= retries; attempt += 1) {
1393
2557
  let handle: BrowserOpenHandle | undefined;
1394
2558
  try {
1395
- handle = await openBrowser(repoRoot, config, target, args);
2559
+ if (target.existingSession) assertBrowserSessionAvailable(repoRoot, target.sessionId);
2560
+ const requireExistingResource = options.requireExistingResource ?? Boolean(target.existingSession);
2561
+ const browserArgs = requireExistingResource
2562
+ ? { ...args, __forge_require_existing_resource: true }
2563
+ : args;
2564
+ handle = await openBrowser(repoRoot, config, target, browserArgs);
1396
2565
  const result = await run(handle.page, handle.diagnostics, handle.connection);
1397
2566
  if (options.persistSession) {
1398
- const identity = await readPageIdentity(handle.page, handle.connection);
1399
- assertUrlAllowed(identity.url, config);
2567
+ const identity = await readPageIdentity(handle.page, handle.connection, target.url);
1400
2568
  saveSession(repoRoot, sessionFromPage(target, identity.url, identity.title, handle.connection));
2569
+ if (result && typeof result === 'object' && !Array.isArray(result) && Object.prototype.hasOwnProperty.call(result, 'url')) {
2570
+ (result as Record<string, unknown>).url = identity.url;
2571
+ }
1401
2572
  }
1402
2573
  return result;
1403
2574
  } catch (error) {
1404
2575
  lastError = error;
2576
+ const nativeTab = handle?.connection.provider === 'macos-apple-events' ? handle.connection.tab : undefined;
2577
+ if (handle?.connection.provider === 'macos-apple-events'
2578
+ && handle.connection.browserProduct
2579
+ && nativeTab?.windowId
2580
+ && nativeTab.tabId
2581
+ && macOsBrowserPageHandleStale(error)) {
2582
+ // Do not replay the current action. Evict only so the next independent action must
2583
+ // reattach/recover through the existing cold-path identity checks.
2584
+ invalidateMacOsBrowserPageHandle(handle.connection.browserProduct, {
2585
+ windowId: nativeTab.windowId,
2586
+ tabId: nativeTab.tabId,
2587
+ });
2588
+ }
2589
+ if (options.pruneStaleSessionMetadata
2590
+ && target.existingSession
2591
+ && error instanceof AssistantPluginError
2592
+ && error.code === 'PLUGIN_BROWSER_SESSION_STATE_LOST') {
2593
+ removeSession(repoRoot, target.sessionId);
2594
+ }
1405
2595
  const message = error instanceof Error ? error.message : String(error);
1406
2596
  const transient = /timeout|net::|ERR_|Navigation failed|Target closed|Protocol error/i.test(message);
1407
2597
  if (!transient || attempt >= retries) throw error;
@@ -1413,17 +2603,319 @@ async function withPage<T>(
1413
2603
  }
1414
2604
 
1415
2605
  function listSavedSessions(repoRoot: string): BrowserSessionState[] {
1416
- const dir = stateDir(repoRoot, 'sessions');
1417
- if (!existsSync(dir)) return [];
2606
+ return listSavedBrowserSessions(repoRoot)
2607
+ .filter((entry) => Boolean(entry.sessionId && entry.url))
2608
+ .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
2609
+ }
2610
+
2611
+ function nativeTabInventoryUnsupported(error: unknown): boolean {
2612
+ if (error instanceof AssistantPluginError && error.code === 'BROWSER_AUTOMATION_ACTION_UNSUPPORTED') return true;
2613
+ const message = error instanceof Error ? error.message : String(error ?? '');
2614
+ return /\bBROWSER_AUTOMATION_ACTION_UNSUPPORTED\b/.test(message);
2615
+ }
2616
+
2617
+ function isDiscardableNativeOwnedTabUrl(url: string): boolean {
2618
+ const normalized = url.trim().toLowerCase();
2619
+ return isBlankPage(normalized)
2620
+ || normalized === 'chrome://new-tab-page/'
2621
+ || normalized === 'vivaldi://newtab/'
2622
+ || normalized === 'vivaldi://startpage/';
2623
+ }
2624
+
2625
+ type NativeOwnedSessionInspection = {
2626
+ items: Map<string, BrowserSessionInventoryItem>;
2627
+ liveCount: number;
2628
+ deadCount: number;
2629
+ unverifiedCount: number;
2630
+ prunedCount: number;
2631
+ closedInvalidCount: number;
2632
+ failedCleanupCount: number;
2633
+ };
2634
+
2635
+ async function inspectNativeOwnedSessions(
2636
+ repoRoot: string,
2637
+ config: BrowserPluginConfig,
2638
+ saved: BrowserSessionState[],
2639
+ options: { pruneDead?: boolean } = {},
2640
+ ): Promise<NativeOwnedSessionInspection> {
2641
+ const items = new Map<string, BrowserSessionInventoryItem>();
2642
+ const inventories = new Map<MacOsBrowserProduct, Awaited<ReturnType<typeof listMacOsBrowserTabs>> | Error>();
2643
+ const groups = new Map<string, BrowserSessionState[]>();
2644
+ let liveCount = 0;
2645
+ let deadCount = 0;
2646
+ let unverifiedCount = 0;
2647
+ let prunedCount = 0;
2648
+ let closedInvalidCount = 0;
2649
+ let failedCleanupCount = 0;
2650
+ const timeout = config.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS;
2651
+
2652
+ for (const session of saved) {
2653
+ const browser = session.browser;
2654
+ const tab = browser?.tab;
2655
+ const product = browser?.browserProduct;
2656
+ if (browser?.provider !== 'macos-apple-events' || tab?.ownership !== 'plugin_owned' || !product || !tab.windowId || !tab.tabId) continue;
2657
+ const key = `${product}:${tab.windowId}:${tab.tabId}`;
2658
+ const group = groups.get(key) ?? [];
2659
+ group.push(session);
2660
+ groups.set(key, group);
2661
+ }
2662
+
2663
+ for (const group of groups.values()) {
2664
+ const canonical = group.reduce((latest, session) => session.updatedAt > latest.updatedAt ? session : latest);
2665
+ const browser = canonical.browser!;
2666
+ const tab = browser.tab!;
2667
+ const product = browser.browserProduct!;
2668
+ let inventory = inventories.get(product);
2669
+ if (!inventory) {
2670
+ try {
2671
+ inventory = await listMacOsBrowserTabs(product, timeout);
2672
+ } catch (error) {
2673
+ inventory = error instanceof Error ? error : new Error(String(error));
2674
+ }
2675
+ inventories.set(product, inventory);
2676
+ }
2677
+ if (inventory instanceof Error) {
2678
+ if (!nativeTabInventoryUnsupported(inventory)) {
2679
+ for (const session of group) items.set(session.sessionId, { session, liveness: 'unverified', evidence: 'native_inventory_unavailable', cleanupError: inventory.message });
2680
+ unverifiedCount += group.length;
2681
+ continue;
2682
+ }
2683
+
2684
+ const ref = { windowId: tab.windowId!, tabId: tab.tabId! };
2685
+ let metadata: Awaited<ReturnType<typeof readMacOsBrowserOwnedTabMetadata>>;
2686
+ try {
2687
+ metadata = await readMacOsBrowserOwnedTabMetadata(product, ref, timeout);
2688
+ } catch (metadataError) {
2689
+ if (macOsBrowserPageHandleStale(metadataError)) {
2690
+ const pruned = options.pruneDead === true;
2691
+ if (pruned) {
2692
+ for (const session of group) removeSession(repoRoot, session.sessionId);
2693
+ prunedCount += group.length;
2694
+ }
2695
+ for (const session of group) items.set(session.sessionId, { session, liveness: 'dead', evidence: 'native_tab_missing', pruned });
2696
+ deadCount += group.length;
2697
+ } else {
2698
+ const cleanupError = metadataError instanceof Error ? metadataError.message : String(metadataError);
2699
+ for (const session of group) items.set(session.sessionId, { session, liveness: 'unverified', evidence: 'native_inventory_unavailable', cleanupError });
2700
+ unverifiedCount += group.length;
2701
+ }
2702
+ continue;
2703
+ }
2704
+
2705
+ if (isDiscardableNativeOwnedTabUrl(metadata.url)) {
2706
+ deadCount += group.length;
2707
+ if (options.pruneDead === true) {
2708
+ try {
2709
+ await closeMacOsBrowserOwnedTab(product, ref, timeout);
2710
+ for (const session of group) removeSession(repoRoot, session.sessionId);
2711
+ prunedCount += group.length;
2712
+ closedInvalidCount += 1;
2713
+ for (const session of group) items.set(session.sessionId, { session, liveness: 'dead', evidence: 'native_tab_invalid_closed', pruned: true });
2714
+ } catch (error) {
2715
+ failedCleanupCount += 1;
2716
+ const cleanupError = error instanceof Error ? error.message : String(error);
2717
+ for (const session of group) items.set(session.sessionId, { session, liveness: 'dead', evidence: 'native_tab_invalid_close_failed', cleanupError });
2718
+ }
2719
+ } else {
2720
+ for (const session of group) items.set(session.sessionId, { session, liveness: 'dead', evidence: 'native_tab_invalid' });
2721
+ }
2722
+ } else {
2723
+ for (const session of group) items.set(session.sessionId, { session, liveness: 'live', evidence: 'native_tab_live' });
2724
+ liveCount += group.length;
2725
+ }
2726
+ continue;
2727
+ }
2728
+
2729
+ const live = inventory.tabs.find((entry) => entry.windowId === tab.windowId && entry.tabId === tab.tabId);
2730
+ if (!live) {
2731
+ const pruned = options.pruneDead === true;
2732
+ if (pruned) {
2733
+ for (const session of group) removeSession(repoRoot, session.sessionId);
2734
+ prunedCount += group.length;
2735
+ }
2736
+ for (const session of group) items.set(session.sessionId, { session, liveness: 'dead', evidence: 'native_tab_missing', pruned });
2737
+ deadCount += group.length;
2738
+ continue;
2739
+ }
2740
+
2741
+ if (isDiscardableNativeOwnedTabUrl(live.url)) {
2742
+ deadCount += group.length;
2743
+ if (options.pruneDead === true) {
2744
+ try {
2745
+ await closeMacOsBrowserOwnedTab(product, { windowId: tab.windowId!, tabId: tab.tabId! }, timeout);
2746
+ for (const session of group) removeSession(repoRoot, session.sessionId);
2747
+ prunedCount += group.length;
2748
+ closedInvalidCount += 1;
2749
+ for (const session of group) items.set(session.sessionId, { session, liveness: 'dead', evidence: 'native_tab_invalid_closed', pruned: true });
2750
+ } catch (error) {
2751
+ failedCleanupCount += 1;
2752
+ const cleanupError = error instanceof Error ? error.message : String(error);
2753
+ for (const session of group) items.set(session.sessionId, { session, liveness: 'dead', evidence: 'native_tab_invalid_close_failed', cleanupError });
2754
+ }
2755
+ } else {
2756
+ for (const session of group) items.set(session.sessionId, { session, liveness: 'dead', evidence: 'native_tab_invalid' });
2757
+ }
2758
+ continue;
2759
+ }
2760
+
2761
+ for (const session of group) items.set(session.sessionId, { session, liveness: 'live', evidence: 'native_tab_live' });
2762
+ liveCount += group.length;
2763
+ }
2764
+
2765
+ return { items, liveCount, deadCount, unverifiedCount, prunedCount, closedInvalidCount, failedCleanupCount };
2766
+ }
2767
+
2768
+ async function closeTrackedNativeOwnedSession(
2769
+ session: BrowserSessionState,
2770
+ config: BrowserPluginConfig,
2771
+ ): Promise<{ resourceClosed: boolean; resourceAlreadyMissing: boolean }> {
2772
+ const browser = session.browser;
2773
+ const tab = browser?.tab;
2774
+ if (browser?.provider !== 'macos-apple-events' || tab?.ownership !== 'plugin_owned') {
2775
+ return { resourceClosed: false, resourceAlreadyMissing: false };
2776
+ }
2777
+ if (!browser.browserProduct || !tab.windowId || !tab.tabId) {
2778
+ throw new AssistantPluginError('PLUGIN_BROWSER_NATIVE_OWNERSHIP_MISSING', 'Tracked plugin-owned native tab is missing stable browser identity; retaining session metadata instead of orphaning the tab.', {
2779
+ retryable: false, details: { sessionId: session.sessionId, browserProduct: browser.browserProduct, windowId: tab.windowId, tabId: tab.tabId },
2780
+ });
2781
+ }
2782
+ const timeout = config.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS;
2783
+ const ref = { windowId: tab.windowId, tabId: tab.tabId };
2784
+ let inventory: Awaited<ReturnType<typeof listMacOsBrowserTabs>> | undefined;
1418
2785
  try {
1419
- return readdirSync(dir)
1420
- .filter((name) => name.endsWith('.json'))
1421
- .map((name) => readJson<BrowserSessionState>(join(dir, name)))
1422
- .filter((entry): entry is BrowserSessionState => Boolean(entry?.sessionId && entry.url))
1423
- .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
1424
- } catch {
1425
- return [];
2786
+ inventory = await listMacOsBrowserTabs(browser.browserProduct, timeout);
2787
+ } catch (error) {
2788
+ if (!nativeTabInventoryUnsupported(error)) throw error;
2789
+ try {
2790
+ await readMacOsBrowserOwnedTabMetadata(browser.browserProduct, ref, timeout);
2791
+ } catch (metadataError) {
2792
+ if (macOsBrowserPageHandleStale(metadataError)) return { resourceClosed: false, resourceAlreadyMissing: true };
2793
+ throw metadataError;
2794
+ }
2795
+ }
2796
+ if (inventory && !inventory.tabs.some((entry) => entry.windowId === tab.windowId && entry.tabId === tab.tabId)) {
2797
+ return { resourceClosed: false, resourceAlreadyMissing: true };
2798
+ }
2799
+ await closeMacOsBrowserOwnedTab(browser.browserProduct, ref, timeout);
2800
+ return { resourceClosed: true, resourceAlreadyMissing: false };
2801
+ }
2802
+
2803
+ function nativeOwnedAliasSessionIds(repoRoot: string, session: BrowserSessionState): string[] {
2804
+ const browser = session.browser;
2805
+ const tab = browser?.tab;
2806
+ if (browser?.provider !== 'macos-apple-events' || tab?.ownership !== 'plugin_owned' || !browser.browserProduct || !tab.windowId || !tab.tabId) {
2807
+ return [session.sessionId];
1426
2808
  }
2809
+ return listSavedSessions(repoRoot)
2810
+ .filter((candidate) => candidate.browser?.provider === 'macos-apple-events'
2811
+ && candidate.browser.browserProduct === browser.browserProduct
2812
+ && candidate.browser.tab?.ownership === 'plugin_owned'
2813
+ && candidate.browser.tab.windowId === tab.windowId
2814
+ && candidate.browser.tab.tabId === tab.tabId)
2815
+ .map((candidate) => candidate.sessionId);
2816
+ }
2817
+
2818
+ async function inspectSavedSessions(
2819
+ repoRoot: string,
2820
+ config: BrowserPluginConfig,
2821
+ options: { pruneDead?: boolean } = {},
2822
+ ): Promise<BrowserSessionInventory> {
2823
+ const saved = listSavedSessions(repoRoot);
2824
+ const sessions: BrowserSessionInventoryItem[] = [];
2825
+ let liveManagedSessionCount = 0;
2826
+ let unverifiedSessionCount = 0;
2827
+ let deadManagedSessionCount = 0;
2828
+ let prunedSessionCount = 0;
2829
+ const native = await inspectNativeOwnedSessions(repoRoot, config, saved, options);
2830
+ let liveNativeSessionCount = native.liveCount;
2831
+ let deadNativeSessionCount = native.deadCount;
2832
+ let closedInvalidNativeSessionCount = native.closedInvalidCount;
2833
+ let failedNativeCleanupCount = native.failedCleanupCount;
2834
+ prunedSessionCount += native.prunedCount;
2835
+ unverifiedSessionCount += native.unverifiedCount;
2836
+
2837
+ for (const session of saved) {
2838
+ const nativeItem = native.items.get(session.sessionId);
2839
+ if (nativeItem) {
2840
+ sessions.push(nativeItem);
2841
+ continue;
2842
+ }
2843
+ if (session.browser?.provider !== 'playwright-persistent-context' || session.browser.activeMode !== 'managed_persistent') {
2844
+ sessions.push({ session, liveness: 'unverified', evidence: 'provider_unverified' });
2845
+ unverifiedSessionCount += 1;
2846
+ continue;
2847
+ }
2848
+
2849
+ const profile = selectedProfile(config, repoRoot, 'managed_persistent', session.sessionId);
2850
+ const pending = managedBrowserContexts.get(managedContextKey(profile));
2851
+ if (!pending) {
2852
+ sessions.push({ session, liveness: 'unverified', evidence: 'runtime_context_unavailable' });
2853
+ unverifiedSessionCount += 1;
2854
+ continue;
2855
+ }
2856
+
2857
+ let state: ManagedBrowserContextState;
2858
+ try {
2859
+ state = await pending;
2860
+ } catch {
2861
+ sessions.push({ session, liveness: 'unverified', evidence: 'runtime_context_unavailable' });
2862
+ unverifiedSessionCount += 1;
2863
+ continue;
2864
+ }
2865
+
2866
+ const pages = state.context.pages();
2867
+ const boundPage = state.pagesBySession.get(session.sessionId);
2868
+ if (boundPage) {
2869
+ if (pages.includes(boundPage)) {
2870
+ sessions.push({ session, liveness: 'live', evidence: 'runtime_session_binding' });
2871
+ liveManagedSessionCount += 1;
2872
+ continue;
2873
+ }
2874
+
2875
+ const pruned = options.pruneDead === true;
2876
+ if (pruned) {
2877
+ state.pagesBySession.delete(session.sessionId);
2878
+ removeSession(repoRoot, session.sessionId);
2879
+ prunedSessionCount += 1;
2880
+ }
2881
+ sessions.push({ session, liveness: 'dead', evidence: 'runtime_bound_page_missing', pruned });
2882
+ deadManagedSessionCount += 1;
2883
+ continue;
2884
+ }
2885
+
2886
+ const expectedOwnerToken = session.browser.tab?.ownership === 'plugin_owned' ? session.browser.tab.ownerToken : undefined;
2887
+ if (expectedOwnerToken) {
2888
+ const inventory = await inventoryTabs(state.context).catch(() => [] as BrowserTabInventoryEntry[]);
2889
+ const index = inventory.findIndex((entry) => entry.ownerToken === expectedOwnerToken);
2890
+ const page = index >= 0 ? state.context.pages()[index] : undefined;
2891
+ if (page) {
2892
+ state.pagesBySession.set(session.sessionId, page);
2893
+ sessions.push({ session, liveness: 'live', evidence: 'runtime_owner_token' });
2894
+ liveManagedSessionCount += 1;
2895
+ continue;
2896
+ }
2897
+ sessions.push({ session, liveness: 'unverified', evidence: 'runtime_owner_token_not_observed' });
2898
+ unverifiedSessionCount += 1;
2899
+ continue;
2900
+ }
2901
+
2902
+ sessions.push({ session, liveness: 'unverified', evidence: 'provider_unverified' });
2903
+ unverifiedSessionCount += 1;
2904
+ }
2905
+
2906
+ return {
2907
+ sessions,
2908
+ scannedSessionCount: saved.length,
2909
+ savedSessionCount: saved.length - prunedSessionCount,
2910
+ liveManagedSessionCount,
2911
+ liveNativeSessionCount,
2912
+ unverifiedSessionCount,
2913
+ deadManagedSessionCount,
2914
+ deadNativeSessionCount,
2915
+ closedInvalidNativeSessionCount,
2916
+ failedNativeCleanupCount,
2917
+ prunedSessionCount,
2918
+ };
1427
2919
  }
1428
2920
 
1429
2921
  const STABLE_SELECTOR_HELPERS = `
@@ -1504,7 +2996,7 @@ const STABLE_SELECTOR_HELPERS = `
1504
2996
  const EXTRACTION_SCRIPTS = {
1505
2997
  query: (selector: string, limit: number) => `(() => {
1506
2998
  ${STABLE_SELECTOR_HELPERS}
1507
- const nodes = Array.from(document.querySelectorAll(${JSON.stringify(selector)})).slice(0, ${limit});
2999
+ const nodes = ${openShadowSelectorExpression(selector, true)}.slice(0, ${limit});
1508
3000
  return nodes.map((el) => {
1509
3001
  const role = el.getAttribute('role');
1510
3002
  const text = (el.innerText || el.textContent || '').trim().slice(0, 120);
@@ -1512,11 +3004,11 @@ const EXTRACTION_SCRIPTS = {
1512
3004
  });
1513
3005
  })()`,
1514
3006
  attribute: (selector: string, attribute: string) => `(() => {
1515
- const el = document.querySelector(${JSON.stringify(selector)});
3007
+ const el = ${openShadowSelectorExpression(selector)};
1516
3008
  return el ? el.getAttribute(${JSON.stringify(attribute)}) : null;
1517
3009
  })()`,
1518
3010
  html: (selector?: string) => selector
1519
- ? `(() => { const el = document.querySelector(${JSON.stringify(selector)}); return el ? el.outerHTML : ''; })()`
3011
+ ? `(() => { const el = ${openShadowSelectorExpression(selector)}; return el ? el.outerHTML : ''; })()`
1520
3012
  : 'document.documentElement ? document.documentElement.outerHTML : ""',
1521
3013
  links: (limit: number) => `(() => {
1522
3014
  ${STABLE_SELECTOR_HELPERS}
@@ -1546,10 +3038,44 @@ const EXTRACTION_SCRIPTS = {
1546
3038
  })()`,
1547
3039
  interactive: (limit: number) => `(() => {
1548
3040
  ${STABLE_SELECTOR_HELPERS}
1549
- return Array.from(document.querySelectorAll('a,button,input,select,textarea,[role="button"],[role="link"]')).slice(0, ${limit}).map((el) => {
3041
+ const viewport = {
3042
+ width: Number(window.innerWidth || document.documentElement?.clientWidth || 0),
3043
+ height: Number(window.innerHeight || document.documentElement?.clientHeight || 0),
3044
+ scrollX: Number(window.scrollX || window.pageXOffset || 0),
3045
+ scrollY: Number(window.scrollY || window.pageYOffset || 0),
3046
+ devicePixelRatio: Number(window.devicePixelRatio || 1),
3047
+ };
3048
+ const elements = Array.from(document.querySelectorAll('a,button,input,select,textarea,[contenteditable="true"],[role="button"],[role="link"],[role="textbox"]')).slice(0, ${limit}).map((el) => {
1550
3049
  const text = (el.innerText || el.getAttribute('aria-label') || el.getAttribute('value') || el.textContent || '').trim().slice(0, 100);
1551
- return { tag: el.tagName.toLowerCase(), text, type: el.getAttribute('type') || undefined, href: el.getAttribute('href') || undefined, selectorHint: forgeSelectorHint(el) };
3050
+ const rect = el.getBoundingClientRect();
3051
+ const style = typeof getComputedStyle === 'function' ? getComputedStyle(el) : undefined;
3052
+ const width = Number(rect.width || 0);
3053
+ const height = Number(rect.height || 0);
3054
+ const left = Number(rect.left || 0);
3055
+ const top = Number(rect.top || 0);
3056
+ const right = Number(rect.right || (left + width));
3057
+ const bottom = Number(rect.bottom || (top + height));
3058
+ const visible = width > 0 && height > 0 && style?.display !== 'none' && style?.visibility !== 'hidden' && Number(style?.opacity ?? 1) !== 0;
3059
+ const inViewport = visible && right > 0 && bottom > 0 && left < viewport.width && top < viewport.height;
3060
+ const disabled = Boolean(el.disabled || el.getAttribute('aria-disabled') === 'true');
3061
+ const editable = !disabled && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable || el.getAttribute('role') === 'textbox');
3062
+ return {
3063
+ tag: el.tagName.toLowerCase(),
3064
+ text,
3065
+ type: el.getAttribute('type') || undefined,
3066
+ href: el.getAttribute('href') || undefined,
3067
+ role: el.getAttribute('role') || undefined,
3068
+ ariaLabel: el.getAttribute('aria-label') || undefined,
3069
+ selectorHint: forgeSelectorHint(el),
3070
+ bounds: { x: left, y: top, width, height, right, bottom },
3071
+ center: { x: left + width / 2, y: top + height / 2 },
3072
+ visible,
3073
+ inViewport,
3074
+ disabled,
3075
+ editable,
3076
+ };
1552
3077
  });
3078
+ return { geometryVersion: 1, viewport, elements };
1553
3079
  })()`,
1554
3080
  };
1555
3081
 
@@ -1563,17 +3089,229 @@ function selectorRepairHint(selector: string, errorMessage: string): string {
1563
3089
  return `Check selector "${selector}" against the current page structure.`;
1564
3090
  }
1565
3091
 
1566
- async function extractText(page: PageLike, selector: string | undefined, maxChars: number): Promise<Record<string, unknown>> {
1567
- const raw = await page.evaluate<string>(scriptText(selector));
1568
- return truncateText(raw, maxChars);
3092
+ type BrowserEvaluationScope = Pick<PageLike, 'evaluate'>;
3093
+
3094
+ type BrowserFrameSelection = {
3095
+ scope: BrowserEvaluationScope;
3096
+ frame?: { url: string; name: string };
3097
+ frameHandle?: FrameLike;
3098
+ };
3099
+
3100
+ function requestedFrameSelection(args: Record<string, unknown>): { frameUrl?: string; frameName?: string } {
3101
+ return { frameUrl: stringValue(args.frame_url), frameName: stringValue(args.frame_name) };
3102
+ }
3103
+
3104
+ function resolveBrowserEvaluationScope(
3105
+ page: PageLike,
3106
+ args: Record<string, unknown>,
3107
+ connection: BrowserConnectionSummary,
3108
+ ): BrowserFrameSelection {
3109
+ const { frameUrl, frameName } = requestedFrameSelection(args);
3110
+ if (!frameUrl && !frameName) return { scope: page };
3111
+ if (connection.provider === 'macos-apple-events' || !page.frames) {
3112
+ throw new AssistantPluginError(
3113
+ 'PLUGIN_BROWSER_FRAME_SCOPE_UNAVAILABLE',
3114
+ 'Explicit frame targeting requires a Playwright/CDP-controlled page; native Apple Events sessions fail closed.',
3115
+ { retryable: false, details: { provider: connection.provider, frameUrl, frameName } },
3116
+ );
3117
+ }
3118
+ const matches = page.frames().filter((frame) => (!frameUrl || frame.url() === frameUrl) && (!frameName || frame.name() === frameName));
3119
+ if (matches.length === 0) {
3120
+ throw new AssistantPluginError('PLUGIN_BROWSER_FRAME_NOT_FOUND', 'No browser frame matched the requested exact frame identity.', {
3121
+ retryable: true,
3122
+ details: { frameUrl, frameName },
3123
+ });
3124
+ }
3125
+ if (matches.length > 1) {
3126
+ throw new AssistantPluginError('PLUGIN_BROWSER_FRAME_AMBIGUOUS', 'Multiple browser frames matched the requested frame identity; specify both frame_url and frame_name.', {
3127
+ retryable: false,
3128
+ details: { frameUrl, frameName, matchCount: matches.length },
3129
+ });
3130
+ }
3131
+ const frame = matches[0]!;
3132
+ return { scope: frame, frame: { url: frame.url(), name: frame.name() }, frameHandle: frame };
3133
+ }
3134
+
3135
+ const VIEWPORT_METRICS_SCRIPT = `(() => ({
3136
+ viewportMetricsVersion: 1,
3137
+ viewport: {
3138
+ width: Number(window.innerWidth || document.documentElement?.clientWidth || 0),
3139
+ height: Number(window.innerHeight || document.documentElement?.clientHeight || 0),
3140
+ scrollX: Number(window.scrollX || window.pageXOffset || 0),
3141
+ scrollY: Number(window.scrollY || window.pageYOffset || 0),
3142
+ devicePixelRatio: Number(window.devicePixelRatio || 1),
3143
+ }
3144
+ }))()`;
3145
+
3146
+ async function resolveFrameViewportOffset(page: PageLike, selection: BrowserFrameSelection): Promise<{ x: number; y: number; width: number; height: number } | undefined> {
3147
+ const frame = selection.frameHandle;
3148
+ if (!frame) return undefined;
3149
+ if (page.mainFrame && page.mainFrame() === frame) return { x: 0, y: 0, width: 0, height: 0 };
3150
+ if (!frame.frameElement) {
3151
+ throw new AssistantPluginError('PLUGIN_BROWSER_FRAME_GEOMETRY_UNAVAILABLE', 'The selected Playwright frame does not expose a frame element for coordinate grounding.', {
3152
+ retryable: false,
3153
+ details: selection.frame,
3154
+ });
3155
+ }
3156
+ let box: { x: number; y: number; width: number; height: number } | null;
3157
+ try {
3158
+ box = await (await frame.frameElement()).boundingBox();
3159
+ } catch (error) {
3160
+ throw new AssistantPluginError('PLUGIN_BROWSER_FRAME_GEOMETRY_UNAVAILABLE', 'Could not resolve the selected frame element bounding box.', {
3161
+ retryable: true,
3162
+ details: { ...selection.frame, cause: error instanceof Error ? error.message : String(error) },
3163
+ });
3164
+ }
3165
+ if (!box || ![box.x, box.y, box.width, box.height].every((value) => Number.isFinite(value))) {
3166
+ throw new AssistantPluginError('PLUGIN_BROWSER_FRAME_GEOMETRY_UNAVAILABLE', 'The selected frame element has no stable visible bounding box; refusing to guess trusted-input coordinates.', {
3167
+ retryable: true,
3168
+ details: selection.frame,
3169
+ });
3170
+ }
3171
+ return box;
3172
+ }
3173
+
3174
+ function translateFrameGroundingElements(elements: unknown[], offset: { x: number; y: number }, viewport: unknown): unknown[] {
3175
+ const viewportRecord = viewport && typeof viewport === 'object' && !Array.isArray(viewport)
3176
+ ? viewport as Record<string, unknown>
3177
+ : {};
3178
+ const viewportWidth = typeof viewportRecord.width === 'number' && Number.isFinite(viewportRecord.width) ? viewportRecord.width : undefined;
3179
+ const viewportHeight = typeof viewportRecord.height === 'number' && Number.isFinite(viewportRecord.height) ? viewportRecord.height : undefined;
3180
+ if (viewportWidth === undefined || viewportHeight === undefined) {
3181
+ throw new AssistantPluginError('PLUGIN_BROWSER_FRAME_GEOMETRY_UNAVAILABLE', 'Top-level viewport dimensions are not finite for frame grounding.', { retryable: true });
3182
+ }
3183
+ return elements.map((entry) => {
3184
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return entry;
3185
+ const record = entry as Record<string, unknown>;
3186
+ const bounds = record.bounds && typeof record.bounds === 'object' && !Array.isArray(record.bounds)
3187
+ ? record.bounds as Record<string, unknown>
3188
+ : {};
3189
+ const center = record.center && typeof record.center === 'object' && !Array.isArray(record.center)
3190
+ ? record.center as Record<string, unknown>
3191
+ : {};
3192
+ const required = (value: unknown, field: string): number => {
3193
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
3194
+ throw new AssistantPluginError('PLUGIN_BROWSER_FRAME_GEOMETRY_UNAVAILABLE', `Frame grounding field ${field} is not finite.`, { retryable: true });
3195
+ }
3196
+ return value;
3197
+ };
3198
+ const x = required(bounds.x, 'bounds.x');
3199
+ const y = required(bounds.y, 'bounds.y');
3200
+ const width = required(bounds.width, 'bounds.width');
3201
+ const height = required(bounds.height, 'bounds.height');
3202
+ const right = required(bounds.right, 'bounds.right');
3203
+ const bottom = required(bounds.bottom, 'bounds.bottom');
3204
+ const centerX = required(center.x, 'center.x');
3205
+ const centerY = required(center.y, 'center.y');
3206
+ const translatedX = x + offset.x;
3207
+ const translatedY = y + offset.y;
3208
+ const translatedRight = right + offset.x;
3209
+ const translatedBottom = bottom + offset.y;
3210
+ const frameLocalInViewport = record.inViewport === true;
3211
+ const topLevelInViewport = translatedRight > 0
3212
+ && translatedBottom > 0
3213
+ && translatedX < viewportWidth
3214
+ && translatedY < viewportHeight;
3215
+ return {
3216
+ ...record,
3217
+ bounds: {
3218
+ x: translatedX,
3219
+ y: translatedY,
3220
+ width,
3221
+ height,
3222
+ right: translatedRight,
3223
+ bottom: translatedBottom,
3224
+ },
3225
+ center: { x: centerX + offset.x, y: centerY + offset.y },
3226
+ frameLocalInViewport,
3227
+ topLevelInViewport,
3228
+ inViewport: frameLocalInViewport && topLevelInViewport,
3229
+ };
3230
+ });
3231
+ }
3232
+
3233
+ async function extractText(scope: BrowserEvaluationScope, selector: string | undefined, maxChars: number): Promise<Record<string, unknown>> {
3234
+ const raw = await scope.evaluate<string>(scriptText(selector));
3235
+ return truncateText(raw, maxChars);
3236
+ }
3237
+
3238
+ function trustedInputGuardScript(selector: string): string {
3239
+ const encodedSelector = JSON.stringify(selector);
3240
+ return `(() => {
3241
+ let element;
3242
+ try { element = document.querySelector(${encodedSelector}); }
3243
+ catch (error) { return { trustedInputGuardVersion: 1, found: false, selectorError: String(error) }; }
3244
+ if (!element) return { trustedInputGuardVersion: 1, found: false };
3245
+ const rect = element.getBoundingClientRect();
3246
+ const style = typeof getComputedStyle === 'function' ? getComputedStyle(element) : undefined;
3247
+ const visible = Number(rect.width || 0) > 0
3248
+ && Number(rect.height || 0) > 0
3249
+ && style?.display !== 'none'
3250
+ && style?.visibility !== 'hidden'
3251
+ && Number(style?.opacity ?? 1) !== 0;
3252
+ return {
3253
+ trustedInputGuardVersion: 1,
3254
+ found: true,
3255
+ visible,
3256
+ bounds: {
3257
+ x: Number(rect.left || 0), y: Number(rect.top || 0),
3258
+ width: Number(rect.width || 0), height: Number(rect.height || 0),
3259
+ right: Number(rect.right || 0), bottom: Number(rect.bottom || 0),
3260
+ },
3261
+ };
3262
+ })()`;
3263
+ }
3264
+
3265
+ function verifyStateObservationScript(selector: string | undefined, textContains: string | undefined, maxChars: number): string {
3266
+ const payload = JSON.stringify({ selector: selector ?? null, textContains: textContains ?? null, maxChars });
3267
+ return `(() => {
3268
+ const args = ${payload};
3269
+ const element = args.selector ? document.querySelector(args.selector) : null;
3270
+ const textTarget = element || document.body || document.documentElement;
3271
+ const rawText = String(textTarget?.innerText || textTarget?.textContent || '');
3272
+ let visible;
3273
+ if (args.selector) {
3274
+ if (!element) visible = false;
3275
+ else {
3276
+ const rect = element.getBoundingClientRect();
3277
+ const style = typeof getComputedStyle === 'function' ? getComputedStyle(element) : undefined;
3278
+ visible = Number(rect.width || 0) > 0
3279
+ && Number(rect.height || 0) > 0
3280
+ && style?.display !== 'none'
3281
+ && style?.visibility !== 'hidden'
3282
+ && Number(style?.opacity ?? 1) !== 0;
3283
+ }
3284
+ }
3285
+ return {
3286
+ verificationVersion: 1,
3287
+ selectorExists: args.selector ? Boolean(element) : undefined,
3288
+ visible,
3289
+ textContains: args.textContains === null ? undefined : rawText.includes(args.textContains),
3290
+ textSample: rawText.slice(0, args.maxChars),
3291
+ textLength: rawText.length,
3292
+ truncated: rawText.length > args.maxChars,
3293
+ };
3294
+ })()`;
1569
3295
  }
1570
3296
 
1571
- async function readPageIdentity(page: PageLike, connection?: BrowserConnectionSummary): Promise<{ url: string; title: string }> {
3297
+ async function readPageIdentity(page: PageLike, connection?: BrowserConnectionSummary, expectedUrl?: string): Promise<{ url: string; title: string }> {
3298
+ const normalizeObservedUrl = (value: unknown): string => {
3299
+ const internalProduct = browserInternalUrlProduct(value);
3300
+ if (connection?.provider === 'macos-apple-events' && connection.tab?.ownership === 'user_owned') {
3301
+ if (connection.browserProduct && expectedUrl && explicitNativeAdoptionUrlsMatch(expectedUrl, value, connection.browserProduct)) {
3302
+ return normalizedExplicitNativeAdoptionUrl(expectedUrl);
3303
+ }
3304
+ if (internalProduct && internalProduct === connection.browserProduct) {
3305
+ return normalizedExplicitNativeAdoptionUrl(value);
3306
+ }
3307
+ }
3308
+ return normalizedUrl(value);
3309
+ };
1572
3310
  if (connection?.provider === 'macos-apple-events' && typeof page.identity === 'function') {
1573
3311
  const identity = await page.identity();
1574
- return { url: normalizedUrl(identity.url), title: identity.title };
3312
+ return { url: normalizeObservedUrl(identity.url), title: identity.title };
1575
3313
  }
1576
- return { url: normalizedUrl(page.url()), title: await page.title() };
3314
+ return { url: normalizeObservedUrl(page.url()), title: await page.title() };
1577
3315
  }
1578
3316
 
1579
3317
  async function finalizeInteractiveAction(
@@ -1587,14 +3325,12 @@ async function finalizeInteractiveAction(
1587
3325
  extra: Record<string, unknown> = {},
1588
3326
  ): Promise<Record<string, unknown>> {
1589
3327
  const warnings: string[] = [];
1590
- const identity = await readPageIdentity(page, connection);
3328
+ const identity = await readPageIdentity(page, connection, target.url);
1591
3329
  const title = identity.title;
1592
3330
  const pageUrl = identity.url;
1593
- assertUrlAllowed(pageUrl, config);
1594
- const session = sessionFromPage(target, pageUrl, title, connection);
1595
- saveSession(repoRoot, session);
3331
+ const session = saveSession(repoRoot, sessionFromPage(target, pageUrl, title, connection));
1596
3332
  let screenshot: BrowserActionScreenshot | undefined;
1597
- const silentNativeEvidence = connection.provider === 'macos-apple-events' && connection.tab?.ownership === 'plugin_owned';
3333
+ const silentNativeEvidence = connection.provider === 'macos-apple-events';
1598
3334
  if (!silentNativeEvidence) {
1599
3335
  try {
1600
3336
  screenshot = await captureActionScreenshot(page, repoRoot, actionId, session.sessionId, session.url);
@@ -1647,7 +3383,7 @@ function capabilities(): AssistantPluginCapability[] {
1647
3383
  title: 'Browser Sessions',
1648
3384
  description: 'Create, list, reuse, and close persistent browser sessions.',
1649
3385
  scopes: ['browser.read', 'browser.profile'],
1650
- actions: ['create_session', 'list_sessions', 'close_session', 'close_page', 'clear_session'],
3386
+ actions: ['create_session', 'list_sessions', 'reconcile_sessions', 'close_session', 'close_page', 'clear_session'],
1651
3387
  },
1652
3388
  {
1653
3389
  capabilityId: 'browser-human-handoff',
@@ -1663,7 +3399,7 @@ function capabilities(): AssistantPluginCapability[] {
1663
3399
  scopes: ['browser.read', 'browser.profile'],
1664
3400
  actions: [
1665
3401
  'open_page', 'navigate', 'reload', 'go_back', 'wait_for_load_state',
1666
- 'get_text', 'get_html', 'query_selector', 'query_all', 'get_attribute',
3402
+ 'get_text', 'get_html', 'query_selector', 'query_all', 'get_attribute', 'list_frames', 'verify_state',
1667
3403
  'screenshot', 'extract_links', 'extract_tables', 'extract_forms', 'snapshot_interactive',
1668
3404
  'get_console_errors', 'get_failed_requests',
1669
3405
  ],
@@ -1671,11 +3407,11 @@ function capabilities(): AssistantPluginCapability[] {
1671
3407
  {
1672
3408
  capabilityId: 'browser-interaction',
1673
3409
  title: 'Browser Interaction',
1674
- description: 'Perform explicit form and pointer interactions on allowed domains through the persistent Playwright profile.',
3410
+ description: 'Perform explicit form and pointer interactions on HTTP(S) pages through the persistent Playwright profile.',
1675
3411
  scopes: ['browser.interact', 'browser.profile'],
1676
3412
  actions: [
1677
- 'click', 'double_click', 'hover', 'focus', 'type', 'fill', 'select_option',
1678
- 'check', 'uncheck', 'press', 'keyboard_shortcut', 'wait_for_selector', 'attach_local_file', 'await_file_transfer',
3413
+ 'activate_page', 'click', 'click_text', 'double_click', 'hover', 'focus', 'type', 'fill', 'select_option',
3414
+ 'check', 'uncheck', 'press', 'keyboard_shortcut', 'trusted_input', 'dispatch_event', 'wait_for_selector', 'attach_local_file', 'await_file_transfer',
1679
3415
  ],
1680
3416
  },
1681
3417
  ];
@@ -1690,6 +3426,10 @@ function sessionTargetSchema(extra: Record<string, unknown> = {}, required: stri
1690
3426
  wait_until: { type: 'string', enum: ['load', 'domcontentloaded', 'networkidle'] },
1691
3427
  timeout_ms: { type: 'number' },
1692
3428
  retries: { type: 'number' },
3429
+ browser_mode: { type: 'string', enum: ['attach_preferred', 'managed_persistent', 'isolated'] },
3430
+ cdp_attach_fallback: { type: 'string', enum: ['managed_persistent', 'fail_closed'] },
3431
+ native_attach_mode: { type: 'string', enum: ['auto', 'disabled'] },
3432
+ native_browser_candidates: { type: 'array', items: { type: 'string', enum: ['vivaldi', 'chrome'] }, maxItems: 2 },
1693
3433
  ...extra,
1694
3434
  },
1695
3435
  ...(required.length > 0 ? { required } : {}),
@@ -1697,6 +3437,11 @@ function sessionTargetSchema(extra: Record<string, unknown> = {}, required: stri
1697
3437
  };
1698
3438
  }
1699
3439
 
3440
+ const frameScopeProperties = {
3441
+ frame_url: { type: 'string' },
3442
+ frame_name: { type: 'string' },
3443
+ };
3444
+
1700
3445
  function interactSchema(extra: Record<string, unknown>, required: string[]): Record<string, unknown> {
1701
3446
  return sessionTargetSchema({
1702
3447
  post_action_wait_ms: { type: 'number' },
@@ -1713,7 +3458,7 @@ function actions(): AssistantPluginActionDescriptor[] {
1713
3458
  { resource: 'remote' as const, mode: 'exclusive' as const },
1714
3459
  { resource: 'repo-state' as const, mode: 'write' as const },
1715
3460
  ];
1716
- return [
3461
+ const descriptors: AssistantPluginActionDescriptor[] = [
1717
3462
  {
1718
3463
  actionId: 'configure',
1719
3464
  title: 'Configure browser plugin',
@@ -1749,8 +3494,6 @@ function actions(): AssistantPluginActionDescriptor[] {
1749
3494
  native_attach_mode: { type: 'string', enum: ['auto', 'disabled'] },
1750
3495
  native_browser_candidates: { type: 'array', items: { type: 'string', enum: ['vivaldi', 'chrome'] }, maxItems: 2 },
1751
3496
  default_timeout_ms: { type: 'number' },
1752
- allowed_domains: { type: 'array', items: { type: 'string' } },
1753
- clear_allowed_domains: { type: 'boolean' },
1754
3497
  },
1755
3498
  additionalProperties: false,
1756
3499
  },
@@ -1758,10 +3501,17 @@ function actions(): AssistantPluginActionDescriptor[] {
1758
3501
  {
1759
3502
  actionId: 'create_session',
1760
3503
  title: 'Create browser session',
1761
- description: 'Open an allowed URL and persist a reusable session id.',
3504
+ description: 'Open an HTTP(S) URL or explicitly adopt the matching frontmost native tab, then persist a reusable session id.',
1762
3505
  readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
1763
3506
  scopes: ['browser.read', 'browser.profile'], resourceClaims: [{ resource: 'repo-state', mode: 'write' }, ...readRemote],
1764
- argumentsSchema: sessionTargetSchema({ extract_text: { type: 'boolean' }, max_chars: { type: 'number' } }, ['url']),
3507
+ argumentsSchema: sessionTargetSchema({
3508
+ extract_text: { type: 'boolean' },
3509
+ max_chars: { type: 'number' },
3510
+ native_browser_product: { type: 'string', enum: ['chrome', 'vivaldi'] },
3511
+ native_window_id: { type: 'string' },
3512
+ native_tab_id: { type: 'string' },
3513
+ native_active_tab: { type: 'boolean' },
3514
+ }, ['url']),
1765
3515
  },
1766
3516
  {
1767
3517
  actionId: 'list_sessions',
@@ -1769,6 +3519,21 @@ function actions(): AssistantPluginActionDescriptor[] {
1769
3519
  description: 'List saved browser session metadata without secrets or cookies.',
1770
3520
  readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 10_000, cancellable: true, idempotent: true,
1771
3521
  scopes: ['browser.read', 'browser.profile'], resourceClaims: [{ resource: 'repo-state', mode: 'read' }],
3522
+ argumentsSchema: {
3523
+ type: 'object',
3524
+ properties: {
3525
+ limit: { type: 'number', minimum: 1, maximum: MAX_BROWSER_SESSION_LIST_LIMIT, default: DEFAULT_BROWSER_SESSION_LIST_LIMIT },
3526
+ cursor: { type: 'string' },
3527
+ },
3528
+ additionalProperties: false,
3529
+ },
3530
+ },
3531
+ {
3532
+ actionId: 'reconcile_sessions',
3533
+ title: 'Reconcile browser sessions',
3534
+ description: 'Prune only saved managed-session metadata whose exact Runtime-bound page is positively proven gone. Never removes profiles, cookies, or unverified native sessions.',
3535
+ readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 15_000, cancellable: true, idempotent: true,
3536
+ scopes: ['browser.read', 'browser.profile'], resourceClaims: [{ resource: 'repo-state', mode: 'write' }],
1772
3537
  argumentsSchema: { type: 'object', properties: {}, additionalProperties: false },
1773
3538
  },
1774
3539
  {
@@ -1826,7 +3591,7 @@ function actions(): AssistantPluginActionDescriptor[] {
1826
3591
  {
1827
3592
  actionId: 'open_page',
1828
3593
  title: 'Open page',
1829
- description: 'Open an allowed URL with the persistent profile and save a lightweight session.',
3594
+ description: 'Open an HTTP(S) URL with the persistent profile and save a lightweight session.',
1830
3595
  readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
1831
3596
  scopes: ['browser.read'], resourceClaims: readRemote,
1832
3597
  argumentsSchema: sessionTargetSchema({ extract_text: { type: 'boolean' }, max_chars: { type: 'number' } }, ['url']),
@@ -1834,25 +3599,25 @@ function actions(): AssistantPluginActionDescriptor[] {
1834
3599
  {
1835
3600
  actionId: 'navigate',
1836
3601
  title: 'Navigate',
1837
- description: 'Navigate an existing session or open a new page to an allowed URL.',
1838
- readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
1839
- scopes: ['browser.read'], resourceClaims: readRemote,
3602
+ description: 'Navigate an existing session or open a new page to an HTTP(S) URL.',
3603
+ readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
3604
+ scopes: ['browser.read', 'browser.interact', 'browser.profile'], resourceClaims: writeRemote,
1840
3605
  argumentsSchema: sessionTargetSchema({}, ['url']),
1841
3606
  },
1842
3607
  {
1843
3608
  actionId: 'reload',
1844
3609
  title: 'Reload page',
1845
3610
  description: 'Reload the current page for a session.',
1846
- readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
1847
- scopes: ['browser.read'], resourceClaims: readRemote,
3611
+ readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
3612
+ scopes: ['browser.read', 'browser.interact', 'browser.profile'], resourceClaims: writeRemote,
1848
3613
  argumentsSchema: sessionTargetSchema({}, ['session_id']),
1849
3614
  },
1850
3615
  {
1851
3616
  actionId: 'go_back',
1852
3617
  title: 'Go back',
1853
3618
  description: 'Navigate back in history for a session.',
1854
- readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
1855
- scopes: ['browser.read'], resourceClaims: readRemote,
3619
+ readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
3620
+ scopes: ['browser.read', 'browser.interact', 'browser.profile'], resourceClaims: writeRemote,
1856
3621
  argumentsSchema: sessionTargetSchema({}, ['session_id']),
1857
3622
  },
1858
3623
  {
@@ -1869,7 +3634,7 @@ function actions(): AssistantPluginActionDescriptor[] {
1869
3634
  description: 'Extract text from a URL or saved browser session.',
1870
3635
  readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
1871
3636
  scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
1872
- argumentsSchema: sessionTargetSchema({ selector: { type: 'string' }, max_chars: { type: 'number' } }),
3637
+ argumentsSchema: sessionTargetSchema({ selector: { type: 'string' }, max_chars: { type: 'number' }, ...frameScopeProperties }),
1873
3638
  },
1874
3639
  {
1875
3640
  actionId: 'get_html',
@@ -1877,7 +3642,7 @@ function actions(): AssistantPluginActionDescriptor[] {
1877
3642
  description: 'Extract HTML for the page or a selector (bounded).',
1878
3643
  readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
1879
3644
  scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
1880
- argumentsSchema: sessionTargetSchema({ selector: { type: 'string' }, max_chars: { type: 'number' } }),
3645
+ argumentsSchema: sessionTargetSchema({ selector: { type: 'string' }, max_chars: { type: 'number' }, ...frameScopeProperties }),
1881
3646
  },
1882
3647
  {
1883
3648
  actionId: 'query_selector',
@@ -1885,7 +3650,7 @@ function actions(): AssistantPluginActionDescriptor[] {
1885
3650
  description: 'Return the first matching element summary with a stable selector hint.',
1886
3651
  readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
1887
3652
  scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
1888
- argumentsSchema: sessionTargetSchema({ selector: { type: 'string' } }, ['selector']),
3653
+ argumentsSchema: sessionTargetSchema({ selector: { type: 'string' }, ...frameScopeProperties }, ['selector']),
1889
3654
  },
1890
3655
  {
1891
3656
  actionId: 'query_all',
@@ -1893,7 +3658,7 @@ function actions(): AssistantPluginActionDescriptor[] {
1893
3658
  description: 'Return matching element summaries (bounded).',
1894
3659
  readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
1895
3660
  scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
1896
- argumentsSchema: sessionTargetSchema({ selector: { type: 'string' }, limit: { type: 'number' } }, ['selector']),
3661
+ argumentsSchema: sessionTargetSchema({ selector: { type: 'string' }, limit: { type: 'number' }, ...frameScopeProperties }, ['selector']),
1897
3662
  },
1898
3663
  {
1899
3664
  actionId: 'get_attribute',
@@ -1901,7 +3666,31 @@ function actions(): AssistantPluginActionDescriptor[] {
1901
3666
  description: 'Read one attribute from a selector.',
1902
3667
  readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
1903
3668
  scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
1904
- argumentsSchema: sessionTargetSchema({ selector: { type: 'string' }, attribute: { type: 'string' } }, ['selector', 'attribute']),
3669
+ argumentsSchema: sessionTargetSchema({ selector: { type: 'string' }, attribute: { type: 'string' }, ...frameScopeProperties }, ['selector', 'attribute']),
3670
+ },
3671
+ {
3672
+ actionId: 'list_frames',
3673
+ title: 'List page frames',
3674
+ description: 'List bounded Playwright/CDP frame identities for explicit frame-scoped DOM observation.',
3675
+ readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
3676
+ scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
3677
+ argumentsSchema: sessionTargetSchema({ limit: { type: 'number', minimum: 1, maximum: 100 } }),
3678
+ },
3679
+ {
3680
+ actionId: 'verify_state',
3681
+ title: 'Verify browser state',
3682
+ description: 'Observe URL, selector existence/visibility, and bounded text criteria in one read-only call for post-action verification.',
3683
+ readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
3684
+ scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
3685
+ argumentsSchema: sessionTargetSchema({
3686
+ expected_url: { type: 'string' },
3687
+ url_contains: { type: 'string' },
3688
+ selector: { type: 'string' },
3689
+ require_visible: { type: 'boolean' },
3690
+ text_contains: { type: 'string', maxLength: 10000 },
3691
+ max_chars: { type: 'number', minimum: 1, maximum: 100000 },
3692
+ ...frameScopeProperties,
3693
+ }, ['session_id']),
1905
3694
  },
1906
3695
  {
1907
3696
  actionId: 'screenshot',
@@ -1938,10 +3727,10 @@ function actions(): AssistantPluginActionDescriptor[] {
1938
3727
  {
1939
3728
  actionId: 'snapshot_interactive',
1940
3729
  title: 'Snapshot interactive elements',
1941
- description: 'Snapshot buttons/inputs/links with stable selector hints.',
3730
+ description: 'Snapshot interactive elements with stable selector hints plus CSS-pixel viewport geometry for screenshot-to-input grounding.',
1942
3731
  readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
1943
3732
  scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
1944
- argumentsSchema: sessionTargetSchema({ limit: { type: 'number' } }),
3733
+ argumentsSchema: sessionTargetSchema({ limit: { type: 'number' }, ...frameScopeProperties }),
1945
3734
  },
1946
3735
  {
1947
3736
  actionId: 'get_console_errors',
@@ -1959,6 +3748,14 @@ function actions(): AssistantPluginActionDescriptor[] {
1959
3748
  scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
1960
3749
  argumentsSchema: sessionTargetSchema({}),
1961
3750
  },
3751
+ {
3752
+ actionId: 'activate_page',
3753
+ title: 'Activate browser page',
3754
+ description: 'Bring the exact saved browser page/tab to the foreground without opening or replacing a tab.',
3755
+ readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
3756
+ scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
3757
+ argumentsSchema: interactSchema({}, []),
3758
+ },
1962
3759
  {
1963
3760
  actionId: 'click',
1964
3761
  title: 'Click element',
@@ -1967,6 +3764,14 @@ function actions(): AssistantPluginActionDescriptor[] {
1967
3764
  scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
1968
3765
  argumentsSchema: interactSchema({ selector: { type: 'string' } }, ['selector']),
1969
3766
  },
3767
+ {
3768
+ actionId: 'click_text',
3769
+ title: 'Click exact visible text',
3770
+ description: 'Click the smallest visible standard DOM element whose normalized text exactly matches the requested text. Closed shadow roots are intentionally not traversed.',
3771
+ readOnly: false, risk: 'remote_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
3772
+ scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
3773
+ argumentsSchema: interactSchema({ text: { type: 'string', minLength: 1, maxLength: 200 } }, ['text']),
3774
+ },
1970
3775
  {
1971
3776
  actionId: 'double_click',
1972
3777
  title: 'Double-click element',
@@ -2047,6 +3852,39 @@ function actions(): AssistantPluginActionDescriptor[] {
2047
3852
  scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
2048
3853
  argumentsSchema: interactSchema({ key: { type: 'string' } }, ['key']),
2049
3854
  },
3855
+ {
3856
+ actionId: 'trusted_input',
3857
+ title: 'Trusted browser input',
3858
+ description: 'Send bounded mouse, wheel, drag, key, or text input after authorization. Playwright/CDP uses page input primitives; native macOS sessions require the exact saved tab to already be foreground/active and use the stable Desktop Operator input broker without activating another tab.',
3859
+ readOnly: false, risk: 'remote_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
3860
+ scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
3861
+ argumentsSchema: interactSchema({
3862
+ kind: { type: 'string', enum: ['click', 'move', 'wheel', 'drag', 'key', 'text'] },
3863
+ x: { type: 'number', minimum: 0, maximum: 100000 },
3864
+ y: { type: 'number', minimum: 0, maximum: 100000 },
3865
+ from_x: { type: 'number', minimum: 0, maximum: 100000 },
3866
+ from_y: { type: 'number', minimum: 0, maximum: 100000 },
3867
+ to_x: { type: 'number', minimum: 0, maximum: 100000 },
3868
+ to_y: { type: 'number', minimum: 0, maximum: 100000 },
3869
+ delta_x: { type: 'number', minimum: -100000, maximum: 100000 },
3870
+ delta_y: { type: 'number', minimum: -100000, maximum: 100000 },
3871
+ button: { type: 'string', enum: ['left', 'middle', 'right'] },
3872
+ click_count: { type: 'number', minimum: 1, maximum: 3 },
3873
+ steps: { type: 'number', minimum: 1, maximum: 100 },
3874
+ key: { type: 'string', minLength: 1, maxLength: 100 },
3875
+ text: { type: 'string', maxLength: 10000 },
3876
+ guard_selector: { type: 'string' },
3877
+ ...frameScopeProperties,
3878
+ }, ['kind']),
3879
+ },
3880
+ {
3881
+ actionId: 'dispatch_event',
3882
+ title: 'Dispatch DOM event',
3883
+ description: 'Dispatch one named bubbling/composed DOM CustomEvent on an explicit selector after authorization. No arbitrary JavaScript payload is accepted.',
3884
+ readOnly: false, risk: 'remote_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
3885
+ scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
3886
+ argumentsSchema: interactSchema({ selector: { type: 'string' }, event: { type: 'string', minLength: 1, maxLength: 64 } }, ['selector', 'event']),
3887
+ },
2050
3888
  {
2051
3889
  actionId: 'wait_for_selector',
2052
3890
  title: 'Wait for selector',
@@ -2060,11 +3898,15 @@ function actions(): AssistantPluginActionDescriptor[] {
2060
3898
  },
2061
3899
  {
2062
3900
  actionId: 'attach_local_file',
2063
- title: 'Attach local file',
2064
- description: 'Set an input[type=file] path for an allowed local file after authorization. Never auto-opens executables.',
3901
+ title: 'Attach local file(s)',
3902
+ description: 'Set one or more allowed local files on input[type=file] after authorization. file_path remains backward-compatible; file_paths supports multiple selection. Never auto-opens executables.',
2065
3903
  readOnly: false, risk: 'remote_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
2066
3904
  scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
2067
- argumentsSchema: interactSchema({ selector: { type: 'string' }, file_path: { type: 'string' } }, ['selector', 'file_path']),
3905
+ argumentsSchema: interactSchema({
3906
+ selector: { type: 'string' },
3907
+ file_path: { type: 'string' },
3908
+ file_paths: { type: 'array', minItems: 1, maxItems: 32, items: { type: 'string' } },
3909
+ }, ['selector']),
2068
3910
  },
2069
3911
  {
2070
3912
  actionId: 'await_file_transfer',
@@ -2083,21 +3925,36 @@ function actions(): AssistantPluginActionDescriptor[] {
2083
3925
  argumentsSchema: { type: 'object', properties: { session_id: { type: 'string' } }, required: ['session_id'], additionalProperties: false },
2084
3926
  },
2085
3927
  ];
3928
+ const foregroundRequired = new Set(['activate_page', 'request_human_handoff']);
3929
+ const foregroundPossible = new Set([
3930
+ 'create_session', 'open_page', 'navigate', 'reload', 'go_back', 'resolve_handoff',
3931
+ 'click', 'click_text', 'double_click', 'hover', 'focus', 'type', 'fill', 'select_option',
3932
+ 'check', 'uncheck', 'press', 'keyboard_shortcut', 'trusted_input', 'dispatch_event',
3933
+ 'wait_for_selector', 'attach_local_file', 'await_file_transfer',
3934
+ ]);
3935
+ return descriptors.map((descriptor) => ({
3936
+ ...descriptor,
3937
+ foregroundEffect: foregroundRequired.has(descriptor.actionId)
3938
+ ? 'required'
3939
+ : foregroundPossible.has(descriptor.actionId)
3940
+ ? 'possible'
3941
+ : 'none',
3942
+ }));
2086
3943
  }
2087
3944
 
2088
3945
  function browserUserFacingStatus(config: BrowserPluginConfig, ready: boolean, sessionCount = 0): string {
2089
3946
  if (!config.enabled) return 'disabled';
2090
3947
  if (!ready) return 'not ready';
2091
3948
  if (sessionCount > 0) return 'session active';
2092
- if (config.allowedDomains && config.allowedDomains.length > 0) return 'domain restricted';
2093
3949
  return 'ready';
2094
3950
  }
2095
3951
 
2096
3952
  function health(config: BrowserPluginConfig, repoRoot?: string): AssistantPluginHealth {
2097
- const dependencyReady = runtimeHooks.moduleAvailable('playwright');
3953
+ const dependencyReady = runtimeHooks.moduleAvailable('playwright', repoRoot);
2098
3954
  const configErrors = validateConfig(config);
2099
3955
  const warnings = configWarnings(config);
2100
- const sessionCount = repoRoot ? listSavedSessions(repoRoot).length : 0;
3956
+ const authority = currentBrowserSessionAuthorityContext();
3957
+ const savedSessionCount = repoRoot && authority ? listSavedBrowserSessions(repoRoot).length : 0;
2101
3958
  const activeHandoffCount = repoRoot
2102
3959
  ? listBrowserHandoffs(repoRoot).filter((entry) => ['starting', 'waiting_for_user', 'closing'].includes(entry.status)).length
2103
3960
  : 0;
@@ -2116,7 +3973,9 @@ function health(config: BrowserPluginConfig, repoRoot?: string): AssistantPlugin
2116
3973
  nativeBrowserCandidates: config.nativeBrowserCandidates,
2117
3974
  nativeAttachSupported: macOsActiveBrowserAttachSupported(),
2118
3975
  windowMode: 'visible' as const,
2119
- sessionCount,
3976
+ sessionCount: savedSessionCount,
3977
+ sessionCountSemantics: authority ? 'controller_authority' as const : 'controller_authority_unavailable' as const,
3978
+ savedSessionCount,
2120
3979
  activeHandoffCount,
2121
3980
  humanHandoffSupported: true,
2122
3981
  artifactsAvailable: true,
@@ -2147,14 +4006,9 @@ function health(config: BrowserPluginConfig, repoRoot?: string): AssistantPlugin
2147
4006
  const managedRouteReady = dependencyReady
2148
4007
  && (config.browserMode !== 'attach_preferred' || config.cdpAttachFallback === 'managed_persistent');
2149
4008
  const nativeRouteReady = nativeAttachConfigured && nativeObservation?.ready === true;
2150
- const nativeRouteUnverified = nativeAttachConfigured
2151
- && cdpEndpoints(config).length === 0
2152
- && config.cdpAttachFallback === 'fail_closed'
2153
- && nativeObservation === undefined;
2154
- const nativeRouteFailed = nativeAttachConfigured
2155
- && cdpEndpoints(config).length === 0
2156
- && config.cdpAttachFallback === 'fail_closed'
2157
- && nativeObservation?.ready === false;
4009
+ const nativeOnlyRoute = nativeAttachConfigured && !cdpRouteReady && !managedRouteReady;
4010
+ const nativeRouteUnverified = nativeOnlyRoute && nativeObservation === undefined;
4011
+ const nativeRouteFailed = nativeOnlyRoute && nativeObservation?.ready === false;
2158
4012
  if (nativeRouteUnverified || nativeRouteFailed) {
2159
4013
  const nativeWarnings = nativeRouteFailed
2160
4014
  ? nativeObservation?.attempts.map((attempt) => `${attempt.appName}: ${attempt.error ?? attempt.status}`) ?? []
@@ -2169,7 +4023,7 @@ function health(config: BrowserPluginConfig, repoRoot?: string): AssistantPlugin
2169
4023
  details: {
2170
4024
  ...baseDetails,
2171
4025
  nativeAttachObservation: nativeObservation,
2172
- provider: 'macos-active-browser-fail-closed',
4026
+ provider: 'macos-active-browser',
2173
4027
  userFacingStatus: 'not ready',
2174
4028
  },
2175
4029
  };
@@ -2212,11 +4066,10 @@ function health(config: BrowserPluginConfig, repoRoot?: string): AssistantPlugin
2212
4066
  details: {
2213
4067
  ...baseDetails,
2214
4068
  nativeAttachObservation: nativeObservation,
2215
- allowedDomains: config.allowedDomains,
2216
4069
  provider: config.browserMode === 'attach_preferred'
2217
4070
  ? 'cdp-or-macos-active-browser-or-persistent-context'
2218
4071
  : 'playwright-persistent-context',
2219
- userFacingStatus: browserUserFacingStatus(config, true, sessionCount),
4072
+ userFacingStatus: browserUserFacingStatus(config, true, savedSessionCount),
2220
4073
  },
2221
4074
  };
2222
4075
  }
@@ -2236,7 +4089,7 @@ export function buildBrowserPluginManifest(previousRevision = 0, previousUpdated
2236
4089
  authority: {
2237
4090
  strategy: 'derived',
2238
4091
  duplicateStateAllowed: false,
2239
- sourceOfTruth: ['repo-local:.forge/plugins/browser.json', 'repo-local:.forge/browser/'],
4092
+ sourceOfTruth: ['repo-local:.forge/plugins/browser.json', 'controller-home:sqlite/browser_session', 'repo-local:.forge/browser/{screenshots,downloads,diagnostics}'],
2240
4093
  },
2241
4094
  enabled: config.enabled,
2242
4095
  lifecycle: {
@@ -2257,8 +4110,25 @@ export function buildBrowserPluginManifest(previousRevision = 0, previousUpdated
2257
4110
  };
2258
4111
  }
2259
4112
 
2260
- export async function executeBrowserPluginAction(input: AssistantPluginActionExecutionInput): Promise<Record<string, unknown>> {
2261
- const current = loadConfig(input.repoRoot);
4113
+ const NODE_BRIDGE_PRE_ACTION_FAILURE_CODES = new Set([
4114
+ 'PLUGIN_BROWSER_NODE_UNAVAILABLE',
4115
+ 'PLUGIN_BROWSER_NODE_HOST_UNAVAILABLE',
4116
+ 'PLUGIN_BROWSER_NODE_REQUEST_TOO_LARGE',
4117
+ 'PLUGIN_BROWSER_NODE_START_FAILED',
4118
+ ]);
4119
+
4120
+ function browserRuntimeKey(input: AssistantPluginActionExecutionInput): string {
4121
+ return `${input.controllerHome}:${input.repoId}`;
4122
+ }
4123
+
4124
+ async function executeBrowserPluginActionInternal(
4125
+ input: AssistantPluginActionExecutionInput,
4126
+ runtimeAlreadyRouted = false,
4127
+ ): Promise<Record<string, unknown>> {
4128
+ const persisted = loadConfig(input.repoRoot);
4129
+ const actionSessionId = input.actionId === 'configure' ? undefined : stringValue(input.args.session_id);
4130
+ const actionSession = actionSessionId ? findSession(input.repoRoot, actionSessionId) : undefined;
4131
+ const current = input.actionId === 'configure' ? persisted : effectiveBrowserActionConfig(persisted, input.args, actionSession);
2262
4132
  if (!current.enabled && input.actionId !== 'configure') {
2263
4133
  throw new AssistantPluginError('PLUGIN_DISABLED', 'Browser plugin is disabled.', { retryable: false });
2264
4134
  }
@@ -2268,8 +4138,54 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2268
4138
  throw new AssistantPluginError('PLUGIN_CONFIGURATION_INVALID', configErrors[0], { retryable: false });
2269
4139
  }
2270
4140
  }
2271
- if (shouldUseBrowserNodeBridge(input.actionId, current.browserMode, runtimeHooksCustomized, cdpEndpoints(current).length > 0)) {
2272
- return await executeBrowserActionThroughNode(input);
4141
+ if (!runtimeAlreadyRouted) {
4142
+ const runtimeKey = browserRuntimeKey(input);
4143
+ const nodeBridgeEligible = shouldUseBrowserNodeBridge(
4144
+ input.actionId,
4145
+ current.browserMode,
4146
+ runtimeHooksCustomized,
4147
+ cdpEndpoints(current).length > 0,
4148
+ );
4149
+ const result = await executeBrowserRuntimeAction({
4150
+ runtimeKey,
4151
+ input,
4152
+ providers: [
4153
+ ...(nodeBridgeEligible ? [{
4154
+ providerId: 'browser.node_bridge',
4155
+ capabilities: ALL_BROWSER_PROVIDER_CAPABILITIES.filter((capability) => capability !== 'browser.persistent_handle'),
4156
+ foreground: 'none' as const,
4157
+ verifiesPostconditions: true,
4158
+ persistentHandle: false,
4159
+ priority: 10,
4160
+ supportsInput: () => true,
4161
+ execute: async (routedInput: AssistantPluginActionExecutionInput) => {
4162
+ try {
4163
+ return await executeBrowserActionThroughNode(routedInput);
4164
+ } catch (error) {
4165
+ if (error instanceof AssistantPluginError && NODE_BRIDGE_PRE_ACTION_FAILURE_CODES.has(error.code)) {
4166
+ throw new BrowserProviderUnavailableBeforeActionError('browser.node_bridge', error.code);
4167
+ }
4168
+ throw error;
4169
+ }
4170
+ },
4171
+ }] : []),
4172
+ {
4173
+ providerId: 'browser.local',
4174
+ capabilities: ALL_BROWSER_PROVIDER_CAPABILITIES,
4175
+ foreground: 'none' as const,
4176
+ verifiesPostconditions: true,
4177
+ persistentHandle: true,
4178
+ priority: 20,
4179
+ supportsInput: () => true,
4180
+ execute: async (routedInput: AssistantPluginActionExecutionInput) => executeBrowserPluginActionInternal(routedInput, true),
4181
+ },
4182
+ ],
4183
+ });
4184
+ if (input.actionId === 'configure') {
4185
+ invalidateBrowserRuntime(runtimeKey, 'configuration_changed');
4186
+ invalidateMacOsBrowserPageHandles();
4187
+ }
4188
+ return result;
2273
4189
  }
2274
4190
  try {
2275
4191
  switch (input.actionId) {
@@ -2310,63 +4226,161 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2310
4226
  defaultTimeoutMs: typeof args.default_timeout_ms === 'number'
2311
4227
  ? positiveNumber(args.default_timeout_ms, DEFAULT_TIMEOUT_MS)
2312
4228
  : current.defaultTimeoutMs,
2313
- allowedDomains: args.clear_allowed_domains === true ? undefined : stringArray(args.allowed_domains) ?? current.allowedDomains,
2314
4229
  });
2315
4230
  const configErrors = validateConfig(config);
2316
4231
  if (configErrors.length > 0) {
2317
4232
  throw new AssistantPluginError('PLUGIN_CONFIGURATION_INVALID', configErrors[0], { retryable: false });
2318
4233
  }
4234
+ await closeManagedContextsForRepo(input.repoRoot);
2319
4235
  return { config, health: health(config, input.repoRoot) };
2320
4236
  }
2321
- case 'list_sessions':
4237
+ case 'list_sessions': {
4238
+ const inventory = await inspectSavedSessions(input.repoRoot, current);
4239
+ const requestedLimit = Math.max(1, Math.min(Math.trunc(positiveNumber(input.args.limit, DEFAULT_BROWSER_SESSION_LIST_LIMIT)), MAX_BROWSER_SESSION_LIST_LIMIT));
4240
+ let offset = 0;
4241
+ const cursor = stringValue(input.args.cursor);
4242
+ if (cursor) {
4243
+ try {
4244
+ const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as { offset?: unknown };
4245
+ if (!Number.isInteger(parsed.offset) || Number(parsed.offset) < 0) throw new Error('invalid offset');
4246
+ offset = Number(parsed.offset);
4247
+ } catch {
4248
+ throw new AssistantPluginError('PLUGIN_BROWSER_SESSION_CURSOR_INVALID', 'Browser session cursor is invalid or expired.', { retryable: false });
4249
+ }
4250
+ }
4251
+ const page = inventory.sessions.slice(offset, offset + requestedLimit);
4252
+ const nextOffset = offset + page.length;
2322
4253
  return {
2323
4254
  provider: 'playwright',
2324
- sessions: listSavedSessions(input.repoRoot).map((session) => ({
4255
+ sessionCountSemantics: currentBrowserSessionAuthorityContext() ? 'controller_authority' : 'saved_metadata',
4256
+ scannedSessionCount: inventory.scannedSessionCount,
4257
+ savedSessionCount: inventory.savedSessionCount,
4258
+ liveManagedSessionCount: inventory.liveManagedSessionCount,
4259
+ liveNativeSessionCount: inventory.liveNativeSessionCount,
4260
+ unverifiedSessionCount: inventory.unverifiedSessionCount,
4261
+ deadManagedSessionCount: inventory.deadManagedSessionCount,
4262
+ deadNativeSessionCount: inventory.deadNativeSessionCount,
4263
+ limit: requestedLimit,
4264
+ totalCount: inventory.sessions.length,
4265
+ ...(nextOffset < inventory.sessions.length ? { nextCursor: Buffer.from(JSON.stringify({ offset: nextOffset }), 'utf8').toString('base64url') } : {}),
4266
+ sessions: page.map(({ session, liveness, evidence, cleanupError }) => ({
2325
4267
  sessionId: session.sessionId,
2326
4268
  url: session.url,
2327
4269
  title: session.title,
2328
4270
  createdAt: session.createdAt,
2329
4271
  updatedAt: session.updatedAt,
2330
4272
  browser: session.browser,
4273
+ liveness,
4274
+ livenessEvidence: evidence,
4275
+ ...(cleanupError ? { cleanupError } : {}),
2331
4276
  })),
2332
4277
  };
4278
+ }
4279
+ case 'reconcile_sessions': {
4280
+ assertBrowserSessionAvailable(input.repoRoot);
4281
+ const inventory = await inspectSavedSessions(input.repoRoot, current, { pruneDead: true });
4282
+ return {
4283
+ reconciled: true,
4284
+ sessionCountSemantics: 'saved_metadata',
4285
+ scannedSessionCount: inventory.scannedSessionCount,
4286
+ savedSessionCount: inventory.savedSessionCount,
4287
+ liveManagedSessionCount: inventory.liveManagedSessionCount,
4288
+ liveNativeSessionCount: inventory.liveNativeSessionCount,
4289
+ unverifiedSessionCount: inventory.unverifiedSessionCount,
4290
+ deadManagedSessionCount: inventory.deadManagedSessionCount,
4291
+ deadNativeSessionCount: inventory.deadNativeSessionCount,
4292
+ closedInvalidNativeSessionCount: inventory.closedInvalidNativeSessionCount,
4293
+ failedNativeCleanupCount: inventory.failedNativeCleanupCount,
4294
+ prunedSessionCount: inventory.prunedSessionCount,
4295
+ prunedSessionIds: inventory.sessions.filter((item) => item.pruned).map((item) => item.session.sessionId),
4296
+ };
4297
+ }
2333
4298
  case 'close_session':
2334
4299
  case 'close_page': {
2335
4300
  const sessionId = requiredString(input.args.session_id, 'session_id');
2336
4301
  assertBrowserSessionAvailable(input.repoRoot, sessionId);
2337
4302
  const session = findSession(input.repoRoot, sessionId);
2338
4303
  const tab = session?.browser?.tab;
2339
- if (session?.browser?.provider === 'macos-apple-events'
2340
- && session.browser.browserProduct
2341
- && tab?.ownership === 'plugin_owned'
2342
- && typeof tab.windowId === 'string'
2343
- && typeof tab.tabId === 'string') {
2344
- await closeMacOsBrowserOwnedTab(session.browser.browserProduct, { windowId: tab.windowId, tabId: tab.tabId }, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS)
2345
- .catch(() => undefined);
2346
- }
2347
- rmSync(sessionPath(input.repoRoot, sessionId), { force: true });
2348
- return { closed: true, sessionId, resourceClosed: tab?.ownership === 'plugin_owned' };
4304
+ const managedClosed = session ? await closeManagedSessionPage(input.repoRoot, current, session) : false;
4305
+ const nativeClose = session ? await closeTrackedNativeOwnedSession(session, current) : { resourceClosed: false, resourceAlreadyMissing: false };
4306
+ const closedSessionIds = session ? nativeOwnedAliasSessionIds(input.repoRoot, session) : [sessionId];
4307
+ for (const closedSessionId of closedSessionIds) removeSession(input.repoRoot, closedSessionId);
4308
+ return {
4309
+ closed: true, sessionId,
4310
+ resourceClosed: managedClosed || nativeClose.resourceClosed,
4311
+ ...(closedSessionIds.length > 1 ? { closedSessionIds } : {}),
4312
+ ...(nativeClose.resourceAlreadyMissing ? { resourceAlreadyMissing: true } : {}),
4313
+ ...(tab?.ownership === 'user_owned' ? { preservedUserOwnedTab: true } : {}),
4314
+ };
2349
4315
  }
2350
4316
  case 'clear_session': {
2351
4317
  assertBrowserSessionAvailable(input.repoRoot);
2352
4318
  const sessions = listSavedSessions(input.repoRoot);
4319
+ await closeManagedContextsForRepo(input.repoRoot, { strict: true });
4320
+ const failedSessionIds: string[] = [];
4321
+ const cleanupErrors: Array<{ sessionId: string; error: string }> = [];
4322
+ let removedCount = 0;
4323
+ let resourceClosedCount = 0;
4324
+ let resourceAlreadyMissingCount = 0;
2353
4325
  for (const session of sessions) {
2354
- const tab = session.browser?.tab;
2355
- if (session.browser?.provider === 'macos-apple-events'
2356
- && session.browser.browserProduct
2357
- && tab?.ownership === 'plugin_owned'
2358
- && typeof tab.windowId === 'string'
2359
- && typeof tab.tabId === 'string') {
2360
- await closeMacOsBrowserOwnedTab(session.browser.browserProduct, { windowId: tab.windowId, tabId: tab.tabId }, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS)
2361
- .catch(() => undefined);
4326
+ try {
4327
+ const nativeClose = await closeTrackedNativeOwnedSession(session, current);
4328
+ if (nativeClose.resourceClosed) resourceClosedCount += 1;
4329
+ if (nativeClose.resourceAlreadyMissing) resourceAlreadyMissingCount += 1;
4330
+ removeSession(input.repoRoot, session.sessionId);
4331
+ removedCount += 1;
4332
+ } catch (error) {
4333
+ failedSessionIds.push(session.sessionId);
4334
+ cleanupErrors.push({ sessionId: session.sessionId, error: error instanceof Error ? error.message : String(error) });
2362
4335
  }
2363
- rmSync(sessionPath(input.repoRoot, session.sessionId), { force: true });
2364
4336
  }
2365
- return { cleared: true, count: sessions.length };
4337
+ return {
4338
+ cleared: failedSessionIds.length === 0,
4339
+ count: removedCount,
4340
+ requestedCount: sessions.length,
4341
+ resourceClosedCount,
4342
+ resourceAlreadyMissingCount,
4343
+ failedSessionIds,
4344
+ cleanupErrors,
4345
+ };
2366
4346
  }
2367
4347
  case 'request_human_handoff': {
2368
4348
  const target = resolveActionTarget(input.repoRoot, input.args);
2369
- assertUrlAllowed(target.url, current);
4349
+ const managedSession = target.existingSession;
4350
+ const managedTarget = managedSession ? await runtimeManagedPageForSession(input.repoRoot, current, managedSession) : undefined;
4351
+ if (managedSession && managedTarget) {
4352
+ const result = { url: managedTarget.page.url(), title: await managedTarget.page.title().catch(() => managedSession.title) };
4353
+ const handoff = startRuntimeManagedBrowserHandoff({
4354
+ repoRoot: input.repoRoot,
4355
+ repoId: input.repoId,
4356
+ requestId: input.requestId,
4357
+ jobId: input.jobId,
4358
+ sessionId: target.sessionId,
4359
+ sessionPath: sessionPath(input.repoRoot, target.sessionId),
4360
+ url: target.url,
4361
+ profileDir: managedTarget.profile.profileDir,
4362
+ selectedProfilePath: managedTarget.profile.selectedProfilePath,
4363
+ profileDirectory: managedTarget.profile.profileDirectory,
4364
+ browserChannel: current.browserChannel,
4365
+ executablePath: current.executablePath ? resolveConfiguredPath(input.repoRoot, current.executablePath) : undefined,
4366
+ defaultTimeoutMs: current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS,
4367
+ reason: stringValue(input.args.reason) ?? 'manual_review',
4368
+ instructions: stringValue(input.args.instructions),
4369
+ timeoutMs: typeof input.args.handoff_timeout_ms === 'number' ? input.args.handoff_timeout_ms : undefined,
4370
+ }, result);
4371
+ try {
4372
+ if (managedTarget.page.bringToFront) await managedTarget.page.bringToFront();
4373
+ } catch (error) {
4374
+ resolveRuntimeManagedBrowserHandoff(input.repoRoot, handoff.interactionId, 'cancel', result);
4375
+ throw error;
4376
+ }
4377
+ return {
4378
+ provider: 'playwright-runtime-managed-handoff',
4379
+ handoff,
4380
+ session: managedSession,
4381
+ nextAction: 'Complete the manual step, then call resolve_handoff with resolution=resume. Its response contains the updated handoff state; call get_handoff_status only when a later decision needs an observation, not as periodic polling.',
4382
+ };
4383
+ }
2370
4384
  const nativeSession = target.existingSession;
2371
4385
  const nativeTab = nativeSession?.browser?.tab;
2372
4386
  const nativeBrowser = nativeSession?.browser?.provider === 'macos-apple-events'
@@ -2398,7 +4412,6 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2398
4412
  profileDirectory: profile.profileDirectory,
2399
4413
  browserChannel: current.browserChannel,
2400
4414
  executablePath: current.executablePath ? resolveConfiguredPath(input.repoRoot, current.executablePath) : undefined,
2401
- allowedDomains: current.allowedDomains,
2402
4415
  defaultTimeoutMs: current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS,
2403
4416
  nativeBrowser,
2404
4417
  reason: stringValue(input.args.reason) ?? 'manual_review',
@@ -2409,14 +4422,18 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2409
4422
  provider: nativeBrowser ? 'macos-apple-events-handoff-host' : 'playwright-handoff-host',
2410
4423
  handoff,
2411
4424
  session: target.existingSession,
2412
- nextAction: 'Complete the manual step, call resolve_handoff with resolution=resume, then poll get_handoff_status.',
4425
+ nextAction: 'Complete the manual step, then call resolve_handoff with resolution=resume. Its response contains the updated handoff state; call get_handoff_status only when a later decision needs an observation, not as periodic polling.',
2413
4426
  };
2414
4427
  }
2415
4428
  case 'get_handoff_status': {
2416
4429
  const interactionId = requiredString(input.args.interaction_id, 'interaction_id');
2417
4430
  const handoff = getBrowserHandoff(input.repoRoot, interactionId);
2418
4431
  return {
2419
- provider: handoff.targetId.startsWith('macos-apple-events:') ? 'macos-apple-events-handoff-host' : 'playwright-handoff-host',
4432
+ provider: isRuntimeManagedBrowserHandoff(handoff)
4433
+ ? 'playwright-runtime-managed-handoff'
4434
+ : handoff.targetId.startsWith('macos-apple-events:')
4435
+ ? 'macos-apple-events-handoff-host'
4436
+ : 'playwright-handoff-host',
2420
4437
  handoff,
2421
4438
  };
2422
4439
  }
@@ -2426,6 +4443,28 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2426
4443
  if (resolution !== 'resume' && resolution !== 'cancel') {
2427
4444
  throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'resolution must be resume or cancel', { retryable: false });
2428
4445
  }
4446
+ const currentHandoff = getBrowserHandoff(input.repoRoot, interactionId);
4447
+ if (isRuntimeManagedBrowserHandoff(currentHandoff)) {
4448
+ const session = findSession(input.repoRoot, currentHandoff.sessionId);
4449
+ const managedTarget = session ? await runtimeManagedPageForSession(input.repoRoot, current, session) : undefined;
4450
+ const result = managedTarget
4451
+ ? { url: managedTarget.page.url(), title: await managedTarget.page.title().catch(() => session?.title) }
4452
+ : currentHandoff.result;
4453
+ if (resolution === 'resume' && session && result?.url) {
4454
+ saveSession(input.repoRoot, {
4455
+ ...session,
4456
+ url: result.url,
4457
+ title: result.title,
4458
+ updatedAt: new Date().toISOString(),
4459
+ });
4460
+ }
4461
+ const handoff = resolveRuntimeManagedBrowserHandoff(input.repoRoot, interactionId, resolution, result);
4462
+ return {
4463
+ provider: 'playwright-runtime-managed-handoff',
4464
+ resolutionRequested: resolution,
4465
+ handoff,
4466
+ };
4467
+ }
2429
4468
  const handoff = resolution === 'resume'
2430
4469
  ? resumeBrowserHandoff(input.repoRoot, interactionId, input.requestId)
2431
4470
  : cancelBrowserHandoff(input.repoRoot, interactionId, input.requestId);
@@ -2438,17 +4477,152 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2438
4477
  case 'create_session':
2439
4478
  case 'open_page':
2440
4479
  case 'navigate': {
2441
- const url = normalizedUrl(input.args.url);
4480
+ const nativeProduct = stringValue(input.args.native_browser_product);
4481
+ const normalizedNativeProduct: MacOsBrowserProduct | undefined = nativeProduct === 'chrome' || nativeProduct === 'vivaldi' ? nativeProduct : undefined;
4482
+ const nativeWindowId = stringValue(input.args.native_window_id);
4483
+ const nativeTabId = stringValue(input.args.native_tab_id);
4484
+ const nativeActiveTab = input.args.native_active_tab === true;
4485
+ const hasNativeAdoption = Boolean(nativeProduct || nativeWindowId || nativeTabId || nativeActiveTab);
4486
+ const url = hasNativeAdoption && input.actionId === 'create_session'
4487
+ ? normalizedExplicitNativeAdoptionUrl(input.args.url)
4488
+ : normalizedUrl(input.args.url);
2442
4489
  const existingSessionId = stringValue(input.args.session_id);
2443
4490
  const target: BrowserActionTarget = existingSessionId
2444
4491
  ? { sessionId: existingSessionId, url, existingSession: findSession(input.repoRoot, existingSessionId) }
2445
4492
  : { sessionId: sessionIdFor(url), url };
4493
+ if (hasNativeAdoption) {
4494
+ if (input.actionId !== 'create_session' || (nativeProduct && !normalizedNativeProduct)) {
4495
+ throw new AssistantPluginError(
4496
+ 'PLUGIN_ACTION_ARGUMENT_INVALID',
4497
+ 'Explicit native tab adoption is only supported by create_session for Chrome or Vivaldi.',
4498
+ { retryable: false },
4499
+ );
4500
+ }
4501
+ const timeout = positiveNumber(input.args.timeout_ms, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS);
4502
+ let adoptedProduct: MacOsBrowserProduct;
4503
+ let adoptedRef: MacOsBrowserTabRef;
4504
+ let adoptedSystemFrontmost = false;
4505
+ if (nativeActiveTab) {
4506
+ if (nativeWindowId || nativeTabId) {
4507
+ throw new AssistantPluginError(
4508
+ 'PLUGIN_ACTION_ARGUMENT_INVALID',
4509
+ 'native_active_tab cannot be combined with native_window_id or native_tab_id.',
4510
+ { retryable: false },
4511
+ );
4512
+ }
4513
+ const configuredCandidates = (current.nativeBrowserCandidates ?? ['vivaldi', 'chrome'])
4514
+ .filter((candidate): candidate is MacOsBrowserProduct => candidate === 'chrome' || candidate === 'vivaldi');
4515
+ const candidates: MacOsBrowserProduct[] = normalizedNativeProduct ? [normalizedNativeProduct] : configuredCandidates;
4516
+ const discovered = await discoverMacOsBrowserAttachment(candidates, Math.min(timeout, MAX_CDP_DISCOVERY_TIMEOUT_MS));
4517
+ const activeMetadata = discovered.attachment?.metadata;
4518
+ const requiresSystemFrontmost = !normalizedNativeProduct;
4519
+ if (!activeMetadata || (requiresSystemFrontmost && (!activeMetadata.frontmost || activeMetadata.active !== true))) {
4520
+ throw new AssistantPluginError(
4521
+ 'PLUGIN_BROWSER_ACTIVE_TAB_UNAVAILABLE',
4522
+ requiresSystemFrontmost
4523
+ ? 'No system-frontmost active native browser tab with a stable native identity is available for adoption.'
4524
+ : 'The explicitly selected native browser does not expose an active tab with a stable native identity.',
4525
+ { retryable: true, details: { browserProduct: nativeProduct, requiresSystemFrontmost, attempts: discovered.attempts } },
4526
+ );
4527
+ }
4528
+ adoptedSystemFrontmost = activeMetadata.frontmost === true && activeMetadata.active === true;
4529
+ adoptedProduct = activeMetadata.product;
4530
+ if (activeMetadata.windowId && activeMetadata.tabId) {
4531
+ adoptedRef = { windowId: activeMetadata.windowId, tabId: activeMetadata.tabId };
4532
+ } else {
4533
+ const inventory = await listMacOsBrowserTabs(adoptedProduct, Math.min(timeout, MAX_CDP_DISCOVERY_TIMEOUT_MS));
4534
+ const matchingActiveTabs = inventory.tabs.filter((candidate) => candidate.active
4535
+ && comparableUrl(candidate.url) === comparableUrl(activeMetadata.url)
4536
+ && (!activeMetadata.windowId || candidate.windowId === activeMetadata.windowId)
4537
+ && (!activeMetadata.tabId || candidate.tabId === activeMetadata.tabId));
4538
+ if (matchingActiveTabs.length !== 1) {
4539
+ throw new AssistantPluginError(
4540
+ 'PLUGIN_BROWSER_ACTIVE_TAB_UNAVAILABLE',
4541
+ 'The explicitly selected native browser active tab metadata lacks a stable identity and tab inventory did not resolve exactly one matching active tab.',
4542
+ {
4543
+ retryable: true,
4544
+ details: {
4545
+ browserProduct: adoptedProduct,
4546
+ requiresSystemFrontmost,
4547
+ observedUrl: activeMetadata.url,
4548
+ observedTitle: activeMetadata.title,
4549
+ observedWindowId: activeMetadata.windowId,
4550
+ observedTabId: activeMetadata.tabId,
4551
+ matchingActiveTabCount: matchingActiveTabs.length,
4552
+ inventoryTruncated: inventory.truncated,
4553
+ attempts: discovered.attempts,
4554
+ },
4555
+ },
4556
+ );
4557
+ }
4558
+ adoptedRef = { windowId: matchingActiveTabs[0]!.windowId, tabId: matchingActiveTabs[0]!.tabId };
4559
+ }
4560
+ } else {
4561
+ if (!normalizedNativeProduct || !nativeWindowId || !nativeTabId) {
4562
+ throw new AssistantPluginError(
4563
+ 'PLUGIN_ACTION_ARGUMENT_INVALID',
4564
+ 'Explicit native tab adoption requires native_active_tab=true or native_browser_product with native_window_id and native_tab_id.',
4565
+ { retryable: false },
4566
+ );
4567
+ }
4568
+ adoptedProduct = normalizedNativeProduct;
4569
+ adoptedRef = { windowId: nativeWindowId, tabId: nativeTabId };
4570
+ }
4571
+ const adopted = await reattachMacOsBrowserOwnedPage(adoptedProduct, adoptedRef, timeout);
4572
+ const metadata = adopted.attachment.metadata;
4573
+ const pageUrl = normalizedExplicitNativeAdoptionUrl(metadata.url);
4574
+ const requestedUrl = normalizedExplicitNativeAdoptionUrl(url);
4575
+ const urlsMatch = explicitNativeAdoptionUrlsMatch(requestedUrl, pageUrl, adoptedProduct);
4576
+ const pageProtocol = new URL(pageUrl).protocol;
4577
+ if (((pageProtocol === 'chrome:' && adoptedProduct !== 'chrome') || (pageProtocol === 'vivaldi:' && adoptedProduct !== 'vivaldi')) && !urlsMatch) {
4578
+ throw new AssistantPluginError(
4579
+ 'PLUGIN_BROWSER_ADOPTED_TAB_URL_MISMATCH',
4580
+ 'Explicitly adopted native tab uses an internal URL scheme that does not match the selected browser.',
4581
+ { retryable: false, details: { actualUrl: pageUrl, browserProduct: adoptedProduct, windowId: adoptedRef.windowId, tabId: adoptedRef.tabId } },
4582
+ );
4583
+ }
4584
+ if (!urlsMatch) {
4585
+ throw new AssistantPluginError(
4586
+ 'PLUGIN_BROWSER_ADOPTED_TAB_URL_MISMATCH',
4587
+ 'Explicitly adopted native tab URL does not match the requested session URL.',
4588
+ { retryable: false, details: { requestedUrl, actualUrl: pageUrl, browserProduct: adoptedProduct, windowId: adoptedRef.windowId, tabId: adoptedRef.tabId } },
4589
+ );
4590
+ }
4591
+ const title = metadata.title || '';
4592
+ const connection = refreshConnectionTab({
4593
+ requestedMode: 'attach_preferred',
4594
+ mode: 'attach_preferred',
4595
+ provider: 'macos-apple-events',
4596
+ attached: true,
4597
+ browserProduct: adoptedProduct,
4598
+ profile: { selectedProfilePath: `macos:${adoptedProduct}:user-tab` },
4599
+ tabInventory: [{ index: 0, key: tabKey(requestedUrl, title), url: requestedUrl, title }],
4600
+ sessionResume: {
4601
+ sessionId: target.sessionId,
4602
+ status: 'matched',
4603
+ reason: nativeActiveTab && adoptedSystemFrontmost
4604
+ ? 'Explicitly adopted the matching system-frontmost active user-owned native browser tab.'
4605
+ : nativeActiveTab
4606
+ ? 'Explicitly adopted the selected browser active tab; system-frontmost authority was not established.'
4607
+ : 'Explicitly adopted an existing user-owned native browser tab.',
4608
+ },
4609
+ }, requestedUrl, title, 'saved_url', { ownership: 'user_owned', windowId: adoptedRef.windowId, tabId: adoptedRef.tabId });
4610
+ const session = saveSession(input.repoRoot, sessionFromPage(target, requestedUrl, title, connection));
4611
+ return {
4612
+ provider: 'macos-apple-events',
4613
+ session,
4614
+ navigation: { url: requestedUrl },
4615
+ browserConnection: { ...connection, tab: session.browser?.tab, sessionResume: session.browser?.sessionResume },
4616
+ ...(input.args.extract_text === true
4617
+ ? { text: await extractText(adopted.page as unknown as PageLike, undefined, positiveNumber(input.args.max_chars, DEFAULT_MAX_TEXT_CHARS)) }
4618
+ : {}),
4619
+ };
4620
+ }
2446
4621
  return await withPage(input.repoRoot, current, target, input.args, async (page, diagnostics, connection) => {
2447
- const identity = await readPageIdentity(page, connection);
2448
- const session = sessionFromPage(target, identity.url, identity.title, connection);
2449
- saveSession(input.repoRoot, session);
4622
+ const identity = await readPageIdentity(page, connection, target.url);
4623
+ const session = saveSession(input.repoRoot, sessionFromPage(target, identity.url, identity.title, connection));
2450
4624
  return {
2451
- provider: 'playwright',
4625
+ provider: browserResultProvider(connection.provider),
2452
4626
  session,
2453
4627
  navigation: diagnostics.navigation,
2454
4628
  browserConnection: {
@@ -2460,7 +4634,7 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2460
4634
  ? { text: await extractText(page, undefined, positiveNumber(input.args.max_chars, DEFAULT_MAX_TEXT_CHARS)) }
2461
4635
  : {}),
2462
4636
  };
2463
- });
4637
+ }, { requireExistingResource: Boolean(existingSessionId) });
2464
4638
  }
2465
4639
  case 'reload':
2466
4640
  case 'go_back':
@@ -2478,10 +4652,9 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2478
4652
  });
2479
4653
  }
2480
4654
  const identity = await readPageIdentity(page, connection);
2481
- const session = sessionFromPage(target, identity.url, identity.title, connection);
2482
- saveSession(input.repoRoot, session);
4655
+ const session = saveSession(input.repoRoot, sessionFromPage(target, identity.url, identity.title, connection));
2483
4656
  return {
2484
- provider: 'playwright',
4657
+ provider: browserResultProvider(connection.provider),
2485
4658
  session,
2486
4659
  navigation: diagnostics.navigation,
2487
4660
  actionId: input.actionId,
@@ -2491,28 +4664,34 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2491
4664
  sessionResume: session.browser?.sessionResume,
2492
4665
  },
2493
4666
  };
2494
- });
4667
+ }, input.actionId === 'reload'
4668
+ ? { requireExistingResource: true, pruneStaleSessionMetadata: true }
4669
+ : {});
2495
4670
  }
2496
4671
  case 'get_text': {
2497
4672
  const target = resolveActionTarget(input.repoRoot, input.args);
2498
- return {
2499
- provider: 'playwright',
2500
- sessionId: target.sessionId,
2501
- url: target.url,
2502
- ...(await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => ({
2503
- ...(await extractText(page, stringValue(input.args.selector), positiveNumber(input.args.max_chars, DEFAULT_MAX_TEXT_CHARS))),
4673
+ return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4674
+ const selection = resolveBrowserEvaluationScope(page, input.args, connection);
4675
+ return {
4676
+ provider: browserResultProvider(connection.provider),
4677
+ sessionId: target.sessionId,
4678
+ url: target.url,
4679
+ ...(await extractText(selection.scope, stringValue(input.args.selector), positiveNumber(input.args.max_chars, DEFAULT_MAX_TEXT_CHARS))),
4680
+ ...(selection.frame ? { frame: selection.frame } : {}),
2504
4681
  browserConnection: connection,
2505
- }), { persistSession: true })),
2506
- };
4682
+ };
4683
+ }, { persistSession: true });
2507
4684
  }
2508
4685
  case 'get_html': {
2509
4686
  const target = resolveActionTarget(input.repoRoot, input.args);
2510
4687
  return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
2511
- const raw = await page.evaluate<string>(EXTRACTION_SCRIPTS.html(stringValue(input.args.selector)));
4688
+ const selection = resolveBrowserEvaluationScope(page, input.args, connection);
4689
+ const raw = await selection.scope.evaluate<string>(EXTRACTION_SCRIPTS.html(stringValue(input.args.selector)));
2512
4690
  return {
2513
- provider: 'playwright',
4691
+ provider: browserResultProvider(connection.provider),
2514
4692
  sessionId: target.sessionId,
2515
4693
  url: target.url,
4694
+ ...(selection.frame ? { frame: selection.frame } : {}),
2516
4695
  browserConnection: connection,
2517
4696
  ...truncateText(raw, positiveNumber(input.args.max_chars, DEFAULT_MAX_TEXT_CHARS)),
2518
4697
  };
@@ -2524,12 +4703,14 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2524
4703
  const selector = requiredString(input.args.selector, 'selector');
2525
4704
  const limit = Math.min(positiveNumber(input.args.limit, 25), 100);
2526
4705
  return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
2527
- const matches = await page.evaluate<Array<Record<string, unknown>>>(EXTRACTION_SCRIPTS.query(selector, input.actionId === 'query_selector' ? 1 : limit));
4706
+ const selection = resolveBrowserEvaluationScope(page, input.args, connection);
4707
+ const matches = await selection.scope.evaluate<Array<Record<string, unknown>>>(EXTRACTION_SCRIPTS.query(selector, input.actionId === 'query_selector' ? 1 : limit));
2528
4708
  return {
2529
- provider: 'playwright',
4709
+ provider: browserResultProvider(connection.provider),
2530
4710
  sessionId: target.sessionId,
2531
4711
  url: target.url,
2532
4712
  selector,
4713
+ ...(selection.frame ? { frame: selection.frame } : {}),
2533
4714
  browserConnection: connection,
2534
4715
  ...(input.actionId === 'query_selector'
2535
4716
  ? { match: matches[0] ?? null, found: matches.length > 0 }
@@ -2541,15 +4722,95 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2541
4722
  const target = resolveActionTarget(input.repoRoot, input.args);
2542
4723
  const selector = requiredString(input.args.selector, 'selector');
2543
4724
  const attribute = requiredString(input.args.attribute, 'attribute');
2544
- return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => ({
2545
- provider: 'playwright',
2546
- sessionId: target.sessionId,
2547
- url: target.url,
2548
- selector,
2549
- attribute,
2550
- browserConnection: connection,
2551
- value: await page.evaluate<string | null>(EXTRACTION_SCRIPTS.attribute(selector, attribute)),
2552
- }), { persistSession: true });
4725
+ return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4726
+ const selection = resolveBrowserEvaluationScope(page, input.args, connection);
4727
+ return {
4728
+ provider: browserResultProvider(connection.provider),
4729
+ sessionId: target.sessionId,
4730
+ url: target.url,
4731
+ selector,
4732
+ attribute,
4733
+ ...(selection.frame ? { frame: selection.frame } : {}),
4734
+ browserConnection: connection,
4735
+ value: await selection.scope.evaluate<string | null>(EXTRACTION_SCRIPTS.attribute(selector, attribute)),
4736
+ };
4737
+ }, { persistSession: true });
4738
+ }
4739
+ case 'list_frames': {
4740
+ const target = resolveActionTarget(input.repoRoot, input.args);
4741
+ const limit = Math.min(positiveNumber(input.args.limit, 50), 100);
4742
+ return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4743
+ if (connection.provider === 'macos-apple-events' || !page.frames) {
4744
+ throw new AssistantPluginError('PLUGIN_BROWSER_FRAME_SCOPE_UNAVAILABLE', 'Frame discovery requires a Playwright/CDP-controlled page; native Apple Events sessions fail closed.', {
4745
+ retryable: false,
4746
+ details: { provider: connection.provider },
4747
+ });
4748
+ }
4749
+ const allFrames = page.frames();
4750
+ const frames = allFrames.slice(0, limit).map((frame) => ({ url: frame.url(), name: frame.name() }));
4751
+ return {
4752
+ provider: browserResultProvider(connection.provider),
4753
+ sessionId: target.sessionId,
4754
+ url: target.url,
4755
+ frames,
4756
+ count: allFrames.length,
4757
+ truncated: allFrames.length > frames.length,
4758
+ browserConnection: connection,
4759
+ };
4760
+ }, { persistSession: true });
4761
+ }
4762
+ case 'verify_state': {
4763
+ requiredString(input.args.session_id, 'session_id');
4764
+ const target = resolveActionTarget(input.repoRoot, input.args);
4765
+ const expectedUrlRaw = stringValue(input.args.expected_url);
4766
+ const expectedUrl = expectedUrlRaw ? normalizedUrl(expectedUrlRaw) : undefined;
4767
+ const urlContains = stringValue(input.args.url_contains);
4768
+ const selector = stringValue(input.args.selector);
4769
+ const requireVisible = input.args.require_visible === true;
4770
+ const textContains = stringValue(input.args.text_contains);
4771
+ if (!expectedUrl && !urlContains && !selector && !textContains) {
4772
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'verify_state requires at least one of expected_url, url_contains, selector, or text_contains.', { retryable: false });
4773
+ }
4774
+ if (requireVisible && !selector) {
4775
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'require_visible requires selector.', { retryable: false });
4776
+ }
4777
+ return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4778
+ const selection = resolveBrowserEvaluationScope(page, input.args, connection);
4779
+ const observedUrl = selection.frame?.url ?? page.url();
4780
+ const checks: Array<Record<string, unknown>> = [];
4781
+ if (expectedUrl) checks.push({ criterion: 'url_exact', expected: expectedUrl, observed: observedUrl, matched: observedUrl === expectedUrl });
4782
+ if (urlContains) checks.push({ criterion: 'url_contains', expected: urlContains, observed: observedUrl, matched: observedUrl.includes(urlContains) });
4783
+ let observation: { verificationVersion?: unknown; selectorExists?: unknown; visible?: unknown; textContains?: unknown; textSample?: unknown; textLength?: unknown; truncated?: unknown } | undefined;
4784
+ if (selector || textContains) {
4785
+ observation = await selection.scope.evaluate(verifyStateObservationScript(selector, textContains, Math.min(positiveNumber(input.args.max_chars, 2_000), 100_000)));
4786
+ if (observation?.verificationVersion !== 1) {
4787
+ throw new AssistantPluginError('PLUGIN_BROWSER_VERIFY_STATE_UNAVAILABLE', 'Browser state observation returned an unsupported result.', { retryable: true });
4788
+ }
4789
+ }
4790
+ if (selector) {
4791
+ checks.push({ criterion: 'selector_exists', expected: true, observed: observation?.selectorExists === true, matched: observation?.selectorExists === true, selector });
4792
+ if (requireVisible) {
4793
+ checks.push({ criterion: 'selector_visible', expected: true, observed: observation?.visible === true, matched: observation?.visible === true, selector });
4794
+ }
4795
+ }
4796
+ if (textContains) {
4797
+ checks.push({
4798
+ criterion: 'text_contains', expected: textContains, observed: observation?.textSample,
4799
+ matched: observation?.textContains === true, textLength: observation?.textLength, truncated: observation?.truncated,
4800
+ ...(selector ? { selector } : {}),
4801
+ });
4802
+ }
4803
+ return {
4804
+ provider: browserResultProvider(connection.provider),
4805
+ sessionId: target.sessionId,
4806
+ url: target.url,
4807
+ observedUrl,
4808
+ ...(selection.frame ? { frame: selection.frame } : {}),
4809
+ matched: checks.every((check) => check.matched === true),
4810
+ checks,
4811
+ browserConnection: connection,
4812
+ };
4813
+ }, { requireExistingResource: true });
2553
4814
  }
2554
4815
  case 'screenshot': {
2555
4816
  const target = resolveActionTarget(input.repoRoot, input.args);
@@ -2573,7 +4834,7 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2573
4834
  browserConnection: connection,
2574
4835
  };
2575
4836
  });
2576
- return { provider: 'playwright', screenshot };
4837
+ return { provider: browserResultProvider(screenshot.browserConnection.provider), screenshot };
2577
4838
  }
2578
4839
  case 'extract_links':
2579
4840
  case 'extract_tables':
@@ -2589,13 +4850,39 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2589
4850
  : input.actionId === 'extract_forms'
2590
4851
  ? EXTRACTION_SCRIPTS.forms(limit)
2591
4852
  : EXTRACTION_SCRIPTS.interactive(limit);
2592
- const data = await page.evaluate<unknown>(script);
4853
+ const selection = input.actionId === 'snapshot_interactive'
4854
+ ? resolveBrowserEvaluationScope(page, input.args, connection)
4855
+ : { scope: page } as BrowserFrameSelection;
4856
+ const evaluated = await selection.scope.evaluate<unknown>(script);
4857
+ const grounded = input.actionId === 'snapshot_interactive' && evaluated && typeof evaluated === 'object' && !Array.isArray(evaluated)
4858
+ ? evaluated as { geometryVersion?: unknown; viewport?: unknown; elements?: unknown }
4859
+ : undefined;
4860
+ let data = grounded && Array.isArray(grounded.elements) ? grounded.elements : evaluated;
4861
+ let viewport = grounded?.viewport;
4862
+ let frameViewport: unknown;
4863
+ let frameOffset: { x: number; y: number; width: number; height: number } | undefined;
4864
+ if (grounded?.geometryVersion === 1 && selection.frame && Array.isArray(grounded.elements)) {
4865
+ frameOffset = await resolveFrameViewportOffset(page, selection);
4866
+ if (!frameOffset) {
4867
+ throw new AssistantPluginError('PLUGIN_BROWSER_FRAME_GEOMETRY_UNAVAILABLE', 'Frame geometry offset was not available.', { retryable: true });
4868
+ }
4869
+ const topMetrics = await page.evaluate<{ viewportMetricsVersion?: unknown; viewport?: unknown }>(VIEWPORT_METRICS_SCRIPT);
4870
+ if (topMetrics?.viewportMetricsVersion !== 1 || !topMetrics.viewport) {
4871
+ throw new AssistantPluginError('PLUGIN_BROWSER_FRAME_GEOMETRY_UNAVAILABLE', 'Top-level viewport metrics were not available for frame grounding.', { retryable: true });
4872
+ }
4873
+ frameViewport = grounded.viewport;
4874
+ viewport = topMetrics.viewport;
4875
+ data = translateFrameGroundingElements(grounded.elements, frameOffset, topMetrics.viewport);
4876
+ }
2593
4877
  return {
2594
- provider: 'playwright',
4878
+ provider: browserResultProvider(connection.provider),
2595
4879
  sessionId: target.sessionId,
2596
4880
  url: target.url,
2597
4881
  actionId: input.actionId,
2598
4882
  data,
4883
+ ...(selection.frame ? { frame: selection.frame } : {}),
4884
+ ...(grounded?.geometryVersion === 1 ? { geometryVersion: 1, viewport } : {}),
4885
+ ...(selection.frame && frameOffset ? { coordinateSpace: 'main_viewport_css', frameViewport, frameOffset } : {}),
2599
4886
  browserConnection: connection,
2600
4887
  };
2601
4888
  }, { persistSession: true });
@@ -2603,16 +4890,83 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2603
4890
  case 'get_console_errors':
2604
4891
  case 'get_failed_requests': {
2605
4892
  const target = resolveActionTarget(input.repoRoot, input.args);
2606
- return await withPage(input.repoRoot, current, target, input.args, async (_page, diagnostics, connection) => ({
2607
- provider: 'playwright',
2608
- sessionId: target.sessionId,
2609
- url: target.url,
2610
- navigation: diagnostics.navigation,
2611
- browserConnection: connection,
2612
- ...(input.actionId === 'get_console_errors'
2613
- ? { consoleErrors: diagnostics.consoleErrors.slice(0, 50) }
2614
- : { failedRequests: diagnostics.failedRequests.slice(0, 50) }),
2615
- }), { persistSession: true });
4893
+ return await withPage(input.repoRoot, current, target, input.args, async (_page, diagnostics, connection) => {
4894
+ if (connection.provider === 'macos-apple-events') {
4895
+ throw new AssistantPluginError(
4896
+ 'PLUGIN_BROWSER_DIAGNOSTICS_UNAVAILABLE',
4897
+ 'Console and failed-request diagnostics require a Playwright/CDP-controlled page; native Apple Events sessions do not expose browser diagnostic events.',
4898
+ { retryable: false, details: { provider: connection.provider, actionId: input.actionId, sessionId: target.sessionId } },
4899
+ );
4900
+ }
4901
+ return {
4902
+ provider: browserResultProvider(connection.provider),
4903
+ sessionId: target.sessionId,
4904
+ url: target.url,
4905
+ navigation: diagnostics.navigation,
4906
+ browserConnection: connection,
4907
+ ...(input.actionId === 'get_console_errors'
4908
+ ? { consoleErrors: diagnostics.consoleErrors.slice(0, 50) }
4909
+ : { failedRequests: diagnostics.failedRequests.slice(0, 50) }),
4910
+ };
4911
+ }, { persistSession: true });
4912
+ }
4913
+ case 'activate_page': {
4914
+ const target = resolveActionTarget(input.repoRoot, input.args);
4915
+ return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4916
+ const waitMs = positiveNumber(input.args.post_action_wait_ms, DEFAULT_POST_ACTION_WAIT_MS);
4917
+ if (connection.provider === 'macos-apple-events') {
4918
+ await establishAuthoritativeNativeForeground(input, page, connection, target, waitMs);
4919
+ } else {
4920
+ if (!page.bringToFront) {
4921
+ throw new AssistantPluginError('PLUGIN_BROWSER_ACTIVATION_UNSUPPORTED', 'The selected browser provider cannot bring the saved page to the foreground.', { retryable: false });
4922
+ }
4923
+ await page.bringToFront();
4924
+ await delay(waitMs);
4925
+ }
4926
+ return finalizeInteractiveAction(input.repoRoot, current, page, target, connection, 'activate_page', 'Activated the exact saved browser page in authoritative foreground state.', {});
4927
+ });
4928
+ }
4929
+ case 'click_text': {
4930
+ const target = resolveActionTarget(input.repoRoot, input.args);
4931
+ const text = requiredString(input.args.text, 'text');
4932
+ if (text.length > 200) {
4933
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'text must be 200 characters or fewer.', { retryable: false });
4934
+ }
4935
+ return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4936
+ const clicked = await page.evaluate((payload) => {
4937
+ const args = payload as { text: string };
4938
+ const normalize = (value: string) => value.replace(/\s+/g, ' ').trim();
4939
+ const roots: Array<Document | ShadowRoot> = [document];
4940
+ const candidates: HTMLElement[] = [];
4941
+ for (let index = 0; index < roots.length; index += 1) {
4942
+ const root = roots[index];
4943
+ candidates.push(...Array.from(root.querySelectorAll<HTMLElement>('button,[role=button],a,div,span')));
4944
+ for (const host of Array.from(root.querySelectorAll<HTMLElement>('*'))) {
4945
+ if (host.shadowRoot) roots.push(host.shadowRoot);
4946
+ }
4947
+ }
4948
+ const matches = candidates
4949
+ .filter((element) => normalize(element.innerText || element.textContent || '') === args.text)
4950
+ .filter((element) => {
4951
+ const style = getComputedStyle(element);
4952
+ const rect = element.getBoundingClientRect();
4953
+ return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
4954
+ })
4955
+ .sort((left, right) => {
4956
+ const childDelta = left.children.length - right.children.length;
4957
+ if (childDelta !== 0) return childDelta;
4958
+ const leftRect = left.getBoundingClientRect();
4959
+ const rightRect = right.getBoundingClientRect();
4960
+ return (leftRect.width * leftRect.height) - (rightRect.width * rightRect.height);
4961
+ });
4962
+ const element = matches[0];
4963
+ if (!element) throw new Error(`visible exact text not found: ${args.text}`);
4964
+ element.click();
4965
+ return { tag: element.tagName.toLowerCase(), className: element.className || '', text: normalize(element.innerText || element.textContent || '') };
4966
+ }, { text });
4967
+ await delay(positiveNumber(input.args.post_action_wait_ms, DEFAULT_POST_ACTION_WAIT_MS));
4968
+ return finalizeInteractiveAction(input.repoRoot, current, page, target, connection, 'click_text', `Clicked exact visible text ${text}.`, { text, clicked });
4969
+ });
2616
4970
  }
2617
4971
  case 'click':
2618
4972
  case 'double_click':
@@ -2625,7 +4979,22 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2625
4979
  const timeoutMs = positiveNumber(input.args.timeout_ms, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS);
2626
4980
  try {
2627
4981
  return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
2628
- if (input.actionId === 'click') await page.click(selector, { timeout: timeoutMs });
4982
+ if (input.actionId === 'click' && openShadowSelectorParts(selector).length > 1) {
4983
+ const parts = openShadowSelectorParts(selector);
4984
+ await page.evaluate((payload) => {
4985
+ const args = payload as { parts: string[] };
4986
+ let root: Document | ShadowRoot = document;
4987
+ for (let index = 0; index < args.parts.length - 1; index += 1) {
4988
+ const host: Element | null = root.querySelector(args.parts[index]);
4989
+ if (!host || !host.shadowRoot) throw new Error(`Open shadow root not found for selector segment: ${args.parts[index]}`);
4990
+ root = host.shadowRoot;
4991
+ }
4992
+ const element = root.querySelector(args.parts[args.parts.length - 1]) as HTMLElement | null;
4993
+ if (!element || typeof element.click !== 'function') throw new Error('Shadow selector did not resolve to a clickable element.');
4994
+ element.click();
4995
+ return true;
4996
+ }, { parts });
4997
+ } else if (input.actionId === 'click') await page.click(selector, { timeout: timeoutMs });
2629
4998
  else if (input.actionId === 'double_click') {
2630
4999
  if (page.dblclick) await page.dblclick(selector, { timeout: timeoutMs });
2631
5000
  else await page.click(selector, { timeout: timeoutMs, clickCount: 2 } as Record<string, unknown>);
@@ -2684,9 +5053,15 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2684
5053
  const values = Array.isArray(input.args.values) ? input.args.values.map(String) : [];
2685
5054
  if (values.length === 0) throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'values is required.', { retryable: false });
2686
5055
  return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
2687
- if (page.selectOption) await page.selectOption(selector, values, { timeout: positiveNumber(input.args.timeout_ms, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS) });
5056
+ if (!page.selectOption) {
5057
+ throw new AssistantPluginError('PLUGIN_BROWSER_SELECT_OPTION_UNAVAILABLE', 'The active browser provider cannot select options safely.', { retryable: false });
5058
+ }
5059
+ const selectedValues = await page.selectOption(selector, values, { timeout: positiveNumber(input.args.timeout_ms, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS) });
5060
+ if (!Array.isArray(selectedValues) || selectedValues.length === 0) {
5061
+ throw new AssistantPluginError('PLUGIN_BROWSER_SELECT_OPTION_FAILED', 'The browser provider did not retain any requested option selection.', { retryable: true, details: { selector, requestedCount: values.length } });
5062
+ }
2688
5063
  await delay(positiveNumber(input.args.post_action_wait_ms, DEFAULT_POST_ACTION_WAIT_MS));
2689
- return finalizeInteractiveAction(input.repoRoot, current, page, target, connection, 'select_option', `Selected ${values.length} option(s) on ${selector}.`, { selector, values });
5064
+ return finalizeInteractiveAction(input.repoRoot, current, page, target, connection, 'select_option', `Selected ${values.length} option(s) on ${selector}.`, { selector, values, selectedValues });
2690
5065
  });
2691
5066
  }
2692
5067
  case 'press': {
@@ -2712,6 +5087,172 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2712
5087
  return finalizeInteractiveAction(input.repoRoot, current, page, target, connection, 'keyboard_shortcut', `Pressed shortcut ${key}.`, { key });
2713
5088
  });
2714
5089
  }
5090
+ case 'trusted_input': {
5091
+ const target = resolveActionTarget(input.repoRoot, input.args);
5092
+ const kind = requiredString(input.args.kind, 'kind');
5093
+ if (!['click', 'move', 'wheel', 'drag', 'key', 'text'].includes(kind)) {
5094
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'kind must be click, move, wheel, drag, key, or text.', { retryable: false });
5095
+ }
5096
+ const guardSelector = stringValue(input.args.guard_selector);
5097
+ const hasFrameScope = Boolean(stringValue(input.args.frame_url) || stringValue(input.args.frame_name));
5098
+ if (hasFrameScope && !guardSelector) {
5099
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'trusted_input frame scope requires guard_selector; trusted input coordinates are always main-viewport CSS pixels.', { retryable: false });
5100
+ }
5101
+ if (guardSelector && kind !== 'click' && kind !== 'drag') {
5102
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'guard_selector is supported only for trusted_input click or drag.', { retryable: false });
5103
+ }
5104
+ return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
5105
+ const nativeTrustedInput = connection.provider === 'macos-apple-events';
5106
+ if (nativeTrustedInput && !page.trustedInput) {
5107
+ throw new AssistantPluginError(
5108
+ 'PLUGIN_BROWSER_TRUSTED_INPUT_UNAVAILABLE',
5109
+ 'Native browser provider does not expose the stable macOS trusted-input broker.',
5110
+ { retryable: false, details: { provider: connection.provider, sessionId: target.sessionId } },
5111
+ );
5112
+ }
5113
+ if (!nativeTrustedInput && (!page.mouse || !page.keyboard)) {
5114
+ throw new AssistantPluginError(
5115
+ 'PLUGIN_BROWSER_TRUSTED_INPUT_UNAVAILABLE',
5116
+ 'Trusted browser input requires Playwright mouse/keyboard primitives or the stable native macOS trusted-input broker.',
5117
+ { retryable: false, details: { provider: connection.provider, sessionId: target.sessionId } },
5118
+ );
5119
+ }
5120
+ const button = input.args.button === 'middle' || input.args.button === 'right' ? input.args.button : 'left';
5121
+ let guard: Record<string, unknown> | undefined;
5122
+ if (guardSelector) {
5123
+ const point = kind === 'click'
5124
+ ? { x: requiredFiniteNumber(input.args.x, 'x', 0), y: requiredFiniteNumber(input.args.y, 'y', 0) }
5125
+ : { x: requiredFiniteNumber(input.args.from_x, 'from_x', 0), y: requiredFiniteNumber(input.args.from_y, 'from_y', 0) };
5126
+ const selection = resolveBrowserEvaluationScope(page, input.args, connection);
5127
+ const observed = await selection.scope.evaluate<{
5128
+ trustedInputGuardVersion?: unknown; found?: unknown; visible?: unknown; selectorError?: unknown;
5129
+ bounds?: { x?: unknown; y?: unknown; width?: unknown; height?: unknown; right?: unknown; bottom?: unknown };
5130
+ }>(trustedInputGuardScript(guardSelector));
5131
+ if (observed?.trustedInputGuardVersion !== 1 || observed.found !== true || observed.visible !== true || !observed.bounds) {
5132
+ throw new AssistantPluginError('PLUGIN_BROWSER_TRUSTED_INPUT_GUARD_MISMATCH', 'Trusted input guard selector is missing or not visibly actionable.', {
5133
+ retryable: true,
5134
+ details: { selector: guardSelector, frame: selection.frame, found: observed?.found, visible: observed?.visible, selectorError: observed?.selectorError },
5135
+ });
5136
+ }
5137
+ const finite = (value: unknown, field: string): number => {
5138
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
5139
+ throw new AssistantPluginError('PLUGIN_BROWSER_TRUSTED_INPUT_GUARD_MISMATCH', `Trusted input guard ${field} is not finite.`, { retryable: true });
5140
+ }
5141
+ return value;
5142
+ };
5143
+ let bounds = {
5144
+ x: finite(observed.bounds.x, 'bounds.x'), y: finite(observed.bounds.y, 'bounds.y'),
5145
+ width: finite(observed.bounds.width, 'bounds.width'), height: finite(observed.bounds.height, 'bounds.height'),
5146
+ right: finite(observed.bounds.right, 'bounds.right'), bottom: finite(observed.bounds.bottom, 'bounds.bottom'),
5147
+ };
5148
+ if (selection.frame) {
5149
+ const offset = await resolveFrameViewportOffset(page, selection);
5150
+ if (!offset) throw new AssistantPluginError('PLUGIN_BROWSER_TRUSTED_INPUT_GUARD_MISMATCH', 'Trusted input frame offset is unavailable.', { retryable: true });
5151
+ bounds = {
5152
+ ...bounds,
5153
+ x: bounds.x + offset.x,
5154
+ y: bounds.y + offset.y,
5155
+ right: bounds.right + offset.x,
5156
+ bottom: bounds.bottom + offset.y,
5157
+ };
5158
+ }
5159
+ const metrics = await page.evaluate<{ viewportMetricsVersion?: unknown; viewport?: { width?: unknown; height?: unknown } }>(VIEWPORT_METRICS_SCRIPT);
5160
+ const viewportWidth = metrics?.viewportMetricsVersion === 1 && typeof metrics.viewport?.width === 'number' && Number.isFinite(metrics.viewport.width) ? metrics.viewport.width : undefined;
5161
+ const viewportHeight = metrics?.viewportMetricsVersion === 1 && typeof metrics.viewport?.height === 'number' && Number.isFinite(metrics.viewport.height) ? metrics.viewport.height : undefined;
5162
+ if (viewportWidth === undefined || viewportHeight === undefined) {
5163
+ throw new AssistantPluginError('PLUGIN_BROWSER_TRUSTED_INPUT_GUARD_MISMATCH', 'Trusted input guard could not verify the top-level viewport.', { retryable: true });
5164
+ }
5165
+ const targetInViewport = bounds.right > 0 && bounds.bottom > 0 && bounds.x < viewportWidth && bounds.y < viewportHeight;
5166
+ const pointInViewport = point.x >= 0 && point.y >= 0 && point.x < viewportWidth && point.y < viewportHeight;
5167
+ const pointInsideTarget = point.x >= bounds.x && point.x <= bounds.right && point.y >= bounds.y && point.y <= bounds.bottom;
5168
+ if (!targetInViewport || !pointInViewport || !pointInsideTarget) {
5169
+ throw new AssistantPluginError('PLUGIN_BROWSER_TRUSTED_INPUT_GUARD_MISMATCH', 'Trusted input coordinates no longer match the guarded selector geometry.', {
5170
+ retryable: true,
5171
+ details: { selector: guardSelector, frame: selection.frame, point, bounds, targetInViewport, pointInViewport, pointInsideTarget },
5172
+ });
5173
+ }
5174
+ guard = { selector: guardSelector, ...(selection.frame ? { frame: selection.frame } : {}), point, bounds, targetInViewport: true, pointInViewport: true, pointInsideTarget: true };
5175
+ }
5176
+ let trustedRequest: Record<string, unknown>;
5177
+ if (kind === 'click') {
5178
+ const x = requiredFiniteNumber(input.args.x, 'x', 0);
5179
+ const y = requiredFiniteNumber(input.args.y, 'y', 0);
5180
+ const clickCount = Math.min(Math.max(positiveNumber(input.args.click_count, 1), 1), 3);
5181
+ trustedRequest = { kind, x, y, button, clickCount };
5182
+ if (!nativeTrustedInput) await page.mouse!.click(x, y, { button, clickCount });
5183
+ } else if (kind === 'move') {
5184
+ const x = requiredFiniteNumber(input.args.x, 'x', 0);
5185
+ const y = requiredFiniteNumber(input.args.y, 'y', 0);
5186
+ const steps = Math.min(Math.max(positiveNumber(input.args.steps, 1), 1), 100);
5187
+ trustedRequest = { kind, x, y, steps };
5188
+ if (!nativeTrustedInput) await page.mouse!.move(x, y, { steps });
5189
+ } else if (kind === 'wheel') {
5190
+ const deltaX = requiredFiniteNumber(input.args.delta_x, 'delta_x');
5191
+ const deltaY = requiredFiniteNumber(input.args.delta_y, 'delta_y');
5192
+ trustedRequest = { kind, deltaX, deltaY };
5193
+ if (!nativeTrustedInput) await page.mouse!.wheel(deltaX, deltaY);
5194
+ } else if (kind === 'drag') {
5195
+ const fromX = requiredFiniteNumber(input.args.from_x, 'from_x', 0);
5196
+ const fromY = requiredFiniteNumber(input.args.from_y, 'from_y', 0);
5197
+ const toX = requiredFiniteNumber(input.args.to_x, 'to_x', 0);
5198
+ const toY = requiredFiniteNumber(input.args.to_y, 'to_y', 0);
5199
+ const steps = Math.min(Math.max(positiveNumber(input.args.steps, 10), 1), 100);
5200
+ trustedRequest = { kind, fromX, fromY, toX, toY, button, steps };
5201
+ if (!nativeTrustedInput) {
5202
+ await page.mouse!.move(fromX, fromY);
5203
+ await page.mouse!.down({ button });
5204
+ try {
5205
+ await page.mouse!.move(toX, toY, { steps });
5206
+ } finally {
5207
+ await page.mouse!.up({ button });
5208
+ }
5209
+ }
5210
+ } else if (kind === 'key') {
5211
+ const key = requiredString(input.args.key, 'key');
5212
+ trustedRequest = { kind, key };
5213
+ if (!nativeTrustedInput) await page.keyboard!.press(key);
5214
+ } else {
5215
+ const text = typeof input.args.text === 'string' ? input.args.text : undefined;
5216
+ if (text === undefined || text.length > 10_000) {
5217
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'text is required and must be at most 10000 characters.', { retryable: false });
5218
+ }
5219
+ trustedRequest = { kind, text };
5220
+ if (!nativeTrustedInput) {
5221
+ if (!page.keyboard!.insertText) {
5222
+ throw new AssistantPluginError('PLUGIN_BROWSER_TRUSTED_INPUT_UNAVAILABLE', 'This Playwright page does not expose trusted text insertion.', { retryable: false });
5223
+ }
5224
+ await page.keyboard!.insertText(text);
5225
+ }
5226
+ }
5227
+ if (nativeTrustedInput) await page.trustedInput!(trustedRequest);
5228
+ await delay(positiveNumber(input.args.post_action_wait_ms, DEFAULT_POST_ACTION_WAIT_MS));
5229
+ return finalizeInteractiveAction(input.repoRoot, current, page, target, connection, 'trusted_input', `Sent trusted browser input (${kind}).`, { kind, ...(guard ? { guard } : {}) });
5230
+ });
5231
+ }
5232
+ case 'dispatch_event': {
5233
+ const target = resolveActionTarget(input.repoRoot, input.args);
5234
+ const selector = requiredString(input.args.selector, 'selector');
5235
+ const event = requiredString(input.args.event, 'event');
5236
+ if (!/^[A-Za-z][A-Za-z0-9:_-]{0,63}$/.test(event)) {
5237
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'event must be a simple DOM event name (letters, digits, colon, underscore, or hyphen; max 64 chars).', { retryable: false });
5238
+ }
5239
+ return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
5240
+ const dispatched = await page.evaluate((payload) => {
5241
+ const args = payload as { selector: string; event: string };
5242
+ const element = document.querySelector(args.selector);
5243
+ if (!element) throw new Error(`element not found: ${args.selector}`);
5244
+ const domEvent = new CustomEvent(args.event, { bubbles: true, composed: true, cancelable: true });
5245
+ const accepted = element.dispatchEvent(domEvent);
5246
+ return { accepted, defaultPrevented: domEvent.defaultPrevented };
5247
+ }, { selector, event });
5248
+ await delay(positiveNumber(input.args.post_action_wait_ms, DEFAULT_POST_ACTION_WAIT_MS));
5249
+ return finalizeInteractiveAction(input.repoRoot, current, page, target, connection, 'dispatch_event', `Dispatched ${event} on ${selector}.`, {
5250
+ selector,
5251
+ event,
5252
+ dispatched,
5253
+ });
5254
+ });
5255
+ }
2715
5256
  case 'wait_for_selector': {
2716
5257
  const target = resolveActionTarget(input.repoRoot, input.args);
2717
5258
  const selector = requiredString(input.args.selector, 'selector');
@@ -2730,64 +5271,122 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2730
5271
  case 'attach_local_file': {
2731
5272
  const target = resolveActionTarget(input.repoRoot, input.args);
2732
5273
  const selector = requiredString(input.args.selector, 'selector');
2733
- const filePath = requiredString(input.args.file_path, 'file_path');
2734
- const resolved = resolve(input.repoRoot, filePath);
2735
- if (!existsSync(resolved)) {
2736
- throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'file_path does not exist.', { retryable: false });
5274
+ const single = stringValue(input.args.file_path);
5275
+ const many = stringList(input.args.file_paths);
5276
+ if (single && many) {
5277
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'Use either file_path or file_paths, not both.', { retryable: false });
2737
5278
  }
2738
- if (/\.(exe|dmg|pkg|sh|bat|cmd|app)$/i.test(resolved)) {
2739
- throw new AssistantPluginError('PLUGIN_POLICY_BLOCKED', 'Executable file attachments are not allowed.', { retryable: false });
5279
+ const requested = many ?? (single ? [single] : []);
5280
+ if (requested.length === 0 || requested.length > 32) {
5281
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'attach_local_file requires file_path or 1-32 file_paths.', { retryable: false });
2740
5282
  }
2741
- return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
2742
- if (connection.provider === 'macos-apple-events') {
2743
- throw new AssistantPluginError('PLUGIN_BROWSER_NATIVE_ACTION_UNSUPPORTED', 'Native Apple Events attachment does not support local file input. Use CDP or a managed browser context.', { retryable: false });
5283
+ const repositoryLexicalRoot = resolve(input.repoRoot);
5284
+ const repositoryRoot = realpathSync(input.repoRoot);
5285
+ const resolved = requested.map((filePath) => {
5286
+ const lexicalPath = resolve(input.repoRoot, filePath);
5287
+ const lexicalRelative = relative(repositoryLexicalRoot, lexicalPath);
5288
+ if (lexicalRelative === '..' || lexicalRelative.startsWith(`..${sep}`) || isAbsolute(lexicalRelative)) {
5289
+ throw new AssistantPluginError('PLUGIN_POLICY_BLOCKED', 'Local file attachments must stay inside the repository root.', { retryable: false, details: { fileName: basename(filePath) } });
5290
+ }
5291
+ if (!existsSync(lexicalPath)) {
5292
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', `Local attachment does not exist: ${basename(lexicalPath)}`, { retryable: false });
5293
+ }
5294
+ const realPath = realpathSync(lexicalPath);
5295
+ const repositoryRelative = relative(repositoryRoot, realPath);
5296
+ if (repositoryRelative === '..' || repositoryRelative.startsWith(`..${sep}`) || isAbsolute(repositoryRelative)) {
5297
+ throw new AssistantPluginError('PLUGIN_POLICY_BLOCKED', 'Local file attachments must resolve inside the repository root.', { retryable: false, details: { fileName: basename(lexicalPath) } });
2744
5298
  }
5299
+ if (!statSync(realPath).isFile()) {
5300
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', `Local attachment is not a regular file: ${basename(lexicalPath)}`, { retryable: false });
5301
+ }
5302
+ if (/\.(exe|dmg|pkg|sh|bat|cmd|app)$/i.test(realPath)) {
5303
+ throw new AssistantPluginError('PLUGIN_POLICY_BLOCKED', 'Executable file attachments are not allowed.', { retryable: false });
5304
+ }
5305
+ return lexicalPath;
5306
+ });
5307
+ return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
2745
5308
  await page.evaluate((payload) => {
2746
- const args = payload as { selector: string; path: string };
5309
+ const args = payload as { selector: string; count: number };
2747
5310
  const el = document.querySelector(args.selector) as HTMLInputElement | null;
2748
- if (!el) throw new Error(`input not found: ${args.selector}`);
2749
- }, { selector, path: resolved });
2750
- // Prefer Playwright setInputFiles when available via evaluate fallback marker
2751
- const anyPage = page as PageLike & { setInputFiles?: (selector: string, files: string | string[]) => Promise<void> };
2752
- if (typeof anyPage.setInputFiles === 'function') {
2753
- await anyPage.setInputFiles(selector, resolved);
5311
+ if (!(el instanceof HTMLInputElement) || el.type !== 'file') throw new Error(`file input not found: ${args.selector}`);
5312
+ if (args.count > 1 && !el.multiple) throw new Error(`file input does not allow multiple files: ${args.selector}`);
5313
+ }, { selector, count: resolved.length });
5314
+ if (typeof page.setInputFiles !== 'function') {
5315
+ throw new AssistantPluginError('PLUGIN_BROWSER_FILE_ATTACH_UNSUPPORTED', 'The selected browser provider does not support local file attachment.', { retryable: false });
2754
5316
  }
5317
+ await page.setInputFiles(selector, resolved.length === 1 ? resolved[0] : resolved);
2755
5318
  await delay(positiveNumber(input.args.post_action_wait_ms, DEFAULT_POST_ACTION_WAIT_MS));
2756
- return finalizeInteractiveAction(input.repoRoot, current, page, target, connection, 'attach_local_file', `Attached local file to ${selector}.`, {
5319
+ const fileNames = resolved.map((path) => basename(path));
5320
+ return finalizeInteractiveAction(input.repoRoot, current, page, target, connection, 'attach_local_file', `Attached ${resolved.length} local file(s) to ${selector}.`, {
2757
5321
  selector,
2758
- fileName: basename(resolved),
5322
+ fileCount: resolved.length,
5323
+ fileNames,
5324
+ ...(resolved.length === 1 ? { fileName: fileNames[0] } : {}),
2759
5325
  });
2760
5326
  });
2761
5327
  }
2762
5328
  case 'await_file_transfer': {
2763
5329
  const target = resolveActionTarget(input.repoRoot, input.args);
2764
5330
  const selector = requiredString(input.args.selector, 'selector');
5331
+ const requestedName = stringValue(input.args.suggested_name);
2765
5332
  const downloadDir = stateDir(input.repoRoot, 'downloads');
2766
5333
  mkdirSync(downloadDir, { recursive: true });
2767
- const suggested = stringValue(input.args.suggested_name) ?? `download-${Date.now()}`;
2768
- const safeName = suggested.replace(/[^a-zA-Z0-9._-]+/g, '_');
2769
- const dest = join(downloadDir, safeName);
2770
5334
  return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
2771
5335
  if (connection.provider === 'macos-apple-events') {
2772
5336
  throw new AssistantPluginError('PLUGIN_BROWSER_NATIVE_ACTION_UNSUPPORTED', 'Native Apple Events attachment does not support download capture. Use CDP or a managed browser context.', { retryable: false });
2773
5337
  }
2774
- await page.click(selector, { timeout: positiveNumber(input.args.timeout_ms, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS) });
2775
- // Best-effort artifact placeholder when Playwright download events are unavailable in mocks.
2776
- if (!existsSync(dest)) writeFileSync(dest, '');
2777
- if (/\.(exe|dmg|pkg|sh|bat|cmd|app)$/i.test(dest)) {
5338
+ if (!page.waitForEvent) {
5339
+ throw new AssistantPluginError('PLUGIN_BROWSER_DOWNLOAD_UNAVAILABLE', 'The selected Playwright page does not expose download events; refusing to fabricate a download artifact.', { retryable: false, details: { provider: connection.provider } });
5340
+ }
5341
+ const timeout = positiveNumber(input.args.timeout_ms, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS);
5342
+ let download: BrowserDownloadLike;
5343
+ try {
5344
+ [download] = await Promise.all([
5345
+ page.waitForEvent('download', { timeout }),
5346
+ page.click(selector, { timeout }),
5347
+ ]);
5348
+ } catch (error) {
5349
+ throw new AssistantPluginError('PLUGIN_BROWSER_DOWNLOAD_FAILED', 'Browser download did not start successfully after the authorized click.', {
5350
+ retryable: true, details: { selector, cause: error instanceof Error ? error.message : String(error) },
5351
+ });
5352
+ }
5353
+ const browserSuggestedName = download.suggestedFilename();
5354
+ if (isBlockedExecutableArtifactName(browserSuggestedName) || isBlockedExecutableArtifactName(requestedName)) {
5355
+ await download.delete?.().catch(() => undefined);
5356
+ throw new AssistantPluginError('PLUGIN_POLICY_BLOCKED', 'Downloaded executable files are not retained or auto-opened and were discarded.', {
5357
+ retryable: false, details: { suggestedFilename: browserSuggestedName },
5358
+ });
5359
+ }
5360
+ if (download.failure) {
5361
+ const failure = await download.failure();
5362
+ if (failure) {
5363
+ throw new AssistantPluginError('PLUGIN_BROWSER_DOWNLOAD_FAILED', 'Browser reported that the download failed.', { retryable: true, details: { selector, cause: failure } });
5364
+ }
5365
+ }
5366
+ const safeName = safeDownloadArtifactName(requestedName, browserSuggestedName);
5367
+ const dest = uniqueDownloadArtifactPath(downloadDir, safeName);
5368
+ try {
5369
+ await download.saveAs(dest);
5370
+ } catch (error) {
2778
5371
  rmSync(dest, { force: true });
2779
- throw new AssistantPluginError('PLUGIN_POLICY_BLOCKED', 'Downloaded executable files are not auto-opened and were discarded.', { retryable: false });
5372
+ throw new AssistantPluginError('PLUGIN_BROWSER_DOWNLOAD_FAILED', 'Browser download could not be saved into Forge artifact storage.', {
5373
+ retryable: true, details: { selector, cause: error instanceof Error ? error.message : String(error) },
5374
+ });
5375
+ }
5376
+ if (!existsSync(dest)) {
5377
+ throw new AssistantPluginError('PLUGIN_BROWSER_DOWNLOAD_FAILED', 'Browser download save completed without producing an artifact; refusing false success.', { retryable: true, details: { selector } });
2780
5378
  }
2781
5379
  await delay(positiveNumber(input.args.post_action_wait_ms, DEFAULT_POST_ACTION_WAIT_MS));
2782
- const result = await finalizeInteractiveAction(input.repoRoot, current, page, target, connection, 'await_file_transfer', `Captured download artifact for ${selector}.`, {
5380
+ return finalizeInteractiveAction(input.repoRoot, current, page, target, connection, 'await_file_transfer', `Captured download artifact for ${selector}.`, {
2783
5381
  selector,
2784
5382
  download: {
2785
5383
  path: dest,
2786
5384
  relativePath: relative(input.repoRoot, dest),
5385
+ fileName: basename(dest),
5386
+ browserSuggestedName,
2787
5387
  autoOpened: false,
2788
5388
  },
2789
5389
  });
2790
- return result;
2791
5390
  });
2792
5391
  }
2793
5392
  default:
@@ -2810,8 +5409,16 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
2810
5409
  }
2811
5410
  }
2812
5411
 
5412
+ export async function executeBrowserPluginAction(input: AssistantPluginActionExecutionInput): Promise<Record<string, unknown>> {
5413
+ return await withBrowserSessionAuthorityContext(
5414
+ { controllerHome: input.controllerHome, repoId: input.repoId },
5415
+ () => executeBrowserPluginActionInternal(input),
5416
+ );
5417
+ }
5418
+
2813
5419
  export const browserPluginAdapter = {
2814
5420
  pluginId: BROWSER_PLUGIN_ID,
2815
5421
  buildManifest: buildBrowserPluginManifest,
2816
5422
  executeAction: executeBrowserPluginAction,
5423
+ resolveAuthorizationContext: resolveBrowserPluginAuthorizationContext,
2817
5424
  };