@moretea-labs/forge 1.8.1 → 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 (328) hide show
  1. package/AGENTS.md +5 -2
  2. package/CHANGELOG.md +20 -0
  3. package/README.en.md +24 -3
  4. package/README.md +10 -6
  5. package/README.zh-CN.md +5 -3
  6. package/THIRD_PARTY_NOTICES.md +1 -2
  7. package/adapters/chatgpt/browser-delivery-runtime.ts +5 -3
  8. package/adapters/chatgpt/provider-delivery.ts +1 -1
  9. package/adapters/chatgpt/work-conversation-binding-store.ts +20 -1
  10. package/adapters/computer/chatgpt-extension-target.ts +295 -50
  11. package/adapters/computer/chrome-extension/background.js +227 -71
  12. package/adapters/computer/chrome-extension/content.js +181 -157
  13. package/adapters/computer/chrome-extension/manifest.json +3 -1
  14. package/adapters/computer/desktop-operator-provider.ts +18 -6
  15. package/adapters/computer/interaction-target-authority.ts +39 -9
  16. package/adapters/mcp/access-mode.ts +18 -39
  17. package/adapters/mcp/auth.ts +2 -6
  18. package/adapters/mcp/instructions.ts +2 -2
  19. package/adapters/mcp/multi-repository.ts +7 -28
  20. package/adapters/mcp/runtime-gateway/context-adapter.ts +79 -40
  21. package/adapters/mcp/runtime-gateway/context-pack-sidecar.ts +7 -15
  22. package/adapters/mcp/runtime-gateway/execution-tools.ts +7 -13
  23. package/adapters/mcp/runtime-gateway/plugin-adapter.ts +16 -3
  24. package/adapters/mcp/runtime-gateway/process-tools.ts +3 -21
  25. package/adapters/mcp/runtime-gateway/router.ts +3 -5
  26. package/adapters/mcp/runtime-gateway/routing-policy.ts +17 -10
  27. package/adapters/mcp/runtime-gateway/runtime-observation-adapter.ts +1 -1
  28. package/adapters/mcp/runtime-gateway/runtime-tool-definitions.ts +14 -9
  29. package/adapters/mcp/runtime-gateway/runtime-tools.ts +1 -5
  30. package/adapters/mcp/runtime-gateway/scheduler-adapter.ts +36 -1
  31. package/adapters/mcp/runtime-gateway/status-inbox-adapter.ts +58 -4
  32. package/adapters/mcp/runtime-gateway/typescript-navigation-sidecar.ts +7 -3
  33. package/adapters/mcp/runtime-gateway/work-adapter.ts +16 -16
  34. package/adapters/mcp/runtime-gateway/work-input-wire-migration.ts +0 -17
  35. package/adapters/mcp/runtime-gateway/work-semantic-operations.ts +69 -12
  36. package/adapters/mcp/runtime-gateway/workflow-supervisor-adapter.ts +26 -2
  37. package/adapters/mcp/server.ts +24 -3
  38. package/adapters/mcp/setup.ts +4 -10
  39. package/adapters/mcp/tool-mapping/access-tools.ts +0 -6
  40. package/adapters/mcp/tool-mapping/legacy-tool-service.ts +672 -2935
  41. package/adapters/mcp/tool-mapping/post-finalize-work-attribution.ts +1 -1
  42. package/adapters/mcp/tool-mapping/repository-tools.ts +52 -86
  43. package/adapters/mcp/toolset-names.ts +11 -42
  44. package/adapters/mcp/toolset.ts +16 -90
  45. package/{src/cli → adapters}/mcp/transports/AGENTS.md +5 -5
  46. package/{src/cli → adapters}/mcp/transports/CLAUDE.md +5 -5
  47. package/adapters/mcp/transports/http-observation.ts +18 -6
  48. package/adapters/mcp/transports/http-post-lifecycle.ts +19 -0
  49. package/adapters/mcp/transports/http.ts +54 -14
  50. package/adapters/mcp/types.ts +0 -6
  51. package/assets/hooks/lib/session-state.sh +0 -38
  52. package/assets/hooks/post-bash.sh +1 -1
  53. package/assets/hooks/post-tool-observer.sh +0 -5
  54. package/assets/hooks/prompt-guard.sh +0 -55
  55. package/assets/partials/04-project-structure.partial.md +1 -1
  56. package/assets/partials/07-footer.partial.md +1 -1
  57. package/assets/partials-agents/02-operating-mode.partial.md +1 -1
  58. package/assets/partials-agents/08-deep-docs.partial.md +1 -1
  59. package/assets/reference-configs/agentic-development-flow.md +1 -1
  60. package/assets/reference-configs/external-tooling.md +0 -76
  61. package/assets/reference-configs/release-deploy.md +1 -1
  62. package/assets/templates/helpers/architecture-queue.sh +1 -1
  63. package/assets/templates/helpers/check-agent-tooling.sh +1 -327
  64. package/assets/templates/helpers/check-task-workflow.sh +1 -1
  65. package/assets/templates/helpers/ensure-task-workflow.sh +0 -14
  66. package/assets/templates/helpers/migrate-project-template.sh +2 -2
  67. package/assets/workflow-contract.v1.json +0 -2
  68. package/docs/README.md +5 -2
  69. package/docs/architecture/CURRENT.md +114 -30
  70. package/docs/architecture/decisions/20260904-ios-device-migration-baseline.md +42 -0
  71. package/docs/architecture/decisions/20260918-stable-candidate-release-lifecycle.md +15 -2
  72. package/docs/architecture/decisions/20260924-thin-semantic-working-context.md +4 -2
  73. package/docs/architecture/decisions/20261001-supervisor-turn-authority.md +109 -4
  74. package/docs/architecture/decisions/20261009-exact-code-context-discovery.md +19 -0
  75. package/docs/architecture/decisions/20261009-generated-xcode-cache-retention.md +37 -0
  76. package/docs/architecture/decisions/20261011-autonomous-continuation-convergence.md +597 -0
  77. package/docs/architecture/decisions/20261017-edit-session-finalization-reconciliation.md +17 -0
  78. package/docs/architecture/decisions/20261019-retire-host-skills.md +16 -0
  79. package/docs/architecture/diagrams/agentic-dev-plugin-architecture.html +1 -1
  80. package/docs/architecture/domains/public-surface.md +3 -5
  81. package/docs/architecture/domains/verification.md +5 -5
  82. package/docs/architecture/modules/controller-runtime/transport-lifecycle.md +2 -2
  83. package/docs/architecture/requests/2026-09-04-computer-plugin-workflow-taxonomy.md +4 -4
  84. package/docs/architecture/reviews/20261015-code-search-runtime-acceptance.md +75 -0
  85. package/docs/forge-plugin-management.md +14 -2
  86. package/docs/operations/controller-browser-plugin.md +23 -0
  87. package/docs/operations/features.md +6 -7
  88. package/docs/operations/features.zh-CN.md +1 -1
  89. package/docs/operations/github-repository.md +4 -4
  90. package/docs/operations/github-semantic-release-projection.md +20 -0
  91. package/docs/operations/homebrew.md +2 -2
  92. package/docs/operations/mcp-tool-exposure.md +13 -54
  93. package/docs/operations/runtime-performance-diagnostics.md +8 -0
  94. package/docs/operations/standalone-disaster-recovery.md +1 -1
  95. package/docs/tutorials/01-install-and-start.zh-CN.md +1 -1
  96. package/docs/tutorials/03-first-repository-task.md +2 -0
  97. package/docs/wiki/Computer-and-Plugins.md +78 -0
  98. package/docs/wiki/Computer-and-Plugins.zh-CN.md +88 -0
  99. package/docs/wiki/Home.md +16 -2
  100. package/docs/wiki/Implementation.md +24 -12
  101. package/docs/wiki/Installation.md +28 -32
  102. package/docs/wiki/Integrations.md +4 -0
  103. package/docs/wiki/Quick-Start.md +35 -22
  104. package/docs/wiki/Releases-and-Upgrades.md +15 -25
  105. package/docs/wiki/_Sidebar.md +5 -1
  106. package/package.json +1 -12
  107. package/packages/kernel/controller/infrastructure/controller-session-store.ts +63 -38
  108. package/packages/kernel/identity/infrastructure/activity-projection-store.ts +10 -0
  109. package/packages/kernel/scheduler/application/eligibility.ts +4 -1
  110. package/packages/kernel/scheduler/infrastructure/schedule-store.ts +13 -10
  111. package/packages/kernel/work/application/work-service.ts +1 -0
  112. package/packages/kernel/work/domain/repository-completion-receipt.ts +1 -1
  113. package/packages/kernel/work/domain/state-machine.ts +9 -2
  114. package/packages/kernel/work/infrastructure/work-contract-store.ts +28 -1
  115. package/packages/plugin-runtime/browser/provider-registry.ts +12 -36
  116. package/packages/plugin-runtime/computer/conversation-target.ts +17 -7
  117. package/scripts/assemble-template.ts +11 -72
  118. package/scripts/check-mcp-compatibility.ts +55 -74
  119. package/scripts/check-package-identity.mjs +0 -5
  120. package/scripts/check-runtime-architecture.mjs +305 -28
  121. package/scripts/init-project.sh +0 -46
  122. package/scripts/install-ios-hid-toolchain.sh +60 -0
  123. package/scripts/install-standalone-recovery.ts +1 -1
  124. package/scripts/lib/project-init-lib.sh +3 -3
  125. package/scripts/migrate-windows-wsl-controller-home.ts +2 -0
  126. package/scripts/open-source-audit-allowlist.txt +0 -1
  127. package/scripts/run-governed-gate.ts +2 -0
  128. package/scripts/smoke-ios-physical-device.ts +38 -10
  129. package/scripts/smoke-mcp-http-runtime.ts +7 -8
  130. package/scripts/smoke-mcp-tool-surface.ts +27 -41
  131. package/scripts/smoke-workflow-supervisor-chrome-adapter.ts +6 -3
  132. package/scripts/sync-generated-authority.mjs +0 -1
  133. package/src/cli/agent-jobs/executor-health.ts +1 -1
  134. package/src/cli/agent-jobs/integration.ts +2 -2
  135. package/src/cli/chatgpt-browser/file-policy.ts +2 -2
  136. package/src/cli/commands/doctor.ts +1 -143
  137. package/src/cli/commands/global-runtime.ts +3 -196
  138. package/src/cli/commands/init-hook.ts +6 -23
  139. package/src/cli/commands/init.ts +3 -252
  140. package/src/cli/commands/mcp.ts +0 -5
  141. package/src/cli/commands/prompt-guard-decision.ts +0 -2
  142. package/src/cli/commands/recovery.ts +2 -14
  143. package/src/cli/commands/repository.ts +17 -1
  144. package/src/cli/commands/setup-execution.ts +41 -0
  145. package/src/cli/controller/completion-orchestrator.ts +1 -1
  146. package/src/cli/controller/composite-operations.ts +1 -1
  147. package/src/cli/controller/context/instruction-context.ts +1 -1
  148. package/src/cli/controller/context/known-paths.ts +2 -2
  149. package/src/cli/controller/context/query-planning.ts +11 -4
  150. package/src/cli/controller/context/rule-resolution.ts +2 -2
  151. package/src/cli/controller/context/source-materializer.ts +2 -2
  152. package/src/cli/controller/context/structural-provider.ts +84 -0
  153. package/src/cli/controller/context/structural.ts +6 -6
  154. package/src/cli/controller/context/types.ts +8 -9
  155. package/src/cli/controller/context-pack.ts +97 -76
  156. package/src/cli/controller/operational-plan.ts +1 -1
  157. package/src/cli/controller/systemd-user.ts +4 -10
  158. package/src/cli/editing/edit-session.ts +17 -5
  159. package/src/cli/github/github.ts +4 -4
  160. package/src/cli/github/semantic-projection.ts +174 -0
  161. package/src/cli/hook/prompt-intents.ts +0 -9
  162. package/src/cli/index.ts +1 -43
  163. package/src/cli/local-bridge/connector-freshness.ts +1 -12
  164. package/src/cli/local-bridge/console-automations.ts +1 -1
  165. package/src/cli/local-bridge/console-view-models.ts +1 -1
  166. package/src/cli/local-bridge/facade-api.ts +0 -2
  167. package/src/cli/repositories/command-classifier.ts +0 -9
  168. package/src/cli/repositories/command-scope.ts +2 -2
  169. package/src/cli/repositories/locks.ts +20 -0
  170. package/src/cli/repositories/registry.ts +2 -1
  171. package/src/cli/repositories/safe-patch.ts +2 -12
  172. package/src/cli/repositories/structured-git.ts +39 -3
  173. package/src/cli/repository/inspector.ts +185 -343
  174. package/src/effects/process-runner.ts +17 -1
  175. package/src/runtime/context/generic-lsp-provider.ts +1 -1
  176. package/src/runtime/context/semantic-navigation-contract.ts +7 -0
  177. package/src/runtime/context/semantic-navigation.ts +1 -1
  178. package/src/runtime/control-plane/cleanup-artifact-retention.ts +4 -4
  179. package/src/runtime/control-plane/execution/work-delivery-service.ts +150 -31
  180. package/src/runtime/control-plane/execution/work-handle-authority.ts +12 -4
  181. package/src/runtime/control-plane/execution/work-handle-store.ts +53 -1
  182. package/src/runtime/control-plane/execution/work-operation-service.ts +20 -10
  183. package/src/runtime/control-plane/execution/work-preparation-service.ts +108 -4
  184. package/src/runtime/control-plane/execution/work-process-evidence.ts +2 -2
  185. package/src/runtime/control-plane/execution/work-terminal-cleanup.ts +28 -13
  186. package/src/runtime/control-plane/execution/work-validation-reconciler.ts +5 -1
  187. package/src/runtime/control-plane/execution/work-verification-snapshot.ts +1 -1
  188. package/src/runtime/control-plane/facade/capability-registry.ts +2 -0
  189. package/src/runtime/control-plane/facade/work-continuation.ts +1 -1
  190. package/src/runtime/control-plane/generated-cache-retention.ts +114 -63
  191. package/src/runtime/control-plane/global-scheduler/maintenance.ts +20 -8
  192. package/src/runtime/control-plane/global-scheduler/reconciliation.ts +1 -11
  193. package/src/runtime/control-plane/governance/external-effects.ts +1 -1
  194. package/src/runtime/control-plane/launcher/chatgpt-work-continuation.ts +1 -0
  195. package/src/runtime/control-plane/runtime-cleanup.ts +12 -93
  196. package/src/runtime/evidence/sensitive-output.ts +22 -2
  197. package/src/runtime/execution/jobs/child-reference.ts +11 -0
  198. package/src/runtime/execution/jobs/receipt-recovery.ts +1 -11
  199. package/src/runtime/execution/process-runtime/command-facade.ts +3 -3
  200. package/src/runtime/execution/process-runtime/runtime.ts +20 -16
  201. package/src/runtime/execution/thin-harness/execution-router.ts +16 -16
  202. package/src/runtime/execution/thin-harness/fast-executor.ts +2 -2
  203. package/src/runtime/execution/thin-harness/search-worker.ts +2 -2
  204. package/src/runtime/execution/workers/executor.ts +1 -2
  205. package/src/runtime/maintenance/cleanup.ts +41 -5
  206. package/src/runtime/plugins/action-application.ts +12 -1
  207. package/src/runtime/plugins/app-store-connect-adapter.ts +252 -4
  208. package/src/runtime/plugins/browser-adapter.ts +381 -313
  209. package/src/runtime/plugins/browser-macos-bridge.ts +114 -112
  210. package/src/runtime/plugins/browser-manifest-surface.ts +2 -1
  211. package/src/runtime/plugins/browser-registration.ts +2 -0
  212. package/src/runtime/plugins/browser-runtime.ts +26 -35
  213. package/src/runtime/plugins/computer-chatgpt-browser-adapter-installer.ts +11 -2
  214. package/src/runtime/plugins/computer-registration.ts +76 -9
  215. package/src/runtime/plugins/github-adapter.ts +30 -3
  216. package/src/runtime/plugins/gmail-adapter.ts +166 -1
  217. package/src/runtime/plugins/google-shared.ts +10 -5
  218. package/src/runtime/plugins/ios/remote-xpc-hid.ts +70 -30
  219. package/src/runtime/plugins/ios-adapter.ts +45 -40
  220. package/src/runtime/plugins/ios-agent-device.ts +54 -4
  221. package/src/runtime/plugins/ios-device-ownership.ts +103 -0
  222. package/src/runtime/plugins/ios-physical-device.ts +497 -79
  223. package/src/runtime/plugins/local-system-adapter.ts +1 -99
  224. package/src/runtime/plugins/store.ts +80 -7
  225. package/src/runtime/plugins/types.ts +4 -0
  226. package/src/runtime/projections/git-status-sampler.ts +2 -19
  227. package/src/runtime/recovery/actions.ts +1 -1
  228. package/src/runtime/recovery/lifecycle-inventory.ts +1 -11
  229. package/src/runtime/recovery/probe.ts +2 -2
  230. package/src/runtime/release/release-coordinator.ts +20 -4
  231. package/src/runtime/root/controller-progression-composition.ts +67 -5
  232. package/src/runtime/root/package-connector-service.ts +123 -11
  233. package/src/runtime/root/package-runtime-release.ts +6 -18
  234. package/src/runtime/root/package-runtime-service.ts +7 -13
  235. package/src/runtime/root/release-execution-canary.ts +18 -5
  236. package/src/runtime/root/release-manifest.ts +0 -34
  237. package/src/runtime/root/release-materialize.ts +0 -78
  238. package/src/runtime/root/runtime.ts +1 -2
  239. package/src/runtime/root/scheduled-controller-composition.ts +2 -1
  240. package/src/runtime/root/service.ts +90 -11
  241. package/src/runtime/root/types.ts +0 -6
  242. package/src/runtime/root/workflow-supervisor-composition.ts +207 -19
  243. package/src/runtime/root/workflow-supervisor-runtime.ts +365 -10
  244. package/src/runtime/safe-tooling/google-credential-store.ts +1 -11
  245. package/src/runtime/safe-tooling/ios-development.ts +22 -8
  246. package/src/runtime/safe-tooling/job-summary.ts +1 -1
  247. package/src/runtime/safe-tooling/workspace-auth.ts +4 -1
  248. package/src/runtime/shared/atomic-text-file.ts +21 -0
  249. package/src/runtime/shared/json-files.ts +8 -3
  250. package/src/runtime/standalone-recovery/core.ts +189 -174
  251. package/src/runtime/standalone-recovery/entry.ts +62 -30
  252. package/src/runtime/standalone-recovery/operation-lock.ts +11 -1
  253. package/src/runtime/workflow/schedules/chatgpt-automation-migration.ts +1 -0
  254. package/src/runtime/workflow/schedules/engine.ts +36 -14
  255. package/src/runtime/workflow/schedules/pending-activation.ts +11 -4
  256. package/src/runtime/workflow/schedules/work-continuation.ts +51 -6
  257. package/supervisor/client.ts +31 -14
  258. package/supervisor/control-plane.ts +210 -115
  259. package/supervisor/forge-validators.ts +88 -21
  260. package/supervisor/native-browser-adapter.ts +393 -28
  261. package/supervisor/native-messaging/host.ts +21 -2
  262. package/supervisor/protocol.ts +74 -26
  263. package/supervisor/server.ts +16 -8
  264. package/supervisor/store.ts +476 -57
  265. package/supervisor/types.ts +19 -13
  266. package/SKILL.md +0 -427
  267. package/adapters/computer/chatgpt-target-router.ts +0 -103
  268. package/adapters/mcp/runtime-gateway/automation-receipt-adapter.ts +0 -87
  269. package/adapters/mcp/runtime-gateway/work-validation-reconciler.ts +0 -1
  270. package/adapters/mcp/toolset-selection.ts +0 -41
  271. package/agents/openai.yaml +0 -4
  272. package/assets/skill-hooks.json +0 -41
  273. package/assets/skill-version.json +0 -189
  274. package/assets/skills/claude-review/SKILL.md +0 -231
  275. package/assets/skills/codex-review/SKILL.md +0 -103
  276. package/assets/templates/helpers/check-skill-version.ts +0 -193
  277. package/scripts/check-skill-version.ts +0 -5
  278. package/scripts/ensure-codegraph.sh +0 -16
  279. package/scripts/run-skill-hook.ts +0 -244
  280. package/scripts/sync-codex-installed-copies.sh +0 -182
  281. package/src/cli/commands/tools.ts +0 -167
  282. package/src/cli/mcp/access-mode.ts +0 -2
  283. package/src/cli/mcp/access-tools.ts +0 -2
  284. package/src/cli/mcp/audit.ts +0 -2
  285. package/src/cli/mcp/auth.ts +0 -2
  286. package/src/cli/mcp/instructions.ts +0 -2
  287. package/src/cli/mcp/legacy-context.ts +0 -2
  288. package/src/cli/mcp/legacy-tool-service.ts +0 -2
  289. package/src/cli/mcp/multi-repository.ts +0 -2
  290. package/src/cli/mcp/oauth.ts +0 -2
  291. package/src/cli/mcp/openai-secure-tunnel.ts +0 -2
  292. package/src/cli/mcp/paths.ts +0 -2
  293. package/src/cli/mcp/policy.ts +0 -2
  294. package/src/cli/mcp/post-finalize-work-attribution.ts +0 -2
  295. package/src/cli/mcp/proxy-env.ts +0 -2
  296. package/src/cli/mcp/readiness-projection.ts +0 -2
  297. package/src/cli/mcp/redaction.ts +0 -2
  298. package/src/cli/mcp/repo.ts +0 -2
  299. package/src/cli/mcp/repository-tools.ts +0 -2
  300. package/src/cli/mcp/server.ts +0 -2
  301. package/src/cli/mcp/setup.ts +0 -2
  302. package/src/cli/mcp/tool-capability-cache.ts +0 -2
  303. package/src/cli/mcp/tool-contract.ts +0 -2
  304. package/src/cli/mcp/tools.ts +0 -2
  305. package/src/cli/mcp/toolset-names.ts +0 -2
  306. package/src/cli/mcp/toolset-selection.ts +0 -2
  307. package/src/cli/mcp/toolset.ts +0 -2
  308. package/src/cli/mcp/transports/http.ts +0 -2
  309. package/src/cli/mcp/transports/session-registry.ts +0 -2
  310. package/src/cli/mcp/transports/stdio.ts +0 -2
  311. package/src/cli/mcp/types.ts +0 -2
  312. package/src/cli/mcp/umbrella-tools.ts +0 -2
  313. package/src/cli/tools/codegraph.ts +0 -695
  314. package/src/core/adoption/helper-wrapper-plan.ts +0 -159
  315. package/src/runtime/context/codegraph-cache-boundary.ts +0 -255
  316. package/src/runtime/context/codegraph-read-provider.ts +0 -611
  317. package/src/runtime/context/codegraph-sidecar.cjs +0 -248
  318. package/src/runtime/control-plane/codegraph-cache-retention.ts +0 -164
  319. package/src/runtime/gateway/mcp/execution-tools.ts +0 -2
  320. package/src/runtime/gateway/mcp/legacy-ios-tool-adapter.ts +0 -2
  321. package/src/runtime/gateway/mcp/persisted-check-process.ts +0 -2
  322. package/src/runtime/gateway/mcp/process-tools.ts +0 -2
  323. package/src/runtime/gateway/mcp/router.ts +0 -2
  324. package/src/runtime/gateway/mcp/runtime-tool-definitions.ts +0 -2
  325. package/src/runtime/gateway/mcp/runtime-tools.ts +0 -2
  326. package/src/runtime/gateway/mcp/work-validation-reconciler.ts +0 -2
  327. package/src/runtime/plugins/browser-provider-registry.ts +0 -2
  328. package/src/runtime/plugins/computer-chatgpt-macos-target.ts +0 -891
@@ -5,6 +5,7 @@ import { createServer as createNetServer } from 'net';
5
5
  import { existsSync, mkdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'fs';
6
6
  import { homedir } from 'os';
7
7
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'path';
8
+ import { fileURLToPath } from 'url';
8
9
  import type {
9
10
  AssistantPluginActionExecutionInput,
10
11
  AssistantPluginAuthorizationContext,
@@ -22,7 +23,6 @@ import {
22
23
  browserPermissions,
23
24
  } from './browser-manifest-surface';
24
25
  import { executeBrowserActionThroughNode, shouldUseBrowserNodeBridge } from './browser-node-bridge';
25
- import { BrowserProviderUnavailableBeforeActionError } from '../../../packages/plugin-runtime/browser/provider-registry';
26
26
  import { MAX_BROWSER_CDP_ENDPOINT_CANDIDATES } from '../../../packages/plugin-runtime/browser/runtime-contract';
27
27
  import {
28
28
  ALL_BROWSER_PROVIDER_CAPABILITIES,
@@ -33,6 +33,8 @@ import {
33
33
  invalidateBrowserRuntime,
34
34
  } from './browser-runtime';
35
35
  import { activateRuntimeComputerBrowserApplication } from '../root/computer-composition';
36
+ import { resolveWorkspaceTargetPath, WorkspaceTargetGrantError } from '../workspace-targets';
37
+ import { pluginCapabilityAuthorizationOwnerScope } from './capability-authorization-grants';
36
38
  import {
37
39
  DEFAULT_BROWSER_SESSION_LIST_LIMIT,
38
40
  MAX_BROWSER_SESSION_LIST_LIMIT,
@@ -55,6 +57,7 @@ import {
55
57
  readMacOsBrowserOwnedTabMetadata,
56
58
  macOsActiveBrowserAttachSupported,
57
59
  macOsBrowserJavaScriptAutomationDisabled,
60
+ assertMacOsBrowserApplicationRunning,
58
61
  type MacOsBrowserAttachAttempt,
59
62
  type MacOsBrowserCreateTabEvidence,
60
63
  type MacOsBrowserProduct,
@@ -350,6 +353,15 @@ type PlaywrightRuntime = {
350
353
  };
351
354
  };
352
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
+
353
365
  interface BrowserPluginRuntimeHooks {
354
366
  now(): string;
355
367
  moduleAvailable(name: string, repoRoot?: string): boolean;
@@ -407,9 +419,7 @@ const defaultRuntimeHooks: BrowserPluginRuntimeHooks = {
407
419
  }),
408
420
  connectBrowserCdp: connectNativeBrowserCdp,
409
421
  moduleAvailable: (name: string, repoRoot?: string) => {
410
- const anchors = [repoRoot ? join(repoRoot, 'package.json') : undefined, import.meta.url]
411
- .filter((value): value is string => Boolean(value));
412
- for (const anchor of anchors) {
422
+ for (const anchor of browserDependencyRequireAnchors(repoRoot)) {
413
423
  try {
414
424
  createRequire(anchor).resolve(name);
415
425
  return true;
@@ -420,16 +430,14 @@ const defaultRuntimeHooks: BrowserPluginRuntimeHooks = {
420
430
  return false;
421
431
  },
422
432
  loadPlaywright: (repoRoot?: string) => {
423
- const anchors = [repoRoot ? join(repoRoot, 'package.json') : undefined, import.meta.url]
424
- .filter((value): value is string => Boolean(value));
425
- for (const anchor of anchors) {
433
+ for (const anchor of browserDependencyRequireAnchors(repoRoot)) {
426
434
  try {
427
435
  return createRequire(anchor)('playwright') as PlaywrightRuntime;
428
436
  } catch {
429
437
  // Try the next trusted dependency anchor.
430
438
  }
431
439
  }
432
- 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.', {
433
441
  retryable: false,
434
442
  });
435
443
  },
@@ -579,7 +587,7 @@ function normalizeConfig(raw: PersistedBrowserPluginConfig): BrowserPluginConfig
579
587
  schemaVersion: CURRENT_BROWSER_CONFIG_SCHEMA_VERSION,
580
588
  enabled: raw.enabled === true,
581
589
  provider: 'playwright',
582
- browserMode: browserMode(raw.browserMode) ?? 'managed_persistent',
590
+ browserMode: browserMode(raw.browserMode) ?? 'attach_preferred',
583
591
  profileMode: browserProfileMode(raw.profileMode) ?? (normalizedProfileDir ? 'custom' : 'repo_local'),
584
592
  profileDir: normalizedProfileDir,
585
593
  profileDirectory: stringValue(raw.profileDirectory),
@@ -591,7 +599,7 @@ function normalizeConfig(raw: PersistedBrowserPluginConfig): BrowserPluginConfig
591
599
  ? Math.min(positiveNumber(raw.cdpDiscoveryTimeoutMs, DEFAULT_CDP_DISCOVERY_TIMEOUT_MS), MAX_CDP_DISCOVERY_TIMEOUT_MS)
592
600
  : undefined,
593
601
  cdpAttachFallback: browserCdpAttachFallback(raw.cdpAttachFallback) ?? 'fail_closed',
594
- nativeAttachMode: browserNativeAttachMode(raw.nativeAttachMode) ?? 'disabled',
602
+ nativeAttachMode: browserNativeAttachMode(raw.nativeAttachMode) ?? 'auto',
595
603
  nativeBrowserCandidates: browserProductList(raw.nativeBrowserCandidates) ?? [...DEFAULT_USER_NATIVE_BROWSER_CANDIDATES],
596
604
  defaultTimeoutMs: typeof raw.defaultTimeoutMs === 'number' ? positiveNumber(raw.defaultTimeoutMs, DEFAULT_TIMEOUT_MS) : undefined,
597
605
  };
@@ -599,23 +607,37 @@ function normalizeConfig(raw: PersistedBrowserPluginConfig): BrowserPluginConfig
599
607
 
600
608
  function migrateLegacyRepositoryConfig(raw: PersistedBrowserPluginConfig): PersistedBrowserPluginConfig {
601
609
  if (raw.schemaVersion === CURRENT_BROWSER_CONFIG_SCHEMA_VERSION) return raw;
602
- const configuredCdp = Boolean(
603
- stringValue(raw.cdpEndpoint)
604
- || (stringList(raw.cdpEndpointCandidates)?.length ?? 0) > 0,
605
- );
606
- const legacyMode = browserMode(raw.browserMode) ?? 'attach_preferred';
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;
607
614
  return {
608
615
  ...raw,
609
616
  schemaVersion: CURRENT_BROWSER_CONFIG_SCHEMA_VERSION,
610
- // v1/v2 defaulted to attach_preferred + native auto and silently made
611
- // Apple Events JavaScript a global Browser dependency whenever no CDP endpoint
612
- // was configured. Preserve explicit CDP attachment, but retire native attach
613
- // from migrated defaults; it remains available only when explicitly selected.
614
- browserMode: legacyMode === 'attach_preferred' && !configuredCdp ? 'managed_persistent' : legacyMode,
615
- nativeAttachMode: 'disabled',
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
+ } : {}),
616
624
  };
617
625
  }
618
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
+
619
641
  function readJson<T>(path: string): T | undefined {
620
642
  try {
621
643
  return JSON.parse(readFileSync(path, 'utf-8')) as T;
@@ -629,12 +651,24 @@ function writeJson(path: string, value: unknown): void {
629
651
  writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, 'utf-8');
630
652
  }
631
653
 
632
- 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
+ });
633
668
  const persisted = context
634
669
  ? readRepositoryPluginConfig<PersistedBrowserPluginConfig>({ ...context, repoRoot }, BROWSER_PLUGIN_ID)
635
670
  : undefined;
636
- if (!persisted) return normalizeConfig({});
637
- return normalizeConfig(migrateLegacyRepositoryConfig(persisted));
671
+ return persisted ? normalizeConfig(migrateLegacyRepositoryConfig(persisted)) : normalizeConfig({});
638
672
  }
639
673
 
640
674
  async function resolveBrowserPluginAuthorizationContextInternal(
@@ -649,7 +683,7 @@ async function resolveBrowserPluginAuthorizationContextInternal(
649
683
  // call and left the Scheduler without a durable browser-session grant.
650
684
  if (!action || action.confirmation !== 'authorization' || !action.scopes.includes('browser.profile')) return undefined;
651
685
 
652
- const persistedConfig = loadConfig(input.repoRoot, input);
686
+ const persistedConfig = loadConfig(input.repoRoot, input, input.pluginId === 'computer');
653
687
  const sessionId = stringValue(input.args.session_id);
654
688
  const authorizationBrowserMode = parseBrowserModeInput(input.args.browser_mode) ?? persistedConfig.browserMode;
655
689
  if (!sessionId || authorizationBrowserMode === 'isolated') return undefined;
@@ -723,6 +757,10 @@ export async function resolveBrowserPluginAuthorizationContext(
723
757
 
724
758
  function saveConfig(input: Pick<AssistantPluginActionExecutionInput, 'controllerHome' | 'repoId' | 'repoRoot'>, patch: Partial<BrowserPluginConfig>): BrowserPluginConfig {
725
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
+ }
726
764
  return writeRepositoryPluginConfig(input, BROWSER_PLUGIN_ID, next);
727
765
  }
728
766
 
@@ -914,11 +952,11 @@ function validateConfig(config: BrowserPluginConfig): string[] {
914
952
  const error = cdpEndpointValidationError(endpoint);
915
953
  if (error) errors.push(error);
916
954
  }
917
- if (config.browserMode === 'attach_preferred'
918
- && endpoints.length === 0
919
- && config.cdpAttachFallback === 'fail_closed'
920
- && config.nativeAttachMode === 'disabled') {
921
- 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.
922
960
  }
923
961
  return errors;
924
962
  }
@@ -931,14 +969,8 @@ function configWarnings(config: BrowserPluginConfig): string[] {
931
969
  warnings.push('Custom profile mode is more reliable with an explicit Chrome channel or executable path that matches the selected profile format.');
932
970
  }
933
971
  }
934
- if (config.browserMode === 'attach_preferred' && cdpEndpoints(config).length === 0) {
935
- if (config.nativeAttachMode === 'auto' && macOsActiveBrowserAttachSupported()) {
936
- warnings.push(config.cdpAttachFallback === 'fail_closed'
937
- ? 'browserMode=attach_preferred has no configured CDP endpoint; configured native-browser attach will be attempted and managed fallback is disabled.'
938
- : 'browserMode=attach_preferred has no configured CDP endpoint; configured native-browser attach will be attempted before explicit managed fallback.');
939
- } else {
940
- warnings.push('browserMode=attach_preferred has no configured CDP endpoint; actions will use the configured fallback policy.');
941
- }
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.');
942
974
  }
943
975
  if (config.browserMode === 'isolated') {
944
976
  warnings.push('browserMode=isolated uses a per-session repo-local profile and will not share the default browser profile.');
@@ -1391,54 +1423,7 @@ function sameOrigin(left: string, right: string): boolean {
1391
1423
  }
1392
1424
  }
1393
1425
 
1394
- function stableBrowserEntityKey(value: string): string | undefined {
1395
- try {
1396
- const url = new URL(value);
1397
- if (url.protocol !== 'https:' && url.protocol !== 'http:') return undefined;
1398
- const segments = url.pathname.split('/').filter(Boolean);
1399
- for (let index = 0; index + 1 < segments.length; index += 1) {
1400
- if ((segments[index] ?? '').toLowerCase() !== 'apps') continue;
1401
- const id = segments[index + 1]?.trim();
1402
- if (id) return `${url.origin}/apps/${id}`;
1403
- }
1404
- return undefined;
1405
- } catch {
1406
- return undefined;
1407
- }
1408
- }
1409
-
1410
- function selectReusableNativeTab(
1411
- referenceUrls: string[],
1412
- inventory: Awaited<ReturnType<typeof listMacOsBrowserTabs>>,
1413
- preferredWindowId?: string,
1414
- ): {
1415
- candidate?: (typeof inventory.tabs)[number];
1416
- match: 'exact_url' | 'stable_entity' | 'none' | 'ambiguous';
1417
- candidateCount: number;
1418
- } {
1419
- const normalizedReferences = [...new Set(referenceUrls.filter(Boolean).map(comparableUrl))];
1420
- const entityKeys = new Set(normalizedReferences.map(stableBrowserEntityKey).filter((value): value is string => Boolean(value)));
1421
- const scored = inventory.tabs.map((candidate) => {
1422
- const normalizedCandidate = comparableUrl(candidate.url);
1423
- const entityKey = stableBrowserEntityKey(candidate.url);
1424
- const matchScore = normalizedReferences.includes(normalizedCandidate)
1425
- ? 100
1426
- : entityKey && entityKeys.has(entityKey)
1427
- ? 50
1428
- : 0;
1429
- const score = matchScore === 0
1430
- ? 0
1431
- : matchScore + (preferredWindowId && candidate.windowId === preferredWindowId ? 10 : 0) + (candidate.active ? 1 : 0);
1432
- return { candidate, score, matchScore };
1433
- });
1434
- const maxScore = scored.reduce((max, entry) => Math.max(max, entry.score), 0);
1435
- if (maxScore === 0) return { match: 'none', candidateCount: 0 };
1436
- const matches = scored.filter((entry) => entry.score === maxScore);
1437
- if (matches.length !== 1) return { match: 'ambiguous', candidateCount: matches.length };
1438
- return { candidate: matches[0]!.candidate, match: matches[0]!.matchScore === 100 ? 'exact_url' : 'stable_entity', candidateCount: 1 };
1439
- }
1440
-
1441
- function selectLiveForgeOwnedNativeTab(
1426
+ export function selectLiveForgeOwnedNativeTab(
1442
1427
  repoRoot: string,
1443
1428
  targetUrl: string,
1444
1429
  product: MacOsBrowserProduct,
@@ -1464,11 +1449,11 @@ function selectLiveForgeOwnedNativeTab(
1464
1449
  }
1465
1450
 
1466
1451
  const values = [...candidates.values()];
1452
+ if (inventory.truncated) return { match: 'ambiguous', candidateCount: values.length };
1467
1453
  const exact = values.filter(({ candidate }) => comparableUrl(candidate.url) === comparableUrl(targetUrl));
1468
1454
  const choose = (matches: typeof values, match: 'exact_url' | 'same_origin') => {
1469
1455
  if (matches.length === 1) return { ...matches[0], match, candidateCount: 1 } as const;
1470
- const active = matches.filter(({ candidate }) => candidate.active);
1471
- if (active.length === 1) return { ...active[0], match, candidateCount: matches.length } as const;
1456
+ // Multiple matching tabs remain ambiguous even when one is active.
1472
1457
  return { match: 'ambiguous' as const, candidateCount: matches.length };
1473
1458
  };
1474
1459
  if (exact.length > 0) return choose(exact, 'exact_url');
@@ -1478,15 +1463,15 @@ function selectLiveForgeOwnedNativeTab(
1478
1463
  return { match: 'none', candidateCount: 0 };
1479
1464
  }
1480
1465
 
1481
- function selectExactNativeTab(
1466
+ export function selectExactNativeTab(
1482
1467
  targetUrl: string,
1483
1468
  inventory: Awaited<ReturnType<typeof listMacOsBrowserTabs>>,
1484
1469
  ): { candidate?: (typeof inventory.tabs)[number]; match: 'exact_url' | 'none' | 'ambiguous'; candidateCount: number } {
1470
+ if (inventory.truncated) return { match: 'ambiguous', candidateCount: inventory.tabs.length };
1485
1471
  const matches = inventory.tabs.filter((candidate) => comparableUrl(candidate.url) === comparableUrl(targetUrl));
1486
1472
  if (matches.length === 0) return { match: 'none', candidateCount: 0 };
1487
1473
  if (matches.length === 1) return { candidate: matches[0], match: 'exact_url', candidateCount: 1 };
1488
- const active = matches.filter((candidate) => candidate.active);
1489
- 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.
1490
1475
  return { match: 'ambiguous', candidateCount: matches.length };
1491
1476
  }
1492
1477
 
@@ -1519,7 +1504,7 @@ async function inventoryTabs(context: BrowserContextLike): Promise<BrowserTabInv
1519
1504
  return entries;
1520
1505
  }
1521
1506
 
1522
- function chooseTab(
1507
+ export function chooseTab(
1523
1508
  inventory: BrowserTabInventoryEntry[],
1524
1509
  target: BrowserActionTarget,
1525
1510
  options: { allowBlank?: boolean; requireOwnedToken?: boolean } = {},
@@ -1527,6 +1512,20 @@ function chooseTab(
1527
1512
  const desiredUrl = comparableUrl(target.url);
1528
1513
  const savedTab = target.existingSession?.browser?.tab;
1529
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
+ }
1530
1529
  if (options.requireOwnedToken) {
1531
1530
  const owned = expectedOwnerToken ? inventory.find((entry) => entry.ownerToken === expectedOwnerToken) : undefined;
1532
1531
  if (owned) {
@@ -1544,25 +1543,8 @@ function chooseTab(
1544
1543
  };
1545
1544
  }
1546
1545
  const savedUrl = savedTab?.url ? comparableUrl(savedTab.url) : undefined;
1547
- const savedTitle = savedTab?.title;
1548
- const savedUrlTitle = savedUrl && savedTitle
1549
- ? inventory.find((entry) => comparableUrl(entry.url) === savedUrl && entry.title === savedTitle)
1550
- : undefined;
1551
- if (savedUrlTitle) {
1552
- return {
1553
- entry: savedUrlTitle,
1554
- matchedBy: 'saved_url_title',
1555
- sessionResume: {
1556
- sessionId: target.sessionId,
1557
- status: 'matched',
1558
- reason: 'Saved tab URL and title matched an attached browser tab.',
1559
- savedTab,
1560
- },
1561
- };
1562
- }
1563
-
1564
1546
  const savedUrlOnly = savedUrl
1565
- ? inventory.find((entry) => comparableUrl(entry.url) === savedUrl)
1547
+ ? unique(inventory.filter((entry) => comparableUrl(entry.url) === savedUrl), 'saved_url')
1566
1548
  : undefined;
1567
1549
  if (savedUrlOnly) {
1568
1550
  return {
@@ -1571,13 +1553,13 @@ function chooseTab(
1571
1553
  sessionResume: {
1572
1554
  sessionId: target.sessionId,
1573
1555
  status: savedTab ? 'matched' : 'no_saved_tab',
1574
- 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.',
1575
1557
  savedTab,
1576
1558
  },
1577
1559
  };
1578
1560
  }
1579
1561
 
1580
- const exactUrl = inventory.find((entry) => comparableUrl(entry.url) === desiredUrl);
1562
+ const exactUrl = unique(inventory.filter((entry) => comparableUrl(entry.url) === desiredUrl), 'exact_url');
1581
1563
  if (exactUrl) {
1582
1564
  return {
1583
1565
  entry: exactUrl,
@@ -1597,7 +1579,7 @@ function chooseTab(
1597
1579
  };
1598
1580
  }
1599
1581
 
1600
- 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');
1601
1583
  if (blank) {
1602
1584
  return {
1603
1585
  entry: blank,
@@ -2627,15 +2609,13 @@ async function openNativeAttachedContext(
2627
2609
  args: Record<string, unknown>,
2628
2610
  ): Promise<{ handle?: BrowserOpenHandle; attempts: MacOsBrowserAttachAttempt[] }> {
2629
2611
  if (config.nativeAttachMode === 'disabled') return { attempts: [] };
2630
- if (!target.existingSession && !stringValue(args.session_id)) {
2612
+ if (!target.existingSession && !stringValue(args.session_id) && args.__forge_computer_native_only !== true) {
2631
2613
  await inspectNativeOwnedSessions({ repoRoot, savedSessions: listSavedSessions(repoRoot), timeoutMs: config.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS, pruneDead: true });
2632
2614
  }
2633
2615
  const timeout = positiveNumber(args.timeout_ms, config.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS);
2634
2616
  const savedTab = target.existingSession?.browser?.tab;
2635
2617
  const savedOwnership = savedTab?.ownership;
2636
2618
  let effectiveOwnership = savedOwnership;
2637
- let recoveredTabTitle = '';
2638
- let recoveredTabUrl: string | undefined;
2639
2619
  const savedProduct = target.existingSession?.browser?.provider === 'macos-apple-events'
2640
2620
  && savedTab
2641
2621
  && (savedOwnership === 'plugin_owned' || savedOwnership === 'user_owned')
@@ -2685,63 +2665,18 @@ async function openNativeAttachedContext(
2685
2665
  savedTab,
2686
2666
  };
2687
2667
  } catch (error) {
2688
- if (error instanceof AssistantPluginError
2689
- && error.code === 'PLUGIN_BROWSER_NATIVE_TAB_IDENTITY_UNPROVEN'
2690
- && 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') {
2691
2671
  throw error;
2692
2672
  }
2693
- if (savedOwnership === 'user_owned') {
2694
- throw new AssistantPluginError(
2695
- 'PLUGIN_BROWSER_SESSION_STATE_LOST',
2696
- 'Explicitly adopted user-owned macOS browser tab no longer exists; refusing to silently switch to another user tab.',
2697
- {
2698
- retryable: false,
2699
- details: { sessionId: target.sessionId, browserProduct: savedProduct, ownership: savedOwnership, windowId: savedTab.windowId, tabId: savedTab.tabId, cause: error instanceof Error ? error.message : String(error) },
2700
- },
2701
- );
2702
- }
2703
- let inventory: Awaited<ReturnType<typeof listMacOsBrowserTabs>> | undefined;
2704
- let selection: ReturnType<typeof selectReusableNativeTab> | undefined;
2705
- let recoveryFailure: string | undefined;
2706
- try {
2707
- inventory = await listMacOsBrowserTabs(savedProduct, timeout);
2708
- selection = selectReusableNativeTab(
2709
- [savedTab.url, target.existingSession?.url ?? '', target.url],
2710
- inventory,
2711
- savedTab.windowId,
2712
- );
2713
- if (selection.candidate) {
2714
- const recovered = await reattachMacOsBrowserOwnedPage(savedProduct, { windowId: selection.candidate.windowId, tabId: selection.candidate.tabId }, timeout);
2715
- page = recovered.page as unknown as PageLike;
2716
- discovered = { attachment: recovered.attachment, attempts: recovered.attachment.attempts };
2717
- matchedBy = 'recovered_tab';
2718
- effectiveOwnership = 'user_owned';
2719
- recoveredTabTitle = selection.candidate.title;
2720
- recoveredTabUrl = selection.candidate.url;
2721
- sessionResume = {
2722
- sessionId: target.sessionId,
2723
- status: 'matched',
2724
- reason: selection.match === 'exact_url'
2725
- ? 'Saved plugin-owned tab disappeared; rebound safely to the unique currently open tab with the same URL and preserved it as user-owned.'
2726
- : '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.',
2727
- savedTab,
2728
- };
2729
- }
2730
- } catch (recoveryError) {
2731
- recoveryFailure = recoveryError instanceof Error ? recoveryError.message : String(recoveryError);
2732
- }
2733
- if (!page) {
2734
- sessionResume = {
2735
- sessionId: target.sessionId,
2736
- status: 'stale_tab',
2737
- reason: recoveryFailure
2738
- ? `Saved plugin-owned macOS browser tab disappeared and current-tab recovery failed (${recoveryFailure}); creating one replacement plugin-owned tab.`
2739
- : selection?.match === 'ambiguous'
2740
- ? '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.'
2741
- : 'Saved plugin-owned macOS browser tab disappeared and no uniquely reusable current tab matched; creating one replacement plugin-owned tab.',
2742
- savedTab,
2743
- };
2744
- }
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
+ } });
2745
2680
  }
2746
2681
  } else {
2747
2682
  sessionResume = savedTab
@@ -2749,11 +2684,105 @@ async function openNativeAttachedContext(
2749
2684
  : { sessionId: target.sessionId, status: 'no_saved_tab', reason: 'Created one plugin-owned tab while preserving the user active tab.' };
2750
2685
  }
2751
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
+ }
2752
2700
  let createdThisCallRef: MacOsBrowserTabRef | undefined;
2701
+ let preferredNativeWindowId: string | undefined;
2753
2702
  if (!discovered?.attachment) {
2754
2703
  const candidates = savedProduct ? [savedProduct] : (config.nativeBrowserCandidates ?? [...DEFAULT_USER_NATIVE_BROWSER_CANDIDATES]);
2755
- 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));
2756
2784
  const zeroWindowAttempt = args.__forge_require_existing_resource !== true
2785
+ && args.__forge_computer_native_only !== true
2757
2786
  ? discovered.attempts.find((attempt) => attempt.status === 'unavailable' && attempt.error?.includes('FORGE_NO_BROWSER_WINDOW'))
2758
2787
  : undefined;
2759
2788
  if (!discovered.attachment && zeroWindowAttempt) {
@@ -2870,12 +2899,17 @@ async function openNativeAttachedContext(
2870
2899
  }
2871
2900
  };
2872
2901
  try {
2873
- if (!page && !savedTab && !stringValue(args.session_id)) {
2902
+ if (!page && !savedTab && !target.existingSession && (args.__forge_computer_native_only === true || !stringValue(args.session_id))) {
2874
2903
  let inventory: Awaited<ReturnType<typeof listMacOsBrowserTabs>> | undefined;
2875
2904
  try {
2876
2905
  inventory = await listMacOsBrowserTabs(activeAttachment.metadata.product, timeout);
2877
2906
  } catch (error) {
2878
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
+ }
2879
2913
  }
2880
2914
  if (inventory) {
2881
2915
  const ownedSelection = selectLiveForgeOwnedNativeTab(repoRoot, target.url, activeAttachment.metadata.product, inventory);
@@ -2929,8 +2963,6 @@ async function openNativeAttachedContext(
2929
2963
  activeAttachment = rebound.attachment;
2930
2964
  matchedBy = 'exact_url';
2931
2965
  effectiveOwnership = 'user_owned';
2932
- recoveredTabTitle = exactSelection.candidate.title;
2933
- recoveredTabUrl = exactSelection.candidate.url;
2934
2966
  sessionResume = {
2935
2967
  sessionId: target.sessionId,
2936
2968
  status: 'matched',
@@ -2941,7 +2973,23 @@ async function openNativeAttachedContext(
2941
2973
  }
2942
2974
  }
2943
2975
  if (!page) {
2944
- 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
+ }
2945
2993
  createdThisCallRef = (page as unknown as { tabRef?: () => MacOsBrowserTabRef | undefined }).tabRef?.();
2946
2994
  const liveIdentity = await settleNativeCreatedPageIdentity(
2947
2995
  page as unknown as {
@@ -2961,21 +3009,15 @@ async function openNativeAttachedContext(
2961
3009
  if (comparableUrl(page.url()) !== comparableUrl(target.url) && !explicitUserOwnedUrlMatch) {
2962
3010
  const currentRef = (page as unknown as { tabRef?: () => MacOsBrowserTabRef | undefined }).tabRef?.();
2963
3011
  if (effectiveOwnership === 'user_owned') {
2964
- if (matchedBy === 'recovered_tab') {
2965
- 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 };
2966
- } else {
2967
- throw new AssistantPluginError(
2968
- 'PLUGIN_BROWSER_ADOPTED_TAB_URL_MISMATCH',
2969
- 'Explicitly adopted user-owned tab no longer matches the saved session URL; refusing to navigate, replace, or close the user tab.',
2970
- { retryable: true, details: { sessionId: target.sessionId, expectedUrl: target.url, actualUrl: page.url(), windowId: savedTab?.windowId, tabId: savedTab?.tabId } },
2971
- );
2972
- }
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
+ );
2973
3017
  }
2974
3018
  const sessionTargetUnchanged = Boolean(target.existingSession?.url) && comparableUrl(target.url) === comparableUrl(target.existingSession?.url ?? '');
2975
3019
  const preserveOwnedSameOriginDrift = matchedBy === 'owned_token' && sessionTargetUnchanged && sameOrigin(page.url(), target.url);
2976
- if (matchedBy === 'recovered_tab' && effectiveOwnership === 'user_owned') {
2977
- // Safe recovery adopts an already-open tab. Its current page is authoritative and must not be navigated.
2978
- } else if (preserveOwnedSameOriginDrift) {
3020
+ if (preserveOwnedSameOriginDrift) {
2979
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 };
2980
3022
  } else {
2981
3023
  page = await navigateNativePageWithReplacement(page);
@@ -2993,13 +3035,13 @@ async function openNativeAttachedContext(
2993
3035
  if (matchedBy !== 'owned_token' && page.waitForLoadState && !explicitInternalUserSession) {
2994
3036
  await page.waitForLoadState(waitUntil(args.wait_until), { timeout });
2995
3037
  }
2996
- const observedPageUrl = matchedBy === 'recovered_tab' && recoveredTabUrl ? recoveredTabUrl : page.url();
3038
+ const observedPageUrl = page.url();
2997
3039
  const pageUrl = explicitInternalUserSession
2998
3040
  ? normalizedExplicitNativeAdoptionUrl(observedPageUrl)
2999
3041
  : normalizedUrl(observedPageUrl);
3000
3042
  const nativeRef = (page as unknown as { tabRef?: () => MacOsBrowserTabRef | undefined }).tabRef?.();
3001
3043
  if (!nativeRef) throw new AssistantPluginError('PLUGIN_BROWSER_NATIVE_OWNERSHIP_MISSING', 'Native browser did not return a stable plugin-owned tab reference.', { retryable: true });
3002
- const title = matchedBy === 'owned_token' ? (target.existingSession?.title ?? '') : matchedBy === 'recovered_tab' ? recoveredTabTitle : '';
3044
+ const title = matchedBy === 'owned_token' ? (target.existingSession?.title ?? '') : '';
3003
3045
  const inventory: BrowserTabInventoryEntry[] = [{ index: 0, key: tabKey(pageUrl, title), url: pageUrl, title }];
3004
3046
  const metadata = activeAttachment.metadata;
3005
3047
  // The live exact-tab URL is authoritative after a safe native rebind. Keep the
@@ -3042,80 +3084,66 @@ async function openBrowser(
3042
3084
  target: BrowserActionTarget,
3043
3085
  args: Record<string, unknown>,
3044
3086
  ): Promise<BrowserOpenHandle> {
3045
- 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
+ }
3046
3106
  if (requestedMode === 'attach_preferred') {
3047
- const requireExistingResource = args.__forge_require_existing_resource === true;
3048
- const existingProvider = target.existingSession?.browser?.provider;
3049
- const attached = cdpEndpoints(config).length > 0 && runtimeHooks.moduleAvailable('playwright', repoRoot)
3050
- ? await openAttachedContext(runtimeHooks.loadPlaywright(repoRoot), repoRoot, config, target, args)
3051
- : { attempts: [] as CdpAttachAttempt[] };
3052
- if (attached.handle) return attached.handle;
3053
- if (requireExistingResource && existingProvider === 'playwright-cdp') {
3054
- throw new AssistantPluginError(
3055
- 'PLUGIN_BROWSER_SESSION_STATE_LOST',
3056
- 'Saved CDP browser page is no longer attachable; refusing to fall back to another browser or create a replacement page.',
3057
- { retryable: false, details: { sessionId: target.sessionId, provider: existingProvider } },
3058
- );
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 } });
3059
3112
  }
3060
- 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
+ }
3061
3121
  if (!runtimeHooks.moduleAvailable('playwright', repoRoot)) {
3062
- 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 });
3063
3124
  }
3064
- return openManagedContext(runtimeHooks.loadPlaywright(repoRoot), repoRoot, config, target, args, 'managed_persistent', requestedMode, {
3065
- policy: config.cdpAttachFallback ?? 'fail_closed',
3066
- from: 'attach_preferred',
3067
- to: 'managed_persistent',
3068
- reason: 'Reusing the existing managed fallback session without probing or creating a different browser resource.',
3069
- attempts: attached.attempts,
3070
- });
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 });
3071
3135
  }
3072
-
3073
3136
  const native = await openNativeAttachedContext(repoRoot, config, target, args);
3074
3137
  if (native.handle) return native.handle;
3075
- if (requireExistingResource) {
3076
- throw new AssistantPluginError(
3077
- 'PLUGIN_BROWSER_SESSION_STATE_LOST',
3078
- 'Saved browser resource is no longer attachable; refusing to create a replacement tab/page for this existing-session action.',
3079
- { retryable: false, details: { sessionId: target.sessionId, provider: existingProvider } },
3080
- );
3081
- }
3082
-
3083
- const fallbackPolicy = config.cdpAttachFallback ?? 'fail_closed';
3084
- const cdpReason = attached.attempts.map((attempt) => `${attempt.endpoint}: ${attempt.error ?? 'unavailable'}`).join('; ')
3085
- || 'No CDP endpoint candidates were configured.';
3086
- const nativeReason = native.attempts.map((attempt) => `${attempt.appName}: ${attempt.error ?? attempt.status}`).join('; ')
3087
- || (config.nativeAttachMode === 'disabled' ? 'Native browser attach is disabled.' : 'No scriptable macOS browser was available.');
3088
- const reason = `CDP: ${cdpReason} Native: ${nativeReason}`;
3089
- if (fallbackPolicy === 'fail_closed') {
3090
- throw new AssistantPluginError('PLUGIN_BROWSER_ATTACH_UNAVAILABLE', `No configured browser attach provider was available. Fallback policy fail_closed prevented managed launch. ${reason}`, {
3091
- retryable: true,
3092
- details: {
3093
- browserMode: requestedMode,
3094
- fallbackPolicy,
3095
- attempts: attached.attempts,
3096
- nativeAttempts: native.attempts,
3097
- },
3098
- });
3099
- }
3100
- if (!runtimeHooks.moduleAvailable('playwright', repoRoot)) {
3101
- throw new AssistantPluginError('PLUGIN_BROWSER_DEPENDENCY_UNAVAILABLE', `Managed browser fallback requires Playwright, but native/CDP attach was unavailable. ${reason}`, { retryable: false });
3102
- }
3103
- return openManagedContext(runtimeHooks.loadPlaywright(repoRoot), repoRoot, config, target, args, 'managed_persistent', requestedMode, {
3104
- policy: fallbackPolicy,
3105
- from: 'attach_preferred',
3106
- to: 'managed_persistent',
3107
- reason,
3108
- attempts: attached.attempts,
3109
- nativeAttempts: native.attempts,
3110
- });
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 } });
3111
3141
  }
3112
-
3113
3142
  if (!runtimeHooks.moduleAvailable('playwright', repoRoot)) {
3114
3143
  throw new AssistantPluginError('PLUGIN_BROWSER_DEPENDENCY_UNAVAILABLE', 'Managed browser mode requires Playwright.', { retryable: false });
3115
3144
  }
3116
3145
  return openManagedContext(runtimeHooks.loadPlaywright(repoRoot), repoRoot, config, target, args, requestedMode, requestedMode);
3117
3146
  }
3118
-
3119
3147
  async function withPage<T>(
3120
3148
  actionId: string,
3121
3149
  repoRoot: string,
@@ -3875,9 +3903,12 @@ function health(config: BrowserPluginConfig, repoRoot?: string): AssistantPlugin
3875
3903
  };
3876
3904
  }
3877
3905
 
3878
- 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 {
3879
3910
  const root = repoRoot ?? process.cwd();
3880
- const config = loadConfig(root, context);
3911
+ const config = loadConfig(root, context, computerNativeOnly);
3881
3912
  const state = health(config, root);
3882
3913
  return {
3883
3914
  schemaVersion: 1,
@@ -3899,9 +3930,7 @@ export function buildBrowserPluginManifest(previousRevision = 0, previousUpdated
3899
3930
  ? 'Browser plugin is disabled.'
3900
3931
  : state.ready
3901
3932
  ? `Browser plugin is ready via ${config.browserMode === 'attach_preferred'
3902
- ? config.cdpAttachFallback === 'managed_persistent'
3903
- ? 'CDP, macOS native attach, and explicit managed fallback.'
3904
- : 'CDP or macOS native attach; managed fallback is disabled.'
3933
+ ? 'the one configured CDP or native browser target (no provider fallback).'
3905
3934
  : 'Playwright persistent context.'}`
3906
3935
  : state.state === 'degraded'
3907
3936
  ? state.warnings[0] ?? 'Browser plugin requires a successful live native attach probe.'
@@ -3915,13 +3944,6 @@ export function buildBrowserPluginManifest(previousRevision = 0, previousUpdated
3915
3944
  };
3916
3945
  }
3917
3946
 
3918
- const NODE_BRIDGE_PRE_ACTION_FAILURE_CODES = new Set([
3919
- 'PLUGIN_BROWSER_NODE_UNAVAILABLE',
3920
- 'PLUGIN_BROWSER_NODE_HOST_UNAVAILABLE',
3921
- 'PLUGIN_BROWSER_NODE_REQUEST_TOO_LARGE',
3922
- 'PLUGIN_BROWSER_NODE_START_FAILED',
3923
- ]);
3924
-
3925
3947
  function browserRuntimeKey(input: AssistantPluginActionExecutionInput): string {
3926
3948
  return `${input.controllerHome}:${input.repoId}`;
3927
3949
  }
@@ -3930,9 +3952,15 @@ async function executeBrowserPluginActionInternal(
3930
3952
  input: AssistantPluginActionExecutionInput,
3931
3953
  runtimeAlreadyRouted = false,
3932
3954
  ): Promise<Record<string, unknown>> {
3933
- const persisted = loadConfig(input.repoRoot, input);
3955
+ const persisted = loadConfig(input.repoRoot, input, input.pluginId === 'computer');
3934
3956
  const actionSessionId = input.actionId === 'configure' ? undefined : stringValue(input.args.session_id);
3935
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
+ }
3936
3964
  const current = input.actionId === 'configure' ? persisted : effectiveBrowserActionConfig(persisted, input.args, actionSession);
3937
3965
  if (!current.enabled && input.actionId !== 'configure') {
3938
3966
  throw new AssistantPluginError('PLUGIN_DISABLED', 'Browser plugin is disabled.', { retryable: false });
@@ -3945,7 +3973,7 @@ async function executeBrowserPluginActionInternal(
3945
3973
  }
3946
3974
  if (!runtimeAlreadyRouted) {
3947
3975
  const runtimeKey = browserRuntimeKey(input);
3948
- const nodeBridgeEligible = shouldUseBrowserNodeBridge(
3976
+ const nodeBridgeEligible = input.pluginId !== 'computer' && shouldUseBrowserNodeBridge(
3949
3977
  input.actionId,
3950
3978
  current.browserMode,
3951
3979
  runtimeHooksCustomized,
@@ -3954,33 +3982,21 @@ async function executeBrowserPluginActionInternal(
3954
3982
  const result = await executeBrowserRuntimeAction({
3955
3983
  runtimeKey,
3956
3984
  input,
3957
- providers: [
3958
- ...(nodeBridgeEligible ? [{
3985
+ providers: nodeBridgeEligible ? [{
3959
3986
  providerId: 'browser.node_bridge',
3960
3987
  capabilities: ALL_BROWSER_PROVIDER_CAPABILITIES.filter((capability) => capability !== 'browser.persistent_handle'),
3961
3988
  foreground: 'none' as const,
3962
3989
  verifiesPostconditions: true,
3963
3990
  persistentHandle: false,
3964
- priority: 10,
3965
3991
  supportsInput: () => true,
3966
- execute: async (routedInput: AssistantPluginActionExecutionInput) => {
3967
- try {
3968
- return await executeBrowserActionThroughNode(routedInput);
3969
- } catch (error) {
3970
- if (error instanceof AssistantPluginError && NODE_BRIDGE_PRE_ACTION_FAILURE_CODES.has(error.code)) {
3971
- throw new BrowserProviderUnavailableBeforeActionError('browser.node_bridge', error.code);
3972
- }
3973
- throw error;
3974
- }
3975
- },
3976
- }] : []),
3992
+ execute: async (routedInput: AssistantPluginActionExecutionInput) => await executeBrowserActionThroughNode(routedInput),
3993
+ }] : [
3977
3994
  {
3978
3995
  providerId: 'browser.local',
3979
3996
  capabilities: ALL_BROWSER_PROVIDER_CAPABILITIES,
3980
3997
  foreground: 'none' as const,
3981
3998
  verifiesPostconditions: true,
3982
3999
  persistentHandle: true,
3983
- priority: 20,
3984
4000
  supportsInput: () => true,
3985
4001
  execute: async (routedInput: AssistantPluginActionExecutionInput) => executeBrowserPluginActionInternal(routedInput, true),
3986
4002
  },
@@ -4032,10 +4048,6 @@ async function executeBrowserPluginActionInternal(
4032
4048
  ? positiveNumber(args.default_timeout_ms, DEFAULT_TIMEOUT_MS)
4033
4049
  : current.defaultTimeoutMs,
4034
4050
  });
4035
- const configErrors = validateConfig(config);
4036
- if (configErrors.length > 0) {
4037
- throw new AssistantPluginError('PLUGIN_CONFIGURATION_INVALID', configErrors[0], { retryable: false });
4038
- }
4039
4051
  await closeManagedContextsForRepo(input.repoRoot);
4040
4052
  return { config, health: health(config, input.repoRoot) };
4041
4053
  }
@@ -4118,7 +4130,10 @@ async function executeBrowserPluginActionInternal(
4118
4130
  throw new AssistantPluginError('PLUGIN_BROWSER_SESSION_CURSOR_INVALID', 'Browser session cursor is invalid or expired.', { retryable: false });
4119
4131
  }
4120
4132
  }
4121
- 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);
4122
4137
  const nextOffset = offset + page.length;
4123
4138
  return {
4124
4139
  provider: 'playwright',
@@ -4131,8 +4146,8 @@ async function executeBrowserPluginActionInternal(
4131
4146
  deadManagedSessionCount: inventory.deadManagedSessionCount,
4132
4147
  deadNativeSessionCount: inventory.deadNativeSessionCount,
4133
4148
  limit: requestedLimit,
4134
- totalCount: inventory.sessions.length,
4135
- ...(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') } : {}),
4136
4151
  sessions: page.map(({ session, liveness, evidence, cleanupError }) => ({
4137
4152
  sessionId: session.sessionId,
4138
4153
  url: session.url,
@@ -4217,7 +4232,13 @@ async function executeBrowserPluginActionInternal(
4217
4232
  case 'request_human_handoff': {
4218
4233
  const target = resolveActionTarget(input.repoRoot, input.args);
4219
4234
  const managedSession = target.existingSession;
4220
- 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;
4221
4242
  if (managedSession && managedTarget) {
4222
4243
  const result = { url: managedTarget.page.url(), title: await managedTarget.page.title().catch(() => managedSession.title) };
4223
4244
  const handoff = startRuntimeManagedBrowserHandoff({
@@ -4254,7 +4275,7 @@ async function executeBrowserPluginActionInternal(
4254
4275
  const nativeTab = nativeSession?.browser?.tab;
4255
4276
  const nativeBrowser = nativeSession?.browser?.provider === 'macos-apple-events'
4256
4277
  && nativeSession.browser.browserProduct
4257
- && nativeTab?.ownership === 'plugin_owned'
4278
+ && (nativeTab?.ownership === 'plugin_owned' || nativeTab?.ownership === 'user_owned')
4258
4279
  && typeof nativeTab.windowId === 'string'
4259
4280
  && typeof nativeTab.tabId === 'string'
4260
4281
  ? {
@@ -4262,6 +4283,11 @@ async function executeBrowserPluginActionInternal(
4262
4283
  ref: { windowId: nativeTab.windowId, tabId: nativeTab.tabId },
4263
4284
  }
4264
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
+ }
4265
4291
  const handoffMode: BrowserMode = current.browserMode === 'isolated' ? 'isolated' : 'managed_persistent';
4266
4292
  const profile = selectedProfile(current, input.repoRoot, handoffMode, target.sessionId);
4267
4293
  if (!nativeBrowser) mkdirSync(profile.profileDir, { recursive: true });
@@ -4296,6 +4322,11 @@ async function executeBrowserPluginActionInternal(
4296
4322
  case 'get_handoff_status': {
4297
4323
  const interactionId = requiredString(input.args.interaction_id, 'interaction_id');
4298
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
+ }
4299
4330
  if (handoff.status === 'completed' && handoff.result?.url) {
4300
4331
  const session = findBrowserSession(input.repoRoot, handoff.sessionId);
4301
4332
  if (session) {
@@ -4323,6 +4354,11 @@ async function executeBrowserPluginActionInternal(
4323
4354
  throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'resolution must be resume or cancel', { retryable: false });
4324
4355
  }
4325
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
+ }
4326
4362
  if (isRuntimeManagedBrowserHandoff(currentHandoff)) {
4327
4363
  const session = findBrowserSession(input.repoRoot, currentHandoff.sessionId);
4328
4364
  const managedTarget = session ? await runtimeManagedPageForSession(input.repoRoot, current, session) : undefined;
@@ -5223,30 +5259,7 @@ async function executeBrowserPluginActionInternal(
5223
5259
  if (requested.length === 0 || requested.length > 32) {
5224
5260
  throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'attach_local_file requires file_path or 1-32 file_paths.', { retryable: false });
5225
5261
  }
5226
- const repositoryLexicalRoot = resolve(input.repoRoot);
5227
- const repositoryRoot = realpathSync(input.repoRoot);
5228
- const resolved = requested.map((filePath) => {
5229
- const lexicalPath = resolve(input.repoRoot, filePath);
5230
- const lexicalRelative = relative(repositoryLexicalRoot, lexicalPath);
5231
- if (lexicalRelative === '..' || lexicalRelative.startsWith(`..${sep}`) || isAbsolute(lexicalRelative)) {
5232
- throw new AssistantPluginError('PLUGIN_POLICY_BLOCKED', 'Local file attachments must stay inside the repository root.', { retryable: false, details: { fileName: basename(filePath) } });
5233
- }
5234
- if (!existsSync(lexicalPath)) {
5235
- throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', `Local attachment does not exist: ${basename(lexicalPath)}`, { retryable: false });
5236
- }
5237
- const realPath = realpathSync(lexicalPath);
5238
- const repositoryRelative = relative(repositoryRoot, realPath);
5239
- if (repositoryRelative === '..' || repositoryRelative.startsWith(`..${sep}`) || isAbsolute(repositoryRelative)) {
5240
- throw new AssistantPluginError('PLUGIN_POLICY_BLOCKED', 'Local file attachments must resolve inside the repository root.', { retryable: false, details: { fileName: basename(lexicalPath) } });
5241
- }
5242
- if (!statSync(realPath).isFile()) {
5243
- throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', `Local attachment is not a regular file: ${basename(lexicalPath)}`, { retryable: false });
5244
- }
5245
- if (/\.(exe|dmg|pkg|sh|bat|cmd|app)$/i.test(realPath)) {
5246
- throw new AssistantPluginError('PLUGIN_POLICY_BLOCKED', 'Executable file attachments are not allowed.', { retryable: false });
5247
- }
5248
- return lexicalPath;
5249
- });
5262
+ const resolved = resolveBrowserAttachmentPaths(input, requested, stringValue(input.args.target_key));
5250
5263
  return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
5251
5264
  await page.evaluate((payload) => {
5252
5265
  const args = payload as { selector: string; count: number };
@@ -5352,6 +5365,61 @@ async function executeBrowserPluginActionInternal(
5352
5365
  }
5353
5366
  }
5354
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
+
5355
5423
  export async function executeBrowserPluginAction(input: AssistantPluginActionExecutionInput): Promise<Record<string, unknown>> {
5356
5424
  return await withBrowserSessionAuthorityContext(
5357
5425
  { controllerHome: input.controllerHome, repoId: input.repoId },