diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index abc91a4e..a38ffc1f 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "webcmd", - "version": "0.6.0", + "version": "0.6.1", "description": "Turn websites, browser sessions, desktop apps, and local tools into deterministic CLI surfaces for humans and AI agents.", "author": { "name": "AgentRHQ", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 2933a858..2dcf0b8d 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "webcmd", - "version": "0.6.0", + "version": "0.6.1", "description": "Turn websites, browser sessions, desktop apps, and local tools into deterministic CLI surfaces for humans and AI agents.", "author": { "name": "AgentRHQ", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf32baa5..b4704d6c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -194,7 +194,7 @@ jobs: run: ./scripts/collect-ci-diagnostics.ps1 -SelfTest - name: Run real Cloak lifecycle smoke - run: npx vitest run --project e2e tests/e2e/cloak-runtime.test.ts + run: npx vitest run --project e2e tests/e2e/cloak-runtime.test.ts tests/e2e/cloak-session-concurrency.test.ts - name: Collect sanitized diagnostics if: failure() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index de2e6af9..25138b49 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,6 +56,10 @@ jobs: if: ${{ steps.release.outputs.release_created || inputs.publish_tag != '' }} run: npm ci + - name: Install Playwright Chromium + if: ${{ steps.release.outputs.release_created || inputs.publish_tag != '' }} + run: npx playwright-core install chromium --with-deps + - name: Verify existing release tag if: ${{ inputs.publish_tag != '' }} run: test "${{ inputs.publish_tag }}" = "webcmd-v$(node -p "require('./package.json').version")" diff --git a/.release-please-manifest.json b/.release-please-manifest.json index b5b966fb..f4a3a7f2 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.6.0" + ".": "0.6.1" } diff --git a/README.md b/README.md index fcf2af09..8acdf3df 100644 --- a/README.md +++ b/README.md @@ -35,14 +35,21 @@ On top of live browser control, Webcmd adds 3 layers of learnings. Each layer co | 3. Extend existing CLIs | The workflow is deterministic enough to stop browsing. | Extend the `webcmd ` adapter with a tailored command so the workflow runs instantly with the least amount of tokens. | For local, multi-step browser exploration, agents can send one sandboxed -Playwright-style program to an existing CloakBrowser session: +Playwright-style program to an explicit browser session: ```bash -webcmd browser work run --file explore.js -printf 'const page = await browser.currentPage(); return await page.title();' \ - | webcmd browser work run --stdin +webcmd session create -f json +webcmd --session session_abc browser run --file explore.js +printf 'return await page.title();' \ + | webcmd --session session_abc browser run --stdin +webcmd session close session_abc ``` +Profiles are cookie jars; Sessions are independent browser windows within a +profile, so parallel agents should create separate Sessions. Adapter commands +use an adapter-default Session unless `--session` intentionally routes them to +an explicit one. + ## Demo https://github.com/user-attachments/assets/04eceadc-d398-4303-984d-ae3197bfa664 diff --git a/docs/agents/claude-code.md b/docs/agents/claude-code.md index 83fb138c..d697d339 100644 --- a/docs/agents/claude-code.md +++ b/docs/agents/claude-code.md @@ -88,7 +88,7 @@ Denying these tools does not affect the Bash tool, which is how `webcmd` is driv | Skill text looks out of date | `webcmd update` upgrades only the CLI. Run `claude plugin update webcmd@webcmd` to refresh plugin skills. | | Claude Code still uses `WebFetch` / `WebSearch` | Confirm `permissions.deny` lists both in the active settings file, then restart `claude`. | | `claude` requires permission prompts for `webcmd` | The Bash tool still asks before non-approved commands; run `claude --dangerously-skip-permissions` or allow the shell command if you accept the risk. | -| Browser sessions stop working after idle | Ask the agent to open a fresh session or re-bind with `tabs` and `bind --page`. | +| Browser Session idles or loses its window | Keep the same Session ID; the next `webcmd --session browser ...` command reopens it. Use `webcmd session create -f json`, `webcmd session list`, and `webcmd session close ` for lifecycle. | ## See also diff --git a/docs/agents/codex-cli.md b/docs/agents/codex-cli.md index 48a49be5..07d9f2a4 100644 --- a/docs/agents/codex-cli.md +++ b/docs/agents/codex-cli.md @@ -84,7 +84,7 @@ disabled_tools = ["navigate", "screenshot"] | Search results look stale | `web_search` defaults to `"cached"`. Set `web_search = "live"` in `~/.codex/config.toml`, then restart `codex`. | | `web_search` was disabled and search stopped working | Expected. Set it back to `"live"` or `"cached"` — Webcmd does not replace search. | | `webcmd` not found in Codex shell | Confirm `webcmd` is on the PATH Codex uses; restart after installing the CLI. | -| Browser sessions stop working after idle | Ask the agent to open a fresh session or re-bind with `tabs` and `bind --page`. | +| Browser Session idles or loses its window | Keep the same Session ID; the next `webcmd --session browser ...` command reopens it. Use `webcmd session create -f json`, `webcmd session list`, and `webcmd session close ` for lifecycle. | ## See also diff --git a/docs/agents/cursor.md b/docs/agents/cursor.md index ed5af67e..0a602807 100644 --- a/docs/agents/cursor.md +++ b/docs/agents/cursor.md @@ -52,8 +52,8 @@ Use Webcmd for anything on the open web — fetching, authenticated third-party sites, multi-step automation, workflows worth making reusable: - Check `webcmd list -f json` for an adapter that covers the task; use it first. -- Otherwise drive a live browser with `webcmd browser ...` via the shell tool. -- Run `webcmd doctor` first and keep the session lifecycle (`tabs`, `bind`, `snapshot`, `run`, `close`). +- Otherwise create a session, then drive it with `webcmd --session browser ...` via the shell tool. +- Run `webcmd doctor` first; use `webcmd session list` to inspect state and `webcmd session close ` when finished. - For login walls, use Webcmd's human handoff; never type passwords, OTPs, cookies, or credentials. Use the native Browser tool only for the app being edited: localhost dev server, @@ -77,7 +77,7 @@ Note that the rule is guidance, not a block. Cursor's Browser Automation has bee | Cursor uses its Browser tool for external sites | Confirm `.cursor/rules/webcmd-browser.mdc` has `alwaysApply: true`; for a hard block, set Browser Automation to Off. | | Browser Automation turns itself back on | Known behaviour — a prompt mentioning "browser" can re-enable it. Avoid the word, or turn it off in the agent window. | | `webcmd` not found in Cursor shell | Confirm `webcmd` is on the PATH the Cursor shell uses; restart Cursor after installing the CLI. | -| Browser sessions stop working after idle | Ask the agent to open a fresh session or re-bind with `tabs` and `bind --page`. | +| Browser Session idles or loses its window | Keep the same Session ID; the next `webcmd --session browser ...` command reopens it. Use `webcmd session create -f json`, `webcmd session list`, and `webcmd session close ` for lifecycle. | ## See also diff --git a/docs/agents/hermes.md b/docs/agents/hermes.md index 3da0be33..07e3f2b7 100644 --- a/docs/agents/hermes.md +++ b/docs/agents/hermes.md @@ -52,7 +52,7 @@ Hermes' web surface spans three toolsets: **Hermes toggles toolsets, not individual tools.** There is no way to drop `web_extract` while keeping `web_search`, so leave the `web` toolset on and steer the agent with instructions instead. Add this to your Hermes system prompt or project instructions: -> Use Webcmd (`webcmd list`, `webcmd browser ...` via the `terminal` toolset) for anything on the open web: fetching, authenticated third-party sites, multi-step automation. Prefer it over `web_extract`. Use the `browser_*` tools only for the app being edited — localhost dev server, console and network triage, visual checks. Keep using `web_search` and `x_search` to find URLs. +> Use Webcmd (`webcmd list`, then `webcmd session create -f json` and `webcmd --session browser ...` via the `terminal` toolset) for anything on the open web: fetching, authenticated third-party sites, multi-step automation. Prefer it over `web_extract`. Use the `browser_*` tools only for the app being edited — localhost dev server, console and network triage, visual checks. Keep using `web_search` and `x_search` to find URLs. Also check the `computer_use` toolset. It drives the whole desktop rather than a browser, so it overlaps with Webcmd whenever it is aimed at a website. Disable it if the user does not need desktop control. @@ -85,7 +85,7 @@ Do not disable the `terminal` toolset — that is how Hermes runs `webcmd`. | Search disappeared after disabling `web` | Expected: `web_search` and `web_extract` share one toolset. Re-enable `web` and steer the agent with instructions instead. | | `x_search` appeared on its own | Expected: it auto-registers when `XAI_API_KEY` or Grok OAuth is configured. Leave it — it is search. | | `webcmd` not found in Hermes terminal | Confirm `webcmd` is on the host PATH that Hermes' `terminal` toolset uses; non-interactive shells may skip shell init files. | -| Browser sessions stop working after idle | Ask the agent to open a fresh session or re-bind with `tabs` and `bind --page`. | +| Browser Session idles or loses its window | Keep the same Session ID; the next `webcmd --session browser ...` command reopens it. Use `webcmd session create -f json`, `webcmd session list`, and `webcmd session close ` for lifecycle. | ## See also diff --git a/docs/agents/openclaw.md b/docs/agents/openclaw.md index 8f65f1af..ea4effba 100644 --- a/docs/agents/openclaw.md +++ b/docs/agents/openclaw.md @@ -82,7 +82,7 @@ Or remove it entirely — CLI, `browser.request` gateway method, and agent tool | OpenClaw uses `browser` for external sites | Remind it that Webcmd handles the open web; for a hard block, set `browser.enabled: false`. | | Search stopped working | Check whether `web_search` was denied. Webcmd does not replace search — remove it from `tools.deny`. | | `webcmd` not found in OpenClaw exec | Confirm `webcmd` is on the PATH the Gateway's `exec` tool uses; restart after installing the CLI. | -| Browser sessions stop working after idle | Ask the agent to open a fresh session or re-bind with `tabs` and `bind --page`. | +| Browser Session idles or loses its window | Keep the same Session ID; the next `webcmd --session browser ...` command reopens it. Use `webcmd session create -f json`, `webcmd session list`, and `webcmd session close ` for lifecycle. | ## See also diff --git a/docs/agents/opencode.md b/docs/agents/opencode.md index a880c36a..c3fab330 100644 --- a/docs/agents/opencode.md +++ b/docs/agents/opencode.md @@ -59,8 +59,8 @@ Deny `webfetch` so OpenCode cannot fall back to it while Webcmd is its browser s | Skills not loading in OpenCode | Run `webcmd skills add` with the `agents` provider, restart OpenCode, and check `/skills`. | | OpenCode still uses `webfetch` | Confirm `permission.webfetch` is `deny` in the active config, then restart OpenCode. | | `websearch` is missing entirely | It registers only with the OpenCode provider or `OPENCODE_ENABLE_EXA=1`. Not a Webcmd problem. | -| `webcmd browser` errors | Read `webcmd-usage` and `webcmd-browser` skills; sessions require a `` name after `browser`. | -| Browser sessions stop working after idle | Ask the agent to open a fresh session or re-bind with `tabs` and `bind --page`. | +| `webcmd browser` errors | Read `webcmd-usage` and `webcmd-browser` skills; create a session and pass its ID as root `--session`. | +| Browser Session idles or loses its window | Keep the same Session ID; the next `webcmd --session browser ...` command reopens it. Use `webcmd session create -f json`, `webcmd session list`, and `webcmd session close ` for lifecycle. | ## See also diff --git a/docs/agents/pi.md b/docs/agents/pi.md index 9bdffa79..d245104f 100644 --- a/docs/agents/pi.md +++ b/docs/agents/pi.md @@ -66,7 +66,7 @@ To remove one outright, delete its folder — for example `~/.pi/agent/skills/pi | Pi still uses `browser-tools` or a web-fetch extension | Remove the skill folder or prompt Pi to prefer Webcmd, then restart Pi. | | Search stopped working after removing an extension | Some extensions bundle search with extraction. Reinstall it and steer Pi with instructions instead — Webcmd does not replace search. | | `webcmd` not found in Pi's shell | Confirm `webcmd` is on the PATH Pi's `bash` tool uses; restart Pi after installing the CLI. | -| Browser sessions stop working after idle | Ask the agent to open a fresh session or re-bind with `tabs` and `bind --page`. | +| Browser Session idles or loses its window | Keep the same Session ID; the next `webcmd --session browser ...` command reopens it. Use `webcmd session create -f json`, `webcmd session list`, and `webcmd session close ` for lifecycle. | ## See also diff --git a/docs/authentication-and-profiles.mdx b/docs/authentication-and-profiles.mdx index 5f32eb8a..48893be1 100644 --- a/docs/authentication-and-profiles.mdx +++ b/docs/authentication-and-profiles.mdx @@ -15,7 +15,10 @@ Use Webcmd with my `work` profile to complete this task on Acme Billing. If the ## Named Profiles -Profiles separate browser identities and their login state. Use a named profile when work and personal accounts, customers, or environments must stay separate. +Profiles are cookie jars and authentication scope. Sessions are browser +windows/workspaces within a profile; separate Sessions let multiple agents use +the same profile in parallel. Use a named profile when work and personal +accounts, customers, or environments must stay separate. ## Hosted Profiles @@ -58,7 +61,11 @@ Deletion permanently removes that hosted browser state and its saved sign-in sta ## When You Need to Sign In -When a profile needs interactive sign-in, complete it directly in the browser; never share credentials through chat. +When a profile needs interactive sign-in, complete it directly in the browser; +never share credentials through chat. The handoff belongs to the Session that +started it. After signing in, run the returned verification command verbatim; +it includes the Session selector when applicable. Webcmd blocks `session close` +while that Session has a live handoff. ## Credential Safety @@ -66,6 +73,6 @@ Credentials never belong in prompts or adapter code. Ask the agent to use an aut ## Local and Hosted Authentication -In local mode, `webcmd login` opens the foreground Webcmd browser and returns `action_required` immediately. Complete sign-in in that browser, tell the agent when you are done, and let it run `webcmd whoami` before retrying the original task. Never send credentials, OTPs, cookies, or recovery codes through chat. +In local mode, `webcmd login` opens the foreground Webcmd browser and returns `action_required` immediately. Complete sign-in in that browser, tell the agent when you are done, and let it run the returned verification command before retrying the original task. Never send credentials, OTPs, cookies, or recovery codes through chat. Hosted mode returns a Webcmd-owned live authentication view when interactive sign-in is required. Complete the sign-in there before the agent continues. diff --git a/docs/cli-reference.mdx b/docs/cli-reference.mdx index 6f85e232..3e5bee3d 100644 --- a/docs/cli-reference.mdx +++ b/docs/cli-reference.mdx @@ -57,16 +57,28 @@ The old `web read` command has been renamed to `web fetch-browser`. ## Local Browser Programs -`browser run` executes one Playwright-style JavaScript program against an -existing local CloakBrowser session: +Create an opaque session before raw browser work. Profiles hold cookie/auth +state; sessions are browser workspaces within that profile. Adapter commands +may omit `--session` and use their profile's adapter-default session; pass +`--session ` only when intentionally routing an adapter into an +explicit session. Raw browser commands must always pass it. The retired +positional session form is invalid: ```bash -webcmd browser work snapshot --snapshot-mode act -webcmd browser work snapshot --snapshot-mode read -webcmd browser work run --stdin --timeout 45 -webcmd browser work run --stdin --no-snapshot-diff +webcmd session create -f json +webcmd --session session_abc browser snapshot --snapshot-mode act +webcmd --session session_abc browser snapshot --snapshot-mode read +webcmd --session session_abc browser run --stdin --timeout 45 +webcmd --session session_abc browser run --stdin --no-snapshot-diff +webcmd session list +webcmd session close session_abc ``` +Agents sharing a profile can work in parallel by creating separate Sessions. +An authentication handoff is scoped to the Session that started it: run the +returned verification command verbatim because it includes `--session` when +needed. `session close` is blocked while that Session has a live handoff. + Use `snapshot` for explicit page inspection. `act` is the default action-first mode, `tree` preserves fuller page structure, and `read` extracts readable article/content text. Exactly one of `--file ` or `--stdin` is required for `run`. The CLI reads files locally and @@ -79,11 +91,12 @@ The program runs in a fresh QuickJS sandbox with `page`, `context`, `browser`, and `console` globals. `page.snapshotForAI()` is not available. It can use the supported Page/Frame/Locator methods and passively inspect request and response events. It cannot access Node.js, the filesystem, environment variables, raw -CDP endpoints, browser launch/connect APIs, or browser-context ownership. +CDP endpoints, browser launch/connect APIs, browser-context ownership, or +`context.newPage()`. Screenshot bytes are written to a Webcmd-owned cache directory and returned as a receipt. -The public browser surface is `tabs`, `bind`, `run`, `snapshot`, and `close`. +The public raw-browser surface is `tabs`, `bind`, `run`, and `snapshot`. Reusable adapters continue to use the existing `IPage` API. Playwright-style programs are for reconnaissance and ad-hoc multi-step work; they are not pasted into adapter modules. diff --git a/docs/concepts.mdx b/docs/concepts.mdx index b545f986..58583f3e 100644 --- a/docs/concepts.mdx +++ b/docs/concepts.mdx @@ -41,9 +41,11 @@ The agent chooses the strategy; the human describes the outcome and constraints. | `UI` | Drives the live page UI. | | `LOCAL` | Talks to a local app, service, or CLI. | -## Sessions and State +## Profiles, Sessions, And Tabs -Browser commands can use an `ephemeral` session for an isolated tab or a `persistent` session for a longer workflow. A command can request `freshPage: true` when it needs a clean tab while keeping its session state. +A Profile is the browser identity and storage bucket, such as `default` or `work`. A Session is an opaque browser workspace inside a Profile; raw browser work creates one with `webcmd session create` and selects it at the root with `--session `. A tab is one page inside that Session. + +Adapter browser commands can use `siteSession: 'ephemeral'` for an isolated tab or `siteSession: 'persistent'` for a longer same-site workflow. Those adapter site-session modes are separate from raw browser Sessions. ## What the Human Needs to Decide diff --git a/docs/skills.mdx b/docs/skills.mdx index 1ddd97b9..52b8bd38 100644 --- a/docs/skills.mdx +++ b/docs/skills.mdx @@ -32,3 +32,4 @@ Do not also add the skills with `webcmd skills add` in Codex. ## Other Agents or Plugin-Free Setup Run `webcmd skills add` to install or refresh the bundled Webcmd skills for your agent. The agent can then start with `webcmd-usage` and load the specialized skill that matches the outcome. +For raw browser work, agents should create a Session with `webcmd session create -f json` and pass it as a root selector: `webcmd --session browser ...`. diff --git a/docs/troubleshooting.mdx b/docs/troubleshooting.mdx index 2ee3f6d8..2382c2ad 100644 --- a/docs/troubleshooting.mdx +++ b/docs/troubleshooting.mdx @@ -35,6 +35,27 @@ Webcmd says the browser bridge is unavailable. Diagnose the daemon and runtime, Public adapters may still work. Authenticated, intercepted, UI, and browser-backed workflows need the bridge. +## Browser Session Errors + +```text +Webcmd returned a SESSION_* error. Explain which Profile/Session is involved, whether another command or human handoff is holding it, and give me the next safe command. +``` + +Common Session codes: + +| Code | Meaning | Next step | +| --- | --- | --- | +| `SESSION_REQUIRED` | A raw browser command needs a root Session selector. | Run `webcmd session create -f json`, then retry as `webcmd --session browser ...`. | +| `INVALID_SESSION_SELECTOR` | The selector is not an opaque Webcmd Session ID. | Use an ID returned by `webcmd session create` or `webcmd session list`. | +| `SESSION_SELECTOR_POSITION` | `--session` was placed after the command name. | Move it before the command: `webcmd --session browser ...`. | +| `SESSION_NOT_FOUND` | The selected Session is missing for the current Profile. | Run `webcmd session list -f json`; create a new Session if needed. | +| `INVALID_SESSION_LIMIT` | `session list --limit` is outside 1-100. | Retry with a limit from 1 to 100. | +| `SESSION_BUSY` | Another command is writing in the same Session or site scope. | Wait for the holder to finish; if it is dead, use `webcmd session close --force` as the last resort. | +| `SESSION_PAUSED_FOR_HUMAN_HANDOFF` | The Session is waiting for user action such as sign-in. | Finish the action in the browser, then run the returned verifier before retrying. | +| `SESSION_WINDOW_CONFLICT` | The Session's window ownership no longer matches Webcmd's expected window. | Inspect with `webcmd session list`; close and recreate the Session if needed. | +| `SESSION_CAPACITY_EXCEEDED` | The account or runtime has reached its concurrent Session limit. | Close idle Sessions or wait for one to finish. | +| `HOSTED_CONTRACT_MISMATCH` | The hosted CLI and cloud API disagree about the Session protocol. | Upgrade `webcmd` to the version pinned by the cloud service, then retry. | + ## Multiple Profiles Are Connected ```text diff --git a/package-lock.json b/package-lock.json index 4e08a3b2..042dfcda 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@agentrhq/webcmd", - "version": "0.6.0", + "version": "0.6.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@agentrhq/webcmd", - "version": "0.6.0", + "version": "0.6.2", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index f9ea6fb8..0b0cf0d1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@agentrhq/webcmd", - "version": "0.6.0", + "version": "0.6.2", "description": "Turn websites, browser sessions, desktop apps, and local tools into deterministic CLI surfaces for humans and AI agents.", "engines": { "node": ">=20.6.0" @@ -73,6 +73,7 @@ "test:plugin": "vitest run --project plugin", "test:all": "vitest run", "test:e2e": "vitest run --project e2e-fixed-port --project e2e", + "gate:cloak-sessions": "WEBCMD_LIVE_CLOAK=1 vitest run --project e2e tests/e2e/cloak-session-concurrency.test.ts", "check-community-plugins": "tsx scripts/sync-community-plugins.ts --check", "advise:listing-id-pairing": "node scripts/check-listing-id-pairing.mjs", "check:package-bin": "node scripts/check-package-bin.mjs", diff --git a/plugins/linkedin/package.json b/plugins/linkedin/package.json index 49c55c9a..d2845134 100644 --- a/plugins/linkedin/package.json +++ b/plugins/linkedin/package.json @@ -4,6 +4,6 @@ "type": "module", "description": "LinkedIn profile, network, messaging, job, and Sales Navigator commands for WebCMD", "peerDependencies": { - "@agentrhq/webcmd": ">=0.6.0" + "@agentrhq/webcmd": ">=0.6.2" } } diff --git a/plugins/linkedin/webcmd-plugin.json b/plugins/linkedin/webcmd-plugin.json index 22cc0dbc..00bf8c16 100644 --- a/plugins/linkedin/webcmd-plugin.json +++ b/plugins/linkedin/webcmd-plugin.json @@ -2,7 +2,7 @@ "name": "linkedin", "version": "0.1.0", "description": "LinkedIn profile, network, messaging, job, and Sales Navigator commands for WebCMD", - "webcmd": ">=0.6.0", + "webcmd": ">=0.6.2", "author": { "name": "WebCMD Agent", "handle": "agentrhq" diff --git a/skills/webcmd-adapter-author/SKILL.md b/skills/webcmd-adapter-author/SKILL.md index b7d7428d..2f45c362 100644 --- a/skills/webcmd-adapter-author/SKILL.md +++ b/skills/webcmd-adapter-author/SKILL.md @@ -13,7 +13,7 @@ plus `webcmd doctor`, `webcmd browser init`, and `webcmd browser verify`. Browser-run programs are discovery evidence, not adapter source. -Browser-profile auth commands must reuse `registerSiteAuthCommands`. Keep only site-specific `verify` and `openLogin` logic in the adapter. The login row must return `action_required` and `verify_command` (normally `webcmd whoami`); after the user reports done, agents run that returned command and verification must succeed before retrying the original workflow. Credentials, MFA, and CAPTCHA always use human handoff: CAPTCHA stops automation until the user reports done and verification succeeds, and adapter code must not collect or type passwords or secrets. +Browser-profile auth commands must reuse `registerSiteAuthCommands`. Keep only site-specific `verify` and `openLogin` logic in the adapter. The login row must return `action_required` and `verify_command`; after the user reports done, agents run that returned command verbatim (it includes `--session` when applicable), and verification must succeed before retrying the original workflow. Credentials, MFA, and CAPTCHA always use human handoff: CAPTCHA stops automation until the user reports done and verification succeeds, and adapter code must not collect or type passwords or secrets. Commands whose primary operation searches or discovers matching items from a corpus must set `tags: ['search']`. Add short `keywords` only for non-obvious intent synonyms; do not infer tags from command names alone when authoring new adapters. @@ -149,8 +149,8 @@ Check these off step by step: [ ] If memory is older than 30 days according to `verified_at`, treat it as stale and use the cold-start path through Steps 3 and 4. [ ] 3. Recon (`site-recon.md`): - [ ] **Preferred:** use `webcmd browser recon run --stdin` for navigation, readiness, network hints, and page evidence in one Playwright-style program. - [ ] Use `webcmd browser recon snapshot --snapshot-mode tree` when structural page evidence is needed. + [ ] **Preferred:** create a session, then use `webcmd --session browser run --stdin` for navigation, readiness, network hints, and page evidence in one Playwright-style program. + [ ] Use `webcmd --session browser snapshot --snapshot-mode tree` when structural page evidence is needed. [ ] Use the run result as reconnaissance evidence; do not copy Playwright code into an adapter. [ ] Choose Pattern A / B / C / D / E. @@ -262,7 +262,7 @@ Check these off step by step: - **Intermediate parsing object keys must not overlap any `columns` entry.** Otherwise silent-column-drop audits can misread the adapter. Use dedicated internal names and destructure with aliases when pushing rows. - **The `browser:` field determines the `func` signature:** `browser:false -> (args)`, `browser:true -> (page, args)`. If this is reversed, `args` may actually be a debug flag and all external parameters can silently fall back to defaults. - Throw the correct typed error for known failures according to [`references/typed-errors.md`](./references/typed-errors.md). **Do not** silently `return []`, **do not** silently `return [{sentinel}]`, and **do not** silently clamp external parameters with `Math.max/min`. -- **Persistent sessions keep stale DOM between commands.** `siteSession: 'persistent'` shares one tab per site; leftover modals/drawers from the previous command leak into the next one. State-sensitive write commands (checkout flows) should add `freshPage: true` (new tab, same lease — cookies/login/location survive). Verify session-scoped context (login, selected city/date) *before* side effects, and embed such context in URLs/IDs your command emits for sibling commands. See `references/adapter-template.md` and "Persistent Sessions and State Hygiene" in `docs/authoring.mdx`. +- **Persistent site sessions keep stale DOM between commands.** `siteSession: 'persistent'` shares one tab per site; leftover modals/drawers from the previous command leak into the next one. State-sensitive write commands (checkout flows) should add `freshPage: true` (new tab, same lease — cookies/login/location survive). Verify session-scoped context (login, selected city/date) *before* side effects, and embed such context in URLs/IDs your command emits for sibling commands. See `references/adapter-template.md` and "Persistent Site Sessions and State Hygiene" in `docs/authoring.mdx`. - For private iteration, write `~/.webcmd/clis//.js` to avoid a build. Use `webcmd adapter override /` to fork an existing plugin command. When the user says to promote a CLI, keep the `webcmd plugin create --dir plugins/` step for packaging a new plugin, then register it in root `webcmd-plugin.json`, install the plugin, and run `webcmd validate ` and smoke commands. See `references/adapter-template.md` for details. - After `webcmd plugin update`, check the reported overrides needing reconciliation and merge `yours` with `upstream`, using `base` as the common ancestor for a three-way merge. Only overrides are reported; a user-authored adapter has no upstream. - Write site memory every round: no memory -> use skill -> produce memory -> next time becomes a five-minute task. diff --git a/skills/webcmd-adapter-author/references/adapter-template.md b/skills/webcmd-adapter-author/references/adapter-template.md index 20871dac..0933a606 100644 --- a/skills/webcmd-adapter-author/references/adapter-template.md +++ b/skills/webcmd-adapter-author/references/adapter-template.md @@ -133,7 +133,7 @@ Rules: | `args` | Include type, default, and help for every external parameter. | | `columns` | Must exactly match row keys, including order. | | `pipeline` or `func` | Use the style already established by nearby adapters. | -| `siteSession` | `'persistent'` shares one tab per site across commands (multi-step flows); `'ephemeral'` gets a fresh isolated tab per run. Persistent tabs keep leftover DOM (modals, drawers) between commands — see "Persistent Sessions and State Hygiene" in docs/authoring.mdx. | +| `siteSession` | `'persistent'` shares one tab per site across commands (multi-step flows); `'ephemeral'` gets a fresh isolated tab per run. Persistent site-session tabs keep leftover DOM (modals, drawers) between commands — see "Persistent Site Sessions and State Hygiene" in docs/authoring.mdx. | | `freshPage` | With `siteSession: 'persistent'`, set `true` to start the command on a newly created tab under the same lease: profile state (cookies, login, location) survives, stale DOM does not. Recommended for state-sensitive write commands such as checkout flows. | ## Strategy Enum Examples diff --git a/skills/webcmd-adapter-author/references/api-discovery.md b/skills/webcmd-adapter-author/references/api-discovery.md index 1cf9bbf4..c896d904 100644 --- a/skills/webcmd-adapter-author/references/api-discovery.md +++ b/skills/webcmd-adapter-author/references/api-discovery.md @@ -28,7 +28,7 @@ For example, a page on `jobs.51job.com` fetching an API on `cupid.51job.com` wil Probe it explicitly: ```bash -webcmd browser recon run --stdin <<'JS' +webcmd --session browser run --stdin <<'JS' await page.goto('https:///'); return await page.evaluate(async () => { try { @@ -54,7 +54,7 @@ When it is blocked, `credentials: include` is not a CORS fix across subdomains. Use for Pattern A and for deeper data in Pattern B. ```bash -webcmd browser recon run --stdin <<'JS' +webcmd --session browser run --stdin <<'JS' const candidates = []; page.on('response', async response => { const url = response.url(); @@ -92,7 +92,7 @@ Reject candidates that only contain telemetry, unrelated recommendations, beacon Replay directly when possible: ```bash -webcmd browser recon run --stdin <<'JS' +webcmd --session browser run --stdin <<'JS' return await page.evaluate(async () => fetch('', { credentials: 'include' }).then(r => r.text()) ); @@ -136,7 +136,7 @@ Look for: Commands: ```bash -webcmd browser recon run --stdin <<'JS' +webcmd --session browser run --stdin <<'JS' return await page.evaluate(() => ({ globals: Object.keys(window).filter(k => /STATE|DATA|NUXT|APP/i.test(k)), jsonScriptCount: document.querySelectorAll('script[type="application/json"], script:not([src])').length, @@ -154,7 +154,7 @@ Use for Pattern C. Collect script sources: ```bash -webcmd browser recon run --stdin <<'JS' +webcmd --session browser run --stdin <<'JS' return await page.evaluate(() => [...document.querySelectorAll('script[src]')].map(s => s.src)); JS ``` @@ -194,7 +194,7 @@ Find token sources in this order: Useful probes: ```bash -webcmd browser recon run --stdin <<'JS' +webcmd --session browser run --stdin <<'JS' return await page.evaluate(() => ({ csrf: document.querySelector('meta[name="csrf-token"]')?.content ?? null, localStorageKeys: Object.keys(localStorage), @@ -218,7 +218,7 @@ Use only after public API, cookie API, DOM state, and UI selector options are in For page actions: ```bash -webcmd browser recon run --stdin <<'JS' +webcmd --session browser run --stdin <<'JS' const pending = page.waitForResponse(response => response.url().includes('')); await page.locator('').click(); const response = await pending; diff --git a/skills/webcmd-adapter-author/references/field-decode-playbook.md b/skills/webcmd-adapter-author/references/field-decode-playbook.md index 28d67626..809dc5b3 100644 --- a/skills/webcmd-adapter-author/references/field-decode-playbook.md +++ b/skills/webcmd-adapter-author/references/field-decode-playbook.md @@ -18,7 +18,7 @@ Change the site's sort order in the UI, or change known query params, then compa Example workflow: ```bash -webcmd browser recon run --stdin <<'JS' +webcmd --session browser run --stdin <<'JS' const responses = []; page.on('response', async response => { if (!response.url().includes('')) return; diff --git a/skills/webcmd-adapter-author/references/jsdom-fixture-pattern.md b/skills/webcmd-adapter-author/references/jsdom-fixture-pattern.md index a243e646..6c3b4df8 100644 --- a/skills/webcmd-adapter-author/references/jsdom-fixture-pattern.md +++ b/skills/webcmd-adapter-author/references/jsdom-fixture-pattern.md @@ -35,7 +35,7 @@ Temporary debug dumps still belong only in: Use the browser to capture the specific DOM region, not the entire page. ```bash -webcmd browser recon run --stdin <<'JS' +webcmd --session browser run --stdin <<'JS' return await page.evaluate(() => document.querySelector('')?.outerHTML ?? ''); JS ``` diff --git a/skills/webcmd-adapter-author/references/site-recon.md b/skills/webcmd-adapter-author/references/site-recon.md index 61070b74..e2b14454 100644 --- a/skills/webcmd-adapter-author/references/site-recon.md +++ b/skills/webcmd-adapter-author/references/site-recon.md @@ -9,7 +9,7 @@ This file only classifies sites. It does not explain how to discover endpoints. Preferred flow: ```bash -webcmd browser recon run --stdin --snapshot-mode tree <<'JS' +webcmd --session browser run --stdin --snapshot-mode tree <<'JS' const responses = []; page.on('response', response => { const contentType = response.headers()['content-type'] || ''; @@ -42,7 +42,7 @@ JS Then inspect page structure when needed: ```bash -webcmd browser recon snapshot --snapshot-mode tree +webcmd --session browser snapshot --snapshot-mode tree ``` Use this evidence to choose Pattern A/B/C/D/E. Do not paste the Playwright-style program into the adapter. @@ -53,9 +53,9 @@ Use this when the user already has a relevant tab open. List pages, bind the cho then run dependent recon steps together: ```bash -webcmd browser recon tabs -webcmd browser recon bind --page page-123 -webcmd browser recon run --stdin <<'JS' +webcmd --session browser tabs +webcmd --session browser bind --page page-123 +webcmd --session browser run --stdin <<'JS' const responsePromise = page.waitForResponse( response => response.url().includes('/api/path-fragment'), ); @@ -72,7 +72,7 @@ JS Then inspect the current page when a snapshot is needed: ```bash -webcmd browser recon snapshot --snapshot-mode tree +webcmd --session browser snapshot --snapshot-mode tree ``` Use the snapshot and any response evidence collected in the run to classify the site: diff --git a/skills/webcmd-adapter-author/references/strategy-selection.md b/skills/webcmd-adapter-author/references/strategy-selection.md index 4d226999..6bc54e6a 100644 --- a/skills/webcmd-adapter-author/references/strategy-selection.md +++ b/skills/webcmd-adapter-author/references/strategy-selection.md @@ -64,7 +64,7 @@ Use when only page-context `fetch` can reuse same-origin/session/runtime state. Required evidence: -- `webcmd browser recon run --stdin` with page-context `fetch(...)` returns non-empty target data. +- `webcmd --session browser run --stdin` with page-context `fetch(...)` returns non-empty target data. - Simpler strategies are ruled out in the strategy note. - Internal endpoint drift risk is accepted and documented. diff --git a/skills/webcmd-autofix/SKILL.md b/skills/webcmd-autofix/SKILL.md index 5954e571..50d84b75 100644 --- a/skills/webcmd-autofix/SKILL.md +++ b/skills/webcmd-autofix/SKILL.md @@ -12,8 +12,8 @@ When a `webcmd` command fails because a website changed its DOM, API, or respons Hard stops before any code change: -- **Human-action handoff:** if a failure returns `handoff.status === action_required`, stop before trace collection or AutoFix. Give the user `handoff.action` and any `Webcmd browser:` or `handoff.viewUrl` link, then wait. Never request or enter credentials, passwords, or CAPTCHA answers. After the user reports done, run `handoff.verifyCommand` when present; verification must succeed before retrying. Without a verifier, inspect fresh browser state and verify the intended post-action state before any retry, especially for write commands. -- **`AUTH_REQUIRED`** (exit code 77): if a site login command exists, run `webcmd login`, give its `action_required` instructions and any returned `action_url` or `view_url` to the user, and wait. Run the returned `verify_command` (normally `webcmd whoami`); verification must succeed before retrying the original command. If no site login command exists, stop browser writes, hand the visible browser to the user, and wait. After they report done, take fresh browser state and use an available identity check or verify the intended post-action state before retrying. Their report alone is not verification. Never request, type, echo, store, or automate passwords, OTPs, recovery codes, cookies, or session secrets. +- **Human-action handoff:** if a failure returns `handoff.status === action_required`, stop before trace collection or AutoFix. The handoff is scoped to its Session, which cannot be closed while the handoff is live. Give the user `handoff.action` and any `Webcmd browser:` or `handoff.viewUrl` link, then wait. Never request or enter credentials, passwords, or CAPTCHA answers. After the user reports done, run the returned `handoff.verifyCommand` verbatim; it includes `--session` when applicable, and verification must succeed before retrying. Without a verifier, inspect fresh browser state and verify the intended post-action state before any retry, especially for write commands. +- **`AUTH_REQUIRED`** (exit code 77): if a site login command exists, run `webcmd login`, give its `action_required` instructions and any returned `action_url` or `view_url` to the user, and wait. Run the returned `verify_command` verbatim; it includes `--session` when applicable, and verification must succeed before retrying the original command. If no site login command exists, stop browser writes, hand the visible browser to the user, and wait. After they report done, take fresh browser state and use an available identity check or verify the intended post-action state before retrying. Their report alone is not verification. Never request, type, echo, store, or automate passwords, OTPs, recovery codes, cookies, or session secrets. - **`BROWSER_CONNECT`** (exit code 69): stop. Tell the user to run `webcmd doctor`. - **CAPTCHA / raw-browser user takeover:** stop automation. Follow the human-action handoff above when one is returned; otherwise let the user act in the visible browser. Verification must succeed before retrying. With no verifier, take fresh browser state and verify the intended post-action state before any retry. The user's report alone is not verification. CAPTCHA is not an adapter issue. - **Rate limiting / IP block:** stop. This is not an adapter issue. @@ -61,7 +61,7 @@ Persistent-session adapters (`siteSession: 'persistent'`) share one tab per site - Check the trace screenshot and `location.href`: a modal over a blank page or the wrong URL means the tab carried stale DOM from a previous command, not that the site rejected this request. - Check session-scoped context: sites often scope results to a selected city, date, or account. A "closed" / "unavailable" verdict can simply mean the browser's selected context does not match the request (for example, a seat layout opened while the site's location cookie points at another city). -- Reproduce in a separate browser session with `webcmd browser repair-clean run --stdin` before trusting the verdict. If it only fails in the adapter's persistent tab, fix state handling (`freshPage: true`, dismiss-and-renavigate, context preconditions) instead of selectors. +- Reproduce in a separate browser session with `webcmd --session browser run --stdin` before trusting the verdict. If it only fails in the adapter's persistent tab, fix state handling (`freshPage: true`, dismiss-and-renavigate, context preconditions) instead of selectors. ## Step 1: Collect Trace Context @@ -144,18 +144,18 @@ Use `webcmd browser` to inspect the live site. Do not use the broken adapter for For DOM changes: ```bash -webcmd browser repair run --stdin --snapshot-mode tree <<'JS' +webcmd --session browser run --stdin --snapshot-mode tree <<'JS' await page.goto('https://example.com/target-page'); await page.waitForLoadState('domcontentloaded'); return { url: page.url(), title: await page.title() }; JS -webcmd browser repair snapshot --snapshot-mode tree +webcmd --session browser snapshot --snapshot-mode tree ``` For API changes: ```bash -webcmd browser repair run --stdin <<'JS' +webcmd --session browser run --stdin <<'JS' const responses = []; page.on('response', async response => { if (!response.url().includes('')) return; @@ -290,8 +290,8 @@ In all stop cases, clearly report the situation instead of making speculative pa -> Page loaded, but post cards now use "[data-testid=post-container]" 4. Agent explores: - -> webcmd browser repair run --stdin --snapshot-mode tree - -> webcmd browser repair snapshot --snapshot-mode tree + -> webcmd --session browser run --stdin --snapshot-mode tree + -> webcmd --session browser snapshot --snapshot-mode tree 5. Agent patches adapterSourcePath: -> Replace old selector with stable scoped selector diff --git a/skills/webcmd-browser-sitemap/SKILL.md b/skills/webcmd-browser-sitemap/SKILL.md index e25b57e1..6cbf0f45 100644 --- a/skills/webcmd-browser-sitemap/SKILL.md +++ b/skills/webcmd-browser-sitemap/SKILL.md @@ -6,7 +6,7 @@ allowed-tools: Bash(webcmd:*), Read, Edit, Write, Grep # webcmd-browser-sitemap -Use this skill when `webcmd browser run --stdin` or an adapter trace reports `sitemap.available: true`, or when the user asks you to use a site's sitemap. +Use this skill when `webcmd --session browser run --stdin` or an adapter trace reports `sitemap.available: true`, or when the user asks you to use a site's sitemap. The sitemap is **prior knowledge**, not ground truth. It should reduce blind clicking, but it must never override the live browser state. @@ -14,7 +14,7 @@ The sitemap is **prior knowledge**, not ground truth. It should reduce blind cli ## Consumption Loop -1. Run or reuse `webcmd browser snapshot --snapshot-mode tree` to know the current page. +1. Run or reuse `webcmd --session browser snapshot --snapshot-mode tree` to know the current page. 2. Read only the smallest relevant sitemap files: - `SITE.md` for site-level orientation. - One matching `pages/.md` for current state. diff --git a/skills/webcmd-browser/SKILL.md b/skills/webcmd-browser/SKILL.md index bda8421b..3bc5a7c0 100644 --- a/skills/webcmd-browser/SKILL.md +++ b/skills/webcmd-browser/SKILL.md @@ -30,42 +30,42 @@ Until `doctor` is green, browser commands may fail. Registry and plugin discover ## Session lifecycle -- `webcmd browser *` commands require a `` positional immediately after `browser`. -- Use the same session name for a multi-step flow; use a different name to isolate parallel browser work. +- Create an opaque browser session before raw browser work: `webcmd session create -f json`. +- Raw browser commands require that ID at the root: `webcmd --session browser ...`; the old positional session form is retired. +- Profiles are cookie jars and auth scope; sessions are browser workspaces/windows within a profile. Parallel agents use separate sessions. +- `webcmd session list` shows sessions and their handoff/runtime state; close finished work with `webcmd session close `. Close is blocked while that Session has a live handoff. - Browser state in the bound page persists between calls, but each `run` gets a fresh JavaScript scope. -- `webcmd browser tabs` lists existing pages without creating a new one. -- `webcmd browser bind --page ` explicitly attaches a session to an existing page. -- `webcmd browser close` releases the session when finished. +- `webcmd --session browser tabs` lists existing pages without creating a new one. +- `webcmd --session browser bind --page ` explicitly attaches the session to an existing page. - If the user manually signs in or changes the visible tab, re-bind or inspect with a fresh snapshot before continuing. --- ## Command surface -The raw surface is `tabs`, `bind --page`, `snapshot`, `run`, and `close`. +The raw surface is `tabs`, `bind --page`, `snapshot`, and `run`; close through `webcmd session close`. Common calls: -1. `webcmd browser work tabs` lists existing pages and is read-only. -2. `webcmd browser work bind --page page-123` is an explicit bind that selects one page for `work`. -3. `webcmd browser work snapshot --snapshot-mode act` inspects actionable controls. Use `--snapshot-mode tree` for fuller page structure or `--snapshot-mode read` for readable article/content text. -4. `webcmd browser work run --stdin` runs one JavaScript program with fresh JavaScript scope and persistent browser state in the bound page. -5. `webcmd browser work close` detaches and closes the session when finished. +1. `webcmd --session browser tabs` lists existing pages and is read-only. +2. `webcmd --session browser bind --page page-123` is an explicit bind that selects one page. +3. `webcmd --session browser snapshot --snapshot-mode act` inspects actionable controls. Use `--snapshot-mode tree` for fuller page structure or `--snapshot-mode read` for readable article/content text. +4. `webcmd --session browser run --stdin` runs one JavaScript program with fresh JavaScript scope and persistent browser state in the bound page. +5. `webcmd session close ` closes the session when finished. | command | use | | --- | --- | | `tabs` | List existing pages. Read-only. | -| `bind --page ` | Bind a named session to an existing page. | +| `bind --page ` | Bind this session to an existing page. | | `snapshot --snapshot-mode act` | Inspect actionable controls. | | `snapshot --snapshot-mode tree` | Inspect fuller page structure. | | `snapshot --snapshot-mode read` | Extract readable article/content text. | | `run --stdin` / `run --file ` | Execute one Playwright-style program with `page`, `context`, `browser`, and `console`. | -| `close` | Release the browser session. | Keep related browser actions in one `run` and return compact JSON-compatible data. Successful runs return `snapshotDiff` automatically. Use `--no-snapshot-diff` only when the program is pure read-only and its result already contains the needed state. Do not call the legacy semantic-snapshot page helper; it is not part of Webcmd's Playwright runtime. ```bash -webcmd browser work run --stdin <<'JS' +webcmd --session session_abc browser run --stdin <<'JS' await page.goto('https://example.com'); await page.getByRole('link', { name: 'More information' }).click(); return { title: await page.title(), url: page.url() }; @@ -95,7 +95,7 @@ Prefer one `run` over shell-chaining multiple browser calls. It keeps Playwright Good: ```bash -webcmd browser checkout run --stdin <<'JS' +webcmd --session session_abc browser run --stdin <<'JS' await page.goto('https://example.com/cart'); const pending = page.waitForResponse(r => r.url().includes('/api/checkout')); await page.getByRole('button', { name: /checkout/i }).click(); @@ -120,6 +120,10 @@ If Webcmd reports sitemap context, load `webcmd-browser-sitemap` before continui If a failure returns `handoff.status === action_required`, stop browser writes. Give the user `handoff.action` and any `Webcmd browser:` or `handoff.viewUrl` link, then wait. After the user reports done, run `handoff.verifyCommand` when present; verification must succeed before retrying. +The handoff is scoped to the Session that started it. Run the returned +`verify_command` or `handoff.verifyCommand` verbatim; it includes `--session` +when applicable. Do not close that Session during the live handoff. + 1. On a clear login redirect or auth wall, stop browser writes. 2. If the site exposes a login command, run `webcmd login`. 3. `already_logged_in` is verified; continue. @@ -136,7 +140,7 @@ For CAPTCHA or raw user takeover, stop automation, give the user any viewer URL For native controls, inspect structure first, then use Playwright's normal form APIs inside `run`. Do not guess date formats, option labels, or file constraints from memory; read them from the DOM. ```bash -webcmd browser form run --stdin <<'JS' +webcmd --session session_abc browser run --stdin <<'JS' await page.goto('https://example.com/form'); const country = page.locator('select[name="country"]'); return { @@ -157,7 +161,7 @@ For custom React/Radix/shadcn/Material UI dropdowns, use semantic locators and v ### Capture a request triggered by UI ```bash -webcmd browser app run --stdin <<'JS' +webcmd --session session_abc browser run --stdin <<'JS' await page.goto('https://example.com/search'); const pending = page.waitForResponse(r => r.url().includes('/api/search')); await page.getByRole('textbox', { name: /search/i }).fill('browser automation'); @@ -208,6 +212,9 @@ Use `run` and inspect `page.frames()`; target the frame by URL/name and keep ifr | Bound page is wrong or stale | Run `tabs`, choose the current page id, then `bind --page ` again. | | `run` times out before returning | Increase `--timeout` only after checking whether the wait condition is wrong. | | Write may have happened before timeout | Take a fresh snapshot before retrying. Avoid duplicate submissions. | +| `SESSION_REQUIRED` | Create a Session, then retry with root `--session `. | +| `SESSION_BUSY` | Wait for the listed holder; if it is dead, `webcmd session close --force` is the last resort. | +| `SESSION_PAUSED_FOR_HUMAN_HANDOFF` | Finish the handoff and run the returned verifier before retrying. | | Login wall appears | Use the Authentication and human handoff recipe. | | User reports login complete | Run the returned verifier first. Without one, inspect fresh state and verify identity/post-action state. | | Page shows expected data but returned extraction is empty | Use `snapshot --snapshot-mode tree` to locate scope, or capture the network response in `run`. | diff --git a/skills/webcmd-browser/references/browser-run-playwright.md b/skills/webcmd-browser/references/browser-run-playwright.md index c6de193a..fdb2ac31 100644 --- a/skills/webcmd-browser/references/browser-run-playwright.md +++ b/skills/webcmd-browser/references/browser-run-playwright.md @@ -4,6 +4,8 @@ `run` evaluates the supplied JavaScript in a fresh sandbox. Browser state in the bound session persists, but JavaScript variables and handles do not. `page`, `context`, `browser`, and `console` are normal Playwright globals; use the vendored Playwright client as the API reference. Return only JSON-compatible data. `page.snapshotForAI()` is not available. +`context.newPage()` is not available inside `run`; create or bind Session tabs through Webcmd commands so page ownership stays deterministic. + ## Artifact paths Artifacts written by Playwright must use a relative logical filename. Webcmd returns an artifact receipt with its locator; it does not grant host-path write access. @@ -14,7 +16,7 @@ Artifacts written by Playwright must use a relative logical filename. Webcmd ret ## Snapshot behavior -Use `webcmd browser snapshot --snapshot-mode act` to inspect actionable controls, `--snapshot-mode tree` for fuller page structure, or `--snapshot-mode read` for readable article/content text. Successful runs return `snapshotDiff` automatically and support `--snapshot-mode act|tree`; pass `--no-snapshot-diff` only for pure read-only code when its result already contains the needed state. A failed post-run snapshot becomes a warning, not a successful result change. +Use `webcmd --session browser snapshot --snapshot-mode act` to inspect actionable controls, `--snapshot-mode tree` for fuller page structure, or `--snapshot-mode read` for readable article/content text. Successful runs return `snapshotDiff` automatically and support `--snapshot-mode act|tree`; pass `--no-snapshot-diff` only for pure read-only code when its result already contains the needed state. A failed post-run snapshot becomes a warning, not a successful result change. ## Timing diff --git a/skills/webcmd-sitemap-author/SKILL.md b/skills/webcmd-sitemap-author/SKILL.md index 826b09d7..c5e4e892 100644 --- a/skills/webcmd-sitemap-author/SKILL.md +++ b/skills/webcmd-sitemap-author/SKILL.md @@ -45,7 +45,7 @@ The 800-token target remains the audit threshold. If a file exceeds it, either e ## Authoring Loop 1. Load existing memory: local overlay first, then global seed if present. -2. Verify reality with `webcmd browser state`, `find`, `network`, and `analyze`. Browser state is truth. +2. Verify reality with `webcmd --session browser state`, `find`, `network`, and `analyze`. Browser state is truth. 3. If you just completed `webcmd-adapter-author` for this site, seed from retained browse traces under `~/.webcmd/sites//traces/` instead of rediscovering from zero. 4. Record durable structure only: page purpose, stable anchors, state signatures, actions, workflows, API references, pitfalls. 5. Use stable ids for pages, actions, and workflows. They should survive URL params, locale text drift, and minor layout changes. @@ -63,7 +63,7 @@ do: post: fail: | recover: ; adapter_health_update: -> suspect -evidence: webcmd browser or trace: +evidence: webcmd --session browser or trace: ``` Use this compact form by default. Use the longer Markdown form from `references/sitemap-schema.md` only when an action genuinely needs longer explanation. `verified_at` and `source` are inherited from file front matter; do not repeat them per action. @@ -116,7 +116,7 @@ Start fallback paths with trigger condition plus `adapter_health_update`: ```yaml on_adapter_fail: - adapter_health_update: webcmd twitter post -> suspect - - webcmd browser snapshot --snapshot-mode tree (verify current page) + - webcmd --session browser snapshot --snapshot-mode tree (verify current page) - if not on /home: goto /home - action:open_compose in pages/home.md ``` diff --git a/skills/webcmd-sitemap-author/references/sitemap-schema.md b/skills/webcmd-sitemap-author/references/sitemap-schema.md index 19fafa4e..e6e474c0 100644 --- a/skills/webcmd-sitemap-author/references/sitemap-schema.md +++ b/skills/webcmd-sitemap-author/references/sitemap-schema.md @@ -69,7 +69,7 @@ kind: site | page | partial | workflow | apis | pitfalls | draft id: stable-id status: verified | draft | stale verified_at: YYYY-MM-DD -source: webcmd browser snapshot --snapshot-mode tree | trace: | adapter:/ +source: webcmd --session browser snapshot --snapshot-mode tree | trace: | adapter:/ --- ``` @@ -145,7 +145,7 @@ kind: page id: repo status: verified verified_at: YYYY-MM-DD -source: webcmd browser snapshot --snapshot-mode tree +source: webcmd --session browser snapshot --snapshot-mode tree url_patterns: - https://github.com/*/* state_signature: @@ -175,7 +175,7 @@ do: post: fail: | recover: ; adapter_health_update: -> suspect -evidence: webcmd browser or trace: +evidence: webcmd --session browser or trace: ## Linked APIs @@ -222,7 +222,7 @@ kind: partial id: post-card status: verified verified_at: YYYY-MM-DD -source: webcmd browser snapshot --snapshot-mode tree +source: webcmd --session browser snapshot --snapshot-mode tree url_patterns: [] scope_root: article[role="article"] --- @@ -302,7 +302,7 @@ Read an issue and extract title, author, body, labels, and comments. on_adapter_fail: - adapter_health_update: webcmd github issue -> suspect - - webcmd browser snapshot --snapshot-mode tree + - webcmd --session browser snapshot --snapshot-mode tree - action:open_issue in pages/repo.md - action:extract_comments in pages/issue.md @@ -390,7 +390,7 @@ do: post: fail: | recover: ; adapter_health_update: -> suspect -evidence: webcmd browser or trace: +evidence: webcmd --session browser or trace: ``` Field rules: @@ -462,7 +462,7 @@ Do not delete stale evidence immediately. Mark it stale with reason and next ste ```yaml status: stale stale_reason: selector `[data-testid=old]` missing -next: rerun `webcmd browser run --stdin` against the current URL +next: rerun `webcmd --session browser run --stdin` against the current URL ``` ## 12. Security And Privacy diff --git a/skills/webcmd-usage/SKILL.md b/skills/webcmd-usage/SKILL.md index 48a45be5..a5d8a7ce 100644 --- a/skills/webcmd-usage/SKILL.md +++ b/skills/webcmd-usage/SKILL.md @@ -34,7 +34,7 @@ Do not install Node.js or silently fall back to `npx`. ## The Three Pillars - **Adapter commands:** `webcmd [...]`. Core ships no site adapters; every site — official and community — lives as an independently installable plugin under `plugins//` in the main repo, or `~/.webcmd/plugins//` once installed. Private iteration adapters live in `~/.webcmd/clis/`. A command in `~/.webcmd/clis//.js` takes precedence over the same command from an installed plugin. Each command has a strategy such as `PUBLIC`, `COOKIE`, `INTERCEPT`, `UI`, or `LOCAL`. -- **Browser driving:** use an existing adapter command first; otherwise load `webcmd-browser` and run Playwright. +- **Browser driving:** use an existing adapter command first; otherwise load `webcmd-browser`, create a session, and run Playwright with root `--session `. - **External CLI passthrough:** `webcmd gh`, `webcmd docker`, `webcmd vercel`, and similar wrappers. Manage them with `webcmd external install ` or `webcmd external register `. **REQUIRED SUB-SKILL:** Before raw browser work, load `webcmd-browser`. @@ -57,6 +57,28 @@ npx tsx src/main.ts `webcmd doctor` reports daemon status, runtime connection, version checks, and live browser connectivity. It is required for `COOKIE`, `INTERCEPT`, `UI`, and `webcmd browser *` work. It is not required for `PUBLIC`, `LOCAL`, `webcmd list`, `validate`, `verify`, plugin commands, or external CLI passthrough. +## Sessions + +Profiles are cookie jars and authentication scope. Sessions are browser workspaces/windows within a profile. Create one for each parallel raw-browser agent, then route every raw command through the opaque ID: + +```bash +webcmd session create -f json +webcmd --session session_abc browser snapshot --snapshot-mode act +webcmd --session session_abc browser run --stdin +webcmd session list +webcmd session close session_abc +``` + +`webcmd session close ` is blocked while that Session has a live human handoff. +Adapter commands may omit `--session` and use the selected profile's adapter-default session. Pass `--session ` to route one into an explicit session. Raw browser commands never omit it; the retired positional session form is invalid. + +Structured Session failures are runtime state, not adapter breakage. `SESSION_REQUIRED` +means add a root `--session ` selector before `browser`; `SESSION_BUSY` +means another holder owns the same Session or site scope, so wait, inspect +`webcmd session list`, and use `webcmd session close --force` only +when the holder is dead. `SESSION_PAUSED_FOR_HUMAN_HANDOFF` means finish the +handoff and run its verifier before retrying. + ## Prerequisites By Strategy | Strategy | Needs | @@ -147,9 +169,13 @@ The error envelope includes a `trace` block pointing at `summary.md`. Patch only If a failure returns `handoff.status === action_required`, stop before AutoFix. Give the user `handoff.action` and any `Webcmd browser:` or `handoff.viewUrl` link, then wait. After the user reports done, run `handoff.verifyCommand` when present; verification must succeed before retrying. +Human handoff is scoped to the Session that started it. Run the returned +`verify_command` or `handoff.verifyCommand` verbatim; it includes `--session` +when applicable. Do not close that Session during the live handoff. + `AUTH_REQUIRED` is not an adapter failure. Run `webcmd login`: `already_logged_in` is verified; `in_progress` means no current user action, so do not ask the user or wait for confirmation, and do not poll; `action_required` is a hard stop. For `action_required`, give the user its instructions and any returned `action_url` or `view_url`, then wait. If Webcmd returned no URL, use the current visible browser. -Run the returned `verify_command` (normally `webcmd whoami`) or `handoff.verifyCommand` only after the user reports done; verification must succeed before retrying. Without a verifier, take fresh browser state and verify the intended post-action state before any retry, especially for write commands. Use `webcmd auth refresh` only when an explicit auth-state refresh is needed. Their report alone is not verification. Never request, type, echo, store, or automate passwords, OTPs, recovery codes, cookies, session secrets, or CAPTCHA answers; CAPTCHA stops automation and follows the same verification rule. +Run the returned `verify_command` or `handoff.verifyCommand` only after the user reports done; verification must succeed before retrying. Without a verifier, take fresh browser state and verify the intended post-action state before any retry, especially for write commands. Use `webcmd auth refresh` only when an explicit auth-state refresh is needed. Their report alone is not verification. Never request, type, echo, store, or automate passwords, OTPs, recovery codes, cookies, session secrets, or CAPTCHA answers; CAPTCHA stops automation and follows the same verification rule. ## Report A Webcmd Defect diff --git a/src/browser/analyze.ts b/src/browser/analyze.ts index 95d224b5..13e0e28c 100644 --- a/src/browser/analyze.ts +++ b/src/browser/analyze.ts @@ -507,15 +507,15 @@ export function analyzeSite( } else if (pattern.pattern === 'A') { next = 'Inspect `api_candidates`, then replay the best endpoint and record the status/content-type/sample shape in your strategy note; do not choose API strategy from XHR count alone.'; } else if (pattern.pattern === 'B') { - next = 'Read the SSR global with `webcmd browser run --stdin` and page.evaluate(() => window.__INITIAL_STATE__ ?? window.__NUXT__ ?? window.__NEXT_DATA__ ?? window.__APOLLO_STATE__) — no API needed.'; + next = 'Read the SSR global with `webcmd --session browser run --stdin` and page.evaluate(() => window.__INITIAL_STATE__ ?? window.__NUXT__ ?? window.__NEXT_DATA__ ?? window.__APOLLO_STATE__) — no API needed.'; } else if (pattern.pattern === 'C') { - next = 'No API visible — use `webcmd browser snapshot --snapshot-mode read` or DOM extraction inside `browser run` against the rendered page.'; + next = 'No API visible — use `webcmd --session browser snapshot --snapshot-mode read` or DOM extraction inside `browser run` against the rendered page.'; } else if (pattern.pattern === 'D') { next = 'Endpoints need auth. Re-open the page from a signed-in session, then retry analyze; see `field-decode-playbook` §4 for token tracing.'; } else if (pattern.pattern === 'E') { next = 'WebSocket stream detected — find the underlying HTTP poll/long-poll endpoint; raw WS is not supported.'; } else { - next = 'No strong signal. Use `webcmd browser run --stdin` with response listeners and pick a pattern from the captured evidence.'; + next = 'No strong signal. Use `webcmd --session browser run --stdin` with response listeners and pick a pattern from the captured evidence.'; } return { diff --git a/src/browser/base-page.ts b/src/browser/base-page.ts index 2993e4ce..d084be0f 100644 --- a/src/browser/base-page.ts +++ b/src/browser/base-page.ts @@ -727,7 +727,7 @@ export abstract class BasePage implements IPage { throw new TargetError({ code: 'not_checkable', message: `Target "${ref}" is not a checkbox, radio, switch, or aria-checked control.`, - hint: 'Use `webcmd browser snapshot --snapshot-mode tree` to pick an input[type=checkbox], input[type=radio], or role=checkbox/switch target.', + hint: 'Use `webcmd --session browser snapshot --snapshot-mode tree` to pick an input[type=checkbox], input[type=radio], or role=checkbox/switch target.', }); } if (before.disabled) { @@ -828,7 +828,7 @@ export abstract class BasePage implements IPage { throw new TargetError({ code: 'not_file_input', message: `Target "${ref}" is not an input[type=file].`, - hint: 'Use `webcmd browser snapshot --snapshot-mode tree` or a targeted `browser run` query to find an input[type=file].', + hint: 'Use `webcmd --session browser snapshot --snapshot-mode tree` or a targeted `browser run` query to find an input[type=file].', }); } if (files.length > 1 && !info?.multiple) { @@ -984,7 +984,7 @@ export abstract class BasePage implements IPage { throw new TargetError({ code: 'not_editable', message: `Target "${ref}" is not a fillable input, textarea, or contenteditable element.`, - hint: 'Use `webcmd browser snapshot --snapshot-mode tree` to pick an editable target, or use `browser run` for keyboard-like interactions.', + hint: 'Use `webcmd --session browser snapshot --snapshot-mode tree` to pick an editable target, or use `browser run` for keyboard-like interactions.', }); } diff --git a/src/browser/bridge.ts b/src/browser/bridge.ts index 392a0dfd..1257309a 100644 --- a/src/browser/bridge.ts +++ b/src/browser/bridge.ts @@ -25,7 +25,7 @@ export class BrowserBridge implements IBrowserFactory { return this._state; } - async connect(opts: { timeout?: number; session?: string; idleTimeout?: number; contextId?: string; preferredContextId?: string; windowMode?: 'foreground' | 'background'; surface?: 'browser' | 'adapter'; siteSession?: 'ephemeral' | 'persistent'; freshPage?: boolean } = {}): Promise { + async connect(opts: { timeout?: number; session?: string; idleTimeout?: number; contextId?: string; preferredContextId?: string; windowMode?: 'foreground' | 'background'; surface?: 'browser' | 'adapter'; siteSession?: 'ephemeral' | 'persistent'; freshPage?: boolean; adapterSite?: string } = {}): Promise { if (this._state === 'connected' && this._page) return this._page; if (this._state === 'connecting') throw new Error('Already connecting'); if (this._state === 'closing') throw new Error('Session is closing'); @@ -38,8 +38,9 @@ export class BrowserBridge implements IBrowserFactory { ? { contextId: opts.contextId, preferredContextId: opts.preferredContextId } : profileRouteParams(resolveProfileSelection()); await this._ensureDaemon(opts.timeout, routing.contextId); - if (!opts.session?.trim()) throw new Error('Browser session is required'); - this._page = new Page(opts.session.trim(), opts.idleTimeout, routing.contextId, opts.windowMode, opts.surface, opts.siteSession, routing.preferredContextId, opts.freshPage); + const session = opts.session?.trim(); + if (!session && opts.surface !== 'adapter') throw new Error('Browser session is required'); + this._page = new Page(session, opts.idleTimeout, routing.contextId, opts.windowMode, opts.surface, opts.siteSession, routing.preferredContextId, opts.freshPage, opts.adapterSite); this._state = 'connected'; return this._page; } catch (err) { diff --git a/src/browser/cdp.ts b/src/browser/cdp.ts index 5291e2cd..1a9da044 100644 --- a/src/browser/cdp.ts +++ b/src/browser/cdp.ts @@ -54,7 +54,7 @@ export class CDPBridge implements IBrowserFactory { private _pending = new Map void; reject: (err: Error) => void; timer: ReturnType }>(); private _eventListeners = new Map void>>(); - async connect(opts?: { timeout?: number; session?: string; cdpEndpoint?: string; contextId?: string; preferredContextId?: string; idleTimeout?: number; windowMode?: 'foreground' | 'background'; surface?: 'browser' | 'adapter'; siteSession?: 'ephemeral' | 'persistent' }): Promise { + async connect(opts?: { timeout?: number; session?: string; cdpEndpoint?: string; contextId?: string; preferredContextId?: string; idleTimeout?: number; windowMode?: 'foreground' | 'background'; surface?: 'browser' | 'adapter'; siteSession?: 'ephemeral' | 'persistent'; adapterSite?: string }): Promise { if (this._ws) throw new Error('CDPBridge is already connected. Call close() before reconnecting.'); const endpoint = opts?.cdpEndpoint ?? process.env.WEBCMD_CDP_ENDPOINT; diff --git a/src/browser/daemon-client.test.ts b/src/browser/daemon-client.test.ts index d37afbda..4e3b1560 100644 --- a/src/browser/daemon-client.test.ts +++ b/src/browser/daemon-client.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { BrowserCommandError, + cancelDaemonRun, fetchDaemonStatus, getDaemonHealth, listExistingBrowserTabs, @@ -205,7 +206,6 @@ describe('daemon-client', () => { setDaemonRunContext({ runId: 'run_4242_1000_1', command: 'example write', - access: 'write', }); vi.mocked(fetch).mockResolvedValue({ status: 200, @@ -218,7 +218,6 @@ describe('daemon-client', () => { expect(body).toMatchObject({ runId: 'run_4242_1000_1', command: 'example write', - access: 'write', pid: process.pid, }); }); @@ -305,6 +304,8 @@ describe('daemon-client', () => { holder: { command: 'other write', pid: 4242, + sessionId: 'session_a', + admissionSite: 'github', acquiredAt: 1_000, heartbeatAt: 2_000, }, @@ -315,6 +316,29 @@ describe('daemon-client', () => { name: 'SessionBusyError', code: 'SESSION_BUSY', message: expect.stringContaining('other write'), + hint: expect.stringContaining('session_a'), + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('maps local handoff pauses to an auth-required BrowserCommandError', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 400, + json: () => Promise.resolve({ + id: 'cmd', + ok: false, + errorCode: 'SESSION_PAUSED_FOR_HUMAN_HANDOFF', + error: 'Session session_a is paused while a human completes github authentication.', + details: { sessionId: 'session_a', site: 'github' }, + }), + } as Response); + + await expect(sendCommand('exec', { code: '1' })).rejects.toMatchObject({ + name: 'BrowserCommandError', + code: 'SESSION_PAUSED_FOR_HUMAN_HANDOFF', + exitCode: 77, }); expect(fetchMock).toHaveBeenCalledTimes(1); }); @@ -323,7 +347,6 @@ describe('daemon-client', () => { setDaemonRunContext({ runId: 'run_9999_newer_2', command: 'newer write', - access: 'write', }); const ensureSpy = vi.spyOn(daemonLifecycle, 'ensureBrowserBridgeReady'); vi.mocked(fetch).mockRejectedValueOnce(new TypeError('fetch failed')); @@ -364,6 +387,30 @@ describe('daemon-client', () => { } }); + it('cancelDaemonRun makes one best-effort POST without inheriting active run metadata', async () => { + setDaemonRunContext({ + runId: 'run_9999_newer_2', + command: 'newer write', + }); + const ensureSpy = vi.spyOn(daemonLifecycle, 'ensureBrowserBridgeReady'); + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ id: 'cancel', ok: true, data: { released: 1 } }), + } as Response); + + await expect(cancelDaemonRun('run_4242_1000_1')).resolves.toBeUndefined(); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(ensureSpy).not.toHaveBeenCalled(); + const body = JSON.parse(String(vi.mocked(fetch).mock.calls[0][1]?.body)); + expect(body).toMatchObject({ + action: 'run-cancel', + runId: 'run_4242_1000_1', + }); + expect(body.command).toBeUndefined(); + expect(body.pid).toBeUndefined(); + }); + it('sendCommand does not retry command_result_unknown even when the message looks transient', async () => { const fetchMock = vi.mocked(fetch); fetchMock.mockResolvedValue({ diff --git a/src/browser/daemon-client.ts b/src/browser/daemon-client.ts index b69771b0..da4e6070 100644 --- a/src/browser/daemon-client.ts +++ b/src/browser/daemon-client.ts @@ -5,7 +5,7 @@ */ import { sleep } from '../utils.js'; -import { BrowserConnectError, SessionBusyError } from '../errors.js'; +import { BrowserConnectError, CliError, EXIT_CODES, SessionBusyError, type ExitCode } from '../errors.js'; import { COMMAND_RESULT_UNKNOWN_CODE, COMMAND_RESULT_UNKNOWN_HINT } from '../daemon-utils.js'; import { getDaemonRunContext, type SessionLeaseHolder } from '../session-lease.js'; import { classifyBrowserError } from './errors.js'; @@ -23,6 +23,7 @@ import { type DaemonStatus, } from './daemon-transport.js'; import type { BrowserRuntimeCommand, BrowserRuntimeResult, BrowserWindowMode } from './protocol.js'; +import type { BrowserSessionRecord } from './sessions.js'; let _idCounter = 0; @@ -34,6 +35,7 @@ const DEFAULT_COMMAND_TIMEOUT_SECONDS = 120; const RUNTIME_OP_TIMEOUT_MARGIN_MS = 15_000; const HTTP_TIMEOUT_MARGIN_MS = 10_000; const TRANSPORT_MAX_ATTEMPTS = 4; +type DaemonCommandParams = Omit; let _userCommandTimeoutSeconds: number | null = null; @@ -41,7 +43,7 @@ export function setDaemonCommandTimeoutSeconds(seconds: number | null): void { _userCommandTimeoutSeconds = typeof seconds === 'number' && seconds > 0 ? Math.ceil(seconds) : null; } -function effectiveCommandTimeoutSeconds(params: Omit): number { +function effectiveCommandTimeoutSeconds(params: DaemonCommandParams): number { const base = _userCommandTimeoutSeconds ?? DEFAULT_COMMAND_TIMEOUT_SECONDS; if (typeof params.timeoutMs === 'number' && params.timeoutMs > 0) { return Math.max(base, Math.ceil((params.timeoutMs + RUNTIME_OP_TIMEOUT_MARGIN_MS) / 1000)); @@ -81,18 +83,23 @@ function isPreConnectFetchError(err: unknown): boolean { export type DaemonCommand = BrowserRuntimeCommand; export type DaemonResult = BrowserRuntimeResult; -export class BrowserCommandError extends Error { +export class BrowserCommandError extends CliError { constructor( message: string, - readonly code?: string, - readonly hint?: string, + code?: string, + hint?: string, readonly details?: unknown, ) { - super(message); - this.name = 'BrowserCommandError'; + super(code ?? 'BROWSER_COMMAND', message, hint, browserCommandExitCode(code)); } } +function browserCommandExitCode(code?: string): ExitCode { + if (code === 'SESSION_PAUSED_FOR_HUMAN_HANDOFF') return EXIT_CODES.NOPERM; + if (code === 'SESSION_WINDOW_CONFLICT') return EXIT_CODES.TEMPFAIL; + return EXIT_CODES.GENERIC_ERROR; +} + export { fetchDaemonStatus, getDaemonHealth, @@ -115,7 +122,7 @@ export { */ async function sendCommandRaw( action: DaemonCommand['action'], - params: Omit, + params: DaemonCommandParams, ): Promise { const timeoutSeconds = effectiveCommandTimeoutSeconds(params); const deadlineAt = Date.now() + timeoutSeconds * 1000; @@ -153,7 +160,7 @@ async function sendCommandRaw( } const remainingMs = Math.max(1000, deadlineAt - Date.now()); - const run = action === 'lease-release' ? undefined : getDaemonRunContext(); + const run = action === 'lease-release' || action === 'run-cancel' ? undefined : getDaemonRunContext(); const command: DaemonCommand = { id, action, @@ -166,7 +173,6 @@ async function sendCommandRaw( ...(run && { runId: run.runId, command: run.command, - access: run.access, pid: process.pid, }), }; @@ -252,7 +258,7 @@ async function sendCommandRaw( */ export async function sendCommand( action: DaemonCommand['action'], - params: Omit = {}, + params: DaemonCommandParams = {}, ): Promise { const result = await sendCommandRaw(action, params); return result.data; @@ -264,16 +270,49 @@ export async function sendCommand( */ export async function sendCommandFull( action: DaemonCommand['action'], - params: Omit = {}, + params: DaemonCommandParams = {}, ): Promise<{ data: unknown; page?: string }> { const result = await sendCommandRaw(action, params); return { data: result.data, page: result.page }; } export async function releaseSiteSessionLease(runId: string): Promise { + await postRunControl('lease-release', runId); +} + +export async function cancelDaemonRun(runId: string): Promise { + await postRunControl('run-cancel', runId); +} + +type SessionHandoffParams = { + session?: string; + site: string; + contextId?: string; + preferredContextId?: string; +}; + +export async function startSessionHandoff( + params: SessionHandoffParams & { expiresAt: string }, +): Promise { + return sendCommand('session-handoff-start', { + ...params, + surface: 'adapter', + adapterSite: params.site, + }) as Promise; +} + +export async function clearSessionHandoff(params: SessionHandoffParams): Promise { + return sendCommand('session-handoff-clear', { + ...params, + surface: 'adapter', + adapterSite: params.site, + }) as Promise; +} + +async function postRunControl(action: 'lease-release' | 'run-cancel', runId: string): Promise { const command: DaemonCommand = { id: generateId(), - action: 'lease-release', + action, runId, }; await requestDaemon('/command', { diff --git a/src/browser/dom-snapshot.ts b/src/browser/dom-snapshot.ts index 41f95e38..024689cd 100644 --- a/src/browser/dom-snapshot.ts +++ b/src/browser/dom-snapshot.ts @@ -861,7 +861,7 @@ export function generateSnapshotJs(opts: DomSnapshotOptions = {}): string { if (!doc || !doc.body) { const attrs = serializeAttrs(el); const frameLabel = '[F' + crossOriginIndex + ']'; - lines.push(indent + '|iframe|' + frameLabel + ' (cross-origin, use: webcmd browser run --stdin and page.frames()[index])'); + lines.push(indent + '|iframe|' + frameLabel + ' (cross-origin, use: webcmd --session browser run --stdin and page.frames()[index])'); crossOriginIndex++; return false; } @@ -876,7 +876,7 @@ export function generateSnapshotJs(opts: DomSnapshotOptions = {}): string { } catch { const attrs = serializeAttrs(el); const frameLabel = '[F' + crossOriginIndex + ']'; - lines.push(indent + '|iframe|' + frameLabel + ' (blocked, use: webcmd browser run --stdin and page.frames()[index])'); + lines.push(indent + '|iframe|' + frameLabel + ' (blocked, use: webcmd --session browser run --stdin and page.frames()[index])'); crossOriginIndex++; return false; } diff --git a/src/browser/page.test.ts b/src/browser/page.test.ts index 157af61d..6ae25c35 100644 --- a/src/browser/page.test.ts +++ b/src/browser/page.test.ts @@ -55,21 +55,23 @@ describe('Page.getCurrentUrl', () => { sendCommandFullMock.mockResolvedValueOnce({ page: 'page-1', data: { url: 'https://chatgpt.com/' } }); sendCommandMock.mockResolvedValueOnce(null); - const page = new Page('site:chatgpt', undefined, undefined, undefined, 'adapter', 'persistent'); + const page = new Page('session_a', undefined, undefined, undefined, 'adapter', 'persistent', undefined, false, 'chatgpt'); await page.goto('https://chatgpt.com/', { waitUntil: 'none' }); await page.evaluate('document.title'); expect(sendCommandFullMock).toHaveBeenCalledWith('navigate', expect.objectContaining({ - session: 'site:chatgpt', + session: 'session_a', surface: 'adapter', siteSession: 'persistent', + adapterSite: 'chatgpt', waitUntil: 'none', })); expect(sendCommandMock).toHaveBeenCalledWith('exec', expect.objectContaining({ - session: 'site:chatgpt', + session: 'session_a', surface: 'adapter', siteSession: 'persistent', + adapterSite: 'chatgpt', page: 'page-1', })); }); diff --git a/src/browser/page.ts b/src/browser/page.ts index 927de36d..bf546fd8 100644 --- a/src/browser/page.ts +++ b/src/browser/page.ts @@ -43,7 +43,7 @@ export class Page extends BasePage { private readonly _idleTimeout: number | undefined; constructor( - private readonly session: string, + private readonly session: string | undefined, idleTimeout?: number, public readonly contextId?: string, private readonly windowMode?: 'foreground' | 'background', @@ -51,6 +51,7 @@ export class Page extends BasePage { private readonly siteSession?: 'ephemeral' | 'persistent', public readonly preferredContextId?: string, freshPage?: boolean, + private readonly adapterSite?: string, ) { super(); this._idleTimeout = idleTimeout; @@ -72,15 +73,16 @@ export class Page extends BasePage { private _networkCaptureWarned = false; /** Helper: spread session into command params */ - private _sessionOpts(): { session: string; surface: 'browser' | 'adapter'; idleTimeout?: number; contextId?: string; preferredContextId?: string; windowMode?: 'foreground' | 'background'; siteSession?: 'ephemeral' | 'persistent' } { + private _sessionOpts(): { session?: string; surface: 'browser' | 'adapter'; idleTimeout?: number; contextId?: string; preferredContextId?: string; windowMode?: 'foreground' | 'background'; siteSession?: 'ephemeral' | 'persistent'; adapterSite?: string } { return { - session: this.session, surface: this.surface, + ...(this.session && { session: this.session }), ...(this.contextId && { contextId: this.contextId }), ...(this.preferredContextId && { preferredContextId: this.preferredContextId }), ...(this._idleTimeout != null && { idleTimeout: this._idleTimeout }), ...(this.windowMode && { windowMode: this.windowMode }), ...(this.siteSession && { siteSession: this.siteSession }), + ...(this.adapterSite && { adapterSite: this.adapterSite }), ...this._freshPageOpts(), }; } @@ -88,14 +90,15 @@ export class Page extends BasePage { /** Helper: spread session + page identity into command params */ private _cmdOpts(): Record { return { - session: this.session, surface: this.surface, + ...(this.session && { session: this.session }), ...(this.contextId && { contextId: this.contextId }), ...(this.preferredContextId && { preferredContextId: this.preferredContextId }), ...(this._page !== undefined && { page: this._page }), ...(this._idleTimeout != null && { idleTimeout: this._idleTimeout }), ...(this.windowMode && { windowMode: this.windowMode }), ...(this.siteSession && { siteSession: this.siteSession }), + ...(this.adapterSite && { adapterSite: this.adapterSite }), ...this._freshPageOpts(), }; } diff --git a/src/browser/protocol.ts b/src/browser/protocol.ts index b645d5c0..9ac5501e 100644 --- a/src/browser/protocol.ts +++ b/src/browser/protocol.ts @@ -1,4 +1,5 @@ import type { SessionLeaseStatus } from '../session-lease.js'; +import type { BrowserSessionListRow } from './sessions.js'; import type { SnapshotMode } from './snapshot/index.js'; export type BrowserRuntimeAction = @@ -18,7 +19,13 @@ export type BrowserRuntimeAction = | 'frames' | 'run' | 'snapshot' - | 'lease-release'; + | 'lease-release' + | 'run-cancel' + | 'session-create' + | 'session-list' + | 'session-close' + | 'session-handoff-start' + | 'session-handoff-clear'; export type BrowserSurface = 'browser' | 'adapter'; export type SiteSessionMode = 'ephemeral' | 'persistent'; @@ -30,8 +37,12 @@ export interface BrowserRuntimeCommand { page?: string; code?: string; session?: string; + sessionId?: string; + sessionKind?: 'explicit' | 'adapter-default'; surface?: BrowserSurface; siteSession?: SiteSessionMode; + /** Trusted adapter identity used for admission and tab routing. */ + adapterSite?: string; /** Close any existing leased page and start on a new one (sent on the first action of a command run). */ freshPage?: boolean; url?: string; @@ -54,6 +65,10 @@ export interface BrowserRuntimeCommand { timeout?: number; /** Absolute command deadline in epoch milliseconds. Preferred by newer daemons. */ deadlineAt?: number; + /** Force Session lifecycle actions such as close past active work/handoff guards. */ + force?: boolean; + /** Maximum Session rows returned by session-list. */ + limit?: number; cdpMethod?: string; cdpParams?: Record; windowMode?: BrowserWindowMode; @@ -78,6 +93,9 @@ export interface BrowserRuntimeCommand { access?: 'read' | 'write'; /** Originating CLI process, used only for actionable local busy guidance. */ pid?: number; + /** Site and expiry payload for internal Session handoff controls. */ + site?: string; + expiresAt?: string; } export interface BrowserRuntimeResult { @@ -111,4 +129,5 @@ export interface BrowserRuntimeStatus { commandResultUnknown?: number; /** Active local leases with internal run ownership tokens removed. */ sessionLeases?: SessionLeaseStatus[]; + sessions?: BrowserSessionListRow[]; } diff --git a/src/browser/run/playwright-transport.ts b/src/browser/run/playwright-transport.ts index 921361d9..ec843895 100644 --- a/src/browser/run/playwright-transport.ts +++ b/src/browser/run/playwright-transport.ts @@ -4,6 +4,7 @@ import { BROWSER_RUN_PLAYWRIGHT_VERSION, BrowserRunError, } from './types.js'; +import type { BrowserRunSessionScope } from './runner.js'; interface DispatcherConnection { onmessage: (message: Record) => void; @@ -78,12 +79,221 @@ function implementation(object: T): unknown { return value; } +function scopedContext( + context: object, + scope: { + pages(): object[]; + createPage(): Promise; + onPage(listener: (page: object) => void): () => void; + }, +): object { + const pageListeners = new Map void>>(); + const contextListeners = new Map void>>>(); + const related = (value: unknown, property: string): unknown => { + if ((typeof value !== 'object' || value === null) && typeof value !== 'function') return undefined; + try { + const candidate = Reflect.get(value as object, property, value); + return typeof candidate === 'function' ? Reflect.apply(candidate, value, []) : candidate; + } catch { + return undefined; + } + }; + const requestPage = (request: unknown): unknown => { + const frame = related(request, 'frame') ?? related(request, '_frame'); + return related(frame, 'page') ?? related(frame, '_page'); + }; + const eventBelongsToScope = (event: string, args: unknown[]): boolean => { + let eventPage: unknown; + if (event === 'request' || event === 'requestfailed') { + eventPage = requestPage(args[0]); + } else if (event === 'response') { + eventPage = requestPage(related(args[0], 'request')); + } else if (event === 'requestfinished') { + eventPage = requestPage(related(args[0], 'request')); + } else if (event === 'console') { + eventPage = related(args[0], 'page'); + } else if (event === 'pageerror') { + eventPage = args[1]; + } else if (event === 'recorderevent') { + eventPage = related(args[0], 'page'); + } + return eventPage !== undefined && scope.pages().includes(eventPage as object); + }; + const addPageListener = (listener: Function, once = false) => { + let dispose: () => void = () => undefined; + const registered = (page: object) => { + if (once) { + dispose(); + pageListeners.get(listener)?.delete(dispose); + } + listener(page); + }; + dispose = scope.onPage(registered); + const disposers = pageListeners.get(listener) ?? new Set(); + disposers.add(dispose); + pageListeners.set(listener, disposers); + }; + const removePageListener = (listener: Function) => { + for (const dispose of pageListeners.get(listener) ?? []) dispose(); + pageListeners.delete(listener); + }; + const removeAllPageListeners = () => { + for (const listener of pageListeners.keys()) removePageListener(listener); + }; + const addContextListener = (event: string, listener: Function, once = false) => { + let dispose: () => void = () => undefined; + const registered = (...args: unknown[]) => { + if (event !== 'close' && !eventBelongsToScope(event, args)) return; + if (once) { + dispose(); + contextListeners.get(event)?.get(listener)?.delete(dispose); + } + listener(...args); + }; + Reflect.apply(Reflect.get(context, 'on'), context, [event, registered]); + dispose = () => Reflect.apply(Reflect.get(context, 'off'), context, [event, registered]); + const byListener = contextListeners.get(event) ?? new Map(); + const disposers = byListener.get(listener) ?? new Set(); + disposers.add(dispose); + byListener.set(listener, disposers); + contextListeners.set(event, byListener); + }; + const removeContextListener = (event: string, listener: Function) => { + const byListener = contextListeners.get(event); + for (const dispose of byListener?.get(listener) ?? []) dispose(); + byListener?.delete(listener); + if (byListener?.size === 0) contextListeners.delete(event); + }; + const removeAllContextListeners = (event?: string) => { + const events = event === undefined ? [...contextListeners.keys()] : [event]; + for (const name of events) { + for (const listener of contextListeners.get(name)?.keys() ?? []) { + removeContextListener(name, listener); + } + } + }; + const dialogManager = Reflect.get(context, 'dialogManager', context) as object; + const dialogHandlers = new Map(); + const scopedDialogManager = new Proxy(dialogManager, { + get(target, property) { + if (property === 'addDialogHandler') { + return (handler: Function) => { + const registered = (dialog: object) => { + const page = related(dialog, 'page'); + return page !== undefined && scope.pages().includes(page as object) + ? handler(dialog) + : false; + }; + dialogHandlers.set(handler, registered); + Reflect.apply(Reflect.get(target, property), target, [registered]); + }; + } + if (property === 'removeDialogHandler') { + return (handler: Function) => { + const registered = dialogHandlers.get(handler) ?? handler; + dialogHandlers.delete(handler); + Reflect.apply(Reflect.get(target, property), target, [registered]); + }; + } + const value = Reflect.get(target, property, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + let proxy: object; + proxy = new Proxy(context, { + get(target, property) { + if (property === 'pages') return scope.pages; + if (property === 'newPage') return scope.createPage; + if (property === 'dialogManager') return scopedDialogManager; + if (property === 'backgroundPages' || property === 'serviceWorkers') return () => []; + if (property === 'on' || property === 'addListener') { + return (event: string, listener: Function) => { + if (event === 'page') { + addPageListener(listener); + return proxy; + } + addContextListener(event, listener); + return proxy; + }; + } + if (property === 'off' || property === 'removeListener') { + return (event: string, listener: Function) => { + if (event === 'page') { + removePageListener(listener); + return proxy; + } + removeContextListener(event, listener); + return proxy; + }; + } + if (property === 'once') { + return (event: string, listener: Function) => { + if (event === 'page') { + addPageListener(listener, true); + return proxy; + } + addContextListener(event, listener, true); + return proxy; + }; + } + if (property === 'removeAllListeners') { + return (event?: string) => { + if (event === undefined || event === 'page') removeAllPageListeners(); + if (event !== 'page') removeAllContextListeners(event); + return proxy; + }; + } + if (property === 'waitForEvent') { + return (event: string, optionsOrPredicate?: object | Function) => { + const options = typeof optionsOrPredicate === 'object' ? optionsOrPredicate as { + predicate?: (page: object) => boolean | Promise; + timeout?: number; + } : undefined; + const predicate = typeof optionsOrPredicate === 'function' ? optionsOrPredicate : options?.predicate; + return new Promise((resolve, reject) => { + let timer: ReturnType | undefined; + const removeListener = (listener: Function) => { + if (event === 'page') removePageListener(listener); + else removeContextListener(event, listener); + }; + const listener = async (value: object) => { + try { + if (predicate && !await predicate(value)) return; + removeListener(listener); + if (timer) clearTimeout(timer); + resolve(value); + } catch (error) { + removeListener(listener); + if (timer) clearTimeout(timer); + reject(error); + } + }; + if (event === 'page') addPageListener(listener); + else addContextListener(event, listener); + if (options?.timeout) { + timer = setTimeout(() => { + removeListener(listener); + reject(new Error(`Timeout while waiting for event "${event}"`)); + }, options.timeout); + } + }); + }; + } + const value = Reflect.get(target, property, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + return proxy; +} + function scopedBrowser(browser: object, context: object): object { const contextListeners = new WeakMap(); let proxy: object; proxy = new Proxy(browser, { get(target, property) { - if (property === 'contexts') return () => [context]; + if (property === 'contexts') return () => ( + Reflect.get(target, '_defaultContext', target) ? [] : [context] + ); if (property === 'on' || property === 'addListener') { return (event: string, listener: Function) => { let registered = listener; @@ -117,13 +327,13 @@ export class PlaywrightTransport { readonly #connection: DispatcherConnection; readonly #root: RootDispatcher; readonly #deliver: (message: string) => void; - readonly #hostPages = new Set(); + #registerPageImpl: (page: Page) => void; #cancellation: Promise | undefined; #disposed = false; #browserWaitMs = 0; constructor( - input: { browser: Browser; context: BrowserContext; page: Page }, + input: BrowserRunSessionScope, deliver: (message: string) => void, ) { if ( @@ -137,10 +347,14 @@ export class PlaywrightTransport { } const browser = implementation(input.browser) as object; - const context = implementation(input.context) as object; + const context = scopedContext(implementation(input.context) as object, { + pages: () => input.pages().map(page => implementation(page) as object), + createPage: async () => implementation(await input.createPage()) as object, + onPage: listener => input.onPage(page => listener(implementation(page) as object)), + }); this.pageGuid = pageGuid(input.page); - this.#pages = () => input.context.pages(); - this.#hostPages.add(input.page); + this.#registerPageImpl = page => { implementation(page); }; + for (const page of input.pages()) this.registerPage(page); this.#deliver = deliver; this.#connection = new server.DispatcherConnection(); this.#connection.onmessage = message => { @@ -195,10 +409,8 @@ export class PlaywrightTransport { return this.#browserWaitMs; } - #pages: () => Page[]; - registerPage(page: Page): void { - this.#hostPages.add(page); + this.#registerPageImpl(page); } cancel(error: Error): Promise { diff --git a/src/browser/run/runner.test.ts b/src/browser/run/runner.test.ts index d525667e..da3e585e 100644 --- a/src/browser/run/runner.test.ts +++ b/src/browser/run/runner.test.ts @@ -8,6 +8,7 @@ import { describe, expect, it, + vi, } from 'vitest'; import { chromium, @@ -26,11 +27,23 @@ let browser: Browser; let context: BrowserContext; let page: Page; -function run(source: string, options = {}) { - return runBrowserProgram({ +function sessionScope(pages: () => readonly Page[] = () => context.pages()) { + return { browser, context, page, + pages, + createPage: () => context.newPage(), + onPage(listener: (page: Page) => void) { + context.on('page', listener); + return () => context.off('page', listener); + }, + }; +} + +function run(source: string, options = {}) { + return runBrowserProgram({ + ...sessionScope(), pageId: 'page-1', }, source, options); } @@ -240,13 +253,15 @@ describe('runBrowserProgram', () => { it('waits for popups and exposes context pages', async () => { const registered: Page[] = []; const output = await runBrowserProgram({ - browser, - context, - page, + ...sessionScope(), pageId: 'page-1', - registerPage: popup => { - registered.push(popup); - return 'popup-1'; + onPage(listener) { + const registeredListener = (popup: Page) => { + if (!registered.includes(popup)) registered.push(popup); + listener(popup); + }; + context.on('page', registeredListener); + return () => context.off('page', registeredListener); }, }, ` const popupPromise = page.waitForEvent('popup'); @@ -263,6 +278,180 @@ describe('runBrowserProgram', () => { expect(registered).toEqual([context.pages()[1]]); }); + it('hides pages outside the supplied session page set', async () => { + const other = await context.newPage(); + await other.setContent(''); + + const output = await runBrowserProgram({ + ...sessionScope(() => [page]), + pageId: 'page-1', + }, ` + return { + pages: context.pages().length, + urls: context.pages().map(page => page.url()), + }; + `); + + expect(output.result).toEqual({ + pages: 1, + urls: [page.url()], + }); + }); + + it.each([ + ['once', ` + const seen = new Promise(resolve => context.once('page', page => resolve(page.url()))); + await context.newPage(); + return await seen; + `], + ['waitForEvent', ` + const seen = context.waitForEvent('page'); + await context.newPage(); + return (await seen).url(); + `], + ])('scopes context.%s page events to Session-owned creation', async (_api, source) => { + const sibling = await context.newPage(); + const owned = await context.newPage(); + await owned.goto('data:text/plain,owned'); + let pageListener: ((page: Page) => void) | undefined; + const createPage = vi.fn(async () => { + queueMicrotask(() => pageListener?.(owned)); + return owned; + }); + + const output = await runBrowserProgram({ + ...sessionScope(() => [page, owned]), + pageId: 'page-1', + createPage, + onPage(listener) { + pageListener = listener; + return () => { + if (pageListener === listener) pageListener = undefined; + }; + }, + }, source); + + expect(output.result).toBe(owned.url()); + expect(output.result).not.toBe(sibling.url()); + expect(createPage).toHaveBeenCalledOnce(); + }); + + it('does not let removeAllListeners remove the Session ownership listener', async () => { + const seen: Page[] = []; + const owned = await context.newPage(); + let pageListener: ((page: Page) => void) | undefined; + const output = await runBrowserProgram({ + ...sessionScope(() => [page, owned]), + pageId: 'page-1', + createPage: async () => { + queueMicrotask(() => pageListener?.(owned)); + return owned; + }, + onPage(listener) { + pageListener = (candidate: Page) => { + seen.push(candidate); + listener(candidate); + }; + return () => { + pageListener = undefined; + }; + }, + }, ` + context.removeAllListeners('page'); + await context.newPage(); + return context.pages().length; + `); + + expect(output.result).toBe(2); + expect(seen).toEqual([owned]); + }); + + it('filters context request events from sibling Session pages', async () => { + const sibling = await context.newPage(); + await sibling.goto('data:text/html,sibling'); + const output = runBrowserProgram({ + ...sessionScope(() => [page]), + pageId: 'page-1', + }, ` + const request = context.waitForEvent('request'); + await page.evaluate(() => document.body.dataset.contextListener = 'ready'); + return (await request).frame().page().url(); + `, { timeoutMs: 2_000 }); + + await page.waitForFunction(() => document.body.dataset.contextListener === 'ready'); + await sibling.evaluate(() => fetch('https://example.test/data').catch(() => undefined)); + await page.evaluate(() => fetch('https://example.test/data').catch(() => undefined)); + + await expect(output).resolves.toMatchObject({ result: page.url() }); + }); + + it('filters context dialog events from sibling Session pages', async () => { + const sibling = await context.newPage(); + await sibling.goto('data:text/html,sibling'); + const output = runBrowserProgram({ + ...sessionScope(() => [page]), + pageId: 'page-1', + }, ` + const pendingDialog = context.waitForEvent('dialog'); + await page.evaluate(() => document.body.dataset.dialogListener = 'ready'); + const dialog = await pendingDialog; + const url = dialog.page().url(); + await dialog.dismiss(); + return url; + `, { timeoutMs: 2_000 }); + + await page.waitForFunction(() => document.body.dataset.dialogListener === 'ready'); + await sibling.evaluate(() => alert('sibling')); + await page.evaluate(() => alert('owned')); + + await expect(output).resolves.toMatchObject({ result: page.url() }); + }); + + it('delegates context.newPage to the Session-owned page creator', async () => { + const createPage = vi.fn(() => context.newPage()); + const output = await runBrowserProgram({ + browser, + context, + page, + pageId: 'page-1', + pages: () => [page], + createPage, + onPage: () => () => undefined, + }, ` + await context.newPage(); + return context.pages().length; + `); + + expect(output.result).toBe(1); + expect(createPage).toHaveBeenCalledOnce(); + }); + + it('initializes against a pre-launched persistent context without registering it twice', async () => { + const userDataDir = fs.mkdtempSync('/tmp/webcmd-persistent-browser-run-'); + const persistent = await chromium.launchPersistentContext(userDataDir, { headless: true }); + try { + const persistentPage = persistent.pages()[0] ?? await persistent.newPage(); + const persistentBrowser = persistent.browser(); + if (!persistentBrowser) throw new Error('persistent browser missing'); + + await expect(runBrowserProgram({ + browser: persistentBrowser, + context: persistent, + page: persistentPage, + pageId: 'persistent-page', + pages: () => persistent.pages(), + createPage: () => persistent.newPage(), + onPage(listener) { + persistent.on('page', listener); + return () => persistent.off('page', listener); + }, + }, 'return 1;')).resolves.toMatchObject({ result: 1 }); + } finally { + await persistent.close(); + fs.rmSync(userDataDir, { recursive: true, force: true }); + } + }); + it('waits for requests and responses', async () => { const output = await run(` const requestPromise = page.waitForRequest('**/data'); @@ -372,6 +561,18 @@ describe('runBrowserProgram', () => { expect(page.isClosed()).toBe(false); }); + it('cancels an in-flight run through its abort signal', async () => { + const controller = new AbortController(); + const pending = run(`await page.waitForEvent('popup');`, { + timeoutMs: 2_000, + signal: controller.signal, + }); + + setTimeout(() => controller.abort(), 10); + + await expect(pending).rejects.toMatchObject({ code: 'BROWSER_RUN_CANCELLED' }); + }); + it('cancels an in-flight popup operation without closing the popup', async () => { const popupPromise = page.waitForEvent('popup'); await page.getByRole('link', { name: 'Popup' }).click(); diff --git a/src/browser/run/runner.ts b/src/browser/run/runner.ts index 0cd8eb99..fbbb5aa0 100644 --- a/src/browser/run/runner.ts +++ b/src/browser/run/runner.ts @@ -35,13 +35,18 @@ import { type BrowserRunWarning, } from './types.js'; -export interface BrowserRunProgramHost { +export interface BrowserRunSessionScope { browser: PlaywrightBrowser; context: PlaywrightBrowserContext; page: PlaywrightPage; + pages(): readonly PlaywrightPage[]; + createPage(): Promise; + onPage(listener: (page: PlaywrightPage) => void): () => void; +} + +export interface BrowserRunProgramHost extends BrowserRunSessionScope { pageId: string; artifactSink?: BrowserRunArtifactSink; - registerPage?: (page: PlaywrightPage) => string; } const PLAYWRIGHT_CLIENT_SOURCE = fs.readFileSync( @@ -297,19 +302,19 @@ export async function runBrowserProgram( } finally { timings.quickjs_boot_ms = Math.max(0, Date.now() - quickjsBootStartedAt); } - const knownPages = new Set(input.context.pages()); + const knownPages = new Set(input.pages()); for (const page of knownPages) transport.registerPage(page); const registerNewPage = (page: PlaywrightPage) => { if (knownPages.has(page)) return; knownPages.add(page); transport.registerPage(page); - input.registerPage?.(page); }; - input.context.on('page', registerNewPage); + const unsubscribePages = input.onPage(registerNewPage); let timeout: ReturnType | undefined; let timeoutCleanup: Promise | undefined; let execution: Promise | undefined; + let interrupted = false; const disposeTimedOutRun = (timeoutError: BrowserRunError): void => { if (timeoutCleanup) return; host.cancelPending(timeoutError); @@ -323,7 +328,7 @@ export async function runBrowserProgram( .finally(() => { host.dispose(); void transport.dispose(timeoutError); - input.context.off('page', registerNewPage); + unsubscribePages(); }); }; try { @@ -486,10 +491,22 @@ export async function runBrowserProgram( reject(error); }, remainingMs); }); + const cancelled = new Promise((_resolve, reject) => { + const signal = options.signal; + if (!signal) return; + const abort = () => { + interrupted = true; + const error = new BrowserRunError('BROWSER_RUN_CANCELLED', 'Browser-run execution was cancelled.'); + disposeTimedOutRun(error); + reject(error); + }; + if (signal.aborted) abort(); + else signal.addEventListener('abort', abort, { once: true }); + }); execution.catch(() => {}); let serialized: unknown; try { - serialized = await Promise.race([execution, deadline]); + serialized = await Promise.race([execution, deadline, cancelled]); } finally { timings.program_ms = Math.max(0, Date.now() - programStartedAt); timings.browser_wait_ms = transport.browserWaitMs; @@ -581,7 +598,7 @@ export async function runBrowserProgram( throw await failure(normalized); } finally { if (timeout) clearTimeout(timeout); - if (timedOut) { + if (timedOut || interrupted) { void timeoutCleanup; } else { const completionError = new BrowserRunError( @@ -596,7 +613,7 @@ export async function runBrowserProgram( ).catch(() => undefined); await transport.dispose(completionError); host.dispose(); - input.context.off('page', registerNewPage); + unsubscribePages(); } } } diff --git a/src/browser/run/types.ts b/src/browser/run/types.ts index d7c8c7cc..fb1dd686 100644 --- a/src/browser/run/types.ts +++ b/src/browser/run/types.ts @@ -85,6 +85,7 @@ export interface BrowserRunOptions { snapshotDiff?: boolean; snapshotMode?: SnapshotTreeMode; snapshotBaselineStore?: SnapshotBaselineStore; + signal?: AbortSignal; } export interface BrowserRunLogEntry { diff --git a/src/browser/runtime/local-cloak/actions.ts b/src/browser/runtime/local-cloak/actions.ts index b9e313ec..c12b35a5 100644 --- a/src/browser/runtime/local-cloak/actions.ts +++ b/src/browser/runtime/local-cloak/actions.ts @@ -63,32 +63,54 @@ function invalidRequest(command: BrowserRuntimeCommand, error: string): BrowserR } async function resolveLease(manager: CloakSessionManager, command: BrowserRuntimeCommand) { + const profileId = resolveCloakCommandProfileId(manager, command); if (command.page) { - const existing = manager.findPageById(command.page, { idleTimeout: command.idleTimeout }); + const existing = await manager.findPageById(command.page, { + profileId, + session: command.session, + sessionId: command.sessionId, + surface: command.surface, + idleTimeout: command.idleTimeout, + }); if (existing) return existing; throw new CloakActionError('stale_page_identity', `Page not found: ${command.page} — stale page identity`); } return manager.getPage({ - profileId: resolveCloakCommandProfileId(manager, command), + profileId, session: command.session, surface: command.surface, siteSession: command.siteSession, + sessionKind: command.sessionKind, + sessionId: command.sessionId, + adapterSite: command.adapterSite, + runId: command.runId, idleTimeout: command.idleTimeout, freshPage: command.freshPage, windowMode: command.windowMode, }); } -function resolveExistingLease(manager: CloakSessionManager, command: BrowserRuntimeCommand) { +async function resolveExistingLease(manager: CloakSessionManager, command: BrowserRuntimeCommand) { + const profileId = resolveCloakCommandProfileId(manager, command); if (command.page) { - const existing = manager.findPageById(command.page, { idleTimeout: command.idleTimeout }); + const existing = await manager.findPageById(command.page, { + profileId, + session: command.session, + sessionId: command.sessionId, + surface: command.surface, + idleTimeout: command.idleTimeout, + }); if (existing) return existing; throw new CloakActionError('stale_page_identity', `Page not found: ${command.page} — stale page identity`); } - const existing = manager.findPage({ - profileId: resolveCloakCommandProfileId(manager, command), + const existing = await manager.findPage({ + profileId, session: command.session, surface: command.surface, + siteSession: command.siteSession, + sessionId: command.sessionId, + adapterSite: command.adapterSite, + runId: command.runId, idleTimeout: command.idleTimeout, }); if (existing) return existing; @@ -176,16 +198,32 @@ async function captureScreenshot(page: PlaywrightPage, context: BrowserContext, } } -export async function dispatchCloakAction(manager: CloakSessionManager, command: BrowserRuntimeCommand): Promise { +export async function dispatchCloakAction(manager: CloakSessionManager, command: BrowserRuntimeCommand, signal?: AbortSignal): Promise { try { switch (command.action) { case 'navigate': { if (!command.url) return invalidRequest(command, 'Missing url'); - const lease = await resolveLease(manager, command); + const profileId = resolveCloakCommandProfileId(manager, command); // 'none' maps to Playwright's 'commit': sites that stream analytics forever // never fire the load event, so adapters gating readiness on their own // selector waits must be able to skip it. - await lease.page.goto(command.url, { waitUntil: command.waitUntil === 'none' ? 'commit' : 'load' }); + const lease = await manager.navigatePage( + { + profileId, + session: command.session, + surface: command.surface, + siteSession: command.siteSession, + sessionKind: command.sessionKind, + sessionId: command.sessionId, + adapterSite: command.adapterSite, + runId: command.runId, + idleTimeout: command.idleTimeout, + freshPage: command.freshPage, + windowMode: command.windowMode, + }, + command.url, + command.waitUntil === 'none' ? 'commit' : 'load', + ); return { id: command.id, ok: true, data: { title: await lease.page.title(), url: lease.page.url(), timedOut: false }, page: lease.pageId }; } case 'exec': { @@ -208,25 +246,21 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command: }; } const lease = await resolveLease(manager, command); - const browser = lease.context.browser(); - if (!browser) throw new CloakActionError( - 'BROWSER_RUN_API_UNSUPPORTED', - 'The selected browser context is not attached to a browser.', - lease.pageId, - ); + const scope = await manager.browserRunScope({ + profileId: lease.profileId, + session: command.session, + sessionId: command.sessionId, + surface: command.surface, + siteSession: command.siteSession, + sessionKind: command.sessionKind, + adapterSite: command.adapterSite, + runId: command.runId, + idleTimeout: command.idleTimeout, + windowMode: command.windowMode, + }, lease.page); const data = await runBrowserProgram({ - browser, - context: lease.context, - page: lease.page, + ...scope, pageId: lease.pageId, - registerPage: (page) => manager.registerPage({ - profileId: lease.profileId, - session: command.session, - surface: command.surface, - siteSession: command.siteSession, - idleTimeout: command.idleTimeout, - windowMode: command.windowMode, - }, page), }, command.source, { timeoutMs: command.timeoutMs, maxOutputChars: command.maxOutputChars, @@ -234,6 +268,7 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command: snapshotDiff: command.noSnapshotDiff ? false : command.snapshotDiff, snapshotMode: command.snapshotMode === 'tree' ? 'tree' : 'act', snapshotBaselineStore: snapshotBaselineStore(manager), + ...(signal ? { signal } : {}), }); return { id: command.id, @@ -243,7 +278,7 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command: }; } case 'snapshot': { - const lease = resolveExistingLease(manager, command); + const lease = await resolveExistingLease(manager, command); if (command.snapshotMode === 'read') { const readable = readableSnapshotText(await extractArticle(lease.page, { force: true })); const redacted = redactUrl(redactText(readable.text, { maxStringLength: Number.MAX_SAFE_INTEGER })); @@ -311,13 +346,23 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command: } case 'close-window': { if (command.page) { - const closed = await manager.closePage({ profileId: resolveCloakCommandProfileId(manager, command), pageId: command.page }); + const closed = await manager.closePage({ + profileId: resolveCloakCommandProfileId(manager, command), + session: command.session, + surface: command.surface, + pageId: command.page, + }); return { id: command.id, ok: true, data: { closed: Boolean(closed), page: closed ?? command.page, session: command.session } }; } else { await manager.release({ profileId: resolveCloakCommandProfileId(manager, command), session: command.session, surface: command.surface, + siteSession: command.siteSession, + sessionKind: command.sessionKind, + sessionId: command.sessionId, + adapterSite: command.adapterSite, + runId: command.runId, }); return { id: command.id, ok: true, data: { closed: true, session: command.session } }; } @@ -325,7 +370,11 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command: case 'tabs': { switch (command.op ?? 'list') { case 'list': { - const tabs = await manager.listPages({ profileId: resolveCloakCommandProfileId(manager, command) }); + const tabs = await manager.listPages({ + profileId: resolveCloakCommandProfileId(manager, command), + session: command.session, + surface: command.surface, + }); return { id: command.id, ok: true, data: tabs }; } case 'new': { @@ -334,6 +383,10 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command: session: command.session, surface: command.surface, siteSession: command.siteSession, + sessionKind: command.sessionKind, + sessionId: command.sessionId, + adapterSite: command.adapterSite, + runId: command.runId, idleTimeout: command.idleTimeout, url: command.url, windowMode: command.windowMode, @@ -341,12 +394,25 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command: return { id: command.id, ok: true, data: { title: await lease.page.title(), url: lease.page.url() }, page: lease.pageId }; } case 'select': { - const lease = await manager.selectPage({ profileId: resolveCloakCommandProfileId(manager, command), pageId: command.page, index: command.index, windowMode: command.windowMode }); + const lease = await manager.selectPage({ + profileId: resolveCloakCommandProfileId(manager, command), + session: command.session, + surface: command.surface, + pageId: command.page, + index: command.index, + windowMode: command.windowMode, + }); if (!lease) return { id: command.id, ok: false, errorCode: 'runtime_command_failed', error: 'Tab not found' }; return { id: command.id, ok: true, data: { selected: true, url: lease.page.url() }, page: lease.pageId }; } case 'close': { - const closed = await manager.closePage({ profileId: resolveCloakCommandProfileId(manager, command), pageId: command.page, index: command.index }); + const closed = await manager.closePage({ + profileId: resolveCloakCommandProfileId(manager, command), + session: command.session, + surface: command.surface, + pageId: command.page, + index: command.index, + }); if (!closed) return { id: command.id, ok: false, errorCode: 'runtime_command_failed', error: 'Tab not found' }; return { id: command.id, ok: true, data: { closed } }; } @@ -405,7 +471,7 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command: ok: false, errorCode: 'invalid_request', error: 'Bind requires --page or --index for a Cloak runtime tab', - errorHint: 'Run `webcmd browser tab list`, then retry with `webcmd browser bind --page `.', + errorHint: 'Run `webcmd --session browser tab list`, then retry with `webcmd --session browser bind --page `.', }; } { @@ -414,6 +480,10 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command: session: command.session, surface: command.surface, siteSession: command.siteSession, + sessionKind: command.sessionKind, + sessionId: command.sessionId, + adapterSite: command.adapterSite, + runId: command.runId, idleTimeout: command.idleTimeout, windowMode: command.windowMode, pageId: command.page, @@ -425,7 +495,7 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command: ok: false, errorCode: 'bound_tab_not_found', error: 'Cloak tab not found for bind target', - errorHint: 'Run `webcmd browser tab list` and choose a current Cloak tab id or index.', + errorHint: 'Run `webcmd --session browser tab list` and choose a current Cloak tab id or index.', }; } return { @@ -452,7 +522,7 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command: err instanceof Error && 'code' in err && typeof err.code === 'string' - && err.code.startsWith('BROWSER_RUN_') + && (err.code.startsWith('BROWSER_RUN_') || err.code === 'SESSION_WINDOW_CONFLICT') ) { const hint = 'hint' in err && typeof err.hint === 'string' ? err.hint diff --git a/src/browser/runtime/local-cloak/browser-run.test.ts b/src/browser/runtime/local-cloak/browser-run.test.ts index 84f3e83f..125f5984 100644 --- a/src/browser/runtime/local-cloak/browser-run.test.ts +++ b/src/browser/runtime/local-cloak/browser-run.test.ts @@ -31,6 +31,7 @@ beforeEach(async () => { baseDir: '/tmp/webcmd-browser-run-test', launchPersistentContext, }); + initialPage = (await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' })).page; }); afterEach(async () => { @@ -219,7 +220,7 @@ describe('local Cloak browser run', () => { expect(unstartedLaunch).not.toHaveBeenCalled(); }); - it('binds a session to the requested page and releases every page in that session', async () => { + it('does not bind a page owned by another Session', async () => { const original = await dispatchCloakAction(manager, command('run-original', 'run', { source: "await page.setContent('

original

'); return 'original';", })); @@ -227,19 +228,10 @@ describe('local Cloak browser run', () => { op: 'new', session: 'manual', })); - const boundPage = context.pages().find(page => page !== initialPage)!; - await boundPage.setContent('

bound

'); const bound = await dispatchCloakAction(manager, command('bind', 'bind', { page: created.page })); - const rerun = await dispatchCloakAction(manager, command('run-bound', 'run', { - source: 'return await page.locator("p").innerText();', - })); - const closed = await dispatchCloakAction(manager, command('close', 'close-window')); - const tabs = await dispatchCloakAction(manager, command('tabs-after-close', 'tabs', { op: 'list' })); expect(original).toMatchObject({ ok: true, page: expect.any(String) }); - expect(bound).toMatchObject({ ok: true, page: created.page }); - expect(rerun).toMatchObject({ ok: true, page: created.page, data: { result: 'bound' } }); - expect(closed).toMatchObject({ ok: true, data: { closed: true } }); - expect(tabs).toMatchObject({ ok: true, data: [] }); + expect(bound).toMatchObject({ ok: false, errorCode: 'SESSION_WINDOW_CONFLICT' }); + expect(created).toMatchObject({ ok: true, page: expect.any(String) }); }); }); diff --git a/src/browser/runtime/local-cloak/darwin-background-launch.test.ts b/src/browser/runtime/local-cloak/darwin-background-launch.test.ts index ef7f2056..133c9a58 100644 --- a/src/browser/runtime/local-cloak/darwin-background-launch.test.ts +++ b/src/browser/runtime/local-cloak/darwin-background-launch.test.ts @@ -45,6 +45,7 @@ describe('launchDarwinBackgroundPersistentContext', () => { '--fingerprint=123', '--password-store=basic', '--use-mock-keychain', + '--disable-popup-blocking', '--user-data-dir=/tmp/cloak profile', '--remote-debugging-address=127.0.0.1', '--remote-debugging-port=0', diff --git a/src/browser/runtime/local-cloak/darwin-background-launch.ts b/src/browser/runtime/local-cloak/darwin-background-launch.ts index 9a9e9576..42798eb4 100644 --- a/src/browser/runtime/local-cloak/darwin-background-launch.ts +++ b/src/browser/runtime/local-cloak/darwin-background-launch.ts @@ -7,6 +7,7 @@ import { buildLaunchOptions, humanizeBrowser } from 'cloakbrowser'; import type { LaunchPersistentContextOptions } from 'cloakbrowser'; import { chromium } from 'playwright-core'; import type { Browser, BrowserContext } from 'playwright-core'; +import { findExactCloakProfileProcesses } from './process-matcher.js'; const execFileAsync = promisify(execFile); @@ -40,13 +41,7 @@ export async function waitForDevToolsPort(portFile: string, timeoutMs = 10_000): } async function terminateProfile(userDataDir: string): Promise { - const { stdout } = await execFileAsync('/bin/ps', ['-axo', 'pid=,command=']); - const needle = `--user-data-dir=${userDataDir}`; - for (const line of stdout.split('\n')) { - if (!line.includes(needle)) continue; - const pid = Number.parseInt(line.trim().split(/\s+/, 1)[0], 10); - if (Number.isInteger(pid) && pid !== process.pid) process.kill(pid, 'SIGTERM'); - } + for (const pid of await findExactCloakProfileProcesses(userDataDir)) process.kill(pid, 'SIGTERM'); } const defaultDependencies: Dependencies = { @@ -92,6 +87,7 @@ export async function launchDarwinBackgroundPersistentContext( ...(launchOptions.args ?? []), '--password-store=basic', '--use-mock-keychain', + '--disable-popup-blocking', `--user-data-dir=${options.userDataDir}`, '--remote-debugging-address=127.0.0.1', '--remote-debugging-port=0', diff --git a/src/browser/runtime/local-cloak/process-matcher.test.ts b/src/browser/runtime/local-cloak/process-matcher.test.ts new file mode 100644 index 00000000..568d8e87 --- /dev/null +++ b/src/browser/runtime/local-cloak/process-matcher.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { matchCloakProfileCommand } from './process-matcher.js'; + +describe('matchCloakProfileCommand', () => { + it('matches only exact Cloak user-data-dir arguments', () => { + const cloak = '/Users/me/.cloakbrowser/chromium-146.0.7680.177.4/chrome --user-data-dir=/profiles/work'; + const cloakSeparate = '/Users/me/.cloakbrowser/chromium-146.0.7680.177.4/chrome --user-data-dir /profiles/work'; + const cloakQuoted = '"/Users/me/.cloakbrowser/chromium-146.0.7680.177.4/Chromium.app/Contents/MacOS/Chromium" "--user-data-dir=/profiles/work"'; + const cloakWork2 = '/Users/me/.cloakbrowser/chromium-146.0.7680.177.4/chrome --user-data-dir=/profiles/work-2'; + const chromeWork = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome --user-data-dir=/profiles/work'; + + expect(matchCloakProfileCommand(cloak, '/profiles/work')).toBe(true); + expect(matchCloakProfileCommand(cloakSeparate, '/profiles/work')).toBe(true); + expect(matchCloakProfileCommand(cloakQuoted, '/profiles/work')).toBe(true); + expect(matchCloakProfileCommand('C:\\Users\\me\\.cloakbrowser\\chromium-146.0.7680.177.4\\chrome.exe --user-data-dir=C:\\profiles\\work', 'C:\\profiles\\work')).toBe(true); + expect(matchCloakProfileCommand(cloakWork2, '/profiles/work')).toBe(false); + expect(matchCloakProfileCommand(chromeWork, '/profiles/work')).toBe(false); + expect(matchCloakProfileCommand('node tool.js --user-data-dir=/profiles/work', '/profiles/work')).toBe(false); + expect(matchCloakProfileCommand('node /tmp/.cloakbrowser/tool.js --user-data-dir=/profiles/work', '/profiles/work')).toBe(false); + expect(matchCloakProfileCommand('/tmp/.cloakbrowser/helper --user-data-dir=/profiles/work', '/profiles/work')).toBe(false); + }); + + it('accepts quotes around a separate or equals-form profile value', () => { + const executable = '/Users/me/.cloakbrowser/chromium-146.0.7680.177.4/chrome'; + expect(matchCloakProfileCommand(`${executable} --user-data-dir "/profiles/work space"`, '/profiles/work space')).toBe(true); + expect(matchCloakProfileCommand(`${executable} --user-data-dir='/profiles/work space'`, '/profiles/work space')).toBe(true); + }); +}); diff --git a/src/browser/runtime/local-cloak/process-matcher.ts b/src/browser/runtime/local-cloak/process-matcher.ts new file mode 100644 index 00000000..dc015819 --- /dev/null +++ b/src/browser/runtime/local-cloak/process-matcher.ts @@ -0,0 +1,65 @@ +import fs from 'node:fs'; +import { execFile } from 'node:child_process'; + +export function matchCloakProfileCommand(command: string, userDataDir: string): boolean { + const args = splitCommand(command); + const executable = args[0]; + const executableParts = executable?.split(/[\\/]/u) ?? []; + const cacheIndex = executableParts.lastIndexOf('.cloakbrowser'); + if (cacheIndex < 0 || !/^chromium-\d+(?:\.\d+)*(?:-pro)?$/u.test(executableParts[cacheIndex + 1] ?? '')) return false; + if (!['chrome', 'chrome.exe', 'chromium'].includes(executableParts.at(-1)?.toLowerCase() ?? '')) return false; + for (let index = 0; index < args.length; index += 1) { + if (args[index] === '--user-data-dir' && args[index + 1] === userDataDir) return true; + if (args[index] === `--user-data-dir=${userDataDir}`) return true; + } + return false; +} + +export async function findExactCloakProfileProcesses(userDataDir: string): Promise { + const aliases = new Set([userDataDir]); + try { + aliases.add(fs.realpathSync.native(userDataDir)); + } catch { + // The launch path is still useful when the directory does not exist yet. + } + const stdout = await psOutput(); + const pids = stdout.split('\n').flatMap((line) => { + const match = line.match(/^\s*(\d+)\s+(.+)$/); + if (!match) return []; + const pid = Number(match[1]); + if (!Number.isInteger(pid) || pid === process.pid) return []; + return [...aliases].some(dir => matchCloakProfileCommand(match[2], dir)) ? [pid] : []; + }); + return [...new Set(pids)]; +} + +function splitCommand(command: string): string[] { + const args: string[] = []; + let current = ''; + let quote = ''; + for (const char of command) { + if (quote) { + if (char === quote) quote = ''; + else current += char; + } else if (char === '"' || char === "'") { + quote = char; + } else if (/\s/u.test(char)) { + if (current) { + args.push(current); + current = ''; + } + } else { + current += char; + } + } + if (current) args.push(current); + return args; +} + +function psOutput(): Promise { + return new Promise((resolve) => { + execFile('ps', ['-axo', 'pid=,command='], { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, timeout: 2000 }, (err, stdout) => { + resolve(err ? '' : String(stdout)); + }); + }); +} diff --git a/src/browser/runtime/local-cloak/provider.test.ts b/src/browser/runtime/local-cloak/provider.test.ts index 147f7d92..2eb87c6f 100644 --- a/src/browser/runtime/local-cloak/provider.test.ts +++ b/src/browser/runtime/local-cloak/provider.test.ts @@ -20,10 +20,11 @@ function runOutput(result: unknown) { }; } -function fakePage(url: string, initialViewport: { width: number; height: number } | null = { width: 1280, height: 720 }) { +function fakePage(url: string, initialViewport: { width: number; height: number } | null = { width: 1280, height: 720 }, opener: object | null = null) { let closed = false; let viewportSize = initialViewport; - return { + const listeners = new Map void>>(); + const page = { isClosed: vi.fn(() => closed), goto: vi.fn(async (nextUrl: string) => { url = nextUrl; @@ -39,35 +40,107 @@ function fakePage(url: string, initialViewport: { width: number; height: number }), locator: vi.fn(), waitForEvent: vi.fn(), + opener: vi.fn().mockResolvedValue(opener), + on(event: string, listener: (...args: unknown[]) => void) { + const bucket = listeners.get(event) ?? new Set(); + bucket.add(listener); + listeners.set(event, bucket); + }, + once(event: string, listener: (...args: unknown[]) => void) { + const once = (...args: unknown[]) => { + page.off(event, once); + listener(...args); + }; + page.on(event, once); + }, + off(event: string, listener: (...args: unknown[]) => void) { + listeners.get(event)?.delete(listener); + }, bringToFront: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockImplementation(async () => { closed = true; + for (const listener of listeners.get('close') ?? []) listener(); }), }; + return page; } function makeProviderWithFakePage(initialViewport: { width: number; height: number } | null = { width: 1280, height: 720 }) { const pages = [fakePage('https://example.com/', initialViewport)]; - const cdpSession = { send: vi.fn().mockResolvedValue(undefined), detach: vi.fn().mockResolvedValue(undefined) }; - const browser = { contexts: vi.fn(() => [context]) }; + const listeners = new Map void>>(); + const emit = (event: string, ...args: unknown[]) => { + for (const listener of listeners.get(event) ?? []) listener(...args); + }; + const targetIds = new WeakMap(); + const windowIds = new Map(); + let targetCounter = 0; + let windowCounter = 0; + const assignTarget = (page: object) => { + const targetId = `target-${++targetCounter}`; + targetIds.set(page, targetId); + windowIds.set(targetId, ++windowCounter); + return targetId; + }; + assignTarget(pages[0]); + const cdpSession = { send: vi.fn(), detach: vi.fn().mockResolvedValue(undefined) }; + const pageCdpSessions: { send: ReturnType; detach: ReturnType }[] = []; + const browser = { contexts: vi.fn(() => [context]), newBrowserCDPSession: vi.fn().mockResolvedValue(cdpSession) }; const context = { browser: vi.fn(() => browser), - on: vi.fn(), + on: vi.fn((event: string, listener: (...args: unknown[]) => void) => { + const bucket = listeners.get(event) ?? new Set(); + bucket.add(listener); + listeners.set(event, bucket); + }), + off: vi.fn((event: string, listener: (...args: unknown[]) => void) => { + listeners.get(event)?.delete(listener); + }), + waitForEvent: vi.fn((event: string) => new Promise((resolve) => context.on(event, resolve))), pages: vi.fn(() => pages.filter((page) => !page.isClosed())), newPage: vi.fn(async () => { const page = fakePage('about:blank'); pages.push(page); + assignTarget(page); return page; }), - newCDPSession: vi.fn().mockResolvedValue(cdpSession), + newCDPSession: vi.fn(async (target: object) => { + const pageSession = { + send: vi.fn(async (command: string, params?: unknown) => { + if (params === undefined) cdpSession.send(command); + else cdpSession.send(command, params); + if (command === 'Target.getTargetInfo') return { targetInfo: { targetId: targetIds.get(target) } }; + return {}; + }), + detach: vi.fn().mockResolvedValue(undefined), + }; + pageCdpSessions.push(pageSession); + return pageSession; + }), cookies: vi.fn().mockResolvedValue([{ name: 'sid', value: '1', domain: 'example.com', path: '/' }]), close: vi.fn().mockResolvedValue(undefined), }; + let usedInitialPage = false; + cdpSession.send.mockImplementation(async (command: string, params?: { targetId?: string; hidden?: boolean }) => { + if (command === 'Target.createTarget') { + const page = params?.hidden ? fakePage('about:blank') : usedInitialPage ? await context.newPage() : pages[0]; + if (params?.hidden) { + pages.push(page); + assignTarget(page); + } else { + usedInitialPage = true; + } + queueMicrotask(() => emit('page', page)); + return { targetId: targetIds.get(page) }; + } + if (command === 'Browser.getWindowForTarget') return { windowId: windowIds.get(params?.targetId ?? '') }; + if (command === 'Target.closeTarget') return { success: true }; + return {}; + }); const provider = new LocalCloakRuntimeProvider({ baseDir: '/tmp/webcmd-test', launchPersistentContext: vi.fn().mockResolvedValue(context), }); - return { provider, browser, page: pages[0], pages, context, cdpSession }; + return { provider, browser, page: pages[0], pages, context, cdpSession, pageCdpSessions }; } describe('LocalCloakRuntimeProvider', () => { @@ -114,6 +187,38 @@ describe('LocalCloakRuntimeProvider', () => { expect(page.goto).toHaveBeenCalledWith('https://example.com/', expect.objectContaining({ waitUntil: 'commit' })); }); + it('does not execute a queued command after its daemon deadline expires', async () => { + const { provider, page } = makeProviderWithFakePage(); + let releaseFirst!: () => void; + page.goto.mockImplementationOnce(() => new Promise((resolve) => { + releaseFirst = resolve; + })); + const first = provider.dispatch({ + id: 'first', + action: 'navigate', + session: 'work', + surface: 'browser', + url: 'https://first.example/', + profileId: 'default', + }); + await vi.waitFor(() => expect(page.goto).toHaveBeenCalledTimes(1)); + const second = provider.dispatch({ + id: 'second', + action: 'navigate', + session: 'work', + surface: 'browser', + url: 'https://late.example/', + profileId: 'default', + deadlineAt: Date.now() - 1, + }); + + releaseFirst(); + + await expect(first).resolves.toMatchObject({ ok: true }); + await expect(second).resolves.toMatchObject({ ok: false, errorCode: 'command_result_unknown' }); + expect(page.goto).toHaveBeenCalledTimes(1); + }); + it('evaluates JavaScript in the resolved page', async () => { const { provider } = makeProviderWithFakePage(); const nav = await provider.dispatch({ id: 'nav', action: 'navigate', session: 'work', surface: 'browser', url: 'https://example.com/', profileId: 'default' }); @@ -147,11 +252,33 @@ describe('LocalCloakRuntimeProvider', () => { browser, context, page, + pages: expect.any(Function), }), expect.stringContaining('return page.url()'), expect.objectContaining({ snapshotDiff: undefined, })); }); + it('browser-run receives only pages from the selected session', async () => { + const { provider, pages } = makeProviderWithFakePage(); + runBrowserProgram.mockResolvedValue(runOutput(null)); + await provider.dispatch({ id: 'first', action: 'navigate', session: 'first', surface: 'browser', url: 'https://first.example/', profileId: 'default' }); + await provider.dispatch({ id: 'second', action: 'tabs', op: 'new', session: 'second', surface: 'browser', url: 'https://second.example/', profileId: 'default' }); + + await provider.dispatch({ + id: 'run', + action: 'run', + session: 'first', + surface: 'browser', + source: 'return context.pages().length;', + profileId: 'default', + }); + + expect(runBrowserProgram).toHaveBeenCalledWith(expect.objectContaining({ + pages: expect.any(Function), + }), expect.any(String), expect.any(Object)); + expect(runBrowserProgram.mock.calls[0][0].pages()).toEqual([pages[0]]); + }); + it('preserves structured browser-run error details', async () => { const { provider } = makeProviderWithFakePage(); runBrowserProgram.mockRejectedValue(new BrowserRunError( @@ -248,6 +375,140 @@ describe('LocalCloakRuntimeProvider', () => { expect(page.evaluate).toHaveBeenCalledTimes(1); }); + it('serializes raw and adapter commands in the same explicit Session', async () => { + const { provider } = makeProviderWithFakePage(); + const manager = (provider as unknown as { manager: { + runWithProfileActivity(profileId: string, operation: () => Promise): Promise; + } }).manager; + const runWithProfileActivity = manager.runWithProfileActivity.bind(manager); + let active = 0; + let maxActive = 0; + vi.spyOn(manager, 'runWithProfileActivity').mockImplementation(async (profileId, operation) => { + active += 1; + maxActive = Math.max(maxActive, active); + try { + return await runWithProfileActivity(profileId, operation); + } finally { + active -= 1; + } + }); + let finishRun!: () => void; + runBrowserProgram.mockImplementationOnce(() => new Promise((resolve) => { + finishRun = () => resolve(runOutput(1)); + })); + const raw = provider.dispatch({ + id: 'raw-run', action: 'run', session: 'session_a', sessionKind: 'explicit', + surface: 'browser', source: 'return 1;', profileId: 'default', + }); + await vi.waitFor(() => expect(runBrowserProgram).toHaveBeenCalledTimes(1)); + const adapter = provider.dispatch({ + id: 'adapter-exec', action: 'exec', session: 'session_a', sessionKind: 'explicit', + surface: 'adapter', adapterSite: 'github', code: 'document.title', profileId: 'default', + }); + + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(runBrowserProgram).toHaveBeenCalledTimes(1); + finishRun(); + await Promise.all([raw, adapter]); + expect(maxActive).toBe(1); + }); + + it('partitions the local queue by adapter site only for adapter-default Sessions', () => { + const { provider } = makeProviderWithFakePage(); + const queueKey = (provider as unknown as { + commandQueueKey(command: Parameters[0]): string; + }).commandQueueKey.bind(provider); + + expect(queueKey({ + id: 'github', + action: 'exec', + surface: 'adapter', + session: 'session_default', + sessionKind: 'adapter-default', + adapterSite: 'github', + profileId: 'default', + })).not.toBe(queueKey({ + id: 'linkedin', + action: 'exec', + surface: 'adapter', + session: 'session_default', + sessionKind: 'adapter-default', + adapterSite: 'linkedin', + profileId: 'default', + })); + expect(queueKey({ + id: 'github-explicit', + action: 'exec', + surface: 'adapter', + session: 'session_default', + sessionKind: 'explicit', + adapterSite: 'github', + profileId: 'default', + })).toBe(queueKey({ + id: 'linkedin-explicit', + action: 'exec', + surface: 'adapter', + session: 'session_default', + sessionKind: 'explicit', + adapterSite: 'linkedin', + profileId: 'default', + })); + expect(queueKey({ + id: 'github-explicit', action: 'exec', surface: 'adapter', session: 'session_a', + sessionKind: 'explicit', adapterSite: 'github', profileId: 'default', + })).toBe(queueKey({ + id: 'raw-explicit', action: 'exec', surface: 'browser', session: 'session_a', + sessionKind: 'explicit', profileId: 'default', + })); + }); + + it('keeps adapter-default page-scoped queue keys partitioned by site', async () => { + const { provider } = makeProviderWithFakePage(); + const queueKey = (provider as unknown as { + commandQueueKey(command: Parameters[0]): string; + }).commandQueueKey.bind(provider); + const github = await provider.dispatch({ + id: 'github-nav', + action: 'navigate', + surface: 'adapter', + session: 'session_default', + sessionId: 'session_default', + sessionKind: 'adapter-default', + siteSession: 'persistent', + adapterSite: 'github', + profileId: 'default', + url: 'https://github.example/', + }); + const linkedin = await provider.dispatch({ + id: 'linkedin-nav', + action: 'navigate', + surface: 'adapter', + session: 'session_default', + sessionId: 'session_default', + sessionKind: 'adapter-default', + siteSession: 'persistent', + adapterSite: 'linkedin', + profileId: 'default', + url: 'https://linkedin.example/', + }); + + expect(queueKey({ + id: 'github-followup', + action: 'exec', + surface: 'adapter', + session: 'session_default', + page: github.page, + profileId: 'default', + })).not.toBe(queueKey({ + id: 'linkedin-followup', + action: 'exec', + surface: 'adapter', + session: 'session_default', + page: linkedin.page, + profileId: 'default', + })); + }); + it('serializes commands by the resolved page lease when explicit page metadata differs', async () => { const { provider, page } = makeProviderWithFakePage(); runBrowserProgram.mockImplementationOnce(async () => { @@ -268,19 +529,19 @@ describe('LocalCloakRuntimeProvider', () => { id: 'run', action: 'run', page: nav.page, - session: 'misleading-session', - surface: 'adapter', + session: 'work', + surface: 'browser', source: 'await new Promise(resolve => setTimeout(resolve, 30)); return 1;', - profileId: 'misleading-profile', + profileId: 'default', }); const second = provider.dispatch({ id: 'exec', action: 'exec', page: nav.page, - session: 'different-session', - surface: 'adapter', + session: 'work', + surface: 'browser', code: 'document.title', - profileId: 'different-profile', + profileId: 'default', }); await new Promise((resolve) => setTimeout(resolve, 5)); @@ -289,208 +550,59 @@ describe('LocalCloakRuntimeProvider', () => { expect(page.evaluate).toHaveBeenCalledTimes(1); }); - it('keeps popup pages under the originating session queue lock', async () => { - const { provider, page, pages } = makeProviderWithFakePage(); - const popup = fakePage('https://popup.example/'); - pages.push(popup); - page.waitForEvent.mockResolvedValue(popup); + it('does not adopt a sibling Session page created during browser-run', async () => { + const { provider } = makeProviderWithFakePage(); runBrowserProgram.mockImplementationOnce(async (input) => { - const registeredPopup = await input.page.waitForEvent('popup'); - input.registerPage?.(registeredPopup); - await new Promise(resolve => setTimeout(resolve, 30)); + await provider.dispatch({ + id: 'sibling', + action: 'tabs', + op: 'new', + session: 'session_b', + surface: 'browser', + profileId: 'default', + }); + expect(input.pages().map((candidate: { url(): string }) => candidate.url())) + .toEqual(['https://example.com/']); return runOutput(null); }); - const nav = await provider.dispatch({ - id: 'nav', + await provider.dispatch({ + id: 'nav-a', action: 'navigate', - session: 'work', + session: 'session_a', surface: 'browser', url: 'https://example.com/', profileId: 'default', }); - const manager = ( - provider as unknown as { - manager: { pageIdFor(target: unknown): string | undefined }; - } - ).manager; - - const first = provider.dispatch({ - id: 'run', + await provider.dispatch({ + id: 'run-a', action: 'run', - page: nav.page, - session: 'misleading-session', - surface: 'adapter', - source: ` - await page.waitForEvent("popup"); - await new Promise(resolve => setTimeout(resolve, 30)); - return null; - `, - profileId: 'misleading-profile', - }); - await vi.waitFor(() => { - expect(manager.pageIdFor(popup)).toEqual(expect.any(String)); - }, { interval: 1, timeout: 100 }); - const popupPageId = manager.pageIdFor(popup)!; - popup.evaluate.mockClear(); - - const second = provider.dispatch({ - id: 'exec', - action: 'exec', - page: popupPageId, - session: 'different-session', - surface: 'adapter', - code: 'document.title', - profileId: 'different-profile', - }); - - await new Promise((resolve) => setTimeout(resolve, 5)); - expect(popup.evaluate).not.toHaveBeenCalled(); - await Promise.all([first, second]); - expect(popup.evaluate).toHaveBeenCalledTimes(1); - }); - - it('holds the original page queue lock throughout a bind transition', async () => { - const { provider, page } = makeProviderWithFakePage(); - const nav = await provider.dispatch({ - id: 'nav', - action: 'navigate', - session: 'before-bind', - surface: 'browser', - url: 'https://example.com/', - profileId: 'default', - }); - let markBindStarted!: () => void; - let finishBind!: () => void; - const bindStarted = new Promise((resolve) => { - markBindStarted = resolve; - }); - page.bringToFront.mockImplementation(() => { - markBindStarted(); - return new Promise((resolve) => { - finishBind = resolve; - }); - }); - page.evaluate.mockClear(); - - const bind = provider.dispatch({ - id: 'bind', - action: 'bind', - page: nav.page, - session: 'after-bind', + session: 'session_a', surface: 'browser', + source: 'return null;', profileId: 'default', }); - await bindStarted; - const exec = provider.dispatch({ - id: 'exec', - action: 'exec', - page: nav.page, - session: 'after-bind', - surface: 'browser', - code: 'document.title', - profileId: 'default', - }); - - await new Promise((resolve) => setTimeout(resolve, 5)); - expect(page.evaluate).not.toHaveBeenCalled(); - finishBind(); - await Promise.all([bind, exec]); - expect(page.evaluate).toHaveBeenCalledTimes(1); }); - it('joins target commands queued on opposite sides of a bind transition', async () => { - const { provider, page, pages, context } = makeProviderWithFakePage(); + it('denies misleading Session metadata for an owned page', async () => { + const { provider, page } = makeProviderWithFakePage(); const nav = await provider.dispatch({ id: 'nav', action: 'navigate', - session: 'source', + session: 'session_a', surface: 'browser', url: 'https://example.com/', profileId: 'default', }); - - const priorTargetPage = fakePage('https://prior-target.example/'); - pages.push(priorTargetPage); - context.newPage.mockResolvedValueOnce(priorTargetPage); - let releaseBlocker!: () => void; - let markBlockerStarted!: () => void; - const blockerStarted = new Promise((resolve) => { - markBlockerStarted = resolve; - }); - priorTargetPage.evaluate.mockImplementationOnce(() => { - markBlockerStarted(); - return new Promise((resolve) => { - releaseBlocker = () => resolve({ ok: true }); - }); - }); - const blocker = provider.dispatch({ - id: 'blocker', - action: 'exec', - session: 'target', - surface: 'browser', - code: 'document.title', - profileId: 'default', - }); - await blockerStarted; - - let finishBind!: () => void; - let markBindStarted!: () => void; - const bindStarted = new Promise((resolve) => { - markBindStarted = resolve; - }); - page.bringToFront.mockImplementation(() => { - markBindStarted(); - return new Promise((resolve) => { - finishBind = resolve; - }); - }); - const bind = provider.dispatch({ + await expect(provider.dispatch({ id: 'bind', action: 'bind', page: nav.page, - session: 'target', - surface: 'browser', - profileId: 'default', - }); - const beforeMapping = provider.dispatch({ - id: 'before-mapping', - action: 'exec', - page: nav.page, - session: 'target', - surface: 'browser', - code: 'document.title', - profileId: 'default', - }); - - releaseBlocker(); - await blocker; - await bindStarted; - - let finishFirstTargetExec!: () => void; - page.evaluate.mockImplementationOnce(() => ( - new Promise((resolve) => { - finishFirstTargetExec = () => resolve({ ok: true }); - }) - )); - const afterMapping = provider.dispatch({ - id: 'after-mapping', - action: 'exec', - session: 'target', + session: 'session_b', surface: 'browser', - code: 'document.title', profileId: 'default', - }); - - finishBind(); - await vi.waitFor(() => { - expect(page.evaluate).toHaveBeenCalledTimes(1); - }, { interval: 1, timeout: 100 }); - await new Promise((resolve) => setTimeout(resolve, 5)); - expect(page.evaluate).toHaveBeenCalledTimes(1); - - finishFirstTargetExec(); - await Promise.all([bind, beforeMapping, afterMapping]); - expect(page.evaluate).toHaveBeenCalledTimes(2); + })).resolves.toMatchObject({ ok: false, errorCode: 'SESSION_WINDOW_CONFLICT' }); + expect(page.bringToFront).not.toHaveBeenCalled(); + expect(page.evaluate).not.toHaveBeenCalled(); }); it('evaluates JavaScript in the requested iframe', async () => { @@ -589,7 +701,7 @@ describe('LocalCloakRuntimeProvider', () => { }); it('reversibly overrides via CDP and never pins the viewport when the context has no fixed viewport', async () => { - const { provider, page, cdpSession } = makeProviderWithFakePage(null); + const { provider, page, cdpSession, pageCdpSessions } = makeProviderWithFakePage(null); const nav = await provider.dispatch({ id: 'nav', action: 'navigate', session: 'work', surface: 'browser', url: 'https://example.com/', profileId: 'default' }); await provider.dispatch({ id: 'shot', action: 'screenshot', session: 'work', surface: 'browser', page: nav.page, format: 'png', width: 375, height: 812, profileId: 'default' }); @@ -599,7 +711,7 @@ describe('LocalCloakRuntimeProvider', () => { expect(cdpSession.send).toHaveBeenCalledWith('Emulation.setDeviceMetricsOverride', expect.objectContaining({ width: 375, height: 812 })); // ...and it must be cleared afterward so the override is per-shot only. expect(cdpSession.send).toHaveBeenCalledWith('Emulation.clearDeviceMetricsOverride'); - expect(cdpSession.detach).toHaveBeenCalledTimes(1); + expect(pageCdpSessions.some(session => session.detach.mock.calls.length > 0)).toBe(true); expect(page.screenshot).toHaveBeenCalledTimes(1); }); @@ -624,37 +736,23 @@ describe('LocalCloakRuntimeProvider', () => { }); }); - it('binds a browser session to an existing Cloak tab by page id', async () => { + it('rejects binding a page owned by another Session', async () => { const { provider, pages } = makeProviderWithFakePage(); const created = await provider.dispatch({ id: 'new', action: 'tabs', op: 'new', session: 'manual', surface: 'browser', url: 'https://signed-in.example/', profileId: 'default' }); await expect(provider.dispatch({ id: 'bind', action: 'bind', session: 'work', surface: 'browser', page: created.page, profileId: 'default' })) - .resolves.toMatchObject({ - id: 'bind', - ok: true, - page: created.page, - data: { bound: true, session: 'work', page: created.page, url: 'https://signed-in.example/' }, - }); - - await provider.dispatch({ id: 'exec', action: 'exec', session: 'work', surface: 'browser', code: 'window.__loggedIn', profileId: 'default' }); - expect(pages[1].evaluate).toHaveBeenCalledWith('window.__loggedIn'); - expect(pages[0].evaluate).not.toHaveBeenCalledWith('window.__loggedIn'); + .resolves.toMatchObject({ id: 'bind', ok: false, errorCode: 'SESSION_WINDOW_CONFLICT' }); + expect(pages[0].bringToFront).not.toHaveBeenCalled(); }); - it('binds a browser session to an existing Cloak tab by index', async () => { + it('does not enumerate another Session page by bind index', async () => { const { provider, pages } = makeProviderWithFakePage(); await provider.dispatch({ id: 'nav', action: 'navigate', session: 'first', surface: 'browser', url: 'https://first.example/', profileId: 'default' }); await provider.dispatch({ id: 'new', action: 'tabs', op: 'new', session: 'manual', surface: 'browser', url: 'https://second.example/', profileId: 'default' }); await expect(provider.dispatch({ id: 'bind', action: 'bind', session: 'work', surface: 'browser', index: 1, profileId: 'default' })) - .resolves.toMatchObject({ - id: 'bind', - ok: true, - data: { bound: true, session: 'work', url: 'https://second.example/' }, - }); - - await provider.dispatch({ id: 'exec', action: 'exec', session: 'work', surface: 'browser', code: 'document.readyState', profileId: 'default' }); - expect(pages[1].evaluate).toHaveBeenCalledWith('document.readyState'); + .resolves.toMatchObject({ id: 'bind', ok: false, errorCode: 'SESSION_WINDOW_CONFLICT' }); + expect(pages[0].bringToFront).not.toHaveBeenCalled(); }); it('returns a typed bind error when the requested Cloak tab is missing', async () => { @@ -697,15 +795,15 @@ describe('LocalCloakRuntimeProvider', () => { const created = await provider.dispatch({ id: 'new', action: 'tabs', op: 'new', session: 'work', surface: 'browser', url: 'https://second.example/', profileId: 'default' }); expect(created).toMatchObject({ id: 'new', ok: true, page: expect.any(String), data: { url: 'https://second.example/' } }); - expect(pages[1].goto).toHaveBeenCalledWith('https://second.example/', expect.objectContaining({ waitUntil: 'load' })); + expect(pages[0].goto).toHaveBeenCalledWith('https://second.example/', expect.objectContaining({ waitUntil: 'load' })); await expect(provider.dispatch({ id: 'select', action: 'tabs', op: 'select', session: 'work', surface: 'browser', page: created.page, profileId: 'default' })) .resolves.toMatchObject({ id: 'select', ok: true, page: created.page, data: { selected: true } }); - expect(pages[1].bringToFront).toHaveBeenCalled(); + expect(pages[0].bringToFront).toHaveBeenCalled(); await expect(provider.dispatch({ id: 'close', action: 'tabs', op: 'close', session: 'work', surface: 'browser', page: created.page, profileId: 'default' })) .resolves.toMatchObject({ id: 'close', ok: true, data: { closed: created.page } }); - expect(pages[1].close).toHaveBeenCalled(); + expect(pages[0].close.mock.calls.length + pages[0].goto.mock.calls.filter(([url]) => url === 'about:blank').length).toBeGreaterThan(0); }); it('does not bring selected tabs to front in background window mode', async () => { @@ -723,7 +821,7 @@ describe('LocalCloakRuntimeProvider', () => { profileId: 'default', windowMode: 'background', })).resolves.toMatchObject({ id: 'select', ok: true }); - expect(pages[1].bringToFront).not.toHaveBeenCalled(); + expect(pages[0].bringToFront).not.toHaveBeenCalled(); }); it('does not bring bound tabs to front in background window mode', async () => { @@ -738,8 +836,8 @@ describe('LocalCloakRuntimeProvider', () => { page: created.page, profileId: 'default', windowMode: 'background', - })).resolves.toMatchObject({ id: 'bind', ok: true }); - expect(pages[1].bringToFront).not.toHaveBeenCalled(); + })).resolves.toMatchObject({ id: 'bind', ok: false, errorCode: 'SESSION_WINDOW_CONFLICT' }); + expect(pages[0].bringToFront).not.toHaveBeenCalled(); }); it('brings bound tabs to front by default', async () => { @@ -747,20 +845,24 @@ describe('LocalCloakRuntimeProvider', () => { const created = await provider.dispatch({ id: 'new', action: 'tabs', op: 'new', session: 'source', surface: 'browser', url: 'https://second.example/', profileId: 'default' }); await expect(provider.dispatch({ id: 'bind', action: 'bind', session: 'target', surface: 'browser', page: created.page, profileId: 'default' })) - .resolves.toMatchObject({ id: 'bind', ok: true }); - expect(pages[1].bringToFront).toHaveBeenCalledOnce(); + .resolves.toMatchObject({ id: 'bind', ok: false, errorCode: 'SESSION_WINDOW_CONFLICT' }); + expect(pages[0].bringToFront).not.toHaveBeenCalled(); }); - it('closes a window by page identity when command.page is provided', async () => { + it('rejects a page identity from a different session', async () => { const { provider, pages } = makeProviderWithFakePage(); const first = await provider.dispatch({ id: 'first', action: 'navigate', session: 'first', surface: 'browser', url: 'https://first.example/', profileId: 'default' }); const second = await provider.dispatch({ id: 'second', action: 'tabs', op: 'new', session: 'second', surface: 'browser', url: 'https://second.example/', profileId: 'default' }); await expect(provider.dispatch({ id: 'close-window', action: 'close-window', session: 'first', surface: 'browser', page: second.page, profileId: 'default' })) - .resolves.toMatchObject({ id: 'close-window', ok: true, data: { closed: true, page: second.page } }); + .resolves.toMatchObject({ id: 'close-window', ok: true, data: { closed: false, page: second.page } }); + + await expect(provider.dispatch({ id: 'exec', action: 'exec', session: 'first', surface: 'browser', page: second.page, code: 'document.title', profileId: 'default' })) + .resolves.toMatchObject({ id: 'exec', ok: false, errorCode: 'stale_page_identity' }); expect(pages[0].isClosed()).toBe(false); - expect(pages[1].close).toHaveBeenCalled(); + expect(pages[1].close).not.toHaveBeenCalled(); + expect(pages[1].evaluate).not.toHaveBeenCalled(); expect(first.page).not.toBe(second.page); }); }); diff --git a/src/browser/runtime/local-cloak/provider.ts b/src/browser/runtime/local-cloak/provider.ts index 74b0f696..86dfa1da 100644 --- a/src/browser/runtime/local-cloak/provider.ts +++ b/src/browser/runtime/local-cloak/provider.ts @@ -1,5 +1,6 @@ import type { BrowserRuntimeCommand, BrowserRuntimeResult, BrowserRuntimeStatus } from '../../protocol.js'; import type { BrowserRuntimeProvider, RuntimeStatusOptions } from '../provider.js'; +import { LocalBrowserSessionStore, type BrowserSessionListRow, type BrowserSessionRecord } from '../../sessions.js'; import { dispatchCloakAction, resolveCloakCommandProfileId } from './actions.js'; import type { LaunchPersistentContext } from './session-manager.js'; import { @@ -14,13 +15,23 @@ export interface LocalCloakRuntimeProviderOptions { export class LocalCloakRuntimeProvider implements BrowserRuntimeProvider { private readonly manager: CloakSessionManager; + private readonly sessions: LocalBrowserSessionStore; private readonly sessionQueues = new Map>(); constructor(private readonly opts: LocalCloakRuntimeProviderOptions = {}) { - this.manager = new CloakSessionManager(opts); + this.sessions = new LocalBrowserSessionStore({ + baseDir: opts.baseDir, + isActive: session => this.manager?.hasSession(session.profileId, session.id) ?? false, + }); + this.manager = new CloakSessionManager({ + ...opts, + hasActiveHandoff: profileId => this.sessions.list(profileId, 100).some(session => ( + Boolean(session.handoff) && Date.parse(session.handoff!.expiresAt) > Date.now() + )), + }); } - async status(_opts: RuntimeStatusOptions = {}): Promise { + async status(opts: RuntimeStatusOptions = {}): Promise { const profiles = this.manager.profileStatuses(); return { runtimeConnected: true, @@ -29,6 +40,7 @@ export class LocalCloakRuntimeProvider implements BrowserRuntimeProvider { profiles, pending: 0, commandResultUnknown: 0, + sessions: await this.listSessions({ profileId: opts.contextId }), }; } @@ -36,7 +48,49 @@ export class LocalCloakRuntimeProvider implements BrowserRuntimeProvider { return resolveCloakCommandProfileId(this.manager, command); } - async dispatch(command: BrowserRuntimeCommand): Promise { + async createSession(command: BrowserRuntimeCommand): Promise { + return this.sessions.create(this.resolveProfileId(command)); + } + + async requireSession(command: BrowserRuntimeCommand): Promise { + return this.sessions.require(this.resolveProfileId(command), command.session); + } + + async resolveAdapterDefault(command: BrowserRuntimeCommand): Promise { + return this.sessions.resolveAdapterDefault(this.resolveProfileId(command)); + } + + async startSessionHandoff(command: BrowserRuntimeCommand): Promise { + const profileId = this.resolveProfileId(command); + const sessionId = command.sessionId!; + const record = this.sessions.markHandoff(profileId, sessionId, { + site: command.site!, + expiresAt: command.expiresAt!, + }); + await this.manager.foregroundSession(profileId, sessionId); + return record; + } + + async clearSessionHandoff(command: BrowserRuntimeCommand): Promise { + return this.sessions.clearHandoff(this.resolveProfileId(command), command.sessionId!); + } + + async listSessions(input: { profileId?: string; limit?: number }): Promise { + return this.sessions.list(input.profileId, input.limit).map((session) => ({ + ...session, + runtimeState: this.manager.hasSession(session.profileId, session.id) ? 'active' : 'idle', + })); + } + + async closeSession(command: BrowserRuntimeCommand): Promise<{ closed: boolean; alreadyIdle: boolean; session: string }> { + const record = this.sessions.require(this.resolveProfileId(command), command.session); + const closedCount = await this.manager.closeSession(record.profileId, record.id); + if (command.force && record.handoff) this.sessions.clearHandoff(record.profileId, record.id); + else this.sessions.touch(record.profileId, record.id); + return { closed: closedCount > 0, alreadyIdle: closedCount === 0, session: record.id }; + } + + async dispatch(command: BrowserRuntimeCommand, signal?: AbortSignal): Promise { const key = this.commandQueueKey(command); const previous = this.sessionQueues.get(key) ?? Promise.resolve(); let release!: () => void; @@ -47,7 +101,19 @@ export class LocalCloakRuntimeProvider implements BrowserRuntimeProvider { await previous.catch(() => {}); try { - return await dispatchCloakAction(this.manager, command); + signal?.throwIfAborted(); + if (typeof command.deadlineAt === 'number' && command.deadlineAt > 0 && Date.now() >= command.deadlineAt) { + return { + id: command.id, + ok: false, + errorCode: 'command_result_unknown', + error: 'Command deadline expired before browser work started.', + }; + } + return await this.manager.runWithProfileActivity( + this.resolveProfileId(command), + () => dispatchCloakAction(this.manager, command, signal), + ); } finally { release(); if (this.sessionQueues.get(key) === current) { @@ -62,8 +128,13 @@ export class LocalCloakRuntimeProvider implements BrowserRuntimeProvider { private commandQueueKey(command: BrowserRuntimeCommand): string { if (command.page) { - const profileId = this.manager.profileIdForPage(command.page); - if (profileId) return `profile\u0000${profileId}`; + const owner = this.manager.pageOwner(command.page); + if (owner) { + const adapterDefaultSite = owner.surface === 'adapter' && owner.sessionKind === 'adapter-default' + ? owner.adapterSite?.trim() + : undefined; + return `session\u0000${owner.profileId}\u0000${owner.session}${adapterDefaultSite ? `\u0000${adapterDefaultSite}` : ''}`; + } } let profileId: string; @@ -75,6 +146,9 @@ export class LocalCloakRuntimeProvider implements BrowserRuntimeProvider { ?? command.preferredContextId ?? 'default'; } - return `profile\u0000${profileId.trim() || 'default'}`; + const adapterDefaultSite = command.surface === 'adapter' && command.sessionKind === 'adapter-default' + ? command.adapterSite?.trim() + : undefined; + return `session\u0000${profileId.trim() || 'default'}\u0000${command.session ?? ''}${adapterDefaultSite ? `\u0000${adapterDefaultSite}` : ''}`; } } diff --git a/src/browser/runtime/local-cloak/session-manager.test.ts b/src/browser/runtime/local-cloak/session-manager.test.ts index 5b531871..b6eb2dbd 100644 --- a/src/browser/runtime/local-cloak/session-manager.test.ts +++ b/src/browser/runtime/local-cloak/session-manager.test.ts @@ -1,48 +1,148 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import path from 'node:path'; import type { BrowserContext, Page as PlaywrightPage } from 'playwright-core'; -import { CloakSessionManager } from './session-manager.js'; +import { CloakSessionManager, resolveLeaseKey } from './session-manager.js'; +import { log } from '../../../logger.js'; import { dispatchCloakAction } from './actions.js'; function fakeContext() { const listeners = new Map void>>(); - const fakePage = () => ({ - goto: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn().mockResolvedValue('ok'), - title: vi.fn().mockResolvedValue('Title'), - url: vi.fn().mockReturnValue('https://example.com/'), - screenshot: vi.fn().mockResolvedValue(Buffer.from('png')), - isClosed: vi.fn().mockReturnValue(false), - close: vi.fn().mockResolvedValue(undefined), - }); + const cdpListeners = new Map void>>(); + const pageListeners = new WeakMap void>>>(); + const targetIds = new WeakMap(); + const windowIds = new Map(); + let targetCounter = 0; + let windowCounter = 0; + let context: any; + const emitPageEvent = (page: any, event: string, ...args: unknown[]) => { + for (const listener of pageListeners.get(page)?.get(event) ?? []) listener(...args); + }; + const fakePage = (opener?: any, windowId = ++windowCounter, initialUrl = 'https://example.com/') => { + let closed = false; + let currentUrl = initialUrl; + const page: any = { + goto: vi.fn().mockImplementation(async (url: string) => { + currentUrl = url; + }), + evaluate: vi.fn(async (fn: unknown, ...args: unknown[]) => { + if (typeof fn !== 'function' || !String(fn).includes('window.open')) return 'ok'; + const source = String(fn); + const popup = fakePage(source.includes('noopener') ? undefined : page, windowId, typeof args[0] === 'string' ? args[0] : 'about:blank'); + allPages.push(popup); + queueMicrotask(() => { + if (!source.includes('noopener')) emitPageEvent(page, 'popup', popup); + emit('page', popup); + }); + return null; + }), + title: vi.fn().mockResolvedValue('Title'), + url: vi.fn(() => currentUrl), + screenshot: vi.fn().mockResolvedValue(Buffer.from('png')), + bringToFront: vi.fn().mockResolvedValue(undefined), + isClosed: vi.fn(() => closed), + close: vi.fn(async () => { + closed = true; + emitPageEvent(page, 'close'); + }), + opener: vi.fn().mockResolvedValue(opener ?? null), + on(event: string, listener: (...args: unknown[]) => void) { + const events = pageListeners.get(page) ?? new Map(); + const bucket = events.get(event) ?? new Set(); + bucket.add(listener); + events.set(event, bucket); + pageListeners.set(page, events); + }, + once(event: string, listener: (...args: unknown[]) => void) { + const once = (...args: unknown[]) => { + page.off(event, once); + listener(...args); + }; + page.on(event, once); + }, + off(event: string, listener: (...args: unknown[]) => void) { + pageListeners.get(page)?.get(event)?.delete(listener); + }, + waitForEvent(event: string) { + return new Promise((resolve, reject) => { + page.once(event, resolve); + setTimeout(() => reject(new Error(`Timeout waiting for ${event}`)), 0); + }); + }, + }; + const targetId = `target-${++targetCounter}`; + targetIds.set(page, targetId); + windowIds.set(targetId, windowId); + return page; + }; const page = fakePage(); + const allPages = [page]; const backgroundPages: ReturnType[] = []; const emit = (event: string, ...args: unknown[]) => { for (const listener of listeners.get(event) ?? []) listener(...args); }; const cdp = { - send: vi.fn(async (command: string) => { + send: vi.fn(async (command: string, params?: { targetId?: string; hidden?: boolean; newWindow?: boolean }) => { if (command === 'Target.createTarget') { - const backgroundPage = fakePage(); + let backgroundPage; + if (params?.hidden) { + backgroundPage = fakePage(); + allPages.push(backgroundPage); + } else if (params?.newWindow === false) { + let opener; + for (let index = allPages.length - 1; index >= 0; index -= 1) { + if (!allPages[index]!.isClosed()) { + opener = allPages[index]; + break; + } + } + backgroundPage = fakePage(undefined, opener ? windowIds.get(targetIds.get(opener)!) : undefined); + allPages.push(backgroundPage); + } else { + backgroundPage = await context.newPage(); + } backgroundPages.push(backgroundPage); queueMicrotask(() => emit('page', backgroundPage)); + return { targetId: targetIds.get(backgroundPage) }; } + if (command === 'Browser.getWindowForTarget') return { windowId: windowIds.get(params?.targetId ?? '') }; + if (command === 'Target.closeTarget') return { success: true }; + return {}; + }), + on: vi.fn((event: string, listener: (...args: any[]) => void) => { + const bucket = cdpListeners.get(event) ?? new Set(); + bucket.add(listener); + cdpListeners.set(event, bucket); }), detach: vi.fn().mockResolvedValue(undefined), }; return { - context: { + context: context = { on(event: string, listener: (...args: unknown[]) => void) { const bucket = listeners.get(event) ?? new Set(); bucket.add(listener); listeners.set(event, bucket); }, + off(event: string, listener: (...args: unknown[]) => void) { + listeners.get(event)?.delete(listener); + }, emit, waitForEvent(event: string) { return new Promise((resolve) => this.on(event, resolve)); }, - pages: vi.fn().mockReturnValue([page]), - newPage: vi.fn().mockResolvedValue(page), + pages: vi.fn(() => allPages.filter((page) => !page.isClosed())), + newPage: vi.fn(async () => { + const created = fakePage(); + allPages.push(created); + return created; + }), + newCDPSession: vi.fn(async (target: object) => ({ + send: vi.fn(async (command: string, params?: { targetId?: string }) => { + if (command === 'Target.getTargetInfo') return { targetInfo: { targetId: targetIds.get(target) } }; + if (command === 'Browser.getWindowForTarget') return { windowId: windowIds.get(params?.targetId ?? '') }; + return {}; + }), + detach: vi.fn().mockResolvedValue(undefined), + })), browser: vi.fn().mockReturnValue({ newBrowserCDPSession: vi.fn().mockResolvedValue(cdp) }), cookies: vi.fn().mockResolvedValue([{ name: 'sid', value: '1', domain: 'example.com', path: '/' }]), close: vi.fn().mockResolvedValue(undefined), @@ -50,6 +150,16 @@ function fakeContext() { page, backgroundPages, cdp, + targetIdFor: (target: object) => targetIds.get(target), + windowIdFor: (target: object) => windowIds.get(targetIds.get(target) ?? ''), + moveToWindow: (target: object, windowId: number) => windowIds.set(targetIds.get(target)!, windowId), + emitPage: (target: object) => emit('page', target), + emitPageEvent, + emitCdp: (event: string, payload: unknown) => { + for (const listener of cdpListeners.get(event) ?? []) listener(payload); + }, + pageListenerCount: (target: object, event: string) => pageListeners.get(target)?.get(event)?.size ?? 0, + makePage: fakePage, }; } @@ -60,6 +170,7 @@ function expectedProfileDir(profileId: string): string { describe('CloakSessionManager', () => { afterEach(() => { vi.useRealTimers(); + vi.restoreAllMocks(); }); it('launches one persistent context per profile and reuses named sessions', async () => { @@ -78,6 +189,291 @@ describe('CloakSessionManager', () => { expect(launchPersistentContext.mock.calls[0][0]).toMatchObject({ headless: false }); }); + it('correlates created targets and isolates Sessions into owned windows', async () => { + const launched = fakeContext(); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + platform: 'darwin', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + + const first = await manager.getPage({ profileId: 'default', session: 'session_a', sessionId: 'session_a', surface: 'browser' }); + const second = await manager.getPage({ profileId: 'default', session: 'session_b', sessionId: 'session_b', surface: 'browser' }); + + expect(launched.cdp.send.mock.calls.filter(([method, params]) => method === 'Target.createTarget' && !(params as { hidden?: boolean })?.hidden)) + .toHaveLength(2); + expect(launched.windowIdFor(first.page)).not.toBe(launched.windowIdFor(second.page)); + expect((await manager.listPages({ profileId: 'default', session: 'session_a', sessionId: 'session_a' })) + .map(tab => tab.sessionId)).toEqual(['session_a']); + }); + + it('matches Target.createTarget by target id instead of adopting the next context page', async () => { + const launched = fakeContext(); + const unrelated = launched.makePage(); + const send = launched.cdp.send.getMockImplementation()!; + launched.cdp.send.mockImplementationOnce(async (method: string, params: unknown) => { + launched.emitPage(unrelated); + return send(method, params as { targetId?: string } | undefined); + }); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + platform: 'darwin', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + + const lease = await manager.getPage({ profileId: 'default', session: 'session_a', sessionId: 'session_a', surface: 'browser' }); + + expect(lease.page).not.toBe(unrelated); + expect(manager.pageIdFor(unrelated)).toBeUndefined(); + expect(launched.targetIdFor(lease.page)).toEqual(expect.stringMatching(/^target-/)); + }); + + it('creates later Session pages with noopener and adopts the context page in the same window', async () => { + const launched = fakeContext(); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + platform: 'darwin', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + const key = { profileId: 'default', session: 'session_a', sessionId: 'session_a', surface: 'browser' as const }; + const first = await manager.getPage(key); + + const second = await manager.newPage(key); + const evaluate = vi.mocked(first.page.evaluate); + expect(evaluate).toHaveBeenCalledTimes(1); + expect(String(evaluate.mock.calls[0]?.[0])).toContain('noopener'); + expect(launched.context.newCDPSession.mock.calls.length).toBeGreaterThanOrEqual(2); + expect(launched.cdp.send.mock.calls.filter(([method, params]) => ( + method === 'Target.createTarget' && !(params as { hidden?: boolean })?.hidden + ))).toHaveLength(1); + expect(launched.windowIdFor(second.page)).toBe(launched.windowIdFor(first.page)); + expect(await second.page.opener()).toBeNull(); + expect((await manager.listPages(key)).every(tab => tab.session === 'session_a')).toBe(true); + }); + + it('falls back to another owned window when Chromium does not create the requested tab', async () => { + const launched = fakeContext(); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + platform: 'darwin', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + const key = { profileId: 'default', session: 'session_a', sessionId: 'session_a', surface: 'browser' as const }; + const first = await manager.getPage(key); + vi.mocked(first.page.evaluate).mockResolvedValueOnce(null); + + const second = await manager.newPage(key); + + expect(launched.windowIdFor(second.page)).not.toBe(launched.windowIdFor(first.page)); + expect((await manager.listPages(key)).map(tab => tab.sessionId)).toEqual(['session_a', 'session_a']); + }); + + it('logs when window.open fails before falling back to another owned window', async () => { + const launched = fakeContext(); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + platform: 'darwin', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + const key = { profileId: 'default', session: 'session_a', sessionId: 'session_a', surface: 'browser' as const }; + const first = await manager.getPage(key); + vi.mocked(first.page.evaluate).mockRejectedValueOnce(new Error('window.open blocked')); + const warn = vi.spyOn(log, 'warn').mockImplementation(() => {}); + vi.useFakeTimers(); + + const second = manager.newPage(key); + await vi.advanceTimersByTimeAsync(1_000); + + await expect(second).resolves.toMatchObject({ page: expect.any(Object) }); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('window.open failed')); + }); + + it('ignores an unmarked opener-less page when waiting for a Session tab', async () => { + const launched = fakeContext(); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + platform: 'darwin', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + const key = { profileId: 'default', session: 'session_a', sessionId: 'session_a', surface: 'browser' as const }; + const first = await manager.getPage(key); + const opened = launched.makePage(undefined, 999); + vi.mocked(first.page.evaluate).mockImplementationOnce(async () => { + queueMicrotask(() => launched.emitPage(opened)); + return null; + }); + + vi.useFakeTimers(); + const secondPromise = manager.newPage(key); + await vi.advanceTimersByTimeAsync(1_000); + const second = await secondPromise; + + expect(second.page).not.toBe(opened); + expect(launched.cdp.send.mock.calls.filter(([method, params]) => ( + method === 'Target.createTarget' && !(params as { hidden?: boolean })?.hidden + ))).toHaveLength(2); + expect((await manager.listPages(key)).map(tab => tab.sessionId)).toEqual(['session_a', 'session_a']); + }); + + it('keeps a site popup owned while noopener tab creation uses another page', async () => { + const launched = fakeContext(); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + platform: 'darwin', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + const key = { profileId: 'default', session: 'session_a', sessionId: 'session_a', surface: 'browser' as const }; + const first = await manager.getPage(key); + const popup = launched.makePage(first.page, 999); + vi.mocked(first.page.evaluate).mockImplementationOnce(async () => { + queueMicrotask(() => { + launched.emitPageEvent(first.page, 'popup', popup); + launched.emitPage(popup); + }); + return null; + }); + + const second = await manager.newPage(key); + + expect(second.page).not.toBe(popup); + expect((await manager.listPages(key)).map(tab => tab.sessionId)).toEqual(['session_a', 'session_a', 'session_a']); + }); + + it('creates a later page in its Session window when another Session was used last', async () => { + const launched = fakeContext(); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', platform: 'darwin', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + const firstKey = { profileId: 'default', session: 'session_a', sessionId: 'session_a', surface: 'browser' as const }; + const first = await manager.getPage(firstKey); + await manager.getPage({ profileId: 'default', session: 'session_b', sessionId: 'session_b', surface: 'browser' }); + + const second = await manager.newPage(firstKey); + + expect(launched.windowIdFor(second.page)).toBe(launched.windowIdFor(first.page)); + }); + + it('times out target correlation and releases the profile creation lock', async () => { + vi.useFakeTimers(); + const launched = fakeContext(); + const send = launched.cdp.send.getMockImplementation()!; + launched.cdp.send.mockImplementation(async (method: string, params?: { hidden?: boolean }) => ( + method === 'Target.createTarget' && !params?.hidden + ? { targetId: 'missing-target' } + : send(method, params) + )); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + platform: 'darwin', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + const key = { profileId: 'default', session: 'session_a', sessionId: 'session_a', surface: 'browser' as const }; + + const missing = manager.getPage(key); + const missingExpectation = expect(missing).rejects.toThrow('Timed out waiting for Cloak target missing-target'); + await vi.waitFor(() => expect(launched.cdp.send).toHaveBeenCalledWith('Target.createTarget', expect.any(Object))); + await vi.advanceTimersByTimeAsync(1_000); + await missingExpectation; + + launched.cdp.send.mockImplementation(send); + const next = manager.getPage(key); + await vi.runAllTimersAsync(); + await expect(next).resolves.toMatchObject({ pageId: expect.any(String) }); + }); + + it('registers a child-window popup under its opener Session', async () => { + const launched = fakeContext(); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + const key = { profileId: 'default', session: 'session_a', sessionId: 'session_a', surface: 'browser' as const }; + const first = await manager.getPage(key); + const popup = launched.makePage(first.page, 999); + + launched.emitPage(popup); + await vi.waitFor(() => expect(manager.pageIdFor(popup)).toEqual(expect.any(String))); + + expect((await manager.listPages(key)).map(tab => tab.session)).toEqual(['session_a', 'session_a']); + expect(launched.windowIdFor(popup)).toBe(999); + }); + + it('rejects every operation after a Session page moves into another owned window', async () => { + const launched = fakeContext(); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + const a = { profileId: 'default', session: 'session_a', sessionId: 'session_a', surface: 'browser' as const }; + const b = { profileId: 'default', session: 'session_b', sessionId: 'session_b', surface: 'browser' as const }; + const first = await manager.getPage(a); + const second = await manager.getPage(b); + launched.moveToWindow(first.page, launched.windowIdFor(second.page)!); + + await expect(manager.listPages(a)).rejects.toMatchObject({ code: 'SESSION_WINDOW_CONFLICT' }); + await expect(manager.selectPage({ ...a, pageId: first.pageId })).rejects.toMatchObject({ code: 'SESSION_WINDOW_CONFLICT' }); + await expect(manager.bindPage({ ...a, pageId: first.pageId })).rejects.toMatchObject({ code: 'SESSION_WINDOW_CONFLICT' }); + await expect(manager.closePage({ ...a, pageId: first.pageId })).rejects.toMatchObject({ code: 'SESSION_WINDOW_CONFLICT' }); + await expect(manager.closeSession(a.profileId, a.sessionId)).rejects.toMatchObject({ code: 'SESSION_WINDOW_CONFLICT' }); + expect(first.page.close).not.toHaveBeenCalled(); + expect(await manager.findPageById(second.pageId, a)).toBeNull(); + }); + + it('does not let another Session bind an owned page moved to an unowned window', async () => { + const launched = fakeContext(); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + const a = { profileId: 'default', session: 'session_a', sessionId: 'session_a', surface: 'browser' as const }; + const b = { profileId: 'default', session: 'session_b', sessionId: 'session_b', surface: 'browser' as const }; + const first = await manager.getPage(a); + launched.moveToWindow(first.page, 999); + + await expect(manager.bindPage({ ...b, pageId: first.pageId })) + .rejects.toMatchObject({ code: 'SESSION_WINDOW_CONFLICT' }); + expect(first.page.close).not.toHaveBeenCalled(); + expect(await manager.listPages(b)).toEqual([]); + await expect(manager.listPages(a)).rejects.toMatchObject({ code: 'SESSION_WINDOW_CONFLICT' }); + }); + + it('checks opener window ownership before calling window.open', async () => { + const launched = fakeContext(); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + const key = { profileId: 'default', session: 'session_a', sessionId: 'session_a', surface: 'browser' as const }; + const first = await manager.getPage(key); + launched.moveToWindow(first.page, 999); + + await expect(manager.newPage(key)).rejects.toMatchObject({ code: 'SESSION_WINDOW_CONFLICT' }); + expect(first.page.evaluate).not.toHaveBeenCalled(); + expect(first.page.close).not.toHaveBeenCalled(); + }); + + it('binds an unowned context page without adopting another Session page', async () => { + const launched = fakeContext(); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + await manager.getPage({ profileId: 'default', session: 'session_a', surface: 'browser' }); + + const bound = await manager.bindPage({ + profileId: 'default', + session: 'session_b', + surface: 'browser', + index: 0, + }); + + expect(bound?.page).toBe(launched.page); + expect((await manager.listPages({ profileId: 'default', session: 'session_b' })).map(tab => tab.id)) + .toEqual([bound?.pageId]); + expect(await manager.listPages({ profileId: 'default', session: 'session_a' })).toHaveLength(1); + }); + it.each([ { platform: 'darwin', windowMode: 'background', backgroundCalls: 1, normalCalls: 0 }, { platform: 'darwin', windowMode: 'foreground', backgroundCalls: 0, normalCalls: 1 }, @@ -115,8 +511,27 @@ describe('CloakSessionManager', () => { windowMode: 'background', }); - await manager.selectPage({ profileId: 'default', pageId: lease.pageId, windowMode: 'foreground' }); + await manager.selectPage({ profileId: 'default', session: 'work', surface: 'browser', pageId: lease.pageId, windowMode: 'foreground' }); + + expect(activateBackgroundContext).toHaveBeenCalledWith(launched.context); + }); + + it('foregrounds only the selected Session window during handoff', async () => { + const launched = fakeContext(); + const activateBackgroundContext = vi.fn().mockResolvedValue(undefined); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + platform: 'darwin', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + activateBackgroundContext, + }); + const first = await manager.getPage({ profileId: 'work', session: 'session_a', sessionId: 'session_a', surface: 'adapter' }); + const sibling = await manager.getPage({ profileId: 'work', session: 'session_b', sessionId: 'session_b', surface: 'adapter' }); + + await manager.foregroundSession('work', 'session_a'); + expect(first.page.bringToFront).toHaveBeenCalledOnce(); + expect(sibling.page.bringToFront).not.toHaveBeenCalled(); expect(activateBackgroundContext).toHaveBeenCalledWith(launched.context); }); @@ -137,10 +552,10 @@ describe('CloakSessionManager', () => { expect(launched.cdp.send).toHaveBeenCalledWith('Target.createTarget', { url: 'about:blank', + newWindow: true, background: true, focus: false, }); - expect(launched.context.newPage).not.toHaveBeenCalled(); }); it('creates an explicit background tab without focusing Chromium', async () => { @@ -160,13 +575,13 @@ describe('CloakSessionManager', () => { expect(launched.cdp.send).toHaveBeenCalledWith('Target.createTarget', { url: 'about:blank', + newWindow: true, background: true, focus: false, }); - expect(launched.context.newPage).not.toHaveBeenCalled(); }); - it('creates an explicit foreground tab through Playwright', async () => { + it('creates an explicit foreground tab in a new CDP window', async () => { const launched = fakeContext(); const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', @@ -180,8 +595,12 @@ describe('CloakSessionManager', () => { windowMode: 'foreground', }); - expect(launched.context.newPage).toHaveBeenCalledOnce(); - expect(launched.cdp.send).not.toHaveBeenCalled(); + expect(launched.cdp.send).toHaveBeenCalledWith('Target.createTarget', { + url: 'about:blank', + newWindow: true, + background: false, + focus: true, + }); }); it('gives concurrent background tabs distinct pages', async () => { @@ -207,7 +626,7 @@ describe('CloakSessionManager', () => { const [first, second] = await Promise.all([firstRequest, secondRequest]); expect(first.page).not.toBe(second.page); - expect(launched.backgroundPages).toEqual([first.page, second.page]); + expect(launched.backgroundPages.slice(-2)).toEqual([first.page, second.page]); }); it('coalesces concurrent same-lease page acquisition', async () => { @@ -234,15 +653,13 @@ describe('CloakSessionManager', () => { expect(second.context).toBe(launched.context); expect(first.page).toBe(second.page); expect(first.pageId).toBe(second.pageId); - expect(launched.context.newPage).not.toHaveBeenCalled(); - expect(launched.cdp.send).not.toHaveBeenCalled(); + expect(launched.context.newPage).toHaveBeenCalledOnce(); + expect(launched.cdp.send).toHaveBeenCalledWith('Target.createTarget', expect.objectContaining({ newWindow: true })); }); it('evicts a closed runtime and clears every tracked page resource', async () => { vi.useFakeTimers(); const launched = fakeContext(); - const secondPage = fakeContext().page; - launched.context.newPage.mockResolvedValue(secondPage); const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), @@ -352,12 +769,10 @@ describe('CloakSessionManager', () => { vi.useFakeTimers(); const first = fakeContext(); first.context.pages.mockReturnValue([]); - first.context.newPage.mockImplementation(() => ({ - then(resolve: (page: typeof first.page) => void) { - resolve(first.page); - queueMicrotask(() => first.context.emit('close')); - }, - })); + first.context.newPage.mockImplementation(() => { + queueMicrotask(() => first.context.emit('close')); + return Promise.resolve(first.page); + }); const replacement = fakeContext(); replacement.context.pages.mockReturnValue([]); const launchPersistentContext = vi.fn() @@ -365,7 +780,8 @@ describe('CloakSessionManager', () => { .mockResolvedValueOnce(replacement.context); const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext }); - await manager.getPage({ profileId: 'default', session: 'first', surface: 'browser', idleTimeout: 25 }); + await expect(manager.getPage({ profileId: 'default', session: 'first', surface: 'browser', idleTimeout: 25 })) + .rejects.toThrow('Target page, context or browser has been closed'); expect(manager.activeProfileIds()).toEqual([]); expect(vi.getTimerCount()).toBe(0); @@ -445,6 +861,7 @@ describe('CloakSessionManager', () => { resolveNavigation = resolve; }); }); + launched.context.newPage.mockResolvedValue(launched.page); const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), @@ -458,7 +875,7 @@ describe('CloakSessionManager', () => { url: 'https://example.com/', }); await navigationStarted; - const pagesDuringNavigation = await manager.listPages({ profileId: 'default' }); + const pagesDuringNavigation = await manager.listPages({ profileId: 'default', session: 'work' }); const pageIdDuringNavigation = manager.pageIdFor(launched.page as unknown as PlaywrightPage); const timersDuringNavigation = vi.getTimerCount(); resolveNavigation(); @@ -468,29 +885,35 @@ describe('CloakSessionManager', () => { expect(pageIdDuringNavigation).toBeUndefined(); expect(timersDuringNavigation).toBe(0); expect(manager.pageIdFor(launched.page as unknown as PlaywrightPage)).toBe(lease.pageId); - expect(await manager.listPages({ profileId: 'default' })).toHaveLength(1); + expect(await manager.listPages({ profileId: 'default', session: 'work' })).toHaveLength(1); expect(vi.getTimerCount()).toBe(1); }); - it('does not retry or retain a page when navigation fails after creation', async () => { + it('retries initial navigation once after a closed-context failure', async () => { const navigationFailure = new Error('Target page, context or browser has been closed'); const launched = fakeContext(); launched.page.goto.mockRejectedValue(navigationFailure); - const launchPersistentContext = vi.fn().mockResolvedValue(launched.context); + launched.context.newPage.mockResolvedValue(launched.page); + const replacement = fakeContext(); + replacement.context.newPage.mockResolvedValue(replacement.page); + const launchPersistentContext = vi.fn() + .mockResolvedValueOnce(launched.context) + .mockResolvedValueOnce(replacement.context); const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext }); - await expect(manager.newPage({ + const lease = await manager.newPage({ profileId: 'default', session: 'work', surface: 'browser', url: 'https://example.com/', - })).rejects.toBe(navigationFailure); + }); - expect(launchPersistentContext).toHaveBeenCalledTimes(1); - expect(launched.context.newPage).toHaveBeenCalledTimes(1); + expect(lease.context).toBe(replacement.context); + expect(launchPersistentContext).toHaveBeenCalledTimes(2); expect(launched.page.goto).toHaveBeenCalledTimes(1); expect(launched.page.close).toHaveBeenCalledTimes(1); - expect(await manager.listPages({ profileId: 'default' })).toEqual([]); + expect(replacement.page.goto).toHaveBeenCalledWith('https://example.com/', { waitUntil: 'load' }); + expect(await manager.listPages({ profileId: 'default', session: 'work' })).toHaveLength(1); }); it('clears a stale Cloak profile owner and retries when Chromium reports an existing session', async () => { @@ -513,29 +936,10 @@ describe('CloakSessionManager', () => { }); it('freshPage closes the existing persistent lease page and creates a new one', async () => { - const makePage = () => ({ - goto: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn().mockResolvedValue('ok'), - title: vi.fn().mockResolvedValue('Title'), - url: vi.fn().mockReturnValue('about:blank'), - isClosed: vi.fn().mockReturnValue(false), - close: vi.fn().mockResolvedValue(undefined), - }); - const openPages: ReturnType[] = []; - const context = { - on: vi.fn(), - pages: vi.fn(() => openPages), - newPage: vi.fn(async () => { - const page = makePage(); - openPages.push(page); - return page; - }), - cookies: vi.fn().mockResolvedValue([]), - close: vi.fn().mockResolvedValue(undefined), - }; + const launched = fakeContext(); const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', - launchPersistentContext: vi.fn().mockResolvedValue(context), + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), }); const key = { profileId: 'default', session: 'site:district', surface: 'adapter' as const, siteSession: 'persistent' as const }; @@ -550,50 +954,115 @@ describe('CloakSessionManager', () => { expect(reused.page).toBe(fresh.page); }); - it('freshPage never adopts a leftover context tab', async () => { - const leftover = { - goto: vi.fn(), - isClosed: vi.fn().mockReturnValue(false), - close: vi.fn().mockResolvedValue(undefined), - }; - const created = { - goto: vi.fn(), - isClosed: vi.fn().mockReturnValue(false), - close: vi.fn().mockResolvedValue(undefined), + it('keeps persistent adapter pages separate by Session and site', async () => { + const launched = fakeContext(); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + const base = { + profileId: 'default', + session: 'session_a', + sessionId: 'session_a', + surface: 'adapter' as const, + siteSession: 'persistent' as const, }; - const context = { - on: vi.fn(), - pages: vi.fn().mockReturnValue([leftover]), - newPage: vi.fn().mockResolvedValue(created), - cookies: vi.fn().mockResolvedValue([]), - close: vi.fn().mockResolvedValue(undefined), + + const githubA = await manager.getPage({ ...base, adapterSite: 'github' }); + const linkedinA = await manager.getPage({ ...base, adapterSite: 'linkedin' }); + const githubB = await manager.getPage({ ...base, session: 'session_b', sessionId: 'session_b', adapterSite: 'github' }); + + expect(linkedinA.page).not.toBe(githubA.page); + expect(githubB.page).not.toBe(githubA.page); + expect((await manager.getPage({ ...base, adapterSite: 'github' })).page).toBe(githubA.page); + }); + + it('keys ephemeral adapter pages by Session, site, and run', () => { + const base = { + session: 'session_a', + sessionId: 'session_a', + surface: 'adapter' as const, + siteSession: 'ephemeral' as const, }; + + expect(resolveLeaseKey({ ...base, adapterSite: 'github', runId: 'run_a' })) + .toBe('session_a\0ephemeral:github:run_a'); + expect(resolveLeaseKey({ ...base, adapterSite: 'linkedin', runId: 'run_a' })) + .not.toBe(resolveLeaseKey({ ...base, adapterSite: 'github', runId: 'run_a' })); + expect(resolveLeaseKey({ ...base, adapterSite: 'github', runId: 'run_b' })) + .not.toBe(resolveLeaseKey({ ...base, adapterSite: 'github', runId: 'run_a' })); + }); + + it('freshPage never adopts a leftover context tab', async () => { + const launched = fakeContext(); + const leftover = launched.page; + const created = fakeContext().page; + launched.context.newPage.mockResolvedValue(created); const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', - launchPersistentContext: vi.fn().mockResolvedValue(context), + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), }); const lease = await manager.getPage({ profileId: 'default', session: 'site:district', surface: 'adapter', siteSession: 'persistent', freshPage: true }); expect(lease.page).toBe(created); - expect(context.newPage).toHaveBeenCalled(); + expect(lease.page).not.toBe(leftover); }); it('closes ephemeral adapter sessions when released', async () => { const launched = fakeContext(); const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', + platform: 'darwin', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), }); - const lease = await manager.getPage({ profileId: 'default', session: 'site:x:uuid', surface: 'adapter', siteSession: 'ephemeral' }); - await manager.release({ profileId: 'default', session: 'site:x:uuid', surface: 'adapter' }); + const key = { profileId: 'default', session: 'session_default', sessionId: 'session_default', surface: 'adapter' as const, siteSession: 'ephemeral' as const, adapterSite: 'github', runId: 'run_a' }; + const lease = await manager.getPage(key); + await manager.release(key); expect(lease.page.close).toHaveBeenCalled(); }); + it('releases only the owning ephemeral adapter site and run', async () => { + const launched = fakeContext(); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + platform: 'darwin', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + const base = { profileId: 'default', session: 'session_default', sessionId: 'session_default', surface: 'adapter' as const, siteSession: 'ephemeral' as const }; + const github = { ...base, adapterSite: 'github', runId: 'run_a' }; + const linkedin = { ...base, adapterSite: 'linkedin', runId: 'run_b' }; + const githubLease = await manager.getPage(github); + const linkedinLease = await manager.getPage(linkedin); + + await manager.release(github); + + expect(githubLease.page.close).toHaveBeenCalledOnce(); + expect(linkedinLease.page.close).not.toHaveBeenCalled(); + expect((await manager.getPage(linkedin)).page).toBe(linkedinLease.page); + }); + + it('keeps persistent adapter pages tracked when release is requested', async () => { + const launched = fakeContext(); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + const key = { profileId: 'default', session: 'session_default', sessionId: 'session_default', surface: 'adapter' as const, siteSession: 'persistent' as const, adapterSite: 'github', runId: 'run_a' }; + const lease = await manager.getPage(key); + + await manager.release(key); + + expect(lease.page.close).not.toHaveBeenCalled(); + await expect(manager.listPages(key)).resolves.toHaveLength(1); + expect((await manager.getPage(key)).page).toBe(lease.page); + }); + it('closes non-persistent leases when their idle timeout expires', async () => { vi.useFakeTimers(); const launched = fakeContext(); const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', + platform: 'darwin', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), }); const lease = await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser', idleTimeout: 25 }); @@ -603,7 +1072,7 @@ describe('CloakSessionManager', () => { await vi.advanceTimersByTimeAsync(1); expect(lease.page.close).toHaveBeenCalled(); - expect(await manager.listPages({ profileId: 'default' })).toEqual([]); + expect(await manager.listPages({ profileId: 'default', session: 'work' })).toEqual([]); }); it('refreshes an idle timeout when a lease is reused', async () => { @@ -611,6 +1080,7 @@ describe('CloakSessionManager', () => { const launched = fakeContext(); const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', + platform: 'darwin', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), }); const first = await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser', idleTimeout: 25 }); @@ -637,7 +1107,7 @@ describe('CloakSessionManager', () => { await vi.advanceTimersByTimeAsync(25); expect(lease.page.close).not.toHaveBeenCalled(); - expect(await manager.listPages({ profileId: 'default' })).toHaveLength(1); + expect(await manager.listPages({ profileId: 'default', session: 'site:x:uuid' })).toHaveLength(1); }); it('launches a preferred profile when no Cloak profile is active', async () => { @@ -661,6 +1131,59 @@ describe('CloakSessionManager', () => { expect(launchPersistentContext.mock.calls[0][0].userDataDir).toBe(expectedProfileDir('profile-default')); }); + it('retries action navigation once after a closed-context failure', async () => { + const first = fakeContext(); + first.page.goto.mockRejectedValue(new Error('Target page, context or browser has been closed')); + first.context.newPage.mockResolvedValue(first.page); + const replacement = fakeContext(); + replacement.context.newPage.mockResolvedValue(replacement.page); + const launchPersistentContext = vi.fn() + .mockResolvedValueOnce(first.context) + .mockResolvedValueOnce(replacement.context); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + launchPersistentContext, + }); + + const result = await dispatchCloakAction(manager, { + id: 'cmd-retry-navigation', + action: 'navigate', + session: 'work', + surface: 'browser', + url: 'https://example.com/', + profileId: 'default', + }); + + expect(result).toMatchObject({ ok: true }); + expect(first.page.goto).toHaveBeenCalledTimes(1); + expect(launchPersistentContext).toHaveBeenCalledTimes(2); + expect(replacement.page.goto).toHaveBeenCalledWith('https://example.com/', { waitUntil: 'load' }); + }); + + it('does not invalidate a replacement runtime when stale navigation fails', async () => { + const first = fakeContext(); + first.context.newPage.mockResolvedValue(first.page); + let rejectNavigation!: (error: Error) => void; + first.page.goto.mockImplementationOnce(() => new Promise((_, reject) => { rejectNavigation = reject; })); + const replacement = fakeContext(); + replacement.context.newPage.mockResolvedValue(replacement.page); + const launchPersistentContext = vi.fn() + .mockResolvedValueOnce(first.context) + .mockResolvedValueOnce(replacement.context); + const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext }); + const key = { profileId: 'default', session: 'work', sessionId: 'session_a', surface: 'browser' as const }; + await manager.getPage(key); + const navigation = manager.navigatePage(key, 'https://example.com/', 'load'); + await vi.waitFor(() => expect(first.page.goto).toHaveBeenCalledTimes(1)); + first.context.emit('close'); + const replacementLease = await manager.getPage(key); + rejectNavigation(new Error('Target page, context or browser has been closed')); + + await expect(navigation).resolves.toMatchObject({ context: replacement.context }); + expect(replacementLease.context).toBe(replacement.context); + expect(launchPersistentContext).toHaveBeenCalledTimes(2); + }); + it('falls back to the only active profile when the preferred profile is stale', async () => { const launched = fakeContext(); const launchPersistentContext = vi.fn().mockResolvedValue(launched.context); @@ -731,4 +1254,262 @@ describe('CloakSessionManager', () => { }); expect(launchPersistentContext).toHaveBeenCalledTimes(2); }); + + it('does not publish a runtime until its hidden keeper exists', async () => { + const launched = fakeContext(); + const send = launched.cdp.send.getMockImplementation()!; + let resolveAnchor!: () => void; + launched.cdp.send.mockImplementationOnce(() => new Promise((resolve) => { + resolveAnchor = () => resolve({ targetId: 'anchor-target' }); + })); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + platform: 'darwin', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + + const pending = manager.getPage({ profileId: 'work', session: 'session_a', surface: 'browser' }); + await vi.waitFor(() => expect(launched.cdp.send).toHaveBeenCalledWith('Target.createTarget', { + url: 'about:blank', + hidden: true, + background: true, + })); + expect(manager.activeProfileIds()).toEqual([]); + + resolveAnchor(); + launched.cdp.send.mockImplementation(send); + await pending; + expect(manager.activeProfileIds()).toEqual(['work']); + }); + + it('keeps an empty profile warm for sixty seconds before closing it', async () => { + vi.useFakeTimers(); + const launched = fakeContext(); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + platform: 'linux', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + await manager.getPage({ profileId: 'work', session: 'session_a', surface: 'browser' }); + await manager.closeSession('work', 'session_a'); + + await vi.advanceTimersByTimeAsync(59_999); + expect(manager.activeProfileIds()).toEqual(['work']); + expect(launched.context.close).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(manager.activeProfileIds()).toEqual([]); + expect(launched.context.close).toHaveBeenCalledOnce(); + }); + + it('fences a launch that finishes after shutdown starts', async () => { + const launched = fakeContext(); + let resolveLaunch!: (context: BrowserContext) => void; + const launchPersistentContext = vi.fn(() => new Promise((resolve) => { + resolveLaunch = resolve; + })); + const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext }); + + const pending = manager.getPage({ profileId: 'work', session: 'session_a', surface: 'browser' }); + await vi.waitFor(() => expect(launchPersistentContext).toHaveBeenCalledOnce()); + const shutdown = manager.shutdown(); + resolveLaunch(launched.context as unknown as BrowserContext); + + await shutdown; + await expect(pending).rejects.toMatchObject({ code: 'DAEMON_SHUTTING_DOWN' }); + expect(launched.context.close).toHaveBeenCalledOnce(); + expect(manager.activeProfileIds()).toEqual([]); + await expect(manager.getPage({ profileId: 'work', session: 'session_b', surface: 'browser' })) + .rejects.toMatchObject({ code: 'DAEMON_SHUTTING_DOWN' }); + expect(launchPersistentContext).toHaveBeenCalledOnce(); + }); + + it('falls back to a parking keeper when macOS rejects the hidden target', async () => { + const launched = fakeContext(); + const send = launched.cdp.send.getMockImplementation()!; + launched.cdp.send.mockImplementation((method: string, params?: { hidden?: boolean }) => ( + method === 'Target.createTarget' && params?.hidden + ? Promise.reject(new Error('hidden targets unsupported')) + : send(method, params) + )); + const warn = vi.spyOn(log, 'warn').mockImplementation(() => {}); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + platform: 'darwin', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + + const lease = await manager.getPage({ profileId: 'work', session: 'session_a', surface: 'browser' }); + await manager.closeSession('work', 'session_a'); + + expect(manager.activeProfileIds()).toEqual(['work']); + expect(lease.page.goto).toHaveBeenLastCalledWith('about:blank', { waitUntil: 'load' }); + expect(lease.page.close).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledOnce(); + expect(launched.cdp.detach).not.toHaveBeenCalled(); + await manager.shutdown(); + expect(launched.cdp.detach).toHaveBeenCalledOnce(); + }); + + it('uses a parking keeper when the persistent context exposes no browser', async () => { + const launched = fakeContext(); + launched.context.browser.mockReturnValue(null); + const warn = vi.spyOn(log, 'warn').mockImplementation(() => {}); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + platform: 'darwin', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + + const lease = await manager.getPage({ profileId: 'work', session: 'session_a', surface: 'browser' }); + expect(lease.context).toBe(launched.context); + expect(manager.activeProfileIds()).toEqual(['work']); + expect(warn).toHaveBeenCalledOnce(); + }); + + it.each(['linux', 'win32'] as const)('reuses a warm %s profile and replaces its parking page on the next Session', async (platform) => { + vi.useFakeTimers(); + const launched = fakeContext(); + const launchPersistentContext = vi.fn().mockResolvedValue(launched.context); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + platform, + launchPersistentContext, + }); + const first = await manager.getPage({ profileId: 'work', session: 'session_a', surface: 'browser' }); + await manager.closeSession('work', 'session_a'); + await vi.advanceTimersByTimeAsync(59_999); + + const second = await manager.runWithProfileActivity('work', () => ( + manager.getPage({ profileId: 'work', session: 'session_b', surface: 'browser' }) + )); + + expect(second.context).toBe(first.context); + expect(second.page).not.toBe(first.page); + expect(first.page.close).toHaveBeenCalledOnce(); + expect(await manager.listPages({ profileId: 'work', session: 'session_a' })).toEqual([]); + expect(await manager.listPages({ profileId: 'work', session: 'session_b' })).toHaveLength(1); + expect(launchPersistentContext).toHaveBeenCalledOnce(); + }); + + it('rechecks an empty profile after its active handoff expires', async () => { + vi.useFakeTimers(); + let handoffActive = true; + const launched = fakeContext(); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + hasActiveHandoff: () => handoffActive, + }); + await manager.getPage({ profileId: 'work', session: 'session_a', surface: 'browser' }); + await manager.closeSession('work', 'session_a'); + + await vi.advanceTimersByTimeAsync(60_000); + expect(manager.activeProfileIds()).toEqual(['work']); + handoffActive = false; + await vi.advanceTimersByTimeAsync(60_000); + + expect(manager.activeProfileIds()).toEqual(['work']); + await vi.advanceTimersByTimeAsync(60_000); + + expect(manager.activeProfileIds()).toEqual([]); + expect(launched.context.close).toHaveBeenCalledOnce(); + }); + + it('starts a fresh idle grace when handoff expiry is observed near a wakeup boundary', async () => { + vi.useFakeTimers(); + let handoffActive = true; + const launched = fakeContext(); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + hasActiveHandoff: () => handoffActive, + }); + await manager.getPage({ profileId: 'work', session: 'session_a', surface: 'browser' }); + await manager.closeSession('work', 'session_a'); + + await vi.advanceTimersByTimeAsync(119_999); + handoffActive = false; + await vi.advanceTimersByTimeAsync(1); + expect(manager.activeProfileIds()).toEqual(['work']); + + await vi.advanceTimersByTimeAsync(59_999); + expect(manager.activeProfileIds()).toEqual(['work']); + await vi.advanceTimersByTimeAsync(1); + + expect(manager.activeProfileIds()).toEqual([]); + expect(launched.context.close).toHaveBeenCalledOnce(); + }); + + it('unrefs the profile idle timer', async () => { + const timer = setTimeout(() => {}, 0); + const unref = vi.spyOn(Object.getPrototypeOf(timer), 'unref'); + clearTimeout(timer); + const launched = fakeContext(); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + await manager.getPage({ profileId: 'work', session: 'session_a', surface: 'browser' }); + + await manager.closeSession('work', 'session_a'); + + expect(unref).toHaveBeenCalled(); + await manager.shutdown(); + }); + + it('repairs one anchor for duplicate destruction and page-close notifications', async () => { + const launched = fakeContext(); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + platform: 'darwin', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + await manager.getPage({ profileId: 'work', session: 'session_a', surface: 'browser' }); + const anchor = launched.backgroundPages[0]; + const anchorTargetId = launched.targetIdFor(anchor)!; + launched.emitPage(anchor); + await vi.waitFor(() => expect(launched.pageListenerCount(anchor, 'close')).toBeGreaterThan(0)); + + launched.emitCdp('Target.targetDestroyed', { targetId: anchorTargetId }); + await anchor.close(); + await vi.waitFor(() => expect(launched.cdp.send.mock.calls.filter(([, params]) => ( + (params as { hidden?: boolean })?.hidden + ))).toHaveLength(2)); + await Promise.resolve(); + + expect(launched.cdp.send.mock.calls.filter(([, params]) => ( + (params as { hidden?: boolean })?.hidden + ))).toHaveLength(2); + }); + + it('recovers one timed-out idle close before launching one replacement', async () => { + vi.useFakeTimers(); + const first = fakeContext(); + first.context.close.mockImplementation(() => new Promise(() => {})); + const replacement = fakeContext(); + const launchPersistentContext = vi.fn() + .mockResolvedValueOnce(first.context) + .mockResolvedValueOnce(replacement.context); + const recoverLockedProfile = vi.fn().mockResolvedValue(true); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + platform: 'linux', + launchPersistentContext, + recoverLockedProfile, + }); + await manager.getPage({ profileId: 'work', session: 'session_a', surface: 'browser' }); + await manager.closeSession('work', 'session_a'); + await vi.advanceTimersByTimeAsync(60_000); + + const one = manager.getPage({ profileId: 'work', session: 'session_b', surface: 'browser' }); + const two = manager.getPage({ profileId: 'work', session: 'session_c', surface: 'browser' }); + await vi.advanceTimersByTimeAsync(3_000); + const leases = await Promise.all([one, two]); + + expect(recoverLockedProfile).toHaveBeenCalledOnce(); + expect(leases[0].context).toBe(replacement.context); + expect(leases[1].context).toBe(replacement.context); + expect(launchPersistentContext).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/browser/runtime/local-cloak/session-manager.ts b/src/browser/runtime/local-cloak/session-manager.ts index 644fb679..98400815 100644 --- a/src/browser/runtime/local-cloak/session-manager.ts +++ b/src/browser/runtime/local-cloak/session-manager.ts @@ -1,16 +1,21 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { execFile } from 'node:child_process'; -import type { BrowserContext, Page as PlaywrightPage } from 'playwright-core'; +import type { Browser, BrowserContext, CDPSession, Page as PlaywrightPage } from 'playwright-core'; import { launchPersistentContext as cloakLaunchPersistentContext } from 'cloakbrowser'; import type { BrowserSurface, BrowserWindowMode, SiteSessionMode } from '../../protocol.js'; import { activateDarwinBackgroundContext, launchDarwinBackgroundPersistentContext } from './darwin-background-launch.js'; import { normalizeProfileId, resolveCloakProfileDir } from './profiles.js'; import { CloakNetworkCapture } from './network.js'; import { findPackageRoot } from '../../../package-paths.js'; +import { findExactCloakProfileProcesses } from './process-matcher.js'; +import { log } from '../../../logger.js'; +import { CliError, EXIT_CODES } from '../../../errors.js'; const UNRESOLVED = Symbol('unresolved'); +const TARGET_PAGE_MATCH_TIMEOUT_MS = 1_000; +export const PROFILE_IDLE_TIMEOUT_MS = 60_000; +export const PROFILE_CLOSE_TIMEOUT_MS = 3_000; let cachedCloakBrowserVersion: string | undefined | typeof UNRESOLVED = UNRESOLVED; /** @@ -42,6 +47,10 @@ export interface SessionKeyInput { session?: string; surface?: BrowserSurface; siteSession?: SiteSessionMode; + sessionKind?: 'explicit' | 'adapter-default'; + sessionId?: string; + adapterSite?: string; + runId?: string; idleTimeout?: number; windowMode?: BrowserWindowMode; /** Discard the existing leased page (if any) and create a new one under the same lease. */ @@ -51,9 +60,14 @@ export interface SessionKeyInput { type PageEntry = { page: PlaywrightPage; pageId: string; + targetId: string; + leaseKey: string; + sessionId?: string; session: string; surface: BrowserSurface; siteSession?: SiteSessionMode; + sessionKind?: 'explicit' | 'adapter-default'; + adapterSite?: string; idleTimeout?: number; idleTimer?: ReturnType; }; @@ -74,15 +88,56 @@ export interface CloakTabInfo { url: string; profileId: string; session: string; + sessionId: string; surface: BrowserSurface; selected: boolean; } interface ProfileRuntime { + profileId: string; context: BrowserContext; + cdp?: CDPSession; + sessions: Map; + windowOwners: Map; + targetPages: Map; + userDataDir: string; + anchorTargetId?: string; + parkingPage?: PlaywrightPage; + useParkingKeeper: boolean; + keeperWarningLogged: boolean; + activeCommands: number; + idleTimer?: ReturnType; + handoffTimer?: ReturnType; + closing: boolean; + disposed: boolean; + lastSeenAt: number; +} + +interface SessionRuntime { + id: string; + windowIds: Set; pages: Map; selectedPageId?: string; - lastSeenAt: number; +} + +export interface BrowserRunSessionScope { + browser: Browser; + context: BrowserContext; + page: PlaywrightPage; + pages(): readonly PlaywrightPage[]; + createPage(): Promise; + onPage(listener: (page: PlaywrightPage) => void): () => void; +} + +export class SessionWindowConflictError extends CliError { + constructor(pageId: string, sessionId: string, owner?: string) { + super( + 'SESSION_WINDOW_CONFLICT', + `Page ${pageId} is in a window owned by Session ${owner ?? 'unknown'}, not ${sessionId}.`, + undefined, + EXIT_CODES.TEMPFAIL, + ); + } } export interface CloakSessionManagerOptions { @@ -92,6 +147,7 @@ export interface CloakSessionManagerOptions { activateBackgroundContext?: typeof activateDarwinBackgroundContext; recoverLockedProfile?: RecoverLockedProfile; platform?: NodeJS.Platform; + hasActiveHandoff?: (profileId: string) => boolean; } let pageCounter = 0; @@ -100,6 +156,13 @@ export function resolveLeaseKey(input: SessionKeyInput): string { const surface = input.surface === 'adapter' ? 'adapter' : 'browser'; const session = input.session?.trim(); if (!session) throw new Error('Browser session is required.'); + const sessionId = input.sessionId?.trim() || session; + if (surface === 'adapter' && input.siteSession === 'persistent' && input.adapterSite) { + return `${sessionId}\u0000site:${input.adapterSite}`; + } + if (surface === 'adapter' && input.runId) { + return `${sessionId}\u0000ephemeral:${input.adapterSite ?? 'browser'}:${input.runId}`; + } return `${surface}\u0000${encodeURIComponent(session)}`; } @@ -112,6 +175,14 @@ function isClosedContextError(error: unknown): boolean { return /Target page, context or browser has been closed/i.test(message); } +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function daemonShuttingDownError(): Error & { code: 'DAEMON_SHUTTING_DOWN' } { + return Object.assign(new Error('The browser daemon is shutting down.'), { code: 'DAEMON_SHUTTING_DOWN' as const }); +} + export class CloakSessionManager { readonly networkCapture = new CloakNetworkCapture(); @@ -120,9 +191,23 @@ export class CloakSessionManager { private readonly activateBackgroundContext: typeof activateDarwinBackgroundContext; private readonly platform: NodeJS.Platform; private readonly recoverLockedProfile: RecoverLockedProfile; + private readonly hasActiveHandoff: (profileId: string) => boolean; private readonly profiles = new Map(); private readonly profileLaunches = new Map>(); + private readonly profileLifecycleQueues = new Map>(); + private readonly profileActivities = new Map(); private readonly pageCreationQueues = new Map>(); + private readonly pageTargetIds = new WeakMap(); + private readonly pageTargetIdPromises = new WeakMap>(); + private readonly pageCdpSessions = new WeakMap(); + private readonly pendingTargetPages = new WeakMap>(); + private readonly targetPageWaiters = new WeakMap; + }>>(); + private readonly sessionPageListeners = new WeakMap void>>(); + private shuttingDown = false; constructor(private readonly opts: CloakSessionManagerOptions = {}) { this.launchPersistentContext = opts.launchPersistentContext ?? cloakLaunchPersistentContext; @@ -130,6 +215,7 @@ export class CloakSessionManager { this.activateBackgroundContext = opts.activateBackgroundContext ?? activateDarwinBackgroundContext; this.platform = opts.platform ?? process.platform; this.recoverLockedProfile = opts.recoverLockedProfile ?? recoverLockedCloakProfile; + this.hasActiveHandoff = opts.hasActiveHandoff ?? (() => false); } profileStatuses() { @@ -146,69 +232,101 @@ export class CloakSessionManager { return [...this.profiles.keys()]; } + async runWithProfileActivity(profileIdInput: string | undefined, task: () => Promise): Promise { + const profileId = normalizeProfileId(profileIdInput); + await this.withProfileLifecycleLock(profileId, async () => { + this.assertRunning(); + const count = (this.profileActivities.get(profileId) ?? 0) + 1; + this.profileActivities.set(profileId, count); + const runtime = this.profiles.get(profileId); + if (runtime) { + runtime.activeCommands = count; + this.cancelProfileIdle(runtime); + } + }); + try { + return await task(); + } finally { + await this.withProfileLifecycleLock(profileId, async () => { + const count = Math.max(0, (this.profileActivities.get(profileId) ?? 1) - 1); + if (count === 0) this.profileActivities.delete(profileId); + else this.profileActivities.set(profileId, count); + const runtime = this.profiles.get(profileId); + if (runtime) { + runtime.activeCommands = count; + this.scheduleProfileIdle(profileId, runtime); + } + }); + } + } + async getPage(input: SessionKeyInput): Promise { const profileId = normalizeProfileId(input.profileId); const session = requireSession(input.session); + const sessionId = requireSessionId(input); const surface = normalizeSurface(input.surface); const leaseKey = resolveLeaseKey(input); const freshPage = input.freshPage === true; return this.withPageCreationLock(profileId, async () => { const runtime = await this.getProfileRuntime(profileId, input.windowMode); - const existing = runtime.pages.get(leaseKey); + const sessionRuntime = this.getSessionRuntime(runtime, sessionId); + const existing = sessionRuntime.pages.get(leaseKey); if (existing && !pageIsClosed(existing.page) && !freshPage) { + await this.assertOwnedWindow(runtime, sessionId, existing); runtime.lastSeenAt = Date.now(); existing.idleTimeout = input.idleTimeout; - this.refreshIdleTimer(runtime, leaseKey, existing); + this.refreshIdleTimer(runtime, sessionRuntime, leaseKey, existing); return { profileId, leaseKey, context: runtime.context, page: existing.page, pageId: existing.pageId }; } - if (existing && freshPage) { - runtime.pages.delete(leaseKey); - this.clearIdleTimer(existing); - if (runtime.selectedPageId === existing.pageId) runtime.selectedPageId = undefined; - if (!pageIsClosed(existing.page)) await existing.page.close().catch(() => {}); - } - - return this.createPageWithRecoveryAttempt( - profileId, - input.windowMode, - (candidate) => { - const existingPages = candidate.context.pages(); - // freshPage must never adopt a leftover tab — its whole point is a clean DOM. - return !freshPage && existingPages[0] && candidate.pages.size === 0 - ? existingPages[0] - : this.createPage(candidate.context, input.windowMode); - }, - (candidate, page) => { - const pageId = nextPageId(); - const entry: PageEntry = { page, pageId, session, surface, siteSession: input.siteSession, idleTimeout: input.idleTimeout }; - candidate.pages.set(leaseKey, entry); - this.refreshIdleTimer(candidate, leaseKey, entry); - candidate.selectedPageId = pageId; - candidate.lastSeenAt = Date.now(); - return { profileId, leaseKey, context: candidate.context, page, pageId }; - }, - ); + const acquired = await this.acquireSessionPage(profileId, sessionId, input.windowMode); + const entry = await this.registerOwnedPage(acquired.runtime, acquired.session, acquired.page, { + leaseKey, + session, + surface, + siteSession: input.siteSession, + sessionKind: input.sessionKind, + adapterSite: input.adapterSite, + idleTimeout: input.idleTimeout, + }); + if (existing && freshPage && existing !== entry) await this.removeEntry(acquired.runtime, sessionRuntime, existing, true); + this.selectEntry(acquired.session, entry); + acquired.runtime.lastSeenAt = Date.now(); + return { profileId, leaseKey, context: acquired.runtime.context, page: entry.page, pageId: entry.pageId }; }); } - findPage(input: SessionKeyInput): CloakPageLease | null { + async findPage(input: SessionKeyInput): Promise { const profileId = normalizeProfileId(input.profileId); + const sessionId = requireSessionId(input); const leaseKey = resolveLeaseKey(input); const runtime = this.profiles.get(profileId); - const entry = runtime?.pages.get(leaseKey); - if (!runtime || !entry || pageIsClosed(entry.page)) return null; + const sessionRuntime = runtime?.sessions.get(sessionId); + const entry = sessionRuntime?.pages.get(leaseKey); + if (!runtime || !sessionRuntime || !entry || pageIsClosed(entry.page)) return null; + await this.assertOwnedWindow(runtime, sessionId, entry); runtime.lastSeenAt = Date.now(); entry.idleTimeout = input.idleTimeout; - this.refreshIdleTimer(runtime, leaseKey, entry); + this.refreshIdleTimer(runtime, sessionRuntime, leaseKey, entry); return { profileId, leaseKey, context: runtime.context, page: entry.page, pageId: entry.pageId }; } - findPageById(pageId: string, opts: Pick = {}): CloakPageLease | null { + async findPageById(pageId: string, opts: Pick): Promise { + const expectedProfileId = normalizeProfileId(opts.profileId); + const sessionId = requireSessionId(opts); + const expectedSurface = opts.surface ? normalizeSurface(opts.surface) : undefined; for (const [profileId, runtime] of this.profiles.entries()) { - for (const [leaseKey, entry] of runtime.pages.entries()) { - if (entry.pageId === pageId && !pageIsClosed(entry.page)) { + if (expectedProfileId !== profileId) continue; + const sessionRuntime = runtime.sessions.get(sessionId); + if (!sessionRuntime) return null; + for (const [leaseKey, entry] of sessionRuntime.pages.entries()) { + if ( + entry.pageId === pageId + && !pageIsClosed(entry.page) + && (!expectedSurface || entry.surface === expectedSurface) + ) { + await this.assertOwnedWindow(runtime, sessionId, entry); entry.idleTimeout = opts.idleTimeout; - this.refreshIdleTimer(runtime, leaseKey, entry); + this.refreshIdleTimer(runtime, sessionRuntime, leaseKey, entry); return { profileId, leaseKey, context: runtime.context, page: entry.page, pageId: entry.pageId }; } } @@ -216,11 +334,17 @@ export class CloakSessionManager { return null; } - profileIdForPage(pageId: string): string | null { + pageOwner(pageId: string): { profileId: string; session: string; surface: BrowserSurface; sessionKind?: 'explicit' | 'adapter-default'; adapterSite?: string } | null { for (const [profileId, runtime] of this.profiles.entries()) { - for (const entry of runtime.pages.values()) { + for (const entry of runtime.targetPages.values()) { if (entry.pageId === pageId && !pageIsClosed(entry.page)) { - return profileId; + return { + profileId, + session: entry.session, + surface: entry.surface, + sessionKind: entry.sessionKind, + adapterSite: entry.adapterSite, + }; } } } @@ -229,34 +353,53 @@ export class CloakSessionManager { pageIdFor(page: PlaywrightPage): string | undefined { for (const runtime of this.profiles.values()) { - for (const entry of runtime.pages.values()) { + for (const entry of runtime.targetPages.values()) { if (entry.page === page) return entry.pageId; } } return undefined; } - registerPage(input: SessionKeyInput, page: PlaywrightPage): string { + async browserRunScope(input: SessionKeyInput, page: PlaywrightPage): Promise { const profileId = normalizeProfileId(input.profileId); - const session = requireSession(input.session); - const surface = normalizeSurface(input.surface); + const sessionId = requireSessionId(input); const runtime = this.profiles.get(profileId); - if (!runtime) throw new Error(`Profile ${profileId} is not running`); - const pageId = nextPageId(); - const leaseKey = `${resolveLeaseKey(input)}\u0000${pageId}`; - const entry: PageEntry = { page, pageId, session, surface, siteSession: input.siteSession, idleTimeout: input.idleTimeout }; - runtime.pages.set(leaseKey, entry); - this.refreshIdleTimer(runtime, leaseKey, entry); - runtime.selectedPageId = pageId; - runtime.lastSeenAt = Date.now(); - return pageId; + const sessionRuntime = runtime?.sessions.get(sessionId); + const entry = runtime && [...runtime.targetPages.values()].find(candidate => candidate.page === page); + if (!runtime || !sessionRuntime || !entry || entry.sessionId !== sessionId) { + throw new Error('Browser-run page is outside the selected Session.'); + } + await Promise.all(this.openEntries(sessionRuntime).map(([, candidate]) => ( + this.assertOwnedWindow(runtime, sessionId, candidate) + ))); + const browser = runtime.context.browser(); + if (!browser) throw new Error('The selected browser context is not attached to a browser.'); + return { + browser, + context: runtime.context, + page, + pages: () => this.openEntries(sessionRuntime).map(([, candidate]) => candidate.page), + createPage: async () => (await this.newPage(input)).page, + onPage: (listener) => { + const listeners = this.sessionPageListeners.get(sessionRuntime) ?? new Set(); + listeners.add(listener); + this.sessionPageListeners.set(sessionRuntime, listeners); + return () => listeners.delete(listener); + }, + }; } - async listPages(input: Pick): Promise { + async listPages(input: Pick): Promise { const profileId = normalizeProfileId(input.profileId); + const sessionId = requireSessionId(input); + const surface = input.surface ? normalizeSurface(input.surface) : undefined; const runtime = this.profiles.get(profileId); if (!runtime) return []; - const entries = this.openEntries(runtime); + const sessionRuntime = runtime.sessions.get(sessionId); + if (!sessionRuntime) return []; + const entries = this.openEntries(sessionRuntime) + .filter(([, entry]) => !surface || entry.surface === surface); + await Promise.all(entries.map(([, entry]) => this.assertOwnedWindow(runtime, sessionId, entry))); return Promise.all(entries.map(async ([, entry], index) => ({ id: entry.pageId, page: entry.pageId, @@ -265,25 +408,38 @@ export class CloakSessionManager { url: entry.page.url(), profileId, session: entry.session, + sessionId, surface: entry.surface, - selected: runtime.selectedPageId === entry.pageId, + selected: sessionRuntime.selectedPageId === entry.pageId, }))); } async newPage(input: SessionKeyInput & { url?: string }): Promise { + return this.newPageAttempt(input, 0); + } + + async navigatePage(input: SessionKeyInput, url: string, waitUntil: 'load' | 'commit'): Promise { + return this.navigatePageAttempt(input, url, waitUntil, 0); + } + + private async newPageAttempt(input: SessionKeyInput & { url?: string }, attempt: number): Promise { const profileId = normalizeProfileId(input.profileId); const session = requireSession(input.session); + const sessionId = requireSessionId(input); const surface = normalizeSurface(input.surface); - const acquired = await this.createPageWithRecovery( - profileId, - input.windowMode, - (candidate) => this.createPage(candidate.context, input.windowMode), - (runtime, page) => ({ runtime, page }), - ); + const acquired = await this.withPageCreationLock(profileId, async () => { + const result = await this.acquireSessionPage(profileId, sessionId, input.windowMode); + return { runtime: result.runtime, sessionRuntime: result.session, page: result.page }; + }); if (input.url) { try { await acquired.page.goto(input.url, { waitUntil: 'load' }); } catch (error) { + if (attempt === 0 && isClosedContextError(error)) { + this.invalidateProfileRuntime(profileId, acquired.runtime); + if (!pageIsClosed(acquired.page)) await acquired.page.close().catch(() => {}); + return this.newPageAttempt(input, 1); + } if (!pageIsClosed(acquired.page)) await acquired.page.close().catch(() => {}); throw error; } @@ -292,131 +448,242 @@ export class CloakSessionManager { if (!pageIsClosed(acquired.page)) await acquired.page.close().catch(() => {}); throw new Error('Target page, context or browser has been closed'); } - const pageId = nextPageId(); - const leaseKey = `${resolveLeaseKey(input)}\u0000${pageId}`; - const entry: PageEntry = { - page: acquired.page, - pageId, + const entry = await this.registerOwnedPage(acquired.runtime, acquired.sessionRuntime, acquired.page, { session, surface, siteSession: input.siteSession, + sessionKind: input.sessionKind, + adapterSite: input.adapterSite, idleTimeout: input.idleTimeout, - }; - acquired.runtime.pages.set(leaseKey, entry); - this.refreshIdleTimer(acquired.runtime, leaseKey, entry); + }); + const leaseKey = entry.leaseKey; + this.refreshIdleTimer(acquired.runtime, acquired.sessionRuntime, leaseKey, entry); + this.selectEntry(acquired.sessionRuntime, entry); acquired.runtime.lastSeenAt = Date.now(); - return { profileId, leaseKey, context: acquired.runtime.context, page: acquired.page, pageId }; + return { profileId, leaseKey, context: acquired.runtime.context, page: acquired.page, pageId: entry.pageId }; + } + + private async navigatePageAttempt(input: SessionKeyInput, url: string, waitUntil: 'load' | 'commit', attempt: number): Promise { + const profileId = normalizeProfileId(input.profileId); + const lease = await this.getPage(input); + const runtime = this.profiles.get(profileId); + try { + await lease.page.goto(url, { waitUntil }); + return lease; + } catch (error) { + if (attempt !== 0 || !isClosedContextError(error)) throw error; + if (runtime?.context === lease.context) this.invalidateProfileRuntime(profileId, runtime); + if (!pageIsClosed(lease.page)) await lease.page.close().catch(() => {}); + return this.navigatePageAttempt(input, url, waitUntil, 1); + } } - async selectPage(input: Pick & { pageId?: string; index?: number }): Promise { + async selectPage(input: Pick & { pageId?: string; index?: number }): Promise { const profileId = normalizeProfileId(input.profileId); + const sessionId = requireSessionId(input); const runtime = this.profiles.get(profileId); if (!runtime) return null; - const match = input.pageId ? this.findEntryByPageId(runtime, input.pageId) : this.openEntries(runtime)[input.index ?? -1]; + const sessionRuntime = runtime.sessions.get(sessionId); + if (!sessionRuntime) return null; + const candidates = this.sessionEntries(sessionRuntime, input); + const match = input.pageId ? candidates.find(([, entry]) => entry.pageId === input.pageId) : candidates[input.index ?? -1]; if (!match) return null; const [leaseKey, entry] = match; + await this.assertOwnedWindow(runtime, sessionId, entry); if (input.windowMode !== 'background') { await entry.page.bringToFront?.().catch(() => {}); await this.activateBackgroundContext(runtime.context); } - runtime.selectedPageId = entry.pageId; + this.selectEntry(sessionRuntime, entry); runtime.lastSeenAt = Date.now(); return { profileId, leaseKey, context: runtime.context, page: entry.page, pageId: entry.pageId }; } + async foregroundSession(profileIdInput: string, sessionId: string): Promise { + const profileId = normalizeProfileId(profileIdInput); + const runtime = this.profiles.get(profileId); + const session = runtime?.sessions.get(sessionId); + if (!runtime || !session) return false; + const entries = this.openEntries(session); + const match = entries.find(([, entry]) => entry.pageId === session.selectedPageId) ?? entries[0]; + if (!match) return false; + const entry = match[1]; + await this.assertOwnedWindow(runtime, sessionId, entry); + await entry.page.bringToFront?.().catch(() => {}); + await this.activateBackgroundContext(runtime.context); + this.selectEntry(session, entry); + runtime.lastSeenAt = Date.now(); + return true; + } + async bindPage(input: SessionKeyInput & { pageId?: string; index?: number }): Promise { const profileId = normalizeProfileId(input.profileId); const session = requireSession(input.session); + const sessionId = requireSessionId(input); const surface = normalizeSurface(input.surface); const runtime = this.profiles.get(profileId); if (!runtime) return null; - - const match = input.pageId ? this.findEntryByPageId(runtime, input.pageId) : this.openEntries(runtime)[input.index ?? -1]; + const existingSession = runtime.sessions.get(sessionId); + let match = input.pageId + ? this.findEntryByPageId(runtime, input.pageId) + : existingSession && this.openEntries(existingSession)[input.index ?? -1]; + if (!match && input.index !== undefined) { + const candidates: PlaywrightPage[] = []; + for (const candidate of runtime.context.pages()) { + if (pageIsClosed(candidate) || candidate === runtime.parkingPage) continue; + if (await this.targetIdForPage(runtime, candidate) === runtime.anchorTargetId) continue; + candidates.push(candidate); + } + const page = candidates[input.index]; + if (page) { + const targetId = await this.targetIdForPage(runtime, page); + const entry = runtime.targetPages.get(targetId) ?? { + page, + pageId: nextPageId(), + targetId, + leaseKey: `unowned\u0000${targetId}`, + session: '', + surface, + }; + if (!runtime.targetPages.has(targetId)) { + runtime.targetPages.set(targetId, entry); + this.attachPageLifecycle(runtime, entry); + } + match = [entry.leaseKey, entry]; + } + } if (!match) return null; - const [sourceKey, entry] = match; - const canonicalKey = resolveLeaseKey({ profileId, session, surface }); - const currentCanonical = runtime.pages.get(canonicalKey); + const entry = match[1]; + if (entry.sessionId && entry.sessionId !== sessionId) { + throw new SessionWindowConflictError(entry.pageId, sessionId, entry.sessionId); + } + const sessionRuntime = existingSession ?? this.getSessionRuntime(runtime, sessionId); + await this.assertBindableWindow(runtime, sessionRuntime, entry); + const sourceSession = entry.sessionId ? runtime.sessions.get(entry.sessionId) : undefined; + const sourceKey = entry.leaseKey; + const canonicalKey = resolveLeaseKey(input); + const currentCanonical = sessionRuntime.pages.get(canonicalKey); + + if (input.windowMode !== 'background') { + await entry.page.bringToFront?.().catch(() => {}); + await this.activateBackgroundContext(runtime.context); + } if (currentCanonical && currentCanonical !== entry && !pageIsClosed(currentCanonical.page)) { const preservedKey = `${canonicalKey}\u0000${currentCanonical.pageId}`; - runtime.pages.delete(canonicalKey); - runtime.pages.set(preservedKey, currentCanonical); - this.refreshIdleTimer(runtime, preservedKey, currentCanonical); + sessionRuntime.pages.delete(canonicalKey); + currentCanonical.leaseKey = preservedKey; + sessionRuntime.pages.set(preservedKey, currentCanonical); + this.refreshIdleTimer(runtime, sessionRuntime, preservedKey, currentCanonical); } - if (sourceKey !== canonicalKey) runtime.pages.delete(sourceKey); + sourceSession?.pages.delete(sourceKey); + entry.sessionId = sessionId; + entry.leaseKey = canonicalKey; entry.session = session; entry.surface = surface; entry.siteSession = input.siteSession; entry.idleTimeout = input.idleTimeout; - runtime.pages.set(canonicalKey, entry); - if (input.windowMode !== 'background') { - await entry.page.bringToFront?.().catch(() => {}); - await this.activateBackgroundContext(runtime.context); - } - this.refreshIdleTimer(runtime, canonicalKey, entry); - runtime.selectedPageId = entry.pageId; + sessionRuntime.pages.set(canonicalKey, entry); + this.refreshIdleTimer(runtime, sessionRuntime, canonicalKey, entry); + this.selectEntry(sessionRuntime, entry); runtime.lastSeenAt = Date.now(); return { profileId, leaseKey: canonicalKey, context: runtime.context, page: entry.page, pageId: entry.pageId }; } - async closePage(input: Pick & { pageId?: string; index?: number }): Promise { + async closePage(input: Pick & { pageId?: string; index?: number }): Promise { const profileId = normalizeProfileId(input.profileId); + const sessionId = requireSessionId(input); const runtime = this.profiles.get(profileId); if (!runtime) return null; - const match = input.pageId ? this.findEntryByPageId(runtime, input.pageId) : this.openEntries(runtime)[input.index ?? -1]; + const sessionRuntime = runtime.sessions.get(sessionId); + if (!sessionRuntime) return null; + const candidates = this.sessionEntries(sessionRuntime, input); + const match = input.pageId ? candidates.find(([, entry]) => entry.pageId === input.pageId) : candidates[input.index ?? -1]; if (!match) return null; - const [leaseKey, entry] = match; - runtime.pages.delete(leaseKey); - this.clearIdleTimer(entry); - if (runtime.selectedPageId === entry.pageId) runtime.selectedPageId = undefined; - if (!pageIsClosed(entry.page)) await entry.page.close().catch(() => {}); + const [, entry] = match; + await this.assertOwnedWindow(runtime, sessionId, entry); + await this.removeEntry(runtime, sessionRuntime, entry, true); runtime.lastSeenAt = Date.now(); return entry.pageId; } async release(input: SessionKeyInput): Promise { const profileId = normalizeProfileId(input.profileId); + const sessionId = requireSessionId(input); const runtime = this.profiles.get(profileId); if (!runtime) return; + const sessionRuntime = runtime.sessions.get(sessionId); + if (!sessionRuntime) return; const leaseKey = resolveLeaseKey(input); - const entries = this.openEntries(runtime) - .filter(([key, entry]) => key === leaseKey || ( - entry.session === requireSession(input.session) - && entry.surface === normalizeSurface(input.surface) - )); - for (const [key, entry] of entries) { - runtime.pages.delete(key); - this.clearIdleTimer(entry); - if (runtime.selectedPageId === entry.pageId) runtime.selectedPageId = undefined; - if (entry.siteSession !== 'persistent' && !pageIsClosed(entry.page)) { - await entry.page.close().catch(() => {}); - } + const surface = normalizeSurface(input.surface); + const entries = this.openEntries(sessionRuntime).filter(([key, entry]) => ( + surface === 'adapter' + ? key === leaseKey + : entry.session === requireSession(input.session) && entry.surface === surface + )); + await Promise.all(entries.map(([, entry]) => this.assertOwnedWindow(runtime, sessionId, entry))); + for (const [, entry] of entries) { + if (entry.siteSession === 'persistent') continue; + await this.removeEntry(runtime, sessionRuntime, entry, true); } } + hasSession(profileIdInput: string | undefined, sessionInput: string | undefined): boolean { + const profileId = normalizeProfileId(profileIdInput); + const session = requireSession(sessionInput); + const runtime = this.profiles.get(profileId); + return Boolean(runtime?.sessions.get(session) && this.openEntries(runtime.sessions.get(session)!).length > 0); + } + + async closeSession(profileIdInput: string | undefined, sessionInput: string | undefined): Promise { + const profileId = normalizeProfileId(profileIdInput); + const session = requireSession(sessionInput); + const runtime = this.profiles.get(profileId); + if (!runtime) return 0; + const sessionRuntime = runtime.sessions.get(session); + if (!sessionRuntime) return 0; + const entries = this.openEntries(sessionRuntime); + await Promise.all(entries.map(([, entry]) => this.assertOwnedWindow(runtime, session, entry))); + for (const [, entry] of entries) await this.removeEntry(runtime, sessionRuntime, entry, true); + if (entries.length > 0) runtime.lastSeenAt = Date.now(); + return entries.length; + } + async shutdown(): Promise { - for (const runtime of this.profiles.values()) { - for (const entry of runtime.pages.values()) this.clearIdleTimer(entry); - await runtime.context.close().catch(() => {}); + this.shuttingDown = true; + while (this.profileLaunches.size > 0) { + await Promise.allSettled([...this.profileLaunches.values()]); } + await Promise.all([...this.profiles.keys()].map(profileId => this.withProfileLifecycleLock(profileId, async () => { + const runtime = this.profiles.get(profileId); + if (!runtime) return; + this.profiles.delete(profileId); + runtime.closing = true; + await this.closeRuntime(runtime, false).catch(() => {}); + }))); this.profiles.clear(); + this.profileLaunches.clear(); + this.profileActivities.clear(); } private async getProfileRuntime(profileId: string, windowMode?: BrowserWindowMode): Promise { - const existing = this.profiles.get(profileId); - if (existing) return existing; - const pending = this.profileLaunches.get(profileId); - if (pending) return pending; - - const launch = this.launchProfileRuntime(profileId, windowMode); - this.profileLaunches.set(profileId, launch); - try { - return await launch; - } finally { - this.profileLaunches.delete(profileId); - } + return this.withProfileLifecycleLock(profileId, async () => { + this.assertRunning(); + const existing = this.profiles.get(profileId); + if (existing && !existing.closing) { + this.cancelProfileIdle(existing); + return existing; + } + const launch = this.launchProfileRuntime(profileId, windowMode); + this.profileLaunches.set(profileId, launch); + try { + return await launch; + } finally { + if (this.profileLaunches.get(profileId) === launch) this.profileLaunches.delete(profileId); + } + }); } private async launchProfileRuntime(profileId: string, windowMode?: BrowserWindowMode): Promise { @@ -437,62 +704,211 @@ export class CloakSessionManager { if (!isProfileAlreadyInUseError(err) || !(await this.recoverLockedProfile(userDataDir))) throw err; context = await launchPersistentContext(launchOptions); } - const runtime = { context, pages: new Map(), lastSeenAt: Date.now() }; + const browser = context.browser(); + let cdp: CDPSession | undefined; + let keeperError: unknown; + try { + cdp = await browser?.newBrowserCDPSession(); + } catch (error) { + keeperError = error; + } + const runtime: ProfileRuntime = { + profileId, + context, + cdp, + sessions: new Map(), + windowOwners: new Map(), + targetPages: new Map(), + userDataDir, + useParkingKeeper: this.platform !== 'darwin' || !cdp, + keeperWarningLogged: false, + activeCommands: this.profileActivities.get(profileId) ?? 0, + closing: false, + disposed: false, + lastSeenAt: Date.now(), + }; + this.pendingTargetPages.set(runtime, new Map()); + this.targetPageWaiters.set(runtime, new Map()); this.attachRuntimeLifecycle(profileId, runtime); + if (cdp) { + try { + runtime.anchorTargetId = (await cdp.send('Target.createTarget', { + url: 'about:blank', + hidden: true, + background: true, + }) as { targetId: string }).targetId; + } catch (error) { + this.warnKeeperFallback(profileId, runtime, error); + } + } else { + this.warnKeeperFallback(profileId, runtime, keeperError ?? new Error('browser connection unavailable')); + } + if (this.shuttingDown) { + runtime.closing = true; + await this.closeRuntime(runtime, false).catch(() => {}); + throw daemonShuttingDownError(); + } this.profiles.set(profileId, runtime); return runtime; } private invalidateProfileRuntime(profileId: string, runtime: ProfileRuntime): void { - if (this.profiles.get(profileId) !== runtime) return; - this.profiles.delete(profileId); - for (const entry of runtime.pages.values()) { + if (this.profiles.get(profileId) === runtime) this.profiles.delete(profileId); + this.cleanupRuntime(runtime); + } + + private cleanupRuntime(runtime: ProfileRuntime): void { + if (runtime.disposed) return; + runtime.disposed = true; + this.cancelProfileIdle(runtime); + for (const entry of runtime.targetPages.values()) { if (entry.idleTimer) clearTimeout(entry.idleTimer); this.networkCapture.stop(entry.page); + void this.pageCdpSessions.get(entry.page)?.detach().catch(() => {}); + } + runtime.targetPages.clear(); + runtime.sessions.clear(); + runtime.windowOwners.clear(); + for (const waiter of this.targetPageWaiters.get(runtime)?.values() ?? []) { + clearTimeout(waiter.timer); + waiter.reject(new Error('Target page, context or browser has been closed')); } - runtime.pages.clear(); - runtime.selectedPageId = undefined; + this.targetPageWaiters.get(runtime)?.clear(); + void runtime.cdp?.detach().catch(() => {}); } private attachRuntimeLifecycle(profileId: string, runtime: ProfileRuntime): void { runtime.context.on('close', () => this.invalidateProfileRuntime(profileId, runtime)); + runtime.context.on('page', page => { + void this.handleContextPage(runtime, page).catch(() => {}); + }); + const onCdpEvent = (runtime.cdp as (CDPSession & { + on?: (event: string, listener: (payload: { targetId: string }) => void) => void; + }) | undefined)?.on; + onCdpEvent?.call(runtime.cdp, 'Target.targetDestroyed', ({ targetId }: { targetId: string }) => { + this.queueAnchorRepair(profileId, runtime, targetId); + }); } - private async createPageWithRecovery( - profileId: string, - windowMode: BrowserWindowMode | undefined, - createPage: (runtime: ProfileRuntime) => PlaywrightPage | Promise, - commitPage: (runtime: ProfileRuntime, page: PlaywrightPage) => T, - ): Promise { - return this.withPageCreationLock(profileId, () => this.createPageWithRecoveryAttempt( - profileId, - windowMode, - createPage, - commitPage, - )); + private queueAnchorRepair(profileId: string, runtime: ProfileRuntime, destroyedTargetId: string): void { + if (runtime.anchorTargetId !== destroyedTargetId) return; + runtime.anchorTargetId = undefined; + void this.withProfileLifecycleLock(profileId, async () => { + if (this.shuttingDown || runtime.closing || this.profiles.get(profileId) !== runtime) return; + if (runtime.anchorTargetId !== undefined) return; + await this.repairAnchor(profileId, runtime); + }); } - private async createPageWithRecoveryAttempt( - profileId: string, - windowMode: BrowserWindowMode | undefined, - createPage: (runtime: ProfileRuntime) => PlaywrightPage | Promise, - commitPage: (runtime: ProfileRuntime, page: PlaywrightPage) => T, - attempt = 0, - ): Promise { - const runtime = await this.getProfileRuntime(profileId, windowMode); - let page: PlaywrightPage; + private async repairAnchor(profileId: string, runtime: ProfileRuntime): Promise { + if (!runtime.cdp) return; try { - page = await createPage(runtime); + runtime.anchorTargetId = (await runtime.cdp.send('Target.createTarget', { + url: 'about:blank', + hidden: true, + background: true, + }) as { targetId: string }).targetId; } catch (error) { - if (attempt !== 0 || !isClosedContextError(error)) throw error; - this.invalidateProfileRuntime(profileId, runtime); - return this.createPageWithRecoveryAttempt(profileId, windowMode, createPage, commitPage, 1); + this.warnKeeperFallback(profileId, runtime, error); } - if (this.profiles.get(profileId) !== runtime) { - if (!pageIsClosed(page)) await page.close().catch(() => {}); - throw new Error('Target page, context or browser has been closed'); + } + + private warnKeeperFallback(profileId: string, runtime: ProfileRuntime, error: unknown): void { + runtime.useParkingKeeper = true; + if (runtime.keeperWarningLogged) return; + runtime.keeperWarningLogged = true; + log.warn(`Cloak Profile ${profileId} hidden keeper unavailable; using a parking page: ${errorMessage(error)}`); + } + + private scheduleProfileIdle(profileId: string, runtime: ProfileRuntime): void { + if (this.profiles.get(profileId) !== runtime || runtime.closing || runtime.idleTimer || runtime.handoffTimer) return; + if (runtime.activeCommands > 0 || this.hasVisiblePages(runtime)) return; + if (this.hasActiveHandoff(profileId)) { + this.scheduleHandoffWake(profileId, runtime); + return; + } + runtime.idleTimer = setTimeout(() => { + runtime.idleTimer = undefined; + void this.withProfileLifecycleLock(profileId, async () => { + if (this.profiles.get(profileId) !== runtime || runtime.closing) return; + if (runtime.activeCommands > 0 || this.hasVisiblePages(runtime)) return; + if (this.hasActiveHandoff(profileId)) { + this.scheduleHandoffWake(profileId, runtime); + return; + } + runtime.closing = true; + this.profiles.delete(profileId); + await this.closeRuntime(runtime, true); + }); + }, PROFILE_IDLE_TIMEOUT_MS); + runtime.idleTimer.unref?.(); + } + + private scheduleHandoffWake(profileId: string, runtime: ProfileRuntime): void { + runtime.handoffTimer = setTimeout(() => { + runtime.handoffTimer = undefined; + this.scheduleProfileIdle(profileId, runtime); + }, PROFILE_IDLE_TIMEOUT_MS); + runtime.handoffTimer.unref?.(); + } + + private cancelProfileIdle(runtime: ProfileRuntime): void { + if (runtime.idleTimer) clearTimeout(runtime.idleTimer); + if (runtime.handoffTimer) clearTimeout(runtime.handoffTimer); + runtime.idleTimer = undefined; + runtime.handoffTimer = undefined; + } + + private hasVisiblePages(runtime: ProfileRuntime): boolean { + for (const session of runtime.sessions.values()) { + if (this.openEntries(session).length > 0) return true; } - return commitPage(runtime, page); + return false; + } + + private async closeRuntime(runtime: ProfileRuntime, recoverOnTimeout: boolean): Promise { + this.cancelProfileIdle(runtime); + for (const entry of runtime.targetPages.values()) this.clearIdleTimer(entry); + let timeout: ReturnType | undefined; + try { + await Promise.race([ + runtime.context.close(), + new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error('Cloak Profile close timed out')), PROFILE_CLOSE_TIMEOUT_MS); + timeout.unref?.(); + }), + ]); + } catch (error) { + if (recoverOnTimeout && error instanceof Error && error.message === 'Cloak Profile close timed out') { + await this.recoverLockedProfile(runtime.userDataDir); + } else { + throw error; + } + } finally { + if (timeout) clearTimeout(timeout); + this.cleanupRuntime(runtime); + } + } + + private async withProfileLifecycleLock(profileId: string, operation: () => Promise): Promise { + const previous = this.profileLifecycleQueues.get(profileId); + let release!: () => void; + const released = new Promise((resolve) => { + release = resolve; + }); + const queue = (previous ?? Promise.resolve()).then(() => released); + this.profileLifecycleQueues.set(profileId, queue); + if (previous) await previous.catch(() => {}); + try { + return await operation(); + } finally { + release(); + if (this.profileLifecycleQueues.get(profileId) === queue) this.profileLifecycleQueues.delete(profileId); + } + } + + private assertRunning(): void { + if (this.shuttingDown) throw daemonShuttingDownError(); } private async withPageCreationLock(profileId: string, operation: () => Promise): Promise { @@ -512,53 +928,367 @@ export class CloakSessionManager { } } - private async createPage(context: BrowserContext, windowMode?: BrowserWindowMode): Promise { - if (windowMode !== 'background') return context.newPage(); + private getSessionRuntime(runtime: ProfileRuntime, sessionId: string): SessionRuntime { + let session = runtime.sessions.get(sessionId); + if (!session) { + session = { id: sessionId, windowIds: new Set(), pages: new Map() }; + runtime.sessions.set(sessionId, session); + } + return session; + } + + private async createSessionPage( + runtime: ProfileRuntime, + session: SessionRuntime, + windowMode?: BrowserWindowMode, + ): Promise { + const openerEntry = this.openEntries(session)[0]?.[1]; + if (!openerEntry) return this.createWindowPage(runtime, windowMode); + await this.assertOwnedWindow(runtime, session.id, openerEntry); + const opener = openerEntry.page; + const openerWindowId = await this.windowIdForTarget(runtime, openerEntry.targetId, opener); + const targetUrl = `about:blank#webcmd-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const openedPage = this.waitForContextPageForSession(runtime, session.id, openerWindowId, targetUrl, TARGET_PAGE_MATCH_TIMEOUT_MS); - const browser = context.browser(); - if (!browser) throw new Error('Background page creation requires a Chromium browser connection.'); - const cdp = await browser.newBrowserCDPSession(); try { - const [page] = await Promise.all([ - context.waitForEvent('page'), - cdp.send('Target.createTarget', { - url: 'about:blank', - background: true, - focus: false, - }), - ]); + await opener.evaluate((url) => window.open(url, '_blank', 'noopener,noreferrer'), targetUrl); + } catch (error) { + log.warn(`Cloak window.open failed while creating a Session tab; falling back to a new window: ${errorMessage(error)}`); + } + const page = await openedPage; + if (page) return page; + return this.createWindowPage(runtime, windowMode); + } + + private async waitForContextPageForSession( + runtime: ProfileRuntime, + sessionId: string, + openerWindowId: number, + targetUrl: string, + timeoutMs: number, + ): Promise { + return new Promise((resolve) => { + let settled = false; + const done = (page: PlaywrightPage | null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + runtime.context.off('page', onPage); + resolve(page); + }; + const tryPage = async (page: PlaywrightPage) => { + if (settled || pageIsClosed(page)) return; + const targetId = await this.targetIdForPage(runtime, page).catch(() => undefined); + if (!targetId) return; + const actualWindowId = await this.windowIdForTarget(runtime, targetId, page).catch(() => undefined); + if (actualWindowId === undefined) return; + const owner = runtime.windowOwners.get(actualWindowId); + if (owner !== undefined && owner !== sessionId) return; + if (page.url() !== targetUrl) return; + done(page); + }; + const onPage = (page: PlaywrightPage) => { void tryPage(page); }; + const timer = setTimeout(() => done(null), timeoutMs); + runtime.context.on('page', onPage); + for (const page of this.pendingTargetPages.get(runtime)?.values() ?? []) void tryPage(page); + }); + } + + private async acquireSessionPage( + profileId: string, + sessionId: string, + windowMode: BrowserWindowMode | undefined, + attempt = 0, + ): Promise<{ runtime: ProfileRuntime; session: SessionRuntime; page: PlaywrightPage }> { + const runtime = await this.getProfileRuntime(profileId, windowMode); + const session = this.getSessionRuntime(runtime, sessionId); + let page: PlaywrightPage; + try { + page = await this.createSessionPage(runtime, session, windowMode); + } catch (error) { + if (attempt !== 0 || !isClosedContextError(error)) throw error; + this.invalidateProfileRuntime(profileId, runtime); + return this.acquireSessionPage(profileId, sessionId, windowMode, 1); + } + if (this.profiles.get(profileId) !== runtime) { + if (!pageIsClosed(page)) await page.close().catch(() => {}); + throw new Error('Target page, context or browser has been closed'); + } + return { runtime, session, page }; + } + + private async createWindowPage(runtime: ProfileRuntime, windowMode?: BrowserWindowMode, newWindow = true): Promise { + if (!runtime.cdp) return runtime.context.newPage(); + const result = await runtime.cdp.send('Target.createTarget', { + url: 'about:blank', + newWindow, + background: windowMode === 'background', + focus: windowMode !== 'background', + }) as { targetId: string }; + return this.waitForTargetPage(runtime, result.targetId); + } + + private async waitForTargetPage(runtime: ProfileRuntime, targetId: string): Promise { + const pending = this.pendingTargetPages.get(runtime)!; + const page = pending.get(targetId); + if (page) { + pending.delete(targetId); + pending.clear(); return page; + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.targetPageWaiters.get(runtime)?.delete(targetId); + reject(new Error(`Timed out waiting for Cloak target ${targetId}`)); + }, TARGET_PAGE_MATCH_TIMEOUT_MS); + this.targetPageWaiters.get(runtime)!.set(targetId, { resolve, reject, timer }); + }); + } + + private async handleContextPage(runtime: ProfileRuntime, page: PlaywrightPage): Promise { + const targetId = await this.targetIdForPage(runtime, page); + if (targetId === runtime.anchorTargetId) { + page.once('close', () => { + this.queueAnchorRepair(runtime.profileId, runtime, targetId); + }); + return; + } + const waiter = this.targetPageWaiters.get(runtime)?.get(targetId); + if (waiter) { + this.targetPageWaiters.get(runtime)!.delete(targetId); + this.pendingTargetPages.get(runtime)?.clear(); + clearTimeout(waiter.timer); + waiter.resolve(page); + } else { + this.pendingTargetPages.get(runtime)?.set(targetId, page); + } + + const opener = await page.opener().catch(() => null); + const openerEntry = opener && [...runtime.targetPages.values()].find(entry => entry.page === opener); + if (!openerEntry?.sessionId) return; + const session = runtime.sessions.get(openerEntry.sessionId); + if (!session) return; + this.pendingTargetPages.get(runtime)?.delete(targetId); + await this.registerOwnedPage(runtime, session, page, { + session: openerEntry.session, + surface: openerEntry.surface, + siteSession: openerEntry.siteSession, + sessionKind: openerEntry.sessionKind, + adapterSite: openerEntry.adapterSite, + idleTimeout: openerEntry.idleTimeout, + }); + } + + private async registerOwnedPage( + runtime: ProfileRuntime, + session: SessionRuntime, + page: PlaywrightPage, + input: Pick & { leaseKey?: string }, + ): Promise { + const targetId = await this.targetIdForPage(runtime, page); + this.pendingTargetPages.get(runtime)?.delete(targetId); + const windowId = await this.windowIdForTarget(runtime, targetId, page); + const owner = runtime.windowOwners.get(windowId); + if (owner !== undefined && owner !== session.id) { + throw new SessionWindowConflictError(runtime.targetPages.get(targetId)?.pageId ?? 'unknown', session.id, owner); + } + runtime.windowOwners.set(windowId, session.id); + session.windowIds.add(windowId); + + let entry = runtime.targetPages.get(targetId); + const wasOwned = Boolean(entry?.sessionId); + if (entry?.sessionId && entry.sessionId !== session.id) { + throw new SessionWindowConflictError(entry.pageId, session.id, entry.sessionId); + } + if (!entry) { + const pageId = nextPageId(); + entry = { + page, + pageId, + targetId, + leaseKey: input.leaseKey ?? `page\u0000${pageId}`, + sessionId: session.id, + session: input.session, + surface: input.surface, + siteSession: input.siteSession, + sessionKind: input.sessionKind, + adapterSite: input.adapterSite, + idleTimeout: input.idleTimeout, + }; + runtime.targetPages.set(targetId, entry); + this.attachPageLifecycle(runtime, entry); + } else { + if (entry.sessionId) { + const session = runtime.sessions.get(entry.sessionId); + if (session?.pages.get(entry.leaseKey) === entry) session.pages.delete(entry.leaseKey); + } + entry.sessionId = session.id; + entry.session = input.session; + entry.surface = input.surface; + entry.siteSession = input.siteSession; + entry.sessionKind = input.sessionKind; + entry.adapterSite = input.adapterSite; + entry.idleTimeout = input.idleTimeout; + entry.leaseKey = input.leaseKey ?? (entry.leaseKey.startsWith('unowned\u0000') ? `page\u0000${entry.pageId}` : entry.leaseKey); + } + session.pages.set(entry.leaseKey, entry); + this.cancelProfileIdle(runtime); + this.refreshIdleTimer(runtime, session, entry.leaseKey, entry); + if (!wasOwned) for (const listener of this.sessionPageListeners.get(session) ?? []) listener(page); + await this.closeParkingPage(runtime); + return entry; + } + + private attachPageLifecycle(runtime: ProfileRuntime, entry: PageEntry): void { + entry.page.once('close', () => { + runtime.targetPages.delete(entry.targetId); + if (entry.sessionId) { + const session = runtime.sessions.get(entry.sessionId); + if (session?.pages.get(entry.leaseKey) === entry) session.pages.delete(entry.leaseKey); + } + this.clearIdleTimer(entry); + if (runtime.parkingPage === entry.page) runtime.parkingPage = undefined; + this.scheduleProfileIdle(runtime.profileId, runtime); + }); + } + + private async targetIdForPage(runtime: ProfileRuntime, page: PlaywrightPage): Promise { + const cached = this.pageTargetIds.get(page); + if (cached) return cached; + const pending = this.pageTargetIdPromises.get(page); + if (pending) return pending; + const correlation = (async () => { + const session = await runtime.context.newCDPSession(page); + const { targetInfo } = await session.send('Target.getTargetInfo') as { targetInfo: { targetId: string } }; + this.pageTargetIds.set(page, targetInfo.targetId); + this.pageCdpSessions.set(page, session); + page.once('close', () => { + this.pageTargetIds.delete(page); + this.pageCdpSessions.delete(page); + void session.detach().catch(() => {}); + }); + return targetInfo.targetId; + })(); + this.pageTargetIdPromises.set(page, correlation); + try { + return await correlation; } finally { - await cdp.detach().catch(() => {}); + this.pageTargetIdPromises.delete(page); } } - private openEntries(runtime: ProfileRuntime): [string, PageEntry][] { + private async windowIdForTarget(runtime: ProfileRuntime, targetId: string, page?: PlaywrightPage): Promise { + const entry = runtime.targetPages.get(targetId); + const targetPage = page ?? entry?.page; + const cdp = runtime.cdp ?? (targetPage ? this.pageCdpSessions.get(targetPage) : undefined); + if (!cdp) throw new Error('Cloak page has no CDP session.'); + const { windowId } = await cdp.send('Browser.getWindowForTarget', { targetId }) as { windowId: number }; + return windowId; + } + + private async assertOwnedWindow(runtime: ProfileRuntime, sessionId: string, entry: PageEntry): Promise { + const actual = await this.windowIdForTarget(runtime, entry.targetId, entry.page); + const owner = runtime.windowOwners.get(actual); + if (owner !== undefined && owner !== sessionId) { + throw new SessionWindowConflictError(entry.pageId, sessionId, owner); + } + if (!runtime.sessions.get(sessionId)?.windowIds.has(actual)) { + throw new SessionWindowConflictError(entry.pageId, sessionId, owner); + } + } + + private async assertBindableWindow(runtime: ProfileRuntime, session: SessionRuntime, entry: PageEntry): Promise { + if (entry.sessionId) { + if (entry.sessionId !== session.id) { + throw new SessionWindowConflictError(entry.pageId, session.id, entry.sessionId); + } + await this.assertOwnedWindow(runtime, session.id, entry); + return; + } + const actual = await this.windowIdForTarget(runtime, entry.targetId, entry.page); + const owner = runtime.windowOwners.get(actual); + if (owner !== undefined && owner !== session.id) { + throw new SessionWindowConflictError(entry.pageId, session.id, owner); + } + runtime.windowOwners.set(actual, session.id); + session.windowIds.add(actual); + } + + private openEntries(runtime: SessionRuntime): [string, PageEntry][] { return [...runtime.pages.entries()].filter(([, entry]) => !pageIsClosed(entry.page)); } private findEntryByPageId(runtime: ProfileRuntime, pageId: string): [string, PageEntry] | null { - return this.openEntries(runtime).find(([, entry]) => entry.pageId === pageId) ?? null; + const entry = [...runtime.targetPages.values()].find(candidate => candidate.pageId === pageId && !pageIsClosed(candidate.page)); + return entry ? [entry.leaseKey, entry] : null; + } + + private sessionEntries(runtime: SessionRuntime, input: Pick): [string, PageEntry][] { + const session = requireSession(input.session); + const surface = normalizeSurface(input.surface); + return this.openEntries(runtime).filter(([, entry]) => entry.session === session && entry.surface === surface); + } + + private selectEntry(runtime: SessionRuntime, entry: PageEntry): void { + runtime.selectedPageId = entry.pageId; } - private refreshIdleTimer(runtime: ProfileRuntime, leaseKey: string, entry: PageEntry): void { + private clearSelectedPage(runtime: SessionRuntime, entry: PageEntry): void { + if (runtime.selectedPageId === entry.pageId) runtime.selectedPageId = undefined; + } + + private refreshIdleTimer(runtime: ProfileRuntime, session: SessionRuntime, leaseKey: string, entry: PageEntry): void { this.clearIdleTimer(entry); if (!entry.idleTimeout || entry.idleTimeout <= 0 || entry.siteSession === 'persistent') return; entry.idleTimer = setTimeout(() => { - void this.expireLease(runtime, leaseKey, entry); + void this.expireLease(runtime, session, leaseKey, entry); }, entry.idleTimeout); entry.idleTimer.unref?.(); } - private async expireLease(runtime: ProfileRuntime, leaseKey: string, entry: PageEntry): Promise { - if (runtime.pages.get(leaseKey) !== entry) return; - runtime.pages.delete(leaseKey); - this.clearIdleTimer(entry); - if (runtime.selectedPageId === entry.pageId) runtime.selectedPageId = undefined; + private async expireLease(runtime: ProfileRuntime, session: SessionRuntime, leaseKey: string, entry: PageEntry): Promise { + if (session.pages.get(leaseKey) !== entry) return; runtime.lastSeenAt = Date.now(); - if (entry.siteSession !== 'persistent' && !pageIsClosed(entry.page)) { - await entry.page.close().catch(() => {}); + if (entry.siteSession !== 'persistent') await this.removeEntry(runtime, session, entry, true); + } + + private async removeEntry(runtime: ProfileRuntime, session: SessionRuntime, entry: PageEntry, close: boolean): Promise { + const shouldPark = close && runtime.useParkingKeeper + && [...runtime.targetPages.values()].every(candidate => candidate === entry || pageIsClosed(candidate.page)); + const parkingWindowId = shouldPark + ? await this.windowIdForTarget(runtime, entry.targetId, entry.page).catch(() => undefined) + : undefined; + if (session.pages.get(entry.leaseKey) === entry) session.pages.delete(entry.leaseKey); + runtime.targetPages.delete(entry.targetId); + this.clearIdleTimer(entry); + this.clearSelectedPage(session, entry); + this.networkCapture.stop(entry.page); + if (close && !pageIsClosed(entry.page)) { + if (shouldPark) { + await entry.page.goto('about:blank', { waitUntil: 'load' }).catch(() => {}); + entry.sessionId = undefined; + runtime.parkingPage = pageIsClosed(entry.page) ? undefined : entry.page; + if (parkingWindowId !== undefined) { + runtime.windowOwners.delete(parkingWindowId); + session.windowIds.delete(parkingWindowId); + await runtime.cdp?.send('Browser.setWindowBounds', { + windowId: parkingWindowId, + bounds: { windowState: 'minimized' }, + }).catch(() => {}); + } + } else { + await runtime.cdp?.send('Target.closeTarget', { targetId: entry.targetId }).catch(() => {}); + if (!pageIsClosed(entry.page)) await entry.page.close().catch(() => {}); + } } + this.scheduleProfileIdle(runtime.profileId, runtime); + } + + private async closeParkingPage(runtime: ProfileRuntime): Promise { + const parkingPage = runtime.parkingPage; + if (!parkingPage) return; + runtime.parkingPage = undefined; + if (!pageIsClosed(parkingPage)) await parkingPage.close().catch(() => {}); } private clearIdleTimer(entry: PageEntry): void { @@ -581,6 +1311,10 @@ function requireSession(session: string | undefined): string { return normalized; } +function requireSessionId(input: Pick): string { + return input.sessionId?.trim() || requireSession(input.session); +} + function isProfileAlreadyInUseError(err: unknown): boolean { const message = err instanceof Error ? err.message : String(err); return message.includes('Opening in existing browser session') @@ -589,13 +1323,13 @@ function isProfileAlreadyInUseError(err: unknown): boolean { async function recoverLockedCloakProfile(userDataDir: string): Promise { if (process.platform === 'win32') return false; - const initial = await findCloakProfileProcesses(userDataDir); + const initial = await findExactCloakProfileProcesses(userDataDir); if (initial.length === 0) return false; signalPids(initial, 'SIGTERM'); if (await waitForProfileProcessesToExit(userDataDir, 2500)) return true; - signalPids(await findCloakProfileProcesses(userDataDir), 'SIGKILL'); + signalPids(await findExactCloakProfileProcesses(userDataDir), 'SIGKILL'); return waitForProfileProcessesToExit(userDataDir, 1500); } @@ -603,9 +1337,9 @@ async function waitForProfileProcessesToExit(userDataDir: string, timeoutMs: num const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 100)); - if ((await findCloakProfileProcesses(userDataDir)).length === 0) return true; + if ((await findExactCloakProfileProcesses(userDataDir)).length === 0) return true; } - return (await findCloakProfileProcesses(userDataDir)).length === 0; + return (await findExactCloakProfileProcesses(userDataDir)).length === 0; } function signalPids(pids: number[], signal: NodeJS.Signals): void { @@ -617,53 +1351,3 @@ function signalPids(pids: number[], signal: NodeJS.Signals): void { } } } - -async function findCloakProfileProcesses(userDataDir: string): Promise { - const profileDirs = profileDirAliases(userDataDir); - const stdout = await psOutput(); - const pids: number[] = []; - for (const line of stdout.split('\n')) { - const match = line.match(/^\s*(\d+)\s+(.+)$/); - if (!match) continue; - const pid = Number(match[1]); - const command = match[2]; - if (!Number.isInteger(pid) || pid === process.pid) continue; - if (!isCloakBrowserCommand(command)) continue; - if (!commandUsesProfileDir(command, profileDirs)) continue; - pids.push(pid); - } - return [...new Set(pids)]; -} - -function commandUsesProfileDir(command: string, profileDirs: string[]): boolean { - for (const dir of profileDirs) { - const marker = `--user-data-dir=${dir}`; - const index = command.indexOf(marker); - if (index < 0) continue; - const next = command[index + marker.length]; - if (next === undefined || /\s/.test(next)) return true; - } - return false; -} - -function profileDirAliases(userDataDir: string): string[] { - const aliases = new Set([userDataDir]); - try { - aliases.add(fs.realpathSync.native(userDataDir)); - } catch { - // The launch path is still useful even if realpath cannot resolve it. - } - return [...aliases]; -} - -function isCloakBrowserCommand(command: string): boolean { - return command.includes('/.cloakbrowser/') || command.includes('\\.cloakbrowser\\'); -} - -function psOutput(): Promise { - return new Promise((resolve) => { - execFile('ps', ['-axo', 'pid=,command='], { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, timeout: 2000 }, (err, stdout) => { - resolve(err ? '' : String(stdout)); - }); - }); -} diff --git a/src/browser/runtime/provider.ts b/src/browser/runtime/provider.ts index cb295b1f..29a60298 100644 --- a/src/browser/runtime/provider.ts +++ b/src/browser/runtime/provider.ts @@ -1,4 +1,5 @@ import type { BrowserRuntimeCommand, BrowserRuntimeResult, BrowserRuntimeStatus } from '../protocol.js'; +import type { BrowserSessionListRow, BrowserSessionRecord } from '../sessions.js'; export interface RuntimeStatusOptions { contextId?: string; @@ -7,6 +8,13 @@ export interface RuntimeStatusOptions { export interface BrowserRuntimeProvider { status(opts?: RuntimeStatusOptions): Promise; resolveProfileId?(command: BrowserRuntimeCommand): string; - dispatch(command: BrowserRuntimeCommand): Promise; + createSession?(command: BrowserRuntimeCommand): Promise; + requireSession?(command: BrowserRuntimeCommand): Promise; + resolveAdapterDefault?(command: BrowserRuntimeCommand): Promise; + startSessionHandoff?(command: BrowserRuntimeCommand): Promise; + clearSessionHandoff?(command: BrowserRuntimeCommand): Promise; + listSessions?(input: { profileId?: string; limit?: number }): Promise; + closeSession?(command: BrowserRuntimeCommand): Promise<{ closed: boolean; alreadyIdle: boolean; session: string }>; + dispatch(command: BrowserRuntimeCommand, signal?: AbortSignal): Promise; shutdown(): Promise; } diff --git a/src/browser/sessions.test.ts b/src/browser/sessions.test.ts new file mode 100644 index 00000000..bd8616bc --- /dev/null +++ b/src/browser/sessions.test.ts @@ -0,0 +1,161 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { LocalBrowserSessionStore } from './sessions.js'; + +const tempDirs: string[] = []; + +function tempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-sessions-')); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + while (tempDirs.length) fs.rmSync(tempDirs.pop()!, { recursive: true, force: true }); +}); + +describe('LocalBrowserSessionStore', () => { + it('creates unique explicit sessions and persists them', () => { + const baseDir = tempDir(); + const store = new LocalBrowserSessionStore({ + baseDir, + now: () => new Date('2026-08-11T00:00:00.000Z'), + idFactory: () => 'session_11111111-1111-4111-8111-111111111111', + }); + + const created = store.create('profile_work'); + + expect(created).toMatchObject({ + id: 'session_11111111-1111-4111-8111-111111111111', + profileId: 'profile_work', + kind: 'explicit', + createdAt: '2026-08-11T00:00:00.000Z', + updatedAt: '2026-08-11T00:00:00.000Z', + lastUsedAt: '2026-08-11T00:00:00.000Z', + }); + expect(store.create('profile_work').id).not.toBe(created.id); + expect(new LocalBrowserSessionStore({ baseDir }).find('profile_work', created.id)?.id).toBe(created.id); + }); + + it('scopes lookup by profile and validates opaque ids', () => { + const store = new LocalBrowserSessionStore({ baseDir: tempDir(), idFactory: () => 'session_a' }); + const created = store.create('profile_work'); + + expect(() => store.require('profile_other', created.id)).toThrowError(expect.objectContaining({ code: 'SESSION_NOT_FOUND' })); + expect(() => store.find('profile_work', 'work')).toThrowError(expect.objectContaining({ code: 'INVALID_SESSION_SELECTOR' })); + }); + + it('resolves one lazy adapter-default per profile without list side effects', () => { + const store = new LocalBrowserSessionStore({ + baseDir: tempDir(), + idFactory: () => 'session_default', + }); + + expect(store.list('profile_work')).toEqual([]); + const adapterDefault = store.resolveAdapterDefault('profile_work'); + + expect(adapterDefault.kind).toBe('adapter-default'); + expect(store.resolveAdapterDefault('profile_work').id).toBe(adapterDefault.id); + expect(store.list('profile_work')).toHaveLength(1); + }); + + it('writes state atomically with private file mode', () => { + const baseDir = tempDir(); + const store = new LocalBrowserSessionStore({ baseDir, idFactory: () => 'session_private' }); + + store.create('profile_work'); + + const statePath = path.join(baseDir, 'browser-sessions.json'); + expect(fs.existsSync(statePath)).toBe(true); + expect(fs.statSync(statePath).mode & 0o777).toBe(0o600); + expect(fs.readdirSync(baseDir).filter((name) => name.includes('.tmp'))).toEqual([]); + }); + + it('fails closed on malformed persisted JSON', () => { + const baseDir = tempDir(); + fs.writeFileSync(path.join(baseDir, 'browser-sessions.json'), '{not json', { mode: 0o600 }); + + expect(() => new LocalBrowserSessionStore({ baseDir }).list('profile_work')) + .toThrowError(expect.objectContaining({ code: 'CONFIG' })); + }); + + it('clears expired handoffs while resolving and listing Sessions', () => { + let now = new Date('2026-08-11T00:00:00.000Z'); + const store = new LocalBrowserSessionStore({ + baseDir: tempDir(), + now: () => now, + idFactory: () => 'session_a', + }); + const session = store.create('work'); + store.markHandoff('work', session.id, { + site: 'github', + expiresAt: '2026-08-11T00:15:00.000Z', + }); + + expect(store.require('work', session.id).handoff).toEqual({ + site: 'github', + expiresAt: '2026-08-11T00:15:00.000Z', + }); + now = new Date('2026-08-11T00:15:00.000Z'); + + expect(store.require('work', session.id).handoff).toBeUndefined(); + expect(store.list('work')[0]?.handoff).toBeUndefined(); + }); + + it('prunes explicit Sessions idle for 30 days while preserving adapter defaults and handoffs', () => { + const baseDir = tempDir(); + fs.writeFileSync(path.join(baseDir, 'browser-sessions.json'), `${JSON.stringify({ + version: 1, + sessions: [ + sessionRecord('session_old', 'explicit', '2026-07-11T23:59:59.000Z'), + sessionRecord('session_boundary', 'explicit', '2026-07-12T00:00:01.000Z'), + sessionRecord('session_handoff', 'explicit', '2026-07-01T00:00:00.000Z', { site: 'github', expiresAt: '2026-08-12T00:15:00.000Z' }), + sessionRecord('session_expired_handoff', 'explicit', '2026-07-01T00:00:00.000Z', { site: 'github', expiresAt: '2026-08-10T00:15:00.000Z' }), + sessionRecord('session_default', 'adapter-default', '2026-07-01T00:00:00.000Z'), + ], + })}\n`, { mode: 0o600 }); + + const rows = new LocalBrowserSessionStore({ + baseDir, + now: () => new Date('2026-08-11T00:00:00.000Z'), + }).list('work'); + + expect(rows.map((row) => row.id)).toEqual(['session_boundary', 'session_default', 'session_handoff']); + }); + + it('retains active Sessions and limits newest-first listings', () => { + const baseDir = tempDir(); + fs.writeFileSync(path.join(baseDir, 'browser-sessions.json'), `${JSON.stringify({ + version: 1, + sessions: [ + sessionRecord('session_active', 'explicit', '2026-07-01T00:00:00.000Z'), + sessionRecord('session_newest', 'explicit', '2026-08-10T00:00:00.000Z'), + sessionRecord('session_middle', 'explicit', '2026-08-09T00:00:00.000Z'), + ], + })}\n`, { mode: 0o600 }); + + const rows = new LocalBrowserSessionStore({ + baseDir, + now: () => new Date('2026-08-11T00:00:00.000Z'), + isActive: session => session.id === 'session_active', + }).list('work', 2); + + expect(rows.map((row) => row.id)).toEqual(['session_newest', 'session_middle']); + expect(new LocalBrowserSessionStore({ + baseDir, + now: () => new Date('2026-08-11T00:00:00.000Z'), + isActive: session => session.id === 'session_active', + }).find('work', 'session_active')).toBeDefined(); + }); +}); + +function sessionRecord( + id: string, + kind: 'explicit' | 'adapter-default', + lastUsedAt: string, + handoff?: { site: string; expiresAt: string }, +) { + return { id, profileId: 'work', kind, createdAt: lastUsedAt, updatedAt: lastUsedAt, lastUsedAt, ...(handoff ? { handoff } : {}) }; +} diff --git a/src/browser/sessions.ts b/src/browser/sessions.ts new file mode 100644 index 00000000..ec3f085b --- /dev/null +++ b/src/browser/sessions.ts @@ -0,0 +1,281 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { CONFIG_DIR_NAME, ENV_PREFIX } from '../brand.js'; +import { CliError, ConfigError, EXIT_CODES } from '../errors.js'; + +export interface BrowserSessionRecord { + id: string; + profileId: string; + kind: 'explicit' | 'adapter-default'; + createdAt: string; + updatedAt: string; + lastUsedAt: string; + handoff?: { site: string; expiresAt: string }; +} + +export interface BrowserSessionListRow extends BrowserSessionRecord { + runtimeState: 'idle' | 'active'; +} + +export interface LocalBrowserSessionStoreOptions { + baseDir?: string; + now?: () => Date; + idFactory?: () => string; + isActive?: (record: BrowserSessionRecord) => boolean; +} + +type StateFile = { version: 1; sessions: BrowserSessionRecord[] }; +const SESSION_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; + +export class SessionNotFoundError extends CliError { + constructor(sessionId: string, profileId: string) { + super( + 'SESSION_NOT_FOUND', + `Session not found: ${sessionId}`, + `Run \`webcmd --profile ${profileId} session list\` to choose an existing Session.`, + EXIT_CODES.EMPTY_RESULT, + ); + } +} + +export class InvalidSessionSelectorError extends CliError { + constructor(sessionId: string) { + super( + 'INVALID_SESSION_SELECTOR', + `Session selector must be an opaque Session ID: ${sessionId}`, + 'Run `webcmd session create` and pass the returned `session_...` ID.', + EXIT_CODES.USAGE_ERROR, + ); + } +} + +export class LocalBrowserSessionStore { + private readonly baseDir: string; + private readonly now: () => Date; + private readonly idFactory: () => string; + private readonly isActive: (record: BrowserSessionRecord) => boolean; + + constructor(opts: LocalBrowserSessionStoreOptions = {}) { + this.baseDir = opts.baseDir ?? getWebcmdConfigDir(); + this.now = opts.now ?? (() => new Date()); + this.idFactory = opts.idFactory ?? (() => `session_${randomUUID()}`); + this.isActive = opts.isActive ?? (() => false); + } + + create(profileId: string): BrowserSessionRecord { + const state = this.load(); + const record = this.newRecord(profileId, 'explicit', state.sessions); + state.sessions.push(record); + this.save(state); + return { ...record }; + } + + find(profileId: string, sessionId: string): BrowserSessionRecord | undefined { + requireSessionIdShape(sessionId); + const state = this.load(); + const record = state.sessions.find((row) => row.id === sessionId && row.profileId === profileId); + return record ? { ...record } : undefined; + } + + require(profileId: string, sessionId: string | undefined): BrowserSessionRecord { + const id = sessionId?.trim() ?? ''; + requireSessionIdShape(id); + const state = this.load(); + const record = state.sessions.find((row) => row.id === id && row.profileId === profileId); + if (!record) throw new SessionNotFoundError(id, profileId); + this.touchRecord(state, record); + return { ...record }; + } + + resolveAdapterDefault(profileId: string): BrowserSessionRecord { + const state = this.load(); + const existing = state.sessions.find((row) => row.profileId === profileId && row.kind === 'adapter-default'); + if (existing) { + this.touchRecord(state, existing); + return { ...existing }; + } + const record = this.newRecord(profileId, 'adapter-default', state.sessions); + state.sessions.push(record); + this.save(state); + return { ...record }; + } + + list(profileId?: string, limit = 20): BrowserSessionListRow[] { + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) { + throw new CliError('INVALID_SESSION_LIMIT', 'Session list limit must be an integer from 1 to 100.', undefined, EXIT_CODES.USAGE_ERROR); + } + const rows = this.load().sessions + .filter((row) => profileId === undefined || row.profileId === profileId) + .sort((left, right) => right.lastUsedAt.localeCompare(left.lastUsedAt) || left.id.localeCompare(right.id)) + .slice(0, limit); + return rows.map((row) => ({ ...row, runtimeState: 'idle' as const })); + } + + markHandoff(profileId: string, sessionId: string, handoff: { site: string; expiresAt: string }): BrowserSessionRecord { + const state = this.load(); + const record = this.requireMutable(state, profileId, sessionId); + record.handoff = handoff; + this.touchRecord(state, record); + return { ...record }; + } + + clearHandoff(profileId: string, sessionId: string): BrowserSessionRecord { + const state = this.load(); + const record = this.requireMutable(state, profileId, sessionId); + delete record.handoff; + this.touchRecord(state, record); + return { ...record }; + } + + touch(profileId: string, sessionId: string): BrowserSessionRecord { + const state = this.load(); + const record = this.requireMutable(state, profileId, sessionId); + this.touchRecord(state, record); + return { ...record }; + } + + private newRecord( + profileId: string, + kind: BrowserSessionRecord['kind'], + existing: BrowserSessionRecord[], + ): BrowserSessionRecord { + const timestamp = this.now().toISOString(); + const id = this.uniqueId(existing); + return { id, profileId, kind, createdAt: timestamp, updatedAt: timestamp, lastUsedAt: timestamp }; + } + + private uniqueId(existing: BrowserSessionRecord[]): string { + const used = new Set(existing.map((row) => row.id)); + const first = this.idFactory(); + if (!used.has(first)) { + requireSessionIdShape(first); + return first; + } + let candidate = `session_${randomUUID()}`; + while (used.has(candidate)) candidate = `session_${randomUUID()}`; + return candidate; + } + + private touchRecord(state: StateFile, record: BrowserSessionRecord): void { + const timestamp = this.now().toISOString(); + record.updatedAt = timestamp; + record.lastUsedAt = timestamp; + this.save(state); + } + + private requireMutable(state: StateFile, profileId: string, sessionId: string): BrowserSessionRecord { + requireSessionIdShape(sessionId); + const record = state.sessions.find((row) => row.id === sessionId && row.profileId === profileId); + if (!record) throw new SessionNotFoundError(sessionId, profileId); + return record; + } + + private load(): StateFile { + const file = this.statePath(); + if (!fs.existsSync(file)) return { version: 1, sessions: [] }; + let parsed: unknown; + try { + parsed = JSON.parse(fs.readFileSync(file, 'utf8')); + } catch (error) { + throw new ConfigError(`Could not read browser sessions: ${error instanceof Error ? error.message : String(error)}`); + } + const state = validateState(parsed); + const now = this.now().getTime(); + let changed = false; + for (const record of state.sessions) { + if (record.handoff && Date.parse(record.handoff.expiresAt) <= now) { + delete record.handoff; + changed = true; + } + } + const retained = state.sessions.filter((record) => { + const expired = record.kind === 'explicit' + && !record.handoff + && !this.isActive(record) + && Date.parse(record.lastUsedAt) <= now - SESSION_RETENTION_MS; + if (expired) changed = true; + return !expired; + }); + state.sessions = retained; + if (changed) this.save(state); + return state; + } + + private save(state: StateFile): void { + fs.mkdirSync(this.baseDir, { recursive: true }); + const target = this.statePath(); + const tmp = path.join(this.baseDir, `.browser-sessions.${process.pid}.${randomUUID()}.tmp`); + fs.writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 }); + fs.renameSync(tmp, target); + fs.chmodSync(target, 0o600); + } + + private statePath(): string { + return path.join(this.baseDir, 'browser-sessions.json'); + } +} + +function validateState(value: unknown): StateFile { + if (!value || typeof value !== 'object') throw new ConfigError('browser-sessions.json must contain an object.'); + const state = value as { version?: unknown; sessions?: unknown }; + if (state.version !== 1 || !Array.isArray(state.sessions)) { + throw new ConfigError('browser-sessions.json has an unsupported schema.'); + } + const adapterDefaults = new Set(); + const sessions = state.sessions.map((row) => validateRecord(row, adapterDefaults)); + return { version: 1, sessions }; +} + +function validateRecord(value: unknown, adapterDefaults: Set): BrowserSessionRecord { + if (!value || typeof value !== 'object') throw new ConfigError('browser-sessions.json contains an invalid Session record.'); + const row = value as Partial; + if (typeof row.id !== 'string') throw new ConfigError('browser-sessions.json contains a Session without an id.'); + requireSessionIdShape(row.id); + if (typeof row.profileId !== 'string' || !row.profileId.trim()) throw new ConfigError('browser-sessions.json contains a Session without a profileId.'); + if (row.kind !== 'explicit' && row.kind !== 'adapter-default') throw new ConfigError('browser-sessions.json contains an invalid Session kind.'); + const createdAt = row.createdAt; + const updatedAt = row.updatedAt; + const lastUsedAt = row.lastUsedAt; + if (typeof createdAt !== 'string' || Number.isNaN(Date.parse(createdAt))) { + throw new ConfigError('browser-sessions.json contains an invalid createdAt.'); + } + if (typeof updatedAt !== 'string' || Number.isNaN(Date.parse(updatedAt))) { + throw new ConfigError('browser-sessions.json contains an invalid updatedAt.'); + } + if (typeof lastUsedAt !== 'string' || Number.isNaN(Date.parse(lastUsedAt))) { + throw new ConfigError('browser-sessions.json contains an invalid lastUsedAt.'); + } + if (row.kind === 'adapter-default') { + const key = row.profileId; + if (adapterDefaults.has(key)) throw new ConfigError(`browser-sessions.json contains multiple adapter-default Sessions for ${key}.`); + adapterDefaults.add(key); + } + const handoff = row.handoff; + if (handoff && ( + typeof handoff.site !== 'string' + || !handoff.site.trim() + || typeof handoff.expiresAt !== 'string' + || Number.isNaN(Date.parse(handoff.expiresAt)) + )) { + throw new ConfigError('browser-sessions.json contains an invalid handoff.'); + } + return { + id: row.id, + profileId: row.profileId, + kind: row.kind, + createdAt, + updatedAt, + lastUsedAt, + ...(handoff ? { handoff } : {}), + }; +} + +export function requireSessionIdShape(sessionId: string): void { + if (!/^session_[A-Za-z0-9_-]+$/u.test(sessionId)) throw new InvalidSessionSelectorError(sessionId); +} + +function getWebcmdConfigDir(): string { + return process.env[`${ENV_PREFIX}_CONFIG_DIR`] || path.join(os.homedir(), CONFIG_DIR_NAME); +} diff --git a/src/browser/target-resolver.ts b/src/browser/target-resolver.ts index 7ffe5de5..39d064de 100644 --- a/src/browser/target-resolver.ts +++ b/src/browser/target-resolver.ts @@ -187,7 +187,7 @@ export function resolveTargetJs(ref: string, opts: ResolveOptions = {}): string ok: false, code: 'not_found', message: 'ref=' + ref + ' not found in DOM', - hint: 'The element may have been removed. Re-run \`webcmd browser snapshot --snapshot-mode tree\` to get a fresh snapshot.', + hint: 'The element may have been removed. Re-run \`webcmd --session browser snapshot --snapshot-mode tree\` to get a fresh snapshot.', }; } @@ -224,7 +224,7 @@ export function resolveTargetJs(ref: string, opts: ResolveOptions = {}): string code: 'stale_ref', message: 'ref=' + ref + ' was <' + fp.tag + '>' + (fp.text ? '"' + fp.text + '"' : '') + ' but now points to <' + liveFp.tag + '>' + (liveFp.text ? '"' + liveFp.text.slice(0, 30) + '"' : ''), - hint: 'The page has changed since the last snapshot. Re-run \`webcmd browser snapshot --snapshot-mode tree\` to refresh.', + hint: 'The page has changed since the last snapshot. Re-run \`webcmd --session browser snapshot --snapshot-mode tree\` to refresh.', }; } @@ -247,7 +247,7 @@ export function resolveTargetJs(ref: string, opts: ResolveOptions = {}): string ok: false, code: 'selector_not_found', message: 'CSS selector "' + ref + '" matched 0 elements', - hint: 'The element may not exist or may be hidden. Re-run \`webcmd browser snapshot --snapshot-mode tree\` to check, or use \`webcmd browser run --stdin\` for a targeted selector query.', + hint: 'The element may not exist or may be hidden. Re-run \`webcmd --session browser snapshot --snapshot-mode tree\` to check, or use \`webcmd --session browser run --stdin\` for a targeted selector query.', matches_n: 0, }; } @@ -280,7 +280,7 @@ export function resolveTargetJs(ref: string, opts: ResolveOptions = {}): string ok: false, code: 'selector_ambiguous', message: 'CSS selector "' + ref + '" matched ' + matches.length + ' elements', - hint: 'Pass --nth (0-based) to pick one, or use a more specific selector. Use \`webcmd browser run --stdin\` to list matching candidates.', + hint: 'Pass --nth (0-based) to pick one, or use a more specific selector. Use \`webcmd --session browser run --stdin\` to list matching candidates.', candidates: candidates, matches_n: matches.length, }; diff --git a/src/cli-argv-preprocess.test.ts b/src/cli-argv-preprocess.test.ts index aa1dbb32..b6eeddff 100644 --- a/src/cli-argv-preprocess.test.ts +++ b/src/cli-argv-preprocess.test.ts @@ -1,256 +1,42 @@ import { describe, expect, it } from 'vitest'; -import { getBrowserSubcommandNames, rewriteBrowserArgv } from './cli-argv-preprocess.js'; +import { rejectMisplacedSessionSelectorArgv, rejectPositionalBrowserSessionArgv } from './cli-argv-preprocess.js'; -describe('rewriteBrowserArgv', () => { - it('rewrites `browser ` into `browser --session `', () => { - expect(rewriteBrowserArgv(['browser', 'work', 'state'])).toEqual([ - 'browser', - '--session', - 'work', - 'state', - ]); - }); - - it('rewrites with subcommand arguments preserved', () => { - expect(rewriteBrowserArgv(['browser', 'mercury', 'open', 'https://x.com'])).toEqual([ - 'browser', - '--session', - 'mercury', - 'open', - 'https://x.com', - ]); - }); - - it('rewrites `browser bind`', () => { - expect(rewriteBrowserArgv(['browser', 'mercury', 'bind'])).toEqual([ - 'browser', - '--session', - 'mercury', - 'bind', - ]); - }); - - it('rewrites `browser run` with source options preserved', () => { - expect(rewriteBrowserArgv(['browser', 'mercury', 'run', '--file', 'task.js'])).toEqual([ - 'browser', - '--session', - 'mercury', - 'run', - '--file', - 'task.js', - ]); - }); - - it('leaves argv alone when session omitted and a subcommand follows', () => { - // Commander surfaces the required-flag error itself. - expect(rewriteBrowserArgv(['browser', 'state'])).toEqual(['browser', 'state']); - expect(rewriteBrowserArgv(['browser', 'bind'])).toEqual(['browser', 'bind']); - }); - - it('leaves argv alone when the token after `browser` is a flag', () => { - expect(rewriteBrowserArgv(['browser', '--help'])).toEqual(['browser', '--help']); - expect(rewriteBrowserArgv(['browser', '-h'])).toEqual(['browser', '-h']); - }); - - it('refuses the retired `webcmd browser --session foo ...` user form', () => { - // The flag form is no longer a public entrance. Tests calling - // program.parseAsync directly bypass the preprocessor, so internal - // callers still work; but the user-facing pipeline throws. - expect(() => rewriteBrowserArgv(['browser', '--session', 'foo', 'state'])) - .toThrowError(/no longer a public option/i); - expect(() => rewriteBrowserArgv(['browser', '--session=foo', 'state'])) - .toThrowError(/no longer a public option/i); - }); - - it('leaves argv alone when `browser` is not present', () => { - expect(rewriteBrowserArgv(['twitter', 'tweets', '@elonmusk'])).toEqual([ - 'twitter', - 'tweets', - '@elonmusk', - ]); - expect(rewriteBrowserArgv(['doctor'])).toEqual(['doctor']); - }); - - it('returns argv unchanged when `browser` is the last token', () => { - expect(rewriteBrowserArgv(['browser'])).toEqual(['browser']); - }); - - it('only rewrites when `browser` is the root command, not deeper in argv', () => { - // `webcmd adapter init browser/x` — the literal `browser` is a path argument, - // not the root command. Must not be touched. - expect(rewriteBrowserArgv(['adapter', 'init', 'browser', 'x'])).toEqual([ - 'adapter', - 'init', - 'browser', - 'x', - ]); - // Same for URLs or arbitrary arg values that happen to contain `browser`. - expect(rewriteBrowserArgv(['twitter', 'tweets', 'https://browser.example.com'])).toEqual([ - 'twitter', - 'tweets', - 'https://browser.example.com', - ]); - // First-match heuristic must NOT rewrite when an earlier non-flag token - // already established a different root command. - expect(rewriteBrowserArgv(['list', 'browser', 'state'])).toEqual([ - 'list', - 'browser', - 'state', - ]); +describe('rejectPositionalBrowserSessionArgv', () => { + it('rejects retired positional browser sessions with the canonical replacement', () => { + expect(() => rejectPositionalBrowserSessionArgv(['browser', 'session_a', 'run', '--stdin'])) + .toThrowError(/webcmd --session session_a browser run --stdin/); }); - it('skips leading root flags before identifying the root command', () => { - // `--profile` takes a value — the value is not the command. - expect(rewriteBrowserArgv(['--profile', 'work', 'browser', 'mercury', 'state'])).toEqual([ - '--profile', - 'work', - 'browser', - '--session', - 'mercury', - 'state', - ]); - // Long form with `=` separator consumes one slot only. - expect(rewriteBrowserArgv(['--profile=work', 'browser', 'mercury', 'state'])).toEqual([ - '--profile=work', - 'browser', - '--session', - 'mercury', - 'state', - ]); - // Boolean flags don't consume values. - expect(rewriteBrowserArgv(['-v', 'browser', 'mercury', 'state'])).toEqual([ - '-v', - 'browser', - '--session', - 'mercury', - 'state', - ]); - }); - - it('hoists a trailing browser --window option to the namespace slot', () => { - expect(rewriteBrowserArgv(['browser', 'work', 'open', 'https://x.com', '--window', 'background'])).toEqual([ - 'browser', - '--session', - 'work', - '--window', - 'background', - 'open', - 'https://x.com', - ]); - expect(rewriteBrowserArgv(['browser', 'work', 'state', '--window', 'foreground'])).toEqual([ - 'browser', - '--session', - 'work', - '--window', - 'foreground', - 'state', - ]); - }); - - it('hoists trailing browser --window after leading root options', () => { - expect(rewriteBrowserArgv(['--profile', 'sandbox', 'browser', 'work', 'state', '--window', 'background'])).toEqual([ - '--profile', - 'sandbox', - 'browser', - '--session', - 'work', - '--window', - 'background', - 'state', - ]); - }); - - it('hoists a trailing browser --window= option', () => { - expect(rewriteBrowserArgv(['browser', 'work', 'open', 'https://x.com', '--window=background'])).toEqual([ - 'browser', - '--session', - 'work', - '--window=background', - 'open', - 'https://x.com', - ]); - }); - - it('hoists browser --window after nested browser leaf commands', () => { - expect(rewriteBrowserArgv(['browser', 'work', 'get', 'url', '--window', 'background'])).toEqual([ - 'browser', - '--session', - 'work', - '--window', - 'background', - 'get', - 'url', - ]); - expect(rewriteBrowserArgv(['browser', 'work', 'tab', 'close', 'abc123', '--window', 'background'])).toEqual([ - 'browser', - '--session', - 'work', - '--window', - 'background', - 'tab', - 'close', - 'abc123', - ]); - }); - - it('leaves an already parent-slot browser --window option untouched', () => { - expect(rewriteBrowserArgv(['browser', 'work', '--window', 'background', 'open', 'https://x.com'])).toEqual([ - 'browser', - '--session', - 'work', - '--window', - 'background', - 'open', - 'https://x.com', - ]); + it('keeps the canonical root selector unchanged', () => { + expect(rejectPositionalBrowserSessionArgv(['--session', 'session_a', 'browser', 'run', '--stdin'])) + .toEqual(['--session', 'session_a', 'browser', 'run', '--stdin']); }); +}); - it('does not hoist browser --window after a literal -- separator', () => { - expect(rewriteBrowserArgv(['browser', 'work', 'eval', 'console.log(1)', '--', '--window', 'background'])).toEqual([ - 'browser', - '--session', - 'work', - 'eval', - 'console.log(1)', - '--', - '--window', - 'background', +describe('rejectPositionalBrowserSessionArgv details', () => { + it('keeps browser subcommands and hoists trailing --window', () => { + expect(rejectPositionalBrowserSessionArgv(['--session', 'session_a', 'browser', 'state', '--window', 'background'])).toEqual([ + '--session', 'session_a', 'browser', '--window', 'background', 'state', ]); }); - it('does not hoist a bare trailing browser --window without a value', () => { - expect(rewriteBrowserArgv(['browser', 'work', 'open', 'https://x.com', '--window'])).toEqual([ - 'browser', - '--session', - 'work', - 'open', - 'https://x.com', - '--window', - ]); + it('leaves non-browser commands unchanged', () => { + expect(rejectPositionalBrowserSessionArgv(['twitter', 'browser', 'session_a', 'state'])) + .toEqual(['twitter', 'browser', 'session_a', 'state']); }); +}); - it('leaves argv alone when the root command is not `browser`, even if `browser` appears later', () => { - // The first browser keyword does NOT win — it must be at the root. - expect(rewriteBrowserArgv(['twitter', 'browser', 'work', 'state'])).toEqual([ - 'twitter', - 'browser', - 'work', - 'state', - ]); +describe('rejectMisplacedSessionSelectorArgv', () => { + it('rejects trailing --session with a copy-pasteable root-selector command', () => { + expect(() => rejectMisplacedSessionSelectorArgv(['browser', 'run', '--session', 'session_a'])) + .toThrowError(/SESSION_SELECTOR_POSITION: --session must appear before the command\. Use: webcmd --session session_a browser run/); + expect(() => rejectMisplacedSessionSelectorArgv(['github', 'issues', '--session=session_b'])) + .toThrowError(/Use: webcmd --session session_b github issues/); }); - it('reserved subcommand list covers every known browser subcommand registered in cli.ts', () => { - const names = getBrowserSubcommandNames(); - const required = [ - 'analyze', 'back', 'bind', 'check', 'click', 'close', 'console', 'dblclick', - 'dialog', 'drag', 'eval', 'extract', 'fill', 'find', 'focus', 'frames', - 'get', 'hover', 'init', 'keys', 'network', 'open', 'screenshot', 'scroll', - 'run', 'select', 'state', 'tab', 'type', 'unbind', 'uncheck', 'upload', 'verify', - 'wait', - ]; - for (const name of required) { - expect(names.has(name)).toBe(true); - } + it('keeps the root --session selector unchanged', () => { + expect(rejectMisplacedSessionSelectorArgv(['--session', 'session_a', 'browser', 'run'])) + .toEqual(['--session', 'session_a', 'browser', 'run']); }); }); diff --git a/src/cli-argv-preprocess.ts b/src/cli-argv-preprocess.ts index 8201be87..14969eaa 100644 --- a/src/cli-argv-preprocess.ts +++ b/src/cli-argv-preprocess.ts @@ -1,10 +1,6 @@ /** - * argv preprocessing: rewrite `webcmd browser ...` - * into `webcmd browser --session ...` so commander - * (which can't combine a parent positional with subcommand dispatch) can parse it. - * - * The user-facing form is positional; the internal form uses --session. Help text - * for the `browser` command is overridden to advertise the positional form. + * Reject the retired positional browser-session grammar before Commander parses + * the canonical root `--session` selector. */ /** @@ -30,6 +26,7 @@ const BROWSER_SUBCOMMAND_NAMES: ReadonlySet = new Set([ 'fill', 'find', 'focus', + 'fork', 'frames', 'get', 'help', @@ -42,8 +39,10 @@ const BROWSER_SUBCOMMAND_NAMES: ReadonlySet = new Set([ 'screenshot', 'scroll', 'select', + 'snapshot', 'state', 'tab', + 'tabs', 'type', 'unbind', 'uncheck', @@ -60,7 +59,7 @@ const BROWSER_SUBCOMMAND_NAMES: ReadonlySet = new Set([ * * Keep in sync with `program.option(...)` calls in cli.ts. */ -const ROOT_VALUE_FLAGS: ReadonlySet = new Set(['--profile']); +const ROOT_VALUE_FLAGS: ReadonlySet = new Set(['--profile', '--session', '--workspace']); /** * Returns the set of reserved subcommand names (exposed for tests so they stay @@ -70,62 +69,59 @@ export function getBrowserSubcommandNames(): ReadonlySet { return BROWSER_SUBCOMMAND_NAMES; } -/** - * Rewrite `argv` to convert the positional `` after `browser` - * into the internal `--session ` flag form. - * - * Only acts when `browser` is the root command (i.e. the first non-flag token - * after any leading root options), so it can't mis-interpret occurrences of - * the literal word `browser` deeper in the argv (e.g. `webcmd adapter init - * browser/x`, or a URL value containing `browser`). - * - * Leaves argv unchanged when: - * - root command is not `browser` - * - the token after `browser` is a flag (e.g. `--help`) - * - the token after `browser` is a known subcommand name (session was - * omitted; commander will surface its own required-flag error) - */ -export function rewriteBrowserArgv(argv: readonly string[]): string[] { +/** Rejects retired `browser ...` while preserving canonical argv. */ +export function rejectPositionalBrowserSessionArgv(argv: readonly string[]): string[] { const result = [...argv]; - // Walk past leading root flags + their values to find the root command token. - let i = 0; - while (i < result.length) { - const tok = result[i]; - if (!tok.startsWith('-')) break; - // `--flag=value` consumes one slot regardless of whether the flag expects a value. - if (tok.includes('=')) { - i += 1; - continue; - } - if (ROOT_VALUE_FLAGS.has(tok) && i + 1 < result.length) { - i += 2; - } else { - i += 1; - } + const commandIndex = findRootCommandIndex(result); + if (result[commandIndex] !== 'browser') return result; + const candidate = result[commandIndex + 1]; + if (!candidate || candidate.startsWith('-') || BROWSER_SUBCOMMAND_NAMES.has(candidate)) { + hoistBrowserWindowOption(result, commandIndex + 1); + return result; } - if (result[i] !== 'browser') return result; - const sessionIdx = i + 1; - const next = result[sessionIdx]; - if (next === undefined) return result; - // The retired `--session` flag must not be a working public entrance. - if (next === '--session' || next === '--session=' || next.startsWith('--session=')) { - throw new BrowserSessionArgvError( - 'The `--session` flag is no longer a public option. Use the positional form: webcmd browser ', - ); + const replacement = [ + ...result.slice(0, commandIndex), + '--session', candidate, + 'browser', + ...result.slice(commandIndex + 2), + ]; + throw new BrowserSessionArgvError( + `Browser sessions are root selectors. Use: webcmd ${replacement.join(' ')}`, + ); +} + +export function rejectMisplacedSessionSelectorArgv(argv: readonly string[]): string[] { + const result = [...argv]; + const commandIndex = findRootCommandIndex(result); + for (let index = commandIndex + 1; index < result.length; index += 1) { + const token = result[index]; + if (token === '--') break; + if (token === '--session' || token.startsWith('--session=')) { + const sessionId = token === '--session' + ? (result[index + 1] && !result[index + 1]!.startsWith('-') ? result[index + 1]! : '') + : token.slice('--session='.length); + const withoutMisplaced = [ + ...result.slice(0, index), + ...result.slice(index + (token === '--session' && sessionId !== '' ? 2 : 1)), + ]; + throw new BrowserSessionArgvError( + `SESSION_SELECTOR_POSITION: --session must appear before the command. Use: webcmd --session ${sessionId} ${withoutMisplaced.join(' ')}`, + ); + } } - if (next.startsWith('-')) return result; - if (BROWSER_SUBCOMMAND_NAMES.has(next)) return result; - // Splice in --session in place of the positional. - result.splice(sessionIdx, 1, '--session', next); - // `--window` is a browser namespace option, so commander accepts it before the - // leaf command. Users naturally put it at the end: - // `browser work open https://x.com --window background`. Hoist that public - // form into the namespace-option slot instead of mirroring the option onto - // every browser leaf command. - hoistBrowserWindowOption(result, sessionIdx + 2); return result; } +function findRootCommandIndex(argv: readonly string[]): number { + let index = 0; + while (index < argv.length) { + const token = argv[index]!; + if (!token.startsWith('-')) return index; + index += token.includes('=') || !ROOT_VALUE_FLAGS.has(token) ? 1 : 2; + } + return index; +} + /** * Move one trailing `--window ` / `--window=` from after the browser * subcommand to just before it. Stops at `--` so literal browser arguments are diff --git a/src/cli.test.ts b/src/cli.test.ts index 8d327a80..7cb27635 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -5,6 +5,7 @@ import * as path from 'node:path'; import yaml from 'js-yaml'; import { cli, getRegistry, runWithDiscoverySource, Strategy } from './registry.js'; import { BrowserCommandError } from './browser/daemon-client.js'; +import { getDaemonRunContext } from './session-lease.js'; import type { IPage } from './types.js'; import { TargetError } from './browser/target-errors.js'; import { PKG_VERSION } from './version.js'; @@ -23,6 +24,7 @@ const { mockBrowserClose, mockBindTab, mockListExistingBrowserTabs, + mockReleaseSiteSessionLease, mockSendCommand, mockExecFileSync, browserState, @@ -31,6 +33,7 @@ const { mockBrowserClose: vi.fn(), mockBindTab: vi.fn(), mockListExistingBrowserTabs: vi.fn(), + mockReleaseSiteSessionLease: vi.fn(), mockSendCommand: vi.fn(), mockExecFileSync: vi.fn(), browserState: { page: null as IPage | null }, @@ -51,6 +54,7 @@ vi.mock('./browser/daemon-client.js', async () => { ...actual, bindTab: mockBindTab, listExistingBrowserTabs: mockListExistingBrowserTabs, + releaseSiteSessionLease: mockReleaseSiteSessionLease, sendCommand: mockSendCommand, }; }); @@ -1060,22 +1064,19 @@ name: 'search', const browser = program.commands.find(cmd => cmd.name() === 'browser'); expect(browser).toBeTruthy(); - process.argv = ['node', 'webcmd', 'browser', '--session', 'test', '--help', '-f', 'yaml']; + process.argv = ['node', 'webcmd', '--session', 'session_test', 'browser', '--help', '-f', 'yaml']; const data = yaml.load(browser!.helpInformation()) as any; expect(data.namespace).toBe('browser'); expect(data.command).toBe('webcmd browser'); - expect(data.description).toBe('Run Playwright programs against named browser sessions'); + expect(data.description).toBe('Run Playwright programs against an explicit browser Session'); expect(data.command_count).toBe(8); expect(data.commands.map((cmd: any) => cmd.name)).toEqual(['bind', 'close', 'fork', 'init', 'run', 'snapshot', 'tabs', 'verify']); - // `--session` is now a hidden internal option; user-facing surface is the - // positional declared via `.usage()`. Structured help drops - // hidden options, so namespace_options shouldn't expose it. expect(data.namespace_options).not.toEqual(expect.arrayContaining([ expect.objectContaining({ name: 'session' }), ])); expect(data.namespace_options).toEqual([]); - expect(data.usage).toBe('webcmd browser [options]'); + expect(data.usage).toBe('webcmd browser [args] [options]'); expect(data.global_options).toEqual(expect.arrayContaining([ expect.objectContaining({ name: 'version', @@ -1086,15 +1087,17 @@ name: 'search', flags: '--profile ', takes_value: 'required', }), + expect.objectContaining({ + name: 'session', + flags: '--session ', + takes_value: 'required', + }), ])); const bind = data.commands.find((cmd: any) => cmd.name === 'bind'); - // Structured help command/usage paths include the positional so - // agents construct the correct full invocation. `name` is the leaf - // identifier (placeholder positionals are stripped). expect(bind).toMatchObject({ - command: 'webcmd browser bind', - usage: 'webcmd browser bind [options]', + command: 'webcmd browser bind', + usage: 'webcmd browser bind [options]', positionals: [], }); expect(bind.command_options.map((option: any) => option.name)).toEqual(['page']); @@ -1414,7 +1417,7 @@ describe('browser verify', () => { fs.writeFileSync(path.join(adapterDir, 'top.js'), 'export default {};\n', 'utf-8'); const program = createProgram('', ''); - await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'verify', 'hn/top', '--no-fixture', '--trace', 'retain-on-failure']); + await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'verify', 'hn/top', '--no-fixture', '--trace', 'retain-on-failure']); expect(mockExecFileSync).toHaveBeenCalledTimes(1); const [, execArgs] = mockExecFileSync.mock.calls[0] as [string, string[]]; @@ -1441,7 +1444,7 @@ describe('browser verify', () => { fs.writeFileSync(path.join(adapterDir, 'top.js'), 'export default {};\n', 'utf-8'); const program = createProgram('', ''); - await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'verify', 'hn/top', '--no-fixture', '--seed-args', 'webcmd-verify']); + await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'verify', 'hn/top', '--no-fixture', '--seed-args', 'webcmd-verify']); expect(mockExecFileSync).toHaveBeenCalledTimes(1); const [, execArgs] = mockExecFileSync.mock.calls[0] as [string, string[]]; @@ -1469,7 +1472,7 @@ describe('browser verify', () => { fs.writeFileSync(path.join(adapterDir, 'top.js'), 'export default {};\n', 'utf-8'); const program = createProgram('', ''); - await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'verify', 'hn/top', '--write-fixture', '--seed-args', 'webcmd-verify']); + await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'verify', 'hn/top', '--write-fixture', '--seed-args', 'webcmd-verify']); const fixtureFile = path.join(fakeHome, '.webcmd', 'sites', 'hn', 'verify', 'top.json'); const fixture = JSON.parse(fs.readFileSync(fixtureFile, 'utf-8')); @@ -1500,7 +1503,7 @@ describe('browser verify', () => { fs.writeFileSync(path.join(adapterDir, 'top.js'), 'export default {};\n', 'utf-8'); const program = createProgram('', ''); - await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'verify', 'hn/top', '--no-fixture']); + await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'verify', 'hn/top', '--no-fixture']); expect(process.exitCode).toBe(1); const output = consoleLogSpy.mock.calls.map((args) => args.join(' ')).join('\n'); @@ -1591,17 +1594,32 @@ describe('browser raw session commands', () => { stderrSpy.mockClear(); mockBrowserConnect.mockClear(); mockListExistingBrowserTabs.mockReset().mockResolvedValue([]); + mockReleaseSiteSessionLease.mockReset().mockResolvedValue(undefined); mockSendCommand.mockReset().mockResolvedValue({ ok: true }); }); + it.each([ + { argv: ['browser', 'tabs'], code: 'SESSION_REQUIRED' }, + { argv: ['--session', 'work', 'browser', 'tabs'], code: 'INVALID_SESSION_SELECTOR' }, + ])('rejects an unusable raw selector with exit 2 before daemon dispatch: $code', async ({ argv, code }) => { + const program = createProgram('', ''); + + await program.parseAsync(['node', 'webcmd', ...argv]); + + expect(process.exitCode).toBe(2); + expect(mockListExistingBrowserTabs).not.toHaveBeenCalled(); + expect(mockSendCommand).not.toHaveBeenCalled(); + expect(stderrSpy.mock.calls.flat().join('')).toContain(code); + }); + it('lists tabs without allocating a local browser runtime', async () => { const program = createProgram('', ''); - await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'tabs']); + await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'tabs']); expect(mockBrowserConnect).not.toHaveBeenCalled(); expect(mockSendCommand).not.toHaveBeenCalled(); - expect(mockListExistingBrowserTabs).toHaveBeenCalledWith('test', {}); + expect(mockListExistingBrowserTabs).toHaveBeenCalledWith('session_test', {}); expect(consoleLogSpy).toHaveBeenLastCalledWith('[]'); }); @@ -1609,34 +1627,61 @@ describe('browser raw session commands', () => { mockListExistingBrowserTabs.mockResolvedValue([{ page: 'page-123' }]); const program = createProgram('', ''); - await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'tabs']); + await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'tabs']); expect(mockBrowserConnect).not.toHaveBeenCalled(); - expect(mockListExistingBrowserTabs).toHaveBeenCalledWith('test', {}); + expect(mockListExistingBrowserTabs).toHaveBeenCalledWith('session_test', {}); }); it('binds only an explicit stable page id', async () => { const program = createProgram('', ''); - await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'bind', '--page', 'page-123']); + await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'bind', '--page', 'page-123']); expect(mockSendCommand).toHaveBeenCalledWith('bind', { - session: 'test', surface: 'browser', page: 'page-123', + session: 'session_test', surface: 'browser', page: 'page-123', }); - await expect(program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'bind', '--index', '0'])) + await expect(program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'bind', '--index', '0'])) .rejects.toThrow(/process\.exit unexpectedly called/); - await expect(program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'bind', '--page', ' '])) + await expect(program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'bind', '--page', ' '])) .rejects.toThrow(/process\.exit unexpectedly called/); }); it('sends snapshot inspection options to the browser runtime', async () => { const program = createProgram('', ''); - await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'snapshot', '--snapshot-mode', 'read', '--ref', 'e12', '--max-output', '1000']); + await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'snapshot', '--snapshot-mode', 'read', '--ref', 'e12', '--max-output', '1000']); expect(mockSendCommand).toHaveBeenCalledWith('snapshot', { - session: 'test', surface: 'browser', snapshotMode: 'read', ref: 'e12', maxOutputChars: 1000, + session: 'session_test', surface: 'browser', snapshotMode: 'read', ref: 'e12', maxOutputChars: 1000, + }); + }); + + it('binds raw browser daemon operations to one logical run', async () => { + let run = getDaemonRunContext(); + mockSendCommand.mockImplementation(async () => { + run = getDaemonRunContext(); + return { ok: true }; + }); + const program = createProgram('', ''); + + await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'snapshot']); + + expect(run).toMatchObject({ + runId: expect.stringMatching(/^run_/), + command: 'browser/snapshot', }); + expect(getDaemonRunContext()).toBeUndefined(); + }); + + it('keeps the raw browser lease when the daemon outcome is unknown', async () => { + mockSendCommand.mockRejectedValue(new BrowserCommandError('Result unknown', 'command_result_unknown')); + const program = createProgram('', ''); + + await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'snapshot']); + + expect(process.exitCode).toBe(1); + expect(mockReleaseSiteSessionLease).not.toHaveBeenCalled(); }); it('reads program files for run and rejects mutually exclusive input', async () => { @@ -1644,18 +1689,18 @@ describe('browser raw session commands', () => { fs.writeFileSync(sourcePath, 'return 42;', 'utf8'); try { const program = createProgram('', ''); - await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'run', '--file', sourcePath]); + await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'run', '--file', sourcePath]); expect(mockSendCommand).toHaveBeenCalledWith('run', { - session: 'test', surface: 'browser', source: 'return 42;', snapshotMode: 'act', + session: 'session_test', surface: 'browser', source: 'return 42;', snapshotMode: 'act', }); - await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'run', '--stdin', '--file', sourcePath]); + await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'run', '--stdin', '--file', sourcePath]); expect(mockSendCommand).toHaveBeenCalledTimes(1); expect(process.exitCode).toBeDefined(); process.exitCode = undefined; fs.writeFileSync(sourcePath, '', 'utf8'); - await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'run', '--file', sourcePath]); + await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'run', '--file', sourcePath]); expect(mockSendCommand).toHaveBeenCalledTimes(1); expect(process.exitCode).toBeDefined(); } finally { @@ -1665,8 +1710,92 @@ describe('browser raw session commands', () => { it('closes the named session through the daemon', async () => { const program = createProgram('', ''); - await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'close']); - expect(mockSendCommand).toHaveBeenCalledWith('close-window', { session: 'test', surface: 'browser' }); + await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'close']); + expect(mockSendCommand).toHaveBeenCalledWith('close-window', { session: 'session_test', surface: 'browser' }); + }); +}); + +describe('browser Session lifecycle commands', () => { + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + beforeEach(() => { + process.exitCode = undefined; + consoleLogSpy.mockClear(); + mockSendCommand.mockReset(); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('daemon offline'))); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('creates a Session through the daemon mutation path', async () => { + mockSendCommand.mockResolvedValue({ + id: 'session_abc', + kind: 'explicit', + profileId: 'default', + runtimeState: 'idle', + }); + + await createProgram('', '').parseAsync(['node', 'webcmd', 'session', 'create']); + + expect(mockSendCommand).toHaveBeenCalledWith('session-create', { contextId: 'default' }); + const output = consoleLogSpy.mock.calls.flat().join('\n'); + expect(output).toContain('session_abc'); + expect(output).toContain('runtimeState'); + expect(output).not.toContain('profileId'); + }); + + it('lists persisted Sessions without creating the adapter default when daemon is absent', async () => { + const baseDir = path.join(isolatedCliTestHome, '.webcmd'); + fs.mkdirSync(baseDir, { recursive: true }); + fs.writeFileSync(path.join(baseDir, 'browser-sessions.json'), JSON.stringify({ + version: 1, + sessions: [{ + id: 'session_existing', + profileId: 'default', + kind: 'explicit', + createdAt: '2026-08-11T00:00:00.000Z', + updatedAt: '2026-08-11T00:00:00.000Z', + lastUsedAt: '2026-08-11T00:00:00.000Z', + }], + }), { mode: 0o600 }); + + await createProgram('', '').parseAsync(['node', 'webcmd', 'session', 'list', '-f', 'json']); + + expect(mockSendCommand).not.toHaveBeenCalled(); + const rows = JSON.parse(consoleLogSpy.mock.calls.flat().join('\n')); + expect(rows).toEqual([expect.objectContaining({ id: 'session_existing', runtimeState: 'idle' })]); + }); + + it('closes an idle persisted Session as a no-op when daemon is absent', async () => { + mockSendCommand.mockRejectedValueOnce(new Error('daemon unavailable')); + const baseDir = path.join(isolatedCliTestHome, '.webcmd'); + fs.mkdirSync(baseDir, { recursive: true }); + fs.writeFileSync(path.join(baseDir, 'browser-sessions.json'), JSON.stringify({ + version: 1, + sessions: [{ + id: 'session_idle', + profileId: 'default', + kind: 'explicit', + createdAt: '2026-08-11T00:00:00.000Z', + updatedAt: '2026-08-11T00:00:00.000Z', + lastUsedAt: '2026-08-11T00:00:00.000Z', + }], + }), { mode: 0o600 }); + + await createProgram('', '').parseAsync(['node', 'webcmd', 'session', 'close', 'session_idle', '-f', 'json']); + + expect(mockSendCommand).toHaveBeenCalledWith('session-close', { + contextId: 'default', + session: 'session_idle', + force: false, + }); + expect(JSON.parse(consoleLogSpy.mock.calls.flat().join('\n'))).toMatchObject({ + closed: false, + alreadyIdle: true, + session: 'session_idle', + }); }); }); @@ -1699,7 +1828,7 @@ function installSelectorFirstTestHarness(label: string, pageOverrides: () => Par setActivePage: vi.fn(), getActivePage: vi.fn().mockReturnValue('tab-1'), tabs: vi.fn().mockResolvedValue([{ page: 'tab-1', active: true }]), - session: 'test', + session: 'session_test', ...pageOverrides(), } as unknown as IPage; }); diff --git a/src/cli.ts b/src/cli.ts index 31042c1f..78cfc87f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -38,7 +38,7 @@ import { browserOptionValueParser } from './browser/command-catalog.js'; import { registerAuthCommands } from './commands/auth.js'; import { daemonRestart, daemonStatus, daemonStop } from './commands/daemon.js'; import { isVerbose, log } from './logger.js'; -import { BrowserCommandError, listExistingBrowserTabs, sendCommand } from './browser/daemon-client.js'; +import { BrowserCommandError, listExistingBrowserTabs, releaseSiteSessionLease, sendCommand } from './browser/daemon-client.js'; import { fetchDaemonStatus } from './browser/daemon-transport.js'; import { aliasForContextId, loadProfileConfig, profileRouteParams, renameProfile, resolveProfileSelection, setDefaultProfile, type ProfileSelection } from './browser/profile.js'; import { formatDaemonVersion, isDaemonStale } from './browser/daemon-version.js'; @@ -47,11 +47,14 @@ import { CLI_COMMAND, PACKAGE_NAME } from './brand.js'; import type { BrowserDownloadWaitResult, IPage, ScreenshotOptions } from './types.js'; import type { BrowserWindowMode } from './runtime.js'; import { configureRootCommandSurface } from './root-command-surface.js'; +import { validateRawBrowserSession } from './hosted/browser-args.js'; +import { LocalBrowserSessionStore, requireSessionIdShape, type BrowserSessionListRow } from './browser/sessions.js'; import { missingPluginGuidance, PLUGINS_DIR } from './discovery.js'; import { loadBrowserRunSource } from './browser/run/input.js'; import { BrowserRunError } from './browser/run/types.js'; import { classifyCommandOrigin, formatCommandOrigin } from './command-origin.js'; import { readOverrideRecords, removeOverrideRecords } from './override-provenance.js'; +import { clearDaemonRunContext, generateRunId, isUnknownOutcomeError, runWithDaemonRunContext } from './session-lease.js'; const CLI_FILE = fileURLToPath(import.meta.url); const FOLLOW_POLL_MS = 1_000; @@ -65,6 +68,14 @@ function parsePositiveIntOption(value: string | undefined, _label: string, fallb return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback; } +function parseSessionListLimit(value: string): number { + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 100) { + throw new ArgumentError('Session list limit must be an integer from 1 to 100.'); + } + return parsed; +} + type BrowserNetworkItem = { url: string; method: string; @@ -539,12 +550,7 @@ function getCommandOption(command: Command | undefined, option: string): unknown } function getBrowserSession(command?: Command): string { - // The CLI surface is `webcmd browser `. main.ts rewrites - // argv to insert `--session ` before commander parses it; this helper - // reads back the rewritten flag. - const raw = getCommandOption(command, 'session'); - if (typeof raw === 'string' && raw.trim()) return raw.trim(); - throw new Error(' is a required positional argument: webcmd browser '); + return validateRawBrowserSession(getCommandOption(command, 'session'), getCommandOption(command, 'profile') as string | undefined); } function getBrowserProfileSelection(command?: Command): ProfileSelection | undefined { @@ -552,6 +558,20 @@ function getBrowserProfileSelection(command?: Command): ProfileSelection | undef return resolveProfileSelection(typeof raw === 'string' && raw.trim() ? raw.trim() : undefined); } +function getSelectedProfileId(command?: Command): string { + return getBrowserProfileSelection(command)?.contextId ?? 'default'; +} + +function formatHandoff(row: BrowserSessionListRow): string { + return row.handoff ? `${row.handoff.site} until ${row.handoff.expiresAt}` : ''; +} + +function sessionCreateOutput(data: unknown): unknown { + if (!data || typeof data !== 'object' || Array.isArray(data)) return data; + const row = data as Record; + return { id: row.id, kind: row.kind, runtimeState: row.runtimeState }; +} + function applyVerbose(opts: { verbose?: boolean }): void { if (opts.verbose) process.env.WEBCMD_VERBOSE = '1'; } @@ -786,6 +806,72 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string, pluginsDi if (opts.strict && !report.ok) process.exitCode = EXIT_CODES.GENERIC_ERROR; }); + const sessionCmd = program.command('session').description('Create, list, and close browser Sessions'); + + sessionCmd + .command('create') + .description('Create a new opaque browser Session ID for the selected Profile') + .option('-f, --format ', 'Output format: table, json, yaml', 'yaml') + .action(async (opts, command) => { + const profileId = getSelectedProfileId(command); + const data = await sendCommand('session-create', { contextId: profileId }); + await renderOutput(sessionCreateOutput(data), { fmt: opts.format, columns: ['id', 'kind', 'runtimeState'] }); + }); + + sessionCmd + .command('list') + .description('List browser Sessions for the selected Profile') + .option('--limit ', 'Maximum Sessions to return (1-100)', parseSessionListLimit, 20) + .option('-f, --format ', 'Output format: table, json, yaml', 'table') + .action(async (opts, command) => { + const profileId = getSelectedProfileId(command); + let rows: BrowserSessionListRow[]; + const status = await fetchDaemonStatus({ contextId: profileId }); + if (status?.runtimeConnected && !isDaemonStale(status, PKG_VERSION)) { + rows = await sendCommand('session-list', { contextId: profileId, limit: opts.limit }) as BrowserSessionListRow[]; + } else { + rows = new LocalBrowserSessionStore().list(profileId, opts.limit); + } + const output = rows.map((row) => ({ ...row, handoff: formatHandoff(row) })); + if (output.length === 0 && String(opts.format ?? 'table') === 'table') { + console.log(`No browser Sessions found for Profile ${profileId}.`); + return; + } + await renderOutput(output, { fmt: opts.format, columns: ['id', 'kind', 'runtimeState', 'handoff'] }); + }); + + sessionCmd + .command('close') + .description('Close a browser Session runtime without deleting its durable record') + .argument('', 'Existing opaque Session ID from `webcmd session create`') + .option('-f, --format ', 'Output format: table, json, yaml', 'yaml') + .option('--force', 'Close even while the Session is busy or paused for handoff') + .action(async (sessionId: string, opts: { format?: string; force?: boolean }, command) => { + const profileId = getSelectedProfileId(command); + requireSessionIdShape(sessionId); + const status = await fetchDaemonStatus({ contextId: profileId }); + if (!status || (status.runtimeConnected && !isDaemonStale(status, PKG_VERSION))) { + try { + const data = await sendCommand('session-close', { + contextId: profileId, + session: sessionId, + force: opts.force === true, + }); + await renderOutput(data, { fmt: opts.format }); + return; + } catch (error) { + if (status || opts.force === true) throw error; + } + } + if (opts.force === true) { + const data = await sendCommand('session-close', { contextId: profileId, session: sessionId, force: true }); + await renderOutput(data, { fmt: opts.format }); + return; + } + new LocalBrowserSessionStore().require(profileId, sessionId); + await renderOutput({ closed: false, alreadyIdle: true, session: sessionId }, { fmt: opts.format }); + }); + // ── Built-in: browser (browser control for Claude Code skill) ─────────────── // // Make websites accessible for AI agents. @@ -793,20 +879,7 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string, pluginsDi const browser = program .command('browser') - // --session is an internal hidden option used by the daemon protocol and direct - // program.parseAsync callers (tests). User-facing surface is the - // positional; main.ts argv preprocessor rewrites positional -> --session. - .addOption(new Option('--session ', 'Internal — set automatically from the positional').hideHelp()) - .description('Run Playwright programs against named browser sessions') - .usage(' [options]') - .addHelpText('after', ` - is a required positional: pass the name of the browser session every subcommand should operate on. Reuse the same name across calls to keep the tab/state alive; pick a different name to isolate parallel browser work. - -Examples: - $ webcmd browser work tabs - $ webcmd browser work bind --page page-123 - $ printf 'await page.goto("https://example.com")' | webcmd browser work run --stdin -`); + .description('Run Playwright programs against an explicit browser Session'); const originalBrowserDescription = browser.description(); // ── Init (adapter scaffolding) ── @@ -868,7 +941,7 @@ cli({ fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(filePath, template, 'utf-8'); console.log(`Created: ${filePath}`); - console.log('First time on this site? Run: webcmd browser recon run --stdin'); + console.log('First time on this site? Run: webcmd session create, then webcmd --session browser run --stdin'); console.log(`Edit the file to implement your adapter, then run: webcmd browser verify ${name}`); } catch (err) { console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); @@ -1041,11 +1114,16 @@ cli({ function rawBrowserAction(fn: (session: string, routing: { contextId?: string; preferredContextId?: string }, opts: Record) => Promise) { return async (opts: Record, command: Command) => { + const runId = generateRunId(); + const commandName = `browser/${command.name()}`; + let releaseRun = true; try { const session = getBrowserSession(command); const routing = profileRouteParams(getBrowserProfileSelection(command)); - console.log(JSON.stringify(await fn(session, routing, opts), null, 2)); + const result = await runWithDaemonRunContext({ runId, command: commandName }, () => fn(session, routing, opts)); + console.log(JSON.stringify(result, null, 2)); } catch (error) { + if (isUnknownOutcomeError(error)) releaseRun = false; if (error instanceof BrowserCommandError && error.code) { console.log(JSON.stringify({ error: { @@ -1056,8 +1134,12 @@ cli({ }, }, null, 2)); } - log.error(error instanceof Error ? error.message : String(error)); - process.exitCode = EXIT_CODES.GENERIC_ERROR; + log.error(error instanceof CliError ? `${error.code}: ${error.message}` : error instanceof Error ? error.message : String(error)); + if (error instanceof CliError && error.hint) log.error(error.hint); + process.exitCode = error instanceof CliError ? error.exitCode : EXIT_CODES.GENERIC_ERROR; + } finally { + clearDaemonRunContext(runId); + if (releaseRun) await releaseSiteSessionLease(runId); } }; } @@ -1866,28 +1948,6 @@ cli({ program.configureHelp({ visibleCommands: (command) => command.commands.filter(child => command !== program || !adapterNameSet.has(child.name())), }); - // When an ancestor command declares a leading positional via `.usage(...)` - // (e.g. `browser` -> ` [options]`), inject the positional - // between that ancestor's name and the next path segment so the help Usage - // line is accurate: `Usage: webcmd browser run [options]` - // instead of `webcmd browser run [options]`. Commander does NOT - // inherit configureHelp into subcommands, so we walk the descendant tree and - // apply the override on each. - const ancestorAwareCommandUsage = (cmd: Command): string => { - const ancestors: string[] = []; - let ancestor: Command | null = cmd.parent; - while (ancestor) { - const positional = leadingPositionalFromUsage(ancestor); - ancestors.unshift(positional ? `${ancestor.name()} ${positional}` : ancestor.name()); - ancestor = ancestor.parent; - } - return [...ancestors, cmd.name(), cmd.usage()].filter(Boolean).join(' ').trim(); - }; - function applyAncestorAwareUsage(cmd: Command): void { - cmd.configureHelp({ commandUsage: ancestorAwareCommandUsage }); - for (const sub of cmd.commands) applyAncestorAwareUsage(sub); - } - applyAncestorAwareUsage(browser); installRootPresentationHelp( program, () => rootHelpData(program, adapterGroups), diff --git a/src/commanderAdapter.ts b/src/commanderAdapter.ts index 0664c4f5..6ae9a433 100644 --- a/src/commanderAdapter.ts +++ b/src/commanderAdapter.ts @@ -104,6 +104,7 @@ export function registerCommandToProgram( const result = await executeCommand(cmd, kwargs, verbose, { prepared: true, ...(typeof globals.profile === 'string' && globals.profile.trim() ? { profile: globals.profile.trim() } : {}), + ...(typeof globals.session === 'string' && globals.session.trim() ? { session: globals.session.trim() } : {}), ...(typeof optionsRecord.trace === 'string' && optionsRecord.trace !== 'off' ? { trace: optionsRecord.trace } : {}), ...(cmd.browser && typeof optionsRecord.window === 'string' ? { windowMode: optionsRecord.window } : {}), ...(cmd.browser && typeof optionsRecord.siteSession === 'string' ? { siteSession: optionsRecord.siteSession } : {}), diff --git a/src/completion-shared.ts b/src/completion-shared.ts index 36932766..f4a41caa 100644 --- a/src/completion-shared.ts +++ b/src/completion-shared.ts @@ -30,7 +30,7 @@ export const HOSTED_ROOT_HELP: RootHelpPresentation = { description: 'Make any website your CLI. Zero setup. AI-powered.', usage: [ `${CLI_COMMAND} [args] [options]`, - `${CLI_COMMAND} browser [args] [options]`, + `${CLI_COMMAND} --session browser [args] [options]`, `${CLI_COMMAND} list [options]`, `${CLI_COMMAND} setup`, ], diff --git a/src/daemon/server.test.ts b/src/daemon/server.test.ts index d3bd5c8e..93cc63d7 100644 --- a/src/daemon/server.test.ts +++ b/src/daemon/server.test.ts @@ -1,16 +1,22 @@ import { AddressInfo } from 'node:net'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { request as httpRequest } from 'node:http'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { DAEMON_HEADER_NAME } from '../constants.js'; import type { BrowserRuntimeCommand, BrowserRuntimeResult, BrowserRuntimeStatus } from '../browser/protocol.js'; +import type { BrowserSessionListRow, BrowserSessionRecord } from '../browser/sessions.js'; import type { BrowserRuntimeProvider } from '../browser/runtime/provider.js'; import { createDaemonServer } from './server.js'; class FakeProvider implements BrowserRuntimeProvider { commands: BrowserRuntimeCommand[] = []; + sessions: BrowserSessionRecord[] = []; + activeSessions = new Set(); + foregroundedSessions: string[] = []; shutdownCalled = false; delayMs = 0; - dispatchImpl?: (command: BrowserRuntimeCommand) => Promise; + dispatchImpl?: (command: BrowserRuntimeCommand, signal?: AbortSignal) => Promise; resolveProfileId?: (command: BrowserRuntimeCommand) => string; + sessionId = 'session_11111111-1111-4111-8111-111111111111'; private result(command: BrowserRuntimeCommand) { return { id: command.id, ok: true as const, data: { action: command.action }, page: 'page-1' }; @@ -24,12 +30,74 @@ class FakeProvider implements BrowserRuntimeProvider { profiles: [{ contextId: 'default', runtimeConnected: true, runtimeVersion: '1.2.3', pending: 0 }], pending: 0, commandResultUnknown: 0, + sessions: this.listSessionRows(), }; } - async dispatch(command: BrowserRuntimeCommand) { + async createSession(command: BrowserRuntimeCommand): Promise { + const profileId = command.contextId ?? 'default'; + const session = { + id: this.sessionId, + profileId, + kind: 'explicit' as const, + createdAt: '2026-08-11T00:00:00.000Z', + updatedAt: '2026-08-11T00:00:00.000Z', + lastUsedAt: '2026-08-11T00:00:00.000Z', + }; + this.sessions.push(session); + return session; + } + + async listSessions(input: { profileId?: string }): Promise { + return this.listSessionRows(input.profileId); + } + + async closeSession(command: BrowserRuntimeCommand): Promise<{ closed: boolean; alreadyIdle: boolean; session: string }> { + const session = String(command.session); + const wasActive = this.activeSessions.delete(session); + return { closed: wasActive, alreadyIdle: !wasActive, session }; + } + + async requireSession(command: BrowserRuntimeCommand): Promise { + const profileId = command.contextId ?? 'default'; + const existing = this.sessions.find((session) => session.profileId === profileId && session.id === command.session); + if (existing) return existing; + return { + id: String(command.session), + profileId, + kind: command.session === 'session_default' ? 'adapter-default' : 'explicit', + createdAt: '2026-08-11T00:00:00.000Z', + updatedAt: '2026-08-11T00:00:00.000Z', + lastUsedAt: '2026-08-11T00:00:00.000Z', + }; + } + + async resolveAdapterDefault(command: BrowserRuntimeCommand): Promise { + return { + ...await this.requireSession({ ...command, session: command.session ?? 'session_default' }), + kind: 'adapter-default', + }; + } + + async startSessionHandoff(command: BrowserRuntimeCommand): Promise { + const record = await this.requireSession(command); + record.handoff = { site: String(command.site), expiresAt: String(command.expiresAt) }; + const index = this.sessions.findIndex((session) => session.profileId === record.profileId && session.id === record.id); + if (index === -1) this.sessions.push(record); + else this.sessions[index] = record; + this.foregroundedSessions.push(record.id); + return record; + } + + async clearSessionHandoff(command: BrowserRuntimeCommand): Promise { + const record = await this.requireSession(command); + delete record.handoff; + return record; + } + + async dispatch(command: BrowserRuntimeCommand, signal?: AbortSignal) { this.commands.push(command); - if (this.dispatchImpl) return this.dispatchImpl(command); + if (this.dispatchImpl) return this.dispatchImpl(command, signal); if (this.delayMs > 0) await new Promise((resolve) => setTimeout(resolve, this.delayMs)); return this.result(command); } @@ -37,14 +105,28 @@ class FakeProvider implements BrowserRuntimeProvider { async shutdown() { this.shutdownCalled = true; } + + private listSessionRows(profileId?: string): BrowserSessionListRow[] { + return this.sessions + .filter((session) => profileId === undefined || session.profileId === profileId) + .map((session) => ({ + ...session, + runtimeState: this.activeSessions.has(session.id) ? 'active' as const : 'idle' as const, + })); + } } describe('createDaemonServer', () => { const servers: Array<{ close: () => Promise }> = []; + beforeEach(() => { + vi.spyOn(process, 'kill').mockImplementation(() => true); + }); + afterEach(async () => { while (servers.length) await servers.pop()!.close(); vi.useRealTimers(); + vi.restoreAllMocks(); }); async function start(provider = new FakeProvider()) { @@ -63,6 +145,13 @@ describe('createDaemonServer', () => { }); } + async function promiseState(promise: Promise, waitMs = 0): Promise<'pending' | 'settled'> { + return Promise.race([ + promise.then(() => 'settled' as const, () => 'settled' as const), + new Promise<'pending'>((resolve) => setTimeout(() => resolve('pending'), waitMs)), + ]); + } + function persistentWrite( id: string, runId: string, @@ -83,6 +172,19 @@ describe('createDaemonServer', () => { }; } + function adapterCommand( + id: string, + runId: string, + adapterSite: string, + session?: string, + ): BrowserRuntimeCommand { + return persistentWrite(id, runId, { + adapterSite, + command: `${adapterSite} read`, + ...(session === undefined ? { session: undefined } : { session }), + }); + } + it('returns runtime-named status fields without extension aliases', async () => { const { baseUrl } = await start(); const res = await fetch(`${baseUrl}/status`, { headers: { [DAEMON_HEADER_NAME]: '1' } }); @@ -115,6 +217,243 @@ describe('createDaemonServer', () => { expect(provider.commands[0]).toMatchObject({ id: 'cmd-1', action: 'navigate', session: 'work' }); }); + it('handles local Session lifecycle controls outside normal dispatch', async () => { + const { provider, baseUrl } = await start(); + + const created = await postCommand(baseUrl, { id: 'create-session', action: 'session-create' as BrowserRuntimeCommand['action'], contextId: 'profile_work' }); + expect(created.status).toBe(200); + await expect(created.json()).resolves.toMatchObject({ + ok: true, + data: { id: provider.sessionId, profileId: 'profile_work', kind: 'explicit' }, + }); + + provider.activeSessions.add(provider.sessionId); + const listed = await postCommand(baseUrl, { id: 'list-sessions', action: 'session-list' as BrowserRuntimeCommand['action'], contextId: 'profile_work' }); + expect(listed.status).toBe(200); + await expect(listed.json()).resolves.toMatchObject({ + ok: true, + data: [{ id: provider.sessionId, runtimeState: 'active' }], + }); + + const closed = await postCommand(baseUrl, { id: 'close-session', action: 'session-close' as BrowserRuntimeCommand['action'], contextId: 'profile_work', session: provider.sessionId }); + expect(closed.status).toBe(200); + await expect(closed.json()).resolves.toMatchObject({ + ok: true, + data: { closed: true, alreadyIdle: false, session: provider.sessionId }, + }); + + const closedAgain = await postCommand(baseUrl, { id: 'close-session-again', action: 'session-close' as BrowserRuntimeCommand['action'], contextId: 'profile_work', session: provider.sessionId }); + expect(closedAgain.status).toBe(200); + await expect(closedAgain.json()).resolves.toMatchObject({ + ok: true, + data: { closed: false, alreadyIdle: true, session: provider.sessionId }, + }); + expect(provider.commands).toEqual([]); + }); + + it('rejects Session close while its live human handoff owns the window', async () => { + const provider = new FakeProvider(); + provider.sessions.push({ + id: 'session_a', + profileId: 'profile_work', + kind: 'explicit', + createdAt: '2026-08-11T00:00:00.000Z', + updatedAt: '2026-08-11T00:00:00.000Z', + lastUsedAt: '2026-08-11T00:00:00.000Z', + handoff: { + site: 'github', + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }, + }); + provider.activeSessions.add('session_a'); + const { baseUrl } = await start(provider); + + const close = await postCommand(baseUrl, { + id: 'close-handoff-session', + action: 'session-close', + contextId: 'profile_work', + session: 'session_a', + }); + + expect(close.status).toBe(409); + await expect(close.json()).resolves.toMatchObject({ + errorCode: 'SESSION_PAUSED_FOR_HUMAN_HANDOFF', + details: { sessionId: 'session_a', site: 'github' }, + }); + expect(provider.activeSessions).toContain('session_a'); + }); + + it('rejects Session close while work is active in that Session', async () => { + let settle!: () => void; + const provider = new FakeProvider(); + provider.activeSessions.add('session_a'); + provider.dispatchImpl = (command, signal) => new Promise((resolve) => { + settle = () => resolve({ id: command.id, ok: true, data: 'done' }); + signal?.addEventListener('abort', settle, { once: true }); + }); + const { baseUrl } = await start(provider); + + const active = postCommand(baseUrl, { + id: 'active-work', + action: 'exec', + surface: 'browser', + session: 'session_a', + runId: 'run_100_1_1', + command: 'browser/run', + }); + try { + await vi.waitFor(() => expect(provider.commands).toHaveLength(1)); + const close = await postCommand(baseUrl, { + id: 'close-active-session', + action: 'session-close', + contextId: 'default', + session: 'session_a', + }); + + expect(close.status).toBe(409); + await expect(close.json()).resolves.toMatchObject({ + ok: false, + code: 'session_busy', + holder: { command: 'browser/run' }, + }); + expect(provider.activeSessions).toContain('session_a'); + } finally { + settle(); + await active.catch(() => undefined); + } + }); + + it('force-closes a Session while work is active', async () => { + let settle!: () => void; + let settled = false; + let aborted = false; + const events: string[] = []; + const provider = new FakeProvider(); + provider.activeSessions.add('session_a'); + provider.closeSession = vi.fn(async (command) => { + events.push('close'); + const session = String(command.session); + const wasActive = provider.activeSessions.delete(session); + return { closed: wasActive, alreadyIdle: !wasActive, session }; + }); + provider.dispatchImpl = (command, signal) => new Promise((resolve) => { + settle = () => { + settled = true; + events.push('settle'); + resolve({ id: command.id, ok: true, data: 'done' }); + }; + signal?.addEventListener('abort', () => { aborted = true; }, { once: true }); + }); + const { baseUrl } = await start(provider); + + const active = postCommand(baseUrl, { + id: 'active-force-work', + action: 'exec', + surface: 'browser', + session: 'session_a', + runId: 'run_100_1_2', + command: 'browser/run', + }); + try { + await vi.waitFor(() => expect(provider.commands).toHaveLength(1)); + const closeRequest = postCommand(baseUrl, { + id: 'force-close-active-session', + action: 'session-close', + contextId: 'default', + session: 'session_a', + force: true, + }); + await vi.waitFor(() => expect(aborted).toBe(true)); + expect(provider.closeSession).not.toHaveBeenCalled(); + settle(); + const close = await closeRequest; + expect(settled).toBe(true); + expect(close.status).toBe(200); + await expect(close.json()).resolves.toMatchObject({ + ok: true, + data: { + closed: true, + alreadyIdle: false, + session: 'session_a', + displaced: { command: 'browser/run' }, + clearedHandoff: false, + }, + }); + expect(events).toEqual(['settle', 'close']); + } finally { + settle(); + await active.catch(() => undefined); + } + provider.dispatchImpl = async (command) => ({ id: command.id, ok: true }); + const admitted = await postCommand(baseUrl, { + id: 'after-force-settled', action: 'exec', surface: 'browser', session: 'session_a', + runId: 'run_200_2_2', command: 'browser/run', + }); + expect(admitted.status).toBe(200); + }); + + it('returns SESSION_BUSY when force-close cancellation does not settle promptly', async () => { + const provider = new FakeProvider(); + provider.activeSessions.add('session_a'); + let aborted = false; + let settle!: () => void; + provider.dispatchImpl = (command, signal) => new Promise((resolve) => { + settle = () => resolve({ id: command.id, ok: true, data: 'done' }); + signal?.addEventListener('abort', () => { aborted = true; }, { once: true }); + }); + const { baseUrl } = await start(provider); + + const active = postCommand(baseUrl, { + id: 'active-force-timeout', + action: 'exec', + surface: 'browser', + session: 'session_a', + runId: 'run_100_1_2', + command: 'browser/run', + }); + try { + await vi.waitFor(() => expect(provider.commands).toHaveLength(1)); + const closeRequest = postCommand(baseUrl, { + id: 'force-close-timeout', + action: 'session-close', + contextId: 'default', + session: 'session_a', + force: true, + }); + await vi.waitFor(() => expect(aborted).toBe(true)); + + expect(await promiseState(closeRequest, 2_500)).toBe('settled'); + const close = await closeRequest; + expect(close.status).toBe(409); + await expect(close.json()).resolves.toMatchObject({ + ok: false, + code: 'session_busy', + holder: { command: 'browser/run', sessionId: 'session_a' }, + }); + expect(provider.activeSessions).toContain('session_a'); + } finally { + settle(); + await active.catch(() => undefined); + } + }); + + it('force-closes every site-partitioned lease in an adapter-default Session', async () => { + const provider = new FakeProvider(); + provider.activeSessions.add('session_default'); + const { baseUrl } = await start(provider); + expect((await postCommand(baseUrl, adapterCommand('github-owner', 'run_100_1_1', 'github'))).status).toBe(200); + expect((await postCommand(baseUrl, adapterCommand('linkedin-owner', 'run_200_2_2', 'linkedin'))).status).toBe(200); + + const close = await postCommand(baseUrl, { + id: 'force-close-default', action: 'session-close', contextId: 'default', + session: 'session_default', force: true, + }); + const status = await fetch(`${baseUrl}/status`, { headers: { [DAEMON_HEADER_NAME]: '1' } }); + + expect(close.status).toBe(200); + await expect(status.json()).resolves.toMatchObject({ sessionLeases: [] }); + }); + it('accepts the maximum browser-run source envelope', async () => { const { provider, baseUrl } = await start(); const source = 'x'.repeat(256 * 1024); @@ -239,6 +578,140 @@ describe('createDaemonServer', () => { expect(provider.commands.map((command) => command.id)).toEqual(['first']); }); + it('partitions adapter-default admission by site', async () => { + const { provider, baseUrl } = await start(); + + expect((await postCommand(baseUrl, adapterCommand('github-owner', 'run_100_1_1', 'github'))).status).toBe(200); + expect((await postCommand(baseUrl, adapterCommand('linkedin-owner', 'run_200_2_2', 'linkedin'))).status).toBe(200); + expect((await postCommand(baseUrl, adapterCommand('github-conflict', 'run_300_3_3', 'github'))).status).toBe(409); + expect(provider.commands.map(({ id }) => id)).toEqual(['github-owner', 'linkedin-owner']); + expect(provider.commands[0]).toMatchObject({ + sessionId: 'session_default', + sessionKind: 'adapter-default', + adapterSite: 'github', + }); + }); + + it('conflicts an explicitly selected adapter-default Session with its implicit adapter work', async () => { + const { provider, baseUrl } = await start(); + + expect((await postCommand(baseUrl, adapterCommand('github-owner', 'run_100_1_1', 'github'))).status).toBe(200); + const rawConflict = await postCommand(baseUrl, { + id: 'raw-conflict', + action: 'exec', + code: '1', + surface: 'browser', + session: 'session_default', + runId: 'run_200_2_2', + command: 'browser/run', + }); + + expect(rawConflict.status).toBe(409); + expect(provider.commands.map(({ id }) => id)).toEqual(['github-owner']); + }); + + it('marks an explicitly selected adapter-default ID as explicit routing', async () => { + const { provider, baseUrl } = await start(); + + expect((await postCommand(baseUrl, adapterCommand('explicit-default', 'run_100_1_1', 'github', 'session_default'))).status).toBe(200); + expect(provider.commands[0]).toMatchObject({ + sessionId: 'session_default', + sessionKind: 'explicit', + }); + }); + + it('keeps explicit Session admission wide across sites and isolated across Sessions', async () => { + const { provider, baseUrl } = await start(); + + expect((await postCommand(baseUrl, adapterCommand('github-a', 'run_100_1_1', 'github', 'session_a'))).status).toBe(200); + expect((await postCommand(baseUrl, adapterCommand('linkedin-a', 'run_200_2_2', 'linkedin', 'session_a'))).status).toBe(409); + expect((await postCommand(baseUrl, adapterCommand('linkedin-b', 'run_300_3_3', 'linkedin', 'session_b'))).status).toBe(200); + expect(provider.commands.map(({ id }) => id)).toEqual(['github-a', 'linkedin-b']); + }); + + it('pauses only the handoff Session and admits only its internal site verification', async () => { + const { provider, baseUrl } = await start(); + const expiresAt = new Date(Date.now() + 60_000).toISOString(); + + const started = await postCommand(baseUrl, { + id: 'handoff-start', + action: 'session-handoff-start' as BrowserRuntimeCommand['action'], + surface: 'adapter', + session: 'session_a', + adapterSite: 'github', + site: 'github', + expiresAt, + runId: 'run_100_1_1', + command: 'github/login', + }); + expect(started.status).toBe(200); + expect(provider.foregroundedSessions).toEqual(['session_a']); + + await postCommand(baseUrl, { id: 'release-login', action: 'lease-release', runId: 'run_100_1_1' }); + const paused = await postCommand(baseUrl, adapterCommand('paused', 'run_200_2_2', 'github', 'session_a')); + expect(paused.status).toBe(409); + await expect(paused.json()).resolves.toMatchObject({ + errorCode: 'SESSION_PAUSED_FOR_HUMAN_HANDOFF', + details: { sessionId: 'session_a', site: 'github', expiresAt }, + }); + + const sibling = await postCommand(baseUrl, adapterCommand('sibling', 'run_300_3_3', 'linkedin', 'session_b')); + expect(sibling.status).toBe(200); + + const rawWhoami = await postCommand(baseUrl, { + ...adapterCommand('raw-whoami', 'run_400_4_4', 'github', 'session_a'), + surface: 'browser', + command: 'github/whoami', + }); + expect(rawWhoami.status).toBe(409); + + const wrongSite = await postCommand(baseUrl, { + ...adapterCommand('wrong-site', 'run_400_4_4', 'linkedin', 'session_a'), + command: 'github/whoami', + }); + expect(wrongSite.status).toBe(409); + + const verification = await postCommand(baseUrl, { + ...adapterCommand('verify', 'run_500_5_5', 'github', 'session_a'), + command: 'github/whoami', + }); + expect(verification.status).toBe(200); + + const cleared = await postCommand(baseUrl, { + id: 'handoff-clear', + action: 'session-handoff-clear' as BrowserRuntimeCommand['action'], + surface: 'adapter', + session: 'session_a', + adapterSite: 'github', + site: 'github', + runId: 'run_500_5_5', + command: 'github/whoami', + }); + expect(cleared.status).toBe(200); + + await postCommand(baseUrl, { id: 'release-verify', action: 'lease-release', runId: 'run_500_5_5' }); + const resumed = await postCommand(baseUrl, adapterCommand('resumed', 'run_600_6_6', 'github', 'session_a')); + expect(resumed.status).toBe(200); + }); + + it('ignores expired handoffs before admission', async () => { + const provider = new FakeProvider(); + provider.sessions.push({ + id: 'session_a', + profileId: 'default', + kind: 'explicit', + createdAt: '2026-08-11T00:00:00.000Z', + updatedAt: '2026-08-11T00:00:00.000Z', + lastUsedAt: '2026-08-11T00:00:00.000Z', + handoff: { site: 'github', expiresAt: '2026-08-11T00:15:00.000Z' }, + }); + const { baseUrl } = await start(provider); + + const result = await postCommand(baseUrl, adapterCommand('after-expiry', 'run_700_7_7', 'github', 'session_a')); + + expect(result.status).toBe(200); + }); + it('lets one logical run issue multiple operations and heartbeat its lease', async () => { let now = 1_000; vi.spyOn(Date, 'now').mockImplementation(() => now); @@ -251,7 +724,7 @@ describe('createDaemonServer', () => { const status = await fetch(`${baseUrl}/status`, { headers: { [DAEMON_HEADER_NAME]: '1' } }); await expect(status.json()).resolves.toMatchObject({ sessionLeases: [{ - key: 'default␟adapter␟site%3Aexample', + key: 'default␟site:example', command: 'example write', acquiredAt: 1_000, heartbeatAt: 20_000, @@ -264,6 +737,17 @@ describe('createDaemonServer', () => { ['read access', { access: 'read' as const }], ['ephemeral sessions', { siteSession: 'ephemeral' as const }], ['raw browser surface', { surface: 'browser' as const }], + ])('conflicts across %s when the resolved Session is the same', async (_case, overrides) => { + const provider = new FakeProvider(); + provider.resolveProfileId = (command) => command.profileId ?? 'default'; + const { baseUrl } = await start(provider); + + expect((await postCommand(baseUrl, persistentWrite('owner', 'run_100_1_1'))).status).toBe(200); + expect((await postCommand(baseUrl, persistentWrite('other', 'run_200_2_2', overrides))).status).toBe(409); + expect(provider.commands.map((command) => command.id)).toEqual(['owner']); + }); + + it.each([ ['different sites', { session: 'site:other' }], ['different resolved profiles', { profileId: 'other' }], ])('does not conflict across %s', async (_case, overrides) => { @@ -404,6 +888,96 @@ describe('createDaemonServer', () => { expect(provider.commands.map((command) => command.id)).toEqual(['owner', 'next-owner']); }); + it('handles run-cancel locally and permits a new owner', async () => { + const { provider, baseUrl } = await start(); + expect((await postCommand(baseUrl, persistentWrite('owner', 'run_100_1_1'))).status).toBe(200); + + const canceled = await postCommand(baseUrl, { + id: 'cancel', + action: 'run-cancel' as BrowserRuntimeCommand['action'], + runId: 'run_100_1_1', + }); + expect(canceled.status).toBe(200); + await expect(canceled.json()).resolves.toMatchObject({ + id: 'cancel', + ok: true, + data: { released: 1 }, + }); + expect(provider.commands.map((command) => command.id)).toEqual(['owner']); + + expect((await postCommand(baseUrl, persistentWrite('next-owner', 'run_200_2_2'))).status).toBe(200); + expect(provider.commands.map((command) => command.id)).toEqual(['owner', 'next-owner']); + }); + + it('returns SESSION_BUSY when run-cancel does not settle promptly', async () => { + const provider = new FakeProvider(); + let aborted = false; + let settle!: () => void; + provider.dispatchImpl = (command, signal) => new Promise((resolve) => { + settle = () => resolve({ id: command.id, ok: true, data: 'done' }); + signal?.addEventListener('abort', () => { aborted = true; }, { once: true }); + }); + const { baseUrl } = await start(provider); + const active = postCommand(baseUrl, persistentWrite('owner', 'run_100_1_1')); + try { + await vi.waitFor(() => expect(provider.commands).toHaveLength(1)); + const canceled = postCommand(baseUrl, { + id: 'cancel', + action: 'run-cancel' as BrowserRuntimeCommand['action'], + runId: 'run_100_1_1', + }); + await vi.waitFor(() => expect(aborted).toBe(true)); + + expect(await promiseState(canceled, 2_500)).toBe('settled'); + const response = await canceled; + expect(response.status).toBe(409); + await expect(response.json()).resolves.toMatchObject({ + id: 'cancel', + ok: false, + code: 'session_busy', + holder: { command: 'example write' }, + }); + } finally { + settle(); + await active.catch(() => undefined); + } + }); + + it('aborts pending work when the command request disconnects', async () => { + const provider = new FakeProvider(); + let aborted = false; + provider.dispatchImpl = (command, signal) => new Promise((resolve) => { + if (command.id !== 'owner') { + resolve({ id: command.id, ok: true, data: 'done' }); + return; + } + signal?.addEventListener('abort', () => { + aborted = true; + resolve({ id: command.id, ok: false, errorCode: 'aborted', error: 'aborted' }); + }); + }); + const { baseUrl } = await start(provider); + const url = new URL('/command', baseUrl); + const body = JSON.stringify(persistentWrite('owner', 'run_100_1_1')); + const req = httpRequest(url, { + method: 'POST', + headers: { + [DAEMON_HEADER_NAME]: '1', + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + }, + }); + req.on('error', () => undefined); + req.end(body); + + await vi.waitFor(() => expect(provider.commands.map((command) => command.id)).toEqual(['owner'])); + req.destroy(); + + await vi.waitFor(() => expect(aborted).toBe(true)); + expect((await postCommand(baseUrl, persistentWrite('next-owner', 'run_200_2_2'))).status).toBe(200); + expect(provider.commands.map((command) => command.id)).toEqual(['owner', 'next-owner']); + }); + it('returns only sanitized current holders from status', async () => { let now = 1_000; vi.spyOn(Date, 'now').mockImplementation(() => now); diff --git a/src/daemon/server.ts b/src/daemon/server.ts index 30b9a085..9844d18f 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -3,10 +3,12 @@ import { DAEMON_HEADER_NAME, DEFAULT_DAEMON_PORT } from '../constants.js'; import type { BrowserRuntimeCommand, BrowserRuntimeResult } from '../browser/protocol.js'; import type { BrowserRuntimeProvider } from '../browser/runtime/provider.js'; import { buildCommandTimeoutFailure, getResponseCorsHeaders } from '../daemon-utils.js'; -import { getSessionLeaseKey, isSessionLeaseCommand, SessionLeaseRegistry } from '../session-lease.js'; +import { getSessionLeaseKey, isSessionLeaseCommand, type SessionLease, SessionLeaseRegistry } from '../session-lease.js'; +import type { BrowserSessionRecord } from '../browser/sessions.js'; const MAX_BODY = 1024 * 1024; const LOG_BUFFER_SIZE = 200; +const CANCEL_SETTLE_TIMEOUT_MS = 2_000; export interface DaemonServerOptions { port?: number; @@ -24,6 +26,34 @@ interface PendingCommand { promise: Promise; runId?: string; leaseKey?: string; + abortController: AbortController; +} + +async function cancelAndSettle(entries: PendingCommand[]): Promise { + entries.forEach((entry) => entry.abortController.abort()); + if (entries.length === 0) return true; + let timeout: ReturnType | undefined; + const settled = await Promise.race([ + Promise.allSettled(entries.map((entry) => entry.promise)).then(() => true), + new Promise((resolve) => { + timeout = setTimeout(() => resolve(false), CANCEL_SETTLE_TIMEOUT_MS); + timeout.unref?.(); + }), + ]); + if (timeout) clearTimeout(timeout); + return settled; +} + +function publicSessionHolder(holder: SessionLease | undefined) { + if (!holder) return null; + const { key: _key, runId: _runId, ...publicHolder } = holder; + const [, sessionId, admissionSite] = holder.key.split('␟'); + return { ...publicHolder, ...(sessionId ? { sessionId } : {}), ...(admissionSite ? { admissionSite } : {}) }; +} + +function displacedSessionHolder(holder: SessionLease | undefined): { command: string; pid?: number } | null { + if (!holder) return null; + return { command: holder.command, ...(holder.pid === undefined ? {} : { pid: holder.pid }) }; } function commandTimeoutMs(command: BrowserRuntimeCommand): number { @@ -58,6 +88,114 @@ function waitForCommandResult( }); } +const UNRESOLVED_SESSION_LIFECYCLE_ACTIONS = new Set([ + 'session-create', + 'session-list', +]); + +function commandProfileId(provider: BrowserRuntimeProvider, command: BrowserRuntimeCommand): string | undefined { + return provider.resolveProfileId?.(command) + ?? command.profileId + ?? command.contextId + ?? command.preferredContextId; +} + +async function resolveBrowserSession( + provider: BrowserRuntimeProvider, + command: BrowserRuntimeCommand, +): Promise<{ command: BrowserRuntimeCommand; session?: BrowserSessionRecord }> { + if (command.action === 'lease-release' || command.action === 'run-cancel' || UNRESOLVED_SESSION_LIFECYCLE_ACTIONS.has(command.action)) { + return { command }; + } + let session: BrowserSessionRecord | undefined; + let sessionKind: BrowserRuntimeCommand['sessionKind']; + if (command.surface === 'adapter' && !command.session) { + session = await provider.resolveAdapterDefault?.(command); + sessionKind = 'adapter-default'; + } else { + session = await provider.requireSession?.(command); + sessionKind = 'explicit'; + } + return { + command: session ? { ...command, session: session.id, sessionId: session.id, sessionKind } : command, + session, + }; +} + +const HANDOFF_ACTIONS = new Set([ + 'session-handoff-start', + 'session-handoff-clear', +]); + +function isHandoffVerification(command: BrowserRuntimeCommand, site: string): boolean { + return command.surface === 'adapter' + && command.adapterSite === site + && command.command === `${site}/whoami`; +} + +function handoffPauseResult(command: BrowserRuntimeCommand, session: BrowserSessionRecord): BrowserRuntimeResult | null { + const handoff = session.handoff; + if (!handoff || Date.parse(handoff.expiresAt) <= Date.now()) return null; + if (isHandoffVerification(command, handoff.site)) return null; + return { + id: command.id, + ok: false, + errorCode: 'SESSION_PAUSED_FOR_HUMAN_HANDOFF', + error: `Session ${session.id} is paused while a human completes ${handoff.site} authentication.`, + details: { sessionId: session.id, sessionKind: session.kind, ...handoff }, + }; +} + +async function handleSessionHandoff( + provider: BrowserRuntimeProvider, + command: BrowserRuntimeCommand, + session: BrowserSessionRecord, +): Promise { + if (!HANDOFF_ACTIONS.has(command.action)) return null; + if (!command.runId || !command.site?.trim()) { + return { id: command.id, ok: false, errorCode: 'invalid_request', error: 'Session handoff controls require runId and site.' }; + } + if (command.action === 'session-handoff-start') { + const expiresAt = command.expiresAt ? Date.parse(command.expiresAt) : Number.NaN; + if (command.command !== `${command.site}/login` || !Number.isFinite(expiresAt) || expiresAt <= Date.now()) { + return { id: command.id, ok: false, errorCode: 'invalid_request', error: 'Invalid Session handoff start control.' }; + } + const record = await provider.startSessionHandoff?.(command); + return record + ? { id: command.id, ok: true, data: record } + : { id: command.id, ok: false, errorCode: 'runtime_command_failed', error: 'Session handoff is not supported by this runtime.' }; + } + if (!isHandoffVerification(command, command.site) || (session.handoff && command.site !== session.handoff.site)) { + return { id: command.id, ok: false, errorCode: 'invalid_request', error: 'Invalid Session handoff clear control.' }; + } + const record = await provider.clearSessionHandoff?.(command); + return record + ? { id: command.id, ok: true, data: record } + : { id: command.id, ok: false, errorCode: 'runtime_command_failed', error: 'Session handoff is not supported by this runtime.' }; +} + +async function handleSessionLifecycle( + provider: BrowserRuntimeProvider, + command: BrowserRuntimeCommand, +): Promise { + switch (command.action) { + case 'session-create': { + const session = await provider.createSession?.(command); + return session ? { id: command.id, ok: true, data: session } : null; + } + case 'session-list': { + const sessions = await provider.listSessions?.({ profileId: commandProfileId(provider, command), limit: command.limit }); + return sessions ? { id: command.id, ok: true, data: sessions } : null; + } + case 'session-close': { + const closed = await provider.closeSession?.(command); + return closed ? { id: command.id, ok: true, data: closed } : null; + } + default: + return null; + } +} + function readBody(req: IncomingMessage): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; @@ -92,12 +230,27 @@ function jsonResponse( res.end(JSON.stringify(data)); } +function abortOnResponseClose(res: ServerResponse, controller: AbortController, onAbort: () => void): () => void { + let completed = false; + const onClose = () => { + if (completed) return; + onAbort(); + controller.abort(); + }; + res.once('close', onClose); + return () => { + completed = true; + res.off('close', onClose); + }; +} + export function createDaemonServer(provider: BrowserRuntimeProvider, opts: DaemonServerOptions): DaemonServerHandle { const port = opts.port ?? DEFAULT_DAEMON_PORT; const host = opts.host ?? '127.0.0.1'; const logBuffer: Array<{ level: string; msg: string; ts: number }> = []; const pending = new Map(); const leases = new SessionLeaseRegistry(); + const forceClosingRuns = new Set(); const hasPendingWork = (runId: string) => [...pending.values()].some((entry) => entry.runId === runId); let shutdownStarted = false; @@ -193,41 +346,128 @@ export function createDaemonServer(provider: BrowserRuntimeProvider, opts: Daemo jsonResponse(res, result.ok ? 200 : result.errorCode === 'command_result_unknown' ? 408 : 400, result); return; } - if (body.action === 'lease-release') { + if (body.action === 'lease-release' || body.action === 'run-cancel') { + const matching = body.action === 'run-cancel' && typeof body.runId === 'string' + ? [...pending.values()].filter((entry) => entry.runId === body.runId) + : []; + const canceled = await cancelAndSettle(matching); + if (!canceled) { + const holder = typeof body.runId === 'string' + ? leases.list(hasPendingWork).find((lease) => lease.runId === body.runId) + : undefined; + jsonResponse(res, 409, { id: body.id, ok: false, code: 'session_busy', holder: publicSessionHolder(holder) }); + return; + } const released = typeof body.runId === 'string' ? leases.releaseByRunId(body.runId) : 0; jsonResponse(res, 200, { id: body.id, ok: true, data: { released } }); return; } + if (body.action !== 'session-close') { + const lifecycleResult = await handleSessionLifecycle(provider, body); + if (lifecycleResult) { + jsonResponse(res, 200, lifecycleResult); + return; + } + } + const resolved = await resolveBrowserSession(provider, body); + const resolvedBody = resolved.command; + const activeSessionLeases = (sessionKey: string) => leases.list(hasPendingWork).filter((lease) => ( + lease.key === sessionKey + || lease.key.startsWith(`${sessionKey}␟`) + || sessionKey.startsWith(`${lease.key}␟`) + )); + if (resolved.session && !(resolvedBody.action === 'session-close' && resolvedBody.force === true)) { + const paused = handoffPauseResult(resolvedBody, resolved.session); + if (paused) { + jsonResponse(res, 409, paused); + return; + } + } + if (resolvedBody.action === 'session-close') { + const profileId = commandProfileId(provider, resolvedBody) ?? 'default'; + const sessionKey = getSessionLeaseKey(profileId, resolvedBody.sessionId!); + const holders = activeSessionLeases(sessionKey); + const holder = holders[0]; + if (holder && resolvedBody.force !== true) { + jsonResponse(res, 409, { ok: false, code: 'session_busy', holder: publicSessionHolder(holder) }); + return; + } + const forcedRunIds = resolvedBody.force === true + ? new Set(holders.map((lease) => lease.runId)) + : new Set(); + const forcedCommands = [...pending.values()] + .filter((entry) => entry.runId && forcedRunIds.has(entry.runId)) + .map((entry) => entry); + forcedRunIds.forEach((runId) => forceClosingRuns.add(runId)); + const canceled = await cancelAndSettle(forcedCommands); + if (!canceled) { + forcedRunIds.forEach((runId) => forceClosingRuns.delete(runId)); + jsonResponse(res, 409, { ok: false, code: 'session_busy', holder: publicSessionHolder(holder) }); + return; + } + const lifecycleResult = await handleSessionLifecycle(provider, resolvedBody); + if (lifecycleResult) { + for (const runId of forcedRunIds) { + leases.releaseByRunId(runId); + forceClosingRuns.delete(runId); + } + jsonResponse(res, 200, resolvedBody.force === true ? { + ...lifecycleResult, + data: { + ...(lifecycleResult.data as Record), + displaced: displacedSessionHolder(holder), + clearedHandoff: Boolean(resolved.session?.handoff), + }, + } : lifecycleResult); + return; + } + } let leaseKey: string | undefined; let runId: string | undefined; - if (isSessionLeaseCommand(body)) { - const profileId = provider.resolveProfileId?.(body) - ?? body.profileId - ?? body.contextId - ?? body.preferredContextId + if (isSessionLeaseCommand(resolvedBody)) { + const profileId = commandProfileId(provider, resolvedBody) ?? 'default'; - leaseKey = getSessionLeaseKey(profileId, body.surface, body.session); - runId = body.runId; + const admissionSite = resolvedBody.sessionKind === 'adapter-default' + && resolvedBody.surface === 'adapter' + ? resolvedBody.adapterSite + : undefined; + leaseKey = getSessionLeaseKey(profileId, resolvedBody.sessionId, admissionSite); + runId = resolvedBody.runId; const acquired = leases.acquire({ key: leaseKey, runId, - command: body.command ?? body.action, - pid: body.pid, + command: resolvedBody.command ?? resolvedBody.action, + pid: resolvedBody.pid, }, hasPendingWork); if (!acquired.acquired) { - const { key: _key, runId: _runId, ...holder } = acquired.holder; - jsonResponse(res, 409, { ok: false, code: 'session_busy', holder }); + jsonResponse(res, 409, { ok: false, code: 'session_busy', holder: publicSessionHolder(acquired.holder) }); return; } } - const commandPromise = provider.dispatch(body).finally(() => { - if (leaseKey && runId) leases.heartbeat(leaseKey, runId); + if (resolved.session) { + const handoffResult = await handleSessionHandoff(provider, resolvedBody, resolved.session); + if (handoffResult) { + if (leaseKey && runId) leases.heartbeat(leaseKey, runId); + jsonResponse(res, handoffResult.ok ? 200 : 400, handoffResult); + return; + } + } + const abortController = new AbortController(); + let responseAborted = false; + const removeResponseAbort = abortOnResponseClose(res, abortController, () => { + responseAborted = true; + }); + const commandPromise = provider.dispatch(resolvedBody, abortController.signal).finally(() => { + removeResponseAbort(); + if (leaseKey && runId && !responseAborted) leases.heartbeat(leaseKey, runId); pending.delete(body.id); + if (runId && (responseAborted || forceClosingRuns.delete(runId))) leases.releaseByRunId(runId); }); - pending.set(body.id, { promise: commandPromise, runId, leaseKey }); + pending.set(body.id, { promise: commandPromise, runId, leaseKey, abortController }); const result = await waitForCommandResult(body, commandPromise); + removeResponseAbort(); if (!result.ok) pushLog('warn', `Command ${body.id} failed: ${result.error ?? result.errorCode ?? 'unknown error'}`); - jsonResponse(res, result.ok ? 200 : result.errorCode === 'command_result_unknown' ? 408 : 400, result); + if (!responseAborted) jsonResponse(res, result.ok ? 200 : result.errorCode === 'command_result_unknown' ? 408 : 400, result); } catch (err) { jsonResponse(res, 400, { ok: false, error: err instanceof Error ? err.message : 'Invalid request' }); } diff --git a/src/engine.test.ts b/src/engine.test.ts index eaa3e9bf..5a7ddba3 100644 --- a/src/engine.test.ts +++ b/src/engine.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { discoverClis, discoverPlugins, ensureUserCliCompatShims, ensureUserAdapters, PLUGINS_DIR } from './discovery.js'; +import { discoverClis, discoverPlugins, ensureUserCliCompatShims, ensureUserAdapters } from './discovery.js'; import { executeCommand } from './execution.js'; import { getRegistry, cli, Strategy } from './registry.js'; import { clearAllHooks, onAfterExecute } from './hooks.js'; @@ -135,7 +135,7 @@ cli({ expect(sessionOpts).toHaveLength(1); expect(sessionOpts[0]).toMatchObject({ - session: `site:${site}`, + session: undefined, siteSession: 'persistent', freshPage: true, }); @@ -255,21 +255,17 @@ describe('ensureUserAdapters', () => { }); describe('discoverPlugins', () => { - const testPluginDir = path.join(PLUGINS_DIR, '__test-plugin__'); - const yamlPath = path.join(testPluginDir, 'greeting.yaml'); const symlinkTargetDir = path.join(os.tmpdir(), '__test-plugin-symlink-target__'); - const symlinkPluginDir = path.join(PLUGINS_DIR, '__test-plugin-symlink__'); - const brokenSymlinkDir = path.join(PLUGINS_DIR, '__test-plugin-broken__'); const dirSymlinkType: fs.symlink.Type = process.platform === 'win32' ? 'junction' : 'dir'; afterEach(async () => { - try { await fs.promises.rm(testPluginDir, { recursive: true }); } catch {} - try { await fs.promises.rm(symlinkPluginDir, { recursive: true, force: true }); } catch {} try { await fs.promises.rm(symlinkTargetDir, { recursive: true, force: true }); } catch {} - try { await fs.promises.rm(brokenSymlinkDir, { recursive: true, force: true }); } catch {} }); it('ignores YAML files in plugin directories (YAML format removed)', async () => { + const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'webcmd-yaml-plugin-')); + const testPluginDir = path.join(root, '__test-plugin__'); + const yamlPath = path.join(testPluginDir, 'greeting.yaml'); await fs.promises.mkdir(testPluginDir, { recursive: true }); await fs.promises.writeFile(yamlPath, ` site: __test-plugin__ @@ -279,16 +275,17 @@ strategy: public browser: false `); - await discoverPlugins(); + await discoverPlugins(root); const registry = getRegistry(); const cmd = registry.get('__test-plugin__/greeting'); expect(cmd).toBeUndefined(); + await fs.promises.rm(root, { recursive: true, force: true }); }); it('handles non-existent plugins directory gracefully', async () => { // discoverPlugins should not throw if ~/.webcmd/plugins/ does not exist - await expect(discoverPlugins()).resolves.not.toThrow(); + await expect(discoverPlugins(path.join(os.tmpdir(), 'missing-webcmd-plugin-root'))).resolves.not.toThrow(); }); it('discovers only the explicitly supplied installed-plugin root', async () => { @@ -313,7 +310,8 @@ cli({ }); it('ignores YAML files in symlinked plugin directories (YAML format removed)', async () => { - await fs.promises.mkdir(PLUGINS_DIR, { recursive: true }); + const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'webcmd-yaml-plugin-link-')); + const symlinkPluginDir = path.join(root, '__test-plugin-symlink__'); await fs.promises.mkdir(symlinkTargetDir, { recursive: true }); await fs.promises.writeFile(path.join(symlinkTargetDir, 'hello.yaml'), ` site: __test-plugin-symlink__ @@ -324,18 +322,21 @@ browser: false `); await fs.promises.symlink(symlinkTargetDir, symlinkPluginDir, dirSymlinkType); - await discoverPlugins(); + await discoverPlugins(root); const cmd = getRegistry().get('__test-plugin-symlink__/hello'); expect(cmd).toBeUndefined(); + await fs.promises.rm(root, { recursive: true, force: true }); }); it('skips broken plugin symlinks without throwing', async () => { - await fs.promises.mkdir(PLUGINS_DIR, { recursive: true }); + const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'webcmd-broken-plugin-link-')); + const brokenSymlinkDir = path.join(root, '__test-plugin-broken__'); await fs.promises.symlink(path.join(os.tmpdir(), '__missing-plugin-target__'), brokenSymlinkDir, dirSymlinkType); - await expect(discoverPlugins()).resolves.not.toThrow(); + await expect(discoverPlugins(root)).resolves.not.toThrow(); expect(getRegistry().get('__test-plugin-broken__/hello')).toBeUndefined(); + await fs.promises.rm(root, { recursive: true, force: true }); }); }); diff --git a/src/errors.test.ts b/src/errors.test.ts index b25f18ca..fba9ada6 100644 --- a/src/errors.test.ts +++ b/src/errors.test.ts @@ -123,6 +123,13 @@ describe('toEnvelope', () => { expect(envelope.error).not.toHaveProperty('help'); }); + it('keeps Session window conflicts on the structured temporary-failure contract', async () => { + const { SessionWindowConflictError } = await import('./browser/runtime/local-cloak/session-manager.js'); + + expect(toEnvelope(new SessionWindowConflictError('page_1', 'session_a', 'session_b')).error) + .toMatchObject({ code: 'SESSION_WINDOW_CONFLICT', exitCode: 75 }); + }); + it('converts unknown Error to UNKNOWN envelope', () => { const envelope = toEnvelope(new Error('random failure')); expect(envelope).toEqual({ @@ -180,7 +187,7 @@ describe('SessionBusyError platform hints', () => { }; it('uses PowerShell process guidance on Windows when the holder pid is known', () => { - const err = new SessionBusyError(holder, 'win32'); + const err = new SessionBusyError(holder, 'win32', () => true); expect(err.hint).toContain('Stop-Process -Id 4242'); expect(err.hint).not.toContain('kill 4242'); }); @@ -193,11 +200,25 @@ describe('SessionBusyError platform hints', () => { }); it('uses kill guidance on POSIX when the holder pid is known', () => { - const err = new SessionBusyError(holder, 'linux'); + const err = new SessionBusyError(holder, 'linux', () => true); expect(err.hint).toContain('kill 4242'); expect(err.hint).not.toContain('Stop-Process'); }); + it('includes local Session and site details when daemon admission reports them', () => { + const err = new SessionBusyError({ ...holder, sessionId: 'session_a', admissionSite: 'github' }, 'linux', () => true); + expect(err.hint).toContain('Session session_a'); + expect(err.hint).toContain('site github'); + }); + + it('does not suggest killing a holder pid that is no longer alive', () => { + const err = new SessionBusyError({ ...holder, sessionId: 'session_a' }, 'linux', () => false); + expect(err.message).toContain('chatgpt ask'); + expect(err.hint).toMatch(/wait/i); + expect(err.hint).not.toContain('kill 4242'); + expect(err.hint).toContain('webcmd session close --force session_a'); + }); + it.each([ 0, -1, diff --git a/src/errors.ts b/src/errors.ts index 1d13fc07..58ac6bc7 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -20,7 +20,7 @@ * 130 Interrupted by Ctrl-C (set by tui.ts SIGINT handler) */ import type { ObservationTraceReceipt } from './observation/events.js'; -import { isActionablePid, type SessionLeaseHolder } from './session-lease.js'; +import { isActionablePid, isPidAlive, type SessionLeaseHolder } from './session-lease.js'; // ── Exit code table ────────────────────────────────────────────────────────── @@ -149,24 +149,31 @@ function formatBusyMessage(holder: SessionLeaseHolder): string { return `Session is busy: ${owner} is already driving it.`; } -function formatBusyHint(holder: SessionLeaseHolder, platform: string): string { +function formatBusyHint(holder: SessionLeaseHolder, platform: string, pidAlive: (pid: number) => boolean): string { + const hasLivePid = isActionablePid(holder.pid) && pidAlive(holder.pid); + const scope = holder.sessionId + ? `Session ${holder.sessionId}${holder.admissionSite ? `, site ${holder.admissionSite}` : ''}. ` + : ''; + const forceClose = !hasLivePid && holder.sessionId + ? ` Last resort: run \`webcmd session close --force ${holder.sessionId}\`.` + : ''; if (platform === 'win32') { - return !isActionablePid(holder.pid) - ? 'Wait for it to finish, or use Task Manager to stop the owning process if it is stuck.' - : `Wait for it to finish, or run \`Stop-Process -Id ${holder.pid}\` in PowerShell if it is stuck.`; + return scope + (!hasLivePid + ? `Wait for it to finish, or use Task Manager to stop the owning process if it is stuck.${forceClose}` + : `Wait for it to finish, or run \`Stop-Process -Id ${holder.pid}\` in PowerShell if it is stuck.`); } - return !isActionablePid(holder.pid) - ? 'Wait for it to finish, or stop the owning process if it is stuck.' - : `Wait for it to finish, or run \`kill ${holder.pid}\` if it is stuck.`; + return scope + (!hasLivePid + ? `Wait for it to finish, or stop the owning process if it is stuck.${forceClose}` + : `Wait for it to finish, or run \`kill ${holder.pid}\` if it is stuck.`); } /** A persistent write session is temporarily owned by another logical run. */ export class SessionBusyError extends CliError { - constructor(holder: SessionLeaseHolder, platform: string = process.platform) { + constructor(holder: SessionLeaseHolder, platform: string = process.platform, pidAlive: (pid: number) => boolean = isPidAlive) { super( 'SESSION_BUSY', formatBusyMessage(holder), - formatBusyHint(holder, platform), + formatBusyHint(holder, platform, pidAlive), EXIT_CODES.TEMPFAIL, ); } diff --git a/src/execution.test.ts b/src/execution.test.ts index c4dc9316..4bce5b16 100644 --- a/src/execution.test.ts +++ b/src/execution.test.ts @@ -4,17 +4,26 @@ import * as os from 'node:os'; import * as path from 'node:path'; import type { CliCommand } from './registry.js'; -const { mockReleaseSiteSessionLease, mockSetDaemonCommandTimeoutSeconds } = vi.hoisted(() => ({ +const { + mockClearSessionHandoff, + mockReleaseSiteSessionLease, + mockSetDaemonCommandTimeoutSeconds, + mockStartSessionHandoff, +} = vi.hoisted(() => ({ + mockClearSessionHandoff: vi.fn().mockResolvedValue(undefined), mockReleaseSiteSessionLease: vi.fn().mockResolvedValue(undefined), mockSetDaemonCommandTimeoutSeconds: vi.fn(), + mockStartSessionHandoff: vi.fn().mockResolvedValue({ id: 'session_a' }), })); vi.mock('./browser/daemon-client.js', async () => { const actual = await vi.importActual('./browser/daemon-client.js'); return { ...actual, + clearSessionHandoff: mockClearSessionHandoff, releaseSiteSessionLease: mockReleaseSiteSessionLease, setDaemonCommandTimeoutSeconds: mockSetDaemonCommandTimeoutSeconds, + startSessionHandoff: mockStartSessionHandoff, }; }); @@ -207,7 +216,8 @@ describe('executeCommand — non-browser timeout', () => { vi.unstubAllGlobals(); }); - it('binds a run only for browser-backed persistent writes', async () => { + it('binds a run for every browser-backed command and skips non-browser commands', async () => { + mockReleaseSiteSessionLease.mockClear(); const seen = new Map>(); const mockPage = { closeWindow: vi.fn().mockResolvedValue(undefined) } as any; @@ -242,12 +252,17 @@ describe('executeCommand — non-browser timeout', () => { expect(eligibleRun).toMatchObject({ runId: expect.stringMatching(/^run_/), command: 'test-execution/run-eligible', - access: 'write', }); - expect(seen.get('run-read')).toBeUndefined(); - expect(seen.get('run-ephemeral')).toBeUndefined(); + expect(seen.get('run-read')).toMatchObject({ + runId: expect.stringMatching(/^run_/), + command: 'test-execution/run-read', + }); + expect(seen.get('run-ephemeral')).toMatchObject({ + runId: expect.stringMatching(/^run_/), + command: 'test-execution/run-ephemeral', + }); expect(seen.get('run-non-browser')).toBeUndefined(); - expect(mockReleaseSiteSessionLease).toHaveBeenCalledOnce(); + expect(mockReleaseSiteSessionLease).toHaveBeenCalledTimes(3); expect(mockReleaseSiteSessionLease).toHaveBeenCalledWith(eligibleRun?.runId); }); @@ -298,7 +313,6 @@ describe('executeCommand — non-browser timeout', () => { expect(entry.run).toEqual({ runId, command: 'test-execution/run-bound-before-operations', - access: 'write', }); } expect(getDaemonRunContext()).toBeUndefined(); @@ -664,10 +678,87 @@ describe('executeCommand — non-browser timeout', () => { }); }); + describe('local authentication handoff', () => { + afterEach(() => { + mockClearSessionHandoff.mockReset().mockResolvedValue(undefined); + mockStartSessionHandoff.mockReset().mockResolvedValue({ id: 'session_a' }); + vi.restoreAllMocks(); + }); + + it('starts a Session handoff and returns its immutable verification command', async () => { + const mockPage = { closeWindow: vi.fn().mockResolvedValue(undefined) } as any; + vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true); + vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn(mockPage)); + const cmd = cli({ + site: 'github', + name: 'login', + access: 'write', + description: 'test scoped handoff', + browser: true, + strategy: Strategy.PUBLIC, + siteSession: 'persistent', + func: async () => [{ + status: 'action_required', + logged_in: false, + site: 'github', + action: 'sign in', + verify_command: 'webcmd github whoami', + }], + }); + + const result = await executeCommand(cmd, {}, false, { + profile: 'work', + session: 'session_a', + }); + + expect(result).toEqual([{ + status: 'action_required', + logged_in: false, + site: 'github', + action: 'sign in', + verify_command: "webcmd --profile 'work' --session session_a github whoami", + }]); + expect(mockStartSessionHandoff).toHaveBeenCalledWith(expect.objectContaining({ + session: 'session_a', + site: 'github', + expiresAt: expect.any(String), + })); + }); + + it('clears the handoff only after successful internal auth verification', async () => { + const mockPage = { closeWindow: vi.fn().mockResolvedValue(undefined) } as any; + vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true); + vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn(mockPage)); + const verified = cli({ + site: 'handoff-test', + name: 'whoami', + access: 'read', + description: 'test handoff verification', + browser: true, + strategy: Strategy.PUBLIC, + siteSession: 'persistent', + func: async () => [{ logged_in: true, site: 'handoff-test' }], + }) as CliCommand & { _authVerification?: true }; + verified._authVerification = true; + + await executeCommand(verified, {}, false, { profile: 'work', session: 'session_a' }); + + expect(mockClearSessionHandoff).toHaveBeenCalledWith(expect.objectContaining({ + session: 'session_a', + site: 'handoff-test', + })); + + mockClearSessionHandoff.mockClear(); + verified.func = async () => [{ logged_in: false, site: 'handoff-test' }]; + await executeCommand(verified, {}, false, { profile: 'work', session: 'session_a' }); + expect(mockClearSessionHandoff).not.toHaveBeenCalled(); + }); + }); + it('reuses a persistent site browser session and keeps the tab lease open', async () => { const closeWindow = vi.fn().mockResolvedValue(undefined); const mockPage = { closeWindow } as any; - const sessionOpts: Array<{ session?: string; idleTimeout?: number; windowMode?: string; siteSession?: string }> = []; + const sessionOpts: Array<{ session?: string; idleTimeout?: number; windowMode?: string; siteSession?: string; adapterSite?: string }> = []; vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true); vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn, opts) => { @@ -689,8 +780,10 @@ describe('executeCommand — non-browser timeout', () => { await executeCommand(cmd, {}, false, { keepTab: 'false' }); expect(sessionOpts).toHaveLength(2); - expect(sessionOpts[0]).toMatchObject({ session: 'site:test-execution', windowMode: 'background', siteSession: 'persistent' }); - expect(sessionOpts[1]).toMatchObject({ session: 'site:test-execution', windowMode: 'background', siteSession: 'persistent' }); + expect(sessionOpts[0]).toMatchObject({ windowMode: 'background', siteSession: 'persistent', adapterSite: 'test-execution' }); + expect(sessionOpts[1]).toMatchObject({ windowMode: 'background', siteSession: 'persistent', adapterSite: 'test-execution' }); + expect(sessionOpts[0]?.session).toBeUndefined(); + expect(sessionOpts[1]?.session).toBeUndefined(); expect(sessionOpts[0]?.idleTimeout).toBeUndefined(); expect(sessionOpts[1]?.idleTimeout).toBeUndefined(); expect(closeWindow).not.toHaveBeenCalled(); @@ -721,9 +814,8 @@ describe('executeCommand — non-browser timeout', () => { await executeCommand(cmd, {}); expect(sessionOpts).toHaveLength(2); - expect(sessionOpts[0]?.session).toMatch(/^site:test-execution:/); - expect(sessionOpts[1]?.session).toMatch(/^site:test-execution:/); - expect(sessionOpts[0]?.session).not.toBe(sessionOpts[1]?.session); + expect(sessionOpts[0]?.session).toBeUndefined(); + expect(sessionOpts[1]?.session).toBeUndefined(); expect(sessionOpts[0]?.idleTimeout).toBeUndefined(); expect(sessionOpts[1]?.idleTimeout).toBeUndefined(); expect(sessionOpts[0]?.windowMode).toBe('background'); @@ -757,7 +849,7 @@ describe('executeCommand — non-browser timeout', () => { await executeCommand(cmd, {}, false, { siteSession: 'ephemeral' }); expect(sessionOpts).toHaveLength(1); - expect(sessionOpts[0]?.session).toMatch(/^site:test-execution:/); + expect(sessionOpts[0]?.session).toBeUndefined(); expect(sessionOpts[0]?.idleTimeout).toBeUndefined(); expect(closeWindow).toHaveBeenCalledTimes(1); } finally { diff --git a/src/execution.ts b/src/execution.ts index bc11dc9d..e03e6607 100644 --- a/src/execution.ts +++ b/src/execution.ts @@ -21,7 +21,6 @@ import { } from './registry.js'; import type { IPage } from './types.js'; import { pathToFileURL } from 'node:url'; -import * as crypto from 'node:crypto'; import * as fs from 'node:fs'; import * as os from 'node:os'; import { executePipeline } from './pipeline/index.js'; @@ -29,7 +28,12 @@ import { adapterLoadError, ArgumentError, CommandExecutionError, TimeoutError, a import { shouldUseBrowserSession } from './capabilityRouting.js'; import { getBrowserFactory, browserSession, runWithTimeout, DEFAULT_BROWSER_COMMAND_TIMEOUT, type BrowserWindowMode } from './runtime.js'; import { profileRouteParams, resolveProfileSelection } from './browser/profile.js'; -import { releaseSiteSessionLease, setDaemonCommandTimeoutSeconds } from './browser/daemon-client.js'; +import { + clearSessionHandoff, + releaseSiteSessionLease, + setDaemonCommandTimeoutSeconds, + startSessionHandoff, +} from './browser/daemon-client.js'; import { emitHook, type HookContext } from './hooks.js'; import { log } from './logger.js'; import { isElectronApp } from './electron-apps.js'; @@ -43,6 +47,14 @@ const _loadedModules = new Map>(); /** Track mtime of loaded user adapter files for hot-reload in daemon mode. */ const _moduleMtimes = new Map(); const _userClisDir = `${os.homedir()}/.webcmd/clis/`; +const AUTH_HANDOFF_TTL_MS = 15 * 60 * 1000; + +const quoteCliArg = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`; + +function firstResultRow(result: unknown): Record | undefined { + const row = Array.isArray(result) ? result[0] : undefined; + return row && typeof row === 'object' ? row as Record : undefined; +} async function finalizeRun(runId: string, release: boolean): Promise { clearDaemonRunContext(runId); @@ -55,12 +67,7 @@ function normalizeTraceMode(raw: unknown): TraceMode { throw new ArgumentError(`--trace must be one of: ${TRACE_MODES.join(', ')}. Received: "${String(raw)}"`); } -async function runCommand( - cmd: CliCommand, - page: IPage | null, - kwargs: CommandArgs, - debug: boolean, -): Promise { +async function loadCommand(cmd: CliCommand): Promise { const internal = cmd as InternalCliCommand; if (internal._lazy && internal._modulePath) { const modulePath = internal._modulePath; @@ -95,13 +102,18 @@ async function runCommand( } await _loadedModules.get(modulePath); - const updated = getRegistry().get(fullName(cmd)); - if (updated?.func) { - return runCommandFunc(updated, page, kwargs, debug); - } - if (updated?.pipeline) return executePipeline(page, updated.pipeline, { args: kwargs, debug }); + return getRegistry().get(fullName(cmd)) ?? cmd; } + return cmd; +} +async function runCommand( + cmd: CliCommand, + page: IPage | null, + kwargs: CommandArgs, + debug: boolean, +): Promise { + cmd = await loadCommand(cmd); if (cmd.func) return runCommandFunc(cmd, page, kwargs, debug); if (cmd.pipeline) return executePipeline(page, cmd.pipeline, { args: kwargs, debug }); throw new CommandExecutionError( @@ -161,6 +173,7 @@ export async function executeCommand( opts: { prepared?: boolean; profile?: string; + session?: string; trace?: string; keepTab?: string; windowMode?: string; @@ -189,6 +202,7 @@ export async function executeCommand( let result: unknown; try { + cmd = await loadCommand(cmd); if (shouldUseBrowserSession(cmd)) { const electron = isElectronApp(cmd.site); let cdpEndpoint: string | undefined; @@ -214,17 +228,15 @@ export async function executeCommand( const profileSelection = resolveProfileSelection(opts.profile); const profileRouting = profileRouteParams(profileSelection); const contextId = profileSelection?.contextId; - const internal = cmd as InternalCliCommand; + const internal = cmd as InternalCliCommand & { _authVerification?: true }; const siteSession = resolveSiteSession(cmd, opts.siteSession); - const session = resolveAdapterBrowserSession(cmd, siteSession); + const session = opts.session?.trim() || undefined; const keepTab = resolveKeepTab(siteSession, opts.keepTab); const windowMode = resolveBrowserWindowMode(opts.windowMode); const surface = 'adapter' as const; const canonicalCommand = fullName(cmd); - const leaseEligible = surface === 'adapter' - && siteSession === 'persistent' - && cmd.access === 'write'; - const runId = leaseEligible ? generateRunId() : undefined; + const authVerification = internal._authVerification === true; + const runId = generateRunId(); let releaseRun = true; let deferRunFinalization = false; @@ -234,7 +246,7 @@ export async function executeCommand( : new ObservationSession({ scope: { contextId, - session, + session: session ?? 'adapter-default', target: page.getActivePage?.(), site: cmd.site, command: fullName(cmd), @@ -307,10 +319,29 @@ export async function executeCommand( const browserTimeout = userTimeoutSec !== null ? userTimeoutSec + RUNTIME_TIMEOUT_PADDING_SECONDS : DEFAULT_BROWSER_COMMAND_TIMEOUT; - const result = await runWithTimeout(adapterPromise, { + let result = await runWithTimeout(adapterPromise, { timeout: browserTimeout, label: canonicalCommand, }); + const firstRow = firstResultRow(result); + const handoffParams = { + session, + site: cmd.site, + ...profileRouting, + }; + if (cmd.name === 'login' && firstRow?.status === 'action_required') { + const handoff = await startSessionHandoff({ + ...handoffParams, + expiresAt: new Date(Date.now() + AUTH_HANDOFF_TTL_MS).toISOString(), + }); + const profileId = handoff.profileId ?? profileSelection?.contextId ?? 'default'; + result = [ + { ...firstRow, verify_command: `webcmd --profile ${quoteCliArg(profileId)} --session ${handoff.id} ${cmd.site} whoami` }, + ...(result as unknown[]).slice(1), + ]; + } else if (authVerification && firstRow?.logged_in === true) { + await clearSessionHandoff(handoffParams); + } observation?.record({ stream: 'action', name: 'command', @@ -368,15 +399,13 @@ export async function executeCommand( windowMode, surface, siteSession, + adapterSite: cmd.site, freshPage: cmd.freshPage === true && siteSession === 'persistent', }); try { result = runId - ? await runWithDaemonRunContext( - { runId, command: canonicalCommand, access: 'write' }, - executeBrowser, - ) + ? await runWithDaemonRunContext({ runId, command: canonicalCommand }, executeBrowser) : await executeBrowser(); } catch (err) { if (runId && isUnknownOutcomeError(err)) releaseRun = false; @@ -511,11 +540,6 @@ function resolveSiteSession(cmd: CliCommand, rawOption?: unknown): SiteSessionMo return normalizeSiteSession(rawOption) ?? cmd.siteSession ?? 'ephemeral'; } -function resolveAdapterBrowserSession(cmd: CliCommand, siteSession: SiteSessionMode): string { - if (siteSession === 'persistent') return `site:${cmd.site}`; - return `site:${cmd.site}:${crypto.randomUUID()}`; -} - function normalizeBooleanOption(name: string, raw: unknown): boolean | null { if (raw === undefined || raw === '') return null; if (raw === 'true') return true; diff --git a/src/hosted/browser-args.test.ts b/src/hosted/browser-args.test.ts index ee6729ac..645de74b 100644 --- a/src/hosted/browser-args.test.ts +++ b/src/hosted/browser-args.test.ts @@ -1,53 +1,74 @@ import { describe, expect, it } from 'vitest'; import { CommanderStructuralError } from '../command-surface.js'; import { browserCommandCatalog } from '../browser/command-catalog.js'; -import { rewriteBrowserArgv } from '../cli-argv-preprocess.js'; -import { parseHostedBrowserStructure } from './browser-args.js'; +import { parseHostedBrowserStructure, validateRawBrowserSession } from './browser-args.js'; function parse(argv: string[]) { - return parseHostedBrowserStructure(rewriteBrowserArgv(argv)); + return parseHostedBrowserStructure(argv); } describe('hosted browser argument surface', () => { + it('requires an opaque root session selector for raw browser dispatch', () => { + expect(() => validateRawBrowserSession(undefined)).toThrowError( + expect.objectContaining({ code: 'SESSION_REQUIRED', exitCode: 2 }), + ); + expect(() => validateRawBrowserSession('work')).toThrowError( + expect.objectContaining({ code: 'INVALID_SESSION_SELECTOR', exitCode: 2 }), + ); + }); + + it('includes the selected profile in raw-session recovery commands', () => { + expect(() => validateRawBrowserSession(undefined, 'work')).toThrowError( + expect.objectContaining({ + hint: expect.stringContaining('webcmd --profile work session create'), + }), + ); + expect(() => validateRawBrowserSession(undefined, 'work')).toThrowError( + expect.objectContaining({ + hint: expect.stringContaining('webcmd --profile work session list'), + }), + ); + }); + it('uses the same command catalog as local mode', () => { expect(browserCommandCatalog.map(command => command.command)).toEqual(['tabs', 'bind', 'fork', 'run', 'snapshot', 'close']); }); it('parses a hosted adapter fork command', () => { - expect(parse(['browser', 'work', 'fork', 'linkedin/search'])).toMatchObject({ + expect(parse(['--session', 'session_work', 'browser', 'fork', 'linkedin/search'])).toMatchObject({ commandName: 'fork', - session: 'work', + session: 'session_work', positionals: ['linkedin/search'], }); }); it('requires a stable page id for bind', () => { - expect(parse(['browser', 'work', 'bind', '--page', 'page-123'])).toMatchObject({ + expect(parse(['--session', 'session_work', 'browser', 'bind', '--page', 'page-123'])).toMatchObject({ commandName: 'bind', - session: 'work', + session: 'session_work', options: { page: 'page-123' }, }); - expect(() => parse(['browser', 'work', 'bind'])).toThrow(CommanderStructuralError); - expect(() => parse(['browser', 'work', 'bind', '--index', '0'])).toThrow(CommanderStructuralError); - expect(() => parse(['browser', 'work', 'bind', '--page', ' '])).toThrow(CommanderStructuralError); + expect(() => parse(['--session', 'session_work', 'browser', 'bind'])).toThrow(CommanderStructuralError); + expect(() => parse(['--session', 'session_work', 'browser', 'bind', '--index', '0'])).toThrow(CommanderStructuralError); + expect(() => parse(['--session', 'session_work', 'browser', 'bind', '--page', ' '])).toThrow(CommanderStructuralError); }); it('accepts only run program options', () => { - expect(parse(['browser', 'work', 'run', '--file', 'job.js', '--timeout', '12', '--max-output', '1000', '--snapshot-mode', 'tree', '--no-snapshot-diff'])) + expect(parse(['--session', 'session_work', 'browser', 'run', '--file', 'job.js', '--timeout', '12', '--max-output', '1000', '--snapshot-mode', 'tree', '--no-snapshot-diff'])) .toMatchObject({ commandName: 'run', - session: 'work', + session: 'session_work', options: { file: 'job.js', timeout: 12, maxOutput: 1000, snapshotMode: 'tree', noSnapshotDiff: true }, }); - expect(() => parse(['browser', 'work', 'run', '--snapshot-mode', 'read'])).toThrow(CommanderStructuralError); - expect(() => parse(['browser', 'work', 'run', '--tab', 'page-123'])).toThrow(CommanderStructuralError); + expect(() => parse(['--session', 'session_work', 'browser', 'run', '--snapshot-mode', 'read'])).toThrow(CommanderStructuralError); + expect(() => parse(['--session', 'session_work', 'browser', 'run', '--tab', 'page-123'])).toThrow(CommanderStructuralError); }); it('parses snapshot inspection options', () => { - expect(parse(['browser', 'work', 'snapshot', '--snapshot-mode', 'read', '--max-output', '1000'])) + expect(parse(['--session', 'session_work', 'browser', 'snapshot', '--snapshot-mode', 'read', '--max-output', '1000'])) .toMatchObject({ commandName: 'snapshot', - session: 'work', + session: 'session_work', options: { snapshotMode: 'read', maxOutput: 1000 }, }); }); diff --git a/src/hosted/browser-args.ts b/src/hosted/browser-args.ts index 76c480ee..6e5f7644 100644 --- a/src/hosted/browser-args.ts +++ b/src/hosted/browser-args.ts @@ -5,6 +5,8 @@ import { browserOptionValueParser, } from '../browser/command-catalog.js'; import { CommanderStructuralError } from '../command-surface.js'; +import { CliError, EXIT_CODES } from '../errors.js'; +import { configureRootCommandSurface } from '../root-command-surface.js'; export class HostedBrowserHelp extends Error { constructor(readonly output: string) { @@ -22,28 +24,28 @@ export interface ParsedHostedBrowserStructure { profile?: string; } +export function validateRawBrowserSession(value: unknown, profile?: string): string { + const session = typeof value === 'string' ? value.trim() : ''; + const profileFlag = profile?.trim() ? ` --profile ${profile.trim()}` : ''; + const help = `Create one: webcmd${profileFlag} session create\nList sessions: webcmd${profileFlag} session list`; + if (!session) throw new CliError('SESSION_REQUIRED', 'A Session selector is required for browser commands.', help, EXIT_CODES.USAGE_ERROR); + if (!/^session_[A-Za-z0-9_-]+$/u.test(session)) { + throw new CliError('INVALID_SESSION_SELECTOR', 'Session selector must be an opaque Session ID.', help, EXIT_CODES.USAGE_ERROR); + } + return session; +} + /** * Parse the hosted browser argv with the exact canonical Commander grammar. * The returned values are the values produced by Commander's action boundary; * callers must not reinterpret the original argv with a second parser. */ export function parseHostedBrowserStructure(argv: readonly string[]): ParsedHostedBrowserStructure { - const root = new Command('webcmd') - .option('--profile ', 'Chrome profile/context alias for browser runtime commands') - .enablePositionalOptions(); + const root = configureRootCommandSurface(new Command('webcmd')); const browser = root .command('browser') - .addOption(new Option('--session ', 'Internal — set automatically from the positional').hideHelp()) - .description('Run Playwright programs against named browser sessions') - .usage(' [options]') - .addHelpText('after', ` - is a required positional: pass the name of the browser session every subcommand should operate on. Reuse the same name across calls to keep the tab/state alive; pick a different name to isolate parallel browser work. - -Examples: - $ webcmd browser work tabs - $ webcmd browser work bind --page page-123 - $ printf 'await page.goto("https://example.com")' | webcmd browser work run --stdin -`); + .description('Run Playwright programs against an explicit browser Session') + ; let parsed: ParsedHostedBrowserStructure | undefined; const namespaces = new Map([['', browser]]); @@ -115,21 +117,6 @@ Examples: for (const child of command.commands) configure(child); }; configure(root); - const browserAwareUsage = (command: Command): string => { - const ancestors: string[] = []; - let ancestor = command.parent; - while (ancestor) { - ancestors.unshift(ancestor === browser ? `${ancestor.name()} ` : ancestor.name()); - ancestor = ancestor.parent; - } - return [...ancestors, command.name(), command.usage()].filter(Boolean).join(' ').trim(); - }; - const configureBrowserUsage = (command: Command): void => { - command.configureHelp({ commandUsage: browserAwareUsage }); - for (const child of command.commands) configureBrowserUsage(child); - }; - configureBrowserUsage(browser); - try { root.parse([...argv], { from: 'user' }); } catch (error) { @@ -161,7 +148,7 @@ function readBrowserGlobals(root: Command, browser: Command): Pick< const rootOptions = root.opts>(); const browserOptions = browser.opts>(); return { - ...(typeof browserOptions.session === 'string' ? { session: browserOptions.session } : {}), + ...(typeof rootOptions.session === 'string' ? { session: rootOptions.session } : {}), ...(typeof browserOptions.window === 'string' ? { window: browserOptions.window } : {}), ...(typeof rootOptions.profile === 'string' ? { profile: rootOptions.profile } : {}), }; diff --git a/src/hosted/client.test.ts b/src/hosted/client.test.ts index 15ff6959..1cbe67d3 100644 --- a/src/hosted/client.test.ts +++ b/src/hosted/client.test.ts @@ -71,6 +71,48 @@ const validTraceUrlCases = [ ] as const; describe('HostedClient', () => { + it('accepts the hosted Session API wire contract', async () => { + const requests: Array<{ url: string; method: string; body?: string }> = []; + const session = { + id: 'session_wire', + kind: 'explicit', + profileId: 'profile_default', + runtimeState: 'active', + handoff: { site: 'github', expiresAt: '2026-08-12T00:15:00.000Z' }, + createdAt: '2026-08-12T00:00:00.000Z', + updatedAt: '2026-08-12T00:01:00.000Z', + lastUsedAt: '2026-08-12T00:02:00.000Z', + }; + const client = new HostedClient({ + apiBaseUrl: 'https://api.example.com', + apiKey: 'key', + fetchImpl: async (url, init) => { + requests.push({ + url: String(url), + method: init?.method ?? 'GET', + ...(init?.body ? { body: String(init.body) } : {}), + }); + if (String(url).endsWith('/v1/sessions')) return new Response(JSON.stringify({ ok: true, session })); + if (String(url).endsWith('/v1/sessions?profile=default&limit=20')) return new Response(JSON.stringify({ ok: true, sessions: [session] })); + return new Response(JSON.stringify({ ok: true, closed: true, alreadyIdle: false, session: 'session_wire' })); + }, + }); + + await expect(client.createBrowserSession()).resolves.toEqual({ ok: true, session }); + await expect(client.listBrowserSessions('default', 20)).resolves.toEqual({ ok: true, sessions: [session] }); + await expect(client.closeBrowserSession('session_wire')).resolves.toEqual({ + ok: true, + closed: true, + alreadyIdle: false, + session: 'session_wire', + }); + expect(requests.map(({ url, method }) => ({ url, method }))).toEqual([ + { url: 'https://api.example.com/v1/sessions', method: 'POST' }, + { url: 'https://api.example.com/v1/sessions?profile=default&limit=20', method: 'GET' }, + { url: 'https://api.example.com/v1/sessions/session_wire/close', method: 'POST' }, + ]); + }); + it('searches the authenticated marketplace and validates every public plugin field', async () => { const requests: Array<{ url: string; method: string }> = []; const client = new HostedClient({ @@ -224,7 +266,7 @@ describe('HostedClient', () => { }); it('sends bearer auth and parses hosted manifest', async () => { - const requests: Array<{ url: string; authorization: string | null }> = []; + const requests: Array<{ url: string; authorization: string | null; sessionProtocol: string | null }> = []; const client = new HostedClient({ apiBaseUrl: 'https://api.example.com/', apiKey: 'wcmd_live_test', @@ -232,6 +274,7 @@ describe('HostedClient', () => { requests.push({ url: String(url), authorization: new Headers(init?.headers).get('authorization'), + sessionProtocol: new Headers(init?.headers).get('x-webcmd-session-protocol-version'), }); return new Response(JSON.stringify({ ok: true, @@ -239,6 +282,7 @@ describe('HostedClient', () => { userId: 'user_demo', metadata: { contractSchemaVersion: 1, + sessionProtocolVersion: 1, webcmdPackageVersion: '0.3.0', generatedAt: 'now', }, @@ -252,12 +296,17 @@ describe('HostedClient', () => { userId: 'user_demo', metadata: { contractSchemaVersion: 1, + sessionProtocolVersion: 1, webcmdPackageVersion: '0.3.0', generatedAt: 'now', }, commands: [], }); - expect(requests).toEqual([{ url: 'https://api.example.com/v1/manifest', authorization: 'Bearer wcmd_live_test' }]); + expect(requests).toEqual([{ + url: 'https://api.example.com/v1/manifest', + authorization: 'Bearer wcmd_live_test', + sessionProtocol: '1', + }]); }); it('accepts boolean freshPage command metadata', async () => { @@ -270,6 +319,7 @@ describe('HostedClient', () => { userId: 'user_demo', metadata: { contractSchemaVersion: 1, + sessionProtocolVersion: 1, webcmdPackageVersion: '0.3.0', generatedAt: 'now', }, @@ -294,6 +344,22 @@ describe('HostedClient', () => { }); }); + it('reports a missing Session protocol capability as a contract mismatch', async () => { + const client = new HostedClient({ + apiBaseUrl: 'https://api.example.com', apiKey: 'key', + fetchImpl: async () => new Response(JSON.stringify({ + ok: true, + manifest: { + userId: 'user_demo', + metadata: { contractSchemaVersion: 1, webcmdPackageVersion: '0.6.1', generatedAt: 'now' }, + commands: [], + }, + }), { status: 200 }), + }); + + await expect(client.getManifest()).rejects.toMatchObject({ code: 'HOSTED_CONTRACT_MISMATCH' }); + }); + it('accepts string-array search metadata in hosted manifest commands', async () => { const client = new HostedClient({ apiBaseUrl: 'https://api.example.com', @@ -304,6 +370,7 @@ describe('HostedClient', () => { userId: 'user_demo', metadata: { contractSchemaVersion: 1, + sessionProtocolVersion: 1, webcmdPackageVersion: '0.3.0', generatedAt: 'now', }, @@ -342,6 +409,7 @@ describe('HostedClient', () => { userId: 'user_demo', metadata: { contractSchemaVersion: 1, + sessionProtocolVersion: 1, webcmdPackageVersion: '0.3.0', generatedAt: 'now', }, @@ -366,6 +434,7 @@ describe('HostedClient', () => { userId: 'user_demo', metadata: { contractSchemaVersion: 1, + sessionProtocolVersion: 1, webcmdPackageVersion: '0.3.0', generatedAt: 'now', }, @@ -476,6 +545,26 @@ describe('HostedClient', () => { } satisfies Partial); }); + it('carries the root session selector in execute requests', async () => { + let requestBody: unknown; + const client = new HostedClient({ + apiBaseUrl: 'https://api.example.com', + apiKey: 'key', + fetchImpl: async (_url, init) => { + requestBody = JSON.parse(String(init?.body)); + return new Response(JSON.stringify({ + ok: true, + result: [], + execution: { id: 'exec_1', command: 'github/whoami', status: 'succeeded' }, + })); + }, + }); + + await client.execute({ command: 'github/whoami', args: {}, session: 'session_a' }); + + expect(requestBody).toMatchObject({ command: 'github/whoami', session: 'session_a' }); + }); + it('prepares, uploads, runs, and downloads execution artifacts with raw byte bodies', async () => { const requests: Array<{ url: string; method: string; body?: unknown; filename?: string | null }> = []; const bytes = new Uint8Array(Buffer.from('hello cloud')); @@ -558,6 +647,7 @@ describe('HostedClient', () => { executionId: 'exec_files', command: 'twitter/post', args: {}, + session: 'session_a', })).resolves.toMatchObject({ artifacts: [{ artifactId: 'artifact_out' }] }); await expect(client.downloadExecutionArtifact({ executionId: 'exec_files', @@ -574,6 +664,7 @@ describe('HostedClient', () => { filename: 'one.png', body: new Uint8Array(Buffer.from('png')), }); + expect(JSON.parse(String(requests[2]?.body))).toMatchObject({ session: 'session_a' }); }); it('preserves execution and trace metadata from hosted failure envelopes', async () => { @@ -920,7 +1011,7 @@ describe('HostedClient', () => { name: 'metadata with wrong field type', manifest: { userId: 'user_demo', - metadata: { contractSchemaVersion: '1', webcmdPackageVersion: '0.3.0', generatedAt: 'now' }, + metadata: { contractSchemaVersion: '1', sessionProtocolVersion: 1, webcmdPackageVersion: '0.3.0', generatedAt: 'now' }, commands: [], }, }, @@ -928,7 +1019,7 @@ describe('HostedClient', () => { name: 'command without an args array', manifest: { userId: 'user_demo', - metadata: { contractSchemaVersion: 1, webcmdPackageVersion: '0.3.0', generatedAt: 'now' }, + metadata: { contractSchemaVersion: 1, sessionProtocolVersion: 1, webcmdPackageVersion: '0.3.0', generatedAt: 'now' }, commands: [{ site: 'github', name: 'whoami', command: 'github/whoami' }], }, }, @@ -936,7 +1027,7 @@ describe('HostedClient', () => { name: 'command with malformed argument metadata', manifest: { userId: 'user_demo', - metadata: { contractSchemaVersion: 1, webcmdPackageVersion: '0.3.0', generatedAt: 'now' }, + metadata: { contractSchemaVersion: 1, sessionProtocolVersion: 1, webcmdPackageVersion: '0.3.0', generatedAt: 'now' }, commands: [{ site: 'github', name: 'whoami', command: 'github/whoami', description: 'x', access: 'read', strategy: 'PUBLIC', browser: false, args: [{ name: 42 }], @@ -947,7 +1038,7 @@ describe('HostedClient', () => { name: 'command with a private field', manifest: { userId: 'user_demo', - metadata: { contractSchemaVersion: 1, webcmdPackageVersion: '0.3.0', generatedAt: 'now' }, + metadata: { contractSchemaVersion: 1, sessionProtocolVersion: 1, webcmdPackageVersion: '0.3.0', generatedAt: 'now' }, commands: [{ site: 'github', name: 'whoami', command: 'github/whoami', description: 'x', access: 'read', strategy: 'PUBLIC', browser: false, args: [], columns: [], internalPath: '/srv/private/token.json', @@ -958,7 +1049,7 @@ describe('HostedClient', () => { name: 'private wrapper field', manifest: { userId: 'user_demo', - metadata: { contractSchemaVersion: 1, webcmdPackageVersion: '0.3.0', generatedAt: 'now' }, + metadata: { contractSchemaVersion: 1, sessionProtocolVersion: 1, webcmdPackageVersion: '0.3.0', generatedAt: 'now' }, commands: [], }, wrapperExtra: { internalPath: '/srv/private/token.json' }, diff --git a/src/hosted/client.ts b/src/hosted/client.ts index 14ef5aee..cee791b6 100644 --- a/src/hosted/client.ts +++ b/src/hosted/client.ts @@ -1,9 +1,13 @@ import { attachTraceReceipt, CliError, EXIT_CODES, type ExitCode } from '../errors.js'; +import { HOSTED_SESSION_PROTOCOL_VERSION } from './types.js'; import type { HostedBrowserActionRequest, HostedBrowserActionResponse, HostedBrowserFinishRequest, HostedBrowserFinishResponse, + HostedBrowserSessionCloseResponse, + HostedBrowserSessionResponse, + HostedBrowserSessionsResponse, HostedBrowserRunActionInput, HostedBrowserRunActionResponse, HostedBrowserSnapshotActionResponse, @@ -82,6 +86,31 @@ export class HostedClient { async getManifest(): Promise { const body = await this.request('/v1/manifest'); + const manifestMetadata = isRecord(body) + && isRecord(body.manifest) + && isRecord(body.manifest.metadata) + ? body.manifest.metadata + : undefined; + const sessionProtocolVersion = manifestMetadata?.sessionProtocolVersion; + if ( + manifestMetadata + && ( + sessionProtocolVersion === undefined + || ( + typeof sessionProtocolVersion === 'number' + && Number.isInteger(sessionProtocolVersion) + && sessionProtocolVersion > 0 + && sessionProtocolVersion !== HOSTED_SESSION_PROTOCOL_VERSION + ) + ) + ) { + throw new HostedClientError( + 'HOSTED_CONTRACT_MISMATCH', + 'Webcmd Cloud manifest does not match this installed Webcmd hosted contract.', + 'Upgrade Webcmd or use a compatible Webcmd Cloud endpoint.', + EXIT_CODES.CONFIG_ERROR, + ); + } if (!hasExactKeys(body, ['ok', 'manifest']) || !isHostedManifest(body.manifest)) { throw protocolError('Webcmd Cloud returned an invalid manifest.'); } @@ -104,6 +133,36 @@ export class HostedClient { return { ok: true, deleted: true }; } + async createBrowserSession(profile?: string): Promise { + const body = await this.request('/v1/sessions', { + method: 'POST', + body: JSON.stringify(profile !== undefined ? { profile } : {}), + }); + if (!isHostedBrowserSessionResponse(body)) { + throw protocolError('Webcmd Cloud returned an invalid browser session response.'); + } + return body; + } + + async listBrowserSessions(profile?: string, limit?: number): Promise { + const body = await this.request(`/v1/sessions${sessionListQuery(profile, limit)}`); + if (!isHostedBrowserSessionsResponse(body)) { + throw protocolError('Webcmd Cloud returned an invalid browser session list.'); + } + return body; + } + + async closeBrowserSession(session: string, profile?: string, force = false): Promise { + const body = await this.request(`/v1/sessions/${encodeURIComponent(session)}/close${profileQuery(profile)}`, { + method: 'POST', + body: JSON.stringify(force ? { force: true } : {}), + }); + if (!isHostedBrowserSessionCloseResponse(body)) { + throw protocolError('Webcmd Cloud returned an invalid browser session close response.'); + } + return body; + } + async searchMarketplacePlugins(query?: string): Promise { const params = new URLSearchParams(); if (query !== undefined) params.set('query', query); @@ -159,6 +218,7 @@ export class HostedClient { format?: string; trace?: string; profile?: string; + session?: string; }): Promise { const traceMode = normalizeTraceMode(input.trace); const body = await this.request('/v1/execute', { @@ -211,6 +271,7 @@ export class HostedClient { format?: string; trace?: string; profile?: string; + session?: string; }): Promise { const traceMode = normalizeTraceMode(input.trace); const body = await this.request(`/v1/executions/${encodeURIComponent(input.executionId)}/run`, { @@ -221,6 +282,7 @@ export class HostedClient { ...(input.format !== undefined ? { format: input.format } : {}), ...(input.trace !== undefined ? { trace: input.trace } : {}), ...(input.profile !== undefined ? { profile: input.profile } : {}), + ...(input.session !== undefined ? { session: input.session } : {}), }), }, { command: input.command, traceMode }); if (!isHostedExecuteResponse(body, input.command, traceMode)) { @@ -329,6 +391,7 @@ export class HostedClient { accept: 'application/json', ...(init.body ? { 'content-type': 'application/json' } : {}), authorization: `Bearer ${this.apiKey}`, + 'x-webcmd-session-protocol-version': String(HOSTED_SESSION_PROTOCOL_VERSION), ...(this.workspace ? { 'x-webcmd-workspace': this.workspace } : {}), ...(init.headers ?? {}), }, @@ -388,10 +451,13 @@ function isHostedError(value: unknown): value is HostedErrorResponse { function isHostedManifest(value: unknown): value is HostedManifest { return hasExactKeys(value, ['userId', 'metadata', 'commands']) && typeof value.userId === 'string' - && hasExactKeys(value.metadata, ['contractSchemaVersion', 'webcmdPackageVersion', 'generatedAt']) + && hasExactKeys(value.metadata, ['contractSchemaVersion', 'sessionProtocolVersion', 'webcmdPackageVersion', 'generatedAt']) && typeof value.metadata.contractSchemaVersion === 'number' && Number.isInteger(value.metadata.contractSchemaVersion) && value.metadata.contractSchemaVersion > 0 + && typeof value.metadata.sessionProtocolVersion === 'number' + && Number.isInteger(value.metadata.sessionProtocolVersion) + && value.metadata.sessionProtocolVersion > 0 && typeof value.metadata.webcmdPackageVersion === 'string' && typeof value.metadata.generatedAt === 'string' && Array.isArray(value.commands) @@ -455,6 +521,66 @@ function isHostedProfilesResponse(value: unknown): value is HostedProfilesRespon && value.profiles.every(isHostedPublicProfile); } +function isHostedBrowserSessionResponse(value: unknown): value is HostedBrowserSessionResponse { + return hasExactKeys(value, ['ok', 'session']) + && value.ok === true + && isHostedBrowserSession(value.session); +} + +function isHostedBrowserSessionsResponse(value: unknown): value is HostedBrowserSessionsResponse { + return hasExactKeys(value, ['ok', 'sessions']) + && value.ok === true + && Array.isArray(value.sessions) + && value.sessions.every(isHostedBrowserSession); +} + +function isHostedBrowserSessionCloseResponse(value: unknown): value is HostedBrowserSessionCloseResponse { + return hasOnlyKeys(value, ['ok', 'closed', 'alreadyIdle', 'session', 'displaced']) + && value.ok === true + && typeof value.closed === 'boolean' + && typeof value.alreadyIdle === 'boolean' + && typeof value.session === 'string' + && (value.displaced === undefined || isHostedSessionDisplacement(value.displaced)); +} + +function isHostedBrowserSession(value: unknown): boolean { + return hasExactKeys(value, ['id', 'kind', 'profileId', 'runtimeState', 'handoff', 'createdAt', 'updatedAt', 'lastUsedAt']) + && typeof value.id === 'string' + && (value.kind === 'explicit' || value.kind === 'adapter-default') + && typeof value.profileId === 'string' + && (value.runtimeState === 'active' || value.runtimeState === 'idle') + && isHostedSessionHandoff(value.handoff) + && typeof value.createdAt === 'string' + && typeof value.updatedAt === 'string' + && typeof value.lastUsedAt === 'string'; +} + +function isHostedSessionHandoff(value: unknown): boolean { + return value === null + || (hasExactKeys(value, ['site', 'expiresAt']) + && typeof value.site === 'string' + && typeof value.expiresAt === 'string'); +} + +function isHostedSessionDisplacement(value: unknown): boolean { + return hasOnlyKeys(value, ['executionId', 'handoffSite']) + && (value.executionId === undefined || typeof value.executionId === 'string') + && (value.handoffSite === undefined || typeof value.handoffSite === 'string'); +} + +function profileQuery(profile: string | undefined): string { + if (profile === undefined) return ''; + const params = new URLSearchParams({ profile }); + return `?${params}`; +} + +function sessionListQuery(profile: string | undefined, limit: number | undefined): string { + const params = new URLSearchParams(); + if (profile !== undefined) params.set('profile', profile); + if (limit !== undefined) params.set('limit', String(limit)); + return params.size ? `?${params}` : ''; +} + function isHostedMarketplaceSearchResult(value: unknown): value is HostedMarketplaceSearchResult { return hasExactKeys(value, ['plugins', 'errors']) && Array.isArray(value.plugins) diff --git a/src/hosted/contract.test.ts b/src/hosted/contract.test.ts index 258597ab..94bc60a2 100644 --- a/src/hosted/contract.test.ts +++ b/src/hosted/contract.test.ts @@ -127,6 +127,8 @@ describe('buildHostedContract', () => { expect(contract).toEqual({ schemaVersion: HOSTED_CONTRACT_SCHEMA_VERSION, + sessionProtocolVersion: 1, + sessionSelectorPosition: 'root', webcmdVersion: '9.8.7', outputFormats: ['table', 'plain', 'json', 'yaml', 'md', 'csv'], traceModes: ['off', 'on', 'retain-on-failure'], diff --git a/src/hosted/contract.ts b/src/hosted/contract.ts index e31118e5..3d527af5 100644 --- a/src/hosted/contract.ts +++ b/src/hosted/contract.ts @@ -7,6 +7,8 @@ import { deriveHostedAvailability, type HostedAvailability, } from './availability.js'; +import { ROOT_SESSION_SELECTOR_POSITION } from '../root-command-surface.js'; +import { HOSTED_SESSION_PROTOCOL_VERSION } from './types.js'; export const HOSTED_CONTRACT_SCHEMA_VERSION = 1 as const; @@ -92,6 +94,8 @@ export interface HostedContractCommand { export interface HostedContract { schemaVersion: typeof HOSTED_CONTRACT_SCHEMA_VERSION; + sessionProtocolVersion: typeof HOSTED_SESSION_PROTOCOL_VERSION; + sessionSelectorPosition: typeof ROOT_SESSION_SELECTOR_POSITION; webcmdVersion: string; outputFormats: Array<'table' | 'plain' | 'json' | 'yaml' | 'md' | 'csv'>; traceModes: Array<'off' | 'on' | 'retain-on-failure'>; @@ -338,6 +342,8 @@ export function buildHostedContract( return { schemaVersion: HOSTED_CONTRACT_SCHEMA_VERSION, + sessionProtocolVersion: HOSTED_SESSION_PROTOCOL_VERSION, + sessionSelectorPosition: ROOT_SESSION_SELECTOR_POSITION, webcmdVersion: packageVersion, outputFormats: shared.outputFormats, traceModes: shared.traceModes, diff --git a/src/hosted/main-lifecycle.test.ts b/src/hosted/main-lifecycle.test.ts index be5868d1..b5abbb77 100644 --- a/src/hosted/main-lifecycle.test.ts +++ b/src/hosted/main-lifecycle.test.ts @@ -192,6 +192,7 @@ async function createHostedFixture(outcome: 'success' | 'failure'): Promise<{ userId: 'user_lifecycle', metadata: { contractSchemaVersion: 1, + sessionProtocolVersion: 1, webcmdPackageVersion: PKG_VERSION, generatedAt: '2026-07-14T00:00:00.000Z', }, diff --git a/src/hosted/manifest.test.ts b/src/hosted/manifest.test.ts index 2dfc0c11..ab80c9c7 100644 --- a/src/hosted/manifest.test.ts +++ b/src/hosted/manifest.test.ts @@ -28,7 +28,8 @@ const manifest: HostedManifest = { userId: 'user_demo', metadata: { contractSchemaVersion: 1, - webcmdPackageVersion: PKG_VERSION, + sessionProtocolVersion: 1, + webcmdPackageVersion: PKG_VERSION, generatedAt: '2026-07-08T00:00:00.000Z', }, commands: [ diff --git a/src/hosted/output-parity.test.ts b/src/hosted/output-parity.test.ts index 2cfd361c..3945c900 100644 --- a/src/hosted/output-parity.test.ts +++ b/src/hosted/output-parity.test.ts @@ -32,7 +32,8 @@ const manifest = { userId: 'user_demo', metadata: { contractSchemaVersion: 1, - webcmdPackageVersion: PKG_VERSION, + sessionProtocolVersion: 1, + webcmdPackageVersion: PKG_VERSION, generatedAt: '2026-07-14T00:00:00.000Z', }, commands: [{ diff --git a/src/hosted/root-command-surface.test.ts b/src/hosted/root-command-surface.test.ts index 92f1c793..6da56199 100644 --- a/src/hosted/root-command-surface.test.ts +++ b/src/hosted/root-command-surface.test.ts @@ -38,7 +38,8 @@ const manifest = { userId: 'user_demo', metadata: { contractSchemaVersion: 1, - webcmdPackageVersion: PKG_VERSION, + sessionProtocolVersion: 1, + webcmdPackageVersion: PKG_VERSION, generatedAt: '2026-07-08T00:00:00.000Z', }, commands: [{ @@ -180,6 +181,18 @@ const generatedTerminalCorpus = profileForms.flatMap(profile => ); describe('hosted root command surface', () => { + it('parses the canonical root session selector', () => { + expect(parseHostedRootCommandSurface([ + '--profile', 'work', '--session', 'session_a', 'github', 'issues', + ])).toEqual({ + kind: 'dispatch', + argv: ['github', 'issues'], + profile: 'work', + session: 'session_a', + literal: false, + }); + }); + it('advertises hosted profile management as a root command, not a local-only namespace', () => { expect(HOSTED_ROOT_HELP.commands).toContainEqual({ name: 'profile', diff --git a/src/hosted/runner.test.ts b/src/hosted/runner.test.ts index a87836f8..961c13b1 100644 --- a/src/hosted/runner.test.ts +++ b/src/hosted/runner.test.ts @@ -7,7 +7,7 @@ import type { Command } from 'commander'; import { describe, expect, it, vi } from 'vitest'; import { browserCommandCatalog } from '../browser/command-catalog.js'; import { buildHostedContract } from './contract.js'; -import { rewriteBrowserArgv } from '../cli-argv-preprocess.js'; +import { rejectPositionalBrowserSessionArgv } from '../cli-argv-preprocess.js'; import { createProgram } from '../cli.js'; import { formatRootHelp } from '../command-presentation.js'; import { HOSTED_ROOT_HELP } from '../completion-shared.js'; @@ -32,7 +32,8 @@ const manifest = { userId: 'user_demo', metadata: { contractSchemaVersion: 1, - webcmdPackageVersion: PKG_VERSION, + sessionProtocolVersion: 1, + webcmdPackageVersion: PKG_VERSION, generatedAt: '2026-07-08T00:00:00.000Z', }, commands: [ @@ -251,7 +252,7 @@ function captureLocalBrowserStructure(argv: string[]): { }; configure(program); try { - program.parse(rewriteBrowserArgv(argv), { from: 'user' }); + program.parse(rejectPositionalBrowserSessionArgv(argv), { from: 'user' }); return { exitCode: 0, stdout, stderr }; } catch (error) { const commander = error as { exitCode?: number }; @@ -545,6 +546,92 @@ describe('runHostedCli', () => { expect(requests.some(request => request.url.endsWith('/v1/manifest'))).toBe(false); }); + it('preflights the hosted contract before managing browser Sessions', async () => { + const requests: Array<{ url: string; method: string; body?: unknown }> = []; + const session = { + id: 'session_abc', kind: 'explicit', profileId: 'profile_work', runtimeState: 'idle', + handoff: { site: 'github', expiresAt: '2026-01-01T00:15:00.000Z' }, + createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:01:00.000Z', lastUsedAt: '2026-01-01T00:02:00.000Z', + }; + const fetchImpl = vi.fn(async (url, init) => { + const request = { + url: String(url), method: init?.method ?? 'GET', + ...(init?.body ? { body: JSON.parse(String(init.body)) } : {}), + }; + requests.push(request); + if (request.url.endsWith('/v1/manifest')) return manifestResponse(); + if (request.method === 'POST' && request.url.endsWith('/v1/sessions')) return new Response(JSON.stringify({ ok: true, session })); + if (request.method === 'POST' && request.url.endsWith(`/v1/sessions/${session.id}/close?profile=work`)) { + return new Response(JSON.stringify({ ok: true, closed: false, alreadyIdle: true, session: session.id })); + } + return new Response(JSON.stringify({ ok: true, sessions: [session] })); + }); + + const outputs: string[] = []; + for (const argv of [ + ['--profile', 'work', 'session', 'create', '-f', 'json'], + ['--profile', 'work', 'session', 'list', '-f', 'json'], + ['--profile', 'work', 'session', 'close', session.id, '--force', '-f', 'json'], + ]) { + const stdout = sink(); + const result = await runHostedCli(argv, { + config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), + stdout: stdout.stream, + fetchImpl, + }); + expect(result).toEqual({ handled: true, exitCode: 0 }); + expect(stdout.text()).toContain(session.id); + outputs.push(stdout.text()); + } + expect(outputs[0]).toContain('"runtimeState": "idle"'); + expect(outputs[1]).toContain('"handoff": "github until 2026-01-01T00:15:00.000Z"'); + expect(outputs[0]).not.toContain('"profileId"'); + + expect(requests).toEqual([ + { url: 'https://api.example.com/v1/manifest', method: 'GET' }, + { url: 'https://api.example.com/v1/sessions', method: 'POST', body: { profile: 'work' } }, + { url: 'https://api.example.com/v1/manifest', method: 'GET' }, + { url: 'https://api.example.com/v1/sessions?profile=work&limit=20', method: 'GET' }, + { url: 'https://api.example.com/v1/manifest', method: 'GET' }, + { url: 'https://api.example.com/v1/sessions/session_abc/close?profile=work', method: 'POST', body: { force: true } }, + ]); + }); + + it('rejects Session lifecycle calls when the hosted Session protocol differs', async () => { + const stderr = sink(); + const result = await runHostedCli(['session', 'list', '-f', 'json'], { + config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), + stderr: stderr.stream, + fetchImpl: async () => new Response(JSON.stringify({ + ok: true, + manifest: { ...manifest, metadata: { ...manifest.metadata, sessionProtocolVersion: 99 } }, + }), { status: 200 }), + }); + + expect(result.exitCode).toBe(78); + expect(stderr.text()).toMatch(/HOSTED_CONTRACT_MISMATCH/); + }); + + it('forwards hosted session list limit to Cloud', async () => { + const requests: Array<{ url: string; method: string }> = []; + const stdout = sink(); + const result = await runHostedCli(['session', 'list', '--limit', '50', '-f', 'json'], { + config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), + stdout: stdout.stream, + fetchImpl: async (url, init) => { + requests.push({ url: String(url), method: init?.method ?? 'GET' }); + if (String(url).endsWith('/v1/manifest')) return manifestResponse(); + return new Response(JSON.stringify({ ok: true, sessions: [] })); + }, + }); + + expect(result).toEqual({ handled: true, exitCode: 0 }); + expect(requests).toContainEqual({ + url: 'https://api.example.com/v1/sessions?limit=50', + method: 'GET', + }); + }); + it.each(['create', 'get'])('rejects the removed profile %s subcommand', async (command) => { const stderr = sink(); const fetchImpl = vi.fn(); @@ -1182,7 +1269,7 @@ describe('runHostedCli', () => { const requests: Array<{ url: string; body?: unknown }> = []; const stdout = sink(); - const result = await runHostedCli(['github', 'whoami', '-f', 'json'], { + const result = await runHostedCli(['--session', 'session_a', 'github', 'whoami', '-f', 'json'], { config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), stdout: stdout.stream, fetchImpl: async (url, init) => { @@ -1205,6 +1292,7 @@ describe('runHostedCli', () => { args: {}, format: 'json', trace: 'off', + session: 'session_a', }, }); expect(stdout.text()).toBe('[\n {\n "username": "octocat"\n }\n]\n'); @@ -1607,10 +1695,26 @@ describe('runHostedCli', () => { }); expect(result.exitCode).toBe(1); - expect(stderr.text()).toMatch(/HOSTED_PROTOCOL|hosted contract/i); + expect(stderr.text()).toMatch(/HOSTED_CONTRACT_MISMATCH|hosted contract/i); expect(requests).toEqual(['https://api.example.com/v1/manifest']); }); + it('rejects a manifest whose Session protocol differs before execution', async () => { + const stderr = sink(); + const mismatched = { + ...manifest, + metadata: { ...manifest.metadata, sessionProtocolVersion: 99 }, + }; + const result = await runHostedCli(['github', 'whoami'], { + config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), + stderr: stderr.stream, + fetchImpl: async () => new Response(JSON.stringify({ ok: true, manifest: mismatched }), { status: 200 }), + }); + + expect(result.exitCode).toBe(78); + expect(stderr.text()).toMatch(/HOSTED_CONTRACT_MISMATCH/); + }); + it('writes a result larger than 1 MiB completely through injected stdout', async () => { const value = 'x'.repeat((1024 * 1024) + 31); const stdout = sink(); @@ -1656,7 +1760,7 @@ describe('runHostedCli', () => { const stderr = sink(); const fetchImpl = vi.fn(); - const result = await runHostedCli(['browser', 'work', ...parts, '--help'], { + const result = await runHostedCli(['--session', 'session_work', 'browser', ...parts, '--help'], { config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), stdout: stdout.stream, stderr: stderr.stream, @@ -1688,7 +1792,7 @@ describe('runHostedCli', () => { : contract.command === 'run' ? ['--file', uploadFile] : []; - const result = await runHostedCli(['browser', 'work', ...contract.command.split('/'), ...positionals, ...options], { + const result = await runHostedCli(['--session', 'session_work', 'browser', ...contract.command.split('/'), ...positionals, ...options], { config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), stdout: sink().stream, stderr: sink().stream, @@ -1697,7 +1801,7 @@ describe('runHostedCli', () => { const body = init?.body ? JSON.parse(String(init.body)) as Record : undefined; requests.push({ pathname: parsedUrl.pathname, ...(body ? { body } : {}) }); if (parsedUrl.pathname === '/v1/manifest') return manifestResponse(); - if (parsedUrl.pathname === '/v1/browser/work/commands') { + if (parsedUrl.pathname === '/v1/browser/session_work/commands') { return new Response(JSON.stringify({ ok: true, result: {}, @@ -1705,7 +1809,7 @@ describe('runHostedCli', () => { trace: null, run: { executionId: `exec_${contract.command.replaceAll('/', '_')}`, - session: 'work', + session: 'session_work', profile: { id: 'profile_default', displayName: 'default' }, }, execution: { id: `exec_${contract.command.replaceAll('/', '_')}`, status: 'succeeded' }, @@ -1724,7 +1828,7 @@ describe('runHostedCli', () => { }); expect({ command: contract.command, - action: requests.find(request => request.pathname === '/v1/browser/work/commands')?.body, + action: requests.find(request => request.pathname === '/v1/browser/session_work/commands')?.body, }).toMatchObject({ command: contract.command, action: { command: `browser/${contract.command}`, action: contract.action }, @@ -1741,7 +1845,7 @@ describe('runHostedCli', () => { await writeFile(sourcePath, 'return 42;'); const requests: Array<{ url: string; body?: Record }> = []; try { - const result = await runHostedCli(['browser', 'work', 'run', '--file', sourcePath, '--snapshot-mode', 'tree', '--no-snapshot-diff'], { + const result = await runHostedCli(['--session', 'session_work', 'browser', 'run', '--file', sourcePath, '--snapshot-mode', 'tree', '--no-snapshot-diff'], { config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), stdout: sink().stream, stderr: sink().stream, @@ -1754,7 +1858,7 @@ describe('runHostedCli', () => { result: {}, columns: [], trace: null, - run: { executionId: 'exec_browser_run', session: 'work', profile: { id: 'profile_default', displayName: 'default' } }, + run: { executionId: 'exec_browser_run', session: 'session_work', profile: { id: 'profile_default', displayName: 'default' } }, execution: { id: 'exec_browser_run', status: 'succeeded' }, }), { status: 200 }); }, @@ -1774,7 +1878,7 @@ describe('runHostedCli', () => { it('forwards browser snapshot mode to hosted browser actions', async () => { const requests: Array<{ url: string; body?: Record }> = []; - const result = await runHostedCli(['browser', 'work', 'snapshot', '--snapshot-mode', 'read', '--ref', 'l7', '--max-output', '1000'], { + const result = await runHostedCli(['--session', 'session_work', 'browser', 'snapshot', '--snapshot-mode', 'read', '--ref', 'l7', '--max-output', '1000'], { config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), stdout: sink().stream, stderr: sink().stream, @@ -1784,7 +1888,7 @@ describe('runHostedCli', () => { if (String(url).endsWith('/v1/manifest')) return manifestResponse(); return new Response(JSON.stringify({ ok: true, - run: { executionId: 'exec_browser_snapshot', session: 'work', profile: { id: 'profile_default', displayName: 'default' } }, + run: { executionId: 'exec_browser_snapshot', session: 'session_work', profile: { id: 'profile_default', displayName: 'default' } }, result: { ok: true, tree: '', page: { url: 'https://example.test', title: 'Example' }, warnings: [], limits: { snapshotTruncated: false } }, }), { status: 200 }); }, @@ -1799,7 +1903,7 @@ describe('runHostedCli', () => { it('prints hosted snapshot trees', async () => { const stdout = sink(); - const result = await runHostedCli(['browser', 'work', 'snapshot'], { + const result = await runHostedCli(['--session', 'session_work', 'browser', 'snapshot'], { config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), stdout: stdout.stream, stderr: sink().stream, @@ -1807,7 +1911,7 @@ describe('runHostedCli', () => { ? manifestResponse() : new Response(JSON.stringify({ ok: true, - run: { executionId: 'exec_browser_snapshot', session: 'work', profile: { id: 'profile_default', displayName: 'default' } }, + run: { executionId: 'exec_browser_snapshot', session: 'session_work', profile: { id: 'profile_default', displayName: 'default' } }, result: { ok: true, tree: '', page: { url: 'https://example.test', title: 'Example' }, warnings: [], limits: { snapshotTruncated: false } }, }), { status: 200 }), }); @@ -1829,14 +1933,32 @@ describe('runHostedCli', () => { expect(stderr.text()).not.toContain('# webcmd default github'); }); - it('rejects the retired hosted browser --session flag', async () => { + it('rejects retired positional hosted browser sessions', async () => { const stderr = sink(); - const result = await runHostedCli(['browser', '--session', 'work', 'state'], { + const result = await runHostedCli(['browser', 'work', 'state'], { config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), stderr: stderr.stream, }); - expect(result.exitCode).toBe(78); - expect(stderr.text()).toMatch(/session.*no longer a public option/i); + expect(result.exitCode).toBe(2); + expect(stderr.text()).toMatch(/Browser sessions are root selectors/i); + }); + + it.each([ + { argv: ['browser', 'tabs'], code: 'SESSION_REQUIRED' }, + { argv: ['--session', 'work', 'browser', 'tabs'], code: 'INVALID_SESSION_SELECTOR' }, + ])('rejects an unusable raw selector before hosted transport: $code', async ({ argv, code }) => { + const stderr = sink(); + const fetchImpl = vi.fn(); + + const result = await runHostedCli(argv, { + config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), + stderr: stderr.stream, + fetchImpl, + }); + + expect(result.exitCode).toBe(2); + expect(fetchImpl).not.toHaveBeenCalled(); + expect(stderr.text()).toContain(code); }); }); diff --git a/src/hosted/runner.ts b/src/hosted/runner.ts index e4205332..f0a62753 100644 --- a/src/hosted/runner.ts +++ b/src/hosted/runner.ts @@ -1,7 +1,7 @@ import { readFileSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { Command, CommanderError } from 'commander'; +import { Command, CommanderError, InvalidArgumentError } from 'commander'; import { configureCompletionCommandSurface, configureListCommandSurface, @@ -11,7 +11,7 @@ import { configurePluginUninstallSurface, configurePluginUpdateSurface, } from '../builtin-command-surface.js'; -import { BrowserSessionArgvError, rewriteBrowserArgv } from '../cli-argv-preprocess.js'; +import { BrowserSessionArgvError, rejectMisplacedSessionSelectorArgv, rejectPositionalBrowserSessionArgv } from '../cli-argv-preprocess.js'; import { CommanderStructuralError, MissingRequiredPositionalError } from '../command-surface.js'; import { filterCommandsByTag, formatRootHelp, getCommandCompletionCandidates } from '../command-presentation.js'; import { @@ -32,8 +32,9 @@ import { BrowserRunError } from '../browser/run/types.js'; import { CLI_COMMAND } from '../brand.js'; import { missingPluginGuidance } from '../discovery.js'; import { HostedClient, HostedClientError, resolveWorkspace } from './client.js'; +import { HOSTED_SESSION_PROTOCOL_VERSION } from './types.js'; import { parseHostedInvocation } from './args.js'; -import { HostedBrowserHelp, parseHostedBrowserStructure } from './browser-args.js'; +import { HostedBrowserHelp, parseHostedBrowserStructure, validateRawBrowserSession } from './browser-args.js'; import { materializeHostedOutputs, prepareHostedFiles, rewriteHostedOutputResultPaths } from './files.js'; import { findHostedCommand, @@ -93,6 +94,7 @@ export async function runHostedCli(argv: string[], opts: HostedRunnerOptions = { const stderr = opts.stderr ?? process.stderr; try { + argv = rejectMisplacedSessionSelectorArgv(rejectPositionalBrowserSessionArgv(argv)); const credential = await resolveHostedApiKey(config, { credentialStore: opts.credentialStore, env: opts.env, @@ -111,6 +113,10 @@ export async function runHostedCli(argv: string[], opts: HostedRunnerOptions = { return { handled: true, exitCode: EXIT_CODES.SUCCESS }; } catch (err) { if (err instanceof StreamWriteError) throw err; + if (err instanceof BrowserSessionArgvError) { + await writeToStream(stderr, `error: ${err.message}\n`); + return { handled: true, exitCode: EXIT_CODES.USAGE_ERROR }; + } if (err instanceof CliError && err.code === 'UNSUPPORTED_SHELL') throw err; if (err instanceof CommanderStructuralError) { await writeToStream(stderr, err.output); @@ -186,8 +192,19 @@ async function dispatchHosted( LOCAL_ONLY_COMMAND_HELP, ); } + if (args[0] === 'session') { + const parsed = parseHostedSessionSurface(args.slice(1), normalized.literal); + if (parsed.kind === 'help') { + await writeToStream(stdout, parsed.output); + return; + } + const manifest = await client.getManifest(); + validateManifestContractIdentity(manifest); + await dispatchHostedSession(parsed, client, stdout, normalized.profile); + return; + } if (args[0] === 'browser') { - const invocation = await parseHostedBrowserInvocation(args, normalized.profile); + const invocation = await parseHostedBrowserInvocation(args, normalized.profile, normalized.session); const manifest = await client.getManifest(); validateManifestContractIdentity(manifest); await dispatchHostedBrowser(invocation, client, stdout); @@ -398,6 +415,7 @@ async function dispatchHosted( format: parsed.format, trace: parsed.trace, profile: parsed.profile ?? normalized.profile, + session: normalized.session, }) : await client.execute({ command: command.command, @@ -405,6 +423,7 @@ async function dispatchHosted( format: parsed.format, trace: parsed.trace, profile: parsed.profile ?? normalized.profile, + session: normalized.session, }); let format: string = parsed.format; if (!parsed.formatExplicit && format === 'table' && command.defaultFormat) { @@ -428,6 +447,86 @@ async function dispatchHosted( } } +type ParsedHostedSessionSurface = + | { kind: 'help'; output: string } + | { kind: 'run'; command: 'create' | 'list' | 'close'; format: string; session?: string; force?: boolean; limit?: number }; + +function parseHostedSessionSurface(argv: readonly string[], literal: boolean): ParsedHostedSessionSurface { + let stdout = ''; + let stderr = ''; + let parsed: Exclude | undefined; + const root = new Command('webcmd'); + const session = root.command('session').description('Create, list, and close browser Sessions'); + const output = { + writeOut: (value: string) => { stdout += value; }, + writeErr: (value: string) => { stderr += value; }, + }; + root.exitOverride().configureOutput(output); + session.exitOverride().configureOutput(output); + const configure = (command: Command, format: string): Command => command.option('-f, --format ', 'Output format: table, json, yaml', format); + configure(session.command('create'), 'yaml').action((options: { format: string }) => { parsed = { kind: 'run', command: 'create', format: options.format }; }); + configure(session.command('list').option('--limit ', 'Maximum Sessions to return (1-100)', parseHostedSessionListLimit, 20), 'table').action((options: { format: string; limit: number }) => { + parsed = { kind: 'run', command: 'list', format: options.format, limit: options.limit }; + }); + configure(session.command('close').argument('').option('--force', 'Close even while the Session is busy or paused for handoff'), 'yaml').action((sessionId: string, options: { format: string; force?: boolean }) => { + parsed = { kind: 'run', command: 'close', format: options.format, session: sessionId, force: options.force === true }; + }); + try { + root.parse(literal ? ['--', 'session', ...argv] : ['session', ...argv], { from: 'user' }); + } catch (error) { + if (!(error instanceof CommanderError)) throw error; + if (error.code === 'commander.helpDisplayed') return { kind: 'help', output: stdout }; + throw new CommanderStructuralError(stderr || `${error.message}\n`, error.exitCode); + } + if (!parsed) throw new CommanderStructuralError("error: command 'session' did not run\n", 1); + return parsed; +} + +async function dispatchHostedSession( + parsed: Exclude, + client: HostedClient, + stdout: NodeJS.WritableStream, + profile?: string, +): Promise { + if (parsed.command === 'create') { + await renderOutput(sessionCreateOutput((await client.createBrowserSession(profile)).session), { fmt: parsed.format, columns: ['id', 'kind', 'runtimeState'], stdout }); + return; + } + if (parsed.command === 'list') { + const rows = (await client.listBrowserSessions(profile, parsed.limit)).sessions + .map((row) => ({ ...row, handoff: formatHostedSessionHandoff(row.handoff) })); + if (rows.length === 0 && parsed.format === 'table') { + await writeToStream(stdout, `No browser Sessions found${profile ? ` for Profile ${profile}` : ''}.\n`); + return; + } + await renderOutput(rows, { fmt: parsed.format, columns: ['id', 'kind', 'runtimeState', 'handoff'], stdout }); + return; + } + await renderOutput(await client.closeBrowserSession(parsed.session!, profile, parsed.force === true), { fmt: parsed.format, stdout }); +} + +function parseHostedSessionListLimit(value: string): number { + const limit = Number(value); + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) { + throw new InvalidArgumentError('Session list limit must be an integer from 1 to 100.'); + } + return limit; +} + +function sessionCreateOutput(data: unknown): unknown { + if (!data || typeof data !== 'object' || Array.isArray(data)) return data; + const row = data as Record; + return { id: row.id, kind: row.kind, runtimeState: row.runtimeState }; +} + +function formatHostedSessionHandoff(handoff: unknown): string { + if (!handoff || typeof handoff !== 'object') return ''; + const row = handoff as { site?: unknown; expiresAt?: unknown }; + return typeof row.site === 'string' && typeof row.expiresAt === 'string' + ? `${row.site} until ${row.expiresAt}` + : ''; +} + function hasPresentFileArgument( command: import('./types.js').HostedCommand, args: Record, @@ -446,6 +545,7 @@ async function executeHostedFileCommand(input: { format: string; trace: string; profile?: string; + session?: string; }): Promise { const prepared = await prepareHostedFiles({ client: input.client, @@ -459,6 +559,7 @@ async function executeHostedFileCommand(input: { format: input.format, trace: input.trace, ...(input.profile !== undefined ? { profile: input.profile } : {}), + ...(input.session !== undefined ? { session: input.session } : {}), }); const materialized = await materializeHostedOutputs({ client: input.client, @@ -541,37 +642,25 @@ function contentTypeForUpload(filePath: string): string { } } -async function parseHostedBrowserInvocation(argv: string[], profile: string | undefined): Promise { - let rewritten: string[]; - try { - rewritten = rewriteBrowserArgv(argv); - } catch (error) { - if (error instanceof BrowserSessionArgvError) { - throw new ConfigError(error.message, 'Use: webcmd browser '); - } - throw error; - } +async function parseHostedBrowserInvocation( + argv: string[], + profile: string | undefined, + session: string | undefined, +): Promise { let structure; try { - structure = parseHostedBrowserStructure(rewritten); + structure = parseHostedBrowserStructure(session === undefined ? argv : ['--session', session, ...argv]); } catch (error) { if (error instanceof HostedBrowserHelp) throw new CommanderCompatibleError('', 0, error.output); throw error; } - if (rewritten[0] !== 'browser') { + if (argv[0] !== 'browser') { throw new ConfigError('Hosted browser invocation must start with browser.'); } - if (!structure.session) { - throw new ConfigError( - ' is required for hosted browser commands.', - 'Use: webcmd browser ', - ); - } - if (!structure.commandName) { throw new ConfigError( 'Hosted browser command is required.', - 'Use: webcmd browser tabs, bind --page , run --stdin|--file , or close.', + 'Use: webcmd --session browser tabs, bind --page , or run --stdin|--file .', ); } @@ -579,7 +668,7 @@ async function parseHostedBrowserInvocation(argv: string[], profile: string | un const parsed = parseBrowserLeaf(structure.commandName, structure.positionals, structure.options); const browserArgs = await materializeBrowserRunSource(parsed.commandName, parsed.args); return { - session: structure.session, + session: validateRawBrowserSession(structure.session, profile), command: `browser/${parsed.commandName}`, action: parsed.action, args: browserArgs, @@ -1129,12 +1218,13 @@ function validateManifestContractIdentity(manifest: HostedManifest): void { const manifestLine = hostedContractCompatibilityLine(manifest.metadata.webcmdPackageVersion); if ( manifest.metadata.contractSchemaVersion !== installed.schemaVersion + || manifest.metadata.sessionProtocolVersion !== HOSTED_SESSION_PROTOCOL_VERSION || !installedLine || !manifestLine || manifestLine !== installedLine ) { throw new HostedClientError( - 'HOSTED_PROTOCOL', + 'HOSTED_CONTRACT_MISMATCH', 'Webcmd Cloud manifest does not match this installed Webcmd hosted contract.', ); } diff --git a/src/hosted/types.ts b/src/hosted/types.ts index d75c41d7..6f61c02c 100644 --- a/src/hosted/types.ts +++ b/src/hosted/types.ts @@ -1,6 +1,8 @@ import type { CommandSurfaceMetadata } from '../command-surface.js'; import type { Arg } from '../registry.js'; +export const HOSTED_SESSION_PROTOCOL_VERSION = 1 as const; + export type HostedCommandStrategy = 'PUBLIC' | 'COOKIE' | 'INTERCEPT' | 'UI' | 'LOCAL' | string; export interface HostedCommandArg extends Arg {} @@ -40,6 +42,7 @@ export interface HostedManifest { userId: string; metadata: { contractSchemaVersion: number; + sessionProtocolVersion: number; webcmdPackageVersion: string; generatedAt: string; }; @@ -62,6 +65,35 @@ export interface HostedProfilesResponse { profiles: HostedPublicProfile[]; } +export interface HostedBrowserSession { + id: string; + kind: 'explicit' | 'adapter-default'; + profileId: string; + runtimeState: 'active' | 'idle'; + handoff: { site: string; expiresAt: string } | null; + createdAt: string; + updatedAt: string; + lastUsedAt: string; +} + +export interface HostedBrowserSessionResponse { + ok: true; + session: HostedBrowserSession; +} + +export interface HostedBrowserSessionsResponse { + ok: true; + sessions: HostedBrowserSession[]; +} + +export interface HostedBrowserSessionCloseResponse { + ok: true; + closed: boolean; + alreadyIdle: boolean; + session: string; + displaced?: { executionId?: string; handoffSite?: string }; +} + export interface HostedMarketplacePlugin { name: string; description?: string; diff --git a/src/main.ts b/src/main.ts index 57fd60da..b496b008 100644 --- a/src/main.ts +++ b/src/main.ts @@ -87,7 +87,13 @@ if (!fastPathHandled) { const result = await runHostedCli(argv); process.exitCode = result.exitCode; } else { - await runLocalMain(); + const { installDaemonRunSignalCancellation } = await import('./signal-cancel.js'); + const uninstallSignalCancellation = installDaemonRunSignalCancellation(); + try { + await runLocalMain(); + } finally { + uninstallSignalCancellation(); + } } } } @@ -178,13 +184,9 @@ if (getCompIdx !== -1) { process.exit(EXIT_CODES.SUCCESS); } -// Rewrite `webcmd browser ...` so commander (which -// can't combine a parent positional with subcommand dispatch) sees the internal -// `--session ` flag form. Also refuses the retired `webcmd browser -// --session foo ...` user form with a friendly usage error. -const { rewriteBrowserArgv, BrowserSessionArgvError, escapeLeadingDashPositional } = await import('./cli-argv-preprocess.js'); +const { rejectMisplacedSessionSelectorArgv, rejectPositionalBrowserSessionArgv, BrowserSessionArgvError, escapeLeadingDashPositional } = await import('./cli-argv-preprocess.js'); try { - let rewritten = rewriteBrowserArgv(process.argv.slice(2)); + let rewritten = rejectMisplacedSessionSelectorArgv(rejectPositionalBrowserSessionArgv(process.argv.slice(2))); // Use the metadata that discovery actually registered. The core manifest is // intentionally empty, while installed plugins and legacy user CLIs are not. const { getRegistry } = await import('./registry.js'); @@ -193,7 +195,7 @@ try { } catch (err) { if (err instanceof BrowserSessionArgvError) { process.stderr.write(`error: ${err.message}\n`); - process.exit(EXIT_CODES.GENERIC_ERROR); + process.exit(EXIT_CODES.USAGE_ERROR); } throw err; } diff --git a/src/plugin-runtime.test.ts b/src/plugin-runtime.test.ts index 6e8fcb1b..1472e7ef 100644 --- a/src/plugin-runtime.test.ts +++ b/src/plugin-runtime.test.ts @@ -174,6 +174,8 @@ describe('site auth command helper', () => { }); expect(getRegistry().get('auth-helper-registration/auth-status')) .toBe(getRegistry().get('auth-helper-registration/whoami')); + expect(getRegistry().get('auth-helper-registration/whoami')) + .toMatchObject({ _authVerification: true }); const login = getRegistry().get('auth-helper-registration/login')!; expect(login).toMatchObject({ access: 'write', diff --git a/src/plugin-runtime.ts b/src/plugin-runtime.ts index 27f0edd3..8d888578 100644 --- a/src/plugin-runtime.ts +++ b/src/plugin-runtime.ts @@ -1,7 +1,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from './errors.js'; -import { cli, Strategy, type CommandArgs, type CliOptions } from './registry-api.js'; +import { cli, Strategy, type CliCommand, type CommandArgs, type CliOptions } from './registry-api.js'; import type { IPage } from './types.js'; export function clampInt(raw: unknown, fallback: number, min: number, max: number): number { @@ -248,7 +248,7 @@ export function registerSiteAuthCommands(config: SiteAuthConfig): void { const refresh = config.refresh; if (config.registerWhoami !== false) { - cli({ + const whoami = cli({ site: config.site, name: 'whoami', access: 'read', @@ -270,7 +270,8 @@ export function registerSiteAuthCommands(config: SiteAuthConfig): void { : {}), }, func: async (page) => [await tryProbe(page)], - }); + }) as CliCommand & { _authVerification?: true }; + whoami._authVerification = true; } cli({ diff --git a/src/plugin.test.ts b/src/plugin.test.ts index 46eae40a..d87f471c 100644 --- a/src/plugin.test.ts +++ b/src/plugin.test.ts @@ -2,7 +2,7 @@ * Tests for plugin management: install, uninstall, list, and lock file support. */ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from 'vitest'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; @@ -12,10 +12,18 @@ import type { LockEntry } from './plugin.js'; import * as pluginModule from './plugin.js'; import { createAdapterOverride } from './adapter-override.js'; -const { mockExecFileSync, mockExecSync } = vi.hoisted(() => ({ - mockExecFileSync: vi.fn(), - mockExecSync: vi.fn(), -})); +const { mockExecFileSync, mockExecSync, testConfigDir, previousConfigDir } = vi.hoisted(() => { + const previousConfigDir = process.env.WEBCMD_CONFIG_DIR; + const testConfigDir = `${process.env.TMPDIR ?? '/tmp'}/webcmd-plugin-test-${process.pid}-${Date.now()}`; + process.env.WEBCMD_CONFIG_DIR = testConfigDir; + return { mockExecFileSync: vi.fn(), mockExecSync: vi.fn(), testConfigDir, previousConfigDir }; +}); + +afterAll(() => { + fs.rmSync(testConfigDir, { recursive: true, force: true }); + if (previousConfigDir === undefined) delete process.env.WEBCMD_CONFIG_DIR; + else process.env.WEBCMD_CONFIG_DIR = previousConfigDir; +}); const { _getCommitHash, diff --git a/src/root-command-surface.ts b/src/root-command-surface.ts index 5ef75c47..cfa72429 100644 --- a/src/root-command-surface.ts +++ b/src/root-command-surface.ts @@ -4,6 +4,9 @@ import { PKG_VERSION } from './version.js'; export const ROOT_PROFILE_FLAGS = '--profile '; export const ROOT_PROFILE_DESCRIPTION = 'Chrome profile/context alias for browser runtime commands'; +export const ROOT_SESSION_FLAGS = '--session '; +export const ROOT_SESSION_DESCRIPTION = 'Existing opaque Session ID from `webcmd session create`'; +export const ROOT_SESSION_SELECTOR_POSITION = 'root'; export const COMPLETION_SENTINEL = '--get-completions'; /** @@ -15,6 +18,7 @@ export function configureRootCommandSurface(program: Command): Command { return program .version(PKG_VERSION) .option(ROOT_PROFILE_FLAGS, ROOT_PROFILE_DESCRIPTION) + .option(ROOT_SESSION_FLAGS, ROOT_SESSION_DESCRIPTION) .enablePositionalOptions(); } @@ -22,7 +26,7 @@ export type HostedRootCommandSurface = | { kind: 'help'; exitCode: number } | { kind: 'version'; output: string } | { kind: 'completion'; argv: string[] } - | { kind: 'dispatch'; argv: string[]; profile?: string; literal: boolean }; + | { kind: 'dispatch'; argv: string[]; profile?: string; session?: string; literal: boolean }; /** * Parse only the root command surface without registering or discovering local @@ -85,7 +89,7 @@ export function parseHostedRootCommandSurface(argv: readonly string[]): HostedRo throw new CommanderStructuralError(stderr || `${error.message}\n`, error.exitCode); } - const profile = root.opts<{ profile?: string }>().profile; + const { profile, session } = root.opts<{ profile?: string; session?: string }>(); if (boundary.commandIndex === undefined && boundary.separatorIndex === undefined) return { kind: 'help', exitCode: 1 }; const literal = boundary.separatorIndex !== undefined; const parsedArgv = boundary.commandIndex !== undefined @@ -96,6 +100,7 @@ export function parseHostedRootCommandSurface(argv: readonly string[]): HostedRo kind: 'dispatch', argv: parsedArgv, ...(profile !== undefined ? { profile } : {}), + ...(session !== undefined ? { session } : {}), literal, }; } @@ -109,7 +114,7 @@ interface RootCommandBoundary { function findRootCommandBoundary(argv: readonly string[]): RootCommandBoundary { for (let index = 0; index < argv.length; index += 1) { const token = argv[index]!; - if (token === '--profile' || token === '--workspace') { + if (token === '--profile' || token === '--session' || token === '--workspace') { // Commander requires and consumes the next token even when it is `--` or // starts with a dash. Structural failures have already been reported. // `--workspace` is hosted-only (not a registered Commander option here) @@ -118,7 +123,7 @@ function findRootCommandBoundary(argv: readonly string[]): RootCommandBoundary { index += 1; continue; } - if (token.startsWith('--profile=') || token.startsWith('--workspace=')) continue; + if (token.startsWith('--profile=') || token.startsWith('--session=') || token.startsWith('--workspace=')) continue; if (token === '--') return { separatorIndex: index }; if (!token.startsWith('-') || token === '-') return { commandIndex: index }; } diff --git a/src/runtime.ts b/src/runtime.ts index 8d9b0ca0..a5cbbeab 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -54,14 +54,14 @@ export function withTimeoutMs( /** Interface for browser factory (BrowserBridge or test mocks) */ export interface IBrowserFactory { - connect(opts?: { timeout?: number; session?: string; cdpEndpoint?: string; contextId?: string; preferredContextId?: string; idleTimeout?: number; windowMode?: BrowserWindowMode; surface?: BrowserSurface; siteSession?: 'ephemeral' | 'persistent'; freshPage?: boolean }): Promise; + connect(opts?: { timeout?: number; session?: string; cdpEndpoint?: string; contextId?: string; preferredContextId?: string; idleTimeout?: number; windowMode?: BrowserWindowMode; surface?: BrowserSurface; siteSession?: 'ephemeral' | 'persistent'; freshPage?: boolean; adapterSite?: string }): Promise; close(): Promise; } export async function browserSession( BrowserFactory: new () => IBrowserFactory, fn: (page: IPage) => Promise, - opts: { session?: string; cdpEndpoint?: string; contextId?: string; preferredContextId?: string; idleTimeout?: number; windowMode?: BrowserWindowMode; surface?: BrowserSurface; siteSession?: 'ephemeral' | 'persistent'; freshPage?: boolean } = {}, + opts: { session?: string; cdpEndpoint?: string; contextId?: string; preferredContextId?: string; idleTimeout?: number; windowMode?: BrowserWindowMode; surface?: BrowserSurface; siteSession?: 'ephemeral' | 'persistent'; freshPage?: boolean; adapterSite?: string } = {}, ): Promise { const browser = new BrowserFactory(); try { @@ -76,6 +76,7 @@ export async function browserSession( surface: opts.surface, siteSession: opts.siteSession, freshPage: opts.freshPage, + adapterSite: opts.adapterSite, }); return await fn(page); } finally { diff --git a/src/session-docs-sync.test.ts b/src/session-docs-sync.test.ts new file mode 100644 index 00000000..a0f61c2a --- /dev/null +++ b/src/session-docs-sync.test.ts @@ -0,0 +1,103 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const ROOT = process.cwd(); +const DOC_ROOTS = ['README.md', 'docs', 'skills']; + +describe('Session documentation sync', () => { + it('does not call adapter siteSession modes browser Sessions', () => { + const offenders = walkDocs() + .map((file) => ({ file, text: fs.readFileSync(file, 'utf8') })) + .filter(({ text }) => siteSessionModeAsSessionPattern.test(text)); + + expect(offenders.map(({ file }) => path.relative(ROOT, file))).toEqual([]); + }); + + it('flags retired Session wording and examples', () => { + expect(findRemovedSessionSyntaxes('Use webcmd browser session_a run')).toEqual(['positional browser command']); + expect(findRemovedSessionSyntaxes('Use webcmd browser tabs')).toEqual(['positional browser session']); + expect(findRemovedSessionSyntaxes('Use browser --session for this')).toEqual(['browser-local session selector']); + expect(findRemovedSessionSyntaxes('session_abc...')).toEqual(['truncated session id']); + expect(siteSessionModeAsSessionPattern.test('a persistent session')).toBe(true); + expect(siteSessionModeAsSessionPattern.test('an ephemeral session')).toBe(true); + }); + + it('keeps removed Session syntaxes out of docs and skills', () => { + const offenders = walkDocs() + .flatMap((file) => { + const text = fs.readFileSync(file, 'utf8'); + return findRemovedSessionSyntaxes(text).map((label) => `${path.relative(ROOT, file)}: ${label}`); + }); + + expect(offenders).toEqual([]); + }); + + it('documents structured Session runtime errors', () => { + const troubleshooting = fs.readFileSync(path.join(ROOT, 'docs', 'troubleshooting.mdx'), 'utf8'); + + for (const code of [ + 'SESSION_REQUIRED', + 'INVALID_SESSION_SELECTOR', + 'SESSION_SELECTOR_POSITION', + 'SESSION_NOT_FOUND', + 'INVALID_SESSION_LIMIT', + 'SESSION_BUSY', + 'SESSION_PAUSED_FOR_HUMAN_HANDOFF', + 'SESSION_WINDOW_CONFLICT', + 'SESSION_CAPACITY_EXCEEDED', + 'HOSTED_CONTRACT_MISMATCH', + ]) { + expect(troubleshooting).toContain(code); + } + expect(troubleshooting).toContain('webcmd session close --force'); + }); + + it('teaches Session lifecycle commands in every agent guide', () => { + const agentDir = path.join(ROOT, 'docs', 'agents'); + const guides = fs.readdirSync(agentDir) + .filter((file) => file.endsWith('.md')) + .filter((file) => file !== 'custom-sdk.md') + .map((file) => path.join(agentDir, file)); + + for (const guide of guides) { + const text = fs.readFileSync(guide, 'utf8'); + expect(text, path.relative(ROOT, guide)).toContain('webcmd session create'); + expect(text, path.relative(ROOT, guide)).toContain('webcmd --session '); + expect(text, path.relative(ROOT, guide)).toContain('webcmd session list'); + expect(text, path.relative(ROOT, guide)).toContain('webcmd session close '); + } + }); +}); + +function findRemovedSessionSyntaxes(text: string): string[] { + return [ + [/\bwebcmd browser (pattern as RegExp).test(text)) + .map(([, label]) => label as string); +} + +const siteSessionModeAsSessionPattern = /\b(?:persistent|ephemeral) sessions?\b/i; + +function walkDocs(): string[] { + const files: string[] = []; + for (const root of DOC_ROOTS) { + const absolute = path.join(ROOT, root); + if (!fs.existsSync(absolute)) continue; + const stat = fs.statSync(absolute); + if (stat.isFile()) files.push(absolute); + else collect(absolute, files); + } + return files.filter((file) => !file.includes(`${path.sep}docs${path.sep}superpowers${path.sep}`)); +} + +function collect(dir: string, files: string[]): void { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const absolute = path.join(dir, entry.name); + if (entry.isDirectory()) collect(absolute, files); + else if (/\.(md|mdx)$/u.test(entry.name)) files.push(absolute); + } +} diff --git a/src/session-lease.test.ts b/src/session-lease.test.ts index e97a5486..bc31ca92 100644 --- a/src/session-lease.test.ts +++ b/src/session-lease.test.ts @@ -6,6 +6,7 @@ import { clearDaemonRunContext, generateRunId, getDaemonRunContext, + getSignalDaemonRunContext, getSessionLeaseKey, isSessionLeaseCommand, isUnknownOutcomeError, @@ -26,7 +27,7 @@ describe('logical daemon run context', () => { it('keeps one run id stable while a logical run is bound and generates a different id for the next run', () => { vi.spyOn(Date, 'now').mockReturnValue(1_763_000_000_000); const firstRunId = generateRunId(); - setDaemonRunContext({ runId: firstRunId, command: 'chatgpt ask', access: 'write' }); + setDaemonRunContext({ runId: firstRunId, command: 'chatgpt ask' }); expect(getDaemonRunContext()?.runId).toBe(firstRunId); expect(getDaemonRunContext()?.runId).toBe(firstRunId); @@ -38,14 +39,13 @@ describe('logical daemon run context', () => { }); it('does not let deferred cleanup from an older run clear a newer run context', () => { - setDaemonRunContext({ runId: 'run_111_1_1', command: 'chatgpt ask', access: 'write' }); - setDaemonRunContext({ runId: 'run_222_2_2', command: 'claude ask', access: 'write' }); + setDaemonRunContext({ runId: 'run_111_1_1', command: 'chatgpt ask' }); + setDaemonRunContext({ runId: 'run_222_2_2', command: 'claude ask' }); clearDaemonRunContext('run_111_1_1'); expect(getDaemonRunContext()).toEqual({ runId: 'run_222_2_2', command: 'claude ask', - access: 'write', }); clearDaemonRunContext('run_222_2_2'); @@ -62,12 +62,10 @@ describe('logical daemon run context', () => { const firstContext: DaemonRunContext = { runId: 'run_111_1_1', command: 'first write', - access: 'write', }; const secondContext: DaemonRunContext = { runId: 'run_222_2_2', command: 'second write', - access: 'write', }; const first = runWithDaemonRunContext(firstContext, async () => { @@ -81,6 +79,26 @@ describe('logical daemon run context', () => { expect(await first).toEqual(firstContext); expect(getDaemonRunContext()).toBeUndefined(); }); + + it('exposes a running async context only to out-of-band signal handlers', async () => { + let resume!: () => void; + const gate = new Promise(resolve => { resume = resolve; }); + const context: DaemonRunContext = { + runId: 'run_111_1_1', + command: 'browser run', + }; + + const pending = runWithDaemonRunContext(context, async () => { + await gate; + return getDaemonRunContext(); + }); + + expect(getDaemonRunContext()).toBeUndefined(); + expect(getSignalDaemonRunContext()).toEqual(context); + resume(); + await expect(pending).resolves.toEqual(context); + expect(getSignalDaemonRunContext()).toBeUndefined(); + }); }); describe('isUnknownOutcomeError', () => { @@ -105,41 +123,77 @@ describe('isUnknownOutcomeError', () => { }); describe('session lease partitions', () => { - it('partitions persistent writes by resolved profile, surface, and encoded site', () => { - const workChatgpt = getSessionLeaseKey('work', 'adapter', 'site:chatgpt'); - expect(workChatgpt).toBe('work␟adapter␟site%3Achatgpt'); - expect(workChatgpt).not.toBe(getSessionLeaseKey('personal', 'adapter', 'site:chatgpt')); - expect(workChatgpt).not.toBe(getSessionLeaseKey('work', 'adapter', 'site:claude')); - expect(workChatgpt).not.toBe(getSessionLeaseKey('work', 'browser', 'site:chatgpt')); + it('partitions admission by resolved profile and immutable Session id', () => { + const workSession = getSessionLeaseKey('work', 'session_a'); + expect(workSession).toBe('work␟session_a'); + expect(workSession).not.toBe(getSessionLeaseKey('personal', 'session_a')); + expect(workSession).not.toBe(getSessionLeaseKey('work', 'session_b')); }); - it('does not arbitrate reads, ephemeral sessions, raw browser operations, or incomplete identities', () => { + it('partitions only the adapter-default Session by trusted adapter site', () => { + const github = getSessionLeaseKey('work', 'session_default', 'github'); + const linkedin = getSessionLeaseKey('work', 'session_default', 'linkedin'); + + expect(github).toBe('work␟session_default␟github'); + expect(linkedin).toBe('work␟session_default␟linkedin'); + expect(github).not.toBe(linkedin); + expect(getSessionLeaseKey('work', 'session_a')).toBe('work␟session_a'); + }); + + it('arbitrates any resolved browser-backed command with complete run identity', () => { const eligible = { - surface: 'adapter', - siteSession: 'persistent', - access: 'write', - session: 'site:chatgpt', + action: 'exec', + sessionId: 'session_a', runId: 'run_111_1_1', }; expect(isSessionLeaseCommand(eligible)).toBe(true); - expect(isSessionLeaseCommand({ ...eligible, access: 'read' })).toBe(false); - expect(isSessionLeaseCommand({ ...eligible, siteSession: 'ephemeral' })).toBe(false); - expect(isSessionLeaseCommand({ ...eligible, surface: 'browser' })).toBe(false); - expect(isSessionLeaseCommand({ ...eligible, session: '' })).toBe(false); + expect(isSessionLeaseCommand({ ...eligible, action: 'lease-release' })).toBe(false); + expect(isSessionLeaseCommand({ ...eligible, action: 'run-cancel' })).toBe(false); + expect(isSessionLeaseCommand({ ...eligible, sessionId: '' })).toBe(false); expect(isSessionLeaseCommand({ ...eligible, runId: undefined })).toBe(false); }); }); describe('SessionLeaseRegistry', () => { - const KEY = getSessionLeaseKey('work', 'adapter', 'site:chatgpt'); + const KEY = getSessionLeaseKey('work', 'session_a'); let now = T0; beforeEach(() => { now = T0; }); + it('allows same-run re-entry, rejects overlapping different runs, and allows sibling Sessions', () => { + const leases = registry(); + const keyA = getSessionLeaseKey('profile_work', 'session_a'); + const keyB = getSessionLeaseKey('profile_work', 'session_b'); + + expect(leases.acquire({ key: keyA, runId: 'run_7_one', command: 'browser/run', pid: 7 }, () => true).acquired) + .toBe(true); + expect(leases.acquire({ key: keyA, runId: 'run_7_one', command: 'browser/tabs', pid: 7 }, () => true).acquired) + .toBe(true); + expect(leases.acquire({ key: keyA, runId: 'run_7_two', command: 'github/issues', pid: 7 }, () => true)) + .toMatchObject({ acquired: false }); + expect(leases.acquire({ key: keyB, runId: 'run_7_two', command: 'github/issues', pid: 7 }, () => true).acquired) + .toBe(true); + leases.releaseByRunId('run_7_one'); + expect(leases.acquire({ key: keyA, runId: 'run_7_three', command: 'browser/run' }, () => false).acquired) + .toBe(true); + }); + + it('conflicts Session-wide admission with any site partition while allowing sibling sites', () => { + const leases = registry(); + const session = getSessionLeaseKey('profile_work', 'session_default'); + const github = getSessionLeaseKey('profile_work', 'session_default', 'github'); + const linkedin = getSessionLeaseKey('profile_work', 'session_default', 'linkedin'); + + expect(leases.acquire({ key: github, runId: 'run_1', command: 'github/issues' }, () => true).acquired).toBe(true); + expect(leases.acquire({ key: linkedin, runId: 'run_2', command: 'linkedin/posts' }, () => true).acquired).toBe(true); + expect(leases.acquire({ key: session, runId: 'run_3', command: 'browser/run' }, () => true)) + .toMatchObject({ acquired: false }); + }); + function registry(): SessionLeaseRegistry { - return new SessionLeaseRegistry(() => now); + return new SessionLeaseRegistry(() => now, () => true); } function acquire(registry: SessionLeaseRegistry, runId: string, key = KEY) { @@ -185,6 +239,22 @@ describe('SessionLeaseRegistry', () => { }); }); + it('lets a challenger acquire when the live-looking holder pid is gone', () => { + const leases = new SessionLeaseRegistry(() => now, () => false); + expect(acquire(leases, 'run_111_1_1')).toMatchObject({ acquired: true }); + + now += 1_000; + expect(acquire(leases, 'run_222_2_2')).toEqual({ + acquired: true, + lease: expect.objectContaining({ + runId: 'run_222_2_2', + pid: 222, + acquiredAt: now, + heartbeatAt: now, + }), + }); + }); + it.each([ 0, -1, @@ -269,7 +339,7 @@ describe('SessionLeaseRegistry', () => { it('releases only leases owned by the requested run and reports the count', () => { const leases = registry(); acquire(leases, 'run_111_1_1'); - acquire(leases, 'run_111_1_1', getSessionLeaseKey('work', 'adapter', 'site:claude')); + acquire(leases, 'run_111_1_1', getSessionLeaseKey('work', 'session_b')); expect(leases.releaseByRunId('run_999_9_9')).toBe(0); expect(leases.list(() => false)).toHaveLength(2); diff --git a/src/session-lease.ts b/src/session-lease.ts index d5b79897..d30b936a 100644 --- a/src/session-lease.ts +++ b/src/session-lease.ts @@ -13,15 +13,32 @@ export function generateRunId(): string { export interface DaemonRunContext { runId: string; command: string; - access: 'read' | 'write'; } let activeRun: DaemonRunContext | undefined; +let signalRun: DaemonRunContext | undefined; const daemonRunContextStorage = new AsyncLocalStorage(); /** Run one logical execution with context isolated across its async chain. */ export function runWithDaemonRunContext(context: DaemonRunContext, callback: () => T): T { - return daemonRunContextStorage.run(context, callback); + const previousSignalRun = signalRun; + signalRun = context; + const restore = () => { + if (signalRun?.runId === context.runId) signalRun = previousSignalRun; + }; + try { + return daemonRunContextStorage.run(context, () => { + const result = callback(); + if (result && typeof (result as { finally?: unknown }).finally === 'function') { + return (result as unknown as Promise).finally(restore) as T; + } + restore(); + return result; + }); + } catch (err) { + restore(); + throw err; + } } export function setDaemonRunContext(context: DaemonRunContext): void { @@ -32,6 +49,10 @@ export function getDaemonRunContext(): DaemonRunContext | undefined { return daemonRunContextStorage.getStore() ?? activeRun; } +export function getSignalDaemonRunContext(): DaemonRunContext | undefined { + return signalRun ?? activeRun; +} + /** * Clear only the context still owned by `runId`. Deferred cleanup from an old * command must not clear a newer command's run identity. @@ -94,6 +115,8 @@ export function isUnknownOutcomeError(error: unknown): boolean { export interface SessionLeaseHolder { command: string; pid?: number; + sessionId?: string; + admissionSite?: string; acquiredAt: number; heartbeatAt: number; } @@ -118,11 +141,14 @@ export type AcquireResult = | { acquired: false; holder: SessionLease }; /** - * Lease key for a site session after the daemon has resolved its actual Cloak - * profile. Encoding the session keeps key partitions unambiguous. + * Lease key after the daemon has resolved the immutable browser Session. */ -export function getSessionLeaseKey(profileId: string, surface: string, session: string): string { - return `${profileId}␟${surface}␟${encodeURIComponent(session)}`; +export function getSessionLeaseKey( + profileId: string, + sessionId: string, + admissionSite?: string, +): string { + return `${profileId}␟${sessionId}${admissionSite === undefined ? '' : `␟${admissionSite}`}`; } /** Whether a process id is safe to interpolate into local process guidance. */ @@ -130,6 +156,16 @@ export function isActionablePid(pid: unknown): pid is number { return typeof pid === 'number' && Number.isSafeInteger(pid) && pid > 0; } +export function isPidAlive(pid: unknown): boolean { + if (!isActionablePid(pid)) return false; + try { + process.kill(pid, 0); + return true; + } catch (err) { + return (err as NodeJS.ErrnoException).code === 'EPERM'; + } +} + function pidFromRunId(runId: string): number | undefined { const match = /^run_(\d+)_/.exec(runId); if (!match) return undefined; @@ -138,28 +174,22 @@ function pidFromRunId(runId: string): number | undefined { } export interface SessionLeaseCommand { - surface?: unknown; - siteSession?: unknown; - access?: unknown; - session?: unknown; + action?: unknown; + sessionId?: unknown; runId?: unknown; } -/** Only persistent adapter writes with a complete owner identity need a lease. */ +/** Every resolved browser-backed top-level run with a complete owner identity needs a lease. */ export function isSessionLeaseCommand( command: T, ): command is T & { - surface: 'adapter'; - siteSession: 'persistent'; - access: 'write'; - session: string; + sessionId: string; runId: string; } { - return command.surface === 'adapter' - && command.siteSession === 'persistent' - && command.access === 'write' - && typeof command.session === 'string' - && command.session.length > 0 + return command.action !== 'lease-release' + && command.action !== 'run-cancel' + && typeof command.sessionId === 'string' + && command.sessionId.length > 0 && typeof command.runId === 'string' && command.runId.length > 0; } @@ -167,7 +197,7 @@ export function isSessionLeaseCommand( export class SessionLeaseRegistry { private readonly leases = new Map(); - constructor(private readonly now = Date.now) {} + constructor(private readonly now = Date.now, private readonly pidAlive = isPidAlive) {} acquire( input: AcquireSessionLeaseInput, @@ -175,11 +205,20 @@ export class SessionLeaseRegistry { ): AcquireResult { const now = this.now(); const current = this.leases.get(input.key); - const currentIsLive = current !== undefined - && (now - current.heartbeatAt <= SESSION_LEASE_TTL_MS || hasPendingWork(current.runId)); - - if (current && currentIsLive && current.runId !== input.runId) { - return { acquired: false, holder: { ...current } }; + const conflict = [...this.leases.values()].find((lease) => ( + lease.runId !== input.runId + && (lease.key === input.key || lease.key.startsWith(`${input.key}␟`) || input.key.startsWith(`${lease.key}␟`)) + && ( + hasPendingWork(lease.runId) + || ( + now - lease.heartbeatAt <= SESSION_LEASE_TTL_MS + && (!isActionablePid(lease.pid) || this.pidAlive(lease.pid)) + ) + ) + )); + + if (conflict) { + return { acquired: false, holder: { ...conflict } }; } const pid = input.pid === undefined diff --git a/src/signal-cancel.test.ts b/src/signal-cancel.test.ts new file mode 100644 index 00000000..827e7cdd --- /dev/null +++ b/src/signal-cancel.test.ts @@ -0,0 +1,29 @@ +import { EventEmitter } from 'node:events'; +import { describe, expect, it, vi } from 'vitest'; +import { installDaemonRunSignalCancellation } from './signal-cancel.js'; +import { runWithDaemonRunContext } from './session-lease.js'; + +describe('installDaemonRunSignalCancellation', () => { + it('cancels the active daemon run once before exiting on SIGINT', async () => { + const proc = new EventEmitter() as NodeJS.Process; + proc.once = proc.once.bind(proc) as NodeJS.Process['once']; + proc.off = proc.off.bind(proc) as NodeJS.Process['off']; + const cancelRun = vi.fn().mockResolvedValue(undefined); + const exit = vi.fn(); + let resume!: () => void; + const gate = new Promise(resolve => { resume = resolve; }); + const pending = runWithDaemonRunContext({ runId: 'run_111_1_1', command: 'browser run' }, async () => { + await gate; + }); + + installDaemonRunSignalCancellation({ process: proc, cancelRun, exit }); + proc.emit('SIGINT', 'SIGINT'); + proc.emit('SIGTERM', 'SIGTERM'); + + await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(130)); + expect(cancelRun).toHaveBeenCalledTimes(1); + expect(cancelRun).toHaveBeenCalledWith('run_111_1_1'); + resume(); + await pending; + }); +}); diff --git a/src/signal-cancel.ts b/src/signal-cancel.ts new file mode 100644 index 00000000..267b1711 --- /dev/null +++ b/src/signal-cancel.ts @@ -0,0 +1,37 @@ +import { EXIT_CODES } from './errors.js'; +import { getSignalDaemonRunContext } from './session-lease.js'; +import { cancelDaemonRun } from './browser/daemon-client.js'; + +type SignalName = 'SIGINT' | 'SIGTERM'; + +export function installDaemonRunSignalCancellation({ + process: proc = process, + cancelRun = cancelDaemonRun, + exit = (code: number) => process.exit(code), +}: { + process?: Pick; + cancelRun?: (runId: string) => Promise; + exit?: (code: number) => void; +} = {}): () => void { + let fired = false; + const cleanup = () => { + proc.off('SIGINT', onSignal); + proc.off('SIGTERM', onSignal); + }; + const onSignal = (signal: SignalName) => { + if (fired) return; + fired = true; + cleanup(); + const run = getSignalDaemonRunContext(); + const leave = () => exit(EXIT_CODES.INTERRUPTED); + if (!run) { + leave(); + return; + } + void cancelRun(run.runId).finally(leave); + }; + + proc.once('SIGINT', onSignal); + proc.once('SIGTERM', onSignal); + return cleanup; +} diff --git a/src/skills.test.ts b/src/skills.test.ts index dc9c2314..37499ce8 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -178,19 +178,19 @@ describe('webcmd skills content', () => { path.join(process.cwd(), 'skills', 'webcmd-adapter-author', 'references', 'site-recon.md'), 'utf8', ); - expect(usage).toMatch(/existing adapter command first[\s\S]{0,160}load `webcmd-browser` and run Playwright/i); - expect(browser).toMatch(/`tabs`, `bind --page`, `snapshot`, `run`, and `close`/i); - expect(browser).toContain('webcmd browser work tabs'); - expect(browser).toContain('webcmd browser work bind --page'); - expect(browser).toContain('webcmd browser work run --stdin'); - expect(browser).toContain('webcmd browser work close'); + expect(usage).toMatch(/existing adapter command first[\s\S]{0,220}load `webcmd-browser`[\s\S]{0,120}root `--session `/i); + expect(browser).toMatch(/`tabs`, `bind --page`, `snapshot`, and `run`/i); + expect(browser).toContain('webcmd --session browser tabs'); + expect(browser).toContain('webcmd --session browser bind --page'); + expect(browser).toContain('webcmd --session browser run --stdin'); + expect(browser).toContain('webcmd session close '); expect(browser).toMatch(/read-only/i); expect(browser).toMatch(/explicit(?:ly)? bind/i); expect(browser).toMatch(/fresh JavaScript scope/i); expect(browser).toMatch(/persistent browser state/i); expect(browser).toContain("run --stdin <<'JS'"); expect(browser).toContain("await page.getByRole('link', { name: 'More information' }).click()"); - expect(browser).toContain('webcmd browser work snapshot'); + expect(browser).toContain('webcmd --session browser snapshot'); expect(browser).toContain('--snapshot-mode act'); expect(browser).toContain('--snapshot-mode tree'); expect(browser).toContain('--snapshot-mode read'); @@ -214,11 +214,34 @@ describe('webcmd skills content', () => { expect(browserRunReference).not.toContain('browser.currentPage()'); expect(browserRunReference).not.toContain('--observe'); expect(browserRunReference).not.toContain('--tab'); - expect(siteReconReference).toContain("webcmd browser recon run --stdin <<'JS'"); + expect(siteReconReference).toContain("webcmd --session browser run --stdin <<'JS'"); expect(siteReconReference).toContain('page.waitForResponse('); expect(siteReconReference).not.toMatch(/webcmd browser \S+ (?:open|state|click|type|select|find|extract|network|wait|eval)/i); }); + it('keeps raw browser and handoff work scoped to explicit Sessions', () => { + const usage = bundledSkill('webcmd-usage'); + const browser = bundledSkill('webcmd-browser'); + const autofix = bundledSkill('webcmd-autofix'); + + expect(usage).toContain('webcmd session create -f json'); + expect(usage).toContain('webcmd --session session_abc browser'); + expect(usage).toContain('SESSION_BUSY'); + expect(usage).toContain('SESSION_REQUIRED'); + expect(usage).toMatch(/Adapter commands may omit `--session`[\s\S]{0,200}adapter-default session/i); + expect(usage).toMatch(/retired positional session form is invalid/i); + expect(browser).toMatch(/Profiles are cookie jars[\s\S]{0,180}sessions are browser workspaces\/windows/i); + expect(browser).toMatch(/Parallel agents use separate sessions/i); + expect(browser).toContain('SESSION_BUSY'); + expect(browser).toContain('SESSION_PAUSED_FOR_HUMAN_HANDOFF'); + expect(browser).toContain('webcmd session close --force'); + for (const skill of [usage, browser, autofix]) { + expect(skill).toMatch(/handoff is scoped to (?:its|the) Session/i); + expect(skill).toMatch(/(?:verify_command|handoff\.verifyCommand)[\s\S]{0,200}verbatim[\s\S]{0,120}`--session`/i); + expect(skill).toMatch(/(?:cannot be closed|close is blocked|do not close)[\s\S]{0,100}handoff|handoff[\s\S]{0,100}(?:cannot be closed|close is blocked|do not close)/i); + } + }); + it('keeps browser behavioral policy while pruning removed command instructions', () => { const browser = bundledSkill('webcmd-browser'); @@ -237,7 +260,7 @@ describe('webcmd skills content', () => { for (const required of [ 'Absence from truncated output never proves that no adapter exists', - 'Use the same session name for a multi-step flow', + 'Create an opaque browser session before raw browser work', 'fresh JavaScript scope', 'persistent browser state', 'Never ask for or type passwords', diff --git a/tests/e2e/browser-tabs.test.ts b/tests/e2e/browser-tabs.test.ts index 1bad6e12..a91faee4 100644 --- a/tests/e2e/browser-tabs.test.ts +++ b/tests/e2e/browser-tabs.test.ts @@ -118,29 +118,29 @@ describe('browser public command surface e2e', () => { it('uses tabs, bind, run, and close through the built CLI', async () => { const daemon = await startFakeDaemon(); daemons.push(daemon); - const session = 'four-command-surface'; + const session = 'session_four-command-surface'; const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-browser-tabs-')); tempDirs.push(tempDir); const sourcePath = path.join(tempDir, 'program.js'); fs.writeFileSync(sourcePath, "return 'from file';"); - const tabs = await runCli(['browser', session, 'tabs']); + const tabs = await runCli(['--session', session, 'browser', 'tabs']); expect(tabs.code).toBe(0); expect(parseJsonOutput(tabs.stdout)).toEqual(expect.arrayContaining([ expect.objectContaining({ id: 'page-one', title: 'One' }), expect.objectContaining({ id: 'page-two', title: 'Two' }), ])); - const bound = await runCli(['browser', session, 'bind', '--page', 'page-two']); + const bound = await runCli(['--session', session, 'browser', 'bind', '--page', 'page-two']); expect(bound.code).toBe(0); expect(parseJsonOutput(bound.stdout)).toMatchObject({ bound: true, page: 'page-two', title: 'Two' }); - const run = await runCli(['browser', session, 'run', '--file', sourcePath]); + const run = await runCli(['--session', session, 'browser', 'run', '--file', sourcePath]); expect(run.code).toBe(0); expect(parseJsonOutput(run.stdout)).toEqual({ result: 'ran' }); expect(daemon.lastRunSource()).toBe("return 'from file';"); - const closed = await runCli(['browser', session, 'close']); + const closed = await runCli(['--session', session, 'browser', 'close']); expect(closed.code).toBe(0); expect(parseJsonOutput(closed.stdout)).toEqual({ closed: true }); }, 30_000); diff --git a/tests/e2e/cloak-runtime.test.ts b/tests/e2e/cloak-runtime.test.ts index 949b23ba..a116ff83 100644 --- a/tests/e2e/cloak-runtime.test.ts +++ b/tests/e2e/cloak-runtime.test.ts @@ -8,16 +8,40 @@ import { runCli } from './helpers.js'; let server: http.Server; let baseUrl = ''; const sourceDirs: string[] = []; +let sharedConfigDir = ''; +let sharedProfile = ''; + +function isolatedOptions(options: Parameters[1] = {}): Parameters[1] { + return { + ...options, + env: { + HOME: path.join(sharedConfigDir, 'home'), + USERPROFILE: path.join(sharedConfigDir, 'home'), + WEBCMD_CONFIG_DIR: sharedConfigDir, + WEBCMD_PROFILE: sharedProfile, + ...options.env, + }, + }; +} function browserRun(session: string, source: string, options: Parameters[1] = {}) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-cloak-run-')); sourceDirs.push(dir); const sourcePath = path.join(dir, 'program.js'); fs.writeFileSync(sourcePath, source); - return runCli(['browser', session, 'run', '--file', sourcePath], options); + return runCli(['--session', session, 'browser', 'run', '--file', sourcePath], isolatedOptions(options)); +} + +async function createSession(options: Parameters[1] = {}) { + const result = await runCli(['session', 'create', '-f', 'json'], isolatedOptions(options)); + expect(result.code).toBe(0); + return JSON.parse(result.stdout).id as string; } beforeAll(async () => { + sharedConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-cloak-suite-')); + sharedProfile = `cloak-suite-${Date.now()}`; + sourceDirs.push(sharedConfigDir); server = http.createServer((req, res) => { if (req.url === '/cookie') { res.setHeader('Set-Cookie', 'webcmd_smoke=ok; Path=/'); @@ -58,7 +82,7 @@ afterAll(async () => { describe('Cloak runtime e2e', () => { it('runs Playwright against a page through webcmd browser', async () => { - const session = `cloak-smoke-${Date.now()}`; + const session = await createSession({ timeout: 120_000 }); const result = await browserRun(session, ` await page.goto(${JSON.stringify(baseUrl)}); return await page.evaluate(() => document.title + ':' + window.answer); @@ -68,7 +92,7 @@ describe('Cloak runtime e2e', () => { }, 180_000); it('persists cookies inside the Cloak profile', async () => { - const session = `cloak-cookie-${Date.now()}`; + const session = await createSession({ timeout: 120_000 }); const cookies = await browserRun(session, ` await page.goto(${JSON.stringify(`${baseUrl}/cookie`)}); return await page.evaluate(() => document.cookie); @@ -78,7 +102,6 @@ describe('Cloak runtime e2e', () => { }, 180_000); it('survives sequential open and evaluate cycles in one persistent profile', async () => { - const session = `cloak-sequential-${Date.now()}`; const profile = `task5-${Date.now()}`; const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-cloak-sequential-')); const run = (args: string[]) => runCli(args, { @@ -104,6 +127,10 @@ describe('Cloak runtime e2e', () => { expect((await waitForStoppedDaemon()).stdout).toContain('Daemon: not running'); try { + const session = await createSession({ + timeout: 120_000, + env: { WEBCMD_CONFIG_DIR: configDir, WEBCMD_PROFILE: profile }, + }); expect((await browserRun(session, `await page.goto(${JSON.stringify(`${baseUrl}/cookie`)}); return null;`, { timeout: 120_000, env: { WEBCMD_CONFIG_DIR: configDir, WEBCMD_PROFILE: profile }, diff --git a/tests/e2e/cloak-session-concurrency.test.ts b/tests/e2e/cloak-session-concurrency.test.ts new file mode 100644 index 00000000..809f37f2 --- /dev/null +++ b/tests/e2e/cloak-session-concurrency.test.ts @@ -0,0 +1,160 @@ +import fs from 'node:fs'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { CloakSessionManager } from '../../src/browser/runtime/local-cloak/session-manager.js'; +import { findExactCloakProfileProcesses } from '../../src/browser/runtime/local-cloak/process-matcher.js'; +import { resolveCloakProfileDir } from '../../src/browser/runtime/local-cloak/profiles.js'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +let server: http.Server; +let baseUrl = ''; +const tempDirs: string[] = []; + +beforeAll(async () => { + server = http.createServer((req, res) => { + const url = new URL(req.url ?? '/', 'http://127.0.0.1'); + res.end(`${url.pathname}${url.pathname}`); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('test server did not bind'); + baseUrl = `http://127.0.0.1:${address.port}`; +}, 30_000); + +afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe.skipIf(process.env.WEBCMD_LIVE_CLOAK !== '1')('Cloak Session concurrency gate', () => { + it('keeps Cloak and Playwright pinned to the supported live gate runtime', () => { + const appPkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')); + const cloakPkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'node_modules/cloakbrowser/package.json'), 'utf8')); + const playwrightPkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'node_modules/playwright-core/package.json'), 'utf8')); + const cloakConfig = fs.readFileSync(path.join(ROOT, 'node_modules/cloakbrowser/dist/config.js'), 'utf8'); + + expect(appPkg.dependencies.cloakbrowser).toBe('0.4.5'); + expect(appPkg.dependencies['playwright-core']).toBe('1.61.1'); + expect(cloakPkg.version).toBe('0.4.5'); + expect(playwrightPkg.version).toBe('1.61.1'); + expect(cloakConfig).toContain('"darwin-arm64": "145.0.7632.109.2"'); + }); + + it('covers isolated Profiles, explicit Session windows, noopener pages, close survival, and keeper repair', async () => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-cloak-session-gate-')); + tempDirs.push(configDir); + const manager = new CloakSessionManager({ baseDir: configDir }); + const profileA = `gate-a-${Date.now()}`; + const profileB = `gate-b-${Date.now()}`; + const keyA = { + profileId: profileA, + session: 'session_11111111-1111-4111-8111-111111111111', + sessionId: 'session_11111111-1111-4111-8111-111111111111', + surface: 'browser' as const, + }; + const keyB = { ...keyA, profileId: profileB, session: 'session_22222222-2222-4222-8222-222222222222', sessionId: 'session_22222222-2222-4222-8222-222222222222' }; + const keyA2 = { ...keyA, session: 'session_33333333-3333-4333-8333-333333333333', sessionId: 'session_33333333-3333-4333-8333-333333333333' }; + const windowId = async (page: Awaited>['page']) => { + const cdp = await page.context().newCDPSession(page); + try { + const target = await cdp.send('Target.getTargetInfo') as { targetInfo: { targetId: string } }; + return (await cdp.send('Browser.getWindowForTarget', { targetId: target.targetInfo.targetId }) as { windowId: number }).windowId; + } finally { + await cdp.detach(); + } + }; + try { + const [first, profileBFirst] = await Promise.all([manager.getPage(keyA), manager.getPage(keyB)]); + await Promise.all([ + first.page.goto(`${baseUrl}/first`), + profileBFirst.page.goto(`${baseUrl}/profile-b`), + ]); + + const otherSession = await manager.getPage(keyA2); + await otherSession.page.goto(`${baseUrl}/other-session`); + expect(await windowId(otherSession.page)).not.toBe(await windowId(first.page)); + + await first.page.bringToFront(); + expect(await first.page.evaluate(() => document.hasFocus())).toBe(true); + + const second = await manager.newPage({ ...keyA, windowMode: 'background' }); + await second.page.goto(`${baseUrl}/second`); + + expect(await windowId(second.page)).toEqual(expect.any(Number)); + expect(await second.page.evaluate(() => window.opener === null)).toBe(true); + expect(await second.page.evaluate(() => document.referrer)).toBe(''); + expect(await first.page.evaluate(() => document.hasFocus())).toBe(true); + expect((await manager.listPages(keyA)).map((tab) => tab.url)).toEqual([ + `${baseUrl}/first`, + `${baseUrl}/second`, + ]); + + await manager.closeSession(profileA, keyA.sessionId); + await profileBFirst.page.goto(`${baseUrl}/profile-b-after-a-close`); + expect(await profileBFirst.page.title()).toBe('/profile-b-after-a-close'); + + await manager.closeSession(profileB, keyB.sessionId); + const afterFinalClose = await manager.getPage(keyB); + await afterFinalClose.page.goto(`${baseUrl}/keeper-survived`); + expect(await afterFinalClose.page.title()).toBe('/keeper-survived'); + } finally { + await manager.shutdown(); + } + }, 180_000); + + it('falls back to a Session-owned page when window.open is blocked', async () => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-cloak-fallback-gate-')); + tempDirs.push(configDir); + const manager = new CloakSessionManager({ baseDir: configDir }); + const key = { + profileId: `gate-fallback-${Date.now()}`, + session: 'session_44444444-4444-4444-8444-444444444444', + sessionId: 'session_44444444-4444-4444-8444-444444444444', + surface: 'browser' as const, + }; + try { + const first = await manager.getPage(key); + await first.page.goto(`${baseUrl}/first`); + await first.page.evaluate(() => { + (window as unknown as { open: () => null }).open = () => null; + }); + + const fallback = await manager.newPage({ ...key, windowMode: 'background' }); + await fallback.page.goto(`${baseUrl}/fallback`); + + expect(await fallback.page.evaluate(() => window.opener === null)).toBe(true); + expect((await manager.listPages(key)).map((tab) => tab.url)).toEqual([ + `${baseUrl}/first`, + `${baseUrl}/fallback`, + ]); + } finally { + await manager.shutdown(); + } + }, 180_000); + + it('distinguishes work and work-2 Cloak processes from real ps output', async () => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-cloak-process-gate-')); + tempDirs.push(configDir); + const manager = new CloakSessionManager({ baseDir: configDir }); + const work = { profileId: 'work', session: 'session_55555555-5555-4555-8555-555555555555', sessionId: 'session_55555555-5555-4555-8555-555555555555', surface: 'browser' as const }; + const work2 = { profileId: 'work-2', session: 'session_66666666-6666-4666-8666-666666666666', sessionId: 'session_66666666-6666-4666-8666-666666666666', surface: 'browser' as const }; + try { + const [workPage, work2Page] = await Promise.all([manager.getPage(work), manager.getPage(work2)]); + await Promise.all([ + workPage.page.goto(`${baseUrl}/work`), + work2Page.page.goto(`${baseUrl}/work-2`), + ]); + + const workProcesses = await findExactCloakProfileProcesses(resolveCloakProfileDir('work', { baseDir: configDir })); + const work2Processes = await findExactCloakProfileProcesses(resolveCloakProfileDir('work-2', { baseDir: configDir })); + expect(workProcesses.length).toBeGreaterThan(0); + expect(work2Processes.length).toBeGreaterThan(0); + expect(workProcesses.every(pid => !work2Processes.includes(pid))).toBe(true); + } finally { + await manager.shutdown(); + } + }, 180_000); +}); diff --git a/vitest.config.ts b/vitest.config.ts index d264b1e0..5953a512 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -22,7 +22,18 @@ export default defineConfig({ { test: { name: 'unit', - include: ['src/*.test.ts', 'src/!(browser)/**/*.test.ts', 'src/browser/verify-fixture.test.ts'], + include: [ + 'src/*.test.ts', + 'src/!(browser)/**/*.test.ts', + 'src/browser/verify-fixture.test.ts', + 'src/browser/sessions.test.ts', + 'src/browser/daemon-client.test.ts', + 'src/browser/run/runner.test.ts', + 'src/browser/runtime/local-cloak/provider.test.ts', + 'src/browser/runtime/local-cloak/process-matcher.test.ts', + 'src/browser/runtime/local-cloak/session-manager.test.ts', + 'src/browser/runtime/local-cloak/darwin-background-launch.test.ts', + ], sequence: { groupOrder: 0 }, }, }, @@ -54,6 +65,7 @@ export default defineConfig({ 'tests/e2e/plugin-management.test.ts', 'tests/e2e/article-download-pipeline.test.ts', 'tests/e2e/cloak-runtime.test.ts', + 'tests/e2e/cloak-session-concurrency.test.ts', 'tests/e2e/browser-run.test.ts', // Extended browser tests (20+ sites) — opt-in only: // WEBCMD_E2E=1 npx vitest run diff --git a/webcmd-plugin.json b/webcmd-plugin.json index 33ed848c..3170090d 100644 --- a/webcmd-plugin.json +++ b/webcmd-plugin.json @@ -638,7 +638,7 @@ "path": "plugins/linkedin", "version": "0.1.0", "description": "LinkedIn profile, network, messaging, job, and Sales Navigator commands for WebCMD", - "webcmd": ">=0.6.0", + "webcmd": ">=0.6.2", "author": { "name": "WebCMD Agent", "handle": "agentrhq"