@moretea-labs/forge 1.6.0 → 1.7.1
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.
- package/AGENTS.md +4 -0
- package/CHANGELOG.md +39 -0
- package/CLAUDE.md +4 -0
- package/README.md +2 -2
- package/README.zh-CN.md +1 -1
- package/SKILL.md +2 -2
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/agents/openai.yaml +1 -1
- package/assets/hooks/hook-input.sh +11 -8
- package/assets/hooks/lib/session-state.sh +10 -7
- package/assets/hooks/lib/workflow-state.sh +13 -177
- package/assets/hooks/post-tool-observer.sh +8 -2
- package/assets/hooks/prompt-guard.sh +5 -81
- package/assets/hooks/session-start-context.sh +56 -1
- package/assets/partials/05-workflow.partial.md +1 -1
- package/assets/partials-agents/02-operating-mode.partial.md +2 -0
- package/assets/partials-agents/03-orchestration.partial.md +1 -1
- package/assets/plugin-registry.v1.json +3 -3
- package/assets/reference-configs/document-generation.md +1 -1
- package/assets/reference-configs/harness-overview.md +1 -1
- package/assets/reference-configs/sprint-contracts.md +3 -3
- package/assets/skill-version.json +2 -2
- package/assets/templates/contract.template.md +2 -7
- package/assets/templates/helpers/capture-plan.sh +2 -2
- package/assets/templates/helpers/check-architecture-sync.sh +17 -0
- package/assets/templates/helpers/check-task-workflow.sh +4 -4
- package/assets/templates/helpers/contract-worktree.sh +1 -21
- package/assets/templates/helpers/ensure-task-workflow.sh +11 -36
- package/assets/templates/helpers/inspect-project-state.ts +8 -6
- package/assets/templates/helpers/migrate-project-template.sh +22 -45
- package/assets/templates/helpers/migrate-workflow-docs.ts +9 -7
- package/assets/templates/helpers/new-plan.sh +1 -1
- package/assets/templates/helpers/plan-to-todo.sh +12 -37
- package/assets/templates/helpers/ship-worktrees.sh +2 -29
- package/assets/templates/helpers/verify-contract.sh +1 -111
- package/assets/templates/helpers/verify-sprint.sh +3 -180
- package/assets/templates/helpers/workflow-contract.ts +5 -1
- package/assets/templates/plan.template.md +1 -1
- package/assets/templates/review.template.md +9 -34
- package/assets/workflow-contract.v1.json +10 -19
- package/docs/README.md +10 -10
- package/docs/architecture/CURRENT.md +178 -0
- package/docs/architecture/EVOLUTION.md +42 -0
- package/docs/architecture/current/README.md +4 -156
- package/docs/architecture/decisions/20260803-release-identity-binding-and-exit-policy.md +2 -2
- package/docs/architecture/decisions/20260823-immediate-controller-relay.md +53 -0
- package/docs/architecture/decisions/20260826-browser-runtime-v2.md +73 -0
- package/docs/architecture/history/README.md +7 -0
- package/docs/architecture/{chatgpt-handoff-facade.md → history/chatgpt-handoff-facade.md} +4 -0
- package/docs/architecture/{global-hook-runtime.md → history/global-hook-runtime.md} +7 -3
- package/docs/architecture/{ios-semantic-automation-provider-v2.md → history/ios-semantic-automation-provider-v2.md} +6 -2
- package/docs/architecture/history.md +3 -34
- package/docs/architecture/index.md +15 -98
- package/docs/architecture/modules/controller-runtime/transport-lifecycle.md +1 -1
- package/docs/architecture/reviews/2026-08-22-semantic-harness-convergence-acceptance.md +39 -0
- package/docs/architecture/versions/1.6.md +38 -0
- package/docs/architecture/versions/1.7.md +41 -0
- package/docs/forge-chatgpt-browser-engine.md +2 -2
- package/docs/forge-chatgpt-mcp-setup.md +4 -28
- package/docs/forge-chatgpt-supervised-automation.md +3 -3
- package/docs/forge-github-issue-launcher.md +1 -1
- package/docs/forge-plugin-management.md +1 -1
- package/docs/forge-runtime-self-healing-loop.md +1 -1
- package/docs/operations/controller-browser-plugin.md +26 -6
- package/docs/operations/controller-performance-and-502.md +143 -0
- package/docs/operations/controller-reliability-runbook.md +1 -1
- package/docs/operations/features.md +2 -2
- package/docs/operations/github-repository.md +1 -1
- package/docs/operations/homebrew.md +2 -2
- package/docs/operations/mcp-tool-exposure.md +5 -3
- package/docs/wiki/Architecture.md +6 -6
- package/docs/wiki/Implementation.md +2 -2
- package/docs/wiki/Integrations.md +2 -2
- package/docs/wiki/Work-Lifecycle.md +1 -1
- package/package.json +33 -11
- package/scripts/activate-source-baseline.ts +17 -13
- package/scripts/architecture-event.ts +2 -820
- package/scripts/architecture-queue.sh +2 -645
- package/scripts/archive-architecture-request.sh +2 -242
- package/scripts/archive-workflow.sh +1 -291
- package/scripts/benchmark-background-check-overlap.ts +228 -0
- package/scripts/benchmark-mcp-host.sh +117 -0
- package/scripts/benchmark-mcp-transport.ts +170 -0
- package/scripts/benchmark-quality-harness.ts +146 -0
- package/scripts/benchmark-route-session-concurrency.ts +207 -6
- package/scripts/benchmark-thin-harness-mcp-e2e.ts +7 -1
- package/scripts/benchmark-typescript-navigation.ts +186 -0
- package/scripts/benchmark-work-efficiency.ts +334 -0
- package/scripts/capability-config.ts +2 -392
- package/scripts/capability-resolver.ts +2 -503
- package/scripts/capture-plan.sh +1 -332
- package/scripts/check-agent-tooling.sh +2 -1558
- package/scripts/check-architecture-sync.sh +2 -371
- package/scripts/check-brain-manifest.sh +2 -253
- package/scripts/check-context-files.sh +2 -47
- package/scripts/check-deploy-sql-order.sh +2 -115
- package/scripts/check-mcp-compatibility.ts +14 -0
- package/scripts/check-runtime-architecture.mjs +349 -79
- package/scripts/check-skill-version.ts +3 -191
- package/scripts/check-source-duplication.mjs +61 -0
- package/scripts/check-stable-baseline.ts +92 -0
- package/scripts/check-task-sync.sh +2 -63
- package/scripts/check-task-workflow.sh +2 -1000
- package/scripts/codex-handoff-resume.sh +2 -267
- package/scripts/context-contract-sync.sh +2 -514
- package/scripts/contract-run.ts +2 -463
- package/scripts/contract-worktree.sh +1 -722
- package/scripts/ensure-task-workflow.sh +1 -1225
- package/scripts/host-temporary-cloud-mcp-e2e.sh +265 -0
- package/scripts/inspect-project-state.ts +4 -279
- package/scripts/install-standalone-recovery.ts +24 -0
- package/scripts/lib/project-init-lib.sh +22 -46
- package/scripts/lib/run-forge-helper.sh +19 -0
- package/scripts/lib/run-forge-helper.ts +26 -0
- package/scripts/migrate-project-template.sh +1 -1189
- package/scripts/migrate-workflow-docs.ts +4 -412
- package/scripts/new-plan.sh +1 -180
- package/scripts/new-spec.sh +1 -47
- package/scripts/new-sprint.sh +1 -46
- package/scripts/plan-to-todo.sh +1 -796
- package/scripts/prepare-codex-handoff.sh +2 -201
- package/scripts/prepare-handoff.sh +1 -78
- package/scripts/refresh-current-status.sh +2 -467
- package/scripts/run-governed-gate.ts +5 -1
- package/scripts/select-agent-context-blocks.sh +2 -64
- package/scripts/ship-worktrees.sh +1 -558
- package/scripts/smoke-ios-physical-device.ts +4 -2
- package/scripts/smoke-runtime-recovery.ts +1 -35
- package/scripts/smoke-schedule-engine.ts +1 -20
- package/scripts/sprint-backlog.sh +1 -834
- package/scripts/stage-runtime-release.ts +2 -1
- package/scripts/summarize-failures.sh +2 -123
- package/scripts/switch-plan.sh +1 -150
- package/scripts/sync-brain-docs.sh +2 -379
- package/scripts/sync-generated-authority.mjs +175 -0
- package/scripts/verify-contract.sh +2 -703
- package/scripts/verify-forge-runtime.sh +0 -1
- package/scripts/verify-sprint.sh +1 -677
- package/scripts/workflow-contract.ts +4 -205
- package/scripts/workstream-sync.sh +2 -261
- package/src/cli/agent-jobs/executor-health.ts +1 -1
- package/src/cli/commands/init-hook.ts +3 -3
- package/src/cli/commands/mcp.ts +9 -5
- package/src/cli/commands/plugin.ts +12 -3
- package/src/cli/commands/recovery.ts +25 -3
- package/src/cli/commands/runtime.ts +55 -3
- package/src/cli/commands/security.ts +12 -1
- package/src/cli/commands/setup-profile.ts +31 -21
- package/src/cli/controller/check-runner.ts +113 -5
- package/src/cli/controller/composite-result.ts +1 -1
- package/src/cli/controller/context/focus.ts +51 -0
- package/src/cli/controller/context/instruction-context.ts +114 -0
- package/src/cli/controller/context/known-paths.ts +76 -0
- package/src/cli/controller/context/query-planning.ts +110 -0
- package/src/cli/controller/context/rule-resolution.ts +532 -0
- package/src/cli/controller/context/source-materializer.ts +278 -0
- package/src/cli/controller/context/structural.ts +16 -0
- package/src/cli/controller/context/types.ts +286 -0
- package/src/cli/controller/context-pack.ts +828 -487
- package/src/cli/controller/launch-agents.ts +77 -2
- package/src/cli/controller/operational-plan.ts +2 -2
- package/src/cli/controller/work-mode.ts +6 -4
- package/src/cli/editing/edit-session.ts +60 -7
- package/src/cli/github/contracts.ts +16 -0
- package/src/cli/github/github.ts +135 -16
- package/src/cli/github/session.ts +2 -1
- package/src/cli/installer/managed-entries.ts +15 -1
- package/src/cli/local-bridge/console-automations.ts +54 -78
- package/src/cli/local-bridge/console-view-models.ts +1 -35
- package/src/cli/local-bridge/facade-api.ts +37 -264
- package/src/cli/local-bridge/frontend/src/App.tsx +3 -3
- package/src/cli/local-bridge/frontend/src/features/automations/AutomationsView.tsx +23 -13
- package/src/cli/local-bridge/frontend/src/features/capabilities/CapabilitiesView.tsx +37 -4
- package/src/cli/local-bridge/frontend/src/features/overview/OverviewView.tsx +6 -4
- package/src/cli/local-bridge/frontend/src/lib/api.ts +1 -6
- package/src/cli/local-bridge/frontend/src/lib/routes.ts +1 -1
- package/src/cli/local-bridge/frontend/src/shell/Sidebar.tsx +2 -2
- package/src/cli/local-bridge/frontend/src/styles.css +1 -1
- package/src/cli/local-bridge/frontend/src/types.ts +5 -8
- package/src/cli/local-bridge/job-store.ts +1 -490
- package/src/cli/local-bridge/server.ts +2 -515
- package/src/cli/local-bridge/ui-dist/app.css +1 -1
- package/src/cli/local-bridge/ui-dist/app.js +3703 -3757
- package/src/cli/mcp/auth.ts +5 -5
- package/src/cli/mcp/instructions.ts +3 -2
- package/src/cli/mcp/legacy-tool-service.ts +10 -222
- package/src/cli/mcp/multi-repository.ts +16 -5
- package/src/cli/mcp/oauth.ts +175 -26
- package/src/cli/mcp/post-finalize-work-attribution.ts +150 -0
- package/src/cli/mcp/repository-tools.ts +282 -146
- package/src/cli/mcp/server.ts +586 -116
- package/src/cli/mcp/tool-capability-cache.ts +1 -1
- package/src/cli/mcp/tool-contract.ts +14 -0
- package/src/cli/mcp/toolset-selection.ts +1 -1
- package/src/cli/mcp/toolset.ts +44 -7
- package/src/cli/mcp/transports/http.ts +124 -14
- package/src/cli/mcp/transports/session-registry.ts +43 -5
- package/src/cli/mcp/types.ts +3 -3
- package/src/cli/repositories/command-classifier.ts +103 -21
- package/src/cli/repositories/command-executor.ts +78 -526
- package/src/cli/repositories/command-process.ts +108 -0
- package/src/cli/repositories/command-scope.ts +99 -41
- package/src/cli/repositories/controller-home.ts +7 -8
- package/src/cli/repositories/git-command-executor.ts +2 -0
- package/src/cli/repositories/locks.ts +53 -16
- package/src/cli/repositories/repository-snapshot.ts +124 -0
- package/src/cli/repositories/selected-path-actions.ts +17 -9
- package/src/cli/repositories/structured-git.ts +118 -6
- package/src/cli/repository/inspector.ts +563 -65
- package/src/cli/repository/session-cache.ts +58 -17
- package/src/cli/v81-entry.ts +1 -4
- package/src/effects/fs-transaction.ts +85 -17
- package/src/runtime/context/codegraph-read-provider.ts +233 -2
- package/src/runtime/context/codegraph-sidecar.cjs +74 -40
- package/src/runtime/context/generic-lsp-provider.ts +355 -0
- package/src/runtime/context/lsp-client.ts +283 -0
- package/src/runtime/context/semantic-navigation-contract.ts +61 -0
- package/src/runtime/context/semantic-navigation.ts +223 -0
- package/src/runtime/context/swift-navigation.ts +565 -0
- package/src/runtime/context/typescript-navigation.ts +226 -0
- package/src/runtime/control-plane/dispatch-priority.ts +28 -0
- package/src/runtime/control-plane/execution/direct-edit-work-completion.ts +48 -16
- package/src/runtime/control-plane/execution/edit-validation-coordinator.ts +86 -24
- package/src/runtime/control-plane/execution/repository-work-attribution.ts +106 -0
- package/src/runtime/control-plane/execution/retained-work-resume.ts +203 -0
- package/src/runtime/control-plane/execution/session-store.ts +18 -0
- package/src/runtime/control-plane/execution/verification-evidence.ts +58 -3
- package/src/runtime/control-plane/execution/work-completion-authority.ts +58 -0
- package/src/runtime/control-plane/execution/work-evidence-policy.ts +200 -0
- package/src/runtime/control-plane/execution/work-execution-support.ts +386 -0
- package/src/runtime/control-plane/execution/work-finalization-service.ts +1913 -0
- package/src/runtime/control-plane/execution/work-handle-authority.ts +96 -0
- package/src/runtime/control-plane/execution/work-handle-store.ts +27 -2
- package/src/runtime/control-plane/execution/work-lifecycle-audit.ts +258 -0
- package/src/runtime/control-plane/execution/work-operation-service.ts +468 -0
- package/src/runtime/control-plane/execution/work-path-scope.ts +37 -0
- package/src/runtime/control-plane/execution/work-preparation-service.ts +479 -0
- package/src/runtime/control-plane/execution/work-process-evidence.ts +129 -0
- package/src/runtime/control-plane/execution/work-remote-delivery.ts +215 -0
- package/src/runtime/control-plane/execution/work-task-receipt.ts +22 -18
- package/src/runtime/control-plane/execution/work-terminal-cleanup.ts +156 -11
- package/src/runtime/control-plane/execution/work-validation-reconciler.ts +22 -14
- package/src/runtime/control-plane/execution/work-verification-context.ts +56 -0
- package/src/runtime/control-plane/execution/work-verification-service.ts +388 -0
- package/src/runtime/control-plane/execution/work-verification-snapshot.ts +90 -9
- package/src/runtime/control-plane/facade/capability-registry.ts +129 -25
- package/src/runtime/control-plane/facade/controller-round-relay.ts +1118 -0
- package/src/runtime/control-plane/facade/controller-session-store.ts +302 -12
- package/src/runtime/control-plane/facade/goal-workloop-access.ts +17 -1
- package/src/runtime/control-plane/facade/goal-workloop.ts +928 -190
- package/src/runtime/control-plane/facade/index.ts +3 -0
- package/src/runtime/control-plane/facade/plan-contract-store.ts +275 -42
- package/src/runtime/control-plane/facade/repository-work-admission.ts +147 -0
- package/src/runtime/control-plane/facade/requirement-authority.ts +83 -0
- package/src/runtime/control-plane/facade/semantic-admission.ts +205 -0
- package/src/runtime/control-plane/facade/suggested-actions.ts +5 -1
- package/src/runtime/control-plane/facade/types.ts +90 -10
- package/src/runtime/control-plane/facade/work-contract-store.ts +157 -261
- package/src/runtime/control-plane/facade/work-state-machine.ts +250 -0
- package/src/runtime/control-plane/generated-cache-retention.ts +271 -0
- package/src/runtime/control-plane/global-scheduler/config.ts +44 -0
- package/src/runtime/control-plane/global-scheduler/dispatch-capacity.ts +80 -0
- package/src/runtime/control-plane/global-scheduler/durable-admission.ts +46 -0
- package/src/runtime/control-plane/global-scheduler/maintenance.ts +152 -0
- package/src/runtime/control-plane/global-scheduler/projection-refresh.ts +84 -0
- package/src/runtime/control-plane/global-scheduler/reconciliation.ts +3 -3
- package/src/runtime/control-plane/global-scheduler/scheduler.ts +278 -543
- package/src/runtime/control-plane/global-scheduler/source-scan.ts +59 -0
- package/src/runtime/control-plane/global-scheduler/state.ts +88 -0
- package/src/runtime/control-plane/global-scheduler/worker-attachment.ts +57 -0
- package/src/runtime/control-plane/global-scheduler/worker-exit-decision.ts +38 -0
- package/src/runtime/control-plane/global-scheduler/worker-exit-reconciler.ts +175 -0
- package/src/runtime/control-plane/global-scheduler/worker-launch.ts +111 -0
- package/src/runtime/control-plane/global-scheduler/worker-lifecycle-store.ts +64 -0
- package/src/runtime/control-plane/global-scheduler/worker-lifecycle.ts +150 -0
- package/src/runtime/control-plane/global-scheduler/worker-process.ts +119 -0
- package/src/runtime/control-plane/global-scheduler/worker-stderr.ts +60 -0
- package/src/runtime/control-plane/launcher/chatgpt-work-binding-store.ts +63 -0
- package/src/runtime/control-plane/launcher/chatgpt-work-continuation.ts +365 -117
- package/src/runtime/control-plane/launcher/launch-reservation-store.ts +89 -0
- package/src/runtime/control-plane/launcher/thin-launcher.ts +244 -11
- package/src/runtime/control-plane/persistence/controller-home-migration.ts +487 -0
- package/src/runtime/control-plane/persistence/requirement-store.ts +0 -12
- package/src/runtime/control-plane/persistence/sqlite-store.ts +33 -2
- package/src/runtime/control-plane/release-retention.ts +62 -2
- package/src/runtime/control-plane/repo-actor/registry.ts +4 -2
- package/src/runtime/control-plane/routing/route-policy.ts +24 -21
- package/src/runtime/control-plane/routing/workspace-admission.ts +70 -0
- package/src/runtime/control-plane/runtime-status-client.ts +0 -2
- package/src/runtime/diagnostics/mcp-timing.ts +13 -0
- package/src/runtime/diagnostics/mcp-transport-benchmark.ts +129 -0
- package/src/runtime/diagnostics/performance.ts +24 -1
- package/src/runtime/evidence/event-ledger.ts +1 -1
- package/src/runtime/evidence/process-check-execution.ts +18 -0
- package/src/runtime/execution/jobs/child-reference.ts +6 -2
- package/src/runtime/execution/jobs/store.ts +58 -4
- package/src/runtime/execution/jobs/types.ts +1 -1
- package/src/runtime/execution/managed-workspace.ts +203 -1
- package/src/runtime/execution/process-runtime/check-facade.ts +51 -4
- package/src/runtime/execution/process-runtime/check-result.ts +88 -0
- package/src/runtime/execution/process-runtime/check-runner-sidecar.ts +7 -1
- package/src/runtime/execution/process-runtime/check-scheduling.ts +114 -0
- package/src/runtime/execution/process-runtime/command-facade.ts +367 -40
- package/src/runtime/execution/process-runtime/gc.ts +38 -6
- package/src/runtime/execution/process-runtime/lightweight-managed.ts +1201 -0
- package/src/runtime/execution/process-runtime/persisted-check.ts +51 -5
- package/src/runtime/execution/process-runtime/resource-claims.ts +10 -6
- package/src/runtime/execution/process-runtime/runtime.ts +53 -3
- package/src/runtime/execution/process-runtime/store.ts +1 -0
- package/src/runtime/execution/process-runtime/types.ts +4 -12
- package/src/runtime/execution/thin-harness/async-process.ts +12 -2
- package/src/runtime/execution/thin-harness/search-worker.ts +54 -5
- package/src/runtime/execution/work-activity.ts +21 -0
- package/src/runtime/execution/workers/executor.ts +18 -61
- package/src/runtime/gateway/mcp/execution-tools.ts +35 -1852
- package/src/runtime/gateway/mcp/legacy-ios-tool-adapter.ts +61 -0
- package/src/runtime/gateway/mcp/process-tools.ts +83 -37
- package/src/runtime/gateway/mcp/router.ts +186 -84
- package/src/runtime/gateway/mcp/runtime-tool-definitions.ts +565 -0
- package/src/runtime/gateway/mcp/runtime-tools.ts +2378 -1947
- package/src/runtime/plugins/app-store-connect-adapter.ts +216 -47
- package/src/runtime/plugins/browser-adapter.ts +2662 -829
- package/src/runtime/plugins/browser-automation-service.ts +10 -0
- package/src/runtime/plugins/browser-handoff-host.ts +2 -25
- package/src/runtime/plugins/browser-handoff.ts +110 -5
- package/src/runtime/plugins/browser-macos-bridge.ts +701 -63
- package/src/runtime/plugins/browser-manifest-surface.ts +603 -0
- package/src/runtime/plugins/browser-native-session-service.ts +222 -0
- package/src/runtime/plugins/browser-node-bridge.ts +6 -34
- package/src/runtime/plugins/browser-provider-registry.ts +109 -0
- package/src/runtime/plugins/browser-runtime-contract.ts +154 -0
- package/src/runtime/plugins/browser-runtime.ts +193 -0
- package/src/runtime/plugins/browser-session-authority.ts +332 -0
- package/src/runtime/plugins/browser-session-store.ts +121 -0
- package/src/runtime/plugins/browser-session-types.ts +98 -0
- package/src/runtime/plugins/capability-authorization-grants.ts +354 -0
- package/src/runtime/plugins/desktop-operator-registration.ts +10 -4
- package/src/runtime/plugins/external-adapter.ts +358 -3
- package/src/runtime/plugins/external-unix-socket.ts +7 -1
- package/src/runtime/plugins/first-party-registry.ts +2 -0
- package/src/runtime/plugins/github-adapter.ts +125 -0
- package/src/runtime/plugins/ios/agent-device-provider.ts +1 -1
- package/src/runtime/plugins/ios/agent-device-typed-provider.ts +52 -9
- package/src/runtime/plugins/ios/remote-xpc-hid.ts +1171 -0
- package/src/runtime/plugins/ios-adapter.ts +106 -16
- package/src/runtime/plugins/ios-agent-device.ts +234 -64
- package/src/runtime/plugins/ios-physical-device.ts +1143 -392
- package/src/runtime/plugins/lightweight-action.ts +153 -0
- package/src/runtime/plugins/local-system-adapter.ts +123 -6
- package/src/runtime/plugins/macos-capability-broker.ts +47 -9
- package/src/runtime/plugins/plugin-action-sidecar.ts +59 -0
- package/src/runtime/plugins/plugin-management-adapter.ts +49 -8
- package/src/runtime/plugins/resend-adapter.ts +1 -1
- package/src/runtime/plugins/store.ts +330 -33
- package/src/runtime/plugins/types.ts +38 -3
- package/src/runtime/plugins/xiaohongshu-publish.ts +515 -0
- package/src/runtime/projections/git-status-sampler.ts +16 -16
- package/src/runtime/projections/materialized-view.ts +28 -10
- package/src/runtime/recovery/maintenance-executor.ts +426 -25
- package/src/runtime/root/mcp-transport.ts +180 -10
- package/src/runtime/root/package-connector-service.ts +230 -10
- package/src/runtime/root/package-runtime-release.ts +143 -17
- package/src/runtime/root/package-runtime-service.ts +461 -22
- package/src/runtime/root/release-manifest.ts +144 -18
- package/src/runtime/root/release-materialize.ts +125 -32
- package/src/runtime/root/release-store.ts +48 -11
- package/src/runtime/root/runtime.ts +23 -5
- package/src/runtime/root/service.ts +100 -29
- package/src/runtime/root/types.ts +12 -0
- package/src/runtime/safe-tooling/ios-development.ts +321 -63
- package/src/runtime/shared/trusted-executable.ts +62 -0
- package/src/runtime/shared/trusted-node-executable.ts +48 -0
- package/src/runtime/standalone-recovery/core.ts +1079 -240
- package/src/runtime/standalone-recovery/entry.ts +100 -24
- package/src/runtime/standalone-recovery/installer.ts +20 -4
- package/src/runtime/standalone-recovery/service-labels.ts +2 -0
- package/src/runtime/standalone-recovery/watchdog-heartbeat.ts +160 -0
- package/src/runtime/workflow/schedules/browser-probe.ts +81 -3
- package/src/runtime/workflow/schedules/engine.ts +364 -202
- package/src/runtime/workflow/schedules/github-issue-watch.ts +158 -0
- package/src/runtime/workflow/schedules/settlement.ts +64 -17
- package/src/runtime/workflow/schedules/store.ts +209 -19
- package/src/runtime/workflow/schedules/types.ts +2 -4
- package/src/runtime/workflow/schedules/work-continuation.ts +26 -13
- package/src/testing/test-governance.ts +8 -40
- package/assets/reference-configs/heartbeat-triage.md +0 -57
- package/assets/templates/helpers/harness-trace-grade.sh +0 -150
- package/assets/templates/helpers/heartbeat-triage.sh +0 -361
- package/assets/templates/helpers/maintenance-triage.sh +0 -167
- package/docs/architecture/current/approved-target-architecture.zh-CN.md +0 -1501
- package/docs/architecture/current/architecture-invariants.md +0 -331
- package/docs/architecture/current/automation-and-schedule-engine.md +0 -459
- package/docs/architecture/current/codegraph-context-provider.md +0 -238
- package/docs/architecture/current/control-plane-authority-inventory.md +0 -94
- package/docs/architecture/current/controller-repository-decoupling.md +0 -148
- package/docs/architecture/current/dispatch-and-agent-strategy.md +0 -373
- package/docs/architecture/current/entity-model.md +0 -612
- package/docs/architecture/current/external-plugin-governance.md +0 -396
- package/docs/architecture/current/failure-recovery.md +0 -447
- package/docs/architecture/current/governance.md +0 -213
- package/docs/architecture/current/human-interaction-plane.md +0 -87
- package/docs/architecture/current/implementation-status.md +0 -128
- package/docs/architecture/current/job-and-run-lifecycle.md +0 -413
- package/docs/architecture/current/legacy-governance.md +0 -58
- package/docs/architecture/current/local-workspace-target-grants.md +0 -70
- package/docs/architecture/current/migration-roadmap.md +0 -105
- package/docs/architecture/current/multi-repository-execution.md +0 -371
- package/docs/architecture/current/operations-runbook.md +0 -45
- package/docs/architecture/current/peer-supercontroller-runtime.md +0 -32
- package/docs/architecture/current/personal-assistant-plugin-baseline.md +0 -206
- package/docs/architecture/current/repository-access-modes.md +0 -102
- package/docs/architecture/current/route-policy.md +0 -15
- package/docs/architecture/current/route-session-concurrency.md +0 -85
- package/docs/architecture/current/runtime-architecture-simplification.md +0 -214
- package/docs/architecture/current/runtime-directory-map.md +0 -49
- package/docs/architecture/current/runtime-health-and-resource-lifecycle.md +0 -85
- package/docs/architecture/current/scheduler-and-resource-claims.md +0 -422
- package/docs/architecture/current/session-aware-execution-and-authorization.md +0 -77
- package/docs/architecture/current/system-overview.md +0 -169
- package/docs/architecture/current/target-requirements-traceability.md +0 -202
- package/docs/architecture/current/thin-harness-governance-policy.md +0 -32
- package/docs/architecture/current/thin-harness-v1.md +0 -321
- package/docs/architecture/current/verification-and-release-gates.md +0 -391
- package/docs/forge-autonomous-goal-loop.md +0 -9
- package/docs/forge-provider-configuration.md +0 -7
- package/docs/operations/chatgpt-personal-assistant-runtime.md +0 -93
- package/docs/operations/gmail-assistant-routines.md +0 -186
- package/docs/operations/personal-assistant-operator-guide.md +0 -61
- package/docs/operations/provider-configuration.md +0 -31
- package/scripts/harness-trace-grade.sh +0 -150
- package/scripts/heartbeat-triage.sh +0 -361
- package/scripts/maintenance-triage.sh +0 -167
- package/src/cli/local-bridge/frontend/src/features/settings/SettingsView.tsx +0 -2
- package/src/cli/repositories/goal-registry.ts +0 -239
- package/src/runtime/assistant/action-proposals.ts +0 -279
- package/src/runtime/assistant/intent.ts +0 -395
- package/src/runtime/assistant/model-provider.ts +0 -460
- package/src/runtime/assistant/openapi.ts +0 -223
- package/src/runtime/assistant/policy.ts +0 -89
- package/src/runtime/assistant/readiness.ts +0 -258
- package/src/runtime/assistant/routine-runtime.ts +0 -477
- package/src/runtime/assistant/schedule-binding.ts +0 -171
- package/src/runtime/assistant/standing-grants.ts +0 -375
- package/src/runtime/assistant/store.ts +0 -243
- package/src/runtime/assistant/types.ts +0 -135
- package/src/runtime/control-plane/goal-loop/config-facade.ts +0 -700
- package/src/runtime/control-plane/goal-loop/config-store.ts +0 -570
- package/src/runtime/control-plane/goal-loop/config-types.ts +0 -213
- package/src/runtime/control-plane/goal-loop/executor-router.ts +0 -109
- package/src/runtime/control-plane/goal-loop/failure-taxonomy.ts +0 -149
- package/src/runtime/control-plane/goal-loop/goal-contract-store.ts +0 -304
- package/src/runtime/control-plane/goal-loop/goal-loop-engine.ts +0 -1001
- package/src/runtime/control-plane/goal-loop/handoff-packet.ts +0 -160
- package/src/runtime/control-plane/goal-loop/index.ts +0 -13
- package/src/runtime/control-plane/goal-loop/local-tools.ts +0 -290
- package/src/runtime/control-plane/goal-loop/policy-gates.ts +0 -203
- package/src/runtime/control-plane/goal-loop/provider-dispatch.ts +0 -93
- package/src/runtime/control-plane/goal-loop/provider-registry.ts +0 -545
- package/src/runtime/control-plane/goal-loop/types.ts +0 -378
- package/src/runtime/control-plane/startup-recovery.ts +0 -198
- package/src/runtime/gateway/mcp/resource-policy.ts +0 -186
- package/src/runtime/personal-assistant/gmail-triage-manager.ts +0 -231
- package/src/runtime/personal-assistant/reporting-runtime.ts +0 -344
- package/src/runtime/personal-assistant/triage-runtime.ts +0 -748
- package/src/runtime/workflow/findings/store.ts +0 -204
- package/src/runtime/workflow/findings/types.ts +0 -58
- package/src/runtime/workflow/portfolio/engine.ts +0 -20
- package/src/runtime/workflow/portfolio/store.ts +0 -185
- package/src/runtime/workflow/portfolio/types.ts +0 -29
|
@@ -1,26 +1,50 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'crypto';
|
|
2
2
|
import { createRequire } from 'module';
|
|
3
|
-
import { existsSync, mkdirSync,
|
|
4
|
-
import { basename, dirname, join, relative, resolve } from 'path';
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'fs';
|
|
4
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'path';
|
|
5
5
|
import type {
|
|
6
|
-
AssistantPluginActionDescriptor,
|
|
7
6
|
AssistantPluginActionExecutionInput,
|
|
8
|
-
|
|
7
|
+
AssistantPluginAuthorizationContext,
|
|
9
8
|
AssistantPluginHealth,
|
|
10
9
|
AssistantPluginManifest,
|
|
11
|
-
AssistantPluginPermissionScope,
|
|
12
10
|
} from './types';
|
|
13
11
|
import { AssistantPluginError, toAssistantPluginError } from './errors';
|
|
12
|
+
import {
|
|
13
|
+
browserActions,
|
|
14
|
+
browserCapabilities,
|
|
15
|
+
browserPermissions,
|
|
16
|
+
} from './browser-manifest-surface';
|
|
14
17
|
import { executeBrowserActionThroughNode, shouldUseBrowserNodeBridge } from './browser-node-bridge';
|
|
18
|
+
import { BrowserProviderUnavailableBeforeActionError } from './browser-provider-registry';
|
|
19
|
+
import { MAX_BROWSER_CDP_ENDPOINT_CANDIDATES } from './browser-runtime-contract';
|
|
20
|
+
import {
|
|
21
|
+
ALL_BROWSER_PROVIDER_CAPABILITIES,
|
|
22
|
+
browserActionCanReplayAfterDispatch as canReplayBrowserActionAfterDispatch,
|
|
23
|
+
executeBrowserRuntimeAction,
|
|
24
|
+
invalidateBrowserRuntime,
|
|
25
|
+
} from './browser-runtime';
|
|
26
|
+
import { getExternalPluginAdapter } from './external-adapter';
|
|
27
|
+
import {
|
|
28
|
+
currentBrowserSessionAuthorityContext,
|
|
29
|
+
DEFAULT_BROWSER_SESSION_LIST_LIMIT,
|
|
30
|
+
MAX_BROWSER_SESSION_LIST_LIMIT,
|
|
31
|
+
withBrowserSessionAuthorityContext,
|
|
32
|
+
} from './browser-session-authority';
|
|
15
33
|
import {
|
|
16
34
|
closeMacOsBrowserOwnedTab,
|
|
17
35
|
createMacOsBrowserOwnedPage,
|
|
18
36
|
reattachMacOsBrowserOwnedPage,
|
|
19
37
|
discoverMacOsBrowserAttachment,
|
|
38
|
+
invalidateMacOsBrowserPageHandle,
|
|
39
|
+
invalidateMacOsBrowserPageHandles,
|
|
40
|
+
macOsBrowserPageHandleStale,
|
|
20
41
|
getMacOsBrowserAttachObservation,
|
|
42
|
+
listMacOsBrowserTabs,
|
|
43
|
+
readMacOsBrowserOwnedTabMetadata,
|
|
21
44
|
macOsActiveBrowserAttachSupported,
|
|
22
45
|
macOsBrowserJavaScriptAutomationDisabled,
|
|
23
46
|
type MacOsBrowserAttachAttempt,
|
|
47
|
+
type MacOsBrowserCreateTabEvidence,
|
|
24
48
|
type MacOsBrowserProduct,
|
|
25
49
|
type MacOsBrowserTabRef,
|
|
26
50
|
} from './browser-macos-bridge';
|
|
@@ -29,31 +53,61 @@ import {
|
|
|
29
53
|
assertBrowserSessionAvailable,
|
|
30
54
|
cancelBrowserHandoff,
|
|
31
55
|
getBrowserHandoff,
|
|
56
|
+
isRuntimeManagedBrowserHandoff,
|
|
32
57
|
listBrowserHandoffs,
|
|
33
|
-
|
|
58
|
+
resolveRuntimeManagedBrowserHandoff,
|
|
59
|
+
resumeBrowserHandoffAndWait,
|
|
34
60
|
startBrowserHandoff,
|
|
61
|
+
startRuntimeManagedBrowserHandoff,
|
|
35
62
|
} from './browser-handoff';
|
|
63
|
+
import {
|
|
64
|
+
browserStateDir,
|
|
65
|
+
findBrowserSession,
|
|
66
|
+
listSavedBrowserSessions,
|
|
67
|
+
removeBrowserSession,
|
|
68
|
+
requireBrowserSession,
|
|
69
|
+
saveBrowserSession,
|
|
70
|
+
} from './browser-session-store';
|
|
71
|
+
import {
|
|
72
|
+
closeTrackedNativeOwnedSession,
|
|
73
|
+
inspectNativeOwnedSessions,
|
|
74
|
+
nativeTabInventoryUnsupported,
|
|
75
|
+
nativeOwnedAliasSessionIds,
|
|
76
|
+
} from './browser-native-session-service';
|
|
77
|
+
export { browserActionCanReplayAfterDispatch } from './browser-runtime';
|
|
78
|
+
import type {
|
|
79
|
+
BrowserCdpAttachFallback,
|
|
80
|
+
BrowserConnectionFallback,
|
|
81
|
+
BrowserMode,
|
|
82
|
+
BrowserProfileMode,
|
|
83
|
+
BrowserSessionResumeDiagnostic,
|
|
84
|
+
BrowserSessionState,
|
|
85
|
+
BrowserSessionConnectionState,
|
|
86
|
+
BrowserSessionInventoryItem,
|
|
87
|
+
BrowserTabMatchReason,
|
|
88
|
+
BrowserTabResumeState,
|
|
89
|
+
CdpAttachAttempt,
|
|
90
|
+
} from './browser-session-types';
|
|
36
91
|
|
|
37
92
|
const BROWSER_PLUGIN_ID = 'browser';
|
|
38
93
|
const CONFIG_ROOT = '.forge/plugins';
|
|
39
|
-
const STATE_ROOT = '.forge/browser';
|
|
40
94
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
41
95
|
const DEFAULT_MAX_TEXT_CHARS = 20_000;
|
|
42
96
|
const DEFAULT_POST_ACTION_WAIT_MS = 750;
|
|
43
97
|
const DEFAULT_CDP_DISCOVERY_TIMEOUT_MS = 1_500;
|
|
44
98
|
const MAX_CDP_DISCOVERY_TIMEOUT_MS = 5_000;
|
|
45
|
-
const MAX_CDP_ENDPOINT_CANDIDATES = 5;
|
|
46
99
|
|
|
47
100
|
type WaitUntil = 'load' | 'domcontentloaded' | 'networkidle';
|
|
48
101
|
type WaitForSelectorState = 'attached' | 'detached' | 'visible' | 'hidden';
|
|
49
|
-
type BrowserMode = 'attach_preferred' | 'managed_persistent' | 'isolated';
|
|
50
|
-
type BrowserProfileMode = 'repo_local' | 'custom';
|
|
51
102
|
type BrowserChannel = 'chromium' | 'chrome' | 'chrome-beta' | 'chrome-dev' | 'chrome-canary';
|
|
52
|
-
type BrowserCdpAttachFallback = 'managed_persistent' | 'fail_closed';
|
|
53
103
|
type BrowserNativeAttachMode = 'auto' | 'disabled';
|
|
54
104
|
|
|
105
|
+
const CURRENT_BROWSER_CONFIG_SCHEMA_VERSION = 2 as const;
|
|
106
|
+
const DEFAULT_USER_BROWSER_CHANNEL: BrowserChannel = 'chrome';
|
|
107
|
+
const DEFAULT_USER_NATIVE_BROWSER_CANDIDATES: MacOsBrowserProduct[] = ['chrome'];
|
|
108
|
+
|
|
55
109
|
interface BrowserPluginConfig {
|
|
56
|
-
schemaVersion:
|
|
110
|
+
schemaVersion: 2;
|
|
57
111
|
enabled: boolean;
|
|
58
112
|
provider: 'playwright';
|
|
59
113
|
browserMode?: BrowserMode;
|
|
@@ -71,41 +125,23 @@ interface BrowserPluginConfig {
|
|
|
71
125
|
defaultTimeoutMs?: number;
|
|
72
126
|
}
|
|
73
127
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
url: string;
|
|
78
|
-
title?: string;
|
|
79
|
-
matchedBy: BrowserTabMatchReason;
|
|
80
|
-
inventoryCount: number;
|
|
81
|
-
capturedAt: string;
|
|
82
|
-
ownership?: 'plugin_owned' | 'user_owned';
|
|
83
|
-
ownerToken?: string;
|
|
84
|
-
windowId?: string;
|
|
85
|
-
tabId?: string;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
interface BrowserSessionConnectionState {
|
|
89
|
-
mode: BrowserMode;
|
|
90
|
-
activeMode: BrowserMode;
|
|
91
|
-
provider: 'playwright-cdp' | 'playwright-persistent-context' | 'macos-apple-events';
|
|
92
|
-
endpoint?: string;
|
|
93
|
-
browserVersion?: string;
|
|
94
|
-
browserProduct?: MacOsBrowserProduct;
|
|
95
|
-
fallback?: BrowserConnectionFallback;
|
|
96
|
-
tab?: BrowserTabResumeState;
|
|
97
|
-
sessionResume?: BrowserSessionResumeDiagnostic;
|
|
98
|
-
}
|
|
128
|
+
type PersistedBrowserPluginConfig = Partial<Omit<BrowserPluginConfig, 'schemaVersion'>> & {
|
|
129
|
+
schemaVersion?: 1 | 2;
|
|
130
|
+
};
|
|
99
131
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
132
|
+
type BrowserSessionInventory = {
|
|
133
|
+
sessions: BrowserSessionInventoryItem[];
|
|
134
|
+
scannedSessionCount: number;
|
|
135
|
+
savedSessionCount: number;
|
|
136
|
+
liveManagedSessionCount: number;
|
|
137
|
+
liveNativeSessionCount: number;
|
|
138
|
+
unverifiedSessionCount: number;
|
|
139
|
+
deadManagedSessionCount: number;
|
|
140
|
+
deadNativeSessionCount: number;
|
|
141
|
+
closedInvalidNativeSessionCount: number;
|
|
142
|
+
failedNativeCleanupCount: number;
|
|
143
|
+
prunedSessionCount: number;
|
|
144
|
+
};
|
|
109
145
|
|
|
110
146
|
interface BrowserActionTarget {
|
|
111
147
|
sessionId: string;
|
|
@@ -138,6 +174,20 @@ type BrowserLike = {
|
|
|
138
174
|
disconnect?(): Promise<void> | void;
|
|
139
175
|
};
|
|
140
176
|
|
|
177
|
+
type FrameLike = {
|
|
178
|
+
url(): string;
|
|
179
|
+
name(): string;
|
|
180
|
+
evaluate<T>(expression: string | ((...args: unknown[]) => unknown), arg?: unknown): Promise<T>;
|
|
181
|
+
frameElement?(): Promise<{ boundingBox(): Promise<{ x: number; y: number; width: number; height: number } | null> }>;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
type BrowserDownloadLike = {
|
|
185
|
+
suggestedFilename(): string;
|
|
186
|
+
saveAs(path: string): Promise<void>;
|
|
187
|
+
failure?(): Promise<string | null>;
|
|
188
|
+
delete?(): Promise<void>;
|
|
189
|
+
};
|
|
190
|
+
|
|
141
191
|
type PageLike = {
|
|
142
192
|
goto(url: string, options?: Record<string, unknown>): Promise<unknown>;
|
|
143
193
|
reload?(options?: Record<string, unknown>): Promise<unknown>;
|
|
@@ -160,10 +210,24 @@ type PageLike = {
|
|
|
160
210
|
uncheck?(selector: string, options?: Record<string, unknown>): Promise<void>;
|
|
161
211
|
waitForSelector(selector: string, options?: Record<string, unknown>): Promise<unknown>;
|
|
162
212
|
waitForLoadState?(state?: string, options?: Record<string, unknown>): Promise<void>;
|
|
213
|
+
waitForEvent?(event: 'download', options?: Record<string, unknown>): Promise<BrowserDownloadLike>;
|
|
214
|
+
frames?(): FrameLike[];
|
|
215
|
+
mainFrame?(): FrameLike;
|
|
163
216
|
locator?(selector: string): { screenshot(options?: Record<string, unknown>): Promise<Buffer> };
|
|
164
217
|
on?(event: string, handler: (...args: unknown[]) => void): void;
|
|
165
218
|
bringToFront?(): Promise<void>;
|
|
166
|
-
|
|
219
|
+
foregroundState?(): Promise<{ frontmost: boolean; active: boolean }>;
|
|
220
|
+
trustedInput?(input: Record<string, unknown>): Promise<void>;
|
|
221
|
+
mouse?: {
|
|
222
|
+
click(x: number, y: number, options?: { button?: 'left' | 'middle' | 'right'; clickCount?: number }): Promise<void>;
|
|
223
|
+
move(x: number, y: number, options?: { steps?: number }): Promise<void>;
|
|
224
|
+
wheel(deltaX: number, deltaY: number): Promise<void>;
|
|
225
|
+
down(options?: { button?: 'left' | 'middle' | 'right' }): Promise<void>;
|
|
226
|
+
up(options?: { button?: 'left' | 'middle' | 'right' }): Promise<void>;
|
|
227
|
+
};
|
|
228
|
+
keyboard?: { press(key: string): Promise<void>; insertText?(text: string): Promise<void> };
|
|
229
|
+
setInputFiles?: (selector: string, files: string | string[]) => Promise<void>;
|
|
230
|
+
close?(): Promise<void>;
|
|
167
231
|
};
|
|
168
232
|
|
|
169
233
|
type PlaywrightRuntime = {
|
|
@@ -175,29 +239,73 @@ type PlaywrightRuntime = {
|
|
|
175
239
|
|
|
176
240
|
interface BrowserPluginRuntimeHooks {
|
|
177
241
|
now(): string;
|
|
178
|
-
moduleAvailable(name: string): boolean;
|
|
179
|
-
loadPlaywright(): PlaywrightRuntime;
|
|
242
|
+
moduleAvailable(name: string, repoRoot?: string): boolean;
|
|
243
|
+
loadPlaywright(repoRoot?: string): PlaywrightRuntime;
|
|
180
244
|
fetchJson(url: string, timeoutMs: number): Promise<unknown>;
|
|
245
|
+
activateNativeBrowserApplication(input: AssistantPluginActionExecutionInput, product: MacOsBrowserProduct): Promise<void>;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const NATIVE_BROWSER_BUNDLE_IDS: Record<MacOsBrowserProduct, string> = {
|
|
249
|
+
chrome: 'com.google.Chrome',
|
|
250
|
+
vivaldi: 'com.vivaldi.Vivaldi',
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
async function activateNativeBrowserApplicationViaDesktopOperator(
|
|
254
|
+
input: AssistantPluginActionExecutionInput,
|
|
255
|
+
product: MacOsBrowserProduct,
|
|
256
|
+
): Promise<void> {
|
|
257
|
+
const desktopOperator = getExternalPluginAdapter(input.controllerHome, 'desktop_operator');
|
|
258
|
+
if (!desktopOperator) {
|
|
259
|
+
throw new AssistantPluginError(
|
|
260
|
+
'PLUGIN_BROWSER_NATIVE_FOREGROUND_ACTIVATOR_UNAVAILABLE',
|
|
261
|
+
'Native browser foreground activation requires the registered Forge Desktop Operator.',
|
|
262
|
+
{ retryable: true, details: { browserProduct: product } },
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
await desktopOperator.executeAction({
|
|
266
|
+
controllerHome: input.controllerHome,
|
|
267
|
+
repoId: input.repoId,
|
|
268
|
+
repoRoot: input.repoRoot,
|
|
269
|
+
pluginId: 'desktop_operator',
|
|
270
|
+
actionId: 'desktop_session_open',
|
|
271
|
+
requestId: `${input.requestId}:native-browser-foreground:${product}`,
|
|
272
|
+
args: { bundle_id: NATIVE_BROWSER_BUNDLE_IDS[product], launch: false, activate: true },
|
|
273
|
+
origin: input.origin,
|
|
274
|
+
jobId: input.jobId,
|
|
275
|
+
timeoutMs: input.timeoutMs,
|
|
276
|
+
signal: input.signal,
|
|
277
|
+
deadlineAtMs: input.deadlineAtMs,
|
|
278
|
+
});
|
|
181
279
|
}
|
|
182
280
|
|
|
183
281
|
const defaultRuntimeHooks: BrowserPluginRuntimeHooks = {
|
|
184
282
|
now: () => new Date().toISOString(),
|
|
185
|
-
moduleAvailable: (name: string) => {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
283
|
+
moduleAvailable: (name: string, repoRoot?: string) => {
|
|
284
|
+
const anchors = [repoRoot ? join(repoRoot, 'package.json') : undefined, import.meta.url]
|
|
285
|
+
.filter((value): value is string => Boolean(value));
|
|
286
|
+
for (const anchor of anchors) {
|
|
287
|
+
try {
|
|
288
|
+
createRequire(anchor).resolve(name);
|
|
289
|
+
return true;
|
|
290
|
+
} catch {
|
|
291
|
+
// Try the next trusted dependency anchor.
|
|
292
|
+
}
|
|
191
293
|
}
|
|
294
|
+
return false;
|
|
192
295
|
},
|
|
193
|
-
loadPlaywright: () => {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
}
|
|
296
|
+
loadPlaywright: (repoRoot?: string) => {
|
|
297
|
+
const anchors = [repoRoot ? join(repoRoot, 'package.json') : undefined, import.meta.url]
|
|
298
|
+
.filter((value): value is string => Boolean(value));
|
|
299
|
+
for (const anchor of anchors) {
|
|
300
|
+
try {
|
|
301
|
+
return createRequire(anchor)('playwright') as PlaywrightRuntime;
|
|
302
|
+
} catch {
|
|
303
|
+
// Try the next trusted dependency anchor.
|
|
304
|
+
}
|
|
200
305
|
}
|
|
306
|
+
throw new AssistantPluginError('PLUGIN_DEPENDENCY_MISSING', 'Browser plugin requires playwright. Run bun install before using browser actions.', {
|
|
307
|
+
retryable: false,
|
|
308
|
+
});
|
|
201
309
|
},
|
|
202
310
|
fetchJson: async (url: string, timeoutMs: number) => {
|
|
203
311
|
const controller = new AbortController();
|
|
@@ -210,11 +318,21 @@ const defaultRuntimeHooks: BrowserPluginRuntimeHooks = {
|
|
|
210
318
|
clearTimeout(timer);
|
|
211
319
|
}
|
|
212
320
|
},
|
|
321
|
+
activateNativeBrowserApplication: activateNativeBrowserApplicationViaDesktopOperator,
|
|
213
322
|
};
|
|
214
323
|
|
|
215
324
|
let runtimeHooks: BrowserPluginRuntimeHooks = { ...defaultRuntimeHooks };
|
|
216
325
|
let runtimeHooksCustomized = false;
|
|
217
326
|
|
|
327
|
+
interface ManagedBrowserContextState {
|
|
328
|
+
repoRoot: string;
|
|
329
|
+
profilePath: string;
|
|
330
|
+
context: BrowserContextLike;
|
|
331
|
+
pagesBySession: Map<string, PageLike>;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const managedBrowserContexts = new Map<string, Promise<ManagedBrowserContextState>>();
|
|
335
|
+
|
|
218
336
|
export function setBrowserPluginRuntimeHooksForTest(hooks: Partial<BrowserPluginRuntimeHooks>): void {
|
|
219
337
|
runtimeHooks = { ...defaultRuntimeHooks, ...hooks };
|
|
220
338
|
runtimeHooksCustomized = true;
|
|
@@ -223,6 +341,7 @@ export function setBrowserPluginRuntimeHooksForTest(hooks: Partial<BrowserPlugin
|
|
|
223
341
|
export function resetBrowserPluginRuntimeHooksForTest(): void {
|
|
224
342
|
runtimeHooks = { ...defaultRuntimeHooks };
|
|
225
343
|
runtimeHooksCustomized = false;
|
|
344
|
+
managedBrowserContexts.clear();
|
|
226
345
|
}
|
|
227
346
|
|
|
228
347
|
function now(): string {
|
|
@@ -237,6 +356,13 @@ function positiveNumber(value: unknown, fallback: number): number {
|
|
|
237
356
|
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.trunc(value) : fallback;
|
|
238
357
|
}
|
|
239
358
|
|
|
359
|
+
function requiredFiniteNumber(value: unknown, name: string, min = -100_000, max = 100_000): number {
|
|
360
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < min || value > max) {
|
|
361
|
+
throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', `${name} must be a finite number between ${min} and ${max}.`, { retryable: false });
|
|
362
|
+
}
|
|
363
|
+
return value;
|
|
364
|
+
}
|
|
365
|
+
|
|
240
366
|
function browserProfileMode(value: unknown): BrowserProfileMode | undefined {
|
|
241
367
|
return value === 'repo_local' || value === 'custom' ? value : undefined;
|
|
242
368
|
}
|
|
@@ -284,16 +410,12 @@ function configPath(repoRoot: string): string {
|
|
|
284
410
|
return join(repoRoot, CONFIG_ROOT, 'browser.json');
|
|
285
411
|
}
|
|
286
412
|
|
|
287
|
-
function stateDir(repoRoot: string, name: 'sessions' | 'screenshots' | 'profiles' | 'downloads' | 'diagnostics'): string {
|
|
288
|
-
return join(repoRoot, STATE_ROOT, name);
|
|
289
|
-
}
|
|
290
|
-
|
|
291
413
|
function defaultProfileDir(repoRoot: string): string {
|
|
292
|
-
return join(
|
|
414
|
+
return join(browserStateDir(repoRoot, 'profiles'), 'default');
|
|
293
415
|
}
|
|
294
416
|
|
|
295
417
|
function isolatedProfileDir(repoRoot: string, sessionId: string): string {
|
|
296
|
-
return join(
|
|
418
|
+
return join(browserStateDir(repoRoot, 'profiles'), 'isolated', sessionId.replace(/[^a-zA-Z0-9_-]/g, '_'));
|
|
297
419
|
}
|
|
298
420
|
|
|
299
421
|
function resolveConfiguredPath(repoRoot: string, value: string): string {
|
|
@@ -325,30 +447,47 @@ function resolveProfileSelection(repoRoot: string, profileDir: string, profileDi
|
|
|
325
447
|
};
|
|
326
448
|
}
|
|
327
449
|
|
|
328
|
-
function normalizeConfig(raw:
|
|
450
|
+
function normalizeConfig(raw: PersistedBrowserPluginConfig): BrowserPluginConfig {
|
|
329
451
|
const normalizedProfileDir = stringValue(raw.profileDir);
|
|
330
452
|
return {
|
|
331
|
-
schemaVersion:
|
|
453
|
+
schemaVersion: CURRENT_BROWSER_CONFIG_SCHEMA_VERSION,
|
|
332
454
|
enabled: raw.enabled === true,
|
|
333
455
|
provider: 'playwright',
|
|
334
|
-
browserMode: browserMode(raw.browserMode) ?? '
|
|
456
|
+
browserMode: browserMode(raw.browserMode) ?? 'attach_preferred',
|
|
335
457
|
profileMode: browserProfileMode(raw.profileMode) ?? (normalizedProfileDir ? 'custom' : 'repo_local'),
|
|
336
458
|
profileDir: normalizedProfileDir,
|
|
337
459
|
profileDirectory: stringValue(raw.profileDirectory),
|
|
338
|
-
browserChannel: browserChannel(raw.browserChannel) ??
|
|
460
|
+
browserChannel: browserChannel(raw.browserChannel) ?? DEFAULT_USER_BROWSER_CHANNEL,
|
|
339
461
|
executablePath: stringValue(raw.executablePath),
|
|
340
462
|
cdpEndpoint: stringValue(raw.cdpEndpoint),
|
|
341
|
-
cdpEndpointCandidates: stringList(raw.cdpEndpointCandidates)?.slice(0,
|
|
463
|
+
cdpEndpointCandidates: stringList(raw.cdpEndpointCandidates)?.slice(0, MAX_BROWSER_CDP_ENDPOINT_CANDIDATES),
|
|
342
464
|
cdpDiscoveryTimeoutMs: typeof raw.cdpDiscoveryTimeoutMs === 'number'
|
|
343
465
|
? Math.min(positiveNumber(raw.cdpDiscoveryTimeoutMs, DEFAULT_CDP_DISCOVERY_TIMEOUT_MS), MAX_CDP_DISCOVERY_TIMEOUT_MS)
|
|
344
466
|
: undefined,
|
|
345
467
|
cdpAttachFallback: browserCdpAttachFallback(raw.cdpAttachFallback) ?? 'fail_closed',
|
|
346
468
|
nativeAttachMode: browserNativeAttachMode(raw.nativeAttachMode) ?? 'auto',
|
|
347
|
-
nativeBrowserCandidates: browserProductList(raw.nativeBrowserCandidates) ?? [
|
|
469
|
+
nativeBrowserCandidates: browserProductList(raw.nativeBrowserCandidates) ?? [...DEFAULT_USER_NATIVE_BROWSER_CANDIDATES],
|
|
348
470
|
defaultTimeoutMs: typeof raw.defaultTimeoutMs === 'number' ? positiveNumber(raw.defaultTimeoutMs, DEFAULT_TIMEOUT_MS) : undefined,
|
|
349
471
|
};
|
|
350
472
|
}
|
|
351
473
|
|
|
474
|
+
function migrateLegacyRepositoryConfig(raw: PersistedBrowserPluginConfig): PersistedBrowserPluginConfig {
|
|
475
|
+
if (raw.schemaVersion !== 1) return raw;
|
|
476
|
+
return {
|
|
477
|
+
schemaVersion: CURRENT_BROWSER_CONFIG_SCHEMA_VERSION,
|
|
478
|
+
enabled: raw.enabled,
|
|
479
|
+
provider: 'playwright',
|
|
480
|
+
browserMode: 'attach_preferred',
|
|
481
|
+
profileMode: 'repo_local',
|
|
482
|
+
browserChannel: DEFAULT_USER_BROWSER_CHANNEL,
|
|
483
|
+
cdpAttachFallback: 'fail_closed',
|
|
484
|
+
nativeAttachMode: 'auto',
|
|
485
|
+
nativeBrowserCandidates: [...DEFAULT_USER_NATIVE_BROWSER_CANDIDATES],
|
|
486
|
+
defaultTimeoutMs: raw.defaultTimeoutMs,
|
|
487
|
+
cdpDiscoveryTimeoutMs: raw.cdpDiscoveryTimeoutMs,
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
352
491
|
function readJson<T>(path: string): T | undefined {
|
|
353
492
|
try {
|
|
354
493
|
return JSON.parse(readFileSync(path, 'utf-8')) as T;
|
|
@@ -363,7 +502,70 @@ function writeJson(path: string, value: unknown): void {
|
|
|
363
502
|
}
|
|
364
503
|
|
|
365
504
|
function loadConfig(repoRoot: string): BrowserPluginConfig {
|
|
366
|
-
|
|
505
|
+
const persisted = readJson<PersistedBrowserPluginConfig>(configPath(repoRoot));
|
|
506
|
+
if (!persisted) return normalizeConfig({});
|
|
507
|
+
return normalizeConfig(migrateLegacyRepositoryConfig(persisted));
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async function resolveBrowserPluginAuthorizationContextInternal(
|
|
511
|
+
input: AssistantPluginActionExecutionInput,
|
|
512
|
+
): Promise<AssistantPluginAuthorizationContext | undefined> {
|
|
513
|
+
const action = browserActions().find((entry) => entry.actionId === input.actionId);
|
|
514
|
+
if (!action || action.confirmation !== 'authorization' || !action.scopes.includes('browser.interact')) return undefined;
|
|
515
|
+
|
|
516
|
+
const persistedConfig = loadConfig(input.repoRoot);
|
|
517
|
+
const sessionId = stringValue(input.args.session_id);
|
|
518
|
+
const authorizationBrowserMode = parseBrowserModeInput(input.args.browser_mode) ?? persistedConfig.browserMode;
|
|
519
|
+
if (!sessionId || authorizationBrowserMode === 'isolated') return undefined;
|
|
520
|
+
const session = findBrowserSession(input.repoRoot, sessionId);
|
|
521
|
+
const config = effectiveBrowserActionConfig(persistedConfig, input.args, session);
|
|
522
|
+
const connection = session?.browser;
|
|
523
|
+
if (!session || !connection || connection.activeMode === 'isolated') return undefined;
|
|
524
|
+
|
|
525
|
+
let origin: string;
|
|
526
|
+
try {
|
|
527
|
+
const destinationUrl = input.actionId === 'navigate' ? stringValue(input.args.url) : undefined;
|
|
528
|
+
const parsed = new URL(destinationUrl ?? session.url);
|
|
529
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return undefined;
|
|
530
|
+
origin = parsed.origin.toLowerCase();
|
|
531
|
+
} catch {
|
|
532
|
+
return undefined;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const profile = selectedProfile(config, input.repoRoot, connection.activeMode, sessionId);
|
|
536
|
+
const providerId = connection.browserProduct ?? connection.provider;
|
|
537
|
+
const identity = {
|
|
538
|
+
provider: connection.provider,
|
|
539
|
+
browserProduct: connection.browserProduct,
|
|
540
|
+
activeMode: connection.activeMode,
|
|
541
|
+
endpoint: connection.endpoint,
|
|
542
|
+
origin,
|
|
543
|
+
browserChannel: config.browserChannel,
|
|
544
|
+
executablePath: config.executablePath ? resolveConfiguredPath(input.repoRoot, config.executablePath) : undefined,
|
|
545
|
+
profileMode: config.profileMode,
|
|
546
|
+
selectedProfilePath: profile.selectedProfilePath,
|
|
547
|
+
profileDirectory: profile.profileDirectory,
|
|
548
|
+
cdpAttachFallback: config.cdpAttachFallback,
|
|
549
|
+
nativeAttachMode: config.nativeAttachMode,
|
|
550
|
+
nativeBrowserCandidates: config.nativeBrowserCandidates,
|
|
551
|
+
};
|
|
552
|
+
return {
|
|
553
|
+
target: {
|
|
554
|
+
kind: 'browser-origin',
|
|
555
|
+
id: `${providerId}@${origin}`,
|
|
556
|
+
identityFingerprint: createHash('sha256').update(JSON.stringify(identity)).digest('hex'),
|
|
557
|
+
},
|
|
558
|
+
expiresInMinutes: 30 * 24 * 60,
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
export async function resolveBrowserPluginAuthorizationContext(
|
|
563
|
+
input: AssistantPluginActionExecutionInput,
|
|
564
|
+
): Promise<AssistantPluginAuthorizationContext | undefined> {
|
|
565
|
+
return await withBrowserSessionAuthorityContext(
|
|
566
|
+
{ controllerHome: input.controllerHome, repoId: input.repoId },
|
|
567
|
+
() => resolveBrowserPluginAuthorizationContextInternal(input),
|
|
568
|
+
);
|
|
367
569
|
}
|
|
368
570
|
|
|
369
571
|
export function readBrowserPluginConfiguration(repoRoot: string): { enabled: boolean } {
|
|
@@ -377,39 +579,63 @@ function saveConfig(repoRoot: string, patch: Partial<BrowserPluginConfig>): Brow
|
|
|
377
579
|
return next;
|
|
378
580
|
}
|
|
379
581
|
|
|
380
|
-
function
|
|
381
|
-
|
|
582
|
+
function parsedAbsoluteUrl(value: unknown): { raw: string; parsed: URL } {
|
|
583
|
+
const raw = stringValue(value);
|
|
584
|
+
if (!raw) throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'url is required.', { retryable: false });
|
|
585
|
+
try {
|
|
586
|
+
return { raw, parsed: new URL(raw) };
|
|
587
|
+
} catch {
|
|
588
|
+
throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'url must be absolute.', { retryable: false });
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function unsupportedUrlProtocol(value: unknown, raw: string, protocol: string): never {
|
|
593
|
+
throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', `Only http and https URLs are supported; observed protocol: ${protocol}`, {
|
|
594
|
+
retryable: false,
|
|
595
|
+
details: {
|
|
596
|
+
observedProtocol: protocol,
|
|
597
|
+
inputType: typeof value,
|
|
598
|
+
inputLength: raw.length,
|
|
599
|
+
},
|
|
600
|
+
});
|
|
382
601
|
}
|
|
383
602
|
|
|
384
|
-
function
|
|
385
|
-
|
|
603
|
+
function normalizedUrl(value: unknown): string {
|
|
604
|
+
const { raw, parsed } = parsedAbsoluteUrl(value);
|
|
605
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') unsupportedUrlProtocol(value, raw, parsed.protocol);
|
|
606
|
+
return parsed.toString();
|
|
386
607
|
}
|
|
387
608
|
|
|
388
|
-
function
|
|
389
|
-
|
|
609
|
+
function normalizedExplicitNativeAdoptionUrl(value: unknown): string {
|
|
610
|
+
const { raw, parsed } = parsedAbsoluteUrl(value);
|
|
611
|
+
if (!['https:', 'http:', 'chrome:', 'vivaldi:'].includes(parsed.protocol)) unsupportedUrlProtocol(value, raw, parsed.protocol);
|
|
612
|
+
return parsed.toString();
|
|
390
613
|
}
|
|
391
614
|
|
|
392
|
-
function
|
|
393
|
-
const
|
|
394
|
-
|
|
395
|
-
throw new AssistantPluginError('PLUGIN_SESSION_NOT_FOUND', `Browser session not found: ${sessionId}`, { retryable: false });
|
|
396
|
-
}
|
|
397
|
-
return state;
|
|
615
|
+
function browserInternalUrlProduct(value: unknown): MacOsBrowserProduct | undefined {
|
|
616
|
+
const { parsed } = parsedAbsoluteUrl(value);
|
|
617
|
+
return parsed.protocol === 'chrome:' ? 'chrome' : parsed.protocol === 'vivaldi:' ? 'vivaldi' : undefined;
|
|
398
618
|
}
|
|
399
619
|
|
|
400
|
-
function
|
|
401
|
-
const
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
620
|
+
function explicitNativeAdoptionUrlsMatch(requestedValue: unknown, observedValue: unknown, product: MacOsBrowserProduct): boolean {
|
|
621
|
+
const requestedUrl = normalizedExplicitNativeAdoptionUrl(requestedValue);
|
|
622
|
+
const observedUrl = normalizedExplicitNativeAdoptionUrl(observedValue);
|
|
623
|
+
if (comparableUrl(requestedUrl) === comparableUrl(observedUrl)) return true;
|
|
624
|
+
return product === 'vivaldi'
|
|
625
|
+
&& requestedUrl === 'vivaldi:bookmarks'
|
|
626
|
+
&& comparableUrl(observedUrl) === comparableUrl('chrome://bookmarks/');
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function isExplicitNativeUserOwnedSession(session: BrowserSessionState): boolean {
|
|
630
|
+
return session.browser?.provider === 'macos-apple-events'
|
|
631
|
+
&& session.browser.tab?.ownership === 'user_owned'
|
|
632
|
+
&& (session.browser.browserProduct === 'chrome' || session.browser.browserProduct === 'vivaldi');
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function normalizedSavedSessionUrl(session: BrowserSessionState): string {
|
|
636
|
+
return isExplicitNativeUserOwnedSession(session)
|
|
637
|
+
? normalizedExplicitNativeAdoptionUrl(session.url)
|
|
638
|
+
: normalizedUrl(session.url);
|
|
413
639
|
}
|
|
414
640
|
|
|
415
641
|
|
|
@@ -463,11 +689,40 @@ function parseNativeBrowserCandidatesInput(value: unknown): MacOsBrowserProduct[
|
|
|
463
689
|
throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'native_browser_candidates must contain chrome and/or vivaldi.', { retryable: false });
|
|
464
690
|
}
|
|
465
691
|
|
|
692
|
+
function effectiveBrowserActionConfig(
|
|
693
|
+
config: BrowserPluginConfig,
|
|
694
|
+
args: Record<string, unknown>,
|
|
695
|
+
existingSession?: BrowserSessionState,
|
|
696
|
+
): BrowserPluginConfig {
|
|
697
|
+
const explicitBrowserMode = parseBrowserModeInput(args.browser_mode);
|
|
698
|
+
const explicitNativeBrowserCandidates = parseNativeBrowserCandidatesInput(args.native_browser_candidates);
|
|
699
|
+
const existingNativeProduct = existingSession?.browser?.provider === 'macos-apple-events'
|
|
700
|
+
? existingSession.browser.browserProduct
|
|
701
|
+
: undefined;
|
|
702
|
+
if (existingNativeProduct && explicitNativeBrowserCandidates && !explicitNativeBrowserCandidates.includes(existingNativeProduct)) {
|
|
703
|
+
throw new AssistantPluginError(
|
|
704
|
+
'PLUGIN_ACTION_ARGUMENT_INVALID',
|
|
705
|
+
'native_browser_candidates cannot switch browser products for an existing native browser session.',
|
|
706
|
+
{ retryable: false, details: { sessionId: existingSession?.sessionId, browserProduct: existingNativeProduct, nativeBrowserCandidates: explicitNativeBrowserCandidates } },
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
const savedNativeBrowserCandidates = existingSession?.browser?.provider === 'macos-apple-events'
|
|
710
|
+
? existingSession.browser.nativeBrowserCandidates ?? (existingNativeProduct ? [existingNativeProduct] : undefined)
|
|
711
|
+
: undefined;
|
|
712
|
+
return {
|
|
713
|
+
...config,
|
|
714
|
+
browserMode: explicitBrowserMode ?? existingSession?.browser?.mode ?? config.browserMode,
|
|
715
|
+
cdpAttachFallback: parseCdpAttachFallbackInput(args.cdp_attach_fallback) ?? config.cdpAttachFallback,
|
|
716
|
+
nativeAttachMode: parseNativeAttachModeInput(args.native_attach_mode) ?? config.nativeAttachMode,
|
|
717
|
+
nativeBrowserCandidates: explicitNativeBrowserCandidates ?? savedNativeBrowserCandidates ?? config.nativeBrowserCandidates,
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
|
|
466
721
|
function cdpEndpoints(config: BrowserPluginConfig): string[] {
|
|
467
722
|
return Array.from(new Set([
|
|
468
723
|
...(config.cdpEndpoint ? [config.cdpEndpoint] : []),
|
|
469
724
|
...(config.cdpEndpointCandidates ?? []),
|
|
470
|
-
].map((entry) => entry.trim()).filter(Boolean))).slice(0,
|
|
725
|
+
].map((entry) => entry.trim()).filter(Boolean))).slice(0, MAX_BROWSER_CDP_ENDPOINT_CANDIDATES);
|
|
471
726
|
}
|
|
472
727
|
|
|
473
728
|
function isLoopbackHostname(hostname: string): boolean {
|
|
@@ -505,8 +760,8 @@ function validateConfig(config: BrowserPluginConfig): string[] {
|
|
|
505
760
|
if (config.browserChannel && config.browserChannel !== 'chromium' && config.executablePath) {
|
|
506
761
|
errors.push('browserChannel and executablePath cannot both be set.');
|
|
507
762
|
}
|
|
508
|
-
if (endpoints.length >
|
|
509
|
-
errors.push(`At most ${
|
|
763
|
+
if (endpoints.length > MAX_BROWSER_CDP_ENDPOINT_CANDIDATES) {
|
|
764
|
+
errors.push(`At most ${MAX_BROWSER_CDP_ENDPOINT_CANDIDATES} CDP endpoint candidates are supported.`);
|
|
510
765
|
}
|
|
511
766
|
for (const endpoint of endpoints) {
|
|
512
767
|
const error = cdpEndpointValidationError(endpoint);
|
|
@@ -531,7 +786,9 @@ function configWarnings(config: BrowserPluginConfig): string[] {
|
|
|
531
786
|
}
|
|
532
787
|
if (config.browserMode === 'attach_preferred' && cdpEndpoints(config).length === 0) {
|
|
533
788
|
if (config.nativeAttachMode === 'auto' && macOsActiveBrowserAttachSupported()) {
|
|
534
|
-
warnings.push(
|
|
789
|
+
warnings.push(config.cdpAttachFallback === 'fail_closed'
|
|
790
|
+
? 'browserMode=attach_preferred has no configured CDP endpoint; configured native-browser attach will be attempted and managed fallback is disabled.'
|
|
791
|
+
: 'browserMode=attach_preferred has no configured CDP endpoint; configured native-browser attach will be attempted before explicit managed fallback.');
|
|
535
792
|
} else {
|
|
536
793
|
warnings.push('browserMode=attach_preferred has no configured CDP endpoint; actions will use the configured fallback policy.');
|
|
537
794
|
}
|
|
@@ -567,11 +824,34 @@ function requiredString(value: unknown, field: string): string {
|
|
|
567
824
|
return normalized;
|
|
568
825
|
}
|
|
569
826
|
|
|
827
|
+
function openShadowSelectorParts(selector: string): string[] {
|
|
828
|
+
return selector.split(/\s*>>>\s*/).map((part) => part.trim()).filter(Boolean);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function openShadowSelectorExpression(selector: string, all = false): string {
|
|
832
|
+
const parts = openShadowSelectorParts(selector);
|
|
833
|
+
if (parts.length <= 1) {
|
|
834
|
+
return all
|
|
835
|
+
? `Array.from(document.querySelectorAll(${JSON.stringify(selector)}))`
|
|
836
|
+
: `document.querySelector(${JSON.stringify(selector)})`;
|
|
837
|
+
}
|
|
838
|
+
return `(() => {
|
|
839
|
+
const parts = ${JSON.stringify(parts)};
|
|
840
|
+
let root = document;
|
|
841
|
+
for (let index = 0; index < parts.length - 1; index += 1) {
|
|
842
|
+
const host = root.querySelector(parts[index]);
|
|
843
|
+
if (!host || !host.shadowRoot) return ${all ? '[]' : 'null'};
|
|
844
|
+
root = host.shadowRoot;
|
|
845
|
+
}
|
|
846
|
+
return ${all ? 'Array.from(root.querySelectorAll(parts[parts.length - 1]))' : 'root.querySelector(parts[parts.length - 1])'};
|
|
847
|
+
})()`;
|
|
848
|
+
}
|
|
849
|
+
|
|
570
850
|
function scriptText(selector?: string): string {
|
|
571
851
|
if (!selector) {
|
|
572
852
|
return 'document.body ? document.body.innerText : (document.documentElement ? document.documentElement.textContent : "")';
|
|
573
853
|
}
|
|
574
|
-
return `(() => { const el =
|
|
854
|
+
return `(() => { const el = ${openShadowSelectorExpression(selector)}; return el ? (el.innerText || el.textContent || '') : ''; })()`;
|
|
575
855
|
}
|
|
576
856
|
|
|
577
857
|
function resolveActionTarget(repoRoot: string, args: Record<string, unknown>): BrowserActionTarget {
|
|
@@ -581,10 +861,11 @@ function resolveActionTarget(repoRoot: string, args: Record<string, unknown>): B
|
|
|
581
861
|
throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'Provide either url or session_id.', { retryable: false });
|
|
582
862
|
}
|
|
583
863
|
if (providedSessionId) {
|
|
584
|
-
const existingSession =
|
|
585
|
-
const
|
|
864
|
+
const existingSession = requireBrowserSession(repoRoot, providedSessionId);
|
|
865
|
+
const nativeUserOwned = isExplicitNativeUserOwnedSession(existingSession);
|
|
866
|
+
const sessionUrl = normalizedSavedSessionUrl(existingSession);
|
|
586
867
|
if (directUrl) {
|
|
587
|
-
const explicitUrl = normalizedUrl(directUrl);
|
|
868
|
+
const explicitUrl = nativeUserOwned ? normalizedExplicitNativeAdoptionUrl(directUrl) : normalizedUrl(directUrl);
|
|
588
869
|
if (explicitUrl !== sessionUrl) {
|
|
589
870
|
throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'url does not match the saved session.', { retryable: false });
|
|
590
871
|
}
|
|
@@ -609,7 +890,7 @@ function sessionFromPage(target: BrowserActionTarget, pageUrl: string, title: st
|
|
|
609
890
|
}
|
|
610
891
|
|
|
611
892
|
function screenshotFilePath(repoRoot: string, actionId: string, sessionId: string, url: string): string {
|
|
612
|
-
const screenshotDir =
|
|
893
|
+
const screenshotDir = browserStateDir(repoRoot, 'screenshots');
|
|
613
894
|
mkdirSync(screenshotDir, { recursive: true });
|
|
614
895
|
const digest = createHash('sha256').update(url).digest('hex').slice(0, 10);
|
|
615
896
|
const actionLabel = actionId.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
@@ -617,6 +898,27 @@ function screenshotFilePath(repoRoot: string, actionId: string, sessionId: strin
|
|
|
617
898
|
return join(screenshotDir, `${Date.now()}-${actionLabel}-${sessionLabel}-${digest}.png`);
|
|
618
899
|
}
|
|
619
900
|
|
|
901
|
+
function safeDownloadArtifactName(requestedName: string | undefined, browserSuggestedName: string | undefined): string {
|
|
902
|
+
const fallback = `download-${Date.now()}`;
|
|
903
|
+
const raw = (requestedName || browserSuggestedName || fallback).trim();
|
|
904
|
+
const leaf = basename(raw) || fallback;
|
|
905
|
+
const sanitized = leaf.replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 180);
|
|
906
|
+
return sanitized && sanitized !== '.' && sanitized !== '..' ? sanitized : fallback;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function isBlockedExecutableArtifactName(name: string | undefined): boolean {
|
|
910
|
+
return Boolean(name && /\.(exe|dmg|pkg|sh|bat|cmd|app)$/i.test(basename(name)));
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function uniqueDownloadArtifactPath(downloadDir: string, fileName: string): string {
|
|
914
|
+
const initial = join(downloadDir, fileName);
|
|
915
|
+
if (!existsSync(initial)) return initial;
|
|
916
|
+
const dot = fileName.lastIndexOf('.');
|
|
917
|
+
const stem = dot > 0 ? fileName.slice(0, dot) : fileName;
|
|
918
|
+
const ext = dot > 0 ? fileName.slice(dot) : '';
|
|
919
|
+
return join(downloadDir, `${stem}-${Date.now()}${ext}`);
|
|
920
|
+
}
|
|
921
|
+
|
|
620
922
|
async function captureActionScreenshot(page: PageLike, repoRoot: string, actionId: string, sessionId: string, url: string): Promise<BrowserActionScreenshot | undefined> {
|
|
621
923
|
const path = screenshotFilePath(repoRoot, actionId, sessionId, url);
|
|
622
924
|
const bytes = (await page.screenshot({ path, fullPage: true })).length;
|
|
@@ -627,6 +929,10 @@ function responseWithWarnings(base: Record<string, unknown>, warnings: string[])
|
|
|
627
929
|
return warnings.length > 0 ? { ...base, warnings } : base;
|
|
628
930
|
}
|
|
629
931
|
|
|
932
|
+
function browserResultProvider(provider: BrowserSessionConnectionState['provider'] | BrowserConnectionSummary['provider'] | undefined): 'playwright' | 'macos-apple-events' {
|
|
933
|
+
return provider === 'macos-apple-events' ? 'macos-apple-events' : 'playwright';
|
|
934
|
+
}
|
|
935
|
+
|
|
630
936
|
function interactionResult(
|
|
631
937
|
actionId: string,
|
|
632
938
|
session: BrowserSessionState,
|
|
@@ -636,7 +942,7 @@ function interactionResult(
|
|
|
636
942
|
extra: Record<string, unknown> = {},
|
|
637
943
|
): Record<string, unknown> {
|
|
638
944
|
return responseWithWarnings({
|
|
639
|
-
provider:
|
|
945
|
+
provider: browserResultProvider(session.browser?.provider),
|
|
640
946
|
session,
|
|
641
947
|
url: session.url,
|
|
642
948
|
title: session.title,
|
|
@@ -678,8 +984,6 @@ function selectedProfile(config: BrowserPluginConfig, repoRoot: string, activeMo
|
|
|
678
984
|
};
|
|
679
985
|
}
|
|
680
986
|
|
|
681
|
-
type BrowserTabMatchReason = 'owned_token' | 'saved_url_title' | 'saved_url' | 'exact_url' | 'blank' | 'new_page';
|
|
682
|
-
|
|
683
987
|
const BROWSER_OWNER_PREFIX = 'forge-browser-owned:';
|
|
684
988
|
|
|
685
989
|
interface BrowserTabInventoryEntry {
|
|
@@ -694,30 +998,6 @@ function ownerTokenForSession(sessionId: string): string {
|
|
|
694
998
|
return `${BROWSER_OWNER_PREFIX}${createHash('sha256').update(sessionId).digest('hex').slice(0, 24)}`;
|
|
695
999
|
}
|
|
696
1000
|
|
|
697
|
-
interface BrowserSessionResumeDiagnostic {
|
|
698
|
-
sessionId: string;
|
|
699
|
-
status: 'matched' | 'stale_tab' | 'no_saved_tab';
|
|
700
|
-
reason: string;
|
|
701
|
-
savedTab?: Pick<BrowserTabResumeState, 'key' | 'url' | 'title' | 'index'>;
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
interface BrowserConnectionFallback {
|
|
705
|
-
policy: BrowserCdpAttachFallback;
|
|
706
|
-
from: 'attach_preferred';
|
|
707
|
-
to: 'managed_persistent';
|
|
708
|
-
reason: string;
|
|
709
|
-
attempts: CdpAttachAttempt[];
|
|
710
|
-
nativeAttempts?: MacOsBrowserAttachAttempt[];
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
interface CdpAttachAttempt {
|
|
714
|
-
endpoint: string;
|
|
715
|
-
discoveredEndpoint?: string;
|
|
716
|
-
probeUrl?: string;
|
|
717
|
-
browserVersion?: string;
|
|
718
|
-
error?: string;
|
|
719
|
-
}
|
|
720
|
-
|
|
721
1001
|
interface BrowserConnectionSummary {
|
|
722
1002
|
requestedMode: BrowserMode;
|
|
723
1003
|
mode: BrowserMode;
|
|
@@ -726,6 +1006,7 @@ interface BrowserConnectionSummary {
|
|
|
726
1006
|
endpoint?: string;
|
|
727
1007
|
browserVersion?: string;
|
|
728
1008
|
browserProduct?: MacOsBrowserProduct;
|
|
1009
|
+
nativeBrowserCandidates?: MacOsBrowserProduct[];
|
|
729
1010
|
fallback?: BrowserConnectionFallback;
|
|
730
1011
|
profile: {
|
|
731
1012
|
profileMode?: BrowserProfileMode;
|
|
@@ -737,6 +1018,62 @@ interface BrowserConnectionSummary {
|
|
|
737
1018
|
sessionResume?: BrowserSessionResumeDiagnostic;
|
|
738
1019
|
}
|
|
739
1020
|
|
|
1021
|
+
async function establishAuthoritativeNativeForeground(
|
|
1022
|
+
input: AssistantPluginActionExecutionInput,
|
|
1023
|
+
page: PageLike,
|
|
1024
|
+
connection: BrowserConnectionSummary,
|
|
1025
|
+
target: BrowserActionTarget,
|
|
1026
|
+
waitMs: number,
|
|
1027
|
+
): Promise<{ frontmost: boolean; active: boolean }> {
|
|
1028
|
+
if (!page.bringToFront || !page.foregroundState) {
|
|
1029
|
+
throw new AssistantPluginError(
|
|
1030
|
+
'PLUGIN_BROWSER_ACTIVATION_UNSUPPORTED',
|
|
1031
|
+
'The native browser provider cannot verify exact-tab foreground authority.',
|
|
1032
|
+
{ retryable: false, details: { sessionId: target.sessionId, browserProduct: connection.browserProduct } },
|
|
1033
|
+
);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
await page.bringToFront();
|
|
1037
|
+
await delay(waitMs);
|
|
1038
|
+
let state = await page.foregroundState();
|
|
1039
|
+
if (state.frontmost && state.active) return state;
|
|
1040
|
+
|
|
1041
|
+
const product = connection.browserProduct;
|
|
1042
|
+
if (!product) {
|
|
1043
|
+
throw new AssistantPluginError(
|
|
1044
|
+
'PLUGIN_BROWSER_ACTIVATION_FAILED',
|
|
1045
|
+
'Native browser activation could not identify the browser product for system foreground recovery.',
|
|
1046
|
+
{ retryable: true, details: { sessionId: target.sessionId, ...state } },
|
|
1047
|
+
);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
try {
|
|
1051
|
+
await runtimeHooks.activateNativeBrowserApplication(input, product);
|
|
1052
|
+
} catch (error) {
|
|
1053
|
+
const causeCode = error instanceof AssistantPluginError ? error.code : undefined;
|
|
1054
|
+
const cause = error instanceof Error ? error.message : String(error);
|
|
1055
|
+
throw new AssistantPluginError(
|
|
1056
|
+
'PLUGIN_BROWSER_ACTIVATION_FAILED',
|
|
1057
|
+
'Native browser exact-tab activation could not establish macOS system foreground authority through Forge Desktop Operator.',
|
|
1058
|
+
{ retryable: true, details: { sessionId: target.sessionId, browserProduct: product, ...state, ...(causeCode ? { causeCode } : {}), cause: cause.slice(0, 500) } },
|
|
1059
|
+
);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// App-level activation is intentionally separate from tab selection. Re-assert the exact
|
|
1063
|
+
// saved tab after the app becomes frontmost, then verify both authorities together.
|
|
1064
|
+
await page.bringToFront();
|
|
1065
|
+
await delay(waitMs);
|
|
1066
|
+
state = await page.foregroundState();
|
|
1067
|
+
if (!state.frontmost || !state.active) {
|
|
1068
|
+
throw new AssistantPluginError(
|
|
1069
|
+
'PLUGIN_BROWSER_ACTIVATION_FAILED',
|
|
1070
|
+
'Native browser activation did not make the exact saved tab authoritative system foreground state.',
|
|
1071
|
+
{ retryable: true, details: { sessionId: target.sessionId, browserProduct: product, ...state } },
|
|
1072
|
+
);
|
|
1073
|
+
}
|
|
1074
|
+
return state;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
740
1077
|
interface BrowserOpenHandle {
|
|
741
1078
|
page: PageLike;
|
|
742
1079
|
diagnostics: PageDiagnostics;
|
|
@@ -796,6 +1133,106 @@ function comparableUrl(value: string): string {
|
|
|
796
1133
|
}
|
|
797
1134
|
}
|
|
798
1135
|
|
|
1136
|
+
export function nativeReplacementUrlMatchesTarget(requestedUrl: string, actualUrl: string): boolean {
|
|
1137
|
+
if (comparableUrl(actualUrl) === comparableUrl(requestedUrl)) return true;
|
|
1138
|
+
try {
|
|
1139
|
+
const requested = new URL(requestedUrl);
|
|
1140
|
+
const actual = new URL(actualUrl);
|
|
1141
|
+
const requestedIsOriginRoot = (requested.protocol === 'https:' || requested.protocol === 'http:')
|
|
1142
|
+
&& requested.pathname === '/'
|
|
1143
|
+
&& requested.search === ''
|
|
1144
|
+
&& requested.hash === '';
|
|
1145
|
+
return requestedIsOriginRoot && actual.origin === requested.origin;
|
|
1146
|
+
} catch {
|
|
1147
|
+
return false;
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
export function nativeReplacementAssignmentProvenanceProvesTarget(
|
|
1152
|
+
evidence: MacOsBrowserCreateTabEvidence | undefined,
|
|
1153
|
+
requestedUrl: string,
|
|
1154
|
+
replacementRef: MacOsBrowserTabRef,
|
|
1155
|
+
): boolean {
|
|
1156
|
+
if (!evidence || evidence.refSource !== 'structured' || !evidence.navigation) return false;
|
|
1157
|
+
if (evidence.ref.windowId !== replacementRef.windowId || evidence.ref.tabId !== replacementRef.tabId) return false;
|
|
1158
|
+
return evidence.navigation.provenanceVersion === 1
|
|
1159
|
+
&& evidence.navigation.assignmentAccepted === true
|
|
1160
|
+
&& evidence.navigation.requestedUrl === requestedUrl;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
export function nativeReplacementPostAssignmentUrlMatchesTarget(
|
|
1164
|
+
requestedUrl: string,
|
|
1165
|
+
actualUrl: string,
|
|
1166
|
+
assignmentProvenanceProven: boolean,
|
|
1167
|
+
): boolean {
|
|
1168
|
+
if (nativeReplacementUrlMatchesTarget(requestedUrl, actualUrl)) return true;
|
|
1169
|
+
return assignmentProvenanceProven && sameOrigin(actualUrl, requestedUrl);
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
export async function settleNativeCreatedPageIdentity(
|
|
1173
|
+
page: {
|
|
1174
|
+
waitForLoadState: (state?: string, options?: Record<string, unknown>) => Promise<void>;
|
|
1175
|
+
identity: () => Promise<{ url: string; title: string }>;
|
|
1176
|
+
},
|
|
1177
|
+
state: string,
|
|
1178
|
+
timeoutMs: number,
|
|
1179
|
+
target?: { requestedUrl: string; previousUrl?: string },
|
|
1180
|
+
): Promise<{ url: string; title: string }> {
|
|
1181
|
+
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
1182
|
+
await page.waitForLoadState(state, { timeout: timeoutMs, requireHttpUrl: true });
|
|
1183
|
+
let identity = await page.identity();
|
|
1184
|
+
if (!target || nativeReplacementUrlMatchesTarget(target.requestedUrl, identity.url)) return identity;
|
|
1185
|
+
|
|
1186
|
+
const previousUrl = target.previousUrl?.trim();
|
|
1187
|
+
if (!previousUrl || comparableUrl(identity.url) !== comparableUrl(previousUrl)) return identity;
|
|
1188
|
+
|
|
1189
|
+
// Native Chrome/Vivaldi can briefly expose the obsolete tab URL on the newly
|
|
1190
|
+
// created stable replacement ref before the create-tab navigation commits.
|
|
1191
|
+
// Only that exact previous URL is treated as transitional. A different HTTP(S)
|
|
1192
|
+
// URL remains an immediate postcondition failure, and authority never moves to
|
|
1193
|
+
// another tab while this bounded settlement loop runs.
|
|
1194
|
+
while (Date.now() < deadline) {
|
|
1195
|
+
const remainingMs = deadline - Date.now();
|
|
1196
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(100, remainingMs)));
|
|
1197
|
+
identity = await page.identity();
|
|
1198
|
+
if (nativeReplacementUrlMatchesTarget(target.requestedUrl, identity.url)) return identity;
|
|
1199
|
+
if (comparableUrl(identity.url) !== comparableUrl(previousUrl)) return identity;
|
|
1200
|
+
}
|
|
1201
|
+
return identity;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
export function nativeReplacementMismatchDiagnostic(input: {
|
|
1205
|
+
requestedUrl: string;
|
|
1206
|
+
actualUrl: string;
|
|
1207
|
+
previousUrl?: string;
|
|
1208
|
+
obsoleteRef?: MacOsBrowserTabRef;
|
|
1209
|
+
replacementRef?: MacOsBrowserTabRef;
|
|
1210
|
+
}): {
|
|
1211
|
+
sameTargetOrigin: boolean;
|
|
1212
|
+
samePreviousOrigin: boolean;
|
|
1213
|
+
samePreviousUrl: boolean;
|
|
1214
|
+
sameTabRef: boolean;
|
|
1215
|
+
actualScheme: string;
|
|
1216
|
+
} {
|
|
1217
|
+
let actualScheme = 'invalid';
|
|
1218
|
+
try {
|
|
1219
|
+
const protocol = new URL(input.actualUrl).protocol.replace(/:$/, '').toLowerCase();
|
|
1220
|
+
actualScheme = /^[a-z][a-z0-9+.-]*$/.test(protocol) ? protocol : 'invalid';
|
|
1221
|
+
} catch {
|
|
1222
|
+
// Keep the diagnostic bounded and non-sensitive when the observed URL is malformed.
|
|
1223
|
+
}
|
|
1224
|
+
const previousUrl = input.previousUrl?.trim();
|
|
1225
|
+
return {
|
|
1226
|
+
sameTargetOrigin: sameOrigin(input.actualUrl, input.requestedUrl),
|
|
1227
|
+
samePreviousOrigin: previousUrl ? sameOrigin(input.actualUrl, previousUrl) : false,
|
|
1228
|
+
samePreviousUrl: previousUrl ? comparableUrl(input.actualUrl) === comparableUrl(previousUrl) : false,
|
|
1229
|
+
sameTabRef: Boolean(input.obsoleteRef && input.replacementRef
|
|
1230
|
+
&& input.obsoleteRef.windowId === input.replacementRef.windowId
|
|
1231
|
+
&& input.obsoleteRef.tabId === input.replacementRef.tabId),
|
|
1232
|
+
actualScheme,
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
|
|
799
1236
|
function sameOrigin(left: string, right: string): boolean {
|
|
800
1237
|
try {
|
|
801
1238
|
return new URL(left).origin === new URL(right).origin;
|
|
@@ -804,6 +1241,105 @@ function sameOrigin(left: string, right: string): boolean {
|
|
|
804
1241
|
}
|
|
805
1242
|
}
|
|
806
1243
|
|
|
1244
|
+
function stableBrowserEntityKey(value: string): string | undefined {
|
|
1245
|
+
try {
|
|
1246
|
+
const url = new URL(value);
|
|
1247
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') return undefined;
|
|
1248
|
+
const segments = url.pathname.split('/').filter(Boolean);
|
|
1249
|
+
for (let index = 0; index + 1 < segments.length; index += 1) {
|
|
1250
|
+
if ((segments[index] ?? '').toLowerCase() !== 'apps') continue;
|
|
1251
|
+
const id = segments[index + 1]?.trim();
|
|
1252
|
+
if (id) return `${url.origin}/apps/${id}`;
|
|
1253
|
+
}
|
|
1254
|
+
return undefined;
|
|
1255
|
+
} catch {
|
|
1256
|
+
return undefined;
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
function selectReusableNativeTab(
|
|
1261
|
+
referenceUrls: string[],
|
|
1262
|
+
inventory: Awaited<ReturnType<typeof listMacOsBrowserTabs>>,
|
|
1263
|
+
preferredWindowId?: string,
|
|
1264
|
+
): {
|
|
1265
|
+
candidate?: (typeof inventory.tabs)[number];
|
|
1266
|
+
match: 'exact_url' | 'stable_entity' | 'none' | 'ambiguous';
|
|
1267
|
+
candidateCount: number;
|
|
1268
|
+
} {
|
|
1269
|
+
const normalizedReferences = [...new Set(referenceUrls.filter(Boolean).map(comparableUrl))];
|
|
1270
|
+
const entityKeys = new Set(normalizedReferences.map(stableBrowserEntityKey).filter((value): value is string => Boolean(value)));
|
|
1271
|
+
const scored = inventory.tabs.map((candidate) => {
|
|
1272
|
+
const normalizedCandidate = comparableUrl(candidate.url);
|
|
1273
|
+
const entityKey = stableBrowserEntityKey(candidate.url);
|
|
1274
|
+
const matchScore = normalizedReferences.includes(normalizedCandidate)
|
|
1275
|
+
? 100
|
|
1276
|
+
: entityKey && entityKeys.has(entityKey)
|
|
1277
|
+
? 50
|
|
1278
|
+
: 0;
|
|
1279
|
+
const score = matchScore === 0
|
|
1280
|
+
? 0
|
|
1281
|
+
: matchScore + (preferredWindowId && candidate.windowId === preferredWindowId ? 10 : 0) + (candidate.active ? 1 : 0);
|
|
1282
|
+
return { candidate, score, matchScore };
|
|
1283
|
+
});
|
|
1284
|
+
const maxScore = scored.reduce((max, entry) => Math.max(max, entry.score), 0);
|
|
1285
|
+
if (maxScore === 0) return { match: 'none', candidateCount: 0 };
|
|
1286
|
+
const matches = scored.filter((entry) => entry.score === maxScore);
|
|
1287
|
+
if (matches.length !== 1) return { match: 'ambiguous', candidateCount: matches.length };
|
|
1288
|
+
return { candidate: matches[0]!.candidate, match: matches[0]!.matchScore === 100 ? 'exact_url' : 'stable_entity', candidateCount: 1 };
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
function selectLiveForgeOwnedNativeTab(
|
|
1292
|
+
repoRoot: string,
|
|
1293
|
+
targetUrl: string,
|
|
1294
|
+
product: MacOsBrowserProduct,
|
|
1295
|
+
inventory: Awaited<ReturnType<typeof listMacOsBrowserTabs>>,
|
|
1296
|
+
): {
|
|
1297
|
+
session?: BrowserSessionState;
|
|
1298
|
+
candidate?: (typeof inventory.tabs)[number];
|
|
1299
|
+
match: 'exact_url' | 'same_origin' | 'none' | 'ambiguous';
|
|
1300
|
+
candidateCount: number;
|
|
1301
|
+
} {
|
|
1302
|
+
const liveByRef = new Map(inventory.tabs.map((tab) => [`${tab.windowId}:${tab.tabId}`, tab]));
|
|
1303
|
+
const candidates = new Map<string, { session: BrowserSessionState; candidate: (typeof inventory.tabs)[number] }>();
|
|
1304
|
+
for (const session of listSavedBrowserSessions(repoRoot)) {
|
|
1305
|
+
const browser = session.browser;
|
|
1306
|
+
const tab = browser?.tab;
|
|
1307
|
+
if (browser?.provider !== 'macos-apple-events' || browser.browserProduct !== product || tab?.ownership !== 'plugin_owned') continue;
|
|
1308
|
+
if (!tab.windowId || !tab.tabId) continue;
|
|
1309
|
+
const key = `${tab.windowId}:${tab.tabId}`;
|
|
1310
|
+
const live = liveByRef.get(key);
|
|
1311
|
+
if (!live) continue;
|
|
1312
|
+
const previous = candidates.get(key);
|
|
1313
|
+
if (!previous || session.updatedAt > previous.session.updatedAt) candidates.set(key, { session, candidate: live });
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
const values = [...candidates.values()];
|
|
1317
|
+
const exact = values.filter(({ candidate }) => comparableUrl(candidate.url) === comparableUrl(targetUrl));
|
|
1318
|
+
const choose = (matches: typeof values, match: 'exact_url' | 'same_origin') => {
|
|
1319
|
+
if (matches.length === 1) return { ...matches[0], match, candidateCount: 1 } as const;
|
|
1320
|
+
const active = matches.filter(({ candidate }) => candidate.active);
|
|
1321
|
+
if (active.length === 1) return { ...active[0], match, candidateCount: matches.length } as const;
|
|
1322
|
+
return { match: 'ambiguous' as const, candidateCount: matches.length };
|
|
1323
|
+
};
|
|
1324
|
+
if (exact.length > 0) return choose(exact, 'exact_url');
|
|
1325
|
+
|
|
1326
|
+
const sameOriginCandidates = values.filter(({ candidate }) => sameOrigin(candidate.url, targetUrl));
|
|
1327
|
+
if (sameOriginCandidates.length > 0) return choose(sameOriginCandidates, 'same_origin');
|
|
1328
|
+
return { match: 'none', candidateCount: 0 };
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
function selectExactNativeTab(
|
|
1332
|
+
targetUrl: string,
|
|
1333
|
+
inventory: Awaited<ReturnType<typeof listMacOsBrowserTabs>>,
|
|
1334
|
+
): { candidate?: (typeof inventory.tabs)[number]; match: 'exact_url' | 'none' | 'ambiguous'; candidateCount: number } {
|
|
1335
|
+
const matches = inventory.tabs.filter((candidate) => comparableUrl(candidate.url) === comparableUrl(targetUrl));
|
|
1336
|
+
if (matches.length === 0) return { match: 'none', candidateCount: 0 };
|
|
1337
|
+
if (matches.length === 1) return { candidate: matches[0], match: 'exact_url', candidateCount: 1 };
|
|
1338
|
+
const active = matches.filter((candidate) => candidate.active);
|
|
1339
|
+
if (active.length === 1) return { candidate: active[0], match: 'exact_url', candidateCount: matches.length };
|
|
1340
|
+
return { match: 'ambiguous', candidateCount: matches.length };
|
|
1341
|
+
}
|
|
1342
|
+
|
|
807
1343
|
function isBlankPage(url: string): boolean {
|
|
808
1344
|
return !url || url === 'about:blank' || url === 'chrome://newtab/';
|
|
809
1345
|
}
|
|
@@ -980,6 +1516,7 @@ function sessionConnectionFromSummary(connection: BrowserConnectionSummary): Bro
|
|
|
980
1516
|
endpoint: connection.endpoint,
|
|
981
1517
|
browserVersion: connection.browserVersion,
|
|
982
1518
|
browserProduct: connection.browserProduct,
|
|
1519
|
+
nativeBrowserCandidates: connection.nativeBrowserCandidates,
|
|
983
1520
|
fallback: connection.fallback,
|
|
984
1521
|
tab: connection.tab,
|
|
985
1522
|
sessionResume: connection.sessionResume,
|
|
@@ -1045,27 +1582,303 @@ function launchOptionsForRepo(repoRoot: string, config: BrowserPluginConfig, pro
|
|
|
1045
1582
|
};
|
|
1046
1583
|
}
|
|
1047
1584
|
|
|
1048
|
-
|
|
1585
|
+
function managedContextKey(profile: BrowserProfileSelection): string {
|
|
1586
|
+
return resolve(profile.selectedProfilePath);
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
async function evictManagedContext(key: string): Promise<void> {
|
|
1590
|
+
const pending = managedBrowserContexts.get(key);
|
|
1591
|
+
managedBrowserContexts.delete(key);
|
|
1592
|
+
if (!pending) return;
|
|
1593
|
+
try {
|
|
1594
|
+
const state = await pending;
|
|
1595
|
+
await state.context.close().catch(() => undefined);
|
|
1596
|
+
} catch {
|
|
1597
|
+
// Failed launches are already unusable; removing the cache entry is enough.
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
async function closeManagedContextsForRepo(repoRoot: string, options: { strict?: boolean } = {}): Promise<void> {
|
|
1602
|
+
const canonicalRoot = resolve(repoRoot);
|
|
1603
|
+
for (const [key, pending] of [...managedBrowserContexts.entries()]) {
|
|
1604
|
+
let state: ManagedBrowserContextState;
|
|
1605
|
+
try {
|
|
1606
|
+
state = await pending;
|
|
1607
|
+
} catch {
|
|
1608
|
+
managedBrowserContexts.delete(key);
|
|
1609
|
+
continue;
|
|
1610
|
+
}
|
|
1611
|
+
if (state.repoRoot !== canonicalRoot) continue;
|
|
1612
|
+
try {
|
|
1613
|
+
await state.context.close();
|
|
1614
|
+
managedBrowserContexts.delete(key);
|
|
1615
|
+
} catch (error) {
|
|
1616
|
+
if (options.strict === true) {
|
|
1617
|
+
throw new AssistantPluginError('PLUGIN_BROWSER_MANAGED_CONTEXT_CLOSE_FAILED', 'Managed browser context could not be closed; retaining Runtime binding and session metadata for a safe retry.', {
|
|
1618
|
+
retryable: true, details: { profilePath: state.profilePath, cause: error instanceof Error ? error.message : String(error) },
|
|
1619
|
+
});
|
|
1620
|
+
}
|
|
1621
|
+
managedBrowserContexts.delete(key);
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
async function managedContextState(
|
|
1049
1627
|
runtime: PlaywrightRuntime,
|
|
1050
1628
|
repoRoot: string,
|
|
1051
1629
|
config: BrowserPluginConfig,
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
let response: { status?: () => number } | null | undefined;
|
|
1065
|
-
if (selected.matchedBy === 'new_page' || selected.matchedBy === 'blank' || comparableUrl(selected.page.url()) !== comparableUrl(target.url)) {
|
|
1066
|
-
response = await selected.page.goto(target.url, { waitUntil: waitUntil(args.wait_until), timeout }) as { status?: () => number } | null | undefined;
|
|
1630
|
+
profile: BrowserProfileSelection,
|
|
1631
|
+
): Promise<ManagedBrowserContextState> {
|
|
1632
|
+
const key = managedContextKey(profile);
|
|
1633
|
+
const existing = managedBrowserContexts.get(key);
|
|
1634
|
+
if (existing) {
|
|
1635
|
+
try {
|
|
1636
|
+
const state = await existing;
|
|
1637
|
+
state.context.pages();
|
|
1638
|
+
return state;
|
|
1639
|
+
} catch {
|
|
1640
|
+
managedBrowserContexts.delete(key);
|
|
1641
|
+
}
|
|
1067
1642
|
}
|
|
1068
|
-
const
|
|
1643
|
+
const launch = (async () => {
|
|
1644
|
+
const context = await runtime.chromium.launchPersistentContext(profile.profileDir, launchOptionsForRepo(repoRoot, config, profile));
|
|
1645
|
+
return {
|
|
1646
|
+
repoRoot: resolve(repoRoot),
|
|
1647
|
+
profilePath: key,
|
|
1648
|
+
context,
|
|
1649
|
+
pagesBySession: new Map<string, PageLike>(),
|
|
1650
|
+
} satisfies ManagedBrowserContextState;
|
|
1651
|
+
})();
|
|
1652
|
+
managedBrowserContexts.set(key, launch);
|
|
1653
|
+
try {
|
|
1654
|
+
return await launch;
|
|
1655
|
+
} catch (error) {
|
|
1656
|
+
if (managedBrowserContexts.get(key) === launch) managedBrowserContexts.delete(key);
|
|
1657
|
+
throw error;
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
async function markManagedPageOwner(page: PageLike, ownerToken: string): Promise<void> {
|
|
1662
|
+
await page.evaluate((_token) => {
|
|
1663
|
+
if (typeof window !== 'undefined') window.name = String(_token ?? '');
|
|
1664
|
+
}, ownerToken).catch(() => undefined);
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
async function selectManagedSessionPage(
|
|
1668
|
+
state: ManagedBrowserContextState,
|
|
1669
|
+
target: BrowserActionTarget,
|
|
1670
|
+
options: { allowReplacement?: boolean } = {},
|
|
1671
|
+
) {
|
|
1672
|
+
const pages = state.context.pages();
|
|
1673
|
+
const inventory = await inventoryTabs(state.context);
|
|
1674
|
+
const ownerToken = ownerTokenForSession(target.sessionId);
|
|
1675
|
+
const mapped = state.pagesBySession.get(target.sessionId);
|
|
1676
|
+
if (mapped && pages.includes(mapped)) {
|
|
1677
|
+
const index = pages.indexOf(mapped);
|
|
1678
|
+
await markManagedPageOwner(mapped, ownerToken);
|
|
1679
|
+
return {
|
|
1680
|
+
page: mapped,
|
|
1681
|
+
matchedBy: 'owned_token' as const,
|
|
1682
|
+
inventory,
|
|
1683
|
+
selectedEntry: inventory[index],
|
|
1684
|
+
sessionResume: {
|
|
1685
|
+
sessionId: target.sessionId,
|
|
1686
|
+
status: 'matched' as const,
|
|
1687
|
+
reason: 'Reused the live managed page bound to this browser session.',
|
|
1688
|
+
savedTab: target.existingSession?.browser?.tab,
|
|
1689
|
+
},
|
|
1690
|
+
ownerToken,
|
|
1691
|
+
};
|
|
1692
|
+
}
|
|
1693
|
+
state.pagesBySession.delete(target.sessionId);
|
|
1694
|
+
|
|
1695
|
+
const ownerIndex = inventory.findIndex((entry) => entry.ownerToken === ownerToken);
|
|
1696
|
+
if (ownerIndex >= 0) {
|
|
1697
|
+
const page = pages[ownerIndex];
|
|
1698
|
+
if (page) {
|
|
1699
|
+
state.pagesBySession.set(target.sessionId, page);
|
|
1700
|
+
return {
|
|
1701
|
+
page,
|
|
1702
|
+
matchedBy: 'owned_token' as const,
|
|
1703
|
+
inventory,
|
|
1704
|
+
selectedEntry: inventory[ownerIndex],
|
|
1705
|
+
sessionResume: {
|
|
1706
|
+
sessionId: target.sessionId,
|
|
1707
|
+
status: 'matched' as const,
|
|
1708
|
+
reason: 'Rediscovered the managed page from its stable session owner marker.',
|
|
1709
|
+
savedTab: target.existingSession?.browser?.tab,
|
|
1710
|
+
},
|
|
1711
|
+
ownerToken,
|
|
1712
|
+
};
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
if (target.existingSession && options.allowReplacement === false) {
|
|
1717
|
+
throw new AssistantPluginError(
|
|
1718
|
+
'PLUGIN_BROWSER_SESSION_STATE_LOST',
|
|
1719
|
+
'Saved managed browser page no longer exists; refusing to create or claim a replacement page for this existing-session action.',
|
|
1720
|
+
{
|
|
1721
|
+
retryable: false,
|
|
1722
|
+
details: { sessionId: target.sessionId, provider: 'playwright-persistent-context' },
|
|
1723
|
+
},
|
|
1724
|
+
);
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
const blankIndex = inventory.findIndex((entry) => isBlankPage(entry.url) && !entry.ownerToken);
|
|
1728
|
+
const page = blankIndex >= 0 && pages[blankIndex] ? pages[blankIndex]! : await state.context.newPage();
|
|
1729
|
+
const matchedBy: BrowserTabMatchReason = blankIndex >= 0 ? 'blank' : 'new_page';
|
|
1730
|
+
await markManagedPageOwner(page, ownerToken);
|
|
1731
|
+
state.pagesBySession.set(target.sessionId, page);
|
|
1732
|
+
return {
|
|
1733
|
+
page,
|
|
1734
|
+
matchedBy,
|
|
1735
|
+
inventory,
|
|
1736
|
+
selectedEntry: blankIndex >= 0 ? inventory[blankIndex] : undefined,
|
|
1737
|
+
sessionResume: target.existingSession?.browser?.tab
|
|
1738
|
+
? {
|
|
1739
|
+
sessionId: target.sessionId,
|
|
1740
|
+
status: 'stale_tab' as const,
|
|
1741
|
+
reason: 'The previously owned managed page no longer exists; created one replacement page for this session only.',
|
|
1742
|
+
savedTab: target.existingSession.browser.tab,
|
|
1743
|
+
}
|
|
1744
|
+
: {
|
|
1745
|
+
sessionId: target.sessionId,
|
|
1746
|
+
status: 'no_saved_tab' as const,
|
|
1747
|
+
reason: matchedBy === 'blank'
|
|
1748
|
+
? 'Claimed one unowned blank page for this managed browser session.'
|
|
1749
|
+
: 'Created one dedicated managed page for this browser session.',
|
|
1750
|
+
},
|
|
1751
|
+
ownerToken,
|
|
1752
|
+
};
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
async function runtimeManagedPageForSession(
|
|
1756
|
+
repoRoot: string,
|
|
1757
|
+
config: BrowserPluginConfig,
|
|
1758
|
+
session: BrowserSessionState,
|
|
1759
|
+
): Promise<{ page: PageLike; profile: BrowserProfileSelection } | undefined> {
|
|
1760
|
+
if (session.browser?.provider !== 'playwright-persistent-context' || session.browser.activeMode !== 'managed_persistent') return undefined;
|
|
1761
|
+
const profile = selectedProfile(config, repoRoot, 'managed_persistent', session.sessionId);
|
|
1762
|
+
const pending = managedBrowserContexts.get(managedContextKey(profile));
|
|
1763
|
+
if (!pending) return undefined;
|
|
1764
|
+
let state: ManagedBrowserContextState;
|
|
1765
|
+
try {
|
|
1766
|
+
state = await pending;
|
|
1767
|
+
} catch {
|
|
1768
|
+
return undefined;
|
|
1769
|
+
}
|
|
1770
|
+
let page = state.pagesBySession.get(session.sessionId);
|
|
1771
|
+
if (page && state.context.pages().includes(page)) return { page, profile };
|
|
1772
|
+
state.pagesBySession.delete(session.sessionId);
|
|
1773
|
+
const expectedOwnerToken = session.browser.tab?.ownerToken ?? ownerTokenForSession(session.sessionId);
|
|
1774
|
+
const inventory = await inventoryTabs(state.context).catch(() => [] as BrowserTabInventoryEntry[]);
|
|
1775
|
+
const index = inventory.findIndex((entry) => entry.ownerToken === expectedOwnerToken);
|
|
1776
|
+
page = index >= 0 ? state.context.pages()[index] : undefined;
|
|
1777
|
+
if (!page) return undefined;
|
|
1778
|
+
state.pagesBySession.set(session.sessionId, page);
|
|
1779
|
+
return { page, profile };
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
async function closeManagedSessionPage(repoRoot: string, config: BrowserPluginConfig, session: BrowserSessionState): Promise<boolean> {
|
|
1783
|
+
if (session.browser?.provider !== 'playwright-persistent-context' || session.browser.activeMode !== 'managed_persistent') return false;
|
|
1784
|
+
const profile = selectedProfile(config, repoRoot, 'managed_persistent', session.sessionId);
|
|
1785
|
+
const pending = managedBrowserContexts.get(managedContextKey(profile));
|
|
1786
|
+
if (!pending) return false;
|
|
1787
|
+
let state: ManagedBrowserContextState;
|
|
1788
|
+
try {
|
|
1789
|
+
state = await pending;
|
|
1790
|
+
} catch {
|
|
1791
|
+
return false;
|
|
1792
|
+
}
|
|
1793
|
+
let page = state.pagesBySession.get(session.sessionId);
|
|
1794
|
+
if (!page) {
|
|
1795
|
+
const expectedOwnerToken = session.browser.tab?.ownerToken ?? ownerTokenForSession(session.sessionId);
|
|
1796
|
+
const inventory = await inventoryTabs(state.context).catch(() => [] as BrowserTabInventoryEntry[]);
|
|
1797
|
+
const index = inventory.findIndex((entry) => entry.ownerToken === expectedOwnerToken);
|
|
1798
|
+
if (index >= 0) page = state.context.pages()[index];
|
|
1799
|
+
}
|
|
1800
|
+
if (!page || typeof page.close !== 'function') {
|
|
1801
|
+
state.pagesBySession.delete(session.sessionId);
|
|
1802
|
+
return false;
|
|
1803
|
+
}
|
|
1804
|
+
await page.close();
|
|
1805
|
+
state.pagesBySession.delete(session.sessionId);
|
|
1806
|
+
return true;
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
async function openManagedContext(
|
|
1810
|
+
runtime: PlaywrightRuntime,
|
|
1811
|
+
repoRoot: string,
|
|
1812
|
+
config: BrowserPluginConfig,
|
|
1813
|
+
target: BrowserActionTarget,
|
|
1814
|
+
args: Record<string, unknown>,
|
|
1815
|
+
activeMode: BrowserMode,
|
|
1816
|
+
requestedMode: BrowserMode,
|
|
1817
|
+
fallback?: BrowserConnectionFallback,
|
|
1818
|
+
): Promise<BrowserOpenHandle> {
|
|
1819
|
+
const profile = selectedProfile(config, repoRoot, activeMode, target.sessionId);
|
|
1820
|
+
const persistentKey = managedContextKey(profile);
|
|
1821
|
+
const requireExistingResource = args.__forge_require_existing_resource === true;
|
|
1822
|
+
const allowManagedSessionRehydrate = requireExistingResource && args.__forge_allow_managed_session_rehydrate === true;
|
|
1823
|
+
const managedContextWasMissing = !managedBrowserContexts.has(persistentKey);
|
|
1824
|
+
if (requireExistingResource && target.existingSession && activeMode === 'managed_persistent') {
|
|
1825
|
+
const savedConnection = target.existingSession.browser;
|
|
1826
|
+
if (savedConnection?.provider !== 'playwright-persistent-context'
|
|
1827
|
+
|| savedConnection.activeMode !== 'managed_persistent') {
|
|
1828
|
+
throw new AssistantPluginError(
|
|
1829
|
+
'PLUGIN_BROWSER_SESSION_STATE_LOST',
|
|
1830
|
+
'Saved browser session belongs to a different browser provider/mode; refusing to migrate it into a replacement managed browser/page for this existing-session action.',
|
|
1831
|
+
{
|
|
1832
|
+
retryable: false,
|
|
1833
|
+
details: {
|
|
1834
|
+
sessionId: target.sessionId,
|
|
1835
|
+
savedProvider: savedConnection?.provider,
|
|
1836
|
+
savedActiveMode: savedConnection?.activeMode,
|
|
1837
|
+
requestedActiveMode: activeMode,
|
|
1838
|
+
},
|
|
1839
|
+
},
|
|
1840
|
+
);
|
|
1841
|
+
}
|
|
1842
|
+
if (managedContextWasMissing && !allowManagedSessionRehydrate) {
|
|
1843
|
+
throw new AssistantPluginError(
|
|
1844
|
+
'PLUGIN_BROWSER_SESSION_STATE_LOST',
|
|
1845
|
+
'The managed browser context for this saved session is no longer live; refusing to launch a replacement browser/page for this existing-session action.',
|
|
1846
|
+
{ retryable: false, details: { sessionId: target.sessionId, provider: 'playwright-persistent-context' } },
|
|
1847
|
+
);
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
assertBrowserProfileAvailable(repoRoot, profile.selectedProfilePath);
|
|
1851
|
+
mkdirSync(profile.profileDir, { recursive: true });
|
|
1852
|
+
let context: BrowserContextLike;
|
|
1853
|
+
let selected: Awaited<ReturnType<typeof selectManagedSessionPage>> | Awaited<ReturnType<typeof selectPage>>;
|
|
1854
|
+
let managedState: ManagedBrowserContextState | undefined;
|
|
1855
|
+
if (activeMode === 'managed_persistent') {
|
|
1856
|
+
managedState = await managedContextState(runtime, repoRoot, config, profile);
|
|
1857
|
+
context = managedState.context;
|
|
1858
|
+
try {
|
|
1859
|
+
selected = await selectManagedSessionPage(managedState, target, { allowReplacement: !requireExistingResource });
|
|
1860
|
+
} catch (error) {
|
|
1861
|
+
if (requireExistingResource) {
|
|
1862
|
+
if (allowManagedSessionRehydrate && managedContextWasMissing) await evictManagedContext(persistentKey);
|
|
1863
|
+
throw error;
|
|
1864
|
+
}
|
|
1865
|
+
await evictManagedContext(persistentKey);
|
|
1866
|
+
assertBrowserProfileAvailable(repoRoot, profile.selectedProfilePath);
|
|
1867
|
+
managedState = await managedContextState(runtime, repoRoot, config, profile);
|
|
1868
|
+
context = managedState.context;
|
|
1869
|
+
selected = await selectManagedSessionPage(managedState, target);
|
|
1870
|
+
}
|
|
1871
|
+
} else {
|
|
1872
|
+
context = await runtime.chromium.launchPersistentContext(profile.profileDir, launchOptionsForRepo(repoRoot, config, profile));
|
|
1873
|
+
selected = await selectPage(context, target);
|
|
1874
|
+
}
|
|
1875
|
+
const timeout = positiveNumber(args.timeout_ms, config.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
1876
|
+
let response: { status?: () => number } | null | undefined;
|
|
1877
|
+
if (selected.matchedBy === 'new_page' || selected.matchedBy === 'blank' || comparableUrl(selected.page.url()) !== comparableUrl(target.url)) {
|
|
1878
|
+
response = await selected.page.goto(target.url, { waitUntil: waitUntil(args.wait_until), timeout }) as { status?: () => number } | null | undefined;
|
|
1879
|
+
}
|
|
1880
|
+
if (managedState && 'ownerToken' in selected) await markManagedPageOwner(selected.page, selected.ownerToken);
|
|
1881
|
+
const diagnostics = attachDiagnostics(selected.page);
|
|
1069
1882
|
diagnostics.navigation = {
|
|
1070
1883
|
url: target.url,
|
|
1071
1884
|
status: response && typeof response.status === 'function' ? response.status() : undefined,
|
|
@@ -1084,13 +1897,16 @@ async function openManagedContext(
|
|
|
1084
1897
|
},
|
|
1085
1898
|
tabInventory: selected.inventory,
|
|
1086
1899
|
sessionResume: selected.sessionResume,
|
|
1087
|
-
}, normalizedUrl(selected.page.url()), title, selected.matchedBy
|
|
1900
|
+
}, normalizedUrl(selected.page.url()), title, selected.matchedBy,
|
|
1901
|
+
managedState && 'ownerToken' in selected
|
|
1902
|
+
? { ownership: 'plugin_owned', ownerToken: selected.ownerToken }
|
|
1903
|
+
: undefined);
|
|
1088
1904
|
return {
|
|
1089
1905
|
page: selected.page,
|
|
1090
1906
|
diagnostics,
|
|
1091
1907
|
connection,
|
|
1092
1908
|
close: async () => {
|
|
1093
|
-
await context.close().catch(() => undefined);
|
|
1909
|
+
if (activeMode === 'isolated') await context.close().catch(() => undefined);
|
|
1094
1910
|
},
|
|
1095
1911
|
};
|
|
1096
1912
|
}
|
|
@@ -1177,6 +1993,11 @@ async function openAttachedContext(
|
|
|
1177
1993
|
return { attempts };
|
|
1178
1994
|
}
|
|
1179
1995
|
|
|
1996
|
+
function nativeBackgroundNavigationRequiresReplacement(error: unknown): boolean {
|
|
1997
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1998
|
+
return message.includes('BROWSER_AUTOMATION_BACKGROUND_NAVIGATION_REQUIRES_REPLACEMENT');
|
|
1999
|
+
}
|
|
2000
|
+
|
|
1180
2001
|
async function openNativeAttachedContext(
|
|
1181
2002
|
repoRoot: string,
|
|
1182
2003
|
config: BrowserPluginConfig,
|
|
@@ -1184,17 +2005,46 @@ async function openNativeAttachedContext(
|
|
|
1184
2005
|
args: Record<string, unknown>,
|
|
1185
2006
|
): Promise<{ handle?: BrowserOpenHandle; attempts: MacOsBrowserAttachAttempt[] }> {
|
|
1186
2007
|
if (config.nativeAttachMode === 'disabled') return { attempts: [] };
|
|
2008
|
+
if (!target.existingSession && !stringValue(args.session_id)) {
|
|
2009
|
+
await inspectNativeOwnedSessions({ repoRoot, savedSessions: listSavedSessions(repoRoot), timeoutMs: config.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS, pruneDead: true });
|
|
2010
|
+
}
|
|
1187
2011
|
const timeout = positiveNumber(args.timeout_ms, config.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
1188
2012
|
const savedTab = target.existingSession?.browser?.tab;
|
|
2013
|
+
const savedOwnership = savedTab?.ownership;
|
|
2014
|
+
let effectiveOwnership = savedOwnership;
|
|
2015
|
+
let recoveredTabTitle = '';
|
|
2016
|
+
let recoveredTabUrl: string | undefined;
|
|
1189
2017
|
const savedProduct = target.existingSession?.browser?.provider === 'macos-apple-events'
|
|
1190
|
-
&& savedTab
|
|
2018
|
+
&& savedTab
|
|
2019
|
+
&& (savedOwnership === 'plugin_owned' || savedOwnership === 'user_owned')
|
|
1191
2020
|
&& typeof savedTab.windowId === 'string'
|
|
1192
2021
|
&& typeof savedTab.tabId === 'string'
|
|
1193
2022
|
? target.existingSession.browser.browserProduct
|
|
1194
2023
|
: undefined;
|
|
2024
|
+
if (args.__forge_require_existing_resource === true
|
|
2025
|
+
&& target.existingSession?.browser?.provider === 'macos-apple-events'
|
|
2026
|
+
&& !savedProduct) {
|
|
2027
|
+
throw new AssistantPluginError(
|
|
2028
|
+
'PLUGIN_BROWSER_SESSION_STATE_LOST',
|
|
2029
|
+
'Saved native browser session lacks a complete stable browser/tab identity; refusing to create or adopt a replacement tab for this existing-session action.',
|
|
2030
|
+
{
|
|
2031
|
+
retryable: false,
|
|
2032
|
+
details: {
|
|
2033
|
+
sessionId: target.sessionId,
|
|
2034
|
+
provider: target.existingSession.browser.provider,
|
|
2035
|
+
browserProduct: target.existingSession.browser.browserProduct,
|
|
2036
|
+
ownership: savedOwnership,
|
|
2037
|
+
windowId: savedTab?.windowId,
|
|
2038
|
+
tabId: savedTab?.tabId,
|
|
2039
|
+
},
|
|
2040
|
+
},
|
|
2041
|
+
);
|
|
2042
|
+
}
|
|
1195
2043
|
let page: PageLike | undefined;
|
|
1196
2044
|
let discovered: Awaited<ReturnType<typeof discoverMacOsBrowserAttachment>> | undefined;
|
|
1197
|
-
let sessionResume: BrowserSessionResumeDiagnostic
|
|
2045
|
+
let sessionResume: BrowserSessionResumeDiagnostic = savedTab
|
|
2046
|
+
? { sessionId: target.sessionId, status: 'stale_tab', reason: 'Saved native tab has not yet been reattached.', savedTab }
|
|
2047
|
+
: { sessionId: target.sessionId, status: 'no_saved_tab', reason: 'No saved native tab exists yet.' };
|
|
1198
2048
|
let matchedBy: BrowserTabMatchReason = 'new_page';
|
|
1199
2049
|
|
|
1200
2050
|
if (savedProduct && savedTab && typeof savedTab.windowId === 'string' && typeof savedTab.tabId === 'string') {
|
|
@@ -1203,10 +2053,73 @@ async function openNativeAttachedContext(
|
|
|
1203
2053
|
const reattached = await reattachMacOsBrowserOwnedPage(savedProduct, ref, timeout);
|
|
1204
2054
|
page = reattached.page as unknown as PageLike;
|
|
1205
2055
|
discovered = { attachment: reattached.attachment, attempts: reattached.attachment.attempts };
|
|
1206
|
-
matchedBy = 'owned_token';
|
|
1207
|
-
sessionResume = {
|
|
1208
|
-
|
|
1209
|
-
|
|
2056
|
+
matchedBy = savedOwnership === 'user_owned' ? 'saved_url' : 'owned_token';
|
|
2057
|
+
sessionResume = {
|
|
2058
|
+
sessionId: target.sessionId,
|
|
2059
|
+
status: 'matched',
|
|
2060
|
+
reason: savedOwnership === 'user_owned'
|
|
2061
|
+
? 'Reattached directly to the explicitly adopted user-owned macOS browser tab.'
|
|
2062
|
+
: 'Reattached directly to the saved plugin-owned macOS browser tab.',
|
|
2063
|
+
savedTab,
|
|
2064
|
+
};
|
|
2065
|
+
} catch (error) {
|
|
2066
|
+
if (error instanceof AssistantPluginError
|
|
2067
|
+
&& error.code === 'PLUGIN_BROWSER_NATIVE_TAB_IDENTITY_UNPROVEN'
|
|
2068
|
+
&& savedOwnership === 'user_owned') {
|
|
2069
|
+
throw error;
|
|
2070
|
+
}
|
|
2071
|
+
if (savedOwnership === 'user_owned') {
|
|
2072
|
+
throw new AssistantPluginError(
|
|
2073
|
+
'PLUGIN_BROWSER_SESSION_STATE_LOST',
|
|
2074
|
+
'Explicitly adopted user-owned macOS browser tab no longer exists; refusing to silently switch to another user tab.',
|
|
2075
|
+
{
|
|
2076
|
+
retryable: false,
|
|
2077
|
+
details: { sessionId: target.sessionId, browserProduct: savedProduct, ownership: savedOwnership, windowId: savedTab.windowId, tabId: savedTab.tabId, cause: error instanceof Error ? error.message : String(error) },
|
|
2078
|
+
},
|
|
2079
|
+
);
|
|
2080
|
+
}
|
|
2081
|
+
let inventory: Awaited<ReturnType<typeof listMacOsBrowserTabs>> | undefined;
|
|
2082
|
+
let selection: ReturnType<typeof selectReusableNativeTab> | undefined;
|
|
2083
|
+
let recoveryFailure: string | undefined;
|
|
2084
|
+
try {
|
|
2085
|
+
inventory = await listMacOsBrowserTabs(savedProduct, timeout);
|
|
2086
|
+
selection = selectReusableNativeTab(
|
|
2087
|
+
[savedTab.url, target.existingSession?.url ?? '', target.url],
|
|
2088
|
+
inventory,
|
|
2089
|
+
savedTab.windowId,
|
|
2090
|
+
);
|
|
2091
|
+
if (selection.candidate) {
|
|
2092
|
+
const recovered = await reattachMacOsBrowserOwnedPage(savedProduct, { windowId: selection.candidate.windowId, tabId: selection.candidate.tabId }, timeout);
|
|
2093
|
+
page = recovered.page as unknown as PageLike;
|
|
2094
|
+
discovered = { attachment: recovered.attachment, attempts: recovered.attachment.attempts };
|
|
2095
|
+
matchedBy = 'recovered_tab';
|
|
2096
|
+
effectiveOwnership = 'user_owned';
|
|
2097
|
+
recoveredTabTitle = selection.candidate.title;
|
|
2098
|
+
recoveredTabUrl = selection.candidate.url;
|
|
2099
|
+
sessionResume = {
|
|
2100
|
+
sessionId: target.sessionId,
|
|
2101
|
+
status: 'matched',
|
|
2102
|
+
reason: selection.match === 'exact_url'
|
|
2103
|
+
? 'Saved plugin-owned tab disappeared; rebound safely to the unique currently open tab with the same URL and preserved it as user-owned.'
|
|
2104
|
+
: '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.',
|
|
2105
|
+
savedTab,
|
|
2106
|
+
};
|
|
2107
|
+
}
|
|
2108
|
+
} catch (recoveryError) {
|
|
2109
|
+
recoveryFailure = recoveryError instanceof Error ? recoveryError.message : String(recoveryError);
|
|
2110
|
+
}
|
|
2111
|
+
if (!page) {
|
|
2112
|
+
sessionResume = {
|
|
2113
|
+
sessionId: target.sessionId,
|
|
2114
|
+
status: 'stale_tab',
|
|
2115
|
+
reason: recoveryFailure
|
|
2116
|
+
? `Saved plugin-owned macOS browser tab disappeared and current-tab recovery failed (${recoveryFailure}); creating one replacement plugin-owned tab.`
|
|
2117
|
+
: selection?.match === 'ambiguous'
|
|
2118
|
+
? '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.'
|
|
2119
|
+
: 'Saved plugin-owned macOS browser tab disappeared and no uniquely reusable current tab matched; creating one replacement plugin-owned tab.',
|
|
2120
|
+
savedTab,
|
|
2121
|
+
};
|
|
2122
|
+
}
|
|
1210
2123
|
}
|
|
1211
2124
|
} else {
|
|
1212
2125
|
sessionResume = savedTab
|
|
@@ -1215,52 +2128,246 @@ async function openNativeAttachedContext(
|
|
|
1215
2128
|
}
|
|
1216
2129
|
|
|
1217
2130
|
if (!discovered?.attachment) {
|
|
1218
|
-
const candidates = savedProduct ? [savedProduct
|
|
2131
|
+
const candidates = savedProduct ? [savedProduct] : (config.nativeBrowserCandidates ?? [...DEFAULT_USER_NATIVE_BROWSER_CANDIDATES]);
|
|
1219
2132
|
discovered = await discoverMacOsBrowserAttachment(candidates, Math.min(timeout, MAX_CDP_DISCOVERY_TIMEOUT_MS));
|
|
1220
2133
|
}
|
|
1221
2134
|
if (!discovered.attachment) return { attempts: discovered.attempts };
|
|
2135
|
+
let activeAttachment = discovered.attachment;
|
|
1222
2136
|
|
|
1223
2137
|
let createdThisCallRef: MacOsBrowserTabRef | undefined;
|
|
1224
|
-
let
|
|
2138
|
+
let navigationUsedReplacement = false;
|
|
2139
|
+
const navigateNativePageWithReplacement = async (candidate: PageLike): Promise<PageLike> => {
|
|
2140
|
+
const obsoleteRef = (candidate as unknown as { tabRef?: () => MacOsBrowserTabRef | undefined }).tabRef?.();
|
|
2141
|
+
try {
|
|
2142
|
+
await candidate.goto(target.url, { waitUntil: waitUntil(args.wait_until), timeout });
|
|
2143
|
+
return candidate;
|
|
2144
|
+
} catch (error) {
|
|
2145
|
+
if (!nativeBackgroundNavigationRequiresReplacement(error) || effectiveOwnership === 'user_owned' || !obsoleteRef) throw error;
|
|
2146
|
+
const replacement = await createMacOsBrowserOwnedPage(activeAttachment, target.url, timeout) as unknown as PageLike;
|
|
2147
|
+
const replacementPage = replacement as unknown as {
|
|
2148
|
+
tabRef?: () => MacOsBrowserTabRef | undefined;
|
|
2149
|
+
creationEvidence?: () => MacOsBrowserCreateTabEvidence | undefined;
|
|
2150
|
+
};
|
|
2151
|
+
const replacementRef = replacementPage.tabRef?.();
|
|
2152
|
+
if (!replacementRef) {
|
|
2153
|
+
throw new AssistantPluginError('PLUGIN_BROWSER_NATIVE_OWNERSHIP_MISSING', 'Native replacement tab did not return a stable plugin-owned tab reference.', { retryable: true });
|
|
2154
|
+
}
|
|
2155
|
+
const creationEvidence = replacementPage.creationEvidence?.();
|
|
2156
|
+
const assignmentProvenanceProven = nativeReplacementAssignmentProvenanceProvesTarget(creationEvidence, target.url, replacementRef);
|
|
2157
|
+
if (!assignmentProvenanceProven) {
|
|
2158
|
+
await closeMacOsBrowserOwnedTab(activeAttachment.metadata.product, replacementRef, timeout).catch(() => undefined);
|
|
2159
|
+
throw new AssistantPluginError(
|
|
2160
|
+
'PLUGIN_BROWSER_NATIVE_REPLACEMENT_PROVENANCE_UNPROVEN',
|
|
2161
|
+
'Native replacement tab did not prove exact requested-URL assignment through the stable create-tab provenance contract; preserving the original plugin-owned tab.',
|
|
2162
|
+
{
|
|
2163
|
+
retryable: true,
|
|
2164
|
+
details: {
|
|
2165
|
+
browserProduct: activeAttachment.metadata.product,
|
|
2166
|
+
stableRef: creationEvidence?.refSource === 'structured',
|
|
2167
|
+
provenanceVersion: creationEvidence?.navigation?.provenanceVersion ?? null,
|
|
2168
|
+
assignmentAccepted: creationEvidence?.navigation?.assignmentAccepted === true,
|
|
2169
|
+
requestedUrlMatches: creationEvidence?.navigation
|
|
2170
|
+
? creationEvidence.navigation.requestedUrl === target.url
|
|
2171
|
+
: false,
|
|
2172
|
+
},
|
|
2173
|
+
},
|
|
2174
|
+
);
|
|
2175
|
+
}
|
|
2176
|
+
try {
|
|
2177
|
+
const previousUrl = target.existingSession?.url ?? candidate.url();
|
|
2178
|
+
const identity = await settleNativeCreatedPageIdentity(
|
|
2179
|
+
replacement as unknown as {
|
|
2180
|
+
waitForLoadState: (state?: string, options?: Record<string, unknown>) => Promise<void>;
|
|
2181
|
+
identity: () => Promise<{ url: string; title: string }>;
|
|
2182
|
+
},
|
|
2183
|
+
waitUntil(args.wait_until),
|
|
2184
|
+
timeout,
|
|
2185
|
+
{ requestedUrl: target.url, previousUrl },
|
|
2186
|
+
);
|
|
2187
|
+
if (!nativeReplacementPostAssignmentUrlMatchesTarget(target.url, identity.url, assignmentProvenanceProven)) {
|
|
2188
|
+
const diagnostic = nativeReplacementMismatchDiagnostic({
|
|
2189
|
+
requestedUrl: target.url,
|
|
2190
|
+
actualUrl: identity.url,
|
|
2191
|
+
previousUrl,
|
|
2192
|
+
obsoleteRef,
|
|
2193
|
+
replacementRef,
|
|
2194
|
+
});
|
|
2195
|
+
const diagnosticText = Object.entries(diagnostic)
|
|
2196
|
+
.map(([key, value]) => `${key}=${String(value)}`)
|
|
2197
|
+
.join(',');
|
|
2198
|
+
throw new AssistantPluginError(
|
|
2199
|
+
'PLUGIN_BROWSER_NATIVE_REPLACEMENT_MISMATCH',
|
|
2200
|
+
`Native replacement tab did not reach the requested URL; preserving the original plugin-owned tab. diagnostic=${diagnosticText}`,
|
|
2201
|
+
{ retryable: true, details: diagnostic },
|
|
2202
|
+
);
|
|
2203
|
+
}
|
|
2204
|
+
} catch (replacementError) {
|
|
2205
|
+
await closeMacOsBrowserOwnedTab(activeAttachment.metadata.product, replacementRef, timeout).catch(() => undefined);
|
|
2206
|
+
throw replacementError;
|
|
2207
|
+
}
|
|
2208
|
+
try {
|
|
2209
|
+
await closeMacOsBrowserOwnedTab(activeAttachment.metadata.product, obsoleteRef, timeout);
|
|
2210
|
+
} catch (closeError) {
|
|
2211
|
+
await closeMacOsBrowserOwnedTab(activeAttachment.metadata.product, replacementRef, timeout).catch(() => undefined);
|
|
2212
|
+
throw closeError;
|
|
2213
|
+
}
|
|
2214
|
+
createdThisCallRef = replacementRef;
|
|
2215
|
+
navigationUsedReplacement = true;
|
|
2216
|
+
matchedBy = 'owned_token';
|
|
2217
|
+
effectiveOwnership = 'plugin_owned';
|
|
2218
|
+
sessionResume = {
|
|
2219
|
+
sessionId: target.sessionId,
|
|
2220
|
+
status: 'matched',
|
|
2221
|
+
reason: 'Browser broker required replacement for background cross-URL navigation; created and verified a replacement plugin-owned tab before closing the obsolete tab.',
|
|
2222
|
+
savedTab,
|
|
2223
|
+
};
|
|
2224
|
+
return replacement;
|
|
2225
|
+
}
|
|
2226
|
+
};
|
|
1225
2227
|
try {
|
|
2228
|
+
if (!page && !savedTab && !stringValue(args.session_id)) {
|
|
2229
|
+
let inventory: Awaited<ReturnType<typeof listMacOsBrowserTabs>> | undefined;
|
|
2230
|
+
try {
|
|
2231
|
+
inventory = await listMacOsBrowserTabs(activeAttachment.metadata.product, timeout);
|
|
2232
|
+
} catch (error) {
|
|
2233
|
+
if (!nativeTabInventoryUnsupported(error)) throw error;
|
|
2234
|
+
}
|
|
2235
|
+
if (inventory) {
|
|
2236
|
+
const ownedSelection = selectLiveForgeOwnedNativeTab(repoRoot, target.url, activeAttachment.metadata.product, inventory);
|
|
2237
|
+
if (ownedSelection.match === 'ambiguous') {
|
|
2238
|
+
throw new AssistantPluginError(
|
|
2239
|
+
'PLUGIN_BROWSER_REUSABLE_TAB_AMBIGUOUS',
|
|
2240
|
+
'Multiple live Forge-owned browser tabs can satisfy this sessionless navigation; refusing to open another duplicate tab.',
|
|
2241
|
+
{ retryable: false, details: { targetUrl: target.url, candidateCount: ownedSelection.candidateCount } },
|
|
2242
|
+
);
|
|
2243
|
+
}
|
|
2244
|
+
if (ownedSelection.candidate) {
|
|
2245
|
+
if (ownedSelection.session) {
|
|
2246
|
+
target.sessionId = ownedSelection.session.sessionId;
|
|
2247
|
+
target.existingSession = ownedSelection.session;
|
|
2248
|
+
}
|
|
2249
|
+
const rebound = await reattachMacOsBrowserOwnedPage(
|
|
2250
|
+
activeAttachment.metadata.product,
|
|
2251
|
+
{ windowId: ownedSelection.candidate.windowId, tabId: ownedSelection.candidate.tabId },
|
|
2252
|
+
timeout,
|
|
2253
|
+
);
|
|
2254
|
+
page = rebound.page as unknown as PageLike;
|
|
2255
|
+
discovered = { attachment: rebound.attachment, attempts: rebound.attachment.attempts };
|
|
2256
|
+
activeAttachment = rebound.attachment;
|
|
2257
|
+
matchedBy = 'owned_token';
|
|
2258
|
+
effectiveOwnership = 'plugin_owned';
|
|
2259
|
+
sessionResume = {
|
|
2260
|
+
sessionId: target.sessionId,
|
|
2261
|
+
status: 'matched',
|
|
2262
|
+
reason: ownedSelection.match === 'exact_url'
|
|
2263
|
+
? 'No session id was supplied; reused the unique live Forge-owned native tab already at the target URL.'
|
|
2264
|
+
: 'No session id was supplied; reused the unique live Forge-owned native tab for the same origin and navigated it in place.',
|
|
2265
|
+
savedTab: ownedSelection.session?.browser?.tab,
|
|
2266
|
+
};
|
|
2267
|
+
} else {
|
|
2268
|
+
const exactSelection = selectExactNativeTab(target.url, inventory);
|
|
2269
|
+
if (exactSelection.match === 'ambiguous') {
|
|
2270
|
+
throw new AssistantPluginError(
|
|
2271
|
+
'PLUGIN_BROWSER_REUSABLE_TAB_AMBIGUOUS',
|
|
2272
|
+
'Multiple existing browser tabs already match the target URL; refusing to open another duplicate tab.',
|
|
2273
|
+
{ retryable: false, details: { targetUrl: target.url, candidateCount: exactSelection.candidateCount } },
|
|
2274
|
+
);
|
|
2275
|
+
}
|
|
2276
|
+
if (exactSelection.candidate) {
|
|
2277
|
+
const rebound = await reattachMacOsBrowserOwnedPage(
|
|
2278
|
+
activeAttachment.metadata.product,
|
|
2279
|
+
{ windowId: exactSelection.candidate.windowId, tabId: exactSelection.candidate.tabId },
|
|
2280
|
+
timeout,
|
|
2281
|
+
);
|
|
2282
|
+
page = rebound.page as unknown as PageLike;
|
|
2283
|
+
discovered = { attachment: rebound.attachment, attempts: rebound.attachment.attempts };
|
|
2284
|
+
activeAttachment = rebound.attachment;
|
|
2285
|
+
matchedBy = 'exact_url';
|
|
2286
|
+
effectiveOwnership = 'user_owned';
|
|
2287
|
+
recoveredTabTitle = exactSelection.candidate.title;
|
|
2288
|
+
recoveredTabUrl = exactSelection.candidate.url;
|
|
2289
|
+
sessionResume = {
|
|
2290
|
+
sessionId: target.sessionId,
|
|
2291
|
+
status: 'matched',
|
|
2292
|
+
reason: 'No session id was supplied; reused the unique existing native browser tab with the exact target URL instead of opening a duplicate.',
|
|
2293
|
+
};
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
1226
2298
|
if (!page) {
|
|
1227
|
-
page = await createMacOsBrowserOwnedPage(
|
|
2299
|
+
page = await createMacOsBrowserOwnedPage(activeAttachment, target.url, timeout) as unknown as PageLike;
|
|
1228
2300
|
createdThisCallRef = (page as unknown as { tabRef?: () => MacOsBrowserTabRef | undefined }).tabRef?.();
|
|
2301
|
+
const liveIdentity = await settleNativeCreatedPageIdentity(
|
|
2302
|
+
page as unknown as {
|
|
2303
|
+
waitForLoadState: (state?: string, options?: Record<string, unknown>) => Promise<void>;
|
|
2304
|
+
identity: () => Promise<{ url: string; title: string }>;
|
|
2305
|
+
},
|
|
2306
|
+
waitUntil(args.wait_until),
|
|
2307
|
+
timeout,
|
|
2308
|
+
).catch(() => undefined);
|
|
2309
|
+
if (!liveIdentity?.url || isBlankPage(liveIdentity.url) || comparableUrl(liveIdentity.url) !== comparableUrl(target.url)) {
|
|
2310
|
+
page = await navigateNativePageWithReplacement(page);
|
|
2311
|
+
}
|
|
1229
2312
|
}
|
|
1230
|
-
|
|
2313
|
+
const explicitUserOwnedUrlMatch = effectiveOwnership === 'user_owned'
|
|
2314
|
+
&& savedProduct
|
|
2315
|
+
&& explicitNativeAdoptionUrlsMatch(target.url, page.url(), savedProduct);
|
|
2316
|
+
if (comparableUrl(page.url()) !== comparableUrl(target.url) && !explicitUserOwnedUrlMatch) {
|
|
1231
2317
|
const currentRef = (page as unknown as { tabRef?: () => MacOsBrowserTabRef | undefined }).tabRef?.();
|
|
2318
|
+
if (effectiveOwnership === 'user_owned') {
|
|
2319
|
+
if (matchedBy === 'recovered_tab') {
|
|
2320
|
+
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 };
|
|
2321
|
+
} else {
|
|
2322
|
+
throw new AssistantPluginError(
|
|
2323
|
+
'PLUGIN_BROWSER_ADOPTED_TAB_URL_MISMATCH',
|
|
2324
|
+
'Explicitly adopted user-owned tab no longer matches the saved session URL; refusing to navigate, replace, or close the user tab.',
|
|
2325
|
+
{ retryable: true, details: { sessionId: target.sessionId, expectedUrl: target.url, actualUrl: page.url(), windowId: savedTab?.windowId, tabId: savedTab?.tabId } },
|
|
2326
|
+
);
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
1232
2329
|
const sessionTargetUnchanged = Boolean(target.existingSession?.url) && comparableUrl(target.url) === comparableUrl(target.existingSession?.url ?? '');
|
|
1233
2330
|
const preserveOwnedSameOriginDrift = matchedBy === 'owned_token' && sessionTargetUnchanged && sameOrigin(page.url(), target.url);
|
|
1234
|
-
if (
|
|
2331
|
+
if (matchedBy === 'recovered_tab' && effectiveOwnership === 'user_owned') {
|
|
2332
|
+
// Safe recovery adopts an already-open tab. Its current page is authoritative and must not be navigated.
|
|
2333
|
+
} else if (preserveOwnedSameOriginDrift) {
|
|
1235
2334
|
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 };
|
|
1236
|
-
} else if (!currentRef) {
|
|
1237
|
-
await page.goto(target.url, { waitUntil: waitUntil(args.wait_until), timeout });
|
|
1238
2335
|
} else {
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
page = replacement;
|
|
1245
|
-
matchedBy = 'new_page';
|
|
1246
|
-
sessionResume = { sessionId: target.sessionId, status: 'matched', reason: 'Reattached to the saved session by replacing its previous plugin-owned background tab.', savedTab };
|
|
2336
|
+
page = await navigateNativePageWithReplacement(page);
|
|
2337
|
+
if (currentRef && !navigationUsedReplacement) {
|
|
2338
|
+
matchedBy = 'owned_token';
|
|
2339
|
+
sessionResume = { sessionId: target.sessionId, status: 'matched', reason: 'Navigated the existing plugin-owned tab in place and preserved its stable tab identity.', savedTab };
|
|
2340
|
+
}
|
|
1247
2341
|
}
|
|
1248
2342
|
}
|
|
1249
|
-
|
|
2343
|
+
const explicitInternalUserSession = Boolean(
|
|
2344
|
+
target.existingSession
|
|
2345
|
+
&& isExplicitNativeUserOwnedSession(target.existingSession)
|
|
2346
|
+
&& browserInternalUrlProduct(target.url) === savedProduct,
|
|
2347
|
+
);
|
|
2348
|
+
if (matchedBy !== 'owned_token' && page.waitForLoadState && !explicitInternalUserSession) {
|
|
1250
2349
|
await page.waitForLoadState(waitUntil(args.wait_until), { timeout });
|
|
1251
2350
|
}
|
|
1252
|
-
const
|
|
2351
|
+
const observedPageUrl = matchedBy === 'recovered_tab' && recoveredTabUrl ? recoveredTabUrl : page.url();
|
|
2352
|
+
const pageUrl = explicitInternalUserSession
|
|
2353
|
+
? normalizedExplicitNativeAdoptionUrl(observedPageUrl)
|
|
2354
|
+
: normalizedUrl(observedPageUrl);
|
|
1253
2355
|
const nativeRef = (page as unknown as { tabRef?: () => MacOsBrowserTabRef | undefined }).tabRef?.();
|
|
1254
2356
|
if (!nativeRef) throw new AssistantPluginError('PLUGIN_BROWSER_NATIVE_OWNERSHIP_MISSING', 'Native browser did not return a stable plugin-owned tab reference.', { retryable: true });
|
|
1255
|
-
const title = matchedBy === 'owned_token' ? (target.existingSession?.title ?? '') : '';
|
|
2357
|
+
const title = matchedBy === 'owned_token' ? (target.existingSession?.title ?? '') : matchedBy === 'recovered_tab' ? recoveredTabTitle : '';
|
|
1256
2358
|
const inventory: BrowserTabInventoryEntry[] = [{ index: 0, key: tabKey(pageUrl, title), url: pageUrl, title }];
|
|
1257
|
-
const metadata =
|
|
2359
|
+
const metadata = activeAttachment.metadata;
|
|
2360
|
+
// The live exact-tab URL is authoritative after a safe native rebind. Keep the
|
|
2361
|
+
// action-local target synchronized so read/query responses and the persisted
|
|
2362
|
+
// session report the same observed URL in this round.
|
|
2363
|
+
target.url = pageUrl;
|
|
1258
2364
|
const connection = refreshConnectionTab({
|
|
1259
2365
|
requestedMode: 'attach_preferred',
|
|
1260
2366
|
mode: 'attach_preferred',
|
|
1261
2367
|
provider: 'macos-apple-events',
|
|
1262
2368
|
attached: true,
|
|
1263
2369
|
browserProduct: metadata.product,
|
|
2370
|
+
nativeBrowserCandidates: config.nativeBrowserCandidates,
|
|
1264
2371
|
profile: {
|
|
1265
2372
|
profileMode: config.profileMode,
|
|
1266
2373
|
profileDirectory: config.profileDirectory,
|
|
@@ -1269,15 +2376,16 @@ async function openNativeAttachedContext(
|
|
|
1269
2376
|
tabInventory: inventory,
|
|
1270
2377
|
sessionResume,
|
|
1271
2378
|
}, pageUrl, title, matchedBy, {
|
|
1272
|
-
ownership: '
|
|
2379
|
+
ownership: effectiveOwnership === 'user_owned' ? 'user_owned' : 'plugin_owned',
|
|
2380
|
+
windowId: nativeRef.windowId,
|
|
2381
|
+
tabId: nativeRef.tabId,
|
|
1273
2382
|
});
|
|
1274
2383
|
const diagnostics: PageDiagnostics = { consoleErrors: [], failedRequests: [], navigation: { url: pageUrl } };
|
|
1275
|
-
if (replacedRef) await closeMacOsBrowserOwnedTab(metadata.product, replacedRef, timeout);
|
|
1276
2384
|
createdThisCallRef = undefined;
|
|
1277
2385
|
return { attempts: discovered.attempts, handle: { page, diagnostics, connection, close: async () => undefined } };
|
|
1278
2386
|
} catch (error) {
|
|
1279
2387
|
if (createdThisCallRef) {
|
|
1280
|
-
await closeMacOsBrowserOwnedTab(
|
|
2388
|
+
await closeMacOsBrowserOwnedTab(activeAttachment.metadata.product, createdThisCallRef, timeout).catch(() => undefined);
|
|
1281
2389
|
}
|
|
1282
2390
|
throw error;
|
|
1283
2391
|
}
|
|
@@ -1291,15 +2399,43 @@ async function openBrowser(
|
|
|
1291
2399
|
): Promise<BrowserOpenHandle> {
|
|
1292
2400
|
const requestedMode = config.browserMode ?? 'managed_persistent';
|
|
1293
2401
|
if (requestedMode === 'attach_preferred') {
|
|
1294
|
-
const
|
|
1295
|
-
|
|
2402
|
+
const requireExistingResource = args.__forge_require_existing_resource === true;
|
|
2403
|
+
const existingProvider = target.existingSession?.browser?.provider;
|
|
2404
|
+
const attached = cdpEndpoints(config).length > 0 && runtimeHooks.moduleAvailable('playwright', repoRoot)
|
|
2405
|
+
? await openAttachedContext(runtimeHooks.loadPlaywright(repoRoot), repoRoot, config, target, args)
|
|
1296
2406
|
: { attempts: [] as CdpAttachAttempt[] };
|
|
1297
2407
|
if (attached.handle) return attached.handle;
|
|
2408
|
+
if (requireExistingResource && existingProvider === 'playwright-cdp') {
|
|
2409
|
+
throw new AssistantPluginError(
|
|
2410
|
+
'PLUGIN_BROWSER_SESSION_STATE_LOST',
|
|
2411
|
+
'Saved CDP browser page is no longer attachable; refusing to fall back to another browser or create a replacement page.',
|
|
2412
|
+
{ retryable: false, details: { sessionId: target.sessionId, provider: existingProvider } },
|
|
2413
|
+
);
|
|
2414
|
+
}
|
|
2415
|
+
if (requireExistingResource && existingProvider === 'playwright-persistent-context') {
|
|
2416
|
+
if (!runtimeHooks.moduleAvailable('playwright', repoRoot)) {
|
|
2417
|
+
throw new AssistantPluginError('PLUGIN_BROWSER_DEPENDENCY_UNAVAILABLE', 'Managed browser mode requires Playwright.', { retryable: false });
|
|
2418
|
+
}
|
|
2419
|
+
return openManagedContext(runtimeHooks.loadPlaywright(repoRoot), repoRoot, config, target, args, 'managed_persistent', requestedMode, {
|
|
2420
|
+
policy: config.cdpAttachFallback ?? 'fail_closed',
|
|
2421
|
+
from: 'attach_preferred',
|
|
2422
|
+
to: 'managed_persistent',
|
|
2423
|
+
reason: 'Reusing the existing managed fallback session without probing or creating a different browser resource.',
|
|
2424
|
+
attempts: attached.attempts,
|
|
2425
|
+
});
|
|
2426
|
+
}
|
|
1298
2427
|
|
|
1299
2428
|
const native = await openNativeAttachedContext(repoRoot, config, target, args);
|
|
1300
2429
|
if (native.handle) return native.handle;
|
|
2430
|
+
if (requireExistingResource) {
|
|
2431
|
+
throw new AssistantPluginError(
|
|
2432
|
+
'PLUGIN_BROWSER_SESSION_STATE_LOST',
|
|
2433
|
+
'Saved browser resource is no longer attachable; refusing to create a replacement tab/page for this existing-session action.',
|
|
2434
|
+
{ retryable: false, details: { sessionId: target.sessionId, provider: existingProvider } },
|
|
2435
|
+
);
|
|
2436
|
+
}
|
|
1301
2437
|
|
|
1302
|
-
const fallbackPolicy = config.cdpAttachFallback ?? '
|
|
2438
|
+
const fallbackPolicy = config.cdpAttachFallback ?? 'fail_closed';
|
|
1303
2439
|
const cdpReason = attached.attempts.map((attempt) => `${attempt.endpoint}: ${attempt.error ?? 'unavailable'}`).join('; ')
|
|
1304
2440
|
|| 'No CDP endpoint candidates were configured.';
|
|
1305
2441
|
const nativeReason = native.attempts.map((attempt) => `${attempt.appName}: ${attempt.error ?? attempt.status}`).join('; ')
|
|
@@ -1316,10 +2452,10 @@ async function openBrowser(
|
|
|
1316
2452
|
},
|
|
1317
2453
|
});
|
|
1318
2454
|
}
|
|
1319
|
-
if (!runtimeHooks.moduleAvailable('playwright')) {
|
|
2455
|
+
if (!runtimeHooks.moduleAvailable('playwright', repoRoot)) {
|
|
1320
2456
|
throw new AssistantPluginError('PLUGIN_BROWSER_DEPENDENCY_UNAVAILABLE', `Managed browser fallback requires Playwright, but native/CDP attach was unavailable. ${reason}`, { retryable: false });
|
|
1321
2457
|
}
|
|
1322
|
-
return openManagedContext(runtimeHooks.loadPlaywright(), repoRoot, config, target, args, 'managed_persistent', requestedMode, {
|
|
2458
|
+
return openManagedContext(runtimeHooks.loadPlaywright(repoRoot), repoRoot, config, target, args, 'managed_persistent', requestedMode, {
|
|
1323
2459
|
policy: fallbackPolicy,
|
|
1324
2460
|
from: 'attach_preferred',
|
|
1325
2461
|
to: 'managed_persistent',
|
|
@@ -1329,37 +2465,82 @@ async function openBrowser(
|
|
|
1329
2465
|
});
|
|
1330
2466
|
}
|
|
1331
2467
|
|
|
1332
|
-
if (!runtimeHooks.moduleAvailable('playwright')) {
|
|
2468
|
+
if (!runtimeHooks.moduleAvailable('playwright', repoRoot)) {
|
|
1333
2469
|
throw new AssistantPluginError('PLUGIN_BROWSER_DEPENDENCY_UNAVAILABLE', 'Managed browser mode requires Playwright.', { retryable: false });
|
|
1334
2470
|
}
|
|
1335
|
-
return openManagedContext(runtimeHooks.loadPlaywright(), repoRoot, config, target, args, requestedMode, requestedMode);
|
|
2471
|
+
return openManagedContext(runtimeHooks.loadPlaywright(repoRoot), repoRoot, config, target, args, requestedMode, requestedMode);
|
|
1336
2472
|
}
|
|
1337
2473
|
|
|
1338
2474
|
async function withPage<T>(
|
|
2475
|
+
actionId: string,
|
|
1339
2476
|
repoRoot: string,
|
|
1340
2477
|
config: BrowserPluginConfig,
|
|
1341
2478
|
target: BrowserActionTarget,
|
|
1342
2479
|
args: Record<string, unknown>,
|
|
1343
2480
|
run: (page: PageLike, diagnostics: PageDiagnostics, connection: BrowserConnectionSummary) => Promise<T>,
|
|
1344
|
-
options: { persistSession?: boolean } = {},
|
|
2481
|
+
options: { persistSession?: boolean; requireExistingResource?: boolean; pruneStaleSessionMetadata?: boolean } = {},
|
|
1345
2482
|
): Promise<T> {
|
|
1346
2483
|
const retries = Math.min(Math.max(positiveNumber(args.retries, 1), 1), 3);
|
|
2484
|
+
const requireExistingResource = options.requireExistingResource ?? Boolean(target.existingSession);
|
|
2485
|
+
const postDispatchReplaySafe = canReplayBrowserActionAfterDispatch(actionId, requireExistingResource);
|
|
1347
2486
|
let lastError: unknown;
|
|
1348
2487
|
for (let attempt = 1; attempt <= retries; attempt += 1) {
|
|
1349
2488
|
let handle: BrowserOpenHandle | undefined;
|
|
2489
|
+
// Opening a browser resource may itself create or navigate a tab before a
|
|
2490
|
+
// handle is returned. Replay is safe only for an observation action that is
|
|
2491
|
+
// fenced to an already-existing resource. Anything else is potentially
|
|
2492
|
+
// externally mutating from the outset.
|
|
2493
|
+
let actionDispatched = !postDispatchReplaySafe;
|
|
1350
2494
|
try {
|
|
1351
|
-
|
|
2495
|
+
if (target.existingSession) assertBrowserSessionAvailable(repoRoot, target.sessionId);
|
|
2496
|
+
const browserArgs = requireExistingResource
|
|
2497
|
+
? { ...args, __forge_require_existing_resource: true }
|
|
2498
|
+
: args;
|
|
2499
|
+
handle = await openBrowser(repoRoot, config, target, browserArgs);
|
|
2500
|
+
actionDispatched = true;
|
|
1352
2501
|
const result = await run(handle.page, handle.diagnostics, handle.connection);
|
|
1353
2502
|
if (options.persistSession) {
|
|
1354
|
-
const identity = await readPageIdentity(handle.page, handle.connection);
|
|
1355
|
-
|
|
2503
|
+
const identity = await readPageIdentity(handle.page, handle.connection, target.url);
|
|
2504
|
+
saveBrowserSession(repoRoot, sessionFromPage(target, identity.url, identity.title, handle.connection));
|
|
2505
|
+
if (result && typeof result === 'object' && !Array.isArray(result) && Object.prototype.hasOwnProperty.call(result, 'url')) {
|
|
2506
|
+
(result as Record<string, unknown>).url = identity.url;
|
|
2507
|
+
}
|
|
1356
2508
|
}
|
|
1357
2509
|
return result;
|
|
1358
2510
|
} catch (error) {
|
|
1359
2511
|
lastError = error;
|
|
2512
|
+
const nativeTab = handle?.connection.provider === 'macos-apple-events' ? handle.connection.tab : undefined;
|
|
2513
|
+
if (handle?.connection.provider === 'macos-apple-events'
|
|
2514
|
+
&& handle.connection.browserProduct
|
|
2515
|
+
&& nativeTab?.windowId
|
|
2516
|
+
&& nativeTab.tabId
|
|
2517
|
+
&& macOsBrowserPageHandleStale(error)) {
|
|
2518
|
+
// Do not replay the current action. Evict only so the next independent action must
|
|
2519
|
+
// reattach/recover through the existing cold-path identity checks.
|
|
2520
|
+
invalidateMacOsBrowserPageHandle(handle.connection.browserProduct, {
|
|
2521
|
+
windowId: nativeTab.windowId,
|
|
2522
|
+
tabId: nativeTab.tabId,
|
|
2523
|
+
});
|
|
2524
|
+
}
|
|
2525
|
+
if (options.pruneStaleSessionMetadata
|
|
2526
|
+
&& target.existingSession
|
|
2527
|
+
&& error instanceof AssistantPluginError
|
|
2528
|
+
&& error.code === 'PLUGIN_BROWSER_SESSION_STATE_LOST') {
|
|
2529
|
+
removeBrowserSession(repoRoot, target.sessionId);
|
|
2530
|
+
}
|
|
1360
2531
|
const message = error instanceof Error ? error.message : String(error);
|
|
1361
|
-
const transient =
|
|
1362
|
-
|
|
2532
|
+
const transient = error instanceof AssistantPluginError
|
|
2533
|
+
? error.retryable
|
|
2534
|
+
: /timeout|timed out|net::|ERR_|Navigation failed|Target closed|Protocol error/i.test(message);
|
|
2535
|
+
if (!transient) throw error;
|
|
2536
|
+
if (actionDispatched && !postDispatchReplaySafe) {
|
|
2537
|
+
throw new AssistantPluginError(
|
|
2538
|
+
'PLUGIN_BROWSER_MUTATION_OUTCOME_UNKNOWN',
|
|
2539
|
+
`Browser action ${actionId} reported a transient failure after dispatch. Forge refused automatic replay because the external mutation outcome is unknown; verify current browser state before retrying.`,
|
|
2540
|
+
{ retryable: false, details: { actionId, requestedRetries: retries } },
|
|
2541
|
+
);
|
|
2542
|
+
}
|
|
2543
|
+
if (attempt >= retries) throw error;
|
|
1363
2544
|
} finally {
|
|
1364
2545
|
if (handle) await handle.close();
|
|
1365
2546
|
}
|
|
@@ -1368,17 +2549,112 @@ async function withPage<T>(
|
|
|
1368
2549
|
}
|
|
1369
2550
|
|
|
1370
2551
|
function listSavedSessions(repoRoot: string): BrowserSessionState[] {
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
}
|
|
1380
|
-
|
|
2552
|
+
return listSavedBrowserSessions(repoRoot)
|
|
2553
|
+
.filter((entry) => Boolean(entry.sessionId && entry.url))
|
|
2554
|
+
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
async function inspectSavedSessions(
|
|
2558
|
+
repoRoot: string,
|
|
2559
|
+
config: BrowserPluginConfig,
|
|
2560
|
+
options: { pruneDead?: boolean } = {},
|
|
2561
|
+
): Promise<BrowserSessionInventory> {
|
|
2562
|
+
const saved = listSavedSessions(repoRoot);
|
|
2563
|
+
const sessions: BrowserSessionInventoryItem[] = [];
|
|
2564
|
+
let liveManagedSessionCount = 0;
|
|
2565
|
+
let unverifiedSessionCount = 0;
|
|
2566
|
+
let deadManagedSessionCount = 0;
|
|
2567
|
+
let prunedSessionCount = 0;
|
|
2568
|
+
const native = await inspectNativeOwnedSessions({ repoRoot, savedSessions: saved, timeoutMs: config.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS, pruneDead: options.pruneDead });
|
|
2569
|
+
let liveNativeSessionCount = native.liveCount;
|
|
2570
|
+
let deadNativeSessionCount = native.deadCount;
|
|
2571
|
+
let closedInvalidNativeSessionCount = native.closedInvalidCount;
|
|
2572
|
+
let failedNativeCleanupCount = native.failedCleanupCount;
|
|
2573
|
+
prunedSessionCount += native.prunedCount;
|
|
2574
|
+
unverifiedSessionCount += native.unverifiedCount;
|
|
2575
|
+
|
|
2576
|
+
for (const session of saved) {
|
|
2577
|
+
const nativeItem = native.items.get(session.sessionId);
|
|
2578
|
+
if (nativeItem) {
|
|
2579
|
+
sessions.push(nativeItem);
|
|
2580
|
+
continue;
|
|
2581
|
+
}
|
|
2582
|
+
if (session.browser?.provider !== 'playwright-persistent-context' || session.browser.activeMode !== 'managed_persistent') {
|
|
2583
|
+
sessions.push({ session, liveness: 'unverified', evidence: 'provider_unverified' });
|
|
2584
|
+
unverifiedSessionCount += 1;
|
|
2585
|
+
continue;
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
const profile = selectedProfile(config, repoRoot, 'managed_persistent', session.sessionId);
|
|
2589
|
+
const pending = managedBrowserContexts.get(managedContextKey(profile));
|
|
2590
|
+
if (!pending) {
|
|
2591
|
+
sessions.push({ session, liveness: 'unverified', evidence: 'runtime_context_unavailable' });
|
|
2592
|
+
unverifiedSessionCount += 1;
|
|
2593
|
+
continue;
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2596
|
+
let state: ManagedBrowserContextState;
|
|
2597
|
+
try {
|
|
2598
|
+
state = await pending;
|
|
2599
|
+
} catch {
|
|
2600
|
+
sessions.push({ session, liveness: 'unverified', evidence: 'runtime_context_unavailable' });
|
|
2601
|
+
unverifiedSessionCount += 1;
|
|
2602
|
+
continue;
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
const pages = state.context.pages();
|
|
2606
|
+
const boundPage = state.pagesBySession.get(session.sessionId);
|
|
2607
|
+
if (boundPage) {
|
|
2608
|
+
if (pages.includes(boundPage)) {
|
|
2609
|
+
sessions.push({ session, liveness: 'live', evidence: 'runtime_session_binding' });
|
|
2610
|
+
liveManagedSessionCount += 1;
|
|
2611
|
+
continue;
|
|
2612
|
+
}
|
|
2613
|
+
|
|
2614
|
+
const pruned = options.pruneDead === true;
|
|
2615
|
+
if (pruned) {
|
|
2616
|
+
state.pagesBySession.delete(session.sessionId);
|
|
2617
|
+
removeBrowserSession(repoRoot, session.sessionId);
|
|
2618
|
+
prunedSessionCount += 1;
|
|
2619
|
+
}
|
|
2620
|
+
sessions.push({ session, liveness: 'dead', evidence: 'runtime_bound_page_missing', pruned });
|
|
2621
|
+
deadManagedSessionCount += 1;
|
|
2622
|
+
continue;
|
|
2623
|
+
}
|
|
2624
|
+
|
|
2625
|
+
const expectedOwnerToken = session.browser.tab?.ownership === 'plugin_owned' ? session.browser.tab.ownerToken : undefined;
|
|
2626
|
+
if (expectedOwnerToken) {
|
|
2627
|
+
const inventory = await inventoryTabs(state.context).catch(() => [] as BrowserTabInventoryEntry[]);
|
|
2628
|
+
const index = inventory.findIndex((entry) => entry.ownerToken === expectedOwnerToken);
|
|
2629
|
+
const page = index >= 0 ? state.context.pages()[index] : undefined;
|
|
2630
|
+
if (page) {
|
|
2631
|
+
state.pagesBySession.set(session.sessionId, page);
|
|
2632
|
+
sessions.push({ session, liveness: 'live', evidence: 'runtime_owner_token' });
|
|
2633
|
+
liveManagedSessionCount += 1;
|
|
2634
|
+
continue;
|
|
2635
|
+
}
|
|
2636
|
+
sessions.push({ session, liveness: 'unverified', evidence: 'runtime_owner_token_not_observed' });
|
|
2637
|
+
unverifiedSessionCount += 1;
|
|
2638
|
+
continue;
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
sessions.push({ session, liveness: 'unverified', evidence: 'provider_unverified' });
|
|
2642
|
+
unverifiedSessionCount += 1;
|
|
1381
2643
|
}
|
|
2644
|
+
|
|
2645
|
+
return {
|
|
2646
|
+
sessions,
|
|
2647
|
+
scannedSessionCount: saved.length,
|
|
2648
|
+
savedSessionCount: saved.length - prunedSessionCount,
|
|
2649
|
+
liveManagedSessionCount,
|
|
2650
|
+
liveNativeSessionCount,
|
|
2651
|
+
unverifiedSessionCount,
|
|
2652
|
+
deadManagedSessionCount,
|
|
2653
|
+
deadNativeSessionCount,
|
|
2654
|
+
closedInvalidNativeSessionCount,
|
|
2655
|
+
failedNativeCleanupCount,
|
|
2656
|
+
prunedSessionCount,
|
|
2657
|
+
};
|
|
1382
2658
|
}
|
|
1383
2659
|
|
|
1384
2660
|
const STABLE_SELECTOR_HELPERS = `
|
|
@@ -1459,7 +2735,7 @@ const STABLE_SELECTOR_HELPERS = `
|
|
|
1459
2735
|
const EXTRACTION_SCRIPTS = {
|
|
1460
2736
|
query: (selector: string, limit: number) => `(() => {
|
|
1461
2737
|
${STABLE_SELECTOR_HELPERS}
|
|
1462
|
-
const nodes =
|
|
2738
|
+
const nodes = ${openShadowSelectorExpression(selector, true)}.slice(0, ${limit});
|
|
1463
2739
|
return nodes.map((el) => {
|
|
1464
2740
|
const role = el.getAttribute('role');
|
|
1465
2741
|
const text = (el.innerText || el.textContent || '').trim().slice(0, 120);
|
|
@@ -1467,11 +2743,11 @@ const EXTRACTION_SCRIPTS = {
|
|
|
1467
2743
|
});
|
|
1468
2744
|
})()`,
|
|
1469
2745
|
attribute: (selector: string, attribute: string) => `(() => {
|
|
1470
|
-
const el =
|
|
2746
|
+
const el = ${openShadowSelectorExpression(selector)};
|
|
1471
2747
|
return el ? el.getAttribute(${JSON.stringify(attribute)}) : null;
|
|
1472
2748
|
})()`,
|
|
1473
2749
|
html: (selector?: string) => selector
|
|
1474
|
-
? `(() => { const el =
|
|
2750
|
+
? `(() => { const el = ${openShadowSelectorExpression(selector)}; return el ? el.outerHTML : ''; })()`
|
|
1475
2751
|
: 'document.documentElement ? document.documentElement.outerHTML : ""',
|
|
1476
2752
|
links: (limit: number) => `(() => {
|
|
1477
2753
|
${STABLE_SELECTOR_HELPERS}
|
|
@@ -1501,10 +2777,44 @@ const EXTRACTION_SCRIPTS = {
|
|
|
1501
2777
|
})()`,
|
|
1502
2778
|
interactive: (limit: number) => `(() => {
|
|
1503
2779
|
${STABLE_SELECTOR_HELPERS}
|
|
1504
|
-
|
|
2780
|
+
const viewport = {
|
|
2781
|
+
width: Number(window.innerWidth || document.documentElement?.clientWidth || 0),
|
|
2782
|
+
height: Number(window.innerHeight || document.documentElement?.clientHeight || 0),
|
|
2783
|
+
scrollX: Number(window.scrollX || window.pageXOffset || 0),
|
|
2784
|
+
scrollY: Number(window.scrollY || window.pageYOffset || 0),
|
|
2785
|
+
devicePixelRatio: Number(window.devicePixelRatio || 1),
|
|
2786
|
+
};
|
|
2787
|
+
const elements = Array.from(document.querySelectorAll('a,button,input,select,textarea,[contenteditable="true"],[role="button"],[role="link"],[role="textbox"]')).slice(0, ${limit}).map((el) => {
|
|
1505
2788
|
const text = (el.innerText || el.getAttribute('aria-label') || el.getAttribute('value') || el.textContent || '').trim().slice(0, 100);
|
|
1506
|
-
|
|
2789
|
+
const rect = el.getBoundingClientRect();
|
|
2790
|
+
const style = typeof getComputedStyle === 'function' ? getComputedStyle(el) : undefined;
|
|
2791
|
+
const width = Number(rect.width || 0);
|
|
2792
|
+
const height = Number(rect.height || 0);
|
|
2793
|
+
const left = Number(rect.left || 0);
|
|
2794
|
+
const top = Number(rect.top || 0);
|
|
2795
|
+
const right = Number(rect.right || (left + width));
|
|
2796
|
+
const bottom = Number(rect.bottom || (top + height));
|
|
2797
|
+
const visible = width > 0 && height > 0 && style?.display !== 'none' && style?.visibility !== 'hidden' && Number(style?.opacity ?? 1) !== 0;
|
|
2798
|
+
const inViewport = visible && right > 0 && bottom > 0 && left < viewport.width && top < viewport.height;
|
|
2799
|
+
const disabled = Boolean(el.disabled || el.getAttribute('aria-disabled') === 'true');
|
|
2800
|
+
const editable = !disabled && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable || el.getAttribute('role') === 'textbox');
|
|
2801
|
+
return {
|
|
2802
|
+
tag: el.tagName.toLowerCase(),
|
|
2803
|
+
text,
|
|
2804
|
+
type: el.getAttribute('type') || undefined,
|
|
2805
|
+
href: el.getAttribute('href') || undefined,
|
|
2806
|
+
role: el.getAttribute('role') || undefined,
|
|
2807
|
+
ariaLabel: el.getAttribute('aria-label') || undefined,
|
|
2808
|
+
selectorHint: forgeSelectorHint(el),
|
|
2809
|
+
bounds: { x: left, y: top, width, height, right, bottom },
|
|
2810
|
+
center: { x: left + width / 2, y: top + height / 2 },
|
|
2811
|
+
visible,
|
|
2812
|
+
inViewport,
|
|
2813
|
+
disabled,
|
|
2814
|
+
editable,
|
|
2815
|
+
};
|
|
1507
2816
|
});
|
|
2817
|
+
return { geometryVersion: 1, viewport, elements };
|
|
1508
2818
|
})()`,
|
|
1509
2819
|
};
|
|
1510
2820
|
|
|
@@ -1518,17 +2828,229 @@ function selectorRepairHint(selector: string, errorMessage: string): string {
|
|
|
1518
2828
|
return `Check selector "${selector}" against the current page structure.`;
|
|
1519
2829
|
}
|
|
1520
2830
|
|
|
1521
|
-
|
|
1522
|
-
|
|
2831
|
+
type BrowserEvaluationScope = Pick<PageLike, 'evaluate'>;
|
|
2832
|
+
|
|
2833
|
+
type BrowserFrameSelection = {
|
|
2834
|
+
scope: BrowserEvaluationScope;
|
|
2835
|
+
frame?: { url: string; name: string };
|
|
2836
|
+
frameHandle?: FrameLike;
|
|
2837
|
+
};
|
|
2838
|
+
|
|
2839
|
+
function requestedFrameSelection(args: Record<string, unknown>): { frameUrl?: string; frameName?: string } {
|
|
2840
|
+
return { frameUrl: stringValue(args.frame_url), frameName: stringValue(args.frame_name) };
|
|
2841
|
+
}
|
|
2842
|
+
|
|
2843
|
+
function resolveBrowserEvaluationScope(
|
|
2844
|
+
page: PageLike,
|
|
2845
|
+
args: Record<string, unknown>,
|
|
2846
|
+
connection: BrowserConnectionSummary,
|
|
2847
|
+
): BrowserFrameSelection {
|
|
2848
|
+
const { frameUrl, frameName } = requestedFrameSelection(args);
|
|
2849
|
+
if (!frameUrl && !frameName) return { scope: page };
|
|
2850
|
+
if (connection.provider === 'macos-apple-events' || !page.frames) {
|
|
2851
|
+
throw new AssistantPluginError(
|
|
2852
|
+
'PLUGIN_BROWSER_FRAME_SCOPE_UNAVAILABLE',
|
|
2853
|
+
'Explicit frame targeting requires a Playwright/CDP-controlled page; native Apple Events sessions fail closed.',
|
|
2854
|
+
{ retryable: false, details: { provider: connection.provider, frameUrl, frameName } },
|
|
2855
|
+
);
|
|
2856
|
+
}
|
|
2857
|
+
const matches = page.frames().filter((frame) => (!frameUrl || frame.url() === frameUrl) && (!frameName || frame.name() === frameName));
|
|
2858
|
+
if (matches.length === 0) {
|
|
2859
|
+
throw new AssistantPluginError('PLUGIN_BROWSER_FRAME_NOT_FOUND', 'No browser frame matched the requested exact frame identity.', {
|
|
2860
|
+
retryable: true,
|
|
2861
|
+
details: { frameUrl, frameName },
|
|
2862
|
+
});
|
|
2863
|
+
}
|
|
2864
|
+
if (matches.length > 1) {
|
|
2865
|
+
throw new AssistantPluginError('PLUGIN_BROWSER_FRAME_AMBIGUOUS', 'Multiple browser frames matched the requested frame identity; specify both frame_url and frame_name.', {
|
|
2866
|
+
retryable: false,
|
|
2867
|
+
details: { frameUrl, frameName, matchCount: matches.length },
|
|
2868
|
+
});
|
|
2869
|
+
}
|
|
2870
|
+
const frame = matches[0]!;
|
|
2871
|
+
return { scope: frame, frame: { url: frame.url(), name: frame.name() }, frameHandle: frame };
|
|
2872
|
+
}
|
|
2873
|
+
|
|
2874
|
+
const VIEWPORT_METRICS_SCRIPT = `(() => ({
|
|
2875
|
+
viewportMetricsVersion: 1,
|
|
2876
|
+
viewport: {
|
|
2877
|
+
width: Number(window.innerWidth || document.documentElement?.clientWidth || 0),
|
|
2878
|
+
height: Number(window.innerHeight || document.documentElement?.clientHeight || 0),
|
|
2879
|
+
scrollX: Number(window.scrollX || window.pageXOffset || 0),
|
|
2880
|
+
scrollY: Number(window.scrollY || window.pageYOffset || 0),
|
|
2881
|
+
devicePixelRatio: Number(window.devicePixelRatio || 1),
|
|
2882
|
+
}
|
|
2883
|
+
}))()`;
|
|
2884
|
+
|
|
2885
|
+
async function resolveFrameViewportOffset(page: PageLike, selection: BrowserFrameSelection): Promise<{ x: number; y: number; width: number; height: number } | undefined> {
|
|
2886
|
+
const frame = selection.frameHandle;
|
|
2887
|
+
if (!frame) return undefined;
|
|
2888
|
+
if (page.mainFrame && page.mainFrame() === frame) return { x: 0, y: 0, width: 0, height: 0 };
|
|
2889
|
+
if (!frame.frameElement) {
|
|
2890
|
+
throw new AssistantPluginError('PLUGIN_BROWSER_FRAME_GEOMETRY_UNAVAILABLE', 'The selected Playwright frame does not expose a frame element for coordinate grounding.', {
|
|
2891
|
+
retryable: false,
|
|
2892
|
+
details: selection.frame,
|
|
2893
|
+
});
|
|
2894
|
+
}
|
|
2895
|
+
let box: { x: number; y: number; width: number; height: number } | null;
|
|
2896
|
+
try {
|
|
2897
|
+
box = await (await frame.frameElement()).boundingBox();
|
|
2898
|
+
} catch (error) {
|
|
2899
|
+
throw new AssistantPluginError('PLUGIN_BROWSER_FRAME_GEOMETRY_UNAVAILABLE', 'Could not resolve the selected frame element bounding box.', {
|
|
2900
|
+
retryable: true,
|
|
2901
|
+
details: { ...selection.frame, cause: error instanceof Error ? error.message : String(error) },
|
|
2902
|
+
});
|
|
2903
|
+
}
|
|
2904
|
+
if (!box || ![box.x, box.y, box.width, box.height].every((value) => Number.isFinite(value))) {
|
|
2905
|
+
throw new AssistantPluginError('PLUGIN_BROWSER_FRAME_GEOMETRY_UNAVAILABLE', 'The selected frame element has no stable visible bounding box; refusing to guess trusted-input coordinates.', {
|
|
2906
|
+
retryable: true,
|
|
2907
|
+
details: selection.frame,
|
|
2908
|
+
});
|
|
2909
|
+
}
|
|
2910
|
+
return box;
|
|
2911
|
+
}
|
|
2912
|
+
|
|
2913
|
+
function translateFrameGroundingElements(elements: unknown[], offset: { x: number; y: number }, viewport: unknown): unknown[] {
|
|
2914
|
+
const viewportRecord = viewport && typeof viewport === 'object' && !Array.isArray(viewport)
|
|
2915
|
+
? viewport as Record<string, unknown>
|
|
2916
|
+
: {};
|
|
2917
|
+
const viewportWidth = typeof viewportRecord.width === 'number' && Number.isFinite(viewportRecord.width) ? viewportRecord.width : undefined;
|
|
2918
|
+
const viewportHeight = typeof viewportRecord.height === 'number' && Number.isFinite(viewportRecord.height) ? viewportRecord.height : undefined;
|
|
2919
|
+
if (viewportWidth === undefined || viewportHeight === undefined) {
|
|
2920
|
+
throw new AssistantPluginError('PLUGIN_BROWSER_FRAME_GEOMETRY_UNAVAILABLE', 'Top-level viewport dimensions are not finite for frame grounding.', { retryable: true });
|
|
2921
|
+
}
|
|
2922
|
+
return elements.map((entry) => {
|
|
2923
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return entry;
|
|
2924
|
+
const record = entry as Record<string, unknown>;
|
|
2925
|
+
const bounds = record.bounds && typeof record.bounds === 'object' && !Array.isArray(record.bounds)
|
|
2926
|
+
? record.bounds as Record<string, unknown>
|
|
2927
|
+
: {};
|
|
2928
|
+
const center = record.center && typeof record.center === 'object' && !Array.isArray(record.center)
|
|
2929
|
+
? record.center as Record<string, unknown>
|
|
2930
|
+
: {};
|
|
2931
|
+
const required = (value: unknown, field: string): number => {
|
|
2932
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
2933
|
+
throw new AssistantPluginError('PLUGIN_BROWSER_FRAME_GEOMETRY_UNAVAILABLE', `Frame grounding field ${field} is not finite.`, { retryable: true });
|
|
2934
|
+
}
|
|
2935
|
+
return value;
|
|
2936
|
+
};
|
|
2937
|
+
const x = required(bounds.x, 'bounds.x');
|
|
2938
|
+
const y = required(bounds.y, 'bounds.y');
|
|
2939
|
+
const width = required(bounds.width, 'bounds.width');
|
|
2940
|
+
const height = required(bounds.height, 'bounds.height');
|
|
2941
|
+
const right = required(bounds.right, 'bounds.right');
|
|
2942
|
+
const bottom = required(bounds.bottom, 'bounds.bottom');
|
|
2943
|
+
const centerX = required(center.x, 'center.x');
|
|
2944
|
+
const centerY = required(center.y, 'center.y');
|
|
2945
|
+
const translatedX = x + offset.x;
|
|
2946
|
+
const translatedY = y + offset.y;
|
|
2947
|
+
const translatedRight = right + offset.x;
|
|
2948
|
+
const translatedBottom = bottom + offset.y;
|
|
2949
|
+
const frameLocalInViewport = record.inViewport === true;
|
|
2950
|
+
const topLevelInViewport = translatedRight > 0
|
|
2951
|
+
&& translatedBottom > 0
|
|
2952
|
+
&& translatedX < viewportWidth
|
|
2953
|
+
&& translatedY < viewportHeight;
|
|
2954
|
+
return {
|
|
2955
|
+
...record,
|
|
2956
|
+
bounds: {
|
|
2957
|
+
x: translatedX,
|
|
2958
|
+
y: translatedY,
|
|
2959
|
+
width,
|
|
2960
|
+
height,
|
|
2961
|
+
right: translatedRight,
|
|
2962
|
+
bottom: translatedBottom,
|
|
2963
|
+
},
|
|
2964
|
+
center: { x: centerX + offset.x, y: centerY + offset.y },
|
|
2965
|
+
frameLocalInViewport,
|
|
2966
|
+
topLevelInViewport,
|
|
2967
|
+
inViewport: frameLocalInViewport && topLevelInViewport,
|
|
2968
|
+
};
|
|
2969
|
+
});
|
|
2970
|
+
}
|
|
2971
|
+
|
|
2972
|
+
async function extractText(scope: BrowserEvaluationScope, selector: string | undefined, maxChars: number): Promise<Record<string, unknown>> {
|
|
2973
|
+
const raw = await scope.evaluate<string>(scriptText(selector));
|
|
1523
2974
|
return truncateText(raw, maxChars);
|
|
1524
2975
|
}
|
|
1525
2976
|
|
|
1526
|
-
|
|
2977
|
+
function trustedInputGuardScript(selector: string): string {
|
|
2978
|
+
const encodedSelector = JSON.stringify(selector);
|
|
2979
|
+
return `(() => {
|
|
2980
|
+
let element;
|
|
2981
|
+
try { element = document.querySelector(${encodedSelector}); }
|
|
2982
|
+
catch (error) { return { trustedInputGuardVersion: 1, found: false, selectorError: String(error) }; }
|
|
2983
|
+
if (!element) return { trustedInputGuardVersion: 1, found: false };
|
|
2984
|
+
const rect = element.getBoundingClientRect();
|
|
2985
|
+
const style = typeof getComputedStyle === 'function' ? getComputedStyle(element) : undefined;
|
|
2986
|
+
const visible = Number(rect.width || 0) > 0
|
|
2987
|
+
&& Number(rect.height || 0) > 0
|
|
2988
|
+
&& style?.display !== 'none'
|
|
2989
|
+
&& style?.visibility !== 'hidden'
|
|
2990
|
+
&& Number(style?.opacity ?? 1) !== 0;
|
|
2991
|
+
return {
|
|
2992
|
+
trustedInputGuardVersion: 1,
|
|
2993
|
+
found: true,
|
|
2994
|
+
visible,
|
|
2995
|
+
bounds: {
|
|
2996
|
+
x: Number(rect.left || 0), y: Number(rect.top || 0),
|
|
2997
|
+
width: Number(rect.width || 0), height: Number(rect.height || 0),
|
|
2998
|
+
right: Number(rect.right || 0), bottom: Number(rect.bottom || 0),
|
|
2999
|
+
},
|
|
3000
|
+
};
|
|
3001
|
+
})()`;
|
|
3002
|
+
}
|
|
3003
|
+
|
|
3004
|
+
function verifyStateObservationScript(selector: string | undefined, textContains: string | undefined, maxChars: number): string {
|
|
3005
|
+
const payload = JSON.stringify({ selector: selector ?? null, textContains: textContains ?? null, maxChars });
|
|
3006
|
+
return `(() => {
|
|
3007
|
+
const args = ${payload};
|
|
3008
|
+
const element = args.selector ? document.querySelector(args.selector) : null;
|
|
3009
|
+
const textTarget = element || document.body || document.documentElement;
|
|
3010
|
+
const rawText = String(textTarget?.innerText || textTarget?.textContent || '');
|
|
3011
|
+
let visible;
|
|
3012
|
+
if (args.selector) {
|
|
3013
|
+
if (!element) visible = false;
|
|
3014
|
+
else {
|
|
3015
|
+
const rect = element.getBoundingClientRect();
|
|
3016
|
+
const style = typeof getComputedStyle === 'function' ? getComputedStyle(element) : undefined;
|
|
3017
|
+
visible = Number(rect.width || 0) > 0
|
|
3018
|
+
&& Number(rect.height || 0) > 0
|
|
3019
|
+
&& style?.display !== 'none'
|
|
3020
|
+
&& style?.visibility !== 'hidden'
|
|
3021
|
+
&& Number(style?.opacity ?? 1) !== 0;
|
|
3022
|
+
}
|
|
3023
|
+
}
|
|
3024
|
+
return {
|
|
3025
|
+
verificationVersion: 1,
|
|
3026
|
+
selectorExists: args.selector ? Boolean(element) : undefined,
|
|
3027
|
+
visible,
|
|
3028
|
+
textContains: args.textContains === null ? undefined : rawText.includes(args.textContains),
|
|
3029
|
+
textSample: rawText.slice(0, args.maxChars),
|
|
3030
|
+
textLength: rawText.length,
|
|
3031
|
+
truncated: rawText.length > args.maxChars,
|
|
3032
|
+
};
|
|
3033
|
+
})()`;
|
|
3034
|
+
}
|
|
3035
|
+
|
|
3036
|
+
async function readPageIdentity(page: PageLike, connection?: BrowserConnectionSummary, expectedUrl?: string): Promise<{ url: string; title: string }> {
|
|
3037
|
+
const normalizeObservedUrl = (value: unknown): string => {
|
|
3038
|
+
const internalProduct = browserInternalUrlProduct(value);
|
|
3039
|
+
if (connection?.provider === 'macos-apple-events' && connection.tab?.ownership === 'user_owned') {
|
|
3040
|
+
if (connection.browserProduct && expectedUrl && explicitNativeAdoptionUrlsMatch(expectedUrl, value, connection.browserProduct)) {
|
|
3041
|
+
return normalizedExplicitNativeAdoptionUrl(expectedUrl);
|
|
3042
|
+
}
|
|
3043
|
+
if (internalProduct && internalProduct === connection.browserProduct) {
|
|
3044
|
+
return normalizedExplicitNativeAdoptionUrl(value);
|
|
3045
|
+
}
|
|
3046
|
+
}
|
|
3047
|
+
return normalizedUrl(value);
|
|
3048
|
+
};
|
|
1527
3049
|
if (connection?.provider === 'macos-apple-events' && typeof page.identity === 'function') {
|
|
1528
3050
|
const identity = await page.identity();
|
|
1529
|
-
return { url:
|
|
3051
|
+
return { url: normalizeObservedUrl(identity.url), title: identity.title };
|
|
1530
3052
|
}
|
|
1531
|
-
return { url:
|
|
3053
|
+
return { url: normalizeObservedUrl(page.url()), title: await page.title() };
|
|
1532
3054
|
}
|
|
1533
3055
|
|
|
1534
3056
|
async function finalizeInteractiveAction(
|
|
@@ -1542,13 +3064,12 @@ async function finalizeInteractiveAction(
|
|
|
1542
3064
|
extra: Record<string, unknown> = {},
|
|
1543
3065
|
): Promise<Record<string, unknown>> {
|
|
1544
3066
|
const warnings: string[] = [];
|
|
1545
|
-
const identity = await readPageIdentity(page, connection);
|
|
3067
|
+
const identity = await readPageIdentity(page, connection, target.url);
|
|
1546
3068
|
const title = identity.title;
|
|
1547
3069
|
const pageUrl = identity.url;
|
|
1548
|
-
const session = sessionFromPage(target, pageUrl, title, connection);
|
|
1549
|
-
saveSession(repoRoot, session);
|
|
3070
|
+
const session = saveBrowserSession(repoRoot, sessionFromPage(target, pageUrl, title, connection));
|
|
1550
3071
|
let screenshot: BrowserActionScreenshot | undefined;
|
|
1551
|
-
const silentNativeEvidence = connection.provider === 'macos-apple-events'
|
|
3072
|
+
const silentNativeEvidence = connection.provider === 'macos-apple-events';
|
|
1552
3073
|
if (!silentNativeEvidence) {
|
|
1553
3074
|
try {
|
|
1554
3075
|
screenshot = await captureActionScreenshot(page, repoRoot, actionId, session.sessionId, session.url);
|
|
@@ -1568,475 +3089,6 @@ async function finalizeInteractiveAction(
|
|
|
1568
3089
|
};
|
|
1569
3090
|
}
|
|
1570
3091
|
|
|
1571
|
-
function permissions(ready: boolean): AssistantPluginPermissionScope[] {
|
|
1572
|
-
return [
|
|
1573
|
-
{
|
|
1574
|
-
scope: 'browser.read',
|
|
1575
|
-
mode: 'read',
|
|
1576
|
-
description: 'Open pages, extract text, and capture screenshots.',
|
|
1577
|
-
granted: ready,
|
|
1578
|
-
required: true,
|
|
1579
|
-
},
|
|
1580
|
-
{
|
|
1581
|
-
scope: 'browser.interact',
|
|
1582
|
-
mode: 'write',
|
|
1583
|
-
description: 'Click, type, press keys, and wait on allowed browser pages after explicit authorization.',
|
|
1584
|
-
granted: ready,
|
|
1585
|
-
required: true,
|
|
1586
|
-
},
|
|
1587
|
-
{
|
|
1588
|
-
scope: 'browser.profile',
|
|
1589
|
-
mode: 'write',
|
|
1590
|
-
description: 'Persist the dedicated local browser profile, session metadata, and screenshots.',
|
|
1591
|
-
granted: ready,
|
|
1592
|
-
required: true,
|
|
1593
|
-
},
|
|
1594
|
-
];
|
|
1595
|
-
}
|
|
1596
|
-
|
|
1597
|
-
function capabilities(): AssistantPluginCapability[] {
|
|
1598
|
-
return [
|
|
1599
|
-
{
|
|
1600
|
-
capabilityId: 'browser-session',
|
|
1601
|
-
title: 'Browser Sessions',
|
|
1602
|
-
description: 'Create, list, reuse, and close persistent browser sessions.',
|
|
1603
|
-
scopes: ['browser.read', 'browser.profile'],
|
|
1604
|
-
actions: ['create_session', 'list_sessions', 'close_session', 'close_page', 'clear_session'],
|
|
1605
|
-
},
|
|
1606
|
-
{
|
|
1607
|
-
capabilityId: 'browser-human-handoff',
|
|
1608
|
-
title: 'Browser Human Handoff',
|
|
1609
|
-
description: 'Present the persistent browser in the foreground, keep it open for manual verification, and resume safely.',
|
|
1610
|
-
scopes: ['browser.read', 'browser.profile'],
|
|
1611
|
-
actions: ['request_human_handoff', 'get_handoff_status', 'resolve_handoff'],
|
|
1612
|
-
},
|
|
1613
|
-
{
|
|
1614
|
-
capabilityId: 'browser-readonly',
|
|
1615
|
-
title: 'Read-only Browser',
|
|
1616
|
-
description: 'Navigate allowed pages, extract DOM/text, capture screenshots, and collect diagnostics.',
|
|
1617
|
-
scopes: ['browser.read', 'browser.profile'],
|
|
1618
|
-
actions: [
|
|
1619
|
-
'open_page', 'navigate', 'reload', 'go_back', 'wait_for_load_state',
|
|
1620
|
-
'get_text', 'get_html', 'query_selector', 'query_all', 'get_attribute',
|
|
1621
|
-
'screenshot', 'extract_links', 'extract_tables', 'extract_forms', 'snapshot_interactive',
|
|
1622
|
-
'get_console_errors', 'get_failed_requests',
|
|
1623
|
-
],
|
|
1624
|
-
},
|
|
1625
|
-
{
|
|
1626
|
-
capabilityId: 'browser-interaction',
|
|
1627
|
-
title: 'Browser Interaction',
|
|
1628
|
-
description: 'Perform explicit form and pointer interactions on HTTP(S) pages through the persistent Playwright profile.',
|
|
1629
|
-
scopes: ['browser.interact', 'browser.profile'],
|
|
1630
|
-
actions: [
|
|
1631
|
-
'click', 'double_click', 'hover', 'focus', 'type', 'fill', 'select_option',
|
|
1632
|
-
'check', 'uncheck', 'press', 'keyboard_shortcut', 'wait_for_selector', 'attach_local_file', 'await_file_transfer',
|
|
1633
|
-
],
|
|
1634
|
-
},
|
|
1635
|
-
];
|
|
1636
|
-
}
|
|
1637
|
-
|
|
1638
|
-
function sessionTargetSchema(extra: Record<string, unknown> = {}, required: string[] = []): Record<string, unknown> {
|
|
1639
|
-
return {
|
|
1640
|
-
type: 'object',
|
|
1641
|
-
properties: {
|
|
1642
|
-
session_id: { type: 'string' },
|
|
1643
|
-
url: { type: 'string' },
|
|
1644
|
-
wait_until: { type: 'string', enum: ['load', 'domcontentloaded', 'networkidle'] },
|
|
1645
|
-
timeout_ms: { type: 'number' },
|
|
1646
|
-
retries: { type: 'number' },
|
|
1647
|
-
...extra,
|
|
1648
|
-
},
|
|
1649
|
-
...(required.length > 0 ? { required } : {}),
|
|
1650
|
-
additionalProperties: false,
|
|
1651
|
-
};
|
|
1652
|
-
}
|
|
1653
|
-
|
|
1654
|
-
function interactSchema(extra: Record<string, unknown>, required: string[]): Record<string, unknown> {
|
|
1655
|
-
return sessionTargetSchema({
|
|
1656
|
-
post_action_wait_ms: { type: 'number' },
|
|
1657
|
-
...extra,
|
|
1658
|
-
}, required);
|
|
1659
|
-
}
|
|
1660
|
-
|
|
1661
|
-
function actions(): AssistantPluginActionDescriptor[] {
|
|
1662
|
-
const readRemote = [
|
|
1663
|
-
{ resource: 'remote' as const, mode: 'read' as const },
|
|
1664
|
-
{ resource: 'repo-state' as const, mode: 'write' as const },
|
|
1665
|
-
];
|
|
1666
|
-
const writeRemote = [
|
|
1667
|
-
{ resource: 'remote' as const, mode: 'exclusive' as const },
|
|
1668
|
-
{ resource: 'repo-state' as const, mode: 'write' as const },
|
|
1669
|
-
];
|
|
1670
|
-
return [
|
|
1671
|
-
{
|
|
1672
|
-
actionId: 'configure',
|
|
1673
|
-
title: 'Configure browser plugin',
|
|
1674
|
-
description: 'Enable or update the local browser plugin configuration.',
|
|
1675
|
-
readOnly: false,
|
|
1676
|
-
risk: 'workspace_write',
|
|
1677
|
-
confirmation: 'authorization',
|
|
1678
|
-
defaultTimeoutMs: 30_000,
|
|
1679
|
-
cancellable: true,
|
|
1680
|
-
idempotent: true,
|
|
1681
|
-
scopes: ['browser.profile'],
|
|
1682
|
-
resourceClaims: [{ resource: 'repo-state', mode: 'write' }],
|
|
1683
|
-
argumentsSchema: {
|
|
1684
|
-
type: 'object',
|
|
1685
|
-
properties: {
|
|
1686
|
-
enabled: { type: 'boolean' },
|
|
1687
|
-
browser_mode: { type: 'string', enum: ['attach_preferred', 'managed_persistent', 'isolated'] },
|
|
1688
|
-
profile_mode: { type: 'string', enum: ['repo_local', 'custom'] },
|
|
1689
|
-
profile_dir: { type: 'string' },
|
|
1690
|
-
profile_directory: { type: 'string' },
|
|
1691
|
-
clear_profile_dir: { type: 'boolean' },
|
|
1692
|
-
clear_profile_directory: { type: 'boolean' },
|
|
1693
|
-
browser_channel: { type: 'string', enum: ['chromium', 'chrome', 'chrome-beta', 'chrome-dev', 'chrome-canary'] },
|
|
1694
|
-
clear_browser_channel: { type: 'boolean' },
|
|
1695
|
-
browser_executable_path: { type: 'string' },
|
|
1696
|
-
clear_browser_executable_path: { type: 'boolean' },
|
|
1697
|
-
cdp_endpoint: { type: 'string' },
|
|
1698
|
-
clear_cdp_endpoint: { type: 'boolean' },
|
|
1699
|
-
cdp_endpoint_candidates: { type: 'array', items: { type: 'string' }, maxItems: MAX_CDP_ENDPOINT_CANDIDATES },
|
|
1700
|
-
clear_cdp_endpoint_candidates: { type: 'boolean' },
|
|
1701
|
-
cdp_discovery_timeout_ms: { type: 'number' },
|
|
1702
|
-
cdp_attach_fallback: { type: 'string', enum: ['managed_persistent', 'fail_closed'] },
|
|
1703
|
-
native_attach_mode: { type: 'string', enum: ['auto', 'disabled'] },
|
|
1704
|
-
native_browser_candidates: { type: 'array', items: { type: 'string', enum: ['vivaldi', 'chrome'] }, maxItems: 2 },
|
|
1705
|
-
default_timeout_ms: { type: 'number' },
|
|
1706
|
-
},
|
|
1707
|
-
additionalProperties: false,
|
|
1708
|
-
},
|
|
1709
|
-
},
|
|
1710
|
-
{
|
|
1711
|
-
actionId: 'create_session',
|
|
1712
|
-
title: 'Create browser session',
|
|
1713
|
-
description: 'Open an HTTP(S) URL and persist a reusable session id.',
|
|
1714
|
-
readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
|
|
1715
|
-
scopes: ['browser.read', 'browser.profile'], resourceClaims: [{ resource: 'repo-state', mode: 'write' }, ...readRemote],
|
|
1716
|
-
argumentsSchema: sessionTargetSchema({ extract_text: { type: 'boolean' }, max_chars: { type: 'number' } }, ['url']),
|
|
1717
|
-
},
|
|
1718
|
-
{
|
|
1719
|
-
actionId: 'list_sessions',
|
|
1720
|
-
title: 'List browser sessions',
|
|
1721
|
-
description: 'List saved browser session metadata without secrets or cookies.',
|
|
1722
|
-
readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 10_000, cancellable: true, idempotent: true,
|
|
1723
|
-
scopes: ['browser.read', 'browser.profile'], resourceClaims: [{ resource: 'repo-state', mode: 'read' }],
|
|
1724
|
-
argumentsSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
1725
|
-
},
|
|
1726
|
-
{
|
|
1727
|
-
actionId: 'close_session',
|
|
1728
|
-
title: 'Close browser session',
|
|
1729
|
-
description: 'Remove one saved session metadata record.',
|
|
1730
|
-
readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 15_000, cancellable: true, idempotent: true,
|
|
1731
|
-
scopes: ['browser.read', 'browser.profile'], resourceClaims: [{ resource: 'repo-state', mode: 'write' }],
|
|
1732
|
-
argumentsSchema: { type: 'object', properties: { session_id: { type: 'string' } }, required: ['session_id'], additionalProperties: false },
|
|
1733
|
-
},
|
|
1734
|
-
{
|
|
1735
|
-
actionId: 'clear_session',
|
|
1736
|
-
title: 'Clear all browser sessions',
|
|
1737
|
-
description: 'Remove all saved session metadata while keeping the profile.',
|
|
1738
|
-
readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 15_000, cancellable: true, idempotent: true,
|
|
1739
|
-
scopes: ['browser.profile'], resourceClaims: [{ resource: 'repo-state', mode: 'write' }],
|
|
1740
|
-
argumentsSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
1741
|
-
},
|
|
1742
|
-
{
|
|
1743
|
-
actionId: 'request_human_handoff',
|
|
1744
|
-
title: 'Request human browser handoff',
|
|
1745
|
-
description: 'Keep a saved browser session open for CAPTCHA, login, two-factor authentication, or another manual step.',
|
|
1746
|
-
readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 30_000, cancellable: true, idempotent: false,
|
|
1747
|
-
scopes: ['browser.read', 'browser.profile'], resourceClaims: readRemote,
|
|
1748
|
-
argumentsSchema: sessionTargetSchema({
|
|
1749
|
-
reason: { type: 'string', enum: ['captcha', 'login', 'two_factor', 'manual_review', 'sensitive_confirmation'] },
|
|
1750
|
-
instructions: { type: 'string' },
|
|
1751
|
-
handoff_timeout_ms: { type: 'number' },
|
|
1752
|
-
}, ['session_id']),
|
|
1753
|
-
},
|
|
1754
|
-
{
|
|
1755
|
-
actionId: 'get_handoff_status',
|
|
1756
|
-
title: 'Get browser handoff status',
|
|
1757
|
-
description: 'Read durable browser handoff status and reconcile a stale or crashed host.',
|
|
1758
|
-
readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 10_000, cancellable: true, idempotent: true,
|
|
1759
|
-
scopes: ['browser.read', 'browser.profile'], resourceClaims: [{ resource: 'repo-state', mode: 'read' }],
|
|
1760
|
-
argumentsSchema: { type: 'object', properties: { interaction_id: { type: 'string' } }, required: ['interaction_id'], additionalProperties: false },
|
|
1761
|
-
},
|
|
1762
|
-
{
|
|
1763
|
-
actionId: 'resolve_handoff',
|
|
1764
|
-
title: 'Resolve browser handoff',
|
|
1765
|
-
description: 'Resume or cancel a foreground browser handoff.',
|
|
1766
|
-
readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 15_000, cancellable: true, idempotent: true,
|
|
1767
|
-
scopes: ['browser.read', 'browser.profile'], resourceClaims: [{ resource: 'repo-state', mode: 'write' }],
|
|
1768
|
-
argumentsSchema: {
|
|
1769
|
-
type: 'object',
|
|
1770
|
-
properties: {
|
|
1771
|
-
interaction_id: { type: 'string' },
|
|
1772
|
-
resolution: { type: 'string', enum: ['resume', 'cancel'] },
|
|
1773
|
-
},
|
|
1774
|
-
required: ['interaction_id', 'resolution'],
|
|
1775
|
-
additionalProperties: false,
|
|
1776
|
-
},
|
|
1777
|
-
},
|
|
1778
|
-
{
|
|
1779
|
-
actionId: 'open_page',
|
|
1780
|
-
title: 'Open page',
|
|
1781
|
-
description: 'Open an HTTP(S) URL with the persistent profile and save a lightweight session.',
|
|
1782
|
-
readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
|
|
1783
|
-
scopes: ['browser.read'], resourceClaims: readRemote,
|
|
1784
|
-
argumentsSchema: sessionTargetSchema({ extract_text: { type: 'boolean' }, max_chars: { type: 'number' } }, ['url']),
|
|
1785
|
-
},
|
|
1786
|
-
{
|
|
1787
|
-
actionId: 'navigate',
|
|
1788
|
-
title: 'Navigate',
|
|
1789
|
-
description: 'Navigate an existing session or open a new page to an HTTP(S) URL.',
|
|
1790
|
-
readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
|
|
1791
|
-
scopes: ['browser.read'], resourceClaims: readRemote,
|
|
1792
|
-
argumentsSchema: sessionTargetSchema({}, ['url']),
|
|
1793
|
-
},
|
|
1794
|
-
{
|
|
1795
|
-
actionId: 'reload',
|
|
1796
|
-
title: 'Reload page',
|
|
1797
|
-
description: 'Reload the current page for a session.',
|
|
1798
|
-
readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
|
|
1799
|
-
scopes: ['browser.read'], resourceClaims: readRemote,
|
|
1800
|
-
argumentsSchema: sessionTargetSchema({}, ['session_id']),
|
|
1801
|
-
},
|
|
1802
|
-
{
|
|
1803
|
-
actionId: 'go_back',
|
|
1804
|
-
title: 'Go back',
|
|
1805
|
-
description: 'Navigate back in history for a session.',
|
|
1806
|
-
readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
|
|
1807
|
-
scopes: ['browser.read'], resourceClaims: readRemote,
|
|
1808
|
-
argumentsSchema: sessionTargetSchema({}, ['session_id']),
|
|
1809
|
-
},
|
|
1810
|
-
{
|
|
1811
|
-
actionId: 'wait_for_load_state',
|
|
1812
|
-
title: 'Wait for load state',
|
|
1813
|
-
description: 'Wait for load/domcontentloaded/networkidle on a session page.',
|
|
1814
|
-
readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
|
|
1815
|
-
scopes: ['browser.read'], resourceClaims: readRemote,
|
|
1816
|
-
argumentsSchema: sessionTargetSchema({ state: { type: 'string', enum: ['load', 'domcontentloaded', 'networkidle'] } }),
|
|
1817
|
-
},
|
|
1818
|
-
{
|
|
1819
|
-
actionId: 'get_text',
|
|
1820
|
-
title: 'Get text',
|
|
1821
|
-
description: 'Extract text from a URL or saved browser session.',
|
|
1822
|
-
readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
|
|
1823
|
-
scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
|
|
1824
|
-
argumentsSchema: sessionTargetSchema({ selector: { type: 'string' }, max_chars: { type: 'number' } }),
|
|
1825
|
-
},
|
|
1826
|
-
{
|
|
1827
|
-
actionId: 'get_html',
|
|
1828
|
-
title: 'Get HTML',
|
|
1829
|
-
description: 'Extract HTML for the page or a selector (bounded).',
|
|
1830
|
-
readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
|
|
1831
|
-
scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
|
|
1832
|
-
argumentsSchema: sessionTargetSchema({ selector: { type: 'string' }, max_chars: { type: 'number' } }),
|
|
1833
|
-
},
|
|
1834
|
-
{
|
|
1835
|
-
actionId: 'query_selector',
|
|
1836
|
-
title: 'Query selector',
|
|
1837
|
-
description: 'Return the first matching element summary with a stable selector hint.',
|
|
1838
|
-
readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
|
|
1839
|
-
scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
|
|
1840
|
-
argumentsSchema: sessionTargetSchema({ selector: { type: 'string' } }, ['selector']),
|
|
1841
|
-
},
|
|
1842
|
-
{
|
|
1843
|
-
actionId: 'query_all',
|
|
1844
|
-
title: 'Query all selectors',
|
|
1845
|
-
description: 'Return matching element summaries (bounded).',
|
|
1846
|
-
readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
|
|
1847
|
-
scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
|
|
1848
|
-
argumentsSchema: sessionTargetSchema({ selector: { type: 'string' }, limit: { type: 'number' } }, ['selector']),
|
|
1849
|
-
},
|
|
1850
|
-
{
|
|
1851
|
-
actionId: 'get_attribute',
|
|
1852
|
-
title: 'Get attribute',
|
|
1853
|
-
description: 'Read one attribute from a selector.',
|
|
1854
|
-
readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
|
|
1855
|
-
scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
|
|
1856
|
-
argumentsSchema: sessionTargetSchema({ selector: { type: 'string' }, attribute: { type: 'string' } }, ['selector', 'attribute']),
|
|
1857
|
-
},
|
|
1858
|
-
{
|
|
1859
|
-
actionId: 'screenshot',
|
|
1860
|
-
title: 'Screenshot',
|
|
1861
|
-
description: 'Capture a page, full-page, or element screenshot under browser artifact storage.',
|
|
1862
|
-
readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
|
|
1863
|
-
scopes: ['browser.read'], resourceClaims: readRemote,
|
|
1864
|
-
argumentsSchema: sessionTargetSchema({ full_page: { type: 'boolean' }, selector: { type: 'string' } }),
|
|
1865
|
-
},
|
|
1866
|
-
{
|
|
1867
|
-
actionId: 'extract_links',
|
|
1868
|
-
title: 'Extract links',
|
|
1869
|
-
description: 'Extract anchors with href/text from the page.',
|
|
1870
|
-
readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
|
|
1871
|
-
scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
|
|
1872
|
-
argumentsSchema: sessionTargetSchema({ limit: { type: 'number' } }),
|
|
1873
|
-
},
|
|
1874
|
-
{
|
|
1875
|
-
actionId: 'extract_tables',
|
|
1876
|
-
title: 'Extract tables',
|
|
1877
|
-
description: 'Extract simple HTML tables as row arrays.',
|
|
1878
|
-
readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
|
|
1879
|
-
scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
|
|
1880
|
-
argumentsSchema: sessionTargetSchema({ limit: { type: 'number' } }),
|
|
1881
|
-
},
|
|
1882
|
-
{
|
|
1883
|
-
actionId: 'extract_forms',
|
|
1884
|
-
title: 'Extract forms',
|
|
1885
|
-
description: 'Extract form field summaries without values that look like secrets.',
|
|
1886
|
-
readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
|
|
1887
|
-
scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
|
|
1888
|
-
argumentsSchema: sessionTargetSchema({ limit: { type: 'number' } }),
|
|
1889
|
-
},
|
|
1890
|
-
{
|
|
1891
|
-
actionId: 'snapshot_interactive',
|
|
1892
|
-
title: 'Snapshot interactive elements',
|
|
1893
|
-
description: 'Snapshot buttons/inputs/links with stable selector hints.',
|
|
1894
|
-
readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
|
|
1895
|
-
scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
|
|
1896
|
-
argumentsSchema: sessionTargetSchema({ limit: { type: 'number' } }),
|
|
1897
|
-
},
|
|
1898
|
-
{
|
|
1899
|
-
actionId: 'get_console_errors',
|
|
1900
|
-
title: 'Get console errors',
|
|
1901
|
-
description: 'Return captured console error messages for a page open cycle.',
|
|
1902
|
-
readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
|
|
1903
|
-
scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
|
|
1904
|
-
argumentsSchema: sessionTargetSchema({}),
|
|
1905
|
-
},
|
|
1906
|
-
{
|
|
1907
|
-
actionId: 'get_failed_requests',
|
|
1908
|
-
title: 'Get failed requests',
|
|
1909
|
-
description: 'Return failed network requests captured during a page open cycle.',
|
|
1910
|
-
readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
|
|
1911
|
-
scopes: ['browser.read'], resourceClaims: [{ resource: 'remote', mode: 'read' }],
|
|
1912
|
-
argumentsSchema: sessionTargetSchema({}),
|
|
1913
|
-
},
|
|
1914
|
-
{
|
|
1915
|
-
actionId: 'click',
|
|
1916
|
-
title: 'Click element',
|
|
1917
|
-
description: 'Click a selector on an allowed page after authorization.',
|
|
1918
|
-
readOnly: false, risk: 'remote_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
|
|
1919
|
-
scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
|
|
1920
|
-
argumentsSchema: interactSchema({ selector: { type: 'string' } }, ['selector']),
|
|
1921
|
-
},
|
|
1922
|
-
{
|
|
1923
|
-
actionId: 'double_click',
|
|
1924
|
-
title: 'Double-click element',
|
|
1925
|
-
description: 'Double-click a selector after authorization.',
|
|
1926
|
-
readOnly: false, risk: 'remote_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
|
|
1927
|
-
scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
|
|
1928
|
-
argumentsSchema: interactSchema({ selector: { type: 'string' } }, ['selector']),
|
|
1929
|
-
},
|
|
1930
|
-
{
|
|
1931
|
-
actionId: 'hover',
|
|
1932
|
-
title: 'Hover element',
|
|
1933
|
-
description: 'Hover a selector after authorization.',
|
|
1934
|
-
readOnly: false, risk: 'remote_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
|
|
1935
|
-
scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
|
|
1936
|
-
argumentsSchema: interactSchema({ selector: { type: 'string' } }, ['selector']),
|
|
1937
|
-
},
|
|
1938
|
-
{
|
|
1939
|
-
actionId: 'focus',
|
|
1940
|
-
title: 'Focus element',
|
|
1941
|
-
description: 'Focus a selector after authorization.',
|
|
1942
|
-
readOnly: false, risk: 'remote_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
|
|
1943
|
-
scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
|
|
1944
|
-
argumentsSchema: interactSchema({ selector: { type: 'string' } }, ['selector']),
|
|
1945
|
-
},
|
|
1946
|
-
{
|
|
1947
|
-
actionId: 'type',
|
|
1948
|
-
title: 'Type into element',
|
|
1949
|
-
description: 'Type text into a selector (append-style) after authorization.',
|
|
1950
|
-
readOnly: false, risk: 'remote_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
|
|
1951
|
-
scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
|
|
1952
|
-
argumentsSchema: interactSchema({ selector: { type: 'string' }, text: { type: 'string' } }, ['selector', 'text']),
|
|
1953
|
-
},
|
|
1954
|
-
{
|
|
1955
|
-
actionId: 'fill',
|
|
1956
|
-
title: 'Fill element',
|
|
1957
|
-
description: 'Replace the value of a selector after authorization.',
|
|
1958
|
-
readOnly: false, risk: 'remote_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
|
|
1959
|
-
scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
|
|
1960
|
-
argumentsSchema: interactSchema({ selector: { type: 'string' }, text: { type: 'string' } }, ['selector', 'text']),
|
|
1961
|
-
},
|
|
1962
|
-
{
|
|
1963
|
-
actionId: 'select_option',
|
|
1964
|
-
title: 'Select option',
|
|
1965
|
-
description: 'Select one or more option values on a <select> after authorization.',
|
|
1966
|
-
readOnly: false, risk: 'remote_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
|
|
1967
|
-
scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
|
|
1968
|
-
argumentsSchema: interactSchema({ selector: { type: 'string' }, values: { type: 'array', items: { type: 'string' } } }, ['selector', 'values']),
|
|
1969
|
-
},
|
|
1970
|
-
{
|
|
1971
|
-
actionId: 'check',
|
|
1972
|
-
title: 'Check checkbox',
|
|
1973
|
-
description: 'Check a checkbox/radio after authorization.',
|
|
1974
|
-
readOnly: false, risk: 'remote_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
|
|
1975
|
-
scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
|
|
1976
|
-
argumentsSchema: interactSchema({ selector: { type: 'string' } }, ['selector']),
|
|
1977
|
-
},
|
|
1978
|
-
{
|
|
1979
|
-
actionId: 'uncheck',
|
|
1980
|
-
title: 'Uncheck checkbox',
|
|
1981
|
-
description: 'Uncheck a checkbox after authorization.',
|
|
1982
|
-
readOnly: false, risk: 'remote_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
|
|
1983
|
-
scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
|
|
1984
|
-
argumentsSchema: interactSchema({ selector: { type: 'string' } }, ['selector']),
|
|
1985
|
-
},
|
|
1986
|
-
{
|
|
1987
|
-
actionId: 'press',
|
|
1988
|
-
title: 'Press key',
|
|
1989
|
-
description: 'Press a key on a selector on an allowed page after authorization.',
|
|
1990
|
-
readOnly: false, risk: 'remote_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
|
|
1991
|
-
scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
|
|
1992
|
-
argumentsSchema: interactSchema({ selector: { type: 'string' }, key: { type: 'string' } }, ['selector', 'key']),
|
|
1993
|
-
},
|
|
1994
|
-
{
|
|
1995
|
-
actionId: 'keyboard_shortcut',
|
|
1996
|
-
title: 'Keyboard shortcut',
|
|
1997
|
-
description: 'Press a keyboard shortcut (e.g. Meta+A) after authorization.',
|
|
1998
|
-
readOnly: false, risk: 'remote_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
|
|
1999
|
-
scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
|
|
2000
|
-
argumentsSchema: interactSchema({ key: { type: 'string' } }, ['key']),
|
|
2001
|
-
},
|
|
2002
|
-
{
|
|
2003
|
-
actionId: 'wait_for_selector',
|
|
2004
|
-
title: 'Wait for selector',
|
|
2005
|
-
description: 'Wait for a selector state on an allowed page after authorization.',
|
|
2006
|
-
readOnly: true, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: true,
|
|
2007
|
-
scopes: ['browser.interact', 'browser.profile'], resourceClaims: readRemote,
|
|
2008
|
-
argumentsSchema: sessionTargetSchema({
|
|
2009
|
-
selector: { type: 'string' },
|
|
2010
|
-
state: { type: 'string', enum: ['attached', 'detached', 'visible', 'hidden'] },
|
|
2011
|
-
}, ['selector']),
|
|
2012
|
-
},
|
|
2013
|
-
{
|
|
2014
|
-
actionId: 'attach_local_file',
|
|
2015
|
-
title: 'Attach local file',
|
|
2016
|
-
description: 'Set an input[type=file] path for an allowed local file after authorization. Never auto-opens executables.',
|
|
2017
|
-
readOnly: false, risk: 'remote_write', confirmation: 'authorization', defaultTimeoutMs: 60_000, cancellable: true, idempotent: false,
|
|
2018
|
-
scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
|
|
2019
|
-
argumentsSchema: interactSchema({ selector: { type: 'string' }, file_path: { type: 'string' } }, ['selector', 'file_path']),
|
|
2020
|
-
},
|
|
2021
|
-
{
|
|
2022
|
-
actionId: 'await_file_transfer',
|
|
2023
|
-
title: 'Await file transfer',
|
|
2024
|
-
description: 'Capture a browser download into bounded artifact storage after authorization. Never auto-opens downloaded files.',
|
|
2025
|
-
readOnly: false, risk: 'remote_write', confirmation: 'authorization', defaultTimeoutMs: 120_000, cancellable: true, idempotent: false,
|
|
2026
|
-
scopes: ['browser.interact', 'browser.profile'], resourceClaims: writeRemote,
|
|
2027
|
-
argumentsSchema: interactSchema({ selector: { type: 'string' }, suggested_name: { type: 'string' } }, ['selector']),
|
|
2028
|
-
},
|
|
2029
|
-
{
|
|
2030
|
-
actionId: 'close_page',
|
|
2031
|
-
title: 'Close session',
|
|
2032
|
-
description: 'Remove saved session metadata while keeping the persistent profile.',
|
|
2033
|
-
readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 15_000, cancellable: true, idempotent: true,
|
|
2034
|
-
scopes: ['browser.read', 'browser.profile'], resourceClaims: [{ resource: 'repo-state', mode: 'write' }],
|
|
2035
|
-
argumentsSchema: { type: 'object', properties: { session_id: { type: 'string' } }, required: ['session_id'], additionalProperties: false },
|
|
2036
|
-
},
|
|
2037
|
-
];
|
|
2038
|
-
}
|
|
2039
|
-
|
|
2040
3092
|
function browserUserFacingStatus(config: BrowserPluginConfig, ready: boolean, sessionCount = 0): string {
|
|
2041
3093
|
if (!config.enabled) return 'disabled';
|
|
2042
3094
|
if (!ready) return 'not ready';
|
|
@@ -2045,10 +3097,11 @@ function browserUserFacingStatus(config: BrowserPluginConfig, ready: boolean, se
|
|
|
2045
3097
|
}
|
|
2046
3098
|
|
|
2047
3099
|
function health(config: BrowserPluginConfig, repoRoot?: string): AssistantPluginHealth {
|
|
2048
|
-
const dependencyReady = runtimeHooks.moduleAvailable('playwright');
|
|
3100
|
+
const dependencyReady = runtimeHooks.moduleAvailable('playwright', repoRoot);
|
|
2049
3101
|
const configErrors = validateConfig(config);
|
|
2050
3102
|
const warnings = configWarnings(config);
|
|
2051
|
-
const
|
|
3103
|
+
const authority = currentBrowserSessionAuthorityContext();
|
|
3104
|
+
const savedSessionCount = repoRoot && authority ? listSavedBrowserSessions(repoRoot).length : 0;
|
|
2052
3105
|
const activeHandoffCount = repoRoot
|
|
2053
3106
|
? listBrowserHandoffs(repoRoot).filter((entry) => ['starting', 'waiting_for_user', 'closing'].includes(entry.status)).length
|
|
2054
3107
|
: 0;
|
|
@@ -2067,7 +3120,9 @@ function health(config: BrowserPluginConfig, repoRoot?: string): AssistantPlugin
|
|
|
2067
3120
|
nativeBrowserCandidates: config.nativeBrowserCandidates,
|
|
2068
3121
|
nativeAttachSupported: macOsActiveBrowserAttachSupported(),
|
|
2069
3122
|
windowMode: 'visible' as const,
|
|
2070
|
-
sessionCount,
|
|
3123
|
+
sessionCount: savedSessionCount,
|
|
3124
|
+
sessionCountSemantics: authority ? 'controller_authority' as const : 'controller_authority_unavailable' as const,
|
|
3125
|
+
savedSessionCount,
|
|
2071
3126
|
activeHandoffCount,
|
|
2072
3127
|
humanHandoffSupported: true,
|
|
2073
3128
|
artifactsAvailable: true,
|
|
@@ -2098,14 +3153,9 @@ function health(config: BrowserPluginConfig, repoRoot?: string): AssistantPlugin
|
|
|
2098
3153
|
const managedRouteReady = dependencyReady
|
|
2099
3154
|
&& (config.browserMode !== 'attach_preferred' || config.cdpAttachFallback === 'managed_persistent');
|
|
2100
3155
|
const nativeRouteReady = nativeAttachConfigured && nativeObservation?.ready === true;
|
|
2101
|
-
const
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
&& nativeObservation === undefined;
|
|
2105
|
-
const nativeRouteFailed = nativeAttachConfigured
|
|
2106
|
-
&& cdpEndpoints(config).length === 0
|
|
2107
|
-
&& config.cdpAttachFallback === 'fail_closed'
|
|
2108
|
-
&& nativeObservation?.ready === false;
|
|
3156
|
+
const nativeOnlyRoute = nativeAttachConfigured && !cdpRouteReady && !managedRouteReady;
|
|
3157
|
+
const nativeRouteUnverified = nativeOnlyRoute && nativeObservation === undefined;
|
|
3158
|
+
const nativeRouteFailed = nativeOnlyRoute && nativeObservation?.ready === false;
|
|
2109
3159
|
if (nativeRouteUnverified || nativeRouteFailed) {
|
|
2110
3160
|
const nativeWarnings = nativeRouteFailed
|
|
2111
3161
|
? nativeObservation?.attempts.map((attempt) => `${attempt.appName}: ${attempt.error ?? attempt.status}`) ?? []
|
|
@@ -2120,7 +3170,7 @@ function health(config: BrowserPluginConfig, repoRoot?: string): AssistantPlugin
|
|
|
2120
3170
|
details: {
|
|
2121
3171
|
...baseDetails,
|
|
2122
3172
|
nativeAttachObservation: nativeObservation,
|
|
2123
|
-
provider: 'macos-active-browser
|
|
3173
|
+
provider: 'macos-active-browser',
|
|
2124
3174
|
userFacingStatus: 'not ready',
|
|
2125
3175
|
},
|
|
2126
3176
|
};
|
|
@@ -2147,7 +3197,9 @@ function health(config: BrowserPluginConfig, repoRoot?: string): AssistantPlugin
|
|
|
2147
3197
|
details: {
|
|
2148
3198
|
...baseDetails,
|
|
2149
3199
|
provider: config.browserMode === 'attach_preferred'
|
|
2150
|
-
? '
|
|
3200
|
+
? config.cdpAttachFallback === 'managed_persistent'
|
|
3201
|
+
? 'cdp-or-macos-active-browser-or-persistent-context'
|
|
3202
|
+
: 'cdp-or-macos-active-browser'
|
|
2151
3203
|
: 'playwright-persistent-context',
|
|
2152
3204
|
userFacingStatus: 'not ready',
|
|
2153
3205
|
},
|
|
@@ -2166,7 +3218,7 @@ function health(config: BrowserPluginConfig, repoRoot?: string): AssistantPlugin
|
|
|
2166
3218
|
provider: config.browserMode === 'attach_preferred'
|
|
2167
3219
|
? 'cdp-or-macos-active-browser-or-persistent-context'
|
|
2168
3220
|
: 'playwright-persistent-context',
|
|
2169
|
-
userFacingStatus: browserUserFacingStatus(config, true,
|
|
3221
|
+
userFacingStatus: browserUserFacingStatus(config, true, savedSessionCount),
|
|
2170
3222
|
},
|
|
2171
3223
|
};
|
|
2172
3224
|
}
|
|
@@ -2186,7 +3238,7 @@ export function buildBrowserPluginManifest(previousRevision = 0, previousUpdated
|
|
|
2186
3238
|
authority: {
|
|
2187
3239
|
strategy: 'derived',
|
|
2188
3240
|
duplicateStateAllowed: false,
|
|
2189
|
-
sourceOfTruth: ['repo-local:.forge/plugins/browser.json', 'repo-local:.forge/browser/'],
|
|
3241
|
+
sourceOfTruth: ['repo-local:.forge/plugins/browser.json', 'controller-home:sqlite/browser_session', 'repo-local:.forge/browser/{screenshots,downloads,diagnostics}'],
|
|
2190
3242
|
},
|
|
2191
3243
|
enabled: config.enabled,
|
|
2192
3244
|
lifecycle: {
|
|
@@ -2194,21 +3246,42 @@ export function buildBrowserPluginManifest(previousRevision = 0, previousUpdated
|
|
|
2194
3246
|
reason: !config.enabled
|
|
2195
3247
|
? 'Browser plugin is disabled.'
|
|
2196
3248
|
: state.ready
|
|
2197
|
-
? `Browser plugin is ready via ${config.browserMode === 'attach_preferred'
|
|
3249
|
+
? `Browser plugin is ready via ${config.browserMode === 'attach_preferred'
|
|
3250
|
+
? config.cdpAttachFallback === 'managed_persistent'
|
|
3251
|
+
? 'CDP, macOS native attach, and explicit managed fallback.'
|
|
3252
|
+
: 'CDP or macOS native attach; managed fallback is disabled.'
|
|
3253
|
+
: 'Playwright persistent context.'}`
|
|
2198
3254
|
: state.state === 'degraded'
|
|
2199
3255
|
? state.warnings[0] ?? 'Browser plugin requires a successful live native attach probe.'
|
|
2200
3256
|
: state.errors[0],
|
|
2201
3257
|
},
|
|
2202
3258
|
health: state,
|
|
2203
|
-
permissions:
|
|
2204
|
-
capabilities:
|
|
2205
|
-
actions:
|
|
3259
|
+
permissions: browserPermissions(state.ready),
|
|
3260
|
+
capabilities: browserCapabilities(),
|
|
3261
|
+
actions: browserActions(),
|
|
2206
3262
|
updatedAt: previousUpdatedAt ?? now(),
|
|
2207
3263
|
};
|
|
2208
3264
|
}
|
|
2209
3265
|
|
|
2210
|
-
|
|
2211
|
-
|
|
3266
|
+
const NODE_BRIDGE_PRE_ACTION_FAILURE_CODES = new Set([
|
|
3267
|
+
'PLUGIN_BROWSER_NODE_UNAVAILABLE',
|
|
3268
|
+
'PLUGIN_BROWSER_NODE_HOST_UNAVAILABLE',
|
|
3269
|
+
'PLUGIN_BROWSER_NODE_REQUEST_TOO_LARGE',
|
|
3270
|
+
'PLUGIN_BROWSER_NODE_START_FAILED',
|
|
3271
|
+
]);
|
|
3272
|
+
|
|
3273
|
+
function browserRuntimeKey(input: AssistantPluginActionExecutionInput): string {
|
|
3274
|
+
return `${input.controllerHome}:${input.repoId}`;
|
|
3275
|
+
}
|
|
3276
|
+
|
|
3277
|
+
async function executeBrowserPluginActionInternal(
|
|
3278
|
+
input: AssistantPluginActionExecutionInput,
|
|
3279
|
+
runtimeAlreadyRouted = false,
|
|
3280
|
+
): Promise<Record<string, unknown>> {
|
|
3281
|
+
const persisted = loadConfig(input.repoRoot);
|
|
3282
|
+
const actionSessionId = input.actionId === 'configure' ? undefined : stringValue(input.args.session_id);
|
|
3283
|
+
const actionSession = actionSessionId ? findBrowserSession(input.repoRoot, actionSessionId) : undefined;
|
|
3284
|
+
const current = input.actionId === 'configure' ? persisted : effectiveBrowserActionConfig(persisted, input.args, actionSession);
|
|
2212
3285
|
if (!current.enabled && input.actionId !== 'configure') {
|
|
2213
3286
|
throw new AssistantPluginError('PLUGIN_DISABLED', 'Browser plugin is disabled.', { retryable: false });
|
|
2214
3287
|
}
|
|
@@ -2218,8 +3291,54 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
|
|
|
2218
3291
|
throw new AssistantPluginError('PLUGIN_CONFIGURATION_INVALID', configErrors[0], { retryable: false });
|
|
2219
3292
|
}
|
|
2220
3293
|
}
|
|
2221
|
-
if (
|
|
2222
|
-
|
|
3294
|
+
if (!runtimeAlreadyRouted) {
|
|
3295
|
+
const runtimeKey = browserRuntimeKey(input);
|
|
3296
|
+
const nodeBridgeEligible = shouldUseBrowserNodeBridge(
|
|
3297
|
+
input.actionId,
|
|
3298
|
+
current.browserMode,
|
|
3299
|
+
runtimeHooksCustomized,
|
|
3300
|
+
cdpEndpoints(current).length > 0,
|
|
3301
|
+
);
|
|
3302
|
+
const result = await executeBrowserRuntimeAction({
|
|
3303
|
+
runtimeKey,
|
|
3304
|
+
input,
|
|
3305
|
+
providers: [
|
|
3306
|
+
...(nodeBridgeEligible ? [{
|
|
3307
|
+
providerId: 'browser.node_bridge',
|
|
3308
|
+
capabilities: ALL_BROWSER_PROVIDER_CAPABILITIES.filter((capability) => capability !== 'browser.persistent_handle'),
|
|
3309
|
+
foreground: 'none' as const,
|
|
3310
|
+
verifiesPostconditions: true,
|
|
3311
|
+
persistentHandle: false,
|
|
3312
|
+
priority: 10,
|
|
3313
|
+
supportsInput: () => true,
|
|
3314
|
+
execute: async (routedInput: AssistantPluginActionExecutionInput) => {
|
|
3315
|
+
try {
|
|
3316
|
+
return await executeBrowserActionThroughNode(routedInput);
|
|
3317
|
+
} catch (error) {
|
|
3318
|
+
if (error instanceof AssistantPluginError && NODE_BRIDGE_PRE_ACTION_FAILURE_CODES.has(error.code)) {
|
|
3319
|
+
throw new BrowserProviderUnavailableBeforeActionError('browser.node_bridge', error.code);
|
|
3320
|
+
}
|
|
3321
|
+
throw error;
|
|
3322
|
+
}
|
|
3323
|
+
},
|
|
3324
|
+
}] : []),
|
|
3325
|
+
{
|
|
3326
|
+
providerId: 'browser.local',
|
|
3327
|
+
capabilities: ALL_BROWSER_PROVIDER_CAPABILITIES,
|
|
3328
|
+
foreground: 'none' as const,
|
|
3329
|
+
verifiesPostconditions: true,
|
|
3330
|
+
persistentHandle: true,
|
|
3331
|
+
priority: 20,
|
|
3332
|
+
supportsInput: () => true,
|
|
3333
|
+
execute: async (routedInput: AssistantPluginActionExecutionInput) => executeBrowserPluginActionInternal(routedInput, true),
|
|
3334
|
+
},
|
|
3335
|
+
],
|
|
3336
|
+
});
|
|
3337
|
+
if (input.actionId === 'configure') {
|
|
3338
|
+
invalidateBrowserRuntime(runtimeKey, 'configuration_changed');
|
|
3339
|
+
invalidateMacOsBrowserPageHandles();
|
|
3340
|
+
}
|
|
3341
|
+
return result;
|
|
2223
3342
|
}
|
|
2224
3343
|
try {
|
|
2225
3344
|
switch (input.actionId) {
|
|
@@ -2265,56 +3384,155 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
|
|
|
2265
3384
|
if (configErrors.length > 0) {
|
|
2266
3385
|
throw new AssistantPluginError('PLUGIN_CONFIGURATION_INVALID', configErrors[0], { retryable: false });
|
|
2267
3386
|
}
|
|
3387
|
+
await closeManagedContextsForRepo(input.repoRoot);
|
|
2268
3388
|
return { config, health: health(config, input.repoRoot) };
|
|
2269
3389
|
}
|
|
2270
|
-
case 'list_sessions':
|
|
3390
|
+
case 'list_sessions': {
|
|
3391
|
+
const inventory = await inspectSavedSessions(input.repoRoot, current);
|
|
3392
|
+
const requestedLimit = Math.max(1, Math.min(Math.trunc(positiveNumber(input.args.limit, DEFAULT_BROWSER_SESSION_LIST_LIMIT)), MAX_BROWSER_SESSION_LIST_LIMIT));
|
|
3393
|
+
let offset = 0;
|
|
3394
|
+
const cursor = stringValue(input.args.cursor);
|
|
3395
|
+
if (cursor) {
|
|
3396
|
+
try {
|
|
3397
|
+
const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as { offset?: unknown };
|
|
3398
|
+
if (!Number.isInteger(parsed.offset) || Number(parsed.offset) < 0) throw new Error('invalid offset');
|
|
3399
|
+
offset = Number(parsed.offset);
|
|
3400
|
+
} catch {
|
|
3401
|
+
throw new AssistantPluginError('PLUGIN_BROWSER_SESSION_CURSOR_INVALID', 'Browser session cursor is invalid or expired.', { retryable: false });
|
|
3402
|
+
}
|
|
3403
|
+
}
|
|
3404
|
+
const page = inventory.sessions.slice(offset, offset + requestedLimit);
|
|
3405
|
+
const nextOffset = offset + page.length;
|
|
2271
3406
|
return {
|
|
2272
3407
|
provider: 'playwright',
|
|
2273
|
-
|
|
3408
|
+
sessionCountSemantics: currentBrowserSessionAuthorityContext() ? 'controller_authority' : 'saved_metadata',
|
|
3409
|
+
scannedSessionCount: inventory.scannedSessionCount,
|
|
3410
|
+
savedSessionCount: inventory.savedSessionCount,
|
|
3411
|
+
liveManagedSessionCount: inventory.liveManagedSessionCount,
|
|
3412
|
+
liveNativeSessionCount: inventory.liveNativeSessionCount,
|
|
3413
|
+
unverifiedSessionCount: inventory.unverifiedSessionCount,
|
|
3414
|
+
deadManagedSessionCount: inventory.deadManagedSessionCount,
|
|
3415
|
+
deadNativeSessionCount: inventory.deadNativeSessionCount,
|
|
3416
|
+
limit: requestedLimit,
|
|
3417
|
+
totalCount: inventory.sessions.length,
|
|
3418
|
+
...(nextOffset < inventory.sessions.length ? { nextCursor: Buffer.from(JSON.stringify({ offset: nextOffset }), 'utf8').toString('base64url') } : {}),
|
|
3419
|
+
sessions: page.map(({ session, liveness, evidence, cleanupError }) => ({
|
|
2274
3420
|
sessionId: session.sessionId,
|
|
2275
3421
|
url: session.url,
|
|
2276
3422
|
title: session.title,
|
|
2277
3423
|
createdAt: session.createdAt,
|
|
2278
3424
|
updatedAt: session.updatedAt,
|
|
2279
3425
|
browser: session.browser,
|
|
3426
|
+
liveness,
|
|
3427
|
+
livenessEvidence: evidence,
|
|
3428
|
+
...(cleanupError ? { cleanupError } : {}),
|
|
2280
3429
|
})),
|
|
2281
3430
|
};
|
|
3431
|
+
}
|
|
3432
|
+
case 'reconcile_sessions': {
|
|
3433
|
+
assertBrowserSessionAvailable(input.repoRoot);
|
|
3434
|
+
const inventory = await inspectSavedSessions(input.repoRoot, current, { pruneDead: true });
|
|
3435
|
+
return {
|
|
3436
|
+
reconciled: true,
|
|
3437
|
+
sessionCountSemantics: 'saved_metadata',
|
|
3438
|
+
scannedSessionCount: inventory.scannedSessionCount,
|
|
3439
|
+
savedSessionCount: inventory.savedSessionCount,
|
|
3440
|
+
liveManagedSessionCount: inventory.liveManagedSessionCount,
|
|
3441
|
+
liveNativeSessionCount: inventory.liveNativeSessionCount,
|
|
3442
|
+
unverifiedSessionCount: inventory.unverifiedSessionCount,
|
|
3443
|
+
deadManagedSessionCount: inventory.deadManagedSessionCount,
|
|
3444
|
+
deadNativeSessionCount: inventory.deadNativeSessionCount,
|
|
3445
|
+
closedInvalidNativeSessionCount: inventory.closedInvalidNativeSessionCount,
|
|
3446
|
+
failedNativeCleanupCount: inventory.failedNativeCleanupCount,
|
|
3447
|
+
prunedSessionCount: inventory.prunedSessionCount,
|
|
3448
|
+
prunedSessionIds: inventory.sessions.filter((item) => item.pruned).map((item) => item.session.sessionId),
|
|
3449
|
+
};
|
|
3450
|
+
}
|
|
2282
3451
|
case 'close_session':
|
|
2283
3452
|
case 'close_page': {
|
|
2284
3453
|
const sessionId = requiredString(input.args.session_id, 'session_id');
|
|
2285
3454
|
assertBrowserSessionAvailable(input.repoRoot, sessionId);
|
|
2286
|
-
const session =
|
|
3455
|
+
const session = findBrowserSession(input.repoRoot, sessionId);
|
|
2287
3456
|
const tab = session?.browser?.tab;
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
3457
|
+
const managedClosed = session ? await closeManagedSessionPage(input.repoRoot, current, session) : false;
|
|
3458
|
+
const nativeClose = session ? await closeTrackedNativeOwnedSession(session, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS) : { resourceClosed: false, resourceAlreadyMissing: false };
|
|
3459
|
+
const closedSessionIds = session ? nativeOwnedAliasSessionIds(input.repoRoot, session) : [sessionId];
|
|
3460
|
+
for (const closedSessionId of closedSessionIds) removeBrowserSession(input.repoRoot, closedSessionId);
|
|
3461
|
+
return {
|
|
3462
|
+
closed: true, sessionId,
|
|
3463
|
+
resourceClosed: managedClosed || nativeClose.resourceClosed,
|
|
3464
|
+
...(closedSessionIds.length > 1 ? { closedSessionIds } : {}),
|
|
3465
|
+
...(nativeClose.resourceAlreadyMissing ? { resourceAlreadyMissing: true } : {}),
|
|
3466
|
+
...(tab?.ownership === 'user_owned' ? { preservedUserOwnedTab: true } : {}),
|
|
3467
|
+
};
|
|
2298
3468
|
}
|
|
2299
3469
|
case 'clear_session': {
|
|
2300
3470
|
assertBrowserSessionAvailable(input.repoRoot);
|
|
2301
3471
|
const sessions = listSavedSessions(input.repoRoot);
|
|
3472
|
+
await closeManagedContextsForRepo(input.repoRoot, { strict: true });
|
|
3473
|
+
const failedSessionIds: string[] = [];
|
|
3474
|
+
const cleanupErrors: Array<{ sessionId: string; error: string }> = [];
|
|
3475
|
+
let removedCount = 0;
|
|
3476
|
+
let resourceClosedCount = 0;
|
|
3477
|
+
let resourceAlreadyMissingCount = 0;
|
|
2302
3478
|
for (const session of sessions) {
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
3479
|
+
try {
|
|
3480
|
+
const nativeClose = await closeTrackedNativeOwnedSession(session, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
3481
|
+
if (nativeClose.resourceClosed) resourceClosedCount += 1;
|
|
3482
|
+
if (nativeClose.resourceAlreadyMissing) resourceAlreadyMissingCount += 1;
|
|
3483
|
+
removeBrowserSession(input.repoRoot, session.sessionId);
|
|
3484
|
+
removedCount += 1;
|
|
3485
|
+
} catch (error) {
|
|
3486
|
+
failedSessionIds.push(session.sessionId);
|
|
3487
|
+
cleanupErrors.push({ sessionId: session.sessionId, error: error instanceof Error ? error.message : String(error) });
|
|
2311
3488
|
}
|
|
2312
|
-
rmSync(sessionPath(input.repoRoot, session.sessionId), { force: true });
|
|
2313
3489
|
}
|
|
2314
|
-
return {
|
|
3490
|
+
return {
|
|
3491
|
+
cleared: failedSessionIds.length === 0,
|
|
3492
|
+
count: removedCount,
|
|
3493
|
+
requestedCount: sessions.length,
|
|
3494
|
+
resourceClosedCount,
|
|
3495
|
+
resourceAlreadyMissingCount,
|
|
3496
|
+
failedSessionIds,
|
|
3497
|
+
cleanupErrors,
|
|
3498
|
+
};
|
|
2315
3499
|
}
|
|
2316
3500
|
case 'request_human_handoff': {
|
|
2317
3501
|
const target = resolveActionTarget(input.repoRoot, input.args);
|
|
3502
|
+
const managedSession = target.existingSession;
|
|
3503
|
+
const managedTarget = managedSession ? await runtimeManagedPageForSession(input.repoRoot, current, managedSession) : undefined;
|
|
3504
|
+
if (managedSession && managedTarget) {
|
|
3505
|
+
const result = { url: managedTarget.page.url(), title: await managedTarget.page.title().catch(() => managedSession.title) };
|
|
3506
|
+
const handoff = startRuntimeManagedBrowserHandoff({
|
|
3507
|
+
repoRoot: input.repoRoot,
|
|
3508
|
+
repoId: input.repoId,
|
|
3509
|
+
requestId: input.requestId,
|
|
3510
|
+
jobId: input.jobId,
|
|
3511
|
+
sessionId: target.sessionId,
|
|
3512
|
+
url: target.url,
|
|
3513
|
+
profileDir: managedTarget.profile.profileDir,
|
|
3514
|
+
selectedProfilePath: managedTarget.profile.selectedProfilePath,
|
|
3515
|
+
profileDirectory: managedTarget.profile.profileDirectory,
|
|
3516
|
+
browserChannel: current.browserChannel,
|
|
3517
|
+
executablePath: current.executablePath ? resolveConfiguredPath(input.repoRoot, current.executablePath) : undefined,
|
|
3518
|
+
defaultTimeoutMs: current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
3519
|
+
reason: stringValue(input.args.reason) ?? 'manual_review',
|
|
3520
|
+
instructions: stringValue(input.args.instructions),
|
|
3521
|
+
timeoutMs: typeof input.args.handoff_timeout_ms === 'number' ? input.args.handoff_timeout_ms : undefined,
|
|
3522
|
+
}, result);
|
|
3523
|
+
try {
|
|
3524
|
+
if (managedTarget.page.bringToFront) await managedTarget.page.bringToFront();
|
|
3525
|
+
} catch (error) {
|
|
3526
|
+
resolveRuntimeManagedBrowserHandoff(input.repoRoot, handoff.interactionId, 'cancel', result);
|
|
3527
|
+
throw error;
|
|
3528
|
+
}
|
|
3529
|
+
return {
|
|
3530
|
+
provider: 'playwright-runtime-managed-handoff',
|
|
3531
|
+
handoff,
|
|
3532
|
+
session: managedSession,
|
|
3533
|
+
nextAction: 'Complete the manual step, then call resolve_handoff with resolution=resume. Its response contains the updated handoff state; call get_handoff_status only when a later decision needs an observation, not as periodic polling.',
|
|
3534
|
+
};
|
|
3535
|
+
}
|
|
2318
3536
|
const nativeSession = target.existingSession;
|
|
2319
3537
|
const nativeTab = nativeSession?.browser?.tab;
|
|
2320
3538
|
const nativeBrowser = nativeSession?.browser?.provider === 'macos-apple-events'
|
|
@@ -2339,7 +3557,6 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
|
|
|
2339
3557
|
requestId: input.requestId,
|
|
2340
3558
|
jobId: input.jobId,
|
|
2341
3559
|
sessionId: target.sessionId,
|
|
2342
|
-
sessionPath: sessionPath(input.repoRoot, target.sessionId),
|
|
2343
3560
|
url: target.url,
|
|
2344
3561
|
profileDir: profile.profileDir,
|
|
2345
3562
|
selectedProfilePath,
|
|
@@ -2356,14 +3573,29 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
|
|
|
2356
3573
|
provider: nativeBrowser ? 'macos-apple-events-handoff-host' : 'playwright-handoff-host',
|
|
2357
3574
|
handoff,
|
|
2358
3575
|
session: target.existingSession,
|
|
2359
|
-
nextAction: 'Complete the manual step, call resolve_handoff with resolution=resume,
|
|
3576
|
+
nextAction: 'Complete the manual step, then call resolve_handoff with resolution=resume. Its response contains the updated handoff state; call get_handoff_status only when a later decision needs an observation, not as periodic polling.',
|
|
2360
3577
|
};
|
|
2361
3578
|
}
|
|
2362
3579
|
case 'get_handoff_status': {
|
|
2363
3580
|
const interactionId = requiredString(input.args.interaction_id, 'interaction_id');
|
|
2364
3581
|
const handoff = getBrowserHandoff(input.repoRoot, interactionId);
|
|
3582
|
+
if (handoff.status === 'completed' && handoff.result?.url) {
|
|
3583
|
+
const session = findBrowserSession(input.repoRoot, handoff.sessionId);
|
|
3584
|
+
if (session) {
|
|
3585
|
+
saveBrowserSession(input.repoRoot, {
|
|
3586
|
+
...session,
|
|
3587
|
+
url: handoff.result.url,
|
|
3588
|
+
title: handoff.result.title,
|
|
3589
|
+
updatedAt: new Date().toISOString(),
|
|
3590
|
+
});
|
|
3591
|
+
}
|
|
3592
|
+
}
|
|
2365
3593
|
return {
|
|
2366
|
-
provider: handoff
|
|
3594
|
+
provider: isRuntimeManagedBrowserHandoff(handoff)
|
|
3595
|
+
? 'playwright-runtime-managed-handoff'
|
|
3596
|
+
: handoff.targetId.startsWith('macos-apple-events:')
|
|
3597
|
+
? 'macos-apple-events-handoff-host'
|
|
3598
|
+
: 'playwright-handoff-host',
|
|
2367
3599
|
handoff,
|
|
2368
3600
|
};
|
|
2369
3601
|
}
|
|
@@ -2373,9 +3605,42 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
|
|
|
2373
3605
|
if (resolution !== 'resume' && resolution !== 'cancel') {
|
|
2374
3606
|
throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'resolution must be resume or cancel', { retryable: false });
|
|
2375
3607
|
}
|
|
3608
|
+
const currentHandoff = getBrowserHandoff(input.repoRoot, interactionId);
|
|
3609
|
+
if (isRuntimeManagedBrowserHandoff(currentHandoff)) {
|
|
3610
|
+
const session = findBrowserSession(input.repoRoot, currentHandoff.sessionId);
|
|
3611
|
+
const managedTarget = session ? await runtimeManagedPageForSession(input.repoRoot, current, session) : undefined;
|
|
3612
|
+
const result = managedTarget
|
|
3613
|
+
? { url: managedTarget.page.url(), title: await managedTarget.page.title().catch(() => session?.title) }
|
|
3614
|
+
: currentHandoff.result;
|
|
3615
|
+
if (resolution === 'resume' && session && result?.url) {
|
|
3616
|
+
saveBrowserSession(input.repoRoot, {
|
|
3617
|
+
...session,
|
|
3618
|
+
url: result.url,
|
|
3619
|
+
title: result.title,
|
|
3620
|
+
updatedAt: new Date().toISOString(),
|
|
3621
|
+
});
|
|
3622
|
+
}
|
|
3623
|
+
const handoff = resolveRuntimeManagedBrowserHandoff(input.repoRoot, interactionId, resolution, result);
|
|
3624
|
+
return {
|
|
3625
|
+
provider: 'playwright-runtime-managed-handoff',
|
|
3626
|
+
resolutionRequested: resolution,
|
|
3627
|
+
handoff,
|
|
3628
|
+
};
|
|
3629
|
+
}
|
|
2376
3630
|
const handoff = resolution === 'resume'
|
|
2377
|
-
?
|
|
3631
|
+
? await resumeBrowserHandoffAndWait(input.repoRoot, interactionId, input.requestId)
|
|
2378
3632
|
: cancelBrowserHandoff(input.repoRoot, interactionId, input.requestId);
|
|
3633
|
+
if (resolution === 'resume' && handoff.status === 'completed' && handoff.result?.url) {
|
|
3634
|
+
const session = findBrowserSession(input.repoRoot, handoff.sessionId);
|
|
3635
|
+
if (session) {
|
|
3636
|
+
saveBrowserSession(input.repoRoot, {
|
|
3637
|
+
...session,
|
|
3638
|
+
url: handoff.result.url,
|
|
3639
|
+
title: handoff.result.title,
|
|
3640
|
+
updatedAt: new Date().toISOString(),
|
|
3641
|
+
});
|
|
3642
|
+
}
|
|
3643
|
+
}
|
|
2379
3644
|
return {
|
|
2380
3645
|
provider: handoff.targetId.startsWith('macos-apple-events:') ? 'macos-apple-events-handoff-host' : 'playwright-handoff-host',
|
|
2381
3646
|
resolutionRequested: resolution,
|
|
@@ -2385,17 +3650,152 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
|
|
|
2385
3650
|
case 'create_session':
|
|
2386
3651
|
case 'open_page':
|
|
2387
3652
|
case 'navigate': {
|
|
2388
|
-
const
|
|
3653
|
+
const nativeProduct = stringValue(input.args.native_browser_product);
|
|
3654
|
+
const normalizedNativeProduct: MacOsBrowserProduct | undefined = nativeProduct === 'chrome' || nativeProduct === 'vivaldi' ? nativeProduct : undefined;
|
|
3655
|
+
const nativeWindowId = stringValue(input.args.native_window_id);
|
|
3656
|
+
const nativeTabId = stringValue(input.args.native_tab_id);
|
|
3657
|
+
const nativeActiveTab = input.args.native_active_tab === true;
|
|
3658
|
+
const hasNativeAdoption = Boolean(nativeProduct || nativeWindowId || nativeTabId || nativeActiveTab);
|
|
3659
|
+
const url = hasNativeAdoption && input.actionId === 'create_session'
|
|
3660
|
+
? normalizedExplicitNativeAdoptionUrl(input.args.url)
|
|
3661
|
+
: normalizedUrl(input.args.url);
|
|
2389
3662
|
const existingSessionId = stringValue(input.args.session_id);
|
|
2390
3663
|
const target: BrowserActionTarget = existingSessionId
|
|
2391
|
-
? { sessionId: existingSessionId, url, existingSession:
|
|
3664
|
+
? { sessionId: existingSessionId, url, existingSession: findBrowserSession(input.repoRoot, existingSessionId) }
|
|
2392
3665
|
: { sessionId: sessionIdFor(url), url };
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
3666
|
+
if (hasNativeAdoption) {
|
|
3667
|
+
if (input.actionId !== 'create_session' || (nativeProduct && !normalizedNativeProduct)) {
|
|
3668
|
+
throw new AssistantPluginError(
|
|
3669
|
+
'PLUGIN_ACTION_ARGUMENT_INVALID',
|
|
3670
|
+
'Explicit native tab adoption is only supported by create_session for Chrome or Vivaldi.',
|
|
3671
|
+
{ retryable: false },
|
|
3672
|
+
);
|
|
3673
|
+
}
|
|
3674
|
+
const timeout = positiveNumber(input.args.timeout_ms, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
3675
|
+
let adoptedProduct: MacOsBrowserProduct;
|
|
3676
|
+
let adoptedRef: MacOsBrowserTabRef;
|
|
3677
|
+
let adoptedSystemFrontmost = false;
|
|
3678
|
+
if (nativeActiveTab) {
|
|
3679
|
+
if (nativeWindowId || nativeTabId) {
|
|
3680
|
+
throw new AssistantPluginError(
|
|
3681
|
+
'PLUGIN_ACTION_ARGUMENT_INVALID',
|
|
3682
|
+
'native_active_tab cannot be combined with native_window_id or native_tab_id.',
|
|
3683
|
+
{ retryable: false },
|
|
3684
|
+
);
|
|
3685
|
+
}
|
|
3686
|
+
const configuredCandidates = (current.nativeBrowserCandidates ?? [...DEFAULT_USER_NATIVE_BROWSER_CANDIDATES])
|
|
3687
|
+
.filter((candidate): candidate is MacOsBrowserProduct => candidate === 'chrome' || candidate === 'vivaldi');
|
|
3688
|
+
const candidates: MacOsBrowserProduct[] = normalizedNativeProduct ? [normalizedNativeProduct] : configuredCandidates;
|
|
3689
|
+
const discovered = await discoverMacOsBrowserAttachment(candidates, Math.min(timeout, MAX_CDP_DISCOVERY_TIMEOUT_MS));
|
|
3690
|
+
const activeMetadata = discovered.attachment?.metadata;
|
|
3691
|
+
const requiresSystemFrontmost = !normalizedNativeProduct;
|
|
3692
|
+
if (!activeMetadata || (requiresSystemFrontmost && (!activeMetadata.frontmost || activeMetadata.active !== true))) {
|
|
3693
|
+
throw new AssistantPluginError(
|
|
3694
|
+
'PLUGIN_BROWSER_ACTIVE_TAB_UNAVAILABLE',
|
|
3695
|
+
requiresSystemFrontmost
|
|
3696
|
+
? 'No system-frontmost active native browser tab with a stable native identity is available for adoption.'
|
|
3697
|
+
: 'The explicitly selected native browser does not expose an active tab with a stable native identity.',
|
|
3698
|
+
{ retryable: true, details: { browserProduct: nativeProduct, requiresSystemFrontmost, attempts: discovered.attempts } },
|
|
3699
|
+
);
|
|
3700
|
+
}
|
|
3701
|
+
adoptedSystemFrontmost = activeMetadata.frontmost === true && activeMetadata.active === true;
|
|
3702
|
+
adoptedProduct = activeMetadata.product;
|
|
3703
|
+
if (activeMetadata.windowId && activeMetadata.tabId) {
|
|
3704
|
+
adoptedRef = { windowId: activeMetadata.windowId, tabId: activeMetadata.tabId };
|
|
3705
|
+
} else {
|
|
3706
|
+
const inventory = await listMacOsBrowserTabs(adoptedProduct, Math.min(timeout, MAX_CDP_DISCOVERY_TIMEOUT_MS));
|
|
3707
|
+
const matchingActiveTabs = inventory.tabs.filter((candidate) => candidate.active
|
|
3708
|
+
&& comparableUrl(candidate.url) === comparableUrl(activeMetadata.url)
|
|
3709
|
+
&& (!activeMetadata.windowId || candidate.windowId === activeMetadata.windowId)
|
|
3710
|
+
&& (!activeMetadata.tabId || candidate.tabId === activeMetadata.tabId));
|
|
3711
|
+
if (matchingActiveTabs.length !== 1) {
|
|
3712
|
+
throw new AssistantPluginError(
|
|
3713
|
+
'PLUGIN_BROWSER_ACTIVE_TAB_UNAVAILABLE',
|
|
3714
|
+
'The explicitly selected native browser active tab metadata lacks a stable identity and tab inventory did not resolve exactly one matching active tab.',
|
|
3715
|
+
{
|
|
3716
|
+
retryable: true,
|
|
3717
|
+
details: {
|
|
3718
|
+
browserProduct: adoptedProduct,
|
|
3719
|
+
requiresSystemFrontmost,
|
|
3720
|
+
observedUrl: activeMetadata.url,
|
|
3721
|
+
observedTitle: activeMetadata.title,
|
|
3722
|
+
observedWindowId: activeMetadata.windowId,
|
|
3723
|
+
observedTabId: activeMetadata.tabId,
|
|
3724
|
+
matchingActiveTabCount: matchingActiveTabs.length,
|
|
3725
|
+
inventoryTruncated: inventory.truncated,
|
|
3726
|
+
attempts: discovered.attempts,
|
|
3727
|
+
},
|
|
3728
|
+
},
|
|
3729
|
+
);
|
|
3730
|
+
}
|
|
3731
|
+
adoptedRef = { windowId: matchingActiveTabs[0]!.windowId, tabId: matchingActiveTabs[0]!.tabId };
|
|
3732
|
+
}
|
|
3733
|
+
} else {
|
|
3734
|
+
if (!normalizedNativeProduct || !nativeWindowId || !nativeTabId) {
|
|
3735
|
+
throw new AssistantPluginError(
|
|
3736
|
+
'PLUGIN_ACTION_ARGUMENT_INVALID',
|
|
3737
|
+
'Explicit native tab adoption requires native_active_tab=true or native_browser_product with native_window_id and native_tab_id.',
|
|
3738
|
+
{ retryable: false },
|
|
3739
|
+
);
|
|
3740
|
+
}
|
|
3741
|
+
adoptedProduct = normalizedNativeProduct;
|
|
3742
|
+
adoptedRef = { windowId: nativeWindowId, tabId: nativeTabId };
|
|
3743
|
+
}
|
|
3744
|
+
const adopted = await reattachMacOsBrowserOwnedPage(adoptedProduct, adoptedRef, timeout);
|
|
3745
|
+
const metadata = adopted.attachment.metadata;
|
|
3746
|
+
const pageUrl = normalizedExplicitNativeAdoptionUrl(metadata.url);
|
|
3747
|
+
const requestedUrl = normalizedExplicitNativeAdoptionUrl(url);
|
|
3748
|
+
const urlsMatch = explicitNativeAdoptionUrlsMatch(requestedUrl, pageUrl, adoptedProduct);
|
|
3749
|
+
const pageProtocol = new URL(pageUrl).protocol;
|
|
3750
|
+
if (((pageProtocol === 'chrome:' && adoptedProduct !== 'chrome') || (pageProtocol === 'vivaldi:' && adoptedProduct !== 'vivaldi')) && !urlsMatch) {
|
|
3751
|
+
throw new AssistantPluginError(
|
|
3752
|
+
'PLUGIN_BROWSER_ADOPTED_TAB_URL_MISMATCH',
|
|
3753
|
+
'Explicitly adopted native tab uses an internal URL scheme that does not match the selected browser.',
|
|
3754
|
+
{ retryable: false, details: { actualUrl: pageUrl, browserProduct: adoptedProduct, windowId: adoptedRef.windowId, tabId: adoptedRef.tabId } },
|
|
3755
|
+
);
|
|
3756
|
+
}
|
|
3757
|
+
if (!urlsMatch) {
|
|
3758
|
+
throw new AssistantPluginError(
|
|
3759
|
+
'PLUGIN_BROWSER_ADOPTED_TAB_URL_MISMATCH',
|
|
3760
|
+
'Explicitly adopted native tab URL does not match the requested session URL.',
|
|
3761
|
+
{ retryable: false, details: { requestedUrl, actualUrl: pageUrl, browserProduct: adoptedProduct, windowId: adoptedRef.windowId, tabId: adoptedRef.tabId } },
|
|
3762
|
+
);
|
|
3763
|
+
}
|
|
3764
|
+
const title = metadata.title || '';
|
|
3765
|
+
const connection = refreshConnectionTab({
|
|
3766
|
+
requestedMode: 'attach_preferred',
|
|
3767
|
+
mode: 'attach_preferred',
|
|
3768
|
+
provider: 'macos-apple-events',
|
|
3769
|
+
attached: true,
|
|
3770
|
+
browserProduct: adoptedProduct,
|
|
3771
|
+
profile: { selectedProfilePath: `macos:${adoptedProduct}:user-tab` },
|
|
3772
|
+
tabInventory: [{ index: 0, key: tabKey(requestedUrl, title), url: requestedUrl, title }],
|
|
3773
|
+
sessionResume: {
|
|
3774
|
+
sessionId: target.sessionId,
|
|
3775
|
+
status: 'matched',
|
|
3776
|
+
reason: nativeActiveTab && adoptedSystemFrontmost
|
|
3777
|
+
? 'Explicitly adopted the matching system-frontmost active user-owned native browser tab.'
|
|
3778
|
+
: nativeActiveTab
|
|
3779
|
+
? 'Explicitly adopted the selected browser active tab; system-frontmost authority was not established.'
|
|
3780
|
+
: 'Explicitly adopted an existing user-owned native browser tab.',
|
|
3781
|
+
},
|
|
3782
|
+
}, requestedUrl, title, 'saved_url', { ownership: 'user_owned', windowId: adoptedRef.windowId, tabId: adoptedRef.tabId });
|
|
3783
|
+
const session = saveBrowserSession(input.repoRoot, sessionFromPage(target, requestedUrl, title, connection));
|
|
3784
|
+
return {
|
|
3785
|
+
provider: 'macos-apple-events',
|
|
3786
|
+
session,
|
|
3787
|
+
navigation: { url: requestedUrl },
|
|
3788
|
+
browserConnection: { ...connection, tab: session.browser?.tab, sessionResume: session.browser?.sessionResume },
|
|
3789
|
+
...(input.args.extract_text === true
|
|
3790
|
+
? { text: await extractText(adopted.page as unknown as PageLike, undefined, positiveNumber(input.args.max_chars, DEFAULT_MAX_TEXT_CHARS)) }
|
|
3791
|
+
: {}),
|
|
3792
|
+
};
|
|
3793
|
+
}
|
|
3794
|
+
return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, diagnostics, connection) => {
|
|
3795
|
+
const identity = await readPageIdentity(page, connection, target.url);
|
|
3796
|
+
const session = saveBrowserSession(input.repoRoot, sessionFromPage(target, identity.url, identity.title, connection));
|
|
2397
3797
|
return {
|
|
2398
|
-
provider:
|
|
3798
|
+
provider: browserResultProvider(connection.provider),
|
|
2399
3799
|
session,
|
|
2400
3800
|
navigation: diagnostics.navigation,
|
|
2401
3801
|
browserConnection: {
|
|
@@ -2407,13 +3807,13 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
|
|
|
2407
3807
|
? { text: await extractText(page, undefined, positiveNumber(input.args.max_chars, DEFAULT_MAX_TEXT_CHARS)) }
|
|
2408
3808
|
: {}),
|
|
2409
3809
|
};
|
|
2410
|
-
});
|
|
3810
|
+
}, { requireExistingResource: Boolean(existingSessionId) });
|
|
2411
3811
|
}
|
|
2412
3812
|
case 'reload':
|
|
2413
3813
|
case 'go_back':
|
|
2414
3814
|
case 'wait_for_load_state': {
|
|
2415
3815
|
const target = resolveActionTarget(input.repoRoot, input.args);
|
|
2416
|
-
return await withPage(input.repoRoot, current, target, input.args, async (page, diagnostics, connection) => {
|
|
3816
|
+
return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, diagnostics, connection) => {
|
|
2417
3817
|
if (input.actionId === 'reload') {
|
|
2418
3818
|
if (page.reload) await page.reload({ waitUntil: waitUntil(input.args.wait_until), timeout: positiveNumber(input.args.timeout_ms, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS) });
|
|
2419
3819
|
else await page.goto(target.url, { waitUntil: waitUntil(input.args.wait_until), timeout: positiveNumber(input.args.timeout_ms, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS) });
|
|
@@ -2425,10 +3825,9 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
|
|
|
2425
3825
|
});
|
|
2426
3826
|
}
|
|
2427
3827
|
const identity = await readPageIdentity(page, connection);
|
|
2428
|
-
const session = sessionFromPage(target, identity.url, identity.title, connection);
|
|
2429
|
-
saveSession(input.repoRoot, session);
|
|
3828
|
+
const session = saveBrowserSession(input.repoRoot, sessionFromPage(target, identity.url, identity.title, connection));
|
|
2430
3829
|
return {
|
|
2431
|
-
provider:
|
|
3830
|
+
provider: browserResultProvider(connection.provider),
|
|
2432
3831
|
session,
|
|
2433
3832
|
navigation: diagnostics.navigation,
|
|
2434
3833
|
actionId: input.actionId,
|
|
@@ -2438,28 +3837,34 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
|
|
|
2438
3837
|
sessionResume: session.browser?.sessionResume,
|
|
2439
3838
|
},
|
|
2440
3839
|
};
|
|
2441
|
-
}
|
|
3840
|
+
}, input.actionId === 'reload'
|
|
3841
|
+
? { requireExistingResource: true, pruneStaleSessionMetadata: true }
|
|
3842
|
+
: {});
|
|
2442
3843
|
}
|
|
2443
3844
|
case 'get_text': {
|
|
2444
3845
|
const target = resolveActionTarget(input.repoRoot, input.args);
|
|
2445
|
-
return {
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
3846
|
+
return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
|
|
3847
|
+
const selection = resolveBrowserEvaluationScope(page, input.args, connection);
|
|
3848
|
+
return {
|
|
3849
|
+
provider: browserResultProvider(connection.provider),
|
|
3850
|
+
sessionId: target.sessionId,
|
|
3851
|
+
url: target.url,
|
|
3852
|
+
...(await extractText(selection.scope, stringValue(input.args.selector), positiveNumber(input.args.max_chars, DEFAULT_MAX_TEXT_CHARS))),
|
|
3853
|
+
...(selection.frame ? { frame: selection.frame } : {}),
|
|
2451
3854
|
browserConnection: connection,
|
|
2452
|
-
}
|
|
2453
|
-
};
|
|
3855
|
+
};
|
|
3856
|
+
}, { persistSession: true });
|
|
2454
3857
|
}
|
|
2455
3858
|
case 'get_html': {
|
|
2456
3859
|
const target = resolveActionTarget(input.repoRoot, input.args);
|
|
2457
|
-
return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
|
|
2458
|
-
const
|
|
3860
|
+
return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
|
|
3861
|
+
const selection = resolveBrowserEvaluationScope(page, input.args, connection);
|
|
3862
|
+
const raw = await selection.scope.evaluate<string>(EXTRACTION_SCRIPTS.html(stringValue(input.args.selector)));
|
|
2459
3863
|
return {
|
|
2460
|
-
provider:
|
|
3864
|
+
provider: browserResultProvider(connection.provider),
|
|
2461
3865
|
sessionId: target.sessionId,
|
|
2462
3866
|
url: target.url,
|
|
3867
|
+
...(selection.frame ? { frame: selection.frame } : {}),
|
|
2463
3868
|
browserConnection: connection,
|
|
2464
3869
|
...truncateText(raw, positiveNumber(input.args.max_chars, DEFAULT_MAX_TEXT_CHARS)),
|
|
2465
3870
|
};
|
|
@@ -2470,13 +3875,15 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
|
|
|
2470
3875
|
const target = resolveActionTarget(input.repoRoot, input.args);
|
|
2471
3876
|
const selector = requiredString(input.args.selector, 'selector');
|
|
2472
3877
|
const limit = Math.min(positiveNumber(input.args.limit, 25), 100);
|
|
2473
|
-
return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
|
|
2474
|
-
const
|
|
3878
|
+
return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
|
|
3879
|
+
const selection = resolveBrowserEvaluationScope(page, input.args, connection);
|
|
3880
|
+
const matches = await selection.scope.evaluate<Array<Record<string, unknown>>>(EXTRACTION_SCRIPTS.query(selector, input.actionId === 'query_selector' ? 1 : limit));
|
|
2475
3881
|
return {
|
|
2476
|
-
provider:
|
|
3882
|
+
provider: browserResultProvider(connection.provider),
|
|
2477
3883
|
sessionId: target.sessionId,
|
|
2478
3884
|
url: target.url,
|
|
2479
3885
|
selector,
|
|
3886
|
+
...(selection.frame ? { frame: selection.frame } : {}),
|
|
2480
3887
|
browserConnection: connection,
|
|
2481
3888
|
...(input.actionId === 'query_selector'
|
|
2482
3889
|
? { match: matches[0] ?? null, found: matches.length > 0 }
|
|
@@ -2488,21 +3895,101 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
|
|
|
2488
3895
|
const target = resolveActionTarget(input.repoRoot, input.args);
|
|
2489
3896
|
const selector = requiredString(input.args.selector, 'selector');
|
|
2490
3897
|
const attribute = requiredString(input.args.attribute, 'attribute');
|
|
2491
|
-
return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) =>
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
3898
|
+
return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
|
|
3899
|
+
const selection = resolveBrowserEvaluationScope(page, input.args, connection);
|
|
3900
|
+
return {
|
|
3901
|
+
provider: browserResultProvider(connection.provider),
|
|
3902
|
+
sessionId: target.sessionId,
|
|
3903
|
+
url: target.url,
|
|
3904
|
+
selector,
|
|
3905
|
+
attribute,
|
|
3906
|
+
...(selection.frame ? { frame: selection.frame } : {}),
|
|
3907
|
+
browserConnection: connection,
|
|
3908
|
+
value: await selection.scope.evaluate<string | null>(EXTRACTION_SCRIPTS.attribute(selector, attribute)),
|
|
3909
|
+
};
|
|
3910
|
+
}, { persistSession: true });
|
|
3911
|
+
}
|
|
3912
|
+
case 'list_frames': {
|
|
3913
|
+
const target = resolveActionTarget(input.repoRoot, input.args);
|
|
3914
|
+
const limit = Math.min(positiveNumber(input.args.limit, 50), 100);
|
|
3915
|
+
return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
|
|
3916
|
+
if (connection.provider === 'macos-apple-events' || !page.frames) {
|
|
3917
|
+
throw new AssistantPluginError('PLUGIN_BROWSER_FRAME_SCOPE_UNAVAILABLE', 'Frame discovery requires a Playwright/CDP-controlled page; native Apple Events sessions fail closed.', {
|
|
3918
|
+
retryable: false,
|
|
3919
|
+
details: { provider: connection.provider },
|
|
3920
|
+
});
|
|
3921
|
+
}
|
|
3922
|
+
const allFrames = page.frames();
|
|
3923
|
+
const frames = allFrames.slice(0, limit).map((frame) => ({ url: frame.url(), name: frame.name() }));
|
|
3924
|
+
return {
|
|
3925
|
+
provider: browserResultProvider(connection.provider),
|
|
3926
|
+
sessionId: target.sessionId,
|
|
3927
|
+
url: target.url,
|
|
3928
|
+
frames,
|
|
3929
|
+
count: allFrames.length,
|
|
3930
|
+
truncated: allFrames.length > frames.length,
|
|
3931
|
+
browserConnection: connection,
|
|
3932
|
+
};
|
|
3933
|
+
}, { persistSession: true });
|
|
3934
|
+
}
|
|
3935
|
+
case 'verify_state': {
|
|
3936
|
+
requiredString(input.args.session_id, 'session_id');
|
|
3937
|
+
const target = resolveActionTarget(input.repoRoot, input.args);
|
|
3938
|
+
const expectedUrlRaw = stringValue(input.args.expected_url);
|
|
3939
|
+
const expectedUrl = expectedUrlRaw ? normalizedUrl(expectedUrlRaw) : undefined;
|
|
3940
|
+
const urlContains = stringValue(input.args.url_contains);
|
|
3941
|
+
const selector = stringValue(input.args.selector);
|
|
3942
|
+
const requireVisible = input.args.require_visible === true;
|
|
3943
|
+
const textContains = stringValue(input.args.text_contains);
|
|
3944
|
+
if (!expectedUrl && !urlContains && !selector && !textContains) {
|
|
3945
|
+
throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'verify_state requires at least one of expected_url, url_contains, selector, or text_contains.', { retryable: false });
|
|
3946
|
+
}
|
|
3947
|
+
if (requireVisible && !selector) {
|
|
3948
|
+
throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'require_visible requires selector.', { retryable: false });
|
|
3949
|
+
}
|
|
3950
|
+
return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
|
|
3951
|
+
const selection = resolveBrowserEvaluationScope(page, input.args, connection);
|
|
3952
|
+
const observedUrl = selection.frame?.url ?? page.url();
|
|
3953
|
+
const checks: Array<Record<string, unknown>> = [];
|
|
3954
|
+
if (expectedUrl) checks.push({ criterion: 'url_exact', expected: expectedUrl, observed: observedUrl, matched: observedUrl === expectedUrl });
|
|
3955
|
+
if (urlContains) checks.push({ criterion: 'url_contains', expected: urlContains, observed: observedUrl, matched: observedUrl.includes(urlContains) });
|
|
3956
|
+
let observation: { verificationVersion?: unknown; selectorExists?: unknown; visible?: unknown; textContains?: unknown; textSample?: unknown; textLength?: unknown; truncated?: unknown } | undefined;
|
|
3957
|
+
if (selector || textContains) {
|
|
3958
|
+
observation = await selection.scope.evaluate(verifyStateObservationScript(selector, textContains, Math.min(positiveNumber(input.args.max_chars, 2_000), 100_000)));
|
|
3959
|
+
if (observation?.verificationVersion !== 1) {
|
|
3960
|
+
throw new AssistantPluginError('PLUGIN_BROWSER_VERIFY_STATE_UNAVAILABLE', 'Browser state observation returned an unsupported result.', { retryable: true });
|
|
3961
|
+
}
|
|
3962
|
+
}
|
|
3963
|
+
if (selector) {
|
|
3964
|
+
checks.push({ criterion: 'selector_exists', expected: true, observed: observation?.selectorExists === true, matched: observation?.selectorExists === true, selector });
|
|
3965
|
+
if (requireVisible) {
|
|
3966
|
+
checks.push({ criterion: 'selector_visible', expected: true, observed: observation?.visible === true, matched: observation?.visible === true, selector });
|
|
3967
|
+
}
|
|
3968
|
+
}
|
|
3969
|
+
if (textContains) {
|
|
3970
|
+
checks.push({
|
|
3971
|
+
criterion: 'text_contains', expected: textContains, observed: observation?.textSample,
|
|
3972
|
+
matched: observation?.textContains === true, textLength: observation?.textLength, truncated: observation?.truncated,
|
|
3973
|
+
...(selector ? { selector } : {}),
|
|
3974
|
+
});
|
|
3975
|
+
}
|
|
3976
|
+
return {
|
|
3977
|
+
provider: browserResultProvider(connection.provider),
|
|
3978
|
+
sessionId: target.sessionId,
|
|
3979
|
+
url: target.url,
|
|
3980
|
+
observedUrl,
|
|
3981
|
+
...(selection.frame ? { frame: selection.frame } : {}),
|
|
3982
|
+
matched: checks.every((check) => check.matched === true),
|
|
3983
|
+
checks,
|
|
3984
|
+
browserConnection: connection,
|
|
3985
|
+
};
|
|
3986
|
+
}, { requireExistingResource: true });
|
|
2500
3987
|
}
|
|
2501
3988
|
case 'screenshot': {
|
|
2502
3989
|
const target = resolveActionTarget(input.repoRoot, input.args);
|
|
2503
3990
|
const file = screenshotFilePath(input.repoRoot, 'screenshot', target.sessionId, target.url);
|
|
2504
3991
|
const selector = stringValue(input.args.selector);
|
|
2505
|
-
const screenshot = await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
|
|
3992
|
+
const screenshot = await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
|
|
2506
3993
|
let bytes: number;
|
|
2507
3994
|
if (selector && page.locator) {
|
|
2508
3995
|
bytes = (await page.locator(selector).screenshot({ path: file })).length;
|
|
@@ -2520,7 +4007,7 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
|
|
|
2520
4007
|
browserConnection: connection,
|
|
2521
4008
|
};
|
|
2522
4009
|
});
|
|
2523
|
-
return { provider:
|
|
4010
|
+
return { provider: browserResultProvider(screenshot.browserConnection.provider), screenshot };
|
|
2524
4011
|
}
|
|
2525
4012
|
case 'extract_links':
|
|
2526
4013
|
case 'extract_tables':
|
|
@@ -2528,7 +4015,7 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
|
|
|
2528
4015
|
case 'snapshot_interactive': {
|
|
2529
4016
|
const target = resolveActionTarget(input.repoRoot, input.args);
|
|
2530
4017
|
const limit = Math.min(positiveNumber(input.args.limit, 50), 200);
|
|
2531
|
-
return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
|
|
4018
|
+
return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
|
|
2532
4019
|
const script = input.actionId === 'extract_links'
|
|
2533
4020
|
? EXTRACTION_SCRIPTS.links(limit)
|
|
2534
4021
|
: input.actionId === 'extract_tables'
|
|
@@ -2536,13 +4023,39 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
|
|
|
2536
4023
|
: input.actionId === 'extract_forms'
|
|
2537
4024
|
? EXTRACTION_SCRIPTS.forms(limit)
|
|
2538
4025
|
: EXTRACTION_SCRIPTS.interactive(limit);
|
|
2539
|
-
const
|
|
4026
|
+
const selection = input.actionId === 'snapshot_interactive'
|
|
4027
|
+
? resolveBrowserEvaluationScope(page, input.args, connection)
|
|
4028
|
+
: { scope: page } as BrowserFrameSelection;
|
|
4029
|
+
const evaluated = await selection.scope.evaluate<unknown>(script);
|
|
4030
|
+
const grounded = input.actionId === 'snapshot_interactive' && evaluated && typeof evaluated === 'object' && !Array.isArray(evaluated)
|
|
4031
|
+
? evaluated as { geometryVersion?: unknown; viewport?: unknown; elements?: unknown }
|
|
4032
|
+
: undefined;
|
|
4033
|
+
let data = grounded && Array.isArray(grounded.elements) ? grounded.elements : evaluated;
|
|
4034
|
+
let viewport = grounded?.viewport;
|
|
4035
|
+
let frameViewport: unknown;
|
|
4036
|
+
let frameOffset: { x: number; y: number; width: number; height: number } | undefined;
|
|
4037
|
+
if (grounded?.geometryVersion === 1 && selection.frame && Array.isArray(grounded.elements)) {
|
|
4038
|
+
frameOffset = await resolveFrameViewportOffset(page, selection);
|
|
4039
|
+
if (!frameOffset) {
|
|
4040
|
+
throw new AssistantPluginError('PLUGIN_BROWSER_FRAME_GEOMETRY_UNAVAILABLE', 'Frame geometry offset was not available.', { retryable: true });
|
|
4041
|
+
}
|
|
4042
|
+
const topMetrics = await page.evaluate<{ viewportMetricsVersion?: unknown; viewport?: unknown }>(VIEWPORT_METRICS_SCRIPT);
|
|
4043
|
+
if (topMetrics?.viewportMetricsVersion !== 1 || !topMetrics.viewport) {
|
|
4044
|
+
throw new AssistantPluginError('PLUGIN_BROWSER_FRAME_GEOMETRY_UNAVAILABLE', 'Top-level viewport metrics were not available for frame grounding.', { retryable: true });
|
|
4045
|
+
}
|
|
4046
|
+
frameViewport = grounded.viewport;
|
|
4047
|
+
viewport = topMetrics.viewport;
|
|
4048
|
+
data = translateFrameGroundingElements(grounded.elements, frameOffset, topMetrics.viewport);
|
|
4049
|
+
}
|
|
2540
4050
|
return {
|
|
2541
|
-
provider:
|
|
4051
|
+
provider: browserResultProvider(connection.provider),
|
|
2542
4052
|
sessionId: target.sessionId,
|
|
2543
4053
|
url: target.url,
|
|
2544
4054
|
actionId: input.actionId,
|
|
2545
4055
|
data,
|
|
4056
|
+
...(selection.frame ? { frame: selection.frame } : {}),
|
|
4057
|
+
...(grounded?.geometryVersion === 1 ? { geometryVersion: 1, viewport } : {}),
|
|
4058
|
+
...(selection.frame && frameOffset ? { coordinateSpace: 'main_viewport_css', frameViewport, frameOffset } : {}),
|
|
2546
4059
|
browserConnection: connection,
|
|
2547
4060
|
};
|
|
2548
4061
|
}, { persistSession: true });
|
|
@@ -2550,16 +4063,83 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
|
|
|
2550
4063
|
case 'get_console_errors':
|
|
2551
4064
|
case 'get_failed_requests': {
|
|
2552
4065
|
const target = resolveActionTarget(input.repoRoot, input.args);
|
|
2553
|
-
return await withPage(input.repoRoot, current, target, input.args, async (_page, diagnostics, connection) =>
|
|
2554
|
-
provider
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
4066
|
+
return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (_page, diagnostics, connection) => {
|
|
4067
|
+
if (connection.provider === 'macos-apple-events') {
|
|
4068
|
+
throw new AssistantPluginError(
|
|
4069
|
+
'PLUGIN_BROWSER_DIAGNOSTICS_UNAVAILABLE',
|
|
4070
|
+
'Console and failed-request diagnostics require a Playwright/CDP-controlled page; native Apple Events sessions do not expose browser diagnostic events.',
|
|
4071
|
+
{ retryable: false, details: { provider: connection.provider, actionId: input.actionId, sessionId: target.sessionId } },
|
|
4072
|
+
);
|
|
4073
|
+
}
|
|
4074
|
+
return {
|
|
4075
|
+
provider: browserResultProvider(connection.provider),
|
|
4076
|
+
sessionId: target.sessionId,
|
|
4077
|
+
url: target.url,
|
|
4078
|
+
navigation: diagnostics.navigation,
|
|
4079
|
+
browserConnection: connection,
|
|
4080
|
+
...(input.actionId === 'get_console_errors'
|
|
4081
|
+
? { consoleErrors: diagnostics.consoleErrors.slice(0, 50) }
|
|
4082
|
+
: { failedRequests: diagnostics.failedRequests.slice(0, 50) }),
|
|
4083
|
+
};
|
|
4084
|
+
}, { persistSession: true });
|
|
4085
|
+
}
|
|
4086
|
+
case 'activate_page': {
|
|
4087
|
+
const target = resolveActionTarget(input.repoRoot, input.args);
|
|
4088
|
+
return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
|
|
4089
|
+
const waitMs = positiveNumber(input.args.post_action_wait_ms, DEFAULT_POST_ACTION_WAIT_MS);
|
|
4090
|
+
if (connection.provider === 'macos-apple-events') {
|
|
4091
|
+
await establishAuthoritativeNativeForeground(input, page, connection, target, waitMs);
|
|
4092
|
+
} else {
|
|
4093
|
+
if (!page.bringToFront) {
|
|
4094
|
+
throw new AssistantPluginError('PLUGIN_BROWSER_ACTIVATION_UNSUPPORTED', 'The selected browser provider cannot bring the saved page to the foreground.', { retryable: false });
|
|
4095
|
+
}
|
|
4096
|
+
await page.bringToFront();
|
|
4097
|
+
await delay(waitMs);
|
|
4098
|
+
}
|
|
4099
|
+
return finalizeInteractiveAction(input.repoRoot, current, page, target, connection, 'activate_page', 'Activated the exact saved browser page in authoritative foreground state.', {});
|
|
4100
|
+
});
|
|
4101
|
+
}
|
|
4102
|
+
case 'click_text': {
|
|
4103
|
+
const target = resolveActionTarget(input.repoRoot, input.args);
|
|
4104
|
+
const text = requiredString(input.args.text, 'text');
|
|
4105
|
+
if (text.length > 200) {
|
|
4106
|
+
throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'text must be 200 characters or fewer.', { retryable: false });
|
|
4107
|
+
}
|
|
4108
|
+
return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
|
|
4109
|
+
const clicked = await page.evaluate((payload) => {
|
|
4110
|
+
const args = payload as { text: string };
|
|
4111
|
+
const normalize = (value: string) => value.replace(/\s+/g, ' ').trim();
|
|
4112
|
+
const roots: Array<Document | ShadowRoot> = [document];
|
|
4113
|
+
const candidates: HTMLElement[] = [];
|
|
4114
|
+
for (let index = 0; index < roots.length; index += 1) {
|
|
4115
|
+
const root = roots[index];
|
|
4116
|
+
candidates.push(...Array.from(root.querySelectorAll<HTMLElement>('button,[role=button],a,div,span')));
|
|
4117
|
+
for (const host of Array.from(root.querySelectorAll<HTMLElement>('*'))) {
|
|
4118
|
+
if (host.shadowRoot) roots.push(host.shadowRoot);
|
|
4119
|
+
}
|
|
4120
|
+
}
|
|
4121
|
+
const matches = candidates
|
|
4122
|
+
.filter((element) => normalize(element.innerText || element.textContent || '') === args.text)
|
|
4123
|
+
.filter((element) => {
|
|
4124
|
+
const style = getComputedStyle(element);
|
|
4125
|
+
const rect = element.getBoundingClientRect();
|
|
4126
|
+
return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
|
|
4127
|
+
})
|
|
4128
|
+
.sort((left, right) => {
|
|
4129
|
+
const childDelta = left.children.length - right.children.length;
|
|
4130
|
+
if (childDelta !== 0) return childDelta;
|
|
4131
|
+
const leftRect = left.getBoundingClientRect();
|
|
4132
|
+
const rightRect = right.getBoundingClientRect();
|
|
4133
|
+
return (leftRect.width * leftRect.height) - (rightRect.width * rightRect.height);
|
|
4134
|
+
});
|
|
4135
|
+
const element = matches[0];
|
|
4136
|
+
if (!element) throw new Error(`visible exact text not found: ${args.text}`);
|
|
4137
|
+
element.click();
|
|
4138
|
+
return { tag: element.tagName.toLowerCase(), className: element.className || '', text: normalize(element.innerText || element.textContent || '') };
|
|
4139
|
+
}, { text });
|
|
4140
|
+
await delay(positiveNumber(input.args.post_action_wait_ms, DEFAULT_POST_ACTION_WAIT_MS));
|
|
4141
|
+
return finalizeInteractiveAction(input.repoRoot, current, page, target, connection, 'click_text', `Clicked exact visible text ${text}.`, { text, clicked });
|
|
4142
|
+
});
|
|
2563
4143
|
}
|
|
2564
4144
|
case 'click':
|
|
2565
4145
|
case 'double_click':
|
|
@@ -2571,8 +4151,23 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
|
|
|
2571
4151
|
const selector = requiredString(input.args.selector, 'selector');
|
|
2572
4152
|
const timeoutMs = positiveNumber(input.args.timeout_ms, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
2573
4153
|
try {
|
|
2574
|
-
return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
|
|
2575
|
-
if (input.actionId === 'click'
|
|
4154
|
+
return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
|
|
4155
|
+
if (input.actionId === 'click' && openShadowSelectorParts(selector).length > 1) {
|
|
4156
|
+
const parts = openShadowSelectorParts(selector);
|
|
4157
|
+
await page.evaluate((payload) => {
|
|
4158
|
+
const args = payload as { parts: string[] };
|
|
4159
|
+
let root: Document | ShadowRoot = document;
|
|
4160
|
+
for (let index = 0; index < args.parts.length - 1; index += 1) {
|
|
4161
|
+
const host: Element | null = root.querySelector(args.parts[index]);
|
|
4162
|
+
if (!host || !host.shadowRoot) throw new Error(`Open shadow root not found for selector segment: ${args.parts[index]}`);
|
|
4163
|
+
root = host.shadowRoot;
|
|
4164
|
+
}
|
|
4165
|
+
const element = root.querySelector(args.parts[args.parts.length - 1]) as HTMLElement | null;
|
|
4166
|
+
if (!element || typeof element.click !== 'function') throw new Error('Shadow selector did not resolve to a clickable element.');
|
|
4167
|
+
element.click();
|
|
4168
|
+
return true;
|
|
4169
|
+
}, { parts });
|
|
4170
|
+
} else if (input.actionId === 'click') await page.click(selector, { timeout: timeoutMs });
|
|
2576
4171
|
else if (input.actionId === 'double_click') {
|
|
2577
4172
|
if (page.dblclick) await page.dblclick(selector, { timeout: timeoutMs });
|
|
2578
4173
|
else await page.click(selector, { timeout: timeoutMs, clickCount: 2 } as Record<string, unknown>);
|
|
@@ -2615,7 +4210,7 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
|
|
|
2615
4210
|
const selector = requiredString(input.args.selector, 'selector');
|
|
2616
4211
|
const text = requiredString(input.args.text, 'text');
|
|
2617
4212
|
const timeoutMs = positiveNumber(input.args.timeout_ms, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
2618
|
-
return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
|
|
4213
|
+
return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
|
|
2619
4214
|
if (input.actionId === 'fill' || !page.type) await page.fill(selector, text, { timeout: timeoutMs });
|
|
2620
4215
|
else await page.type(selector, text, { timeout: timeoutMs });
|
|
2621
4216
|
await delay(positiveNumber(input.args.post_action_wait_ms, DEFAULT_POST_ACTION_WAIT_MS));
|
|
@@ -2630,17 +4225,23 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
|
|
|
2630
4225
|
const selector = requiredString(input.args.selector, 'selector');
|
|
2631
4226
|
const values = Array.isArray(input.args.values) ? input.args.values.map(String) : [];
|
|
2632
4227
|
if (values.length === 0) throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'values is required.', { retryable: false });
|
|
2633
|
-
return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
|
|
2634
|
-
if (page.selectOption)
|
|
4228
|
+
return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
|
|
4229
|
+
if (!page.selectOption) {
|
|
4230
|
+
throw new AssistantPluginError('PLUGIN_BROWSER_SELECT_OPTION_UNAVAILABLE', 'The active browser provider cannot select options safely.', { retryable: false });
|
|
4231
|
+
}
|
|
4232
|
+
const selectedValues = await page.selectOption(selector, values, { timeout: positiveNumber(input.args.timeout_ms, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS) });
|
|
4233
|
+
if (!Array.isArray(selectedValues) || selectedValues.length === 0) {
|
|
4234
|
+
throw new AssistantPluginError('PLUGIN_BROWSER_SELECT_OPTION_FAILED', 'The browser provider did not retain any requested option selection.', { retryable: true, details: { selector, requestedCount: values.length } });
|
|
4235
|
+
}
|
|
2635
4236
|
await delay(positiveNumber(input.args.post_action_wait_ms, DEFAULT_POST_ACTION_WAIT_MS));
|
|
2636
|
-
return finalizeInteractiveAction(input.repoRoot, current, page, target, connection, 'select_option', `Selected ${values.length} option(s) on ${selector}.`, { selector, values });
|
|
4237
|
+
return finalizeInteractiveAction(input.repoRoot, current, page, target, connection, 'select_option', `Selected ${values.length} option(s) on ${selector}.`, { selector, values, selectedValues });
|
|
2637
4238
|
});
|
|
2638
4239
|
}
|
|
2639
4240
|
case 'press': {
|
|
2640
4241
|
const target = resolveActionTarget(input.repoRoot, input.args);
|
|
2641
4242
|
const selector = requiredString(input.args.selector, 'selector');
|
|
2642
4243
|
const key = requiredString(input.args.key, 'key');
|
|
2643
|
-
return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
|
|
4244
|
+
return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
|
|
2644
4245
|
await page.press(selector, key, { timeout: positiveNumber(input.args.timeout_ms, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS) });
|
|
2645
4246
|
await delay(positiveNumber(input.args.post_action_wait_ms, DEFAULT_POST_ACTION_WAIT_MS));
|
|
2646
4247
|
return finalizeInteractiveAction(input.repoRoot, current, page, target, connection, 'press', `Pressed ${key} on ${selector}.`, {
|
|
@@ -2652,18 +4253,184 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
|
|
|
2652
4253
|
case 'keyboard_shortcut': {
|
|
2653
4254
|
const target = resolveActionTarget(input.repoRoot, input.args);
|
|
2654
4255
|
const key = requiredString(input.args.key, 'key');
|
|
2655
|
-
return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
|
|
4256
|
+
return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
|
|
2656
4257
|
if (page.keyboard?.press) await page.keyboard.press(key);
|
|
2657
4258
|
else await page.press('body', key, { timeout: positiveNumber(input.args.timeout_ms, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS) });
|
|
2658
4259
|
await delay(positiveNumber(input.args.post_action_wait_ms, DEFAULT_POST_ACTION_WAIT_MS));
|
|
2659
4260
|
return finalizeInteractiveAction(input.repoRoot, current, page, target, connection, 'keyboard_shortcut', `Pressed shortcut ${key}.`, { key });
|
|
2660
4261
|
});
|
|
2661
4262
|
}
|
|
4263
|
+
case 'trusted_input': {
|
|
4264
|
+
const target = resolveActionTarget(input.repoRoot, input.args);
|
|
4265
|
+
const kind = requiredString(input.args.kind, 'kind');
|
|
4266
|
+
if (!['click', 'move', 'wheel', 'drag', 'key', 'text'].includes(kind)) {
|
|
4267
|
+
throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'kind must be click, move, wheel, drag, key, or text.', { retryable: false });
|
|
4268
|
+
}
|
|
4269
|
+
const guardSelector = stringValue(input.args.guard_selector);
|
|
4270
|
+
const hasFrameScope = Boolean(stringValue(input.args.frame_url) || stringValue(input.args.frame_name));
|
|
4271
|
+
if (hasFrameScope && !guardSelector) {
|
|
4272
|
+
throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'trusted_input frame scope requires guard_selector; trusted input coordinates are always main-viewport CSS pixels.', { retryable: false });
|
|
4273
|
+
}
|
|
4274
|
+
if (guardSelector && kind !== 'click' && kind !== 'drag') {
|
|
4275
|
+
throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'guard_selector is supported only for trusted_input click or drag.', { retryable: false });
|
|
4276
|
+
}
|
|
4277
|
+
return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
|
|
4278
|
+
const nativeTrustedInput = connection.provider === 'macos-apple-events';
|
|
4279
|
+
if (nativeTrustedInput && !page.trustedInput) {
|
|
4280
|
+
throw new AssistantPluginError(
|
|
4281
|
+
'PLUGIN_BROWSER_TRUSTED_INPUT_UNAVAILABLE',
|
|
4282
|
+
'Native browser provider does not expose the stable macOS trusted-input broker.',
|
|
4283
|
+
{ retryable: false, details: { provider: connection.provider, sessionId: target.sessionId } },
|
|
4284
|
+
);
|
|
4285
|
+
}
|
|
4286
|
+
if (!nativeTrustedInput && (!page.mouse || !page.keyboard)) {
|
|
4287
|
+
throw new AssistantPluginError(
|
|
4288
|
+
'PLUGIN_BROWSER_TRUSTED_INPUT_UNAVAILABLE',
|
|
4289
|
+
'Trusted browser input requires Playwright mouse/keyboard primitives or the stable native macOS trusted-input broker.',
|
|
4290
|
+
{ retryable: false, details: { provider: connection.provider, sessionId: target.sessionId } },
|
|
4291
|
+
);
|
|
4292
|
+
}
|
|
4293
|
+
const button = input.args.button === 'middle' || input.args.button === 'right' ? input.args.button : 'left';
|
|
4294
|
+
let guard: Record<string, unknown> | undefined;
|
|
4295
|
+
if (guardSelector) {
|
|
4296
|
+
const point = kind === 'click'
|
|
4297
|
+
? { x: requiredFiniteNumber(input.args.x, 'x', 0), y: requiredFiniteNumber(input.args.y, 'y', 0) }
|
|
4298
|
+
: { x: requiredFiniteNumber(input.args.from_x, 'from_x', 0), y: requiredFiniteNumber(input.args.from_y, 'from_y', 0) };
|
|
4299
|
+
const selection = resolveBrowserEvaluationScope(page, input.args, connection);
|
|
4300
|
+
const observed = await selection.scope.evaluate<{
|
|
4301
|
+
trustedInputGuardVersion?: unknown; found?: unknown; visible?: unknown; selectorError?: unknown;
|
|
4302
|
+
bounds?: { x?: unknown; y?: unknown; width?: unknown; height?: unknown; right?: unknown; bottom?: unknown };
|
|
4303
|
+
}>(trustedInputGuardScript(guardSelector));
|
|
4304
|
+
if (observed?.trustedInputGuardVersion !== 1 || observed.found !== true || observed.visible !== true || !observed.bounds) {
|
|
4305
|
+
throw new AssistantPluginError('PLUGIN_BROWSER_TRUSTED_INPUT_GUARD_MISMATCH', 'Trusted input guard selector is missing or not visibly actionable.', {
|
|
4306
|
+
retryable: true,
|
|
4307
|
+
details: { selector: guardSelector, frame: selection.frame, found: observed?.found, visible: observed?.visible, selectorError: observed?.selectorError },
|
|
4308
|
+
});
|
|
4309
|
+
}
|
|
4310
|
+
const finite = (value: unknown, field: string): number => {
|
|
4311
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
4312
|
+
throw new AssistantPluginError('PLUGIN_BROWSER_TRUSTED_INPUT_GUARD_MISMATCH', `Trusted input guard ${field} is not finite.`, { retryable: true });
|
|
4313
|
+
}
|
|
4314
|
+
return value;
|
|
4315
|
+
};
|
|
4316
|
+
let bounds = {
|
|
4317
|
+
x: finite(observed.bounds.x, 'bounds.x'), y: finite(observed.bounds.y, 'bounds.y'),
|
|
4318
|
+
width: finite(observed.bounds.width, 'bounds.width'), height: finite(observed.bounds.height, 'bounds.height'),
|
|
4319
|
+
right: finite(observed.bounds.right, 'bounds.right'), bottom: finite(observed.bounds.bottom, 'bounds.bottom'),
|
|
4320
|
+
};
|
|
4321
|
+
if (selection.frame) {
|
|
4322
|
+
const offset = await resolveFrameViewportOffset(page, selection);
|
|
4323
|
+
if (!offset) throw new AssistantPluginError('PLUGIN_BROWSER_TRUSTED_INPUT_GUARD_MISMATCH', 'Trusted input frame offset is unavailable.', { retryable: true });
|
|
4324
|
+
bounds = {
|
|
4325
|
+
...bounds,
|
|
4326
|
+
x: bounds.x + offset.x,
|
|
4327
|
+
y: bounds.y + offset.y,
|
|
4328
|
+
right: bounds.right + offset.x,
|
|
4329
|
+
bottom: bounds.bottom + offset.y,
|
|
4330
|
+
};
|
|
4331
|
+
}
|
|
4332
|
+
const metrics = await page.evaluate<{ viewportMetricsVersion?: unknown; viewport?: { width?: unknown; height?: unknown } }>(VIEWPORT_METRICS_SCRIPT);
|
|
4333
|
+
const viewportWidth = metrics?.viewportMetricsVersion === 1 && typeof metrics.viewport?.width === 'number' && Number.isFinite(metrics.viewport.width) ? metrics.viewport.width : undefined;
|
|
4334
|
+
const viewportHeight = metrics?.viewportMetricsVersion === 1 && typeof metrics.viewport?.height === 'number' && Number.isFinite(metrics.viewport.height) ? metrics.viewport.height : undefined;
|
|
4335
|
+
if (viewportWidth === undefined || viewportHeight === undefined) {
|
|
4336
|
+
throw new AssistantPluginError('PLUGIN_BROWSER_TRUSTED_INPUT_GUARD_MISMATCH', 'Trusted input guard could not verify the top-level viewport.', { retryable: true });
|
|
4337
|
+
}
|
|
4338
|
+
const targetInViewport = bounds.right > 0 && bounds.bottom > 0 && bounds.x < viewportWidth && bounds.y < viewportHeight;
|
|
4339
|
+
const pointInViewport = point.x >= 0 && point.y >= 0 && point.x < viewportWidth && point.y < viewportHeight;
|
|
4340
|
+
const pointInsideTarget = point.x >= bounds.x && point.x <= bounds.right && point.y >= bounds.y && point.y <= bounds.bottom;
|
|
4341
|
+
if (!targetInViewport || !pointInViewport || !pointInsideTarget) {
|
|
4342
|
+
throw new AssistantPluginError('PLUGIN_BROWSER_TRUSTED_INPUT_GUARD_MISMATCH', 'Trusted input coordinates no longer match the guarded selector geometry.', {
|
|
4343
|
+
retryable: true,
|
|
4344
|
+
details: { selector: guardSelector, frame: selection.frame, point, bounds, targetInViewport, pointInViewport, pointInsideTarget },
|
|
4345
|
+
});
|
|
4346
|
+
}
|
|
4347
|
+
guard = { selector: guardSelector, ...(selection.frame ? { frame: selection.frame } : {}), point, bounds, targetInViewport: true, pointInViewport: true, pointInsideTarget: true };
|
|
4348
|
+
}
|
|
4349
|
+
let trustedRequest: Record<string, unknown>;
|
|
4350
|
+
if (kind === 'click') {
|
|
4351
|
+
const x = requiredFiniteNumber(input.args.x, 'x', 0);
|
|
4352
|
+
const y = requiredFiniteNumber(input.args.y, 'y', 0);
|
|
4353
|
+
const clickCount = Math.min(Math.max(positiveNumber(input.args.click_count, 1), 1), 3);
|
|
4354
|
+
trustedRequest = { kind, x, y, button, clickCount };
|
|
4355
|
+
if (!nativeTrustedInput) await page.mouse!.click(x, y, { button, clickCount });
|
|
4356
|
+
} else if (kind === 'move') {
|
|
4357
|
+
const x = requiredFiniteNumber(input.args.x, 'x', 0);
|
|
4358
|
+
const y = requiredFiniteNumber(input.args.y, 'y', 0);
|
|
4359
|
+
const steps = Math.min(Math.max(positiveNumber(input.args.steps, 1), 1), 100);
|
|
4360
|
+
trustedRequest = { kind, x, y, steps };
|
|
4361
|
+
if (!nativeTrustedInput) await page.mouse!.move(x, y, { steps });
|
|
4362
|
+
} else if (kind === 'wheel') {
|
|
4363
|
+
const deltaX = requiredFiniteNumber(input.args.delta_x, 'delta_x');
|
|
4364
|
+
const deltaY = requiredFiniteNumber(input.args.delta_y, 'delta_y');
|
|
4365
|
+
trustedRequest = { kind, deltaX, deltaY };
|
|
4366
|
+
if (!nativeTrustedInput) await page.mouse!.wheel(deltaX, deltaY);
|
|
4367
|
+
} else if (kind === 'drag') {
|
|
4368
|
+
const fromX = requiredFiniteNumber(input.args.from_x, 'from_x', 0);
|
|
4369
|
+
const fromY = requiredFiniteNumber(input.args.from_y, 'from_y', 0);
|
|
4370
|
+
const toX = requiredFiniteNumber(input.args.to_x, 'to_x', 0);
|
|
4371
|
+
const toY = requiredFiniteNumber(input.args.to_y, 'to_y', 0);
|
|
4372
|
+
const steps = Math.min(Math.max(positiveNumber(input.args.steps, 10), 1), 100);
|
|
4373
|
+
trustedRequest = { kind, fromX, fromY, toX, toY, button, steps };
|
|
4374
|
+
if (!nativeTrustedInput) {
|
|
4375
|
+
await page.mouse!.move(fromX, fromY);
|
|
4376
|
+
await page.mouse!.down({ button });
|
|
4377
|
+
try {
|
|
4378
|
+
await page.mouse!.move(toX, toY, { steps });
|
|
4379
|
+
} finally {
|
|
4380
|
+
await page.mouse!.up({ button });
|
|
4381
|
+
}
|
|
4382
|
+
}
|
|
4383
|
+
} else if (kind === 'key') {
|
|
4384
|
+
const key = requiredString(input.args.key, 'key');
|
|
4385
|
+
trustedRequest = { kind, key };
|
|
4386
|
+
if (!nativeTrustedInput) await page.keyboard!.press(key);
|
|
4387
|
+
} else {
|
|
4388
|
+
const text = typeof input.args.text === 'string' ? input.args.text : undefined;
|
|
4389
|
+
if (text === undefined || text.length > 10_000) {
|
|
4390
|
+
throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'text is required and must be at most 10000 characters.', { retryable: false });
|
|
4391
|
+
}
|
|
4392
|
+
trustedRequest = { kind, text };
|
|
4393
|
+
if (!nativeTrustedInput) {
|
|
4394
|
+
if (!page.keyboard!.insertText) {
|
|
4395
|
+
throw new AssistantPluginError('PLUGIN_BROWSER_TRUSTED_INPUT_UNAVAILABLE', 'This Playwright page does not expose trusted text insertion.', { retryable: false });
|
|
4396
|
+
}
|
|
4397
|
+
await page.keyboard!.insertText(text);
|
|
4398
|
+
}
|
|
4399
|
+
}
|
|
4400
|
+
if (nativeTrustedInput) await page.trustedInput!(trustedRequest);
|
|
4401
|
+
await delay(positiveNumber(input.args.post_action_wait_ms, DEFAULT_POST_ACTION_WAIT_MS));
|
|
4402
|
+
return finalizeInteractiveAction(input.repoRoot, current, page, target, connection, 'trusted_input', `Sent trusted browser input (${kind}).`, { kind, ...(guard ? { guard } : {}) });
|
|
4403
|
+
});
|
|
4404
|
+
}
|
|
4405
|
+
case 'dispatch_event': {
|
|
4406
|
+
const target = resolveActionTarget(input.repoRoot, input.args);
|
|
4407
|
+
const selector = requiredString(input.args.selector, 'selector');
|
|
4408
|
+
const event = requiredString(input.args.event, 'event');
|
|
4409
|
+
if (!/^[A-Za-z][A-Za-z0-9:_-]{0,63}$/.test(event)) {
|
|
4410
|
+
throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'event must be a simple DOM event name (letters, digits, colon, underscore, or hyphen; max 64 chars).', { retryable: false });
|
|
4411
|
+
}
|
|
4412
|
+
return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
|
|
4413
|
+
const dispatched = await page.evaluate((payload) => {
|
|
4414
|
+
const args = payload as { selector: string; event: string };
|
|
4415
|
+
const element = document.querySelector(args.selector);
|
|
4416
|
+
if (!element) throw new Error(`element not found: ${args.selector}`);
|
|
4417
|
+
const domEvent = new CustomEvent(args.event, { bubbles: true, composed: true, cancelable: true });
|
|
4418
|
+
const accepted = element.dispatchEvent(domEvent);
|
|
4419
|
+
return { accepted, defaultPrevented: domEvent.defaultPrevented };
|
|
4420
|
+
}, { selector, event });
|
|
4421
|
+
await delay(positiveNumber(input.args.post_action_wait_ms, DEFAULT_POST_ACTION_WAIT_MS));
|
|
4422
|
+
return finalizeInteractiveAction(input.repoRoot, current, page, target, connection, 'dispatch_event', `Dispatched ${event} on ${selector}.`, {
|
|
4423
|
+
selector,
|
|
4424
|
+
event,
|
|
4425
|
+
dispatched,
|
|
4426
|
+
});
|
|
4427
|
+
});
|
|
4428
|
+
}
|
|
2662
4429
|
case 'wait_for_selector': {
|
|
2663
4430
|
const target = resolveActionTarget(input.repoRoot, input.args);
|
|
2664
4431
|
const selector = requiredString(input.args.selector, 'selector');
|
|
2665
4432
|
const state = waitForSelectorState(input.args.state);
|
|
2666
|
-
return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
|
|
4433
|
+
return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
|
|
2667
4434
|
await page.waitForSelector(selector, {
|
|
2668
4435
|
state,
|
|
2669
4436
|
timeout: positiveNumber(input.args.timeout_ms, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS),
|
|
@@ -2677,64 +4444,122 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
|
|
|
2677
4444
|
case 'attach_local_file': {
|
|
2678
4445
|
const target = resolveActionTarget(input.repoRoot, input.args);
|
|
2679
4446
|
const selector = requiredString(input.args.selector, 'selector');
|
|
2680
|
-
const
|
|
2681
|
-
const
|
|
2682
|
-
if (
|
|
2683
|
-
throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'file_path
|
|
4447
|
+
const single = stringValue(input.args.file_path);
|
|
4448
|
+
const many = stringList(input.args.file_paths);
|
|
4449
|
+
if (single && many) {
|
|
4450
|
+
throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'Use either file_path or file_paths, not both.', { retryable: false });
|
|
2684
4451
|
}
|
|
2685
|
-
|
|
2686
|
-
|
|
4452
|
+
const requested = many ?? (single ? [single] : []);
|
|
4453
|
+
if (requested.length === 0 || requested.length > 32) {
|
|
4454
|
+
throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'attach_local_file requires file_path or 1-32 file_paths.', { retryable: false });
|
|
2687
4455
|
}
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
4456
|
+
const repositoryLexicalRoot = resolve(input.repoRoot);
|
|
4457
|
+
const repositoryRoot = realpathSync(input.repoRoot);
|
|
4458
|
+
const resolved = requested.map((filePath) => {
|
|
4459
|
+
const lexicalPath = resolve(input.repoRoot, filePath);
|
|
4460
|
+
const lexicalRelative = relative(repositoryLexicalRoot, lexicalPath);
|
|
4461
|
+
if (lexicalRelative === '..' || lexicalRelative.startsWith(`..${sep}`) || isAbsolute(lexicalRelative)) {
|
|
4462
|
+
throw new AssistantPluginError('PLUGIN_POLICY_BLOCKED', 'Local file attachments must stay inside the repository root.', { retryable: false, details: { fileName: basename(filePath) } });
|
|
4463
|
+
}
|
|
4464
|
+
if (!existsSync(lexicalPath)) {
|
|
4465
|
+
throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', `Local attachment does not exist: ${basename(lexicalPath)}`, { retryable: false });
|
|
4466
|
+
}
|
|
4467
|
+
const realPath = realpathSync(lexicalPath);
|
|
4468
|
+
const repositoryRelative = relative(repositoryRoot, realPath);
|
|
4469
|
+
if (repositoryRelative === '..' || repositoryRelative.startsWith(`..${sep}`) || isAbsolute(repositoryRelative)) {
|
|
4470
|
+
throw new AssistantPluginError('PLUGIN_POLICY_BLOCKED', 'Local file attachments must resolve inside the repository root.', { retryable: false, details: { fileName: basename(lexicalPath) } });
|
|
2691
4471
|
}
|
|
4472
|
+
if (!statSync(realPath).isFile()) {
|
|
4473
|
+
throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', `Local attachment is not a regular file: ${basename(lexicalPath)}`, { retryable: false });
|
|
4474
|
+
}
|
|
4475
|
+
if (/\.(exe|dmg|pkg|sh|bat|cmd|app)$/i.test(realPath)) {
|
|
4476
|
+
throw new AssistantPluginError('PLUGIN_POLICY_BLOCKED', 'Executable file attachments are not allowed.', { retryable: false });
|
|
4477
|
+
}
|
|
4478
|
+
return lexicalPath;
|
|
4479
|
+
});
|
|
4480
|
+
return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
|
|
2692
4481
|
await page.evaluate((payload) => {
|
|
2693
|
-
const args = payload as { selector: string;
|
|
4482
|
+
const args = payload as { selector: string; count: number };
|
|
2694
4483
|
const el = document.querySelector(args.selector) as HTMLInputElement | null;
|
|
2695
|
-
if (!el) throw new Error(`input not found: ${args.selector}`);
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
await anyPage.setInputFiles(selector, resolved);
|
|
4484
|
+
if (!(el instanceof HTMLInputElement) || el.type !== 'file') throw new Error(`file input not found: ${args.selector}`);
|
|
4485
|
+
if (args.count > 1 && !el.multiple) throw new Error(`file input does not allow multiple files: ${args.selector}`);
|
|
4486
|
+
}, { selector, count: resolved.length });
|
|
4487
|
+
if (typeof page.setInputFiles !== 'function') {
|
|
4488
|
+
throw new AssistantPluginError('PLUGIN_BROWSER_FILE_ATTACH_UNSUPPORTED', 'The selected browser provider does not support local file attachment.', { retryable: false });
|
|
2701
4489
|
}
|
|
4490
|
+
await page.setInputFiles(selector, resolved.length === 1 ? resolved[0] : resolved);
|
|
2702
4491
|
await delay(positiveNumber(input.args.post_action_wait_ms, DEFAULT_POST_ACTION_WAIT_MS));
|
|
2703
|
-
|
|
4492
|
+
const fileNames = resolved.map((path) => basename(path));
|
|
4493
|
+
return finalizeInteractiveAction(input.repoRoot, current, page, target, connection, 'attach_local_file', `Attached ${resolved.length} local file(s) to ${selector}.`, {
|
|
2704
4494
|
selector,
|
|
2705
|
-
|
|
4495
|
+
fileCount: resolved.length,
|
|
4496
|
+
fileNames,
|
|
4497
|
+
...(resolved.length === 1 ? { fileName: fileNames[0] } : {}),
|
|
2706
4498
|
});
|
|
2707
4499
|
});
|
|
2708
4500
|
}
|
|
2709
4501
|
case 'await_file_transfer': {
|
|
2710
4502
|
const target = resolveActionTarget(input.repoRoot, input.args);
|
|
2711
4503
|
const selector = requiredString(input.args.selector, 'selector');
|
|
2712
|
-
const
|
|
4504
|
+
const requestedName = stringValue(input.args.suggested_name);
|
|
4505
|
+
const downloadDir = browserStateDir(input.repoRoot, 'downloads');
|
|
2713
4506
|
mkdirSync(downloadDir, { recursive: true });
|
|
2714
|
-
|
|
2715
|
-
const safeName = suggested.replace(/[^a-zA-Z0-9._-]+/g, '_');
|
|
2716
|
-
const dest = join(downloadDir, safeName);
|
|
2717
|
-
return await withPage(input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
|
|
4507
|
+
return await withPage(input.actionId, input.repoRoot, current, target, input.args, async (page, _diagnostics, connection) => {
|
|
2718
4508
|
if (connection.provider === 'macos-apple-events') {
|
|
2719
4509
|
throw new AssistantPluginError('PLUGIN_BROWSER_NATIVE_ACTION_UNSUPPORTED', 'Native Apple Events attachment does not support download capture. Use CDP or a managed browser context.', { retryable: false });
|
|
2720
4510
|
}
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
4511
|
+
if (!page.waitForEvent) {
|
|
4512
|
+
throw new AssistantPluginError('PLUGIN_BROWSER_DOWNLOAD_UNAVAILABLE', 'The selected Playwright page does not expose download events; refusing to fabricate a download artifact.', { retryable: false, details: { provider: connection.provider } });
|
|
4513
|
+
}
|
|
4514
|
+
const timeout = positiveNumber(input.args.timeout_ms, current.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
4515
|
+
let download: BrowserDownloadLike;
|
|
4516
|
+
try {
|
|
4517
|
+
[download] = await Promise.all([
|
|
4518
|
+
page.waitForEvent('download', { timeout }),
|
|
4519
|
+
page.click(selector, { timeout }),
|
|
4520
|
+
]);
|
|
4521
|
+
} catch (error) {
|
|
4522
|
+
throw new AssistantPluginError('PLUGIN_BROWSER_DOWNLOAD_FAILED', 'Browser download did not start successfully after the authorized click.', {
|
|
4523
|
+
retryable: true, details: { selector, cause: error instanceof Error ? error.message : String(error) },
|
|
4524
|
+
});
|
|
4525
|
+
}
|
|
4526
|
+
const browserSuggestedName = download.suggestedFilename();
|
|
4527
|
+
if (isBlockedExecutableArtifactName(browserSuggestedName) || isBlockedExecutableArtifactName(requestedName)) {
|
|
4528
|
+
await download.delete?.().catch(() => undefined);
|
|
4529
|
+
throw new AssistantPluginError('PLUGIN_POLICY_BLOCKED', 'Downloaded executable files are not retained or auto-opened and were discarded.', {
|
|
4530
|
+
retryable: false, details: { suggestedFilename: browserSuggestedName },
|
|
4531
|
+
});
|
|
4532
|
+
}
|
|
4533
|
+
if (download.failure) {
|
|
4534
|
+
const failure = await download.failure();
|
|
4535
|
+
if (failure) {
|
|
4536
|
+
throw new AssistantPluginError('PLUGIN_BROWSER_DOWNLOAD_FAILED', 'Browser reported that the download failed.', { retryable: true, details: { selector, cause: failure } });
|
|
4537
|
+
}
|
|
4538
|
+
}
|
|
4539
|
+
const safeName = safeDownloadArtifactName(requestedName, browserSuggestedName);
|
|
4540
|
+
const dest = uniqueDownloadArtifactPath(downloadDir, safeName);
|
|
4541
|
+
try {
|
|
4542
|
+
await download.saveAs(dest);
|
|
4543
|
+
} catch (error) {
|
|
2725
4544
|
rmSync(dest, { force: true });
|
|
2726
|
-
throw new AssistantPluginError('
|
|
4545
|
+
throw new AssistantPluginError('PLUGIN_BROWSER_DOWNLOAD_FAILED', 'Browser download could not be saved into Forge artifact storage.', {
|
|
4546
|
+
retryable: true, details: { selector, cause: error instanceof Error ? error.message : String(error) },
|
|
4547
|
+
});
|
|
4548
|
+
}
|
|
4549
|
+
if (!existsSync(dest)) {
|
|
4550
|
+
throw new AssistantPluginError('PLUGIN_BROWSER_DOWNLOAD_FAILED', 'Browser download save completed without producing an artifact; refusing false success.', { retryable: true, details: { selector } });
|
|
2727
4551
|
}
|
|
2728
4552
|
await delay(positiveNumber(input.args.post_action_wait_ms, DEFAULT_POST_ACTION_WAIT_MS));
|
|
2729
|
-
|
|
4553
|
+
return finalizeInteractiveAction(input.repoRoot, current, page, target, connection, 'await_file_transfer', `Captured download artifact for ${selector}.`, {
|
|
2730
4554
|
selector,
|
|
2731
4555
|
download: {
|
|
2732
4556
|
path: dest,
|
|
2733
4557
|
relativePath: relative(input.repoRoot, dest),
|
|
4558
|
+
fileName: basename(dest),
|
|
4559
|
+
browserSuggestedName,
|
|
2734
4560
|
autoOpened: false,
|
|
2735
4561
|
},
|
|
2736
4562
|
});
|
|
2737
|
-
return result;
|
|
2738
4563
|
});
|
|
2739
4564
|
}
|
|
2740
4565
|
default:
|
|
@@ -2757,8 +4582,16 @@ export async function executeBrowserPluginAction(input: AssistantPluginActionExe
|
|
|
2757
4582
|
}
|
|
2758
4583
|
}
|
|
2759
4584
|
|
|
4585
|
+
export async function executeBrowserPluginAction(input: AssistantPluginActionExecutionInput): Promise<Record<string, unknown>> {
|
|
4586
|
+
return await withBrowserSessionAuthorityContext(
|
|
4587
|
+
{ controllerHome: input.controllerHome, repoId: input.repoId },
|
|
4588
|
+
() => executeBrowserPluginActionInternal(input),
|
|
4589
|
+
);
|
|
4590
|
+
}
|
|
4591
|
+
|
|
2760
4592
|
export const browserPluginAdapter = {
|
|
2761
4593
|
pluginId: BROWSER_PLUGIN_ID,
|
|
2762
4594
|
buildManifest: buildBrowserPluginManifest,
|
|
2763
4595
|
executeAction: executeBrowserPluginAction,
|
|
4596
|
+
resolveAuthorizationContext: resolveBrowserPluginAuthorizationContext,
|
|
2764
4597
|
};
|