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