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