@moretea-labs/forge 1.8.0 → 1.8.2

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 (695) hide show
  1. package/AGENTS.md +73 -7
  2. package/CHANGELOG.md +34 -0
  3. package/CLAUDE.md +17 -2
  4. package/CONTRIBUTING.md +3 -2
  5. package/README.en.md +24 -3
  6. package/README.md +10 -6
  7. package/README.zh-CN.md +5 -3
  8. package/THIRD_PARTY_NOTICES.md +1 -2
  9. package/adapters/chatgpt/browser-delivery-host.ts +7 -1
  10. package/adapters/chatgpt/browser-delivery-runtime.ts +531 -56
  11. package/adapters/chatgpt/controller-binding-store.ts +101 -22
  12. package/adapters/chatgpt/controller-round-host.ts +15 -25
  13. package/adapters/chatgpt/controller-round-settlement-store.ts +58 -4
  14. package/adapters/chatgpt/provider-delivery.ts +229 -2
  15. package/adapters/chatgpt/work-conversation-binding-store.ts +99 -54
  16. package/adapters/computer/chatgpt-extension-target.ts +708 -0
  17. package/adapters/computer/chrome-extension/background.js +541 -0
  18. package/adapters/computer/chrome-extension/content.js +402 -0
  19. package/adapters/computer/chrome-extension/core.js +24 -0
  20. package/adapters/computer/chrome-extension/manifest.json +32 -0
  21. package/adapters/computer/desktop-operator-discovery.ts +11 -31
  22. package/adapters/computer/desktop-operator-provider.ts +18 -6
  23. package/adapters/computer/interaction-target-authority.ts +178 -24
  24. package/adapters/mcp/access-mode.ts +18 -39
  25. package/adapters/mcp/auth.ts +2 -7
  26. package/adapters/mcp/instructions.ts +6 -5
  27. package/adapters/mcp/multi-repository.ts +10 -47
  28. package/adapters/mcp/oauth.ts +73 -0
  29. package/adapters/mcp/paths.ts +4 -1
  30. package/adapters/mcp/runtime-gateway/bounded-result-runtime.ts +62 -0
  31. package/adapters/mcp/runtime-gateway/cognition-settlement.ts +201 -0
  32. package/adapters/mcp/runtime-gateway/context-adapter.ts +490 -113
  33. package/adapters/mcp/runtime-gateway/context-pack-sidecar.ts +83 -0
  34. package/adapters/mcp/runtime-gateway/controller-authority-adapter.ts +20 -5
  35. package/adapters/mcp/runtime-gateway/core-capability-adapter.ts +258 -0
  36. package/adapters/mcp/runtime-gateway/engineering-preconditions.ts +10 -27
  37. package/adapters/mcp/runtime-gateway/engineering-tool-contract.ts +15 -0
  38. package/adapters/mcp/runtime-gateway/execution-tools.ts +14 -54
  39. package/adapters/mcp/runtime-gateway/gateway-execution-adapter.ts +2 -1
  40. package/adapters/mcp/runtime-gateway/plugin-adapter.ts +132 -47
  41. package/adapters/mcp/runtime-gateway/process-tools.ts +173 -23
  42. package/adapters/mcp/runtime-gateway/protected-computer-adapter.ts +16 -4
  43. package/adapters/mcp/runtime-gateway/recovery-adapter.ts +69 -52
  44. package/adapters/mcp/runtime-gateway/repository-compat-adapter.ts +4 -35
  45. package/adapters/mcp/runtime-gateway/result-adapter.ts +10 -4
  46. package/adapters/mcp/runtime-gateway/router.ts +19 -507
  47. package/adapters/mcp/runtime-gateway/routing-policy.ts +506 -0
  48. package/adapters/mcp/runtime-gateway/runtime-observation-adapter.ts +15 -66
  49. package/adapters/mcp/runtime-gateway/runtime-readiness-observation.ts +218 -0
  50. package/adapters/mcp/runtime-gateway/runtime-tool-definitions.ts +123 -178
  51. package/adapters/mcp/runtime-gateway/runtime-tools.ts +11 -5
  52. package/adapters/mcp/runtime-gateway/scheduler-adapter.ts +37 -1
  53. package/adapters/mcp/runtime-gateway/shared-adapter.ts +20 -0
  54. package/adapters/mcp/runtime-gateway/status-inbox-adapter.ts +416 -270
  55. package/adapters/mcp/runtime-gateway/typescript-navigation-sidecar.ts +140 -0
  56. package/adapters/mcp/runtime-gateway/work-adapter.ts +262 -3123
  57. package/adapters/mcp/runtime-gateway/work-controller-operations.ts +257 -0
  58. package/adapters/mcp/runtime-gateway/work-detail-projection.ts +60 -0
  59. package/adapters/mcp/runtime-gateway/work-input-wire-migration.ts +229 -0
  60. package/adapters/mcp/runtime-gateway/work-learning-operations.ts +145 -0
  61. package/adapters/mcp/runtime-gateway/work-list-projection.ts +38 -0
  62. package/adapters/mcp/runtime-gateway/work-plan-operations.ts +382 -0
  63. package/adapters/mcp/runtime-gateway/work-repair-adapter.ts +308 -0
  64. package/adapters/mcp/runtime-gateway/work-requirement-operations.ts +148 -0
  65. package/adapters/mcp/runtime-gateway/work-semantic-operations.ts +320 -0
  66. package/adapters/mcp/runtime-gateway/work-workflow-operations.ts +113 -0
  67. package/adapters/mcp/runtime-gateway/workflow-supervisor-adapter.ts +180 -0
  68. package/adapters/mcp/server.ts +91 -26
  69. package/adapters/mcp/setup.ts +5 -13
  70. package/adapters/mcp/tool-mapping/access-tools.ts +0 -6
  71. package/adapters/mcp/tool-mapping/legacy-tool-service.ts +703 -3092
  72. package/adapters/mcp/tool-mapping/post-finalize-work-attribution.ts +5 -10
  73. package/adapters/mcp/tool-mapping/repository-tools.ts +170 -213
  74. package/adapters/mcp/toolset-names.ts +11 -37
  75. package/adapters/mcp/toolset.ts +19 -92
  76. package/{src/cli → adapters}/mcp/transports/AGENTS.md +5 -5
  77. package/{src/cli → adapters}/mcp/transports/CLAUDE.md +5 -5
  78. package/adapters/mcp/transports/http-observation.ts +377 -0
  79. package/adapters/mcp/transports/http-post-lifecycle.ts +19 -0
  80. package/adapters/mcp/transports/http.ts +200 -683
  81. package/adapters/mcp/transports/oauth-http.ts +322 -0
  82. package/adapters/mcp/transports/session-registry.ts +6 -0
  83. package/adapters/mcp/tunnels/openai-secure-tunnel.ts +89 -5
  84. package/adapters/mcp/types.ts +0 -6
  85. package/assets/hooks/lib/session-state.sh +0 -38
  86. package/assets/hooks/post-bash.sh +1 -1
  87. package/assets/hooks/post-tool-observer.sh +0 -5
  88. package/assets/hooks/prompt-guard.sh +0 -55
  89. package/assets/partials/04-project-structure.partial.md +1 -1
  90. package/assets/partials/07-footer.partial.md +1 -1
  91. package/assets/partials-agents/02-operating-mode.partial.md +1 -1
  92. package/assets/partials-agents/08-deep-docs.partial.md +1 -1
  93. package/assets/plugin-registry.v1.json +6 -4
  94. package/assets/reference-configs/agentic-development-flow.md +26 -1
  95. package/assets/reference-configs/external-tooling.md +0 -76
  96. package/assets/reference-configs/global-working-rules.md +19 -0
  97. package/assets/reference-configs/release-deploy.md +1 -1
  98. package/assets/templates/helpers/architecture-queue.sh +1 -1
  99. package/assets/templates/helpers/check-agent-tooling.sh +1 -327
  100. package/assets/templates/helpers/check-task-workflow.sh +1 -1
  101. package/assets/templates/helpers/ensure-task-workflow.sh +0 -14
  102. package/assets/templates/helpers/migrate-project-template.sh +2 -2
  103. package/assets/workflow-contract.v1.json +0 -2
  104. package/docs/README.md +5 -2
  105. package/docs/architecture/CURRENT.md +205 -51
  106. package/docs/architecture/decisions/20260803-release-identity-binding-and-exit-policy.md +14 -5
  107. package/docs/architecture/decisions/20260823-immediate-controller-relay.md +6 -3
  108. package/docs/architecture/decisions/20260904-ios-device-migration-baseline.md +42 -0
  109. package/docs/architecture/decisions/20260907-assistant-learning-loop.md +2 -0
  110. package/docs/architecture/decisions/20260907-controller-round-transition-policy.md +19 -4
  111. package/docs/architecture/decisions/20260916-workflow-supervisor-authority.md +249 -0
  112. package/docs/architecture/decisions/20260917-cognitive-memory-foundation.md +82 -0
  113. package/docs/architecture/decisions/20260918-stable-candidate-release-lifecycle.md +234 -0
  114. package/docs/architecture/decisions/20260919-autonomous-work-liveness.md +62 -0
  115. package/docs/architecture/decisions/20260924-thin-semantic-working-context.md +74 -0
  116. package/docs/architecture/decisions/20261001-supervisor-turn-authority.md +342 -0
  117. package/docs/architecture/decisions/20261005-forge-v3-desktop-client-boundary.md +77 -0
  118. package/docs/architecture/decisions/20261009-exact-code-context-discovery.md +19 -0
  119. package/docs/architecture/decisions/20261009-generated-xcode-cache-retention.md +37 -0
  120. package/docs/architecture/decisions/20261011-autonomous-continuation-convergence.md +597 -0
  121. package/docs/architecture/decisions/20261017-edit-session-finalization-reconciliation.md +17 -0
  122. package/docs/architecture/decisions/20261019-retire-host-skills.md +16 -0
  123. package/docs/architecture/diagrams/agentic-dev-plugin-architecture.html +1 -1
  124. package/docs/architecture/domains/public-surface.md +3 -5
  125. package/docs/architecture/domains/verification.md +6 -5
  126. package/docs/architecture/domains/workflow-engine.md +6 -0
  127. package/docs/architecture/modules/controller-runtime/transport-lifecycle.md +3 -3
  128. package/docs/architecture/requests/2026-09-04-computer-plugin-workflow-taxonomy.md +4 -4
  129. package/docs/architecture/reviews/20261015-code-search-runtime-acceptance.md +75 -0
  130. package/docs/forge-plugin-management.md +16 -4
  131. package/docs/operations/controller-browser-plugin.md +23 -0
  132. package/docs/operations/controller-performance-and-502.md +17 -0
  133. package/docs/operations/controller-reliability-runbook.md +2 -2
  134. package/docs/operations/features.md +6 -7
  135. package/docs/operations/features.zh-CN.md +1 -1
  136. package/docs/operations/github-repository.md +4 -4
  137. package/docs/operations/github-semantic-release-projection.md +20 -0
  138. package/docs/operations/homebrew.md +2 -2
  139. package/docs/operations/mcp-tool-exposure.md +13 -52
  140. package/docs/operations/releasing.md +2 -0
  141. package/docs/operations/runtime-performance-diagnostics.md +8 -0
  142. package/docs/operations/standalone-disaster-recovery.md +15 -7
  143. package/docs/tutorials/01-install-and-start.zh-CN.md +1 -1
  144. package/docs/tutorials/03-first-repository-task.md +2 -0
  145. package/docs/wiki/Computer-and-Plugins.md +78 -0
  146. package/docs/wiki/Computer-and-Plugins.zh-CN.md +88 -0
  147. package/docs/wiki/Home.md +16 -2
  148. package/docs/wiki/Implementation.md +25 -13
  149. package/docs/wiki/Installation.md +28 -32
  150. package/docs/wiki/Integrations.md +4 -0
  151. package/docs/wiki/Quick-Start.md +35 -22
  152. package/docs/wiki/Releases-and-Upgrades.md +15 -25
  153. package/docs/wiki/_Sidebar.md +5 -1
  154. package/package.json +25 -25
  155. package/packages/kernel/cognition/api/index.ts +6 -0
  156. package/packages/kernel/cognition/application/activation-service.ts +451 -0
  157. package/packages/kernel/cognition/application/consolidation-service.ts +185 -0
  158. package/packages/kernel/cognition/application/legacy-experience-adapter.ts +38 -0
  159. package/packages/kernel/cognition/application/memory-service.ts +64 -0
  160. package/packages/kernel/cognition/domain/learning.ts +106 -0
  161. package/packages/kernel/cognition/domain/memory.ts +254 -0
  162. package/packages/kernel/controller/api/index.ts +1 -0
  163. package/packages/kernel/controller/application/continuation-service.ts +281 -0
  164. package/packages/kernel/controller/application/controller-round-service.ts +12 -0
  165. package/packages/kernel/controller/domain/controller-round-transition-policy.ts +242 -21
  166. package/packages/kernel/controller/domain/controller-round.ts +4 -1
  167. package/packages/kernel/controller/domain/execution-quality.ts +3 -1
  168. package/packages/kernel/controller/infrastructure/controller-round-store.ts +689 -103
  169. package/packages/kernel/controller/infrastructure/controller-session-store.ts +348 -97
  170. package/packages/kernel/controller/ports/controller-host.ts +2 -0
  171. package/packages/kernel/identity/api/index.ts +10 -0
  172. package/packages/kernel/identity/domain/activity-projection.ts +43 -0
  173. package/packages/kernel/identity/domain/capability-broker.ts +60 -0
  174. package/packages/kernel/identity/domain/grant.ts +72 -0
  175. package/packages/kernel/identity/domain/owned-resource.ts +116 -0
  176. package/packages/kernel/identity/domain/user-request.ts +93 -0
  177. package/packages/kernel/identity/infrastructure/activity-projection-store.ts +87 -0
  178. package/packages/kernel/identity/infrastructure/capability-broker.ts +81 -0
  179. package/packages/kernel/identity/infrastructure/grant-store.ts +284 -0
  180. package/packages/kernel/identity/infrastructure/owned-resource-store.ts +259 -0
  181. package/packages/kernel/identity/infrastructure/user-request-store.ts +182 -0
  182. package/packages/kernel/scheduler/api/index.ts +0 -2
  183. package/packages/kernel/scheduler/application/eligibility.ts +4 -1
  184. package/packages/kernel/scheduler/application/schedule-service.ts +3 -0
  185. package/packages/kernel/scheduler/application/settlement.ts +0 -15
  186. package/packages/kernel/scheduler/domain/schedule.ts +87 -0
  187. package/packages/kernel/scheduler/infrastructure/schedule-store.ts +166 -143
  188. package/packages/kernel/work/api/index.ts +3 -2
  189. package/packages/kernel/work/application/work-service.ts +18 -42
  190. package/packages/kernel/work/domain/current-task-lineage.ts +104 -0
  191. package/packages/kernel/work/domain/engineering-design.ts +10 -5
  192. package/packages/kernel/work/domain/engineering-profile.ts +8 -12
  193. package/packages/kernel/work/domain/evaluation-promotion-receipt.ts +186 -0
  194. package/packages/kernel/work/domain/execution-concurrency.ts +19 -26
  195. package/packages/kernel/work/domain/execution-snapshot.ts +0 -31
  196. package/packages/kernel/work/domain/implementation-review.ts +14 -331
  197. package/packages/kernel/work/domain/objective-graph.ts +119 -0
  198. package/packages/kernel/work/domain/project-engineering-contract.ts +23 -1
  199. package/packages/kernel/work/domain/repository-completion-receipt.ts +1 -1
  200. package/packages/kernel/work/domain/state-machine.ts +34 -65
  201. package/packages/kernel/work/domain/types.ts +57 -51
  202. package/packages/kernel/work/infrastructure/work-contract-store.ts +847 -729
  203. package/packages/kernel/work/ports/work-contract-store.ts +5 -2
  204. package/packages/plugin-runtime/browser/provider-registry.ts +12 -36
  205. package/packages/plugin-runtime/browser/session-authority.ts +2 -1
  206. package/packages/plugin-runtime/computer/chatgpt-identity.ts +24 -0
  207. package/packages/plugin-runtime/computer/conversation-target.ts +153 -0
  208. package/packages/plugin-runtime/computer/index.ts +2 -0
  209. package/packages/plugin-runtime/computer/target-authority.ts +26 -1
  210. package/packages/plugin-runtime/external/unix-jsonl-transport.ts +142 -18
  211. package/packages/protocols/computer/contract.ts +27 -1
  212. package/scripts/activate-source-baseline.ts +7 -9
  213. package/scripts/assemble-template.ts +11 -72
  214. package/scripts/canary-controller-semantic-wait.ts +12 -24
  215. package/scripts/check-mcp-compatibility.ts +94 -201
  216. package/scripts/check-package-identity.mjs +0 -5
  217. package/scripts/check-runtime-architecture.mjs +921 -239
  218. package/scripts/forge-local-recovery-helper.mjs +93 -15
  219. package/scripts/init-project.sh +0 -46
  220. package/scripts/install-ios-hid-toolchain.sh +60 -0
  221. package/scripts/install-standalone-recovery.ts +1 -1
  222. package/scripts/lib/project-init-lib.sh +6 -3
  223. package/scripts/migrate-windows-wsl-controller-home.ts +2 -0
  224. package/scripts/open-source-audit-allowlist.txt +1 -3
  225. package/scripts/run-bounded-command.mjs +5 -0
  226. package/scripts/run-governed-gate.ts +2 -1
  227. package/scripts/run-mobile-monitor-bridge.ts +0 -1
  228. package/scripts/smoke-control-plane-failure-domain.ts +94 -0
  229. package/scripts/smoke-ios-physical-device.ts +38 -10
  230. package/scripts/smoke-mcp-http-runtime.ts +12 -8
  231. package/scripts/smoke-mcp-tool-surface.ts +27 -41
  232. package/scripts/smoke-runtime-recovery.ts +12 -43
  233. package/scripts/smoke-schedule-engine.ts +23 -15
  234. package/scripts/smoke-unattended-transport-recovery.ts +223 -0
  235. package/scripts/smoke-user-request-continuation-restart.ts +232 -0
  236. package/scripts/smoke-workflow-supervisor-chrome-adapter.ts +95 -0
  237. package/scripts/smoke-workflow-supervisor-process-restart.ts +276 -0
  238. package/scripts/smoke-workflow-supervisor-restart-recovery.ts +150 -0
  239. package/scripts/smoke-workflow-supervisor.ts +103 -0
  240. package/scripts/sync-generated-authority.mjs +0 -1
  241. package/scripts/verify-forge-runtime.sh +2 -1
  242. package/src/cli/agent-jobs/executor-health.ts +12 -35
  243. package/src/cli/agent-jobs/integration.ts +20 -51
  244. package/src/cli/agent-jobs/job-manager.ts +7 -3
  245. package/src/cli/chatgpt-browser/file-policy.ts +2 -2
  246. package/src/cli/commands/brain-assistant.ts +27 -13
  247. package/src/cli/commands/brain.ts +13 -15
  248. package/src/cli/commands/chatgpt.ts +1 -163
  249. package/src/cli/commands/doctor.ts +1 -143
  250. package/src/cli/commands/global-runtime.ts +3 -196
  251. package/src/cli/commands/init-hook.ts +6 -23
  252. package/src/cli/commands/init.ts +3 -252
  253. package/src/cli/commands/mcp.ts +0 -5
  254. package/src/cli/commands/prompt-guard-decision.ts +0 -2
  255. package/src/cli/commands/recovery.ts +137 -135
  256. package/src/cli/commands/repository.ts +17 -1
  257. package/src/cli/commands/runtime.ts +3 -3
  258. package/src/cli/commands/setup-execution.ts +41 -0
  259. package/src/cli/commands/setup-profile.ts +8 -0
  260. package/src/cli/controller/check-runner.ts +13 -2
  261. package/src/cli/controller/completion-backlog.ts +3 -3
  262. package/src/cli/controller/completion-orchestrator.ts +5 -44
  263. package/src/cli/controller/completion-target.ts +3 -72
  264. package/src/cli/controller/composite-operations.ts +1 -1
  265. package/src/cli/controller/context/instruction-context.ts +1 -1
  266. package/src/cli/controller/context/known-paths.ts +2 -2
  267. package/src/cli/controller/context/query-planning.ts +11 -4
  268. package/src/cli/controller/context/rule-resolution.ts +2 -2
  269. package/src/cli/controller/context/source-materializer.ts +83 -102
  270. package/src/cli/controller/context/structural-provider.ts +84 -0
  271. package/src/cli/controller/context/structural.ts +6 -6
  272. package/src/cli/controller/context/types.ts +8 -9
  273. package/src/cli/controller/context-pack.ts +107 -84
  274. package/src/cli/controller/execution-completion.ts +4 -7
  275. package/src/cli/controller/execution-policy.ts +36 -153
  276. package/src/cli/controller/governance.ts +2 -3
  277. package/src/cli/controller/issue-store.ts +4 -99
  278. package/src/cli/controller/launch-agents.ts +59 -18
  279. package/src/cli/controller/operational-plan.ts +25 -52
  280. package/src/cli/controller/runtime-config.ts +5 -1
  281. package/src/cli/controller/stuck-state-migration.ts +2 -3
  282. package/src/cli/controller/systemd-user.ts +42 -10
  283. package/src/cli/editing/edit-session.ts +18 -12
  284. package/src/cli/github/github.ts +4 -4
  285. package/src/cli/github/semantic-projection.ts +174 -0
  286. package/src/cli/hook/prompt-intents.ts +0 -9
  287. package/src/cli/index.ts +1 -43
  288. package/src/cli/local-bridge/connector-freshness.ts +1 -12
  289. package/src/cli/local-bridge/console-automations.ts +19 -14
  290. package/src/cli/local-bridge/console-view-models.ts +4 -13
  291. package/src/cli/local-bridge/facade-api.ts +64 -193
  292. package/src/cli/local-bridge/server.ts +24 -147
  293. package/src/cli/local-bridge/types.ts +0 -1
  294. package/src/cli/mcp/oauth-client-retirement.ts +74 -0
  295. package/src/cli/repositories/command-classifier.ts +0 -9
  296. package/src/cli/repositories/command-executor.ts +29 -23
  297. package/src/cli/repositories/command-scope.ts +37 -18
  298. package/src/cli/repositories/controller-home.ts +48 -0
  299. package/src/cli/repositories/ephemeral-workspace.ts +20 -30
  300. package/src/cli/repositories/git-command-executor.ts +2 -2
  301. package/src/cli/repositories/locks.ts +20 -0
  302. package/src/cli/repositories/registry.ts +79 -12
  303. package/src/cli/repositories/safe-patch.ts +2 -12
  304. package/src/cli/repositories/structured-git.ts +101 -5
  305. package/src/cli/repository/inspector.ts +185 -343
  306. package/src/cli/repository/session-cache.ts +92 -6
  307. package/src/effects/process-runner.ts +17 -1
  308. package/src/runtime/context/assistant-context.ts +20 -7
  309. package/src/runtime/context/assistant-work-context.ts +89 -27
  310. package/src/runtime/context/automatic-learning.ts +565 -0
  311. package/src/runtime/context/cognitive-attention.ts +173 -0
  312. package/src/runtime/context/cognitive-skill-canary.ts +158 -0
  313. package/src/runtime/context/context-closure.ts +46 -9
  314. package/src/runtime/context/context-pack-process.ts +103 -0
  315. package/src/runtime/context/context-plane.ts +1 -9
  316. package/src/runtime/context/direct-controller-learning.ts +298 -0
  317. package/src/runtime/context/generic-lsp-provider.ts +1 -1
  318. package/src/runtime/context/semantic-navigation-contract.ts +16 -0
  319. package/src/runtime/context/semantic-navigation.ts +7 -35
  320. package/src/runtime/context/typescript-navigation-process.ts +145 -0
  321. package/src/runtime/context/typescript-navigation.ts +89 -3
  322. package/src/runtime/control-plane/cleanup-artifact-retention.ts +215 -12
  323. package/src/runtime/control-plane/concurrency/work-execution-concurrency.ts +9 -47
  324. package/src/runtime/control-plane/execution/controller-authority-recovery.ts +34 -4
  325. package/src/runtime/control-plane/execution/edit-validation-coordinator.ts +29 -20
  326. package/src/runtime/control-plane/execution/execution-identity.ts +23 -3
  327. package/src/runtime/control-plane/execution/repository-work-attribution.ts +7 -30
  328. package/src/runtime/control-plane/execution/retained-work-resume.ts +206 -199
  329. package/src/runtime/control-plane/execution/session-store.ts +31 -1
  330. package/src/runtime/control-plane/execution/validation.ts +6 -8
  331. package/src/runtime/control-plane/execution/work-authority-reconciler.ts +13 -42
  332. package/src/runtime/control-plane/execution/work-check-evidence.ts +137 -0
  333. package/src/runtime/control-plane/execution/work-completion-authority.ts +30 -85
  334. package/src/runtime/control-plane/execution/work-delivery-service.ts +616 -0
  335. package/src/runtime/control-plane/execution/work-evidence-policy.ts +12 -50
  336. package/src/runtime/control-plane/execution/work-execution-support.ts +50 -149
  337. package/src/runtime/control-plane/execution/work-handle-authority.ts +197 -110
  338. package/src/runtime/control-plane/execution/work-handle-store.ts +72 -44
  339. package/src/runtime/control-plane/execution/work-head-settlement.ts +53 -20
  340. package/src/runtime/control-plane/execution/work-lifecycle-audit.ts +52 -29
  341. package/src/runtime/control-plane/execution/work-operation-service.ts +60 -34
  342. package/src/runtime/control-plane/execution/work-preparation-service.ts +231 -267
  343. package/src/runtime/control-plane/execution/work-process-evidence.ts +2 -2
  344. package/src/runtime/control-plane/execution/work-remote-delivery.ts +2 -3
  345. package/src/runtime/control-plane/execution/work-revision-diff.ts +39 -0
  346. package/src/runtime/control-plane/execution/work-terminal-cleanup.ts +201 -199
  347. package/src/runtime/control-plane/execution/work-validation-reconciler.ts +33 -85
  348. package/src/runtime/control-plane/execution/work-verification-service.ts +406 -50
  349. package/src/runtime/control-plane/execution/work-verification-snapshot.ts +6 -2
  350. package/src/runtime/control-plane/facade/capability-registry.ts +364 -37
  351. package/src/runtime/control-plane/facade/check-normalization.ts +3 -9
  352. package/src/runtime/control-plane/facade/codex-delegation.ts +10 -11
  353. package/src/runtime/control-plane/facade/controller-connection.ts +135 -0
  354. package/src/runtime/control-plane/facade/goal-workloop-access.ts +1 -20
  355. package/src/runtime/control-plane/facade/goal-workloop.ts +281 -1753
  356. package/src/runtime/control-plane/facade/handoff-inbox-application.ts +12 -25
  357. package/src/runtime/control-plane/facade/handoff-inbox-store.ts +256 -52
  358. package/src/runtime/control-plane/facade/index.ts +0 -1
  359. package/src/runtime/control-plane/facade/operation-digest.ts +12 -15
  360. package/src/runtime/control-plane/facade/plan-contract-store.ts +451 -991
  361. package/src/runtime/control-plane/facade/policy-gate.ts +0 -23
  362. package/src/runtime/control-plane/facade/repository-work-admission.ts +12 -22
  363. package/src/runtime/control-plane/facade/requirement-authority.ts +102 -199
  364. package/src/runtime/control-plane/facade/requirement-board.ts +3 -3
  365. package/src/runtime/control-plane/facade/rh-work-operation-contract.ts +51 -14
  366. package/src/runtime/control-plane/facade/self-healing-loop.ts +18 -53
  367. package/src/runtime/control-plane/facade/suggested-actions.ts +15 -18
  368. package/src/runtime/control-plane/facade/types.ts +49 -108
  369. package/src/runtime/control-plane/facade/work-continuation.ts +9 -9
  370. package/src/runtime/control-plane/generated-cache-retention.ts +206 -59
  371. package/src/runtime/control-plane/global-scheduler/autonomous-continuation.ts +248 -0
  372. package/src/runtime/control-plane/global-scheduler/durable-admission.ts +1 -1
  373. package/src/runtime/control-plane/global-scheduler/maintenance.ts +256 -117
  374. package/src/runtime/control-plane/global-scheduler/reconciliation.ts +3 -12
  375. package/src/runtime/control-plane/global-scheduler/scheduler.ts +66 -40
  376. package/src/runtime/control-plane/global-scheduler/wake-signal.ts +42 -2
  377. package/src/runtime/control-plane/governance/access-policy.ts +19 -2
  378. package/src/runtime/control-plane/governance/authorization.ts +20 -3
  379. package/src/runtime/control-plane/governance/external-effects.ts +1 -1
  380. package/src/runtime/control-plane/launcher/chatgpt-round-continuation.ts +63 -300
  381. package/src/runtime/control-plane/launcher/chatgpt-work-continuation.ts +29 -300
  382. package/src/runtime/control-plane/launcher/local-conversation-controller.ts +238 -0
  383. package/src/runtime/control-plane/launcher/provider-mcp-bootstrap.ts +5 -2
  384. package/src/runtime/control-plane/launcher/thin-launcher.ts +96 -240
  385. package/src/runtime/control-plane/persistence/cognition-store.ts +820 -0
  386. package/src/runtime/control-plane/persistence/experience-store.ts +162 -29
  387. package/src/runtime/control-plane/persistence/requirement-store.ts +216 -74
  388. package/src/runtime/control-plane/persistence/sqlite-store.ts +113 -6
  389. package/src/runtime/control-plane/release-retention.ts +135 -47
  390. package/src/runtime/control-plane/repository-namespace-retention.ts +2 -2
  391. package/src/runtime/control-plane/runtime-cleanup.ts +295 -107
  392. package/src/runtime/control-plane/workspace/project-onboarding.ts +80 -0
  393. package/src/runtime/control-plane/workspace/semantic-sync-service.ts +19 -5
  394. package/src/runtime/control-plane/workspace/workspace-store.ts +169 -0
  395. package/src/runtime/diagnostics/incident-repair.ts +313 -146
  396. package/src/runtime/diagnostics/mcp-timing.ts +114 -0
  397. package/src/runtime/evidence/artifact-store.ts +158 -40
  398. package/src/runtime/evidence/evidence-store.ts +112 -8
  399. package/src/runtime/evidence/output-handle-index.ts +225 -0
  400. package/src/runtime/evidence/result-store.ts +158 -44
  401. package/src/runtime/evidence/sensitive-output.ts +22 -2
  402. package/src/runtime/execution/jobs/child-reference.ts +11 -0
  403. package/src/runtime/execution/jobs/receipt-recovery.ts +1 -11
  404. package/src/runtime/execution/jobs/store.ts +3 -4
  405. package/src/runtime/execution/managed-workspace.ts +57 -0
  406. package/src/runtime/execution/process-runtime/check-facade.ts +1 -1
  407. package/src/runtime/execution/process-runtime/check-result-retention.ts +2 -2
  408. package/src/runtime/execution/process-runtime/check-result.ts +105 -1
  409. package/src/runtime/execution/process-runtime/check-runner-sidecar.ts +18 -8
  410. package/src/runtime/execution/process-runtime/command-facade.ts +82 -30
  411. package/src/runtime/execution/process-runtime/gc.ts +15 -2
  412. package/src/runtime/execution/process-runtime/handle-index.ts +176 -0
  413. package/src/runtime/execution/process-runtime/host-command-lane.ts +139 -0
  414. package/src/runtime/execution/process-runtime/index.ts +3 -0
  415. package/src/runtime/execution/process-runtime/lightweight-managed.ts +251 -72
  416. package/src/runtime/execution/process-runtime/persisted-check.ts +3 -3
  417. package/src/runtime/execution/process-runtime/process-runner-entry.ts +47 -5
  418. package/src/runtime/execution/process-runtime/process-scope.ts +160 -0
  419. package/src/runtime/execution/process-runtime/resource-claims.ts +65 -61
  420. package/src/runtime/execution/process-runtime/runtime.ts +55 -18
  421. package/src/runtime/execution/process-runtime/store.ts +30 -2
  422. package/src/runtime/execution/process-runtime/types.ts +6 -1
  423. package/src/runtime/execution/thin-harness/execution-router.ts +16 -16
  424. package/src/runtime/execution/thin-harness/fast-executor.ts +2 -2
  425. package/src/runtime/execution/thin-harness/search-worker.ts +2 -2
  426. package/src/runtime/execution/workers/executor.ts +3 -8
  427. package/src/runtime/maintenance/cleanup.ts +41 -5
  428. package/src/runtime/platform/service-inventory.ts +130 -0
  429. package/src/runtime/platform/service-manager.ts +8 -0
  430. package/src/runtime/plugins/action-application.ts +97 -29
  431. package/src/runtime/plugins/app-store-connect-adapter.ts +290 -18
  432. package/src/runtime/plugins/browser-adapter.ts +1124 -333
  433. package/src/runtime/plugins/browser-automation-service.ts +23 -3
  434. package/src/runtime/plugins/browser-macos-bridge.ts +431 -137
  435. package/src/runtime/plugins/browser-manifest-surface.ts +47 -12
  436. package/src/runtime/plugins/browser-native-session-service.ts +63 -13
  437. package/src/runtime/plugins/browser-registration.ts +18 -0
  438. package/src/runtime/plugins/browser-runtime.ts +40 -36
  439. package/src/runtime/plugins/browser-session-authority.ts +2 -2
  440. package/src/runtime/plugins/browser-session-store.ts +46 -76
  441. package/src/runtime/plugins/capability-authorization-grants.ts +258 -62
  442. package/src/runtime/plugins/computer-chatgpt-browser-adapter-installer.ts +228 -0
  443. package/src/runtime/plugins/computer-registration.ts +298 -20
  444. package/src/runtime/plugins/desktop-operator-external-policy.ts +36 -4
  445. package/src/runtime/plugins/desktop-operator-registration.ts +12 -4
  446. package/src/runtime/plugins/external-adapter.ts +42 -6
  447. package/src/runtime/plugins/external-registration.ts +21 -1
  448. package/src/runtime/plugins/github-adapter.ts +30 -3
  449. package/src/runtime/plugins/gmail-adapter.ts +189 -3
  450. package/src/runtime/plugins/google-calendar-adapter.ts +2 -2
  451. package/src/runtime/plugins/google-shared.ts +24 -12
  452. package/src/runtime/plugins/google-tasks-adapter.ts +2 -2
  453. package/src/runtime/plugins/ios/remote-xpc-hid.ts +70 -30
  454. package/src/runtime/plugins/ios-adapter.ts +46 -41
  455. package/src/runtime/plugins/ios-agent-device.ts +54 -4
  456. package/src/runtime/plugins/ios-device-ownership.ts +103 -0
  457. package/src/runtime/plugins/ios-physical-device.ts +497 -79
  458. package/src/runtime/plugins/lightweight-action.ts +208 -2
  459. package/src/runtime/plugins/local-system-adapter.ts +12 -100
  460. package/src/runtime/plugins/managed-process-adapter.ts +12 -5
  461. package/src/runtime/plugins/plugin-action-sidecar.ts +17 -13
  462. package/src/runtime/plugins/plugin-management-adapter.ts +1 -1
  463. package/src/runtime/plugins/store.ts +682 -435
  464. package/src/runtime/plugins/types.ts +24 -0
  465. package/src/runtime/plugins/xiaohongshu-publish.ts +16 -0
  466. package/src/runtime/projections/git-status-sampler.ts +2 -19
  467. package/src/runtime/projections/invalidation.ts +7 -3
  468. package/src/runtime/projections/materialized-view.ts +80 -36
  469. package/src/runtime/recovery/actions.ts +4 -4
  470. package/src/runtime/recovery/capability-action-application.ts +46 -45
  471. package/src/runtime/recovery/lifecycle-inventory.ts +30 -40
  472. package/src/runtime/recovery/maintenance-executor.ts +374 -123
  473. package/src/runtime/recovery/probe.ts +2 -2
  474. package/src/runtime/recovery/store.ts +29 -11
  475. package/src/runtime/release/release-coordinator.ts +312 -0
  476. package/src/runtime/release/release-session.ts +716 -0
  477. package/src/runtime/release/release-state-migration.ts +25 -0
  478. package/src/runtime/resources/leases/store.ts +56 -25
  479. package/src/runtime/root/computer-composition.ts +191 -6
  480. package/src/runtime/root/controller-progression-composition.ts +254 -0
  481. package/src/runtime/root/controller-round-composition.ts +20 -1
  482. package/src/runtime/root/deployment-topology.ts +106 -0
  483. package/src/runtime/root/entry.ts +4 -3
  484. package/src/runtime/root/known-good-recovery.ts +184 -0
  485. package/src/runtime/root/local-bridge.ts +0 -1
  486. package/src/runtime/root/mcp-transport.ts +111 -18
  487. package/src/runtime/root/ownership.ts +261 -9
  488. package/src/runtime/root/package-connector-service.ts +227 -29
  489. package/src/runtime/root/package-runtime-release.ts +117 -30
  490. package/src/runtime/root/package-runtime-service.ts +24 -24
  491. package/src/runtime/root/release-execution-canary.ts +57 -12
  492. package/src/runtime/root/release-loader.ts +90 -8
  493. package/src/runtime/root/release-manifest.ts +80 -40
  494. package/src/runtime/root/release-materialize.ts +372 -106
  495. package/src/runtime/root/release-store.ts +395 -36
  496. package/src/runtime/root/runtime-lane.ts +191 -0
  497. package/src/runtime/root/runtime.ts +160 -29
  498. package/src/runtime/root/scheduled-controller-composition.ts +29 -3
  499. package/src/runtime/root/service-runner.ts +2 -2
  500. package/src/runtime/root/service.ts +126 -44
  501. package/src/runtime/root/status.ts +48 -0
  502. package/src/runtime/root/types.ts +32 -9
  503. package/src/runtime/root/workflow-supervisor-composition.ts +681 -0
  504. package/src/runtime/root/workflow-supervisor-runtime.ts +564 -0
  505. package/src/runtime/root/write-fence.ts +50 -2
  506. package/src/runtime/safe-tooling/google-credential-store.ts +67 -22
  507. package/src/runtime/safe-tooling/google-oauth-broker.ts +25 -7
  508. package/src/runtime/safe-tooling/ios-development.ts +22 -8
  509. package/src/runtime/safe-tooling/job-summary.ts +1 -1
  510. package/src/runtime/safe-tooling/workspace-auth.ts +5 -2
  511. package/src/runtime/shared/atomic-text-file.ts +21 -0
  512. package/src/runtime/shared/bounded-child-supervisor.ts +9 -1
  513. package/src/runtime/shared/json-files.ts +8 -3
  514. package/src/runtime/shared/process-environment.ts +20 -9
  515. package/src/runtime/shared/response-budget.ts +1 -3
  516. package/src/runtime/standalone-recovery/controller-home-migration.ts +16 -24
  517. package/src/runtime/standalone-recovery/core.ts +3792 -417
  518. package/src/runtime/standalone-recovery/entry.ts +507 -45
  519. package/src/runtime/standalone-recovery/installer.ts +262 -148
  520. package/src/runtime/standalone-recovery/mcp-server.ts +29 -193
  521. package/src/runtime/standalone-recovery/operation-lock.ts +12 -2
  522. package/src/runtime/standalone-recovery/performance.ts +54 -16
  523. package/src/runtime/standalone-recovery/release.ts +53 -5
  524. package/src/runtime/standalone-recovery/service-labels.ts +5 -2
  525. package/src/runtime/standalone-recovery/watchdog-heartbeat.ts +5 -2
  526. package/src/runtime/workflow/schedules/browser-probe.ts +17 -6
  527. package/src/runtime/workflow/schedules/chatgpt-automation-migration.ts +11 -3
  528. package/src/runtime/workflow/schedules/engine.ts +166 -178
  529. package/src/runtime/workflow/schedules/pending-activation.ts +11 -4
  530. package/src/runtime/workflow/schedules/work-continuation.ts +95 -11
  531. package/src/runtime/workflows/runtime.ts +15 -19
  532. package/src/testing/test-governance.ts +5 -1
  533. package/supervisor/chatgpt-conversation.ts +23 -0
  534. package/supervisor/client.ts +218 -0
  535. package/supervisor/control-plane.ts +963 -0
  536. package/supervisor/entry.ts +15 -0
  537. package/supervisor/forge-validators.ts +147 -0
  538. package/supervisor/index.ts +5 -0
  539. package/supervisor/native-browser-adapter.ts +1151 -0
  540. package/supervisor/native-messaging/host.ts +58 -0
  541. package/supervisor/native-messaging/manifest.ts +26 -0
  542. package/supervisor/native-messaging/protocol.ts +27 -0
  543. package/supervisor/paths.ts +18 -0
  544. package/supervisor/protocol.ts +250 -0
  545. package/supervisor/server.ts +412 -0
  546. package/supervisor/store.ts +1870 -0
  547. package/supervisor/tsconfig.json +8 -0
  548. package/supervisor/types.ts +261 -0
  549. package/SKILL.md +0 -427
  550. package/adapters/chatgpt/controller-host.ts +0 -119
  551. package/adapters/mcp/controller-round-compatibility.ts +0 -184
  552. package/adapters/mcp/frozen-client-semantic-compatibility.ts +0 -292
  553. package/adapters/mcp/runtime-gateway/work-compat-adapter.ts +0 -502
  554. package/adapters/mcp/runtime-gateway/work-validation-reconciler.ts +0 -1
  555. package/adapters/mcp/toolset-selection.ts +0 -41
  556. package/agents/openai.yaml +0 -4
  557. package/assets/skill-hooks.json +0 -41
  558. package/assets/skill-version.json +0 -189
  559. package/assets/skills/claude-review/SKILL.md +0 -231
  560. package/assets/skills/codex-review/SKILL.md +0 -103
  561. package/assets/templates/helpers/check-skill-version.ts +0 -193
  562. package/packages/kernel/progression/api/index.ts +0 -2
  563. package/packages/kernel/progression/application/projector.ts +0 -211
  564. package/packages/kernel/progression/domain/types.ts +0 -116
  565. package/packages/kernel/progression/index.ts +0 -1
  566. package/packages/kernel/scheduler/application/continuation-service.ts +0 -212
  567. package/packages/kernel/scheduler/domain/continuation.ts +0 -38
  568. package/packages/kernel/scheduler/infrastructure/continuation-dispatch-store.ts +0 -38
  569. package/packages/kernel/work/domain/admission-policy.ts +0 -34
  570. package/scripts/benchmark-background-check-overlap.ts +0 -228
  571. package/scripts/benchmark-computer-hotpath.ts +0 -255
  572. package/scripts/benchmark-controller-hotpath.ts +0 -212
  573. package/scripts/benchmark-controller-round2.ts +0 -191
  574. package/scripts/benchmark-ios-agent-device.ts +0 -511
  575. package/scripts/benchmark-mcp-host.sh +0 -117
  576. package/scripts/benchmark-mcp-transport.ts +0 -169
  577. package/scripts/benchmark-operational-memory-activation.ts +0 -235
  578. package/scripts/benchmark-operational-shadow.ts +0 -28
  579. package/scripts/benchmark-quality-harness.ts +0 -146
  580. package/scripts/benchmark-route-session-concurrency.ts +0 -915
  581. package/scripts/benchmark-thin-harness-gateway-ab.ts +0 -632
  582. package/scripts/benchmark-thin-harness-mcp-e2e.ts +0 -498
  583. package/scripts/benchmark-thin-harness.ts +0 -251
  584. package/scripts/benchmark-typescript-navigation.ts +0 -186
  585. package/scripts/benchmark-work-efficiency.ts +0 -334
  586. package/scripts/build-controller-ui.ts +0 -28
  587. package/scripts/check-npm-release.sh +0 -32
  588. package/scripts/check-public-release-surface.sh +0 -19
  589. package/scripts/check-release-published.sh +0 -57
  590. package/scripts/check-release-readiness.sh +0 -37
  591. package/scripts/check-release-version.mjs +0 -74
  592. package/scripts/check-skill-version.ts +0 -5
  593. package/scripts/check-tarball-install-smoke.sh +0 -150
  594. package/scripts/ensure-codegraph.sh +0 -16
  595. package/scripts/package-source-archive.sh +0 -147
  596. package/scripts/public-release-files.txt +0 -39
  597. package/scripts/public-release.ts +0 -174
  598. package/scripts/publish-release-tarball.sh +0 -27
  599. package/scripts/route-nl-vs-ts-eval.ts +0 -679
  600. package/scripts/run-skill-hook.ts +0 -244
  601. package/scripts/stage-runtime-release.ts +0 -44
  602. package/scripts/sync-codex-installed-copies.sh +0 -182
  603. package/src/cli/commands/tools.ts +0 -167
  604. package/src/cli/controller/progress.ts +0 -656
  605. package/src/cli/controller/work-mode.ts +0 -181
  606. package/src/cli/local-bridge/console-assets.ts +0 -17
  607. package/src/cli/local-bridge/dashboard.ts +0 -17
  608. package/src/cli/local-bridge/frontend/src/App.tsx +0 -10
  609. package/src/cli/local-bridge/frontend/src/components/Button.tsx +0 -1
  610. package/src/cli/local-bridge/frontend/src/components/DetailPane.tsx +0 -3
  611. package/src/cli/local-bridge/frontend/src/components/SearchField.tsx +0 -1
  612. package/src/cli/local-bridge/frontend/src/components/SectionHeader.tsx +0 -1
  613. package/src/cli/local-bridge/frontend/src/components/Segmented.tsx +0 -1
  614. package/src/cli/local-bridge/frontend/src/components/StatusText.tsx +0 -3
  615. package/src/cli/local-bridge/frontend/src/design/icons.tsx +0 -11
  616. package/src/cli/local-bridge/frontend/src/design/tokens.css +0 -8
  617. package/src/cli/local-bridge/frontend/src/features/automations/AutomationsView.tsx +0 -153
  618. package/src/cli/local-bridge/frontend/src/features/capabilities/CapabilitiesView.tsx +0 -37
  619. package/src/cli/local-bridge/frontend/src/features/overview/OverviewView.tsx +0 -183
  620. package/src/cli/local-bridge/frontend/src/features/repositories/RepositoriesView.tsx +0 -2
  621. package/src/cli/local-bridge/frontend/src/features/system/SystemView.tsx +0 -2
  622. package/src/cli/local-bridge/frontend/src/features/work/WorkView.tsx +0 -4
  623. package/src/cli/local-bridge/frontend/src/lib/api.ts +0 -30
  624. package/src/cli/local-bridge/frontend/src/lib/format.ts +0 -4
  625. package/src/cli/local-bridge/frontend/src/lib/routes.ts +0 -7
  626. package/src/cli/local-bridge/frontend/src/main.tsx +0 -1
  627. package/src/cli/local-bridge/frontend/src/shell/AppShell.tsx +0 -1
  628. package/src/cli/local-bridge/frontend/src/shell/CommandBar.tsx +0 -2
  629. package/src/cli/local-bridge/frontend/src/shell/Sidebar.tsx +0 -4
  630. package/src/cli/local-bridge/frontend/src/styles.css +0 -28
  631. package/src/cli/local-bridge/frontend/src/types.ts +0 -15
  632. package/src/cli/local-bridge/frontend/src/vite-env.d.ts +0 -1
  633. package/src/cli/local-bridge/frontend/vite.config.ts +0 -27
  634. package/src/cli/local-bridge/ui-dist/app.css +0 -1
  635. package/src/cli/local-bridge/ui-dist/app.js +0 -11228
  636. package/src/cli/mcp/access-mode.ts +0 -2
  637. package/src/cli/mcp/access-tools.ts +0 -2
  638. package/src/cli/mcp/audit.ts +0 -2
  639. package/src/cli/mcp/auth.ts +0 -2
  640. package/src/cli/mcp/instructions.ts +0 -2
  641. package/src/cli/mcp/legacy-context.ts +0 -2
  642. package/src/cli/mcp/legacy-tool-service.ts +0 -2
  643. package/src/cli/mcp/multi-repository.ts +0 -2
  644. package/src/cli/mcp/oauth.ts +0 -2
  645. package/src/cli/mcp/openai-secure-tunnel.ts +0 -2
  646. package/src/cli/mcp/paths.ts +0 -2
  647. package/src/cli/mcp/policy.ts +0 -2
  648. package/src/cli/mcp/post-finalize-work-attribution.ts +0 -2
  649. package/src/cli/mcp/proxy-env.ts +0 -2
  650. package/src/cli/mcp/readiness-projection.ts +0 -2
  651. package/src/cli/mcp/redaction.ts +0 -2
  652. package/src/cli/mcp/repo.ts +0 -2
  653. package/src/cli/mcp/repository-tools.ts +0 -2
  654. package/src/cli/mcp/server.ts +0 -2
  655. package/src/cli/mcp/setup.ts +0 -2
  656. package/src/cli/mcp/tool-capability-cache.ts +0 -2
  657. package/src/cli/mcp/tool-contract.ts +0 -2
  658. package/src/cli/mcp/tools.ts +0 -2
  659. package/src/cli/mcp/toolset-names.ts +0 -2
  660. package/src/cli/mcp/toolset-selection.ts +0 -2
  661. package/src/cli/mcp/toolset.ts +0 -2
  662. package/src/cli/mcp/transports/http.ts +0 -2
  663. package/src/cli/mcp/transports/session-registry.ts +0 -2
  664. package/src/cli/mcp/transports/stdio.ts +0 -2
  665. package/src/cli/mcp/types.ts +0 -2
  666. package/src/cli/mcp/umbrella-tools.ts +0 -2
  667. package/src/cli/tools/codegraph.ts +0 -695
  668. package/src/core/adoption/helper-wrapper-plan.ts +0 -159
  669. package/src/runtime/context/codegraph-cache-boundary.ts +0 -255
  670. package/src/runtime/context/codegraph-read-provider.ts +0 -611
  671. package/src/runtime/context/codegraph-sidecar.cjs +0 -248
  672. package/src/runtime/control-plane/codegraph-cache-retention.ts +0 -164
  673. package/src/runtime/control-plane/execution/content-equivalent-commit-authority.ts +0 -169
  674. package/src/runtime/control-plane/execution/direct-canonical-work-reconciliation.ts +0 -85
  675. package/src/runtime/control-plane/execution/direct-edit-work-completion.ts +0 -655
  676. package/src/runtime/control-plane/execution/work-finalization-service.ts +0 -2850
  677. package/src/runtime/control-plane/execution/work-path-scope.ts +0 -37
  678. package/src/runtime/control-plane/execution/work-task-receipt.ts +0 -468
  679. package/src/runtime/control-plane/facade/controller-round-relay.ts +0 -3
  680. package/src/runtime/control-plane/facade/work-admission-policy.ts +0 -142
  681. package/src/runtime/control-plane/facade/work-implementation-review.ts +0 -2
  682. package/src/runtime/control-plane/facade/work-state-machine.ts +0 -2
  683. package/src/runtime/control-plane/facade/work-task-projection.ts +0 -66
  684. package/src/runtime/control-plane/routing/index.ts +0 -1
  685. package/src/runtime/control-plane/routing/route-policy.ts +0 -401
  686. package/src/runtime/control-plane/routing/workspace-admission.ts +0 -70
  687. package/src/runtime/gateway/mcp/execution-tools.ts +0 -2
  688. package/src/runtime/gateway/mcp/legacy-ios-tool-adapter.ts +0 -2
  689. package/src/runtime/gateway/mcp/persisted-check-process.ts +0 -2
  690. package/src/runtime/gateway/mcp/process-tools.ts +0 -2
  691. package/src/runtime/gateway/mcp/router.ts +0 -2
  692. package/src/runtime/gateway/mcp/runtime-tool-definitions.ts +0 -2
  693. package/src/runtime/gateway/mcp/runtime-tools.ts +0 -2
  694. package/src/runtime/gateway/mcp/work-validation-reconciler.ts +0 -2
  695. package/src/runtime/plugins/browser-provider-registry.ts +0 -2
@@ -1,7 +1,11 @@
1
1
  import { createHash, randomUUID } from 'crypto';
2
+ import { spawn, spawnSync } from 'child_process';
2
3
  import { createRequire } from 'module';
3
- import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'fs';
4
+ import { createServer as createNetServer } from 'net';
5
+ import { existsSync, mkdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'fs';
6
+ import { homedir } from 'os';
4
7
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'path';
8
+ import { fileURLToPath } from 'url';
5
9
  import type {
6
10
  AssistantPluginActionExecutionInput,
7
11
  AssistantPluginAuthorizationContext,
@@ -10,6 +14,8 @@ import type {
10
14
  AssistantPluginManifest,
11
15
  } from './types';
12
16
  import { AssistantPluginError, toAssistantPluginError } from './errors';
17
+ import { listCanonicalGrants } from '../../../packages/kernel/identity/api/index';
18
+ import { resolveRepoPreferredControllerHome } from '../../cli/repositories/controller-home';
13
19
  import { readRepositoryPluginConfig, writeRepositoryPluginConfig, type RepositoryPluginConfigContext } from './config-store';
14
20
  import {
15
21
  browserActions,
@@ -17,7 +23,6 @@ import {
17
23
  browserPermissions,
18
24
  } from './browser-manifest-surface';
19
25
  import { executeBrowserActionThroughNode, shouldUseBrowserNodeBridge } from './browser-node-bridge';
20
- import { BrowserProviderUnavailableBeforeActionError } from '../../../packages/plugin-runtime/browser/provider-registry';
21
26
  import { MAX_BROWSER_CDP_ENDPOINT_CANDIDATES } from '../../../packages/plugin-runtime/browser/runtime-contract';
22
27
  import {
23
28
  ALL_BROWSER_PROVIDER_CAPABILITIES,
@@ -28,6 +33,8 @@ import {
28
33
  invalidateBrowserRuntime,
29
34
  } from './browser-runtime';
30
35
  import { activateRuntimeComputerBrowserApplication } from '../root/computer-composition';
36
+ import { resolveWorkspaceTargetPath, WorkspaceTargetGrantError } from '../workspace-targets';
37
+ import { pluginCapabilityAuthorizationOwnerScope } from './capability-authorization-grants';
31
38
  import {
32
39
  DEFAULT_BROWSER_SESSION_LIST_LIMIT,
33
40
  MAX_BROWSER_SESSION_LIST_LIMIT,
@@ -50,6 +57,7 @@ import {
50
57
  readMacOsBrowserOwnedTabMetadata,
51
58
  macOsActiveBrowserAttachSupported,
52
59
  macOsBrowserJavaScriptAutomationDisabled,
60
+ assertMacOsBrowserApplicationRunning,
53
61
  type MacOsBrowserAttachAttempt,
54
62
  type MacOsBrowserCreateTabEvidence,
55
63
  type MacOsBrowserProduct,
@@ -107,12 +115,12 @@ type WaitForSelectorState = 'attached' | 'detached' | 'visible' | 'hidden';
107
115
  type BrowserChannel = 'chromium' | 'chrome' | 'chrome-beta' | 'chrome-dev' | 'chrome-canary';
108
116
  type BrowserNativeAttachMode = 'auto' | 'disabled';
109
117
 
110
- const CURRENT_BROWSER_CONFIG_SCHEMA_VERSION = 2 as const;
118
+ const CURRENT_BROWSER_CONFIG_SCHEMA_VERSION = 3 as const;
111
119
  const DEFAULT_USER_BROWSER_CHANNEL: BrowserChannel = 'chrome';
112
- const DEFAULT_USER_NATIVE_BROWSER_CANDIDATES: MacOsBrowserProduct[] = ['chrome'];
120
+ const DEFAULT_USER_NATIVE_BROWSER_CANDIDATES: MacOsBrowserProduct[] = ['chrome', 'vivaldi'];
113
121
 
114
122
  interface BrowserPluginConfig {
115
- schemaVersion: 2;
123
+ schemaVersion: 3;
116
124
  enabled: boolean;
117
125
  provider: 'playwright';
118
126
  browserMode?: BrowserMode;
@@ -131,7 +139,7 @@ interface BrowserPluginConfig {
131
139
  }
132
140
 
133
141
  type PersistedBrowserPluginConfig = Partial<Omit<BrowserPluginConfig, 'schemaVersion'>> & {
134
- schemaVersion?: 1 | 2;
142
+ schemaVersion?: 1 | 2 | 3;
135
143
  };
136
144
 
137
145
  type BrowserSessionInventory = {
@@ -175,15 +183,109 @@ interface BrowserProfileSelection {
175
183
  selectedProfilePath: string;
176
184
  }
177
185
 
186
+ type BrowserExtensionTargetLike = { url(): string };
187
+ type BrowserCdpSessionLike = {
188
+ send(method: string, params?: Record<string, unknown>): Promise<unknown>;
189
+ detach?(): Promise<void>;
190
+ };
191
+
192
+ function connectNativeBrowserCdp(endpoint: string, timeoutMs: number): Promise<BrowserCdpSessionLike> {
193
+ const boundedTimeoutMs = Math.max(250, Math.min(timeoutMs, 30_000));
194
+ return new Promise<BrowserCdpSessionLike>((resolveSession, rejectSession) => {
195
+ const socket = new WebSocket(endpoint);
196
+ const pending = new Map<number, {
197
+ resolve(value: unknown): void;
198
+ reject(error: Error): void;
199
+ timer: ReturnType<typeof setTimeout>;
200
+ }>();
201
+ let nextId = 1;
202
+ let connectSettled = false;
203
+
204
+ const failPending = (error: Error) => {
205
+ for (const request of pending.values()) {
206
+ clearTimeout(request.timer);
207
+ request.reject(error);
208
+ }
209
+ pending.clear();
210
+ };
211
+ const failConnect = (error: Error) => {
212
+ if (connectSettled) return;
213
+ connectSettled = true;
214
+ clearTimeout(connectTimer);
215
+ rejectSession(error);
216
+ };
217
+ const connectTimer = setTimeout(() => {
218
+ failConnect(new Error(`Browser CDP WebSocket did not open within ${boundedTimeoutMs}ms.`));
219
+ try { socket.close(); } catch { /* best-effort bounded cleanup */ }
220
+ }, boundedTimeoutMs);
221
+
222
+ socket.onopen = () => {
223
+ if (connectSettled) return;
224
+ connectSettled = true;
225
+ clearTimeout(connectTimer);
226
+ resolveSession({
227
+ send: (method, params) => new Promise<unknown>((resolveResult, rejectResult) => {
228
+ const id = nextId++;
229
+ const timer = setTimeout(() => {
230
+ pending.delete(id);
231
+ rejectResult(new Error(`Browser CDP method ${method} timed out after ${boundedTimeoutMs}ms.`));
232
+ }, boundedTimeoutMs);
233
+ pending.set(id, { resolve: resolveResult, reject: rejectResult, timer });
234
+ try {
235
+ socket.send(JSON.stringify({ id, method, ...(params ? { params } : {}) }));
236
+ } catch (error) {
237
+ clearTimeout(timer);
238
+ pending.delete(id);
239
+ rejectResult(error instanceof Error ? error : new Error(String(error)));
240
+ }
241
+ }),
242
+ detach: async () => {
243
+ failPending(new Error('Browser CDP session detached.'));
244
+ try { socket.close(); } catch { /* best-effort bounded cleanup */ }
245
+ },
246
+ });
247
+ };
248
+ socket.onmessage = (event) => {
249
+ if (typeof event.data !== 'string') return;
250
+ let message: { id?: unknown; result?: unknown; error?: { message?: unknown } };
251
+ try { message = JSON.parse(event.data) as typeof message; }
252
+ catch { return; }
253
+ if (!Number.isInteger(message.id)) return;
254
+ const request = pending.get(Number(message.id));
255
+ if (!request) return;
256
+ pending.delete(Number(message.id));
257
+ clearTimeout(request.timer);
258
+ if (message.error) {
259
+ request.reject(new Error(typeof message.error.message === 'string' ? message.error.message : 'Browser CDP command failed.'));
260
+ } else {
261
+ request.resolve(message.result);
262
+ }
263
+ };
264
+ socket.onerror = () => {
265
+ const error = new Error('Browser CDP WebSocket transport failed.');
266
+ failConnect(error);
267
+ failPending(error);
268
+ };
269
+ socket.onclose = () => {
270
+ const error = new Error('Browser CDP WebSocket transport closed.');
271
+ failConnect(error);
272
+ failPending(error);
273
+ };
274
+ });
275
+ }
178
276
  type BrowserContextLike = {
179
277
  pages(): PageLike[];
180
278
  newPage(): Promise<PageLike>;
181
279
  close(): Promise<void>;
280
+ browser?(): BrowserLike | null;
281
+ serviceWorkers?(): BrowserExtensionTargetLike[];
282
+ backgroundPages?(): BrowserExtensionTargetLike[];
182
283
  };
183
284
 
184
285
  type BrowserLike = {
185
286
  contexts(): BrowserContextLike[];
186
287
  newContext?(): Promise<BrowserContextLike>;
288
+ newBrowserCDPSession?(): Promise<BrowserCdpSessionLike>;
187
289
  close?(): Promise<void>;
188
290
  disconnect?(): Promise<void> | void;
189
291
  };
@@ -251,20 +353,73 @@ type PlaywrightRuntime = {
251
353
  };
252
354
  };
253
355
 
356
+ export function browserDependencyRequireAnchors(repoRoot?: string, moduleUrl = import.meta.url): string[] {
357
+ const releasePackageManifest = join(dirname(fileURLToPath(moduleUrl)), 'package', 'package.json');
358
+ return [
359
+ existsSync(releasePackageManifest) ? releasePackageManifest : undefined,
360
+ repoRoot ? join(repoRoot, 'package.json') : undefined,
361
+ moduleUrl,
362
+ ].filter((anchor): anchor is string => typeof anchor === 'string');
363
+ }
364
+
254
365
  interface BrowserPluginRuntimeHooks {
255
366
  now(): string;
256
367
  moduleAvailable(name: string, repoRoot?: string): boolean;
257
368
  loadPlaywright(repoRoot?: string): PlaywrightRuntime;
258
369
  fetchJson(url: string, timeoutMs: number): Promise<unknown>;
259
370
  activateNativeBrowserApplication(input: AssistantPluginActionExecutionInput, product: MacOsBrowserProduct): Promise<void>;
371
+ /** Bounded process inventory used only to reclaim Forge-owned browser profiles. */
372
+ listProcesses(): Array<{ pid: number; command: string }>;
373
+ /** Signal one process; returns false when it already exited. */
374
+ signalProcess(pid: number, signal: NodeJS.Signals): boolean;
375
+ /** Launch one browser process without claiming its lifecycle through Playwright. */
376
+ launchProcess(executable: string, args: string[]): { pid?: number };
377
+ /** Allocate one currently-free loopback TCP port for an immediately following browser launch. */
378
+ allocateLoopbackPort(): Promise<number>;
379
+ /** Open one bounded browser-level CDP session without a Playwright browser/context adapter. */
380
+ connectBrowserCdp(endpoint: string, timeoutMs: number): Promise<BrowserCdpSessionLike>;
260
381
  }
261
382
 
262
383
  const defaultRuntimeHooks: BrowserPluginRuntimeHooks = {
263
384
  now: () => new Date().toISOString(),
385
+ listProcesses: () => {
386
+ const result = spawnSync('ps', ['-eo', 'pid=,command='], { encoding: 'utf8', timeout: 10_000, maxBuffer: 8 * 1024 * 1024 });
387
+ if (result.status !== 0 || typeof result.stdout !== 'string') return [];
388
+ const entries: Array<{ pid: number; command: string }> = [];
389
+ for (const line of result.stdout.split('\n')) {
390
+ const match = /^\s*(\d+)\s+(.*)$/.exec(line);
391
+ if (!match?.[1] || !match[2]) continue;
392
+ entries.push({ pid: Number(match[1]), command: match[2] });
393
+ }
394
+ return entries;
395
+ },
396
+ signalProcess: (pid, signal) => {
397
+ try { process.kill(pid, signal); return true; }
398
+ catch (error) { return (error as NodeJS.ErrnoException).code !== 'ESRCH'; }
399
+ },
400
+ launchProcess: (executable, args) => {
401
+ const child = spawn(executable, args, { stdio: 'ignore', detached: true });
402
+ child.once('error', () => undefined);
403
+ child.unref();
404
+ return { pid: child.pid };
405
+ },
406
+ allocateLoopbackPort: () => new Promise<number>((resolvePort, rejectPort) => {
407
+ const server = createNetServer();
408
+ server.unref();
409
+ server.once('error', rejectPort);
410
+ server.listen({ host: '127.0.0.1', port: 0, exclusive: true }, () => {
411
+ const address = server.address();
412
+ const port = typeof address === 'object' && address ? address.port : 0;
413
+ server.close((error) => {
414
+ if (error) return rejectPort(error);
415
+ if (!Number.isInteger(port) || port <= 0 || port > 65_535) return rejectPort(new Error('BROWSER_LOOPBACK_PORT_ALLOCATION_FAILED'));
416
+ resolvePort(port);
417
+ });
418
+ });
419
+ }),
420
+ connectBrowserCdp: connectNativeBrowserCdp,
264
421
  moduleAvailable: (name: string, repoRoot?: string) => {
265
- const anchors = [repoRoot ? join(repoRoot, 'package.json') : undefined, import.meta.url]
266
- .filter((value): value is string => Boolean(value));
267
- for (const anchor of anchors) {
422
+ for (const anchor of browserDependencyRequireAnchors(repoRoot)) {
268
423
  try {
269
424
  createRequire(anchor).resolve(name);
270
425
  return true;
@@ -275,16 +430,14 @@ const defaultRuntimeHooks: BrowserPluginRuntimeHooks = {
275
430
  return false;
276
431
  },
277
432
  loadPlaywright: (repoRoot?: string) => {
278
- const anchors = [repoRoot ? join(repoRoot, 'package.json') : undefined, import.meta.url]
279
- .filter((value): value is string => Boolean(value));
280
- for (const anchor of anchors) {
433
+ for (const anchor of browserDependencyRequireAnchors(repoRoot)) {
281
434
  try {
282
435
  return createRequire(anchor)('playwright') as PlaywrightRuntime;
283
436
  } catch {
284
437
  // Try the next trusted dependency anchor.
285
438
  }
286
439
  }
287
- throw new AssistantPluginError('PLUGIN_DEPENDENCY_MISSING', 'Browser plugin requires playwright. Run bun install before using browser actions.', {
440
+ throw new AssistantPluginError('PLUGIN_DEPENDENCY_MISSING', 'Browser plugin cannot resolve packaged Playwright or an authorized repository-local installation.', {
288
441
  retryable: false,
289
442
  });
290
443
  },
@@ -313,6 +466,7 @@ interface ManagedBrowserContextState {
313
466
  }
314
467
 
315
468
  const managedBrowserContexts = new Map<string, Promise<ManagedBrowserContextState>>();
469
+ const managedExtensionPaths = new Map<string, Set<string>>();
316
470
 
317
471
  export function setBrowserPluginRuntimeHooksForTest(hooks: Partial<BrowserPluginRuntimeHooks>): void {
318
472
  runtimeHooks = { ...defaultRuntimeHooks, ...hooks };
@@ -323,6 +477,7 @@ export function resetBrowserPluginRuntimeHooksForTest(): void {
323
477
  runtimeHooks = { ...defaultRuntimeHooks };
324
478
  runtimeHooksCustomized = false;
325
479
  managedBrowserContexts.clear();
480
+ managedExtensionPaths.clear();
326
481
  }
327
482
 
328
483
  function now(): string {
@@ -426,6 +581,8 @@ function resolveProfileSelection(repoRoot: string, profileDir: string, profileDi
426
581
 
427
582
  function normalizeConfig(raw: PersistedBrowserPluginConfig): BrowserPluginConfig {
428
583
  const normalizedProfileDir = stringValue(raw.profileDir);
584
+ const normalizedExecutablePath = stringValue(raw.executablePath);
585
+ const normalizedBrowserChannel = browserChannel(raw.browserChannel);
429
586
  return {
430
587
  schemaVersion: CURRENT_BROWSER_CONFIG_SCHEMA_VERSION,
431
588
  enabled: raw.enabled === true,
@@ -434,8 +591,8 @@ function normalizeConfig(raw: PersistedBrowserPluginConfig): BrowserPluginConfig
434
591
  profileMode: browserProfileMode(raw.profileMode) ?? (normalizedProfileDir ? 'custom' : 'repo_local'),
435
592
  profileDir: normalizedProfileDir,
436
593
  profileDirectory: stringValue(raw.profileDirectory),
437
- browserChannel: browserChannel(raw.browserChannel) ?? DEFAULT_USER_BROWSER_CHANNEL,
438
- executablePath: stringValue(raw.executablePath),
594
+ browserChannel: normalizedBrowserChannel ?? (normalizedExecutablePath ? undefined : DEFAULT_USER_BROWSER_CHANNEL),
595
+ executablePath: normalizedExecutablePath,
439
596
  cdpEndpoint: stringValue(raw.cdpEndpoint),
440
597
  cdpEndpointCandidates: stringList(raw.cdpEndpointCandidates)?.slice(0, MAX_BROWSER_CDP_ENDPOINT_CANDIDATES),
441
598
  cdpDiscoveryTimeoutMs: typeof raw.cdpDiscoveryTimeoutMs === 'number'
@@ -449,22 +606,38 @@ function normalizeConfig(raw: PersistedBrowserPluginConfig): BrowserPluginConfig
449
606
  }
450
607
 
451
608
  function migrateLegacyRepositoryConfig(raw: PersistedBrowserPluginConfig): PersistedBrowserPluginConfig {
452
- if (raw.schemaVersion !== 1) return raw;
609
+ if (raw.schemaVersion === CURRENT_BROWSER_CONFIG_SCHEMA_VERSION) return raw;
610
+ // v1 legacy managed profiles were an implicit route to a fresh browser.
611
+ // Migrate that default to the existing signed-in native browser; preserve
612
+ // explicit v2 managed configurations as separate scripting capabilities.
613
+ const legacyV1 = raw.schemaVersion === 1;
453
614
  return {
615
+ ...raw,
454
616
  schemaVersion: CURRENT_BROWSER_CONFIG_SCHEMA_VERSION,
455
- enabled: raw.enabled,
456
- provider: 'playwright',
457
- browserMode: 'attach_preferred',
458
- profileMode: 'repo_local',
459
- browserChannel: DEFAULT_USER_BROWSER_CHANNEL,
460
- cdpAttachFallback: 'fail_closed',
461
- nativeAttachMode: 'auto',
462
- nativeBrowserCandidates: [...DEFAULT_USER_NATIVE_BROWSER_CANDIDATES],
463
- defaultTimeoutMs: raw.defaultTimeoutMs,
464
- cdpDiscoveryTimeoutMs: raw.cdpDiscoveryTimeoutMs,
617
+ browserMode: legacyV1 ? 'attach_preferred' : (browserMode(raw.browserMode) ?? 'attach_preferred'),
618
+ cdpAttachFallback: legacyV1 ? 'fail_closed' : (raw.cdpAttachFallback ?? 'fail_closed'),
619
+ nativeAttachMode: legacyV1 ? 'auto' : (raw.nativeAttachMode ?? 'auto'),
620
+ ...(legacyV1 ? {
621
+ profileMode: 'repo_local' as const, profileDir: undefined, profileDirectory: undefined,
622
+ browserChannel: DEFAULT_USER_BROWSER_CHANNEL, nativeBrowserCandidates: [...DEFAULT_USER_NATIVE_BROWSER_CANDIDATES],
623
+ } : {}),
465
624
  };
466
625
  }
467
626
 
627
+ export function normalizeLegacyBrowserConfig(raw: unknown): BrowserPluginConfig {
628
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
629
+ throw new Error('PLUGIN_CONFIG_MIGRATION_INVALID: browser config must be an object');
630
+ }
631
+ const parsed = raw as PersistedBrowserPluginConfig;
632
+ if (![1, 2, 3].includes(parsed.schemaVersion as number)) {
633
+ throw new Error('PLUGIN_CONFIG_MIGRATION_INVALID: unsupported browser schemaVersion');
634
+ }
635
+ const migrated = normalizeConfig(migrateLegacyRepositoryConfig(parsed));
636
+ const errors = validateConfig(migrated);
637
+ if (errors.length) throw new Error(`PLUGIN_CONFIG_MIGRATION_INVALID: ${errors[0]}`);
638
+ return migrated;
639
+ }
640
+
468
641
  function readJson<T>(path: string): T | undefined {
469
642
  try {
470
643
  return JSON.parse(readFileSync(path, 'utf-8')) as T;
@@ -478,28 +651,63 @@ function writeJson(path: string, value: unknown): void {
478
651
  writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, 'utf-8');
479
652
  }
480
653
 
481
- function loadConfig(repoRoot: string, context?: Pick<RepositoryPluginConfigContext, 'controllerHome' | 'repoId'>): BrowserPluginConfig {
654
+ function loadConfig(
655
+ repoRoot: string,
656
+ context?: Pick<RepositoryPluginConfigContext, 'controllerHome' | 'repoId'>,
657
+ computerNativeOnly = false,
658
+ ): BrowserPluginConfig {
659
+ // Computer Browser is a built-in Forge capability, not an optional
660
+ // repository Browser-script plugin. Its native defaults ship with the
661
+ // first-party Computer adapter; never inherit an independent Playwright
662
+ // profile, enabled flag, CDP endpoint, or fallback from a repository.
663
+ if (computerNativeOnly) return normalizeConfig({
664
+ enabled: true, browserMode: 'attach_preferred',
665
+ nativeAttachMode: 'auto', cdpAttachFallback: 'fail_closed',
666
+ nativeBrowserCandidates: [...DEFAULT_USER_NATIVE_BROWSER_CANDIDATES],
667
+ });
482
668
  const persisted = context
483
669
  ? readRepositoryPluginConfig<PersistedBrowserPluginConfig>({ ...context, repoRoot }, BROWSER_PLUGIN_ID)
484
670
  : undefined;
485
- if (!persisted) return normalizeConfig({});
486
- return normalizeConfig(migrateLegacyRepositoryConfig(persisted));
671
+ return persisted ? normalizeConfig(migrateLegacyRepositoryConfig(persisted)) : normalizeConfig({});
487
672
  }
488
673
 
489
674
  async function resolveBrowserPluginAuthorizationContextInternal(
490
675
  input: AssistantPluginActionExecutionInput,
491
676
  ): Promise<AssistantPluginAuthorizationContext | undefined> {
492
677
  const action = browserActions().find((entry) => entry.actionId === input.actionId);
493
- if (!action || action.confirmation !== 'authorization' || !action.scopes.includes('browser.interact')) return undefined;
494
-
495
- const persistedConfig = loadConfig(input.repoRoot, input);
678
+ // Browser session/profile mutations also need an exact reusable target. In
679
+ // particular, create_session is the bootstrap action for a scheduled
680
+ // Controller round and intentionally carries browser.read + browser.profile
681
+ // rather than browser.interact. Restricting authorization resolution to
682
+ // browser.interact made the first interactive session usable only for that
683
+ // call and left the Scheduler without a durable browser-session grant.
684
+ if (!action || action.confirmation !== 'authorization' || !action.scopes.includes('browser.profile')) return undefined;
685
+
686
+ const persistedConfig = loadConfig(input.repoRoot, input, input.pluginId === 'computer');
496
687
  const sessionId = stringValue(input.args.session_id);
497
688
  const authorizationBrowserMode = parseBrowserModeInput(input.args.browser_mode) ?? persistedConfig.browserMode;
498
689
  if (!sessionId || authorizationBrowserMode === 'isolated') return undefined;
499
690
  const session = findBrowserSession(input.repoRoot, sessionId);
500
691
  const config = effectiveBrowserActionConfig(persistedConfig, input.args, session);
501
692
  const connection = session?.browser;
502
- if (!session || !connection || connection.activeMode === 'isolated') return undefined;
693
+ if (!session || !connection || connection.activeMode === 'isolated') {
694
+ // create_session precedes its durable session record. Recover only the
695
+ // exact browser-origin target already named by this Work's grant refs.
696
+ if (input.actionId !== 'create_session') return undefined;
697
+ let origin: string;
698
+ try { origin = new URL(stringValue(input.args.url) ?? '').origin.toLowerCase(); } catch { return undefined; }
699
+ const refs = new Set((input.authorizationGrantRefs ?? []).map((ref) => ref.trim()).filter(Boolean));
700
+ const target = listCanonicalGrants(input.controllerHome).find((grant) => (
701
+ refs.has(grant.grantId)
702
+ && grant.target?.kind === 'browser-origin'
703
+ && grant.target.id.endsWith(`@${origin}`)
704
+ && grant.scopes?.includes('browser.profile')
705
+ ))?.target;
706
+ return target ? {
707
+ target: { kind: target.kind, id: target.id, identityFingerprint: target.identityFingerprint },
708
+ expiresInMinutes: 30 * 24 * 60,
709
+ } : undefined;
710
+ }
503
711
 
504
712
  let origin: string;
505
713
  try {
@@ -549,6 +757,10 @@ export async function resolveBrowserPluginAuthorizationContext(
549
757
 
550
758
  function saveConfig(input: Pick<AssistantPluginActionExecutionInput, 'controllerHome' | 'repoId' | 'repoRoot'>, patch: Partial<BrowserPluginConfig>): BrowserPluginConfig {
551
759
  const next = normalizeConfig({ ...loadConfig(input.repoRoot, input), ...patch });
760
+ const errors = validateConfig(next);
761
+ if (errors.length > 0) {
762
+ throw new AssistantPluginError('PLUGIN_CONFIGURATION_INVALID', errors[0], { retryable: false });
763
+ }
552
764
  return writeRepositoryPluginConfig(input, BROWSER_PLUGIN_ID, next);
553
765
  }
554
766
 
@@ -740,11 +952,11 @@ function validateConfig(config: BrowserPluginConfig): string[] {
740
952
  const error = cdpEndpointValidationError(endpoint);
741
953
  if (error) errors.push(error);
742
954
  }
743
- if (config.browserMode === 'attach_preferred'
744
- && endpoints.length === 0
745
- && config.cdpAttachFallback === 'fail_closed'
746
- && config.nativeAttachMode === 'disabled') {
747
- errors.push('A CDP endpoint or nativeAttachMode=auto is required when browserMode=attach_preferred and cdpAttachFallback=fail_closed.');
955
+ if (config.browserMode === 'attach_preferred') {
956
+ if (endpoints.length > 1) errors.push('One Browser attach capability may bind only one CDP endpoint.');
957
+ if (config.cdpAttachFallback === 'managed_persistent') errors.push('Managed Browser is an explicit browser_mode, never an attach fallback.');
958
+ // The configured list is discovery scope; execution must bind exactly one
959
+ // provider from the actual tab/product evidence, never select by fallback.
748
960
  }
749
961
  return errors;
750
962
  }
@@ -757,14 +969,8 @@ function configWarnings(config: BrowserPluginConfig): string[] {
757
969
  warnings.push('Custom profile mode is more reliable with an explicit Chrome channel or executable path that matches the selected profile format.');
758
970
  }
759
971
  }
760
- if (config.browserMode === 'attach_preferred' && cdpEndpoints(config).length === 0) {
761
- if (config.nativeAttachMode === 'auto' && macOsActiveBrowserAttachSupported()) {
762
- warnings.push(config.cdpAttachFallback === 'fail_closed'
763
- ? 'browserMode=attach_preferred has no configured CDP endpoint; configured native-browser attach will be attempted and managed fallback is disabled.'
764
- : 'browserMode=attach_preferred has no configured CDP endpoint; configured native-browser attach will be attempted before explicit managed fallback.');
765
- } else {
766
- warnings.push('browserMode=attach_preferred has no configured CDP endpoint; actions will use the configured fallback policy.');
767
- }
972
+ if (config.browserMode === 'attach_preferred' && cdpEndpoints(config).length === 0 && config.nativeAttachMode === 'auto') {
973
+ warnings.push('Native browser attach is the single selected provider; CDP and managed browsers are not automatic alternatives.');
768
974
  }
769
975
  if (config.browserMode === 'isolated') {
770
976
  warnings.push('browserMode=isolated uses a per-session repo-local profile and will not share the default browser profile.');
@@ -1056,7 +1262,7 @@ interface BrowserOpenHandle {
1056
1262
 
1057
1263
  interface PageDiagnostics {
1058
1264
  consoleErrors: Array<{ type: string; text: string }>;
1059
- failedRequests: Array<{ url: string; status?: number; failure?: string }>;
1265
+ failedRequests: Array<{ url: string; method?: string; status?: number; failure?: string }>;
1060
1266
  navigation?: { url: string; status?: number };
1061
1267
  }
1062
1268
 
@@ -1074,18 +1280,21 @@ function attachDiagnostics(page: PageLike): PageDiagnostics {
1074
1280
  }
1075
1281
  });
1076
1282
  page.on('requestfailed', (request) => {
1077
- const entry = request as { url?: () => string; failure?: () => { errorText?: string } | null };
1283
+ const entry = request as { url?: () => string; method?: () => string; failure?: () => { errorText?: string } | null };
1078
1284
  diagnostics.failedRequests.push({
1079
1285
  url: typeof entry.url === 'function' ? entry.url() : '',
1286
+ method: typeof entry.method === 'function' ? entry.method() : undefined,
1080
1287
  failure: typeof entry.failure === 'function' ? entry.failure()?.errorText : undefined,
1081
1288
  });
1082
1289
  });
1083
1290
  page.on('response', (response) => {
1084
- const entry = response as { url?: () => string; status?: () => number; ok?: () => boolean };
1291
+ const entry = response as { url?: () => string; status?: () => number; ok?: () => boolean; request?: () => { method?: () => string } };
1085
1292
  const status = typeof entry.status === 'function' ? entry.status() : undefined;
1086
1293
  if (typeof status === 'number' && status >= 400) {
1294
+ const request = typeof entry.request === 'function' ? entry.request() : undefined;
1087
1295
  diagnostics.failedRequests.push({
1088
1296
  url: typeof entry.url === 'function' ? entry.url() : '',
1297
+ method: typeof request?.method === 'function' ? request.method() : undefined,
1089
1298
  status,
1090
1299
  });
1091
1300
  }
@@ -1214,54 +1423,7 @@ function sameOrigin(left: string, right: string): boolean {
1214
1423
  }
1215
1424
  }
1216
1425
 
1217
- function stableBrowserEntityKey(value: string): string | undefined {
1218
- try {
1219
- const url = new URL(value);
1220
- if (url.protocol !== 'https:' && url.protocol !== 'http:') return undefined;
1221
- const segments = url.pathname.split('/').filter(Boolean);
1222
- for (let index = 0; index + 1 < segments.length; index += 1) {
1223
- if ((segments[index] ?? '').toLowerCase() !== 'apps') continue;
1224
- const id = segments[index + 1]?.trim();
1225
- if (id) return `${url.origin}/apps/${id}`;
1226
- }
1227
- return undefined;
1228
- } catch {
1229
- return undefined;
1230
- }
1231
- }
1232
-
1233
- function selectReusableNativeTab(
1234
- referenceUrls: string[],
1235
- inventory: Awaited<ReturnType<typeof listMacOsBrowserTabs>>,
1236
- preferredWindowId?: string,
1237
- ): {
1238
- candidate?: (typeof inventory.tabs)[number];
1239
- match: 'exact_url' | 'stable_entity' | 'none' | 'ambiguous';
1240
- candidateCount: number;
1241
- } {
1242
- const normalizedReferences = [...new Set(referenceUrls.filter(Boolean).map(comparableUrl))];
1243
- const entityKeys = new Set(normalizedReferences.map(stableBrowserEntityKey).filter((value): value is string => Boolean(value)));
1244
- const scored = inventory.tabs.map((candidate) => {
1245
- const normalizedCandidate = comparableUrl(candidate.url);
1246
- const entityKey = stableBrowserEntityKey(candidate.url);
1247
- const matchScore = normalizedReferences.includes(normalizedCandidate)
1248
- ? 100
1249
- : entityKey && entityKeys.has(entityKey)
1250
- ? 50
1251
- : 0;
1252
- const score = matchScore === 0
1253
- ? 0
1254
- : matchScore + (preferredWindowId && candidate.windowId === preferredWindowId ? 10 : 0) + (candidate.active ? 1 : 0);
1255
- return { candidate, score, matchScore };
1256
- });
1257
- const maxScore = scored.reduce((max, entry) => Math.max(max, entry.score), 0);
1258
- if (maxScore === 0) return { match: 'none', candidateCount: 0 };
1259
- const matches = scored.filter((entry) => entry.score === maxScore);
1260
- if (matches.length !== 1) return { match: 'ambiguous', candidateCount: matches.length };
1261
- return { candidate: matches[0]!.candidate, match: matches[0]!.matchScore === 100 ? 'exact_url' : 'stable_entity', candidateCount: 1 };
1262
- }
1263
-
1264
- function selectLiveForgeOwnedNativeTab(
1426
+ export function selectLiveForgeOwnedNativeTab(
1265
1427
  repoRoot: string,
1266
1428
  targetUrl: string,
1267
1429
  product: MacOsBrowserProduct,
@@ -1287,11 +1449,11 @@ function selectLiveForgeOwnedNativeTab(
1287
1449
  }
1288
1450
 
1289
1451
  const values = [...candidates.values()];
1452
+ if (inventory.truncated) return { match: 'ambiguous', candidateCount: values.length };
1290
1453
  const exact = values.filter(({ candidate }) => comparableUrl(candidate.url) === comparableUrl(targetUrl));
1291
1454
  const choose = (matches: typeof values, match: 'exact_url' | 'same_origin') => {
1292
1455
  if (matches.length === 1) return { ...matches[0], match, candidateCount: 1 } as const;
1293
- const active = matches.filter(({ candidate }) => candidate.active);
1294
- if (active.length === 1) return { ...active[0], match, candidateCount: matches.length } as const;
1456
+ // Multiple matching tabs remain ambiguous even when one is active.
1295
1457
  return { match: 'ambiguous' as const, candidateCount: matches.length };
1296
1458
  };
1297
1459
  if (exact.length > 0) return choose(exact, 'exact_url');
@@ -1301,15 +1463,15 @@ function selectLiveForgeOwnedNativeTab(
1301
1463
  return { match: 'none', candidateCount: 0 };
1302
1464
  }
1303
1465
 
1304
- function selectExactNativeTab(
1466
+ export function selectExactNativeTab(
1305
1467
  targetUrl: string,
1306
1468
  inventory: Awaited<ReturnType<typeof listMacOsBrowserTabs>>,
1307
1469
  ): { candidate?: (typeof inventory.tabs)[number]; match: 'exact_url' | 'none' | 'ambiguous'; candidateCount: number } {
1470
+ if (inventory.truncated) return { match: 'ambiguous', candidateCount: inventory.tabs.length };
1308
1471
  const matches = inventory.tabs.filter((candidate) => comparableUrl(candidate.url) === comparableUrl(targetUrl));
1309
1472
  if (matches.length === 0) return { match: 'none', candidateCount: 0 };
1310
1473
  if (matches.length === 1) return { candidate: matches[0], match: 'exact_url', candidateCount: 1 };
1311
- const active = matches.filter((candidate) => candidate.active);
1312
- if (active.length === 1) return { candidate: active[0], match: 'exact_url', candidateCount: matches.length };
1474
+ // A frontmost tab does not disambiguate two tabs with the same URL.
1313
1475
  return { match: 'ambiguous', candidateCount: matches.length };
1314
1476
  }
1315
1477
 
@@ -1342,7 +1504,7 @@ async function inventoryTabs(context: BrowserContextLike): Promise<BrowserTabInv
1342
1504
  return entries;
1343
1505
  }
1344
1506
 
1345
- function chooseTab(
1507
+ export function chooseTab(
1346
1508
  inventory: BrowserTabInventoryEntry[],
1347
1509
  target: BrowserActionTarget,
1348
1510
  options: { allowBlank?: boolean; requireOwnedToken?: boolean } = {},
@@ -1350,6 +1512,20 @@ function chooseTab(
1350
1512
  const desiredUrl = comparableUrl(target.url);
1351
1513
  const savedTab = target.existingSession?.browser?.tab;
1352
1514
  const expectedOwnerToken = savedTab?.ownership === 'plugin_owned' ? savedTab.ownerToken : undefined;
1515
+ const unique = (matches: BrowserTabInventoryEntry[], basis: string): BrowserTabInventoryEntry | undefined => {
1516
+ if (matches.length > 1) {
1517
+ throw new AssistantPluginError('PLUGIN_BROWSER_REUSABLE_TAB_AMBIGUOUS',
1518
+ 'Multiple browser tabs satisfy the same lookup; exact tab identity is not proven.',
1519
+ { retryable: false, details: { sessionId: target.sessionId, basis, candidateCount: matches.length } });
1520
+ }
1521
+ return matches[0];
1522
+ };
1523
+ if (options.requireOwnedToken && expectedOwnerToken
1524
+ && inventory.filter((entry) => entry.ownerToken === expectedOwnerToken).length > 1) {
1525
+ throw new AssistantPluginError('PLUGIN_BROWSER_REUSABLE_TAB_AMBIGUOUS',
1526
+ 'Multiple browser tabs report the same owned marker.',
1527
+ { retryable: false, details: { sessionId: target.sessionId, basis: 'owned_token' } });
1528
+ }
1353
1529
  if (options.requireOwnedToken) {
1354
1530
  const owned = expectedOwnerToken ? inventory.find((entry) => entry.ownerToken === expectedOwnerToken) : undefined;
1355
1531
  if (owned) {
@@ -1367,25 +1543,8 @@ function chooseTab(
1367
1543
  };
1368
1544
  }
1369
1545
  const savedUrl = savedTab?.url ? comparableUrl(savedTab.url) : undefined;
1370
- const savedTitle = savedTab?.title;
1371
- const savedUrlTitle = savedUrl && savedTitle
1372
- ? inventory.find((entry) => comparableUrl(entry.url) === savedUrl && entry.title === savedTitle)
1373
- : undefined;
1374
- if (savedUrlTitle) {
1375
- return {
1376
- entry: savedUrlTitle,
1377
- matchedBy: 'saved_url_title',
1378
- sessionResume: {
1379
- sessionId: target.sessionId,
1380
- status: 'matched',
1381
- reason: 'Saved tab URL and title matched an attached browser tab.',
1382
- savedTab,
1383
- },
1384
- };
1385
- }
1386
-
1387
1546
  const savedUrlOnly = savedUrl
1388
- ? inventory.find((entry) => comparableUrl(entry.url) === savedUrl)
1547
+ ? unique(inventory.filter((entry) => comparableUrl(entry.url) === savedUrl), 'saved_url')
1389
1548
  : undefined;
1390
1549
  if (savedUrlOnly) {
1391
1550
  return {
@@ -1394,13 +1553,13 @@ function chooseTab(
1394
1553
  sessionResume: {
1395
1554
  sessionId: target.sessionId,
1396
1555
  status: savedTab ? 'matched' : 'no_saved_tab',
1397
- reason: savedTab ? 'Saved tab URL matched but the previous title was not present.' : 'No saved tab metadata was available.',
1556
+ reason: savedTab ? 'Saved tab URL was unique in this browser context; page title was not used as identity.' : 'No saved tab metadata was available.',
1398
1557
  savedTab,
1399
1558
  },
1400
1559
  };
1401
1560
  }
1402
1561
 
1403
- const exactUrl = inventory.find((entry) => comparableUrl(entry.url) === desiredUrl);
1562
+ const exactUrl = unique(inventory.filter((entry) => comparableUrl(entry.url) === desiredUrl), 'exact_url');
1404
1563
  if (exactUrl) {
1405
1564
  return {
1406
1565
  entry: exactUrl,
@@ -1420,7 +1579,7 @@ function chooseTab(
1420
1579
  };
1421
1580
  }
1422
1581
 
1423
- const blank = options.allowBlank === false ? undefined : inventory.find((entry) => isBlankPage(entry.url));
1582
+ const blank = options.allowBlank === false ? undefined : unique(inventory.filter((entry) => isBlankPage(entry.url)), 'blank');
1424
1583
  if (blank) {
1425
1584
  return {
1426
1585
  entry: blank,
@@ -1544,14 +1703,71 @@ async function discoverCdpEndpoint(endpoint: string, timeoutMs: number): Promise
1544
1703
  };
1545
1704
  }
1546
1705
 
1706
+ async function waitForCdpEndpoint(endpoint: string, timeoutMs: number): Promise<CdpAttachAttempt> {
1707
+ const deadline = Date.now() + Math.max(250, timeoutMs);
1708
+ let lastError: unknown;
1709
+ while (Date.now() < deadline) {
1710
+ try {
1711
+ return await discoverCdpEndpoint(endpoint, Math.max(100, Math.min(500, deadline - Date.now())));
1712
+ } catch (error) {
1713
+ lastError = error;
1714
+ await delay(50);
1715
+ }
1716
+ }
1717
+ throw new AssistantPluginError('PLUGIN_BROWSER_EXTENSION_CONTROL_UNAVAILABLE', 'Vivaldi did not expose the allocated loopback CDP endpoint after launch.', {
1718
+ retryable: true,
1719
+ details: { endpoint, error: lastError instanceof Error ? lastError.message : String(lastError ?? 'unknown') },
1720
+ });
1721
+ }
1722
+
1723
+ async function browserCdpExtensionTarget(session: BrowserCdpSessionLike, extensionId: string): Promise<string | undefined> {
1724
+ const response = await session.send('Target.getTargets') as { targetInfos?: Array<{ url?: unknown }> };
1725
+ const prefix = `chrome-extension://${extensionId}/`;
1726
+ return (response.targetInfos ?? [])
1727
+ .map((target) => typeof target.url === 'string' ? target.url : '')
1728
+ .find((url) => url.startsWith(prefix));
1729
+ }
1730
+
1731
+ async function waitForBrowserCdpExtensionTarget(session: BrowserCdpSessionLike, extensionId: string, timeoutMs: number): Promise<string> {
1732
+ const deadline = Date.now() + Math.min(Math.max(timeoutMs, 250), 10_000);
1733
+ while (Date.now() <= deadline) {
1734
+ const target = await browserCdpExtensionTarget(session, extensionId);
1735
+ if (target) return target;
1736
+ await delay(100);
1737
+ }
1738
+ throw new AssistantPluginError('PLUGIN_BROWSER_EXTENSION_POSTCONDITION_FAILED', 'Browser-level CDP did not expose an exact runtime target for the requested extension id.', {
1739
+ retryable: true,
1740
+ details: { extensionId },
1741
+ });
1742
+ }
1743
+
1547
1744
  function launchOptionsForRepo(repoRoot: string, config: BrowserPluginConfig, profile: BrowserProfileSelection): Record<string, unknown> {
1745
+ const extensionPaths = [...(managedExtensionPaths.get(managedContextKey(profile)) ?? [])].sort();
1746
+ const args = [
1747
+ ...(profile.profileDirectory ? [`--profile-directory=${profile.profileDirectory}`] : []),
1748
+ ...(extensionPaths.length > 0 ? ['--enable-unsafe-extension-debugging'] : []),
1749
+ ];
1750
+ const customProfileDefaultArgsToPreserve = [
1751
+ '--disable-extensions',
1752
+ '--disable-component-extensions-with-background-pages',
1753
+ '--disable-default-apps',
1754
+ '--disable-sync',
1755
+ '--use-mock-keychain',
1756
+ '--password-store=basic',
1757
+ ];
1758
+ const ignoreDefaultArgs = config.profileMode === 'custom'
1759
+ ? customProfileDefaultArgsToPreserve
1760
+ : extensionPaths.length > 0
1761
+ ? ['--disable-extensions']
1762
+ : [];
1548
1763
  return {
1549
1764
  headless: false,
1550
1765
  acceptDownloads: true,
1551
1766
  viewport: { width: 1280, height: 900 },
1552
1767
  ...(config.executablePath ? { executablePath: resolveConfiguredPath(repoRoot, config.executablePath) } : {}),
1553
- ...(!config.executablePath && config.browserChannel && config.browserChannel !== 'chromium' ? { channel: config.browserChannel } : {}),
1554
- ...(profile.profileDirectory ? { args: [`--profile-directory=${profile.profileDirectory}`] } : {}),
1768
+ ...(!config.executablePath && extensionPaths.length === 0 && config.browserChannel && config.browserChannel !== 'chromium' ? { channel: config.browserChannel } : {}),
1769
+ ...(ignoreDefaultArgs.length > 0 ? { ignoreDefaultArgs } : {}),
1770
+ ...(args.length > 0 ? { args } : {}),
1555
1771
  };
1556
1772
  }
1557
1773
 
@@ -1571,8 +1787,87 @@ async function evictManagedContext(key: string): Promise<void> {
1571
1787
  }
1572
1788
  }
1573
1789
 
1790
+ /**
1791
+ * Forge-owned isolated/handoff browser profiles are per-session and cannot be
1792
+ * shared, so a browser still running on one of those profiles while no managed
1793
+ * context in this Runtime incarnation owns it is an orphan: the session that
1794
+ * launched it is gone. Left alone it stays visible as a logged-out browser the
1795
+ * user never opened and keeps a stale profile locked.
1796
+ *
1797
+ * Reclamation is mechanical and bounded: only processes whose own command line
1798
+ * names a Forge-owned profile path are considered, browser child processes
1799
+ * (`--type=`) are ignored so the main process is signalled once, and a profile
1800
+ * with a live managed context is never touched.
1801
+ */
1802
+ export function reclaimOrphanedIsolatedBrowserProfiles(input: { profileRoots: readonly string[]; graceMs?: number }): { terminated: number; details: string[] } {
1803
+ const roots = input.profileRoots.map((root) => resolve(root)).filter(Boolean);
1804
+ if (roots.length === 0) return { terminated: 0, details: [] };
1805
+ const owned = (profileDir: string): boolean => {
1806
+ const normalized = resolve(profileDir);
1807
+ const isForgeProfile = /\/browser\/profiles\/(?:isolated|handoff)\//.test(normalized);
1808
+ if (!isForgeProfile) return false;
1809
+ return roots.some((root) => normalized === root || normalized.startsWith(`${root}${sep}`));
1810
+ };
1811
+ const liveOwners = new Set([...managedBrowserContexts.keys()].map((key) => resolve(key)));
1812
+ const graceMs = Math.max(0, Math.min(30_000, input.graceMs ?? 2_000));
1813
+ const targets: Array<{ pid: number; profileDir: string }> = [];
1814
+ for (const entry of runtimeHooks.listProcesses()) {
1815
+ if (entry.command.includes('--type=')) continue;
1816
+ const match = /--user-data-dir=(?:"([^"]+)"|'([^']+)'|(\S+))/.exec(entry.command);
1817
+ const profileDir = match?.[1] ?? match?.[2] ?? match?.[3];
1818
+ if (!profileDir || !owned(profileDir)) continue;
1819
+ if (liveOwners.has(resolve(profileDir))) continue;
1820
+ targets.push({ pid: entry.pid, profileDir: resolve(profileDir) });
1821
+ }
1822
+ const details: string[] = [];
1823
+ let terminated = 0;
1824
+ for (const target of targets) {
1825
+ runtimeHooks.signalProcess(target.pid, 'SIGTERM');
1826
+ details.push(`SIGTERM ${target.pid} ${target.profileDir}`);
1827
+ terminated += 1;
1828
+ }
1829
+ if (graceMs > 0 && targets.length > 0) {
1830
+ const deadline = Date.now() + graceMs;
1831
+ while (Date.now() < deadline) {
1832
+ const remaining = new Set(targets
1833
+ .filter((target) => runtimeHooks.listProcesses().some((entry) => entry.pid === target.pid && !entry.command.includes('--type=')))
1834
+ .map((target) => target.pid));
1835
+ if (remaining.size === 0) break;
1836
+ sleepSyncMs(50);
1837
+ }
1838
+ for (const target of targets) {
1839
+ const alive = runtimeHooks.listProcesses().some((entry) => entry.pid === target.pid && !entry.command.includes('--type='));
1840
+ if (!alive) continue;
1841
+ runtimeHooks.signalProcess(target.pid, 'SIGKILL');
1842
+ details.push(`SIGKILL ${target.pid} ${target.profileDir}`);
1843
+ }
1844
+ }
1845
+ return { terminated, details };
1846
+ }
1847
+
1848
+ function sleepSyncMs(ms: number): void {
1849
+ try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.max(0, Math.min(1_000, ms))); }
1850
+ catch { /* best-effort bounded wait */ }
1851
+ }
1852
+
1853
+ export function browserProfileRootsForRuntime(input: { controllerHome: string; repoRoots?: readonly string[] }): string[] {
1854
+ const roots = new Set<string>();
1855
+ const home = input.controllerHome.trim();
1856
+ if (home) roots.add(join(resolve(home), 'repositories'));
1857
+ for (const repoRoot of input.repoRoots ?? []) {
1858
+ const trimmed = repoRoot.trim();
1859
+ if (trimmed) roots.add(join(resolve(trimmed), '.forge'));
1860
+ }
1861
+ return [...roots];
1862
+ }
1863
+
1574
1864
  async function closeManagedContextsForRepo(repoRoot: string, options: { strict?: boolean } = {}): Promise<void> {
1575
1865
  const canonicalRoot = resolve(repoRoot);
1866
+ // Explicit session cleanup must also retire a browser whose session record is
1867
+ // gone; otherwise the profile stays locked by a process nothing owns.
1868
+ reclaimOrphanedIsolatedBrowserProfiles({
1869
+ profileRoots: browserProfileRootsForRuntime({ controllerHome: resolveRepoPreferredControllerHome(canonicalRoot), repoRoots: [canonicalRoot] }),
1870
+ });
1576
1871
  for (const [key, pending] of [...managedBrowserContexts.entries()]) {
1577
1872
  let state: ManagedBrowserContextState;
1578
1873
  try {
@@ -1884,6 +2179,342 @@ async function openManagedContext(
1884
2179
  };
1885
2180
  }
1886
2181
 
2182
+ const FORGE_NATIVE_MESSAGING_DECLARATION = 'forge-native-messaging-host.json';
2183
+ interface ManagedNativeMessagingHost {
2184
+ name: string;
2185
+ manifest: string;
2186
+ }
2187
+ interface BrowserExtensionInfo {
2188
+ id: string;
2189
+ name?: string;
2190
+ version?: string;
2191
+ path: string;
2192
+ enabled: boolean;
2193
+ }
2194
+
2195
+ function pathWithin(root: string, candidate: string): boolean {
2196
+ const rel = relative(root, candidate);
2197
+ return rel === '' || (!rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel));
2198
+ }
2199
+
2200
+ function unpackedExtensionPath(input: AssistantPluginActionExecutionInput): { path: string; expectedId?: string; nativeHost?: ManagedNativeMessagingHost } {
2201
+ const requested = requiredString(input.args.extension_path, 'extension_path');
2202
+ if (!isAbsolute(requested)) throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'extension_path must be absolute.', { retryable: false });
2203
+ if (!existsSync(requested) || !statSync(requested).isDirectory()) {
2204
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'extension_path must reference an existing directory.', { retryable: false });
2205
+ }
2206
+ const realPath = realpathSync(requested);
2207
+ const trustedRoots = [realpathSync(input.repoRoot)];
2208
+ const controllerBrowserRoot = join(resolve(input.controllerHome), 'supervisor', 'browser-adapter');
2209
+ if (existsSync(controllerBrowserRoot)) trustedRoots.push(realpathSync(controllerBrowserRoot));
2210
+ if (!trustedRoots.some((root) => pathWithin(root, realPath))) {
2211
+ throw new AssistantPluginError('PLUGIN_POLICY_BLOCKED', 'Unpacked extensions must resolve inside the repository or Controller-owned browser-adapter root.', { retryable: false });
2212
+ }
2213
+ const manifestPath = join(realPath, 'manifest.json');
2214
+ if (!existsSync(manifestPath) || !statSync(manifestPath).isFile() || !pathWithin(realPath, realpathSync(manifestPath))) {
2215
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'Unpacked extension requires a regular manifest.json inside the extension directory.', { retryable: false });
2216
+ }
2217
+ let manifest: { key?: unknown };
2218
+ try { manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { key?: unknown }; }
2219
+ catch { throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'Unpacked extension manifest.json must contain valid JSON.', { retryable: false }); }
2220
+ let expectedId: string | undefined;
2221
+ if (typeof manifest.key === 'string' && /^[A-Za-z0-9+/]+={0,2}$/.test(manifest.key.trim())) {
2222
+ const bytes = Buffer.from(manifest.key.trim(), 'base64');
2223
+ if (bytes.length > 0) expectedId = createHash('sha256').update(bytes).digest('hex').slice(0, 32)
2224
+ .replace(/[0-9a-f]/g, (nibble) => String.fromCharCode(97 + Number.parseInt(nibble, 16)));
2225
+ }
2226
+ const declarationPath = join(realPath, FORGE_NATIVE_MESSAGING_DECLARATION);
2227
+ let nativeHost: ManagedNativeMessagingHost | undefined;
2228
+ if (existsSync(declarationPath)) {
2229
+ if (!expectedId || !statSync(declarationPath).isFile() || !pathWithin(realPath, realpathSync(declarationPath))) {
2230
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'Managed native messaging declaration requires a regular file and stable extension manifest key.', { retryable: false });
2231
+ }
2232
+ let declaration: { name?: unknown; description?: unknown; path?: unknown; type?: unknown; allowed_origins?: unknown };
2233
+ try { declaration = JSON.parse(readFileSync(declarationPath, 'utf8')) as typeof declaration; }
2234
+ catch { throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'forge-native-messaging-host.json must contain valid JSON.', { retryable: false }); }
2235
+ const name = typeof declaration.name === 'string' ? declaration.name.trim() : '';
2236
+ const hostPath = typeof declaration.path === 'string' ? declaration.path.trim() : '';
2237
+ const expectedOrigin = `chrome-extension://${expectedId}/`;
2238
+ if (!/^[a-z0-9_]+(?:\.[a-z0-9_]+)*$/.test(name)
2239
+ || declaration.type !== 'stdio'
2240
+ || !isAbsolute(hostPath)
2241
+ || !Array.isArray(declaration.allowed_origins)
2242
+ || declaration.allowed_origins.length !== 1
2243
+ || declaration.allowed_origins[0] !== expectedOrigin) {
2244
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'Managed native messaging declaration must bind one valid host name, stdio transport, absolute executable, and the exact stable extension origin.', { retryable: false });
2245
+ }
2246
+ if (!existsSync(hostPath) || !statSync(hostPath).isFile()) {
2247
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'Managed native messaging host executable must exist as a regular file.', { retryable: false });
2248
+ }
2249
+ const realHostPath = realpathSync(hostPath);
2250
+ if (!trustedRoots.some((root) => pathWithin(root, realHostPath))) {
2251
+ throw new AssistantPluginError('PLUGIN_POLICY_BLOCKED', 'Managed native messaging host executable must resolve inside an already trusted extension, repository, or Controller browser-adapter root.', { retryable: false });
2252
+ }
2253
+ if (process.platform !== 'win32' && (statSync(realHostPath).mode & 0o111) === 0) {
2254
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'Managed native messaging host must be executable.', { retryable: false });
2255
+ }
2256
+ nativeHost = {
2257
+ name,
2258
+ manifest: `${JSON.stringify({
2259
+ name,
2260
+ description: typeof declaration.description === 'string' ? declaration.description.slice(0, 512) : '',
2261
+ path: realHostPath,
2262
+ type: 'stdio',
2263
+ allowed_origins: [expectedOrigin],
2264
+ }, null, 2)}\n`,
2265
+ };
2266
+ }
2267
+ return { path: realPath, expectedId, nativeHost };
2268
+ }
2269
+
2270
+ function projectManagedNativeMessagingHost(profileDir: string, host: ManagedNativeMessagingHost | undefined): boolean {
2271
+ if (!host) return false;
2272
+ const root = join(profileDir, 'NativeMessagingHosts');
2273
+ mkdirSync(root, { recursive: true, mode: 0o700 });
2274
+ const target = join(root, `${host.name}.json`);
2275
+ if (existsSync(target) && readFileSync(target, 'utf8') === host.manifest) return false;
2276
+ const temporary = `${target}.${randomUUID().slice(0, 12)}.tmp`;
2277
+ writeFileSync(temporary, host.manifest, { mode: 0o600 });
2278
+ renameSync(temporary, target);
2279
+ return true;
2280
+ }
2281
+
2282
+ function extensionTargets(context: BrowserContextLike): string[] {
2283
+ return [...(context.serviceWorkers?.() ?? []), ...(context.backgroundPages?.() ?? [])].map((target) => target.url());
2284
+ }
2285
+
2286
+ async function waitForManagedExtension(context: BrowserContextLike, extensionId: string, timeoutMs: number): Promise<string> {
2287
+ const prefix = `chrome-extension://${extensionId}/`;
2288
+ const deadline = Date.now() + Math.min(Math.max(timeoutMs, 250), 10_000);
2289
+ while (Date.now() <= deadline) {
2290
+ const url = extensionTargets(context).find((candidate) => candidate.startsWith(prefix));
2291
+ if (url) return url;
2292
+ await delay(100);
2293
+ }
2294
+ throw new AssistantPluginError('PLUGIN_BROWSER_EXTENSION_POSTCONDITION_FAILED', 'Managed browser did not expose an exact runtime target for the requested extension id.', { retryable: true, details: { extensionId } });
2295
+ }
2296
+
2297
+ async function browserLevelCdp(
2298
+ repoRoot: string,
2299
+ config: BrowserPluginConfig,
2300
+ ): Promise<{ browser: BrowserLike; session: BrowserCdpSessionLike; endpoint: string } | undefined> {
2301
+ if (cdpEndpoints(config).length === 0 || !runtimeHooks.moduleAvailable('playwright', repoRoot)) return undefined;
2302
+ const runtime = runtimeHooks.loadPlaywright(repoRoot);
2303
+ const connectOverCDP = runtime.chromium.connectOverCDP;
2304
+ if (typeof connectOverCDP !== 'function') return undefined;
2305
+ for (const endpoint of cdpEndpoints(config)) {
2306
+ try {
2307
+ const discovered = await discoverCdpEndpoint(endpoint, cdpDiscoveryTimeout(config));
2308
+ const browser = await connectOverCDP.call(runtime.chromium, discovered.discoveredEndpoint ?? endpoint);
2309
+ if (typeof browser.newBrowserCDPSession !== 'function') {
2310
+ if (browser.disconnect) await Promise.resolve(browser.disconnect()).catch(() => undefined);
2311
+ else if (browser.close) await browser.close().catch(() => undefined);
2312
+ continue;
2313
+ }
2314
+ const session = await browser.newBrowserCDPSession();
2315
+ return { browser, session, endpoint: discovered.discoveredEndpoint ?? endpoint };
2316
+ } catch { /* Pre-dispatch endpoint discovery/attach failure; try the next configured endpoint. */ }
2317
+ }
2318
+ return undefined;
2319
+ }
2320
+
2321
+ async function closeBrowserLevelCdp(handle: { browser: BrowserLike; session: BrowserCdpSessionLike }): Promise<void> {
2322
+ await handle.session.detach?.().catch(() => undefined);
2323
+ if (handle.browser.disconnect) await Promise.resolve(handle.browser.disconnect()).catch(() => undefined);
2324
+ else if (handle.browser.close) await handle.browser.close().catch(() => undefined);
2325
+ }
2326
+
2327
+ async function cdpExtensionList(repoRoot: string, config: BrowserPluginConfig): Promise<{ provider: string; endpoint: string; extensions: BrowserExtensionInfo[] } | undefined> {
2328
+ const handle = await browserLevelCdp(repoRoot, config);
2329
+ if (!handle) return undefined;
2330
+ try {
2331
+ const result = await handle.session.send('Extensions.getExtensions') as { extensions?: BrowserExtensionInfo[] };
2332
+ return { provider: 'playwright-cdp', endpoint: handle.endpoint, extensions: Array.isArray(result.extensions) ? result.extensions : [] };
2333
+ } finally { await closeBrowserLevelCdp(handle); }
2334
+ }
2335
+
2336
+ async function loadAndVerifyUnpackedExtension(
2337
+ session: BrowserCdpSessionLike,
2338
+ extensionPath: string,
2339
+ expectedId: string | undefined,
2340
+ enableInIncognito: boolean,
2341
+ ): Promise<BrowserExtensionInfo> {
2342
+ const loaded = await session.send('Extensions.loadUnpacked', {
2343
+ path: extensionPath,
2344
+ ...(enableInIncognito ? { enableInIncognito: true } : {}),
2345
+ }) as { id?: string };
2346
+ const id = typeof loaded.id === 'string' && loaded.id.trim() ? loaded.id.trim() : undefined;
2347
+ if (!id) throw new AssistantPluginError('PLUGIN_BROWSER_EXTENSION_POSTCONDITION_FAILED', 'Browser did not return an extension id after loadUnpacked.', { retryable: true });
2348
+ if (expectedId && id !== expectedId) {
2349
+ throw new AssistantPluginError('PLUGIN_BROWSER_EXTENSION_POSTCONDITION_FAILED', 'Browser returned an unpacked extension id that does not match the manifest-derived stable id.', {
2350
+ retryable: false,
2351
+ details: { extensionId: id, expectedExtensionId: expectedId },
2352
+ });
2353
+ }
2354
+ const listed = await session.send('Extensions.getExtensions') as { extensions?: BrowserExtensionInfo[] };
2355
+ const exact = (listed.extensions ?? []).find((entry) => {
2356
+ if (entry.id !== id || entry.enabled !== true) return false;
2357
+ try { return realpathSync(entry.path) === extensionPath; } catch { return false; }
2358
+ });
2359
+ if (!exact) throw new AssistantPluginError('PLUGIN_BROWSER_EXTENSION_POSTCONDITION_FAILED', 'Browser did not verify the exact enabled unpacked extension id/path after loadUnpacked.', { retryable: true, details: { extensionId: id } });
2360
+ return exact;
2361
+ }
2362
+
2363
+ function customVivaldiExtensionInstall(config: BrowserPluginConfig, repoRoot: string): { executable: string } | undefined {
2364
+ if (config.profileMode !== 'custom' || !config.executablePath) return undefined;
2365
+ const executable = resolveConfiguredPath(repoRoot, config.executablePath);
2366
+ return basename(executable).toLowerCase() === 'vivaldi' ? { executable } : undefined;
2367
+ }
2368
+
2369
+ function processUsesUserDataDir(command: string, profileDir: string): boolean {
2370
+ const expected = resolve(profileDir);
2371
+ const forms = [
2372
+ `--user-data-dir=${expected}`,
2373
+ `--user-data-dir="${expected}"`,
2374
+ `--user-data-dir='${expected}'`,
2375
+ ];
2376
+ return forms.some((form) => {
2377
+ const index = command.indexOf(form);
2378
+ if (index < 0) return false;
2379
+ const next = command[index + form.length];
2380
+ return next === undefined || /\s/.test(next);
2381
+ });
2382
+ }
2383
+
2384
+ function exactCustomVivaldiProfileOwner(
2385
+ executable: string,
2386
+ profile: BrowserProfileSelection,
2387
+ ): { pid: number; command: string } | undefined {
2388
+ let lockTarget: string;
2389
+ try { lockTarget = readlinkSync(join(profile.profileDir, 'SingletonLock')); }
2390
+ catch { return undefined; }
2391
+ const pidText = /(?:^|-)(\d+)$/.exec(lockTarget.trim())?.[1];
2392
+ const pid = pidText ? Number(pidText) : 0;
2393
+ if (!Number.isInteger(pid) || pid <= 0) return undefined;
2394
+ const process = runtimeHooks.listProcesses().find((entry) => entry.pid === pid && !entry.command.includes('--type='));
2395
+ if (!process || !process.command.includes(executable)) return undefined;
2396
+ const hasExplicitProfileDir = process.command.includes('--user-data-dir=');
2397
+ if (hasExplicitProfileDir) {
2398
+ if (!processUsesUserDataDir(process.command, profile.profileDir)) return undefined;
2399
+ } else {
2400
+ const defaultVivaldiProfileDir = resolve(join(homedir(), 'Library', 'Application Support', 'Vivaldi'));
2401
+ if (resolve(profile.profileDir) !== defaultVivaldiProfileDir) return undefined;
2402
+ }
2403
+ return process;
2404
+ }
2405
+
2406
+ async function gracefullyRestartExactCustomVivaldiOwner(
2407
+ executable: string,
2408
+ profile: BrowserProfileSelection,
2409
+ timeoutMs: number,
2410
+ ): Promise<{ restarted: boolean; pid?: number }> {
2411
+ const owner = exactCustomVivaldiProfileOwner(executable, profile);
2412
+ if (!owner) return { restarted: false };
2413
+ runtimeHooks.signalProcess(owner.pid, 'SIGTERM');
2414
+ const deadline = Date.now() + Math.max(500, Math.min(timeoutMs, 10_000));
2415
+ while (Date.now() < deadline) {
2416
+ const alive = runtimeHooks.listProcesses().some((entry) => entry.pid === owner.pid && !entry.command.includes('--type='));
2417
+ if (!alive) return { restarted: true, pid: owner.pid };
2418
+ await delay(50);
2419
+ }
2420
+ throw new AssistantPluginError('PLUGIN_BROWSER_PROFILE_IN_USE', 'The exact Vivaldi custom-profile owner did not exit after a bounded graceful restart request.', {
2421
+ retryable: true,
2422
+ details: { pid: owner.pid, profileDir: profile.profileDir },
2423
+ });
2424
+ }
2425
+
2426
+ async function installExtensionThroughCustomVivaldiCdpPort(
2427
+ input: AssistantPluginActionExecutionInput,
2428
+ config: BrowserPluginConfig,
2429
+ profile: BrowserProfileSelection,
2430
+ extensionPath: string,
2431
+ expectedId: string,
2432
+ ): Promise<Record<string, unknown>> {
2433
+ const vivaldi = customVivaldiExtensionInstall(config, input.repoRoot);
2434
+ if (!vivaldi) {
2435
+ throw new AssistantPluginError('PLUGIN_BROWSER_EXTENSION_CONTROL_UNAVAILABLE', 'Custom Vivaldi unpacked-extension install requires the configured Vivaldi executable.', { retryable: true });
2436
+ }
2437
+ const timeoutMs = positiveNumber(input.args.timeout_ms, config.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS);
2438
+ await gracefullyRestartExactCustomVivaldiOwner(vivaldi.executable, profile, timeoutMs);
2439
+ const port = await runtimeHooks.allocateLoopbackPort();
2440
+ const endpoint = `http://127.0.0.1:${port}`;
2441
+ const args = [
2442
+ `--user-data-dir=${profile.profileDir}`,
2443
+ ...(profile.profileDirectory ? [`--profile-directory=${profile.profileDirectory}`] : []),
2444
+ '--remote-debugging-address=127.0.0.1',
2445
+ `--remote-debugging-port=${port}`,
2446
+ '--enable-unsafe-extension-debugging',
2447
+ '--no-first-run',
2448
+ '--no-default-browser-check',
2449
+ ];
2450
+ const launched = runtimeHooks.launchProcess(vivaldi.executable, args);
2451
+ const pid = launched.pid;
2452
+ if (!pid || !Number.isInteger(pid) || pid <= 0) {
2453
+ throw new AssistantPluginError('PLUGIN_BROWSER_EXTENSION_CONTROL_UNAVAILABLE', 'Vivaldi launcher did not return a process identity for bounded cleanup.', { retryable: true });
2454
+ }
2455
+ let session: BrowserCdpSessionLike | undefined;
2456
+ let keepBrowser = false;
2457
+ try {
2458
+ const discovered = await waitForCdpEndpoint(endpoint, timeoutMs);
2459
+ const browserEndpoint = discovered.discoveredEndpoint ?? endpoint;
2460
+ session = await runtimeHooks.connectBrowserCdp(browserEndpoint, Math.max(1_000, Math.min(timeoutMs, 30_000)));
2461
+ const listed = await session.send('Extensions.getExtensions') as { extensions?: BrowserExtensionInfo[] };
2462
+ let verifiedExtension = (listed.extensions ?? []).find((entry) => {
2463
+ if (entry.id !== expectedId || entry.enabled !== true) return false;
2464
+ try { return realpathSync(entry.path) === extensionPath; } catch { return false; }
2465
+ });
2466
+ let runtimeTarget = await browserCdpExtensionTarget(session, expectedId);
2467
+ if (!verifiedExtension || !runtimeTarget) {
2468
+ verifiedExtension = await loadAndVerifyUnpackedExtension(session, extensionPath, expectedId, input.args.enable_in_incognito === true);
2469
+ if (!runtimeTarget) runtimeTarget = await waitForBrowserCdpExtensionTarget(session, expectedId, timeoutMs);
2470
+ }
2471
+ keepBrowser = true;
2472
+ return {
2473
+ provider: 'native-cdp-vivaldi-custom',
2474
+ endpoint,
2475
+ extension: { ...verifiedExtension, runtimeTarget },
2476
+ verified: true,
2477
+ };
2478
+ } finally {
2479
+ await session?.detach?.().catch(() => undefined);
2480
+ if (!keepBrowser) runtimeHooks.signalProcess(pid, 'SIGTERM');
2481
+ }
2482
+ }
2483
+
2484
+ async function managedCdpInstallExtension(
2485
+ input: AssistantPluginActionExecutionInput,
2486
+ context: BrowserContextLike,
2487
+ extensionPath: string,
2488
+ expectedId: string,
2489
+ runtimeTargetPresent: boolean,
2490
+ ): Promise<BrowserExtensionInfo> {
2491
+ const browser = context.browser?.();
2492
+ if (!browser || typeof browser.newBrowserCDPSession !== 'function') {
2493
+ throw new AssistantPluginError('PLUGIN_BROWSER_EXTENSION_CONTROL_UNAVAILABLE', 'Managed unpacked-extension install requires browser-level CDP on the launched Chromium context.', { retryable: true });
2494
+ }
2495
+ const session = await browser.newBrowserCDPSession();
2496
+ try {
2497
+ const listed = await session.send('Extensions.getExtensions') as { extensions?: BrowserExtensionInfo[] };
2498
+ const exact = (listed.extensions ?? []).find((entry) => {
2499
+ if (entry.id !== expectedId || entry.enabled !== true) return false;
2500
+ try { return realpathSync(entry.path) === extensionPath; } catch { return false; }
2501
+ });
2502
+ if (exact && runtimeTargetPresent) return exact;
2503
+ return await loadAndVerifyUnpackedExtension(session, extensionPath, expectedId, input.args.enable_in_incognito === true);
2504
+ } finally {
2505
+ await session.detach?.().catch(() => undefined);
2506
+ }
2507
+ }
2508
+
2509
+ async function cdpInstallExtension(input: AssistantPluginActionExecutionInput, config: BrowserPluginConfig, extensionPath: string, expectedId?: string): Promise<Record<string, unknown> | undefined> {
2510
+ const handle = await browserLevelCdp(input.repoRoot, config);
2511
+ if (!handle) return undefined;
2512
+ try {
2513
+ const exact = await loadAndVerifyUnpackedExtension(handle.session, extensionPath, expectedId, input.args.enable_in_incognito === true);
2514
+ return { provider: 'playwright-cdp', endpoint: handle.endpoint, extension: exact, verified: true };
2515
+ } finally { await closeBrowserLevelCdp(handle); }
2516
+ }
2517
+
1887
2518
  async function openAttachedContext(
1888
2519
  runtime: PlaywrightRuntime,
1889
2520
  repoRoot: string,
@@ -1978,15 +2609,13 @@ async function openNativeAttachedContext(
1978
2609
  args: Record<string, unknown>,
1979
2610
  ): Promise<{ handle?: BrowserOpenHandle; attempts: MacOsBrowserAttachAttempt[] }> {
1980
2611
  if (config.nativeAttachMode === 'disabled') return { attempts: [] };
1981
- if (!target.existingSession && !stringValue(args.session_id)) {
2612
+ if (!target.existingSession && !stringValue(args.session_id) && args.__forge_computer_native_only !== true) {
1982
2613
  await inspectNativeOwnedSessions({ repoRoot, savedSessions: listSavedSessions(repoRoot), timeoutMs: config.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS, pruneDead: true });
1983
2614
  }
1984
2615
  const timeout = positiveNumber(args.timeout_ms, config.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS);
1985
2616
  const savedTab = target.existingSession?.browser?.tab;
1986
2617
  const savedOwnership = savedTab?.ownership;
1987
2618
  let effectiveOwnership = savedOwnership;
1988
- let recoveredTabTitle = '';
1989
- let recoveredTabUrl: string | undefined;
1990
2619
  const savedProduct = target.existingSession?.browser?.provider === 'macos-apple-events'
1991
2620
  && savedTab
1992
2621
  && (savedOwnership === 'plugin_owned' || savedOwnership === 'user_owned')
@@ -2036,63 +2665,18 @@ async function openNativeAttachedContext(
2036
2665
  savedTab,
2037
2666
  };
2038
2667
  } catch (error) {
2039
- if (error instanceof AssistantPluginError
2040
- && error.code === 'PLUGIN_BROWSER_NATIVE_TAB_IDENTITY_UNPROVEN'
2041
- && savedOwnership === 'user_owned') {
2668
+ // URL/title or a formerly active window cannot identify a lost native tab.
2669
+ // Never silently adopt another user's tab or create a replacement for a saved session.
2670
+ if (savedOwnership === 'user_owned' && error instanceof AssistantPluginError && error.code === 'PLUGIN_BROWSER_NATIVE_TAB_IDENTITY_UNPROVEN') {
2042
2671
  throw error;
2043
2672
  }
2044
- if (savedOwnership === 'user_owned') {
2045
- throw new AssistantPluginError(
2046
- 'PLUGIN_BROWSER_SESSION_STATE_LOST',
2047
- 'Explicitly adopted user-owned macOS browser tab no longer exists; refusing to silently switch to another user tab.',
2048
- {
2049
- retryable: false,
2050
- details: { sessionId: target.sessionId, browserProduct: savedProduct, ownership: savedOwnership, windowId: savedTab.windowId, tabId: savedTab.tabId, cause: error instanceof Error ? error.message : String(error) },
2051
- },
2052
- );
2053
- }
2054
- let inventory: Awaited<ReturnType<typeof listMacOsBrowserTabs>> | undefined;
2055
- let selection: ReturnType<typeof selectReusableNativeTab> | undefined;
2056
- let recoveryFailure: string | undefined;
2057
- try {
2058
- inventory = await listMacOsBrowserTabs(savedProduct, timeout);
2059
- selection = selectReusableNativeTab(
2060
- [savedTab.url, target.existingSession?.url ?? '', target.url],
2061
- inventory,
2062
- savedTab.windowId,
2063
- );
2064
- if (selection.candidate) {
2065
- const recovered = await reattachMacOsBrowserOwnedPage(savedProduct, { windowId: selection.candidate.windowId, tabId: selection.candidate.tabId }, timeout);
2066
- page = recovered.page as unknown as PageLike;
2067
- discovered = { attachment: recovered.attachment, attempts: recovered.attachment.attempts };
2068
- matchedBy = 'recovered_tab';
2069
- effectiveOwnership = 'user_owned';
2070
- recoveredTabTitle = selection.candidate.title;
2071
- recoveredTabUrl = selection.candidate.url;
2072
- sessionResume = {
2073
- sessionId: target.sessionId,
2074
- status: 'matched',
2075
- reason: selection.match === 'exact_url'
2076
- ? 'Saved plugin-owned tab disappeared; rebound safely to the unique currently open tab with the same URL and preserved it as user-owned.'
2077
- : '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.',
2078
- savedTab,
2079
- };
2080
- }
2081
- } catch (recoveryError) {
2082
- recoveryFailure = recoveryError instanceof Error ? recoveryError.message : String(recoveryError);
2083
- }
2084
- if (!page) {
2085
- sessionResume = {
2086
- sessionId: target.sessionId,
2087
- status: 'stale_tab',
2088
- reason: recoveryFailure
2089
- ? `Saved plugin-owned macOS browser tab disappeared and current-tab recovery failed (${recoveryFailure}); creating one replacement plugin-owned tab.`
2090
- : selection?.match === 'ambiguous'
2091
- ? '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.'
2092
- : 'Saved plugin-owned macOS browser tab disappeared and no uniquely reusable current tab matched; creating one replacement plugin-owned tab.',
2093
- savedTab,
2094
- };
2095
- }
2673
+ throw new AssistantPluginError('PLUGIN_BROWSER_SESSION_STATE_LOST',
2674
+ 'Saved native browser tab could not be reattached by its exact identity; refusing URL-based recovery or replacement.',
2675
+ { retryable: false, details: {
2676
+ sessionId: target.sessionId, browserProduct: savedProduct, ownership: savedOwnership,
2677
+ windowId: savedTab.windowId, tabId: savedTab.tabId,
2678
+ causeCode: error instanceof AssistantPluginError ? error.code : 'NATIVE_REATTACH_FAILED',
2679
+ } });
2096
2680
  }
2097
2681
  } else {
2098
2682
  sessionResume = savedTab
@@ -2100,11 +2684,105 @@ async function openNativeAttachedContext(
2100
2684
  : { sessionId: target.sessionId, status: 'no_saved_tab', reason: 'Created one plugin-owned tab while preserving the user active tab.' };
2101
2685
  }
2102
2686
 
2687
+ // Session-bound reads and mutations must never turn a dead tab into a new
2688
+ // tab or silently select another profile. The explicit open-page route is
2689
+ // separate and retains its documented create/recover semantics.
2690
+ if (args.__forge_require_existing_resource === true && target.existingSession && !page) {
2691
+ throw new AssistantPluginError(
2692
+ 'PLUGIN_BROWSER_SESSION_STATE_LOST',
2693
+ 'The saved native browser tab is unavailable and no exact existing tab can be verified; the session was not moved to another profile or recreated.',
2694
+ { retryable: false, details: {
2695
+ sessionId: target.sessionId, browserProduct: savedProduct,
2696
+ savedWindowId: savedTab?.windowId, savedTabId: savedTab?.tabId,
2697
+ } },
2698
+ );
2699
+ }
2103
2700
  let createdThisCallRef: MacOsBrowserTabRef | undefined;
2701
+ let preferredNativeWindowId: string | undefined;
2104
2702
  if (!discovered?.attachment) {
2105
2703
  const candidates = savedProduct ? [savedProduct] : (config.nativeBrowserCandidates ?? [...DEFAULT_USER_NATIVE_BROWSER_CANDIDATES]);
2106
- discovered = await discoverMacOsBrowserAttachment(candidates, Math.min(timeout, MAX_CDP_DISCOVERY_TIMEOUT_MS));
2704
+ let preferredCandidates = candidates;
2705
+ if (candidates.length > 1 && !target.existingSession && (args.__forge_computer_native_only === true || !stringValue(args.session_id))) {
2706
+ // Choosing the frontmost browser can select a different, signed-out
2707
+ // profile even while the user's matching Chrome tab is in the background.
2708
+ // Prefer exact URL evidence across *all* configured native products.
2709
+ const inventories = await Promise.all(candidates.map(async (product) => {
2710
+ try { await assertMacOsBrowserApplicationRunning(product, timeout); }
2711
+ catch (error) {
2712
+ if (error instanceof AssistantPluginError && [
2713
+ 'PLUGIN_BROWSER_NATIVE_APP_NOT_RUNNING', 'PLUGIN_BROWSER_NATIVE_APP_NOT_INSTALLED',
2714
+ 'PLUGIN_BROWSER_NATIVE_APP_UNSUPPORTED_PLATFORM',
2715
+ ].includes(error.code)) return undefined;
2716
+ throw error;
2717
+ }
2718
+ try {
2719
+ const inventory = await listMacOsBrowserTabs(product, timeout, { correctFrontmost: false });
2720
+ if (inventory.truncated) throw new AssistantPluginError('PLUGIN_COMPUTER_BROWSER_INVENTORY_INCOMPLETE',
2721
+ 'Native browser inventory truncated; cannot prove the existing target tab is absent.',
2722
+ { retryable: true, details: { browserProduct: product } });
2723
+ return { product, inventory };
2724
+ } catch (error) {
2725
+ // Unreadable background tabs are not absent tabs: no replacement
2726
+ // browser or new tab may be created from an incomplete inventory.
2727
+ if (args.__forge_computer_native_only === true) {
2728
+ throw new AssistantPluginError('PLUGIN_COMPUTER_BROWSER_INVENTORY_UNAVAILABLE',
2729
+ 'Cannot enumerate existing native browser tabs; refusing to create a duplicate.',
2730
+ { retryable: true, details: { browserProduct: product, cause: error instanceof Error ? error.message : String(error) } });
2731
+ }
2732
+ return undefined;
2733
+ }
2734
+ }));
2735
+ const observed = inventories.filter((item): item is NonNullable<typeof item> => Boolean(item));
2736
+ const exact = observed.flatMap(({ product, inventory }) => inventory.tabs
2737
+ .filter((entry) => comparableUrl(entry.url) === comparableUrl(target.url))
2738
+ .map((entry) => ({ product, entry })));
2739
+ if (exact.length > 1) {
2740
+ throw new AssistantPluginError('PLUGIN_BROWSER_REUSABLE_TAB_AMBIGUOUS',
2741
+ 'Multiple browser products have existing tabs for the exact URL; refusing to guess and create another tab.',
2742
+ { retryable: false, details: { targetUrl: target.url, candidateCount: exact.length } });
2743
+ }
2744
+ if (exact.length === 1) {
2745
+ preferredCandidates = [exact[0]!.product];
2746
+ } else {
2747
+ // Same-origin tabs only identify a likely browser *profile*, not an
2748
+ // exact resource. A new tab may be created there, but never overwrite
2749
+ // an unrelated user tab or invent proof of authentication.
2750
+ const origin = (() => { try { return new URL(target.url).origin; } catch { return ''; } })();
2751
+ const originProducts = observed.filter(({ inventory }) => origin && inventory.tabs.some((entry) => {
2752
+ try { return new URL(entry.url).origin === origin; } catch { return false; }
2753
+ })).map(({ product }) => product);
2754
+ if (originProducts.length === 1) {
2755
+ preferredCandidates = [originProducts[0]!];
2756
+ const matchingWindows = new Set(observed
2757
+ .filter(({ product }) => product === originProducts[0])
2758
+ .flatMap(({ inventory }) => inventory.tabs.filter((entry) => {
2759
+ try { return new URL(entry.url).origin === origin; } catch { return false; }
2760
+ }).map((entry) => entry.windowId)));
2761
+ if (matchingWindows.size === 1) preferredNativeWindowId = [...matchingWindows][0];
2762
+ } else if (observed.length === 1) {
2763
+ // Exactly one available browser product is observed. Product discovery
2764
+ // must not silently fall through multiple provider backends.
2765
+ preferredCandidates = [observed[0]!.product];
2766
+ } else if (observed.length === 0) {
2767
+ throw new AssistantPluginError(args.__forge_computer_native_only === true
2768
+ ? 'PLUGIN_COMPUTER_NATIVE_BROWSER_UNAVAILABLE' : 'PLUGIN_BROWSER_ATTACH_UNAVAILABLE',
2769
+ 'No usable existing native browser was observed; refusing to launch or guess a provider.',
2770
+ { retryable: false });
2771
+ } else {
2772
+ if (args.__forge_require_existing_resource === true) {
2773
+ throw new AssistantPluginError('PLUGIN_BROWSER_EXISTING_TAB_NOT_FOUND',
2774
+ 'No uniquely reusable tab matches the requested existing resource; refusing new tab creation.',
2775
+ { retryable: false });
2776
+ }
2777
+ throw new AssistantPluginError('PLUGIN_BROWSER_PROVIDER_CARDINALITY_INVALID',
2778
+ 'No uniquely identified existing browser product; refusing implicit cross-provider selection.',
2779
+ { retryable: false, details: { candidateCount: observed.length } });
2780
+ }
2781
+ }
2782
+ }
2783
+ discovered = await discoverMacOsBrowserAttachment(preferredCandidates, Math.min(timeout, MAX_CDP_DISCOVERY_TIMEOUT_MS));
2107
2784
  const zeroWindowAttempt = args.__forge_require_existing_resource !== true
2785
+ && args.__forge_computer_native_only !== true
2108
2786
  ? discovered.attempts.find((attempt) => attempt.status === 'unavailable' && attempt.error?.includes('FORGE_NO_BROWSER_WINDOW'))
2109
2787
  : undefined;
2110
2788
  if (!discovered.attachment && zeroWindowAttempt) {
@@ -2112,6 +2790,7 @@ async function openNativeAttachedContext(
2112
2790
  zeroWindowAttempt.product,
2113
2791
  target.url,
2114
2792
  discovered.attempts,
2793
+ undefined,
2115
2794
  timeout,
2116
2795
  );
2117
2796
  page = created.page as unknown as PageLike;
@@ -2220,12 +2899,17 @@ async function openNativeAttachedContext(
2220
2899
  }
2221
2900
  };
2222
2901
  try {
2223
- if (!page && !savedTab && !stringValue(args.session_id)) {
2902
+ if (!page && !savedTab && !target.existingSession && (args.__forge_computer_native_only === true || !stringValue(args.session_id))) {
2224
2903
  let inventory: Awaited<ReturnType<typeof listMacOsBrowserTabs>> | undefined;
2225
2904
  try {
2226
2905
  inventory = await listMacOsBrowserTabs(activeAttachment.metadata.product, timeout);
2227
2906
  } catch (error) {
2228
2907
  if (!nativeTabInventoryUnsupported(error)) throw error;
2908
+ if (args.__forge_computer_native_only === true) {
2909
+ throw new AssistantPluginError('PLUGIN_COMPUTER_BROWSER_INVENTORY_UNAVAILABLE',
2910
+ 'Native tab inventory unavailable; Computer cannot prove an existing tab is absent.',
2911
+ { retryable: true });
2912
+ }
2229
2913
  }
2230
2914
  if (inventory) {
2231
2915
  const ownedSelection = selectLiveForgeOwnedNativeTab(repoRoot, target.url, activeAttachment.metadata.product, inventory);
@@ -2279,8 +2963,6 @@ async function openNativeAttachedContext(
2279
2963
  activeAttachment = rebound.attachment;
2280
2964
  matchedBy = 'exact_url';
2281
2965
  effectiveOwnership = 'user_owned';
2282
- recoveredTabTitle = exactSelection.candidate.title;
2283
- recoveredTabUrl = exactSelection.candidate.url;
2284
2966
  sessionResume = {
2285
2967
  sessionId: target.sessionId,
2286
2968
  status: 'matched',
@@ -2291,7 +2973,23 @@ async function openNativeAttachedContext(
2291
2973
  }
2292
2974
  }
2293
2975
  if (!page) {
2294
- page = await createMacOsBrowserOwnedPage(activeAttachment, target.url, timeout) as unknown as PageLike;
2976
+ // Explicit existing-resource requests (including read-only live
2977
+ // acceptance) must never create a new tab if no exact tab was found.
2978
+ if (!target.existingSession && args.__forge_require_existing_resource === true) {
2979
+ throw new AssistantPluginError('PLUGIN_BROWSER_EXISTING_TAB_NOT_FOUND',
2980
+ 'No uniquely reusable native tab matches the requested URL; refusing to create a new tab.',
2981
+ { retryable: false });
2982
+ }
2983
+ if (preferredNativeWindowId) {
2984
+ const created = await createMacOsBrowserOwnedPageForProduct(
2985
+ activeAttachment.metadata.product, target.url, activeAttachment.attempts, preferredNativeWindowId, timeout,
2986
+ );
2987
+ page = created.page as unknown as PageLike;
2988
+ activeAttachment = created.attachment;
2989
+ discovered = { attachment: created.attachment, attempts: created.attachment.attempts };
2990
+ } else {
2991
+ page = await createMacOsBrowserOwnedPage(activeAttachment, target.url, timeout) as unknown as PageLike;
2992
+ }
2295
2993
  createdThisCallRef = (page as unknown as { tabRef?: () => MacOsBrowserTabRef | undefined }).tabRef?.();
2296
2994
  const liveIdentity = await settleNativeCreatedPageIdentity(
2297
2995
  page as unknown as {
@@ -2311,21 +3009,15 @@ async function openNativeAttachedContext(
2311
3009
  if (comparableUrl(page.url()) !== comparableUrl(target.url) && !explicitUserOwnedUrlMatch) {
2312
3010
  const currentRef = (page as unknown as { tabRef?: () => MacOsBrowserTabRef | undefined }).tabRef?.();
2313
3011
  if (effectiveOwnership === 'user_owned') {
2314
- if (matchedBy === 'recovered_tab') {
2315
- 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 };
2316
- } else {
2317
- throw new AssistantPluginError(
2318
- 'PLUGIN_BROWSER_ADOPTED_TAB_URL_MISMATCH',
2319
- 'Explicitly adopted user-owned tab no longer matches the saved session URL; refusing to navigate, replace, or close the user tab.',
2320
- { retryable: true, details: { sessionId: target.sessionId, expectedUrl: target.url, actualUrl: page.url(), windowId: savedTab?.windowId, tabId: savedTab?.tabId } },
2321
- );
2322
- }
3012
+ throw new AssistantPluginError(
3013
+ 'PLUGIN_BROWSER_ADOPTED_TAB_URL_MISMATCH',
3014
+ 'Explicitly adopted user-owned tab no longer matches the saved session URL; refusing to navigate, replace, or close the user tab.',
3015
+ { retryable: true, details: { sessionId: target.sessionId, expectedUrl: target.url, actualUrl: page.url(), windowId: savedTab?.windowId, tabId: savedTab?.tabId } },
3016
+ );
2323
3017
  }
2324
3018
  const sessionTargetUnchanged = Boolean(target.existingSession?.url) && comparableUrl(target.url) === comparableUrl(target.existingSession?.url ?? '');
2325
3019
  const preserveOwnedSameOriginDrift = matchedBy === 'owned_token' && sessionTargetUnchanged && sameOrigin(page.url(), target.url);
2326
- if (matchedBy === 'recovered_tab' && effectiveOwnership === 'user_owned') {
2327
- // Safe recovery adopts an already-open tab. Its current page is authoritative and must not be navigated.
2328
- } else if (preserveOwnedSameOriginDrift) {
3020
+ if (preserveOwnedSameOriginDrift) {
2329
3021
  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 };
2330
3022
  } else {
2331
3023
  page = await navigateNativePageWithReplacement(page);
@@ -2343,13 +3035,13 @@ async function openNativeAttachedContext(
2343
3035
  if (matchedBy !== 'owned_token' && page.waitForLoadState && !explicitInternalUserSession) {
2344
3036
  await page.waitForLoadState(waitUntil(args.wait_until), { timeout });
2345
3037
  }
2346
- const observedPageUrl = matchedBy === 'recovered_tab' && recoveredTabUrl ? recoveredTabUrl : page.url();
3038
+ const observedPageUrl = page.url();
2347
3039
  const pageUrl = explicitInternalUserSession
2348
3040
  ? normalizedExplicitNativeAdoptionUrl(observedPageUrl)
2349
3041
  : normalizedUrl(observedPageUrl);
2350
3042
  const nativeRef = (page as unknown as { tabRef?: () => MacOsBrowserTabRef | undefined }).tabRef?.();
2351
3043
  if (!nativeRef) throw new AssistantPluginError('PLUGIN_BROWSER_NATIVE_OWNERSHIP_MISSING', 'Native browser did not return a stable plugin-owned tab reference.', { retryable: true });
2352
- const title = matchedBy === 'owned_token' ? (target.existingSession?.title ?? '') : matchedBy === 'recovered_tab' ? recoveredTabTitle : '';
3044
+ const title = matchedBy === 'owned_token' ? (target.existingSession?.title ?? '') : '';
2353
3045
  const inventory: BrowserTabInventoryEntry[] = [{ index: 0, key: tabKey(pageUrl, title), url: pageUrl, title }];
2354
3046
  const metadata = activeAttachment.metadata;
2355
3047
  // The live exact-tab URL is authoritative after a safe native rebind. Keep the
@@ -2392,80 +3084,66 @@ async function openBrowser(
2392
3084
  target: BrowserActionTarget,
2393
3085
  args: Record<string, unknown>,
2394
3086
  ): Promise<BrowserOpenHandle> {
2395
- const requestedMode = config.browserMode ?? 'managed_persistent';
3087
+ const computerNativeOnly = args.__forge_computer_native_only === true;
3088
+ const requestedMode = computerNativeOnly ? 'attach_preferred' : (config.browserMode ?? 'attach_preferred');
3089
+ if (computerNativeOnly) {
3090
+ // Computer operates existing desktop/browser resources. Playwright CDP and
3091
+ // managed profiles remain exclusive to explicitly selected Browser scripts.
3092
+ if (target.existingSession?.browser?.provider
3093
+ && target.existingSession.browser.provider !== 'macos-apple-events') {
3094
+ throw new AssistantPluginError('PLUGIN_COMPUTER_BROWSER_PROVIDER_MISMATCH',
3095
+ 'This saved session belongs to an independent scripted Browser provider; Computer cannot silently adopt or replace it.',
3096
+ { retryable: false, details: { sessionId: target.sessionId, provider: target.existingSession.browser.provider } });
3097
+ }
3098
+ const native = await openNativeAttachedContext(repoRoot, {
3099
+ ...config, browserMode: 'attach_preferred', nativeAttachMode: 'auto', cdpAttachFallback: 'fail_closed',
3100
+ }, target, args);
3101
+ if (native.handle) return native.handle;
3102
+ throw new AssistantPluginError('PLUGIN_COMPUTER_NATIVE_BROWSER_UNAVAILABLE',
3103
+ 'No attachable existing Chrome or Vivaldi browser is available. Computer will not start a Playwright profile.',
3104
+ { retryable: true, details: { nativeAttempts: native.attempts } });
3105
+ }
2396
3106
  if (requestedMode === 'attach_preferred') {
2397
- const requireExistingResource = args.__forge_require_existing_resource === true;
2398
- const existingProvider = target.existingSession?.browser?.provider;
2399
- const attached = cdpEndpoints(config).length > 0 && runtimeHooks.moduleAvailable('playwright', repoRoot)
2400
- ? await openAttachedContext(runtimeHooks.loadPlaywright(repoRoot), repoRoot, config, target, args)
2401
- : { attempts: [] as CdpAttachAttempt[] };
2402
- if (attached.handle) return attached.handle;
2403
- if (requireExistingResource && existingProvider === 'playwright-cdp') {
2404
- throw new AssistantPluginError(
2405
- 'PLUGIN_BROWSER_SESSION_STATE_LOST',
2406
- 'Saved CDP browser page is no longer attachable; refusing to fall back to another browser or create a replacement page.',
2407
- { retryable: false, details: { sessionId: target.sessionId, provider: existingProvider } },
2408
- );
3107
+ const savedProvider = target.existingSession?.browser?.provider;
3108
+ if (savedProvider === 'playwright-persistent-context') {
3109
+ throw new AssistantPluginError('PLUGIN_BROWSER_SESSION_STATE_LOST',
3110
+ 'A managed session cannot be silently adopted by an attach provider. Select managed_persistent explicitly.',
3111
+ { retryable: false, details: { sessionId: target.sessionId, provider: savedProvider } });
2409
3112
  }
2410
- if (requireExistingResource && existingProvider === 'playwright-persistent-context') {
3113
+ const endpoints = cdpEndpoints(config);
3114
+ const route = savedProvider === 'macos-apple-events' ? 'native'
3115
+ : savedProvider === 'playwright-cdp' || endpoints.length > 0 ? 'cdp' : 'native';
3116
+ if (route === 'cdp') {
3117
+ if (endpoints.length !== 1) {
3118
+ throw new AssistantPluginError('PLUGIN_BROWSER_PROVIDER_CARDINALITY_INVALID',
3119
+ 'CDP attach requires exactly one configured endpoint.', { retryable: false });
3120
+ }
2411
3121
  if (!runtimeHooks.moduleAvailable('playwright', repoRoot)) {
2412
- throw new AssistantPluginError('PLUGIN_BROWSER_DEPENDENCY_UNAVAILABLE', 'Managed browser mode requires Playwright.', { retryable: false });
3122
+ throw new AssistantPluginError('PLUGIN_BROWSER_DEPENDENCY_UNAVAILABLE',
3123
+ 'Selected CDP provider requires Playwright; no other provider will be attempted.', { retryable: false });
2413
3124
  }
2414
- return openManagedContext(runtimeHooks.loadPlaywright(repoRoot), repoRoot, config, target, args, 'managed_persistent', requestedMode, {
2415
- policy: config.cdpAttachFallback ?? 'fail_closed',
2416
- from: 'attach_preferred',
2417
- to: 'managed_persistent',
2418
- reason: 'Reusing the existing managed fallback session without probing or creating a different browser resource.',
2419
- attempts: attached.attempts,
2420
- });
3125
+ const attached = await openAttachedContext(runtimeHooks.loadPlaywright(repoRoot), repoRoot, config, target, args);
3126
+ if (attached.handle) return attached.handle;
3127
+ throw new AssistantPluginError('PLUGIN_BROWSER_ATTACH_UNAVAILABLE',
3128
+ 'Configured CDP endpoint unavailable. Native and managed providers will not be attempted.',
3129
+ { retryable: true, details: { provider: 'playwright-cdp', attempts: attached.attempts } });
3130
+ }
3131
+ if (config.nativeAttachMode !== 'auto') {
3132
+ throw new AssistantPluginError('PLUGIN_BROWSER_ATTACH_UNAVAILABLE',
3133
+ 'Native attach is not configured. Choose an explicit Browser mode or one CDP endpoint.',
3134
+ { retryable: false });
2421
3135
  }
2422
-
2423
3136
  const native = await openNativeAttachedContext(repoRoot, config, target, args);
2424
3137
  if (native.handle) return native.handle;
2425
- if (requireExistingResource) {
2426
- throw new AssistantPluginError(
2427
- 'PLUGIN_BROWSER_SESSION_STATE_LOST',
2428
- 'Saved browser resource is no longer attachable; refusing to create a replacement tab/page for this existing-session action.',
2429
- { retryable: false, details: { sessionId: target.sessionId, provider: existingProvider } },
2430
- );
2431
- }
2432
-
2433
- const fallbackPolicy = config.cdpAttachFallback ?? 'fail_closed';
2434
- const cdpReason = attached.attempts.map((attempt) => `${attempt.endpoint}: ${attempt.error ?? 'unavailable'}`).join('; ')
2435
- || 'No CDP endpoint candidates were configured.';
2436
- const nativeReason = native.attempts.map((attempt) => `${attempt.appName}: ${attempt.error ?? attempt.status}`).join('; ')
2437
- || (config.nativeAttachMode === 'disabled' ? 'Native browser attach is disabled.' : 'No scriptable macOS browser was available.');
2438
- const reason = `CDP: ${cdpReason} Native: ${nativeReason}`;
2439
- if (fallbackPolicy === 'fail_closed') {
2440
- throw new AssistantPluginError('PLUGIN_BROWSER_ATTACH_UNAVAILABLE', `No configured browser attach provider was available. Fallback policy fail_closed prevented managed launch. ${reason}`, {
2441
- retryable: true,
2442
- details: {
2443
- browserMode: requestedMode,
2444
- fallbackPolicy,
2445
- attempts: attached.attempts,
2446
- nativeAttempts: native.attempts,
2447
- },
2448
- });
2449
- }
2450
- if (!runtimeHooks.moduleAvailable('playwright', repoRoot)) {
2451
- throw new AssistantPluginError('PLUGIN_BROWSER_DEPENDENCY_UNAVAILABLE', `Managed browser fallback requires Playwright, but native/CDP attach was unavailable. ${reason}`, { retryable: false });
2452
- }
2453
- return openManagedContext(runtimeHooks.loadPlaywright(repoRoot), repoRoot, config, target, args, 'managed_persistent', requestedMode, {
2454
- policy: fallbackPolicy,
2455
- from: 'attach_preferred',
2456
- to: 'managed_persistent',
2457
- reason,
2458
- attempts: attached.attempts,
2459
- nativeAttempts: native.attempts,
2460
- });
3138
+ throw new AssistantPluginError('PLUGIN_BROWSER_ATTACH_UNAVAILABLE',
3139
+ 'Configured native browser target unavailable. No alternate provider will be attempted.',
3140
+ { retryable: true, details: { provider: 'macos-apple-events', attempts: native.attempts } });
2461
3141
  }
2462
-
2463
3142
  if (!runtimeHooks.moduleAvailable('playwright', repoRoot)) {
2464
3143
  throw new AssistantPluginError('PLUGIN_BROWSER_DEPENDENCY_UNAVAILABLE', 'Managed browser mode requires Playwright.', { retryable: false });
2465
3144
  }
2466
3145
  return openManagedContext(runtimeHooks.loadPlaywright(repoRoot), repoRoot, config, target, args, requestedMode, requestedMode);
2467
3146
  }
2468
-
2469
3147
  async function withPage<T>(
2470
3148
  actionId: string,
2471
3149
  repoRoot: string,
@@ -2816,11 +3494,15 @@ const EXTRACTION_SCRIPTS = {
2816
3494
  })()`,
2817
3495
  };
2818
3496
 
3497
+ function selectorUnavailableInTime(errorMessage: string): boolean {
3498
+ return /timeout|timed out|waiting for selector|selector.*not found|not found.*selector/i.test(errorMessage);
3499
+ }
3500
+
2819
3501
  function selectorRepairHint(selector: string, errorMessage: string): string {
2820
3502
  if (/strict mode violation|resolved to \d+ elements/i.test(errorMessage)) {
2821
3503
  return `Selector "${selector}" matched multiple elements. Prefer a unique #id, [data-testid], or more specific path.`;
2822
3504
  }
2823
- if (/Timeout|waiting for selector|not found/i.test(errorMessage)) {
3505
+ if (selectorUnavailableInTime(errorMessage)) {
2824
3506
  return `Selector "${selector}" was not found in time. Use snapshot_interactive or query_all to discover stable selectors, then retry.`;
2825
3507
  }
2826
3508
  return `Check selector "${selector}" against the current page structure.`;
@@ -3221,9 +3903,12 @@ function health(config: BrowserPluginConfig, repoRoot?: string): AssistantPlugin
3221
3903
  };
3222
3904
  }
3223
3905
 
3224
- export function buildBrowserPluginManifest(previousRevision = 0, previousUpdatedAt?: string, repoRoot?: string, context?: AssistantPluginBuildContext): AssistantPluginManifest {
3906
+ export function buildBrowserPluginManifest(
3907
+ previousRevision = 0, previousUpdatedAt?: string, repoRoot?: string,
3908
+ context?: AssistantPluginBuildContext, computerNativeOnly = false,
3909
+ ): AssistantPluginManifest {
3225
3910
  const root = repoRoot ?? process.cwd();
3226
- const config = loadConfig(root, context);
3911
+ const config = loadConfig(root, context, computerNativeOnly);
3227
3912
  const state = health(config, root);
3228
3913
  return {
3229
3914
  schemaVersion: 1,
@@ -3232,7 +3917,7 @@ export function buildBrowserPluginManifest(previousRevision = 0, previousUpdated
3232
3917
  pluginId: BROWSER_PLUGIN_ID,
3233
3918
  provider: 'local-browser',
3234
3919
  displayName: 'Controller Browser Plugin',
3235
- pluginVersion: '1.1.0',
3920
+ pluginVersion: '1.2.0',
3236
3921
  authority: {
3237
3922
  strategy: 'derived',
3238
3923
  duplicateStateAllowed: false,
@@ -3245,9 +3930,7 @@ export function buildBrowserPluginManifest(previousRevision = 0, previousUpdated
3245
3930
  ? 'Browser plugin is disabled.'
3246
3931
  : state.ready
3247
3932
  ? `Browser plugin is ready via ${config.browserMode === 'attach_preferred'
3248
- ? config.cdpAttachFallback === 'managed_persistent'
3249
- ? 'CDP, macOS native attach, and explicit managed fallback.'
3250
- : 'CDP or macOS native attach; managed fallback is disabled.'
3933
+ ? 'the one configured CDP or native browser target (no provider fallback).'
3251
3934
  : 'Playwright persistent context.'}`
3252
3935
  : state.state === 'degraded'
3253
3936
  ? state.warnings[0] ?? 'Browser plugin requires a successful live native attach probe.'
@@ -3261,13 +3944,6 @@ export function buildBrowserPluginManifest(previousRevision = 0, previousUpdated
3261
3944
  };
3262
3945
  }
3263
3946
 
3264
- const NODE_BRIDGE_PRE_ACTION_FAILURE_CODES = new Set([
3265
- 'PLUGIN_BROWSER_NODE_UNAVAILABLE',
3266
- 'PLUGIN_BROWSER_NODE_HOST_UNAVAILABLE',
3267
- 'PLUGIN_BROWSER_NODE_REQUEST_TOO_LARGE',
3268
- 'PLUGIN_BROWSER_NODE_START_FAILED',
3269
- ]);
3270
-
3271
3947
  function browserRuntimeKey(input: AssistantPluginActionExecutionInput): string {
3272
3948
  return `${input.controllerHome}:${input.repoId}`;
3273
3949
  }
@@ -3276,9 +3952,15 @@ async function executeBrowserPluginActionInternal(
3276
3952
  input: AssistantPluginActionExecutionInput,
3277
3953
  runtimeAlreadyRouted = false,
3278
3954
  ): Promise<Record<string, unknown>> {
3279
- const persisted = loadConfig(input.repoRoot, input);
3955
+ const persisted = loadConfig(input.repoRoot, input, input.pluginId === 'computer');
3280
3956
  const actionSessionId = input.actionId === 'configure' ? undefined : stringValue(input.args.session_id);
3281
3957
  const actionSession = actionSessionId ? findBrowserSession(input.repoRoot, actionSessionId) : undefined;
3958
+ if (input.pluginId === 'computer' && actionSession?.browser?.provider
3959
+ && actionSession.browser.provider !== 'macos-apple-events') {
3960
+ throw new AssistantPluginError('PLUGIN_COMPUTER_BROWSER_PROVIDER_MISMATCH',
3961
+ 'Computer cannot manage an independent scripted Browser session; use the Browser plugin for that session.',
3962
+ { retryable: false, details: { sessionId: actionSessionId, provider: actionSession.browser.provider } });
3963
+ }
3282
3964
  const current = input.actionId === 'configure' ? persisted : effectiveBrowserActionConfig(persisted, input.args, actionSession);
3283
3965
  if (!current.enabled && input.actionId !== 'configure') {
3284
3966
  throw new AssistantPluginError('PLUGIN_DISABLED', 'Browser plugin is disabled.', { retryable: false });
@@ -3291,7 +3973,7 @@ async function executeBrowserPluginActionInternal(
3291
3973
  }
3292
3974
  if (!runtimeAlreadyRouted) {
3293
3975
  const runtimeKey = browserRuntimeKey(input);
3294
- const nodeBridgeEligible = shouldUseBrowserNodeBridge(
3976
+ const nodeBridgeEligible = input.pluginId !== 'computer' && shouldUseBrowserNodeBridge(
3295
3977
  input.actionId,
3296
3978
  current.browserMode,
3297
3979
  runtimeHooksCustomized,
@@ -3300,33 +3982,21 @@ async function executeBrowserPluginActionInternal(
3300
3982
  const result = await executeBrowserRuntimeAction({
3301
3983
  runtimeKey,
3302
3984
  input,
3303
- providers: [
3304
- ...(nodeBridgeEligible ? [{
3985
+ providers: nodeBridgeEligible ? [{
3305
3986
  providerId: 'browser.node_bridge',
3306
3987
  capabilities: ALL_BROWSER_PROVIDER_CAPABILITIES.filter((capability) => capability !== 'browser.persistent_handle'),
3307
3988
  foreground: 'none' as const,
3308
3989
  verifiesPostconditions: true,
3309
3990
  persistentHandle: false,
3310
- priority: 10,
3311
3991
  supportsInput: () => true,
3312
- execute: async (routedInput: AssistantPluginActionExecutionInput) => {
3313
- try {
3314
- return await executeBrowserActionThroughNode(routedInput);
3315
- } catch (error) {
3316
- if (error instanceof AssistantPluginError && NODE_BRIDGE_PRE_ACTION_FAILURE_CODES.has(error.code)) {
3317
- throw new BrowserProviderUnavailableBeforeActionError('browser.node_bridge', error.code);
3318
- }
3319
- throw error;
3320
- }
3321
- },
3322
- }] : []),
3992
+ execute: async (routedInput: AssistantPluginActionExecutionInput) => await executeBrowserActionThroughNode(routedInput),
3993
+ }] : [
3323
3994
  {
3324
3995
  providerId: 'browser.local',
3325
3996
  capabilities: ALL_BROWSER_PROVIDER_CAPABILITIES,
3326
3997
  foreground: 'none' as const,
3327
3998
  verifiesPostconditions: true,
3328
3999
  persistentHandle: true,
3329
- priority: 20,
3330
4000
  supportsInput: () => true,
3331
4001
  execute: async (routedInput: AssistantPluginActionExecutionInput) => executeBrowserPluginActionInternal(routedInput, true),
3332
4002
  },
@@ -3364,7 +4034,7 @@ async function executeBrowserPluginActionInternal(
3364
4034
  profileMode: nextProfileMode,
3365
4035
  profileDir: nextProfileMode === 'repo_local' ? undefined : nextProfileDir,
3366
4036
  profileDirectory: nextProfileMode === 'repo_local' ? undefined : nextProfileDirectory,
3367
- browserChannel: nextBrowserChannel ?? 'chromium',
4037
+ browserChannel: nextBrowserChannel ?? (nextExecutablePath ? undefined : 'chromium'),
3368
4038
  executablePath: nextExecutablePath,
3369
4039
  cdpEndpoint: nextCdpEndpoint,
3370
4040
  cdpEndpointCandidates: nextCdpEndpointCandidates,
@@ -3378,13 +4048,74 @@ async function executeBrowserPluginActionInternal(
3378
4048
  ? positiveNumber(args.default_timeout_ms, DEFAULT_TIMEOUT_MS)
3379
4049
  : current.defaultTimeoutMs,
3380
4050
  });
3381
- const configErrors = validateConfig(config);
3382
- if (configErrors.length > 0) {
3383
- throw new AssistantPluginError('PLUGIN_CONFIGURATION_INVALID', configErrors[0], { retryable: false });
3384
- }
3385
4051
  await closeManagedContextsForRepo(input.repoRoot);
3386
4052
  return { config, health: health(config, input.repoRoot) };
3387
4053
  }
4054
+ case 'list_unpacked_extensions': {
4055
+ if (current.browserMode === 'attach_preferred') {
4056
+ const cdp = await cdpExtensionList(input.repoRoot, current);
4057
+ if (cdp) return { ...cdp, verified: true };
4058
+ if (current.cdpAttachFallback !== 'managed_persistent') {
4059
+ throw new AssistantPluginError('PLUGIN_BROWSER_EXTENSION_CONTROL_UNAVAILABLE', 'Unpacked-extension control requires a configured browser-level CDP endpoint; refusing to restart or mutate the user-owned native browser.', { retryable: true });
4060
+ }
4061
+ }
4062
+ if (current.browserMode !== 'managed_persistent' && current.cdpAttachFallback !== 'managed_persistent') {
4063
+ throw new AssistantPluginError('PLUGIN_BROWSER_EXTENSION_CONTROL_UNAVAILABLE', 'Selected Browser mode does not provide unpacked-extension control.', { retryable: false });
4064
+ }
4065
+ const profile = selectedProfile(current, input.repoRoot, 'managed_persistent');
4066
+ const pending = managedBrowserContexts.get(managedContextKey(profile));
4067
+ if (!pending) return { provider: 'playwright-persistent-context', extensions: [], verified: true };
4068
+ const state = await pending;
4069
+ const urls = extensionTargets(state.context);
4070
+ const extensions = [...(managedExtensionPaths.get(managedContextKey(profile)) ?? [])].map((path) => {
4071
+ const { expectedId } = unpackedExtensionPath({ ...input, args: { ...input.args, extension_path: path } });
4072
+ const enabled = Boolean(expectedId && urls.some((url) => url.startsWith(`chrome-extension://${expectedId}/`)));
4073
+ return { id: expectedId, path, enabled };
4074
+ });
4075
+ return { provider: 'playwright-persistent-context', extensions, verified: true };
4076
+ }
4077
+ case 'install_unpacked_extension': {
4078
+ const extension = unpackedExtensionPath(input);
4079
+ if (current.browserMode === 'attach_preferred') {
4080
+ const cdp = await cdpInstallExtension(input, current, extension.path, extension.expectedId);
4081
+ if (cdp) return cdp;
4082
+ if (current.cdpAttachFallback !== 'managed_persistent') {
4083
+ throw new AssistantPluginError('PLUGIN_BROWSER_EXTENSION_CONTROL_UNAVAILABLE', 'Unpacked-extension install requires a configured browser-level CDP endpoint; refusing to restart or mutate the user-owned native browser.', { retryable: true });
4084
+ }
4085
+ }
4086
+ if ((current.browserMode !== 'managed_persistent' && current.cdpAttachFallback !== 'managed_persistent') || !extension.expectedId) {
4087
+ throw new AssistantPluginError('PLUGIN_BROWSER_EXTENSION_CONTROL_UNAVAILABLE', extension.expectedId
4088
+ ? 'Selected Browser mode does not provide managed unpacked-extension control.'
4089
+ : 'Managed unpacked-extension verification requires a stable manifest key.', { retryable: false });
4090
+ }
4091
+ const profile = selectedProfile(current, input.repoRoot, 'managed_persistent');
4092
+ const nativeHostChanged = projectManagedNativeMessagingHost(profile.profileDir, extension.nativeHost);
4093
+ if (customVivaldiExtensionInstall(current, input.repoRoot)) {
4094
+ return await installExtensionThroughCustomVivaldiCdpPort(input, current, profile, extension.path, extension.expectedId);
4095
+ }
4096
+ if (!runtimeHooks.moduleAvailable('playwright', input.repoRoot)) {
4097
+ throw new AssistantPluginError('PLUGIN_BROWSER_DEPENDENCY_UNAVAILABLE', 'Managed unpacked-extension install requires Playwright.', { retryable: false });
4098
+ }
4099
+ const runtime = runtimeHooks.loadPlaywright(input.repoRoot);
4100
+ const key = managedContextKey(profile);
4101
+ const paths = managedExtensionPaths.get(key) ?? new Set<string>();
4102
+ const changed = !paths.has(extension.path);
4103
+ paths.add(extension.path);
4104
+ managedExtensionPaths.set(key, paths);
4105
+ if (changed || nativeHostChanged) await evictManagedContext(key);
4106
+ const state = await managedContextState(runtime, input.repoRoot, current, profile);
4107
+ const prefix = `chrome-extension://${extension.expectedId}/`;
4108
+ let runtimeTarget = extensionTargets(state.context).find((candidate) => candidate.startsWith(prefix));
4109
+ const verifiedExtension = await managedCdpInstallExtension(input, state.context, extension.path, extension.expectedId, Boolean(runtimeTarget));
4110
+ if (!runtimeTarget) {
4111
+ runtimeTarget = await waitForManagedExtension(state.context, extension.expectedId, positiveNumber(input.args.timeout_ms, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS));
4112
+ }
4113
+ return {
4114
+ provider: 'playwright-persistent-context',
4115
+ extension: { ...verifiedExtension, runtimeTarget },
4116
+ verified: true,
4117
+ };
4118
+ }
3388
4119
  case 'list_sessions': {
3389
4120
  const inventory = await inspectSavedSessions(input.repoRoot, current);
3390
4121
  const requestedLimit = Math.max(1, Math.min(Math.trunc(positiveNumber(input.args.limit, DEFAULT_BROWSER_SESSION_LIST_LIMIT)), MAX_BROWSER_SESSION_LIST_LIMIT));
@@ -3399,7 +4130,10 @@ async function executeBrowserPluginActionInternal(
3399
4130
  throw new AssistantPluginError('PLUGIN_BROWSER_SESSION_CURSOR_INVALID', 'Browser session cursor is invalid or expired.', { retryable: false });
3400
4131
  }
3401
4132
  }
3402
- const page = inventory.sessions.slice(offset, offset + requestedLimit);
4133
+ const visibleSessions = input.pluginId === 'computer'
4134
+ ? inventory.sessions.filter(({ session }) => session.browser?.provider === 'macos-apple-events')
4135
+ : inventory.sessions;
4136
+ const page = visibleSessions.slice(offset, offset + requestedLimit);
3403
4137
  const nextOffset = offset + page.length;
3404
4138
  return {
3405
4139
  provider: 'playwright',
@@ -3412,8 +4146,8 @@ async function executeBrowserPluginActionInternal(
3412
4146
  deadManagedSessionCount: inventory.deadManagedSessionCount,
3413
4147
  deadNativeSessionCount: inventory.deadNativeSessionCount,
3414
4148
  limit: requestedLimit,
3415
- totalCount: inventory.sessions.length,
3416
- ...(nextOffset < inventory.sessions.length ? { nextCursor: Buffer.from(JSON.stringify({ offset: nextOffset }), 'utf8').toString('base64url') } : {}),
4149
+ totalCount: visibleSessions.length,
4150
+ ...(nextOffset < visibleSessions.length ? { nextCursor: Buffer.from(JSON.stringify({ offset: nextOffset }), 'utf8').toString('base64url') } : {}),
3417
4151
  sessions: page.map(({ session, liveness, evidence, cleanupError }) => ({
3418
4152
  sessionId: session.sessionId,
3419
4153
  url: session.url,
@@ -3498,7 +4232,13 @@ async function executeBrowserPluginActionInternal(
3498
4232
  case 'request_human_handoff': {
3499
4233
  const target = resolveActionTarget(input.repoRoot, input.args);
3500
4234
  const managedSession = target.existingSession;
3501
- const managedTarget = managedSession ? await runtimeManagedPageForSession(input.repoRoot, current, managedSession) : undefined;
4235
+ if (input.pluginId === 'computer' && managedSession?.browser?.provider
4236
+ && managedSession.browser.provider !== 'macos-apple-events') {
4237
+ throw new AssistantPluginError('PLUGIN_COMPUTER_BROWSER_PROVIDER_MISMATCH',
4238
+ 'Computer handoff requires a native browser tab, not a scripted Browser session.', { retryable: false });
4239
+ }
4240
+ const managedTarget = input.pluginId === 'computer'
4241
+ ? undefined : managedSession ? await runtimeManagedPageForSession(input.repoRoot, current, managedSession) : undefined;
3502
4242
  if (managedSession && managedTarget) {
3503
4243
  const result = { url: managedTarget.page.url(), title: await managedTarget.page.title().catch(() => managedSession.title) };
3504
4244
  const handoff = startRuntimeManagedBrowserHandoff({
@@ -3535,7 +4275,7 @@ async function executeBrowserPluginActionInternal(
3535
4275
  const nativeTab = nativeSession?.browser?.tab;
3536
4276
  const nativeBrowser = nativeSession?.browser?.provider === 'macos-apple-events'
3537
4277
  && nativeSession.browser.browserProduct
3538
- && nativeTab?.ownership === 'plugin_owned'
4278
+ && (nativeTab?.ownership === 'plugin_owned' || nativeTab?.ownership === 'user_owned')
3539
4279
  && typeof nativeTab.windowId === 'string'
3540
4280
  && typeof nativeTab.tabId === 'string'
3541
4281
  ? {
@@ -3543,6 +4283,11 @@ async function executeBrowserPluginActionInternal(
3543
4283
  ref: { windowId: nativeTab.windowId, tabId: nativeTab.tabId },
3544
4284
  }
3545
4285
  : undefined;
4286
+ if (input.pluginId === 'computer' && !nativeBrowser) {
4287
+ throw new AssistantPluginError('PLUGIN_COMPUTER_NATIVE_BROWSER_HANDOFF_UNAVAILABLE',
4288
+ 'Computer handoff requires an existing bound native tab; it will not open a Playwright profile.',
4289
+ { retryable: false });
4290
+ }
3546
4291
  const handoffMode: BrowserMode = current.browserMode === 'isolated' ? 'isolated' : 'managed_persistent';
3547
4292
  const profile = selectedProfile(current, input.repoRoot, handoffMode, target.sessionId);
3548
4293
  if (!nativeBrowser) mkdirSync(profile.profileDir, { recursive: true });
@@ -3577,6 +4322,11 @@ async function executeBrowserPluginActionInternal(
3577
4322
  case 'get_handoff_status': {
3578
4323
  const interactionId = requiredString(input.args.interaction_id, 'interaction_id');
3579
4324
  const handoff = getBrowserHandoff(input.repoRoot, interactionId);
4325
+ if (input.pluginId === 'computer' && !handoff.targetId.startsWith('macos-apple-events:')) {
4326
+ throw new AssistantPluginError('PLUGIN_COMPUTER_BROWSER_PROVIDER_MISMATCH',
4327
+ 'Computer can only inspect native browser handoffs; standalone scripted handoffs are independent.',
4328
+ { retryable: false });
4329
+ }
3580
4330
  if (handoff.status === 'completed' && handoff.result?.url) {
3581
4331
  const session = findBrowserSession(input.repoRoot, handoff.sessionId);
3582
4332
  if (session) {
@@ -3604,6 +4354,11 @@ async function executeBrowserPluginActionInternal(
3604
4354
  throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'resolution must be resume or cancel', { retryable: false });
3605
4355
  }
3606
4356
  const currentHandoff = getBrowserHandoff(input.repoRoot, interactionId);
4357
+ if (input.pluginId === 'computer' && !currentHandoff.targetId.startsWith('macos-apple-events:')) {
4358
+ throw new AssistantPluginError('PLUGIN_COMPUTER_BROWSER_PROVIDER_MISMATCH',
4359
+ 'Computer can resolve only native browser handoffs; scripted Browser handoffs remain independent.',
4360
+ { retryable: false });
4361
+ }
3607
4362
  if (isRuntimeManagedBrowserHandoff(currentHandoff)) {
3608
4363
  const session = findBrowserSession(input.repoRoot, currentHandoff.sessionId);
3609
4364
  const managedTarget = session ? await runtimeManagedPageForSession(input.repoRoot, current, session) : undefined;
@@ -4245,10 +5000,14 @@ async function executeBrowserPluginActionInternal(
4245
5000
  });
4246
5001
  } catch (error) {
4247
5002
  const message = error instanceof Error ? error.message : String(error);
4248
- throw new AssistantPluginError('PLUGIN_ACTION_FAILED', message, {
4249
- retryable: true,
4250
- details: { selector, repairHint: selectorRepairHint(selector, message) },
4251
- });
5003
+ throw new AssistantPluginError(
5004
+ selectorUnavailableInTime(message) ? 'PLUGIN_BROWSER_SELECTOR_UNAVAILABLE' : 'PLUGIN_ACTION_FAILED',
5005
+ message,
5006
+ {
5007
+ retryable: true,
5008
+ details: { selector, repairHint: selectorRepairHint(selector, message) },
5009
+ },
5010
+ );
4252
5011
  }
4253
5012
  }
4254
5013
  case 'type':
@@ -4500,30 +5259,7 @@ async function executeBrowserPluginActionInternal(
4500
5259
  if (requested.length === 0 || requested.length > 32) {
4501
5260
  throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'attach_local_file requires file_path or 1-32 file_paths.', { retryable: false });
4502
5261
  }
4503
- const repositoryLexicalRoot = resolve(input.repoRoot);
4504
- const repositoryRoot = realpathSync(input.repoRoot);
4505
- const resolved = requested.map((filePath) => {
4506
- const lexicalPath = resolve(input.repoRoot, filePath);
4507
- const lexicalRelative = relative(repositoryLexicalRoot, lexicalPath);
4508
- if (lexicalRelative === '..' || lexicalRelative.startsWith(`..${sep}`) || isAbsolute(lexicalRelative)) {
4509
- throw new AssistantPluginError('PLUGIN_POLICY_BLOCKED', 'Local file attachments must stay inside the repository root.', { retryable: false, details: { fileName: basename(filePath) } });
4510
- }
4511
- if (!existsSync(lexicalPath)) {
4512
- throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', `Local attachment does not exist: ${basename(lexicalPath)}`, { retryable: false });
4513
- }
4514
- const realPath = realpathSync(lexicalPath);
4515
- const repositoryRelative = relative(repositoryRoot, realPath);
4516
- if (repositoryRelative === '..' || repositoryRelative.startsWith(`..${sep}`) || isAbsolute(repositoryRelative)) {
4517
- throw new AssistantPluginError('PLUGIN_POLICY_BLOCKED', 'Local file attachments must resolve inside the repository root.', { retryable: false, details: { fileName: basename(lexicalPath) } });
4518
- }
4519
- if (!statSync(realPath).isFile()) {
4520
- throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', `Local attachment is not a regular file: ${basename(lexicalPath)}`, { retryable: false });
4521
- }
4522
- if (/\.(exe|dmg|pkg|sh|bat|cmd|app)$/i.test(realPath)) {
4523
- throw new AssistantPluginError('PLUGIN_POLICY_BLOCKED', 'Executable file attachments are not allowed.', { retryable: false });
4524
- }
4525
- return lexicalPath;
4526
- });
5262
+ const resolved = resolveBrowserAttachmentPaths(input, requested, stringValue(input.args.target_key));
4527
5263
  return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
4528
5264
  await page.evaluate((payload) => {
4529
5265
  const args = payload as { selector: string; count: number };
@@ -4629,6 +5365,61 @@ async function executeBrowserPluginActionInternal(
4629
5365
  }
4630
5366
  }
4631
5367
 
5368
+ /** Resolve one upload batch under the repository or the existing local-system target grant.
5369
+ * A target_key changes the path base; it never grants arbitrary absolute filesystem access.
5370
+ */
5371
+ export function resolveBrowserAttachmentPaths(
5372
+ input: AssistantPluginActionExecutionInput,
5373
+ requested: string[],
5374
+ targetKey?: string,
5375
+ ): string[] {
5376
+ const repositoryLexicalRoot = resolve(input.repoRoot);
5377
+ const repositoryRoot = realpathSync(input.repoRoot);
5378
+ return requested.map((filePath) => {
5379
+ let allowedPath: string;
5380
+ if (targetKey) {
5381
+ try {
5382
+ allowedPath = resolveWorkspaceTargetPath(input.controllerHome, targetKey, filePath, {
5383
+ mustExist: true,
5384
+ kind: 'file',
5385
+ operation: 'read',
5386
+ ownerScope: pluginCapabilityAuthorizationOwnerScope(input.origin),
5387
+ }).path;
5388
+ } catch (error) {
5389
+ if (!(error instanceof WorkspaceTargetGrantError)) throw error;
5390
+ throw new AssistantPluginError('PLUGIN_POLICY_BLOCKED',
5391
+ 'Upload file is not within an active, caller-owned local-system target grant.',
5392
+ { retryable: false, details: { fileName: basename(filePath), targetKey, causeCode: error.code } });
5393
+ }
5394
+ } else {
5395
+ const lexicalPath = resolve(input.repoRoot, filePath);
5396
+ const lexicalRelative = relative(repositoryLexicalRoot, lexicalPath);
5397
+ if (lexicalRelative === '..' || lexicalRelative.startsWith(`..${sep}`) || isAbsolute(lexicalRelative)) {
5398
+ throw new AssistantPluginError('PLUGIN_POLICY_BLOCKED', 'Local file attachments must stay inside the repository root.', {
5399
+ retryable: false, details: { fileName: basename(filePath) },
5400
+ });
5401
+ }
5402
+ if (!existsSync(lexicalPath)) {
5403
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', `Local attachment does not exist: ${basename(lexicalPath)}`, { retryable: false });
5404
+ }
5405
+ allowedPath = realpathSync(lexicalPath);
5406
+ const repositoryRelative = relative(repositoryRoot, allowedPath);
5407
+ if (repositoryRelative === '..' || repositoryRelative.startsWith(`..${sep}`) || isAbsolute(repositoryRelative)) {
5408
+ throw new AssistantPluginError('PLUGIN_POLICY_BLOCKED', 'Local file attachments must resolve inside the repository root.', {
5409
+ retryable: false, details: { fileName: basename(lexicalPath) },
5410
+ });
5411
+ }
5412
+ }
5413
+ if (!statSync(allowedPath).isFile()) {
5414
+ throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', `Local attachment is not a regular file: ${basename(filePath)}`, { retryable: false });
5415
+ }
5416
+ if (/\.(exe|dmg|pkg|sh|bat|cmd|app)$/i.test(allowedPath)) {
5417
+ throw new AssistantPluginError('PLUGIN_POLICY_BLOCKED', 'Executable file attachments are not allowed.', { retryable: false });
5418
+ }
5419
+ return allowedPath;
5420
+ });
5421
+ }
5422
+
4632
5423
  export async function executeBrowserPluginAction(input: AssistantPluginActionExecutionInput): Promise<Record<string, unknown>> {
4633
5424
  return await withBrowserSessionAuthorityContext(
4634
5425
  { controllerHome: input.controllerHome, repoId: input.repoId },