From 597914ee06cc5686f39be18db84549c0cfa65c38 Mon Sep 17 00:00:00 2001 From: Bolin Chen Date: Fri, 23 Oct 2026 16:41:04 +0000 Subject: [PATCH] feat(browser): import cookies into the agent's browser from the dashboard Adds Import cookies and Clear cookies to the Browser panel. The gateway parses a Playwright storageState, a browser extension JSON export or a Netscape cookies.txt, stores it in a directory masked from the agent in every sandbox mode, and hands the cookies to the agent's own browser over its control socket, so a remote gateway's browser can start signed in. The dialog says in plain words where the values are saved (only on the computer running the product), and the two late status read tests wait on the read completing instead of a fixed delay. Supersedes #12377. --- ...-browser-cookies-have-no-audience-scope.md | 21 + docs/decisions/README.md | 1 + docs/feature-map/README.md | 2 +- docs/system-specs/modules/browser.md | 96 +- src/kiro_crew/browser_cli/cookies.py | 1116 +++++++++++ src/kiro_crew/browser_cli/launch.py | 103 +- src/kiro_crew/dashboard/handlers/__init__.py | 3 + src/kiro_crew/dashboard/handlers/messaging.py | 16 + .../dashboard/messaging_api/browser_routes.py | 207 +- src/kiro_crew/dashboard/server.py | 3 + .../server_runtime/api_entrypoint.py | 4 + .../server_runtime/browser_lifecycle.py | 44 +- .../dashboard/server_runtime/mcp_routes.py | 8 + src/kiro_crew/docs/browser-control.md | 22 + src/kiro_crew/sandbox_leaves.py | 13 +- src/kiro_crew/sandbox_precreate.py | 5 + src/kiro_crew/security/paths_lists.py | 1 + test/test_browser_cli_journeys.py | 35 +- test/test_browser_cli_launch.py | 26 + test/test_browser_cookies.py | 1704 +++++++++++++++++ ...board_server_composition_contract_reach.py | 13 +- test/test_dashboard_server_coverage.py | 42 + ...handlers_messaging_composition_contract.py | 11 +- test/test_json_object_body_guard.py | 11 + test/test_sandbox_governance_mask.py | 3 + test/test_sandbox_protected_name_holds.py | 10 +- test/test_security_gate_liveness.py | 6 +- website/capture/cookie-import.html | 11 + website/capture/cookie-import.tsx | 128 ++ website/src/api/client.ts | 4 + .../src/api/client/browserAndComputerUse.ts | 72 +- website/src/components/WebPreviewCookies.tsx | 537 ++++++ website/src/components/WebPreviewPanel.tsx | 40 + website/src/hooks/useBrowserCookies.ts | 209 ++ website/src/i18n/locales/bn.json | 32 +- website/src/i18n/locales/de.json | 32 +- website/src/i18n/locales/en-XA.json | 30 + website/src/i18n/locales/en.json | 32 +- website/src/i18n/locales/es.json | 32 +- website/src/i18n/locales/fr.json | 32 +- website/src/i18n/locales/hi.json | 32 +- website/src/i18n/locales/it.json | 32 +- website/src/i18n/locales/ja.json | 32 +- website/src/i18n/locales/ko.json | 32 +- website/src/i18n/locales/pt.json | 32 +- website/src/i18n/locales/ru.json | 32 +- website/src/i18n/locales/zh-CN.json | 32 +- website/src/lib/cookieExportPreview.ts | 84 + .../test/ApiClient.refactor.surface.test.ts | 3 +- .../src/test/WebPreviewPanel.cookies.test.tsx | 713 +++++++ website/src/test/cookieExportPreview.test.ts | 59 + website/src/test/useBrowserCookies.test.tsx | 424 ++++ 52 files changed, 6143 insertions(+), 81 deletions(-) create mode 100644 docs/decisions/2026-10-23-imported-browser-cookies-have-no-audience-scope.md create mode 100644 src/kiro_crew/browser_cli/cookies.py create mode 100644 test/test_browser_cookies.py create mode 100644 website/capture/cookie-import.html create mode 100644 website/capture/cookie-import.tsx create mode 100644 website/src/components/WebPreviewCookies.tsx create mode 100644 website/src/hooks/useBrowserCookies.ts create mode 100644 website/src/lib/cookieExportPreview.ts create mode 100644 website/src/test/WebPreviewPanel.cookies.test.tsx create mode 100644 website/src/test/cookieExportPreview.test.ts create mode 100644 website/src/test/useBrowserCookies.test.tsx diff --git a/docs/decisions/2026-10-23-imported-browser-cookies-have-no-audience-scope.md b/docs/decisions/2026-10-23-imported-browser-cookies-have-no-audience-scope.md new file mode 100644 index 00000000000..a71fa729f7f --- /dev/null +++ b/docs/decisions/2026-10-23-imported-browser-cookies-have-no-audience-scope.md @@ -0,0 +1,21 @@ +# Imported browser cookies reach every agent browser, with no audience scope + +Decided by: Bolin Chen (maintainer, @bolichen97) +Date: 2026-10-23 + +## Decision + +Cookies the owner imports from the dashboard are delivered to every agent browser on the gateway, including browsers that serve non-owner members and restricted sessions; this exposure is accepted. + +## Why + +- The gateway finds agent browsers by their control socket, and a browser is not tied to one audience: agent processes are pooled across sessions, so one browser can serve the owner and a member in turn. +- Scoping delivery to owner sessions needs a session to process to browser mapping that the launch path does not have. That is a separate change with its own design. +- Non-owner members (Slack/Discord allow-list) and restricted (incognito/temporary) sessions can therefore browse as the owner on the imported sites. Clear cookies stops further use; it does not undo a page already read. +- The acceptance covers this exposure only. It does not clear any other review finding. + +## Evidence + +- https://github.com/kirodotdev/KiroCrew/pull/23332 -- the pull request that adds cookie import and this entry. +- https://github.com/kirodotdev/KiroCrew/pull/23332#issuecomment-6377799382 -- the maintainer restates the ruling on the pull request. +- `docs/system-specs/modules/browser.md` -- residual (iii) in the imported cookies section. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index a3133ccc217..4b849b91a15 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -111,3 +111,4 @@ Links to the pull requests, issues and commits that carry the decision. | [2026-10-22-heartbeat-is-a-schedules-tab-row-not-a-goals-check-in.md](2026-10-22-heartbeat-is-a-schedules-tab-row-not-a-goals-check-in.md) | The crewmate's Heartbeat switch is the first row of the Profile card's Schedules tab, not a check-in on the Goals tab. | | [2026-10-22-a-new-goal-starts-in-the-crewmate-chat.md](2026-10-22-a-new-goal-starts-in-the-crewmate-chat.md) | A new goal starts from the Goals tab's Create a goal row as a conversation in the crewmate's chat, with no text editor for it on the tab; the "Add something to track" row is gone. | | [2026-10-23-scrollbars-are-never-always-visible.md](2026-10-23-scrollbars-are-never-always-visible.md) | No scrollbar in the dashboard is visible at rest: a scroll box either hides it entirely or shows a thin thumb only while it is being scrolled, in a column it already reserves so the reveal moves no content. | +| [2026-10-23-imported-browser-cookies-have-no-audience-scope.md](2026-10-23-imported-browser-cookies-have-no-audience-scope.md) | Cookies the owner imports reach every agent browser on the gateway, including browsers that serve non-owner members and restricted sessions; that exposure is accepted. | diff --git a/docs/feature-map/README.md b/docs/feature-map/README.md index 1c6dbc34c90..8440e0db96d 100644 --- a/docs/feature-map/README.md +++ b/docs/feature-map/README.md @@ -150,7 +150,7 @@ this area is reached from inside it unless stated otherwise. | Slide preview for decks | Shows a `.pptx` / `.ppt` opened in the side panel as rendered slides (pager + thumbstrip) when LibreOffice is on the gateway host; without it, a row naming the install command (with an agent hand-off) over the `.pptx` text outline, or over the download card for `.ppt`, which has no text to extract; a deck whose visible text the credential screen would redact is not rendered and shows the redacted outline under a row saying so; on Windows (no descriptor-relative file operations to pin the render's staging) slides are not rendered and the outline is shown with the reason | Side panel → Files tab → click a deck; any deck chip in chat | `components/FileRenderers.tsx` (`OfficeSlidesRenderer`) | `handlers/office_slides.py` | `GET /api/file-office-slides`, `GET /api/file-office-slide`, `GET /api/file-office-preview` | | Terminal panel | Two shells on the gateway host: an app-wide docked PTY, and a per-chat terminal whose tab lives in that chat's panel state (opens on the chat's working dir; switches with the session). A shell that exits on its own closes its tab in the window attached to it, and the docked panel with its last tab (another window still holding that tab is not told, and its next dial opens a fresh shell); a known non-zero exit leaves a Terminal note in the bell feed, Windows included. Both tab strips, the docked terminal's and the chat right panel's, carry a close menu — **Close**, **Close other tabs**, **Close tabs to the right** and **Close all tabs**, which the terminal strip names **Close other terminals**, **Close terminals to the right** and **Close all terminals** and the workspace strip follows with a note that the Changes, Artifacts and Files views stay open; closing a terminal tab stops its shell, a batch that stops more than one shell asks first, counting and naming the shells, and one that would discard unsaved file edits names the files before closing. With `dashboard.terminal.reuse_current` on, Run in terminal always COPIES the command to the clipboard for a manual paste and never runs it — so the confirm dialog, which promises a copy, cannot mislead. When a settled terminal tab exists it is also revealed (the docked panel opens and that tab is made active, and a popped-out terminal window is raised) so the shell to paste into is in front; when none is (no tab, or a restored tab still unconfirmed during hydration) the raw command is copied verbatim with no tab minted and nothing executed | Header terminal toggle (docked); chat right panel → **+** menu → **Terminal**, or Files tab header → **Project actions** → **Open terminal in the project directory** — both go through one `openProjectTerminal` callback, so they cannot disagree about the cwd (per-chat); workspace chat composer (main chat or shared pane) → `! ` → **Review terminal command** → review → **Run** (fresh docked terminal); right-click a tab, or on touch hold it and lift without moving, for the close menu | `components/BottomTerminalPanel.tsx`, `components/TabCloseMenu.tsx`, `pages/chat/SidePanel.tsx`, `pages/chat/SidePanelTabChip.tsx` (the right panel's tab chips, their close menu and a terminal tab's live title), `pages/chat/SidePanelTabBodies.tsx` (the per-chat terminal's tab body), `pages/chat/FilesHomePanel.tsx`, `pages/ChatPage.tsx` (run-in-terminal reuse), `components/ChatPane.tsx`, `components/ChatInput.tsx`, `components/RunInTerminalConfirm.tsx`, `hooks/useTerminalCommand.ts`, `hooks/useBottomTerminal.ts` (`reuseCurrentTab`), `utils/terminalExitClose.ts` | `handlers/terminal.py` | `POST /api/terminal/sessions`, `GET /api/ws/terminal/{session_id}` | | External terminal app | Desktop app only: opens the user's own terminal emulator (one of a fixed allowlist: on Linux Alacritty, Ghostty, GNOME Terminal, kitty, Konsole, WezTerm and Xfce Terminal; on macOS Alacritty, kitty and WezTerm; none on Windows) in the session's project directory, as a separate action beside the integrated terminal, which is unchanged. Off until an app is picked (`dashboard.terminal.external_app`, unset by default). The gateway is the gate: it approves only the dashboard owner, on a direct-local request, with the integrated terminal enabled and an app picked, and answers a single-use grant; the shell's main process redeems the grant over the machine-only internal-secret route and starts the app's own executable with a pinned argv and the realpath'd directory as its working directory, never through an OS opener. The row names the picked app ("Open in kitty (separate window)") and is hidden in a browser tab, for a remote gateway, and while the picked app is not installed; a refused or failed launch falls back to the integrated terminal with a note naming the app and why (turned off in Settings or no longer installed, not launchable from this window, did not start); a failed check of the setting or of the installed apps is reported rather than read as "none" | Files tab header → **Project actions** → **Open in (separate window)** (shown once an app is picked); Settings → Display → Terminal → **External terminal app** | `pages/chat/FilesHomePanel.tsx`, `pages/chat/SidePanel.tsx` (the fallback note, which outlives the Files tab going out of view), `pages/settings/DisplayPanel.tsx`, `lib/externalTerminal.ts`, `electron/external-terminal.js` (allowlist and launch), `electron/ipc-registrar.js` (`external-terminal:*`), `electron/preload.js` (`externalTerminalAPI`) | `handlers/external_terminal.py` | `POST /api/terminal/external`, `POST /api/terminal/external-grant` (machine-only) | -| Browser panel | Live in-panel browser the agent drives; the non-native address bar opens a real website in it; the framed CLI view is served through the dashboard-origin relay (`/browser-view/`), so it works remotely over the same forwarded port as the dashboard; **Annotate** (desktop) picks page elements, takes a note per element in the panel, and adds them to chat with the agent's `eN` refs (`browser:annotate` IPC, `electron/browser-annotate.js`) | Right panel → **Browser** | `components/WebPreviewPanel.tsx` | `handlers/messaging.py`, `handlers/browser_view_relay.py` | `GET /api/browser/view`, `POST /api/browser/view/start`, `POST /api/browser/open`, `GET /browser-view/{tail}`, `POST /api/browser/command`, `POST /api/browser/command-result` | +| Browser panel | Live in-panel browser the agent drives; the non-native address bar opens a real website in it; the framed CLI view is served through the dashboard-origin relay (`/browser-view/`), so it works remotely over the same forwarded port as the dashboard; **Annotate** (desktop) picks page elements, takes a note per element in the panel, and adds them to chat with the agent's `eN` refs (`browser:annotate` IPC, `electron/browser-annotate.js`); **Import cookies** loads a cookie export (Playwright `storageState`, Cookie-Editor JSON, or Netscape `cookies.txt`) from the browser the owner is logged into, so the agent's browser sessions on a remote gateway start signed in — a read-only chip shows the count, domains and earliest expiry (never a value), and **Clear** removes them; owner-only, hidden in a restricted session | Right panel → **Browser**; toolbar **More actions** → **Import cookies** / **Clear cookies** | `components/WebPreviewPanel.tsx`, `components/WebPreviewCookies.tsx`, `hooks/useBrowserCookies.ts` | `handlers/messaging.py`, `handlers/browser_view_relay.py`, `messaging_api/browser_routes.py`, `browser_cli/cookies.py` | `GET /api/browser/view`, `POST /api/browser/view/start`, `POST /api/browser/open`, `GET /browser-view/{tail}`, `POST /api/browser/command`, `POST /api/browser/command-result`, `GET,POST,DELETE /api/browser/cookies` | | App-contributed panel tabs | Side-panel tabs an installed app declares in `contributes.panelTabs`; each mounts the app's own ESM bundle as the tab body through the in-process app host. Only enabled apps contribute, and no app means no rows | Chat right panel → **+** menu → the app's row; on an empty panel, its launcher card. Also under `/members` — the Crew Members side panel is the same `SidePanel`, so an app's row appears in its **+** menu once the member's thread is confirmed | `pages/chat/SidePanel.tsx` (the **+** menu rows and launcher cards), `pages/chat/SidePanelTabBodies.tsx` (the tab body and the failed app-list notice), `pages/chat/SidePanelTabChip.tsx` (the strip glyph), `hooks/panelTabRegistry.ts`, `components/AppHost.tsx` | `src/kiro_crew/apps/routes_listing.py`, `src/kiro_crew/apps/routes_static.py` | `GET /api/apps`, `GET /apps/{name}/ui/{path}` | | Route-history Back / Forward | Browser-style Back and Forward over the dashboard's own route history (desktop layout; on desktop a session switch pushes an entry, so it retraces sessions as well as pages). Each arrow disables when there is nowhere to go (the Forward watermark persists per tab in `sessionStorage` across a reload; it is discarded on a fresh navigation, and a cross-document Back/Forward return (or a BFCache restore) collapses the watermark to the current entry — persisted, so a reload cannot resurrect it — until an in-app navigation re-establishes the frontier — so Forward never leaves the dashboard) and every step asks the page's draft guards before an unarmed pop can unmount typed work (the channel holds a SET of guards, not one slot, so a page carrying two drafts -- the Mates page's New mate dialog and its side panel's Schedules create form -- has both protected; asking stops at the first refusal). Right-click or long-press an arrow for a menu of up to 10 earlier (Back) or later (Forward) destinations, named by session or page and kept per tab in `sessionStorage`; picking one jumps straight there through the same draft ask | Topbar left cluster → **Back** / **Forward** arrows (right-click or long-press for the history menu); `⌘←` / `⌘→` on macOS, `Ctrl+←` / `Ctrl+→` elsewhere (registry ids `history-back` / `history-forward`; left to text fields and terminals, claimed as a no-op on narrow viewports where the arrows are hidden); in the macOS desktop app, a mouse's Back / Forward buttons sent as swipes (Logitech's Options software and similar utilities) and the three-finger page swipe, which the shell relays as one step (to the page's own history when the cursor is over a native browser panel; inert on narrow viewports too) | `components/NavHistoryArrows.tsx`, `lib/routeHistoryPosition.ts`, `lib/routeHistoryLabels.ts`, `components/NavigationLeaveGuard.tsx` (`useGuardedHistoryStep`), `hooks/useKeyboardShortcuts.ts`, `lib/shortcutRegistry.ts`, `shell/platform/nativeHistoryStep.ts`, `electron/swipe-history.js` | client-only (no handler) | none | | Notifications | Bell feed of agent-pushed notifications; a note an agent publishes links back to the session that sent it (the detail panel's **Go to chat**, or **Resume chat** once that tab is closed); a live `notification` frame also shows a Notification Center-style banner under the top bar that tucks into the bell (approvals keep their own row) (see `docs/system-specs/modules/app-notifications.md` § In-app banner) | Topbar bell → `/notifications` | `pages/NotificationsPage.tsx`, `components/notifications/NotificationBanner.tsx`, `components/notifications/NotificationDetailPanel.tsx` | `handlers/messaging.py`, `handlers/notifications_push.py` | `GET /api/notifications`, `POST /api/notifications/ack`, `POST /api/notifications/push`, `GET /api/notifications/push/vapid-public-key`, `POST /api/notifications/push/subscribe`, `POST /api/notifications/push/unsubscribe` | diff --git a/docs/system-specs/modules/browser.md b/docs/system-specs/modules/browser.md index 1b078e11050..f1bed44cc0c 100644 --- a/docs/system-specs/modules/browser.md +++ b/docs/system-specs/modules/browser.md @@ -613,7 +613,12 @@ config error. **The generated config names the engine and nothing else.** Every added key becomes a default an operator must discover in order to override, and the engine -is the only one the install flow already decided. +is the only one the install flow already decided. In particular it never names +the imported-cookies file: that file is bind-masked out of every agent sandbox, +and the daemon an AGENT's command starts runs inside that sandbox, so a +`contextOptions.storageState` there would fail every browse with ENOENT. There is +no second, gateway-side config either; the cookies reach a daemon over its control +socket as data (see [Imported cookies](#imported-cookies)). **The browser sandbox is deliberately untouched.** Chromium's sandbox is a security boundary, so no generated default removes it. A host that cannot run it — @@ -625,6 +630,94 @@ nothing and the operator's file wins entirely. Naming a config is how an operato selects a different engine, pins an `executablePath`, or accepts the sandbox trade-off on a host that requires it. +### Imported cookies + +A remote gateway runs on a different machine from the browser the operator is +logged into, so the agent's own `playwright-cli` sessions start with no session +cookies. `browser_cli/cookies.py` closes that: the owner exports the cookies +where they are logged in and imports them, and the gateway normalises and stores +them as a Playwright `storageState` in a `storage-state.json` file inside the +data home's `browser-cookie-import` directory. This replaces the hand-written +`PLAYWRIGHT_MCP_CONFIG` that was previously the only path (issues #6546, #12319, +#11382). + +**The agent can never read the values, yet its sessions carry them.** Three +invariants, and the pieces that hold them: + +1. The agent's browser daemon **always runs inside the agent's sandbox**, started + by the agent's own command exactly as before this feature. The gateway never + starts a daemon for an agent session: a gateway-privileged daemon driven by + the agent (`upload `, `state-save`) would be a sandbox escape. +2. **No config the agent or its daemon reads names the hidden file.** The launch + config (`launch.desired_config()`) stays engine-only and there is no second, + gateway-side config. +3. **Cookies reach a daemon only through its control socket, as data.** + +| Piece | What it does | +|---|---| +| `sandbox._CREW_HIDDEN_LEAVES` + `security._CREW_SECRET_LEAVES` | `browser-cookie-import/` is pre-created before every spawn and bind-masked in every sandbox mode and fenced at the tool gate, so neither a spawned shell's `open()` nor a file tool reaches the cookie values. Pinned by `test_sandbox_governance_mask.py` and `test_browser_cookies.py`. | +| `cookies._cookie_set_args` / `_inject_into_session` | The gateway connects to the session's control socket (`pw/<8hex>/s/cli/*.sock`) itself and sends one `cookie-set` request per stored cookie in the daemon's own wire format: newline-delimited JSON, `{"id", "method": "run", "params": {"args", "cwd"}}`, where `args` is the object the CLI client builds from its argv (`{"_": ["cookie-set", name, value], "domain", "path", "sameSite", "expires"?, "httpOnly"?, "secure"?}`, verified against the bundled CLI's `parseCliCommand`). The daemon answers `{"id", "result": {"isError", "text"}}` or `{"id", "error"}` and does `context.addCookies`; it opens no file. No process is spawned, so a cookie value is never on any argv. One connection per session carries every cookie, bounded by `_CLI_TIMEOUT_S` for the whole exchange, and it stops at the first failure. | +| `cookies.inject_into_live_sessions()` | The import handler's injection point: every live `kc-*` session at once, enumerated by SHAPE (`/<8hex>/s/cli/*.sock`, where the roots are the data home's `pw` and, when the gateway's `PWTEST_SOCKETS_DIR` names an absolute foreign root, that operator root, resolved exactly as the agent launch resolves it). Every socket is its own target: a session holding several named browsers (`-s=`) reports each as `kc-<8hex>/`, so an older browser is neither skipped nor hidden. Every cookie is sent even when the browser refuses one (a refusal is counted, not a stop), so one rejected cookie cannot keep the rest out of the session. Returns `{loaded, failed: {name: reason}}` plus a one-line `note` whenever nothing was reached. Reasons are **value-free by construction** (`_exchange` returns a fixed string, a refusal count or an exception class name; a reply nested too deep to parse is a `malformed reply`, never a raise): the daemon's reply to `cookie-set` echoes the cookie, so reply text never reaches a result or a log line. | +| `cookies.SessionWatcher` | The second injection point, for sessions that appear AFTER an import. One per gateway, a daemon thread entirely off the event loop, started by `_register_browser_cookie_watcher`'s startup hook, which both the dashboard and the headless API server register (or by the import handler), only once a state file exists and only where the platform has `AF_UNIX`, and stopped on cleanup. Every `WATCH_INTERVAL_S` (2 s) it lists the control sockets and, for any it has not injected into yet (key: path + inode + ctime, so a restarted daemon's new socket counts as new), injects the current cookies and records the key. It only does work while the state file exists; a changed mtime/size (a re-import) drops the record so every live session is injected again, and `reset()` (the clear) drops it too. Bounded: `WATCH_MAX_SESSIONS_PER_TICK` (4) sessions per tick, `WATCH_MAX_ATTEMPTS` (3) tries per socket, the per-call timeout above; never raises out of a tick; debug-level logs only. | +| `cookies.clear_live_sessions()` | The DELETE companion: one `cookie-clear` request over the control socket of each session the import reaches (the same `_live_sockets()` enumeration, no process spawned) so a clear does not leave an open browser signed in, reported as `{cleared, failed}`, and the watcher's record reset so a later import reaches every session. | +| `messaging._get_cookie_lock()` | One `asyncio.Lock` held across the WHOLE import transaction (save + inject) and the whole clear transaction (delete + `cookie-clear` + reset), so an overlapping POST and DELETE cannot interleave at the `to_thread` boundary and leave a cleared state active in the live sessions. Pinned by `test_import_and_clear_are_serialised_by_one_lock`. | + +Nothing cookie-related runs at agent spawn: `acp/runtime.py` and `acp/client.py` +carry no cookie code and no filesystem I/O for it. + +Residuals, stated plainly: (i) the very first navigation of a brand-new session +can run before the watcher's next tick reaches it (at most `WATCH_INTERVAL_S` +late; the next navigation has the cookies); (ii) the control socket is the +agent's own, so anything that can connect to it (the agent's sandbox, same-UID +processes) can already drive that browser, `cookie-list` included: the imported +values are as readable there as the cookies of any site the agent logs into +itself. No other local account sees them, because no argv carries them; +(iii) **accepted exposure, no audience scope:** delivery reaches every agent +browser the gateway can enumerate, and a browser is not tied to one audience +(processes are pooled across sessions, `launch.browser_session_env`), so +browsers that serve allow-listed non-owner members (Slack/Discord) and +restricted (incognito/temporary) sessions receive the imported cookies too and +can browse as the owner on those sites. Clear cookies stops further use; it +does not undo a page already read. A per-audience scope needs a session to +process to browser mapping the launch path does not have. A maintainer +accepted this exposure: +[`2026-10-23-imported-browser-cookies-have-no-audience-scope.md`](../../decisions/2026-10-23-imported-browser-cookies-have-no-audience-scope.md). + +`parse_cookie_import(text)` accepts three shapes and normalises each to the +Playwright cookie shape (`name`, `value`, `domain`, `path`, `expires` — a float +or `-1` for a session cookie — `httpOnly`, `secure`, `sameSite` in `{Strict, Lax, +None}`, defaulting to `Lax`): + +| Import shape | Source | +|---|---| +| `{"cookies": [...], "origins": [...]}` | a Playwright `storageState` (a `state-save` round-trip) | +| a bare JSON array of cookie objects | Cookie-Editor / EditThisCookie / "Get cookies.txt" (`expirationDate`, wider `sameSite` vocabulary) | +| Netscape `cookies.txt` | tab-separated, `#` comments, the `#HttpOnly_` domain prefix | + +`secure` and `httpOnly` accept a real boolean, an absent value (`false`), or the +strings `"true"`/`"false"`/`"1"`/`"0"` case-insensitively (Cookie-Editor sometimes +exports them as strings); anything else is a `CookieImportError`, because +`bool("false")` is `True` and silently flipping an httpOnly cookie is exactly the +quiet corruption an import must refuse. Already-expired cookies are dropped; an +input over 2 MiB, over 5000 cookies, or a cookie missing its `name`/`domain` is +rejected with a `CookieImportError` carrying a user-readable message. +`save_storage_state` writes the file atomically and owner-only (`0o600` on POSIX, +an owner-only DACL on Windows via `atomic_write(restrict_to_owner=True)`, since a +raw `mode` is a no-op there). `storage_state_summary` reports the cookie count, +the distinct domains (leading dot stripped, sorted), the earliest positive expiry, +and the file mtime as `imported_at` — and **never a cookie value**; nothing here +returns or logs one. + +The API contract (all three owner-only, refused in a restricted +incognito/temporary session, and SEL-audited on import and clear with the cookie +count and domains only): + +| Route | Response | +|---|---| +| `GET /api/browser/cookies` | `{present, summary\|null}`; 403 non-owner or restricted session (`code: restricted_session`, the same body the mutations return) | +| `POST /api/browser/cookies` `{content}` | `{ok, summary, hot_load: {loaded, failed, deliverable, note?}}` — `hot_load` is the live socket injection's outcome, and `deliverable` is false where new agent sessions cannot be reached (an operator-fixed session name, a CLI without the lifecycle hooks, no `AF_UNIX`), in which case the dialog stays open and says no agent browser will be signed in; 400 malformed, 413 over 2 MiB, 403 non-owner, 501 `unsupported_platform` (nothing stored) on a host without `AF_UNIX`, where no browser could ever receive the cookies | +| `DELETE /api/browser/cookies` | `{ok, present: false, live: {cleared, failed, note?}}`; 403 non-owner | + ### Snapshot retention The CLI writes one timestamped YAML per command and documents no pruning, so the @@ -1080,6 +1173,7 @@ this path exists to fix. | Agent reach into a `panel-` session | **Accepted residual.** A `panel-` browser can hold logins the human typed into it, and an agent drives the same CLI through its shell. What separates the populations is structural but not an enforcement boundary: an agent process runs under its own generated `PWTEST_DAEMON_SESSION_DIR`/`PWTEST_SOCKETS_DIR` namespace (see [Generated session reachability](#generated-session-reachability)), so a bare `playwright-cli -s=panel-… goto` from an agent shell resolves no session and its `list` does not show one; reaching the human's browser takes a command that also names the CLI's default registry and the gateway's socket root, both readable by a same-user process. The control on that command is the ordinary shell approval ladder, exactly as for every other `playwright-cli` invocation; the reserved prefix and the `web-browse` skill's rule are the conventions on top. An enforced isolation would be a per-population credential on the daemon socket, which the CLI does not offer | | Reveal | One JSON line to the `show` dashboard's own singleton socket under the gateway-owned socket root both children run with, only when the installed bundle carries that layout, after a successful launch; fails closed when there is no listener. `show -s=` (no port) is never run, since with a stale socket it launches a Chromium app window on the host | | Saved state files | Owner-only permissions; they hold live session credentials | +| Imported cookies (`GET`/`POST`/`DELETE /api/browser/cookies`) | Owner-only (cookie/token), the same guard as the view routes, and additionally refused in a restricted (incognito/temporary) session so an ephemeral session cannot persist a logged-in browser state. The stored `storageState` is owner-only; the summary and the SEL audit carry cookie counts and domains only, never a value; import and clear are both audited. Cookie-authed, deliberately NOT on any internal-path list — an agent never imports cookies. The stored file is bind-masked from every agent sandbox; the agent's daemon stays inside its sandbox (the gateway never starts one for it, so no gateway-privileged daemon is ever under agent control), and the cookies cross to it only as `cookie-set` data the gateway writes to its control socket, never on an argv. Accepted residual: anything that can already drive the agent's browser over that socket can read its cookies back | | Launch config | Write-protected from the agent on both the file-edit and shell gates, and readable. Deliberately anchored rather than bare-token: the filename is not itself the grant, since the agent can name its own `PLAYWRIGHT_MCP_CONFIG` — so what the entry removes is the durable form (rewriting the config the product installed), and a `cd`-relative write is the accepted residual, exactly as for `.data-home-ready` | | Page content | Treated as untrusted input. A URL, instruction, or form target read off a page never decides the next navigation | | Attach mode | Operates the operator's real logged-in browser, so it is the strongest form of the capability and remains behind shell approval | diff --git a/src/kiro_crew/browser_cli/cookies.py b/src/kiro_crew/browser_cli/cookies.py new file mode 100644 index 00000000000..86423e8a295 --- /dev/null +++ b/src/kiro_crew/browser_cli/cookies.py @@ -0,0 +1,1116 @@ +"""Imported browser cookies, stored as a Playwright ``storageState`` and applied +to the agent's ``playwright-cli`` sessions. + +The dashboard user exports cookies from the browser where they are already +logged in and imports them here; the gateway normalises them to Playwright's +cookie shape and writes one owner-only ``storageState`` file in its own +directory under the data home. That directory is **bind-masked out of every +agent sandbox** (``sandbox._CREW_HIDDEN_LEAVES``, pre-created before every +spawn so the mask always mounts): the agent must never be able to read a +session cookie off disk, and a spawned shell's ``open()`` never routes through +the tool gate, so only the OS mask holds. The cookies still have to reach the +agent's browser. Three invariants decide how: + +1. **The agent's browser daemon always runs inside the agent's sandbox**, + started by the agent's own command exactly as before this feature. The + gateway never starts a daemon for an agent session: a gateway-privileged + daemon driven by the agent (``upload ``, ``state-save``) would + be a sandbox escape. +2. **No config the agent or its daemon reads names the hidden file.** The + launch config (:func:`kiro_crew.browser_cli.launch.desired_config`) stays + engine-only; there is no second, gateway-side config. +3. **Cookies reach a daemon only through its control socket, as data.** The + gateway connects to the session's control socket itself and sends one + ``cookie-set`` request per cookie in the daemon's own wire format + (newline-delimited JSON, :func:`_inject_into_session`); the daemon does + ``context.addCookies``. It opens no file, and no process is spawned, so a + cookie value is never on any argv. Two entry points: the import handler injects into every live + ``kc-*`` session at once (:func:`inject_into_live_sessions`), and a + gateway-side :class:`SessionWatcher` thread injects into every daemon + socket that appears afterwards, so a session the agent starts later carries + the cookies too. The clear handler runs ``cookie-clear`` on the live + sessions (:func:`clear_live_sessions`) and resets the watcher's record. + +**Residuals, stated plainly.** (i) The watcher polls every +:data:`WATCH_INTERVAL_S` seconds, so the very first navigation of a brand-new +session can run before its cookies land (at most one interval late; the next +navigation has them). (ii) The control socket is the agent's own: anything +that can connect to it (the agent's sandbox, same-UID processes) can already +drive that browser, including ``cookie-list``, so the values are as readable +there as the cookies of any site the agent logs into. The daemon's reply +echoes the value, which is why an injection failure is reported by a fixed +reason and never by the reply's text. + +**Three import shapes are accepted**, because that is what the browsers and +their export extensions actually produce: + +1. A Playwright ``storageState`` object (``{"cookies": [...], "origins": [...]}``) + -- what ``playwright-cli state-save`` writes, so a round-trip is lossless. +2. A bare JSON array of cookie objects -- the Cookie-Editor / EditThisCookie / + "Get cookies.txt" extension export. Field names differ from Playwright's + (``expirationDate`` for the expiry, a wider ``sameSite`` vocabulary), so they + are normalised. +3. Netscape ``cookies.txt`` -- the tab-separated format ``curl``/``wget`` and + the "Get cookies.txt" extension emit, including the ``#HttpOnly_`` domain + prefix convention. + +Every cookie is normalised to the Playwright shape: ``name``, ``value``, +``domain``, ``path``, ``expires`` (a float, or ``-1`` for a session cookie), +``httpOnly``, ``secure``, ``sameSite`` in ``{"Strict", "Lax", "None"}``. Already +expired cookies are dropped. Every failure raises :class:`CookieImportError` +with a message meant to be shown to the user. + +**Values never leave.** :func:`storage_state_summary` reports counts, domains +and the earliest expiry, and nothing here ever returns or logs a cookie value. +The agent is told (see ``docs/browser-control.md``) that imported cookies apply +to new sessions on their own and that it must never read the storage-state file +-- and the sandbox mask is what makes that last sentence a fact rather than an +instruction. +""" + +from __future__ import annotations + +import json +import logging +import math +import os +import socket +import stat +import threading +import time +from collections.abc import Iterable, Mapping +from pathlib import Path +from typing import Any + +from kiro_crew.atomic_write import atomic_write +from kiro_crew.browser_cli.launch import ( + _LIFECYCLE_DIR, + _SESSION_PREFIX, + SOCKETS_ENV, + _lifecycle_paths, + _operator_base, + _session_leaf, + browser_session_env, +) +from kiro_crew.config.paths import config_dir + +logger = logging.getLogger(__name__) + +#: The directory under the data home that holds the storageState. A DIRECTORY, +#: not a bare file, so its sandbox mask always has a name to bind over: it is +#: pre-created before every namespace spawn (``_CREW_PRECREATE_HIDDEN_DIR_LEAVES``), +#: and a file written inside it later stays inside the stand-in a running sandbox +#: sees. A bare file absent at spawn gets no mask, so a sandbox started before the +#: first import would read the cookies. Listed in ``sandbox._CREW_HIDDEN_LEAVES`` +#: and ``security._CREW_SECRET_LEAVES`` under exactly this name: renaming it here +#: without renaming it there would silently put the cookie values back in the +#: agent's reach. Nothing the agent or its daemon reads ever names this path. +STORAGE_STATE_DIR = "browser-cookie-import" + +#: The storageState file inside :data:`STORAGE_STATE_DIR`. +STORAGE_STATE_FILE = "storage-state.json" + +#: Reject an import whose raw text exceeds this, before parsing. The HTTP +#: handler allows separate JSON-escaping overhead but enforces this same content +#: ceiling through the parser, as does every non-HTTP caller. +MAX_IMPORT_BYTES = 2 * 1024 * 1024 + +#: Reject an import carrying more than this many cookies. A logged-in browser +#: profile has a few hundred; five thousand is far past any legitimate export +#: and bounds the work of writing and re-loading the state. +MAX_COOKIES = 5000 + +#: How long one control-socket exchange with a live session (every +#: ``cookie-set`` of an import, or one ``cookie-clear``) may take before it is +#: abandoned. +_CLI_TIMEOUT_S = 15.0 + +#: Overall bound on one import or clear fan-out across EVERY live socket. The +#: sockets are the agent's own, so it can plant many that accept and never +#: reply; without this the per-socket bound multiplies by their number while +#: the transaction lock is held. A socket not reached in time is reported in +#: ``failed`` as :data:`_NOT_REACHED`. +_FANOUT_DEADLINE_S = 30.0 + +#: Fixed, value-free reason for a socket the fan-out deadline left unreached. +_NOT_REACHED = "not reached before the deadline" + +#: Internal reason for a socket file whose daemon is gone (the connect is +#: refused, or the file vanished): that browser is not live, so a fan-out +#: leaves it out of every result list rather than reporting a failure about a +#: browser that does not exist. +_NOT_LIVE = "not live" + +#: Ceiling on one daemon reply line. A ``cookie-set`` reply is a short +#: confirmation; a peer that streams more than this without a newline is not a +#: daemon this module understands, and the read stops rather than buffering it. +_REPLY_MAX_BYTES = 1024 * 1024 + +#: How often the :class:`SessionWatcher` looks for new daemon sockets. The +#: first navigation of a brand-new session can run up to this long before its +#: cookies land (see the module docstring). +WATCH_INTERVAL_S = 2.0 + +#: Upper bound on the sessions one watcher tick injects into, so a burst of new +#: sessions cannot turn one tick into an unbounded run of socket exchanges; the rest +#: are picked up on the following ticks. +WATCH_MAX_SESSIONS_PER_TICK = 4 + +#: How many ticks the watcher retries a socket whose injection failed before it +#: stops trying. A socket can appear before its daemon accepts connections, and +#: a socket can outlive its daemon (the CLI unlinks it on a failed connect, not +#: on exit); a small budget covers the first and bounds the second. +WATCH_MAX_ATTEMPTS = 3 + +#: Playwright's three accepted ``sameSite`` values. +_SAME_SITE_VALUES = frozenset({"Strict", "Lax", "None"}) + +#: Maps the wider extension/browser ``sameSite`` vocabulary onto Playwright's +#: three. Anything absent or unrecognised falls back to ``Lax`` (see +#: :func:`_normalize_same_site`), which is the browser default for an unspecified +#: attribute. +_SAME_SITE_ALIASES = { + "strict": "Strict", + "lax": "Lax", + "none": "None", + "no_restriction": "None", + "unspecified": "Lax", +} + + +#: One lock for every read-and-apply of the stored cookies: an import (save then +#: inject), a clear (delete then ``cookie-clear``) and a watcher tick (load then +#: inject). Without it a tick that loaded the cookies before a clear could inject +#: them after the clear reported success, leaving the browser signed in. Taken +#: only off the event loop (the handlers run their transaction in a thread). +_TRANSACTION_LOCK = threading.Lock() + + +def transaction() -> threading.Lock: + """The lock an import or clear holds across its whole read-and-apply.""" + return _TRANSACTION_LOCK + + +class CookieImportError(ValueError): + """A cookie import that cannot be accepted, with a user-readable message.""" + + +def storage_state_path() -> Path: + """Where the imported storageState lives, under the data home.""" + return config_dir() / STORAGE_STATE_DIR / STORAGE_STATE_FILE + + +def _normalize_same_site(raw: Any) -> str: + """Normalise any browser/extension ``sameSite`` spelling to Playwright's set. + + Playwright accepts only ``Strict``/``Lax``/``None``; the extensions emit + ``strict``/``lax``/``none``/``no_restriction``/``unspecified`` as well as the + capitalised forms. An absent or unrecognised value becomes ``Lax`` -- the + browser default for a cookie with no ``SameSite`` attribute. + """ + if isinstance(raw, str): + if raw in _SAME_SITE_VALUES: + return raw + mapped = _SAME_SITE_ALIASES.get(raw.strip().lower()) + if mapped is not None: + return mapped + return "Lax" + + +def _reject_json_constant(name: str) -> Any: + """Refuse the non-standard JSON constants ``NaN``/``Infinity``/``-Infinity``. + + ``json.loads`` accepts them by default; a cookie export never legitimately + carries one, and letting one through would persist a value the daemon's + strict parser cannot read. + """ + raise CookieImportError(f"cookie import contains the invalid JSON constant {name}") + + +def _normalize_expires(raw: Any) -> float: + """Normalise an expiry to a float unix timestamp, or ``-1`` for a session cookie. + + Playwright uses ``-1`` for "expires at end of session". A missing, null, + non-numeric, or non-positive expiry is treated as a session cookie: an + export that omits the field is common, and Netscape ``cookies.txt`` writes a + literal ``0`` for a session cookie. A positive value is kept verbatim (an + already-past one is dropped by the caller against the current time). + """ + if isinstance(raw, bool) or raw is None: + return -1.0 + parsed: float | None = None + if isinstance(raw, (int, float)): + parsed = float(raw) + elif isinstance(raw, str): + try: + parsed = float(raw) + except ValueError: + parsed = None + if parsed is None or not math.isfinite(parsed) or parsed <= 0: + # NaN/Infinity would be written verbatim and make the persisted state + # unreadable to the daemon; they carry no expiry, so: session cookie. + return -1.0 + return parsed + + +#: The string spellings of a boolean cookie attribute that exports emit. +#: Cookie-Editor sometimes writes ``"secure": "true"``; Netscape exports carry +#: ``TRUE``/``FALSE`` (handled by :func:`_parse_netscape` before this point). +_FLAG_STRINGS = {"true": True, "1": True, "false": False, "0": False} + + +def _normalize_flag(raw: Any, field: str, cookie_name: str) -> bool: + """Normalise a ``secure`` / ``httpOnly`` attribute to a real boolean. + + Accepts a JSON boolean, an absent/``null`` value (``False``), and the + strings ``"true"``/``"false"``/``"1"``/``"0"`` case-insensitively, which is + what the extension exports actually produce. Anything else -- a number, an + object, another word -- raises :class:`CookieImportError` rather than being + coerced: ``bool("false")`` is ``True``, and silently marking an httpOnly + cookie as script-readable (or the reverse) is exactly the kind of quiet + corruption an import must refuse. + """ + if raw is None: + return False + if isinstance(raw, bool): + return raw + if isinstance(raw, str): + mapped = _FLAG_STRINGS.get(raw.strip().lower()) + if mapped is not None: + return mapped + raise CookieImportError( + f"cookie {cookie_name!r} has an invalid {field} value; expected true or false" + ) + + +def _normalize_cookie(raw: Any) -> dict[str, Any]: + """Normalise one extension/browser cookie object to the Playwright shape. + + Raises :class:`CookieImportError` when a required field is missing: a cookie + with no ``name`` or no ``domain`` cannot be applied to a session and silently + dropping it would hide a broken export. Expiry-based dropping is the caller's + job (:func:`parse_cookie_import`), which knows the current time. + """ + if not isinstance(raw, dict): + raise CookieImportError("each cookie must be a JSON object") + name = raw.get("name") + domain = raw.get("domain") + if not isinstance(name, str) or not name: + raise CookieImportError("a cookie is missing its name") + if not isinstance(domain, str) or not domain: + raise CookieImportError(f"cookie {name!r} is missing its domain") + value = raw.get("value") + # ``expirationDate`` is the extension spelling; ``expires`` is Playwright's. + expires = _normalize_expires( + raw["expirationDate"] if "expirationDate" in raw else raw.get("expires") + ) + path = raw.get("path") + return { + "name": name, + "value": value if isinstance(value, str) else "", + "domain": domain, + "path": path if isinstance(path, str) and path else "/", + "expires": expires, + "httpOnly": _normalize_flag(raw.get("httpOnly"), "httpOnly", name), + "secure": _normalize_flag(raw.get("secure"), "secure", name), + "sameSite": _normalize_same_site(raw.get("sameSite")), + } + + +def _parse_netscape(text: str) -> list[dict[str, Any]]: + """Parse a Netscape ``cookies.txt`` body into raw cookie dicts. + + Tab-separated, seven columns: ``domain includeSubdomains path secure + expires name value``. Lines beginning ``#`` are comments, except the + ``#HttpOnly_`` prefix convention which marks the cookie httpOnly and carries + the real domain after the prefix. A line with too few columns is skipped + rather than failing the whole import. + """ + cookies: list[dict[str, Any]] = [] + for line in text.splitlines(): + # Blank and comment lines are judged on the stripped text, but the + # columns are split from the line with only leading space removed: an + # empty value is a TRAILING tab, and a full strip would eat it and leave + # six columns, silently dropping a valid cookie. + if not line.strip(): + continue + row = line.lstrip(" ") + http_only = False + if row.startswith("#HttpOnly_"): + http_only = True + row = row[len("#HttpOnly_") :] + elif row.startswith("#"): + continue + parts = row.split("\t") + if len(parts) < 7: + continue + domain, _sub, path, secure, expires, name, value = parts[:7] + cookies.append( + { + "domain": domain, + "path": path, + "secure": secure.strip().upper() == "TRUE", + "expires": expires, + "name": name, + "value": value, + "httpOnly": http_only, + } + ) + return cookies + + +def parse_cookie_import(text: str) -> list[dict[str, Any]]: + """Parse and normalise an import in any accepted shape into Playwright cookies. + + Accepts a Playwright ``storageState`` object, a bare JSON array of cookie + objects, or a Netscape ``cookies.txt`` body. Normalises every cookie to the + Playwright shape, drops already-expired cookies, and enforces the size and + count limits. Raises :class:`CookieImportError` with a user-readable message + on any rejection. + """ + if not isinstance(text, str): + raise CookieImportError("cookie import must be text") + if len(text.encode("utf-8", "surrogatepass")) > MAX_IMPORT_BYTES: + raise CookieImportError("cookie import is too large (limit 2 MiB)") + if not text.strip(): + raise CookieImportError("cookie import is empty") + + raw_cookies: list[Any] + stripped = text.lstrip() + if stripped[:1] in "{[": + try: + parsed = json.loads(text, parse_constant=_reject_json_constant) + except RecursionError as exc: + # json.loads recurses per nesting level; a pathological document + # must be a 400, not a 500 from the handler. + raise CookieImportError("cookie import is nested too deeply") from exc + except ValueError as exc: + raise CookieImportError(f"cookie import is not valid JSON: {exc}") from exc + if isinstance(parsed, dict): + # A Playwright storageState object. + candidate = parsed.get("cookies") + if not isinstance(candidate, list): + raise CookieImportError('storageState JSON must carry a "cookies" array') + raw_cookies = candidate + elif isinstance(parsed, list): + raw_cookies = parsed + else: + raise CookieImportError( + "cookie import JSON must be a storageState object or an array of cookies" + ) + else: + raw_cookies = _parse_netscape(text) + if not raw_cookies: + raise CookieImportError( + "cookie import is neither JSON nor a recognisable cookies.txt file" + ) + + if len(raw_cookies) > MAX_COOKIES: + raise CookieImportError(f"too many cookies (limit {MAX_COOKIES})") + + now = time.time() + cookies: list[dict[str, Any]] = [] + for raw in raw_cookies: + cookie = _normalize_cookie(raw) + # Drop an already-expired cookie: a positive expiry in the past can + # never authenticate a session, and loading it only clutters the state. + if cookie["expires"] > 0 and cookie["expires"] < now: + continue + cookies.append(cookie) + + if not cookies: + raise CookieImportError("no unexpired cookies to import") + return cookies + + +def save_storage_state(cookies: list[dict[str, Any]]) -> Path: + """Write *cookies* as a Playwright storageState file, owner-only, and return its path. + + Atomic (temp file + rename) and locked down to the owner: the file carries + live session cookies, so it is written ``0o600`` on POSIX and with an + owner-only DACL on Windows via :func:`atomic_write`'s ``restrict_to_owner`` + (a raw ``mode=0o600`` is a no-op on Windows). + """ + path = storage_state_path() + payload = json.dumps({"cookies": cookies, "origins": []}, indent=2) + "\n" + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + atomic_write(path, payload, mode=0o600, restrict_to_owner=True) + return path + + +def clear_storage_state() -> bool: + """Delete the storageState file. Returns True if a file was removed.""" + path = storage_state_path() + try: + path.unlink() + return True + except FileNotFoundError: + return False + + +def storage_state_summary() -> dict[str, Any] | None: + """Summarise the stored cookies without ever returning a value. + + ``None`` when no storageState file is present or it cannot be read. The + summary carries the cookie count, the distinct domains (leading dot + stripped, sorted), the earliest positive expiry (or ``None`` if every cookie + is a session cookie), and the file's modification time as ``imported_at``. + NEVER a cookie value. + """ + path = storage_state_path() + try: + raw = path.read_text(encoding="utf-8") + except OSError: + return None + try: + data = json.loads(raw) + except (ValueError, RecursionError): + return None + cookies = data.get("cookies") if isinstance(data, dict) else None + if not isinstance(cookies, list): + return None + + domains: set[str] = set() + earliest_expiry: float | None = None + for cookie in cookies: + if not isinstance(cookie, dict): + continue + domain = cookie.get("domain") + if isinstance(domain, str) and domain: + domains.add(domain[1:] if domain.startswith(".") else domain) + expires = cookie.get("expires") + if isinstance(expires, (int, float)) and not isinstance(expires, bool) and expires > 0: + if earliest_expiry is None or expires < earliest_expiry: + earliest_expiry = float(expires) + + try: + imported_at = path.stat().st_mtime + except OSError: + imported_at = time.time() + + return { + "cookie_count": len(cookies), + "domains": sorted(domains), + "earliest_expiry": earliest_expiry, + "imported_at": imported_at, + } + + +# ── Live sessions: the gateway reaching an agent's daemon ──────────────────── + + +def delivery_supported() -> bool: + """Whether this host can hand cookies to an agent's browser at all. + + Delivery is an exchange over the daemon's ``AF_UNIX`` control socket. Where + the platform has no ``AF_UNIX`` (Windows CPython) nothing can reach a + daemon, so a stored import would never reach any browser: the import + handler refuses it instead of reporting a success nothing delivers. + """ + return hasattr(socket, "AF_UNIX") + + +#: Identity of one daemon control socket: ``(path, inode, ctime_ns)``. A new +#: daemon for the same session gets a new socket file, so a key that has been +#: injected into stays injected even when the path is later reused. +SocketKey = tuple[str, int, int] + + +def _lifecycle_roots() -> list[Path]: + """Every root an agent's per-session daemon sockets can live under. + + The same resolution the agent launch applies: it hands + :func:`kiro_crew.browser_cli.launch.browser_socket_env` the gateway's own + environment, so an operator root is the gateway's ``SOCKETS_ENV``, honoured + as a BASE when it is absolute and foreign + (:func:`kiro_crew.browser_cli.launch._operator_base`). The default root is + scanned as well: a session started before the operator set the root still + lives there. Default first, no duplicates. + """ + roots = [config_dir() / _LIFECYCLE_DIR] + configured = os.environ.get(SOCKETS_ENV, "").strip() + if configured and Path(configured).is_absolute(): + base = _operator_base(configured) + if base is not None and base not in roots: + roots.append(base) + return roots + + +def _real_dirs(*paths: Path) -> bool: + """True when every path is a directory itself, not a symlink to one.""" + for path in paths: + try: + if not stat.S_ISDIR(path.lstat().st_mode): + return False + except OSError: + return False + return True + + +def _live_sockets() -> dict[str, SocketKey]: + """Every live ``kc-*`` browser context found by SHAPE, keyed by a distinct name. + + Every generated session gets its own ``/<8hex>/{s,d}`` subtree + (:func:`kiro_crew.browser_cli.launch.browser_socket_env`), precisely so + ``playwright-cli list`` under one root cannot see a peer's browser -- which + also means a bare ``list`` from the gateway sees none of them. So the + gateway enumerates by shape, under every root of :func:`_lifecycle_roots`: + each ``/<8hex>/s/cli/*.sock`` control socket is one candidate browser + of session ``kc-<8hex>``. A session holds one socket per named browser + (``-s=``), and every one of them is returned: an import must reach + each and a clear must empty each, or an older browser stays signed in. + + A session with one socket is named ``kc-<8hex>``; when it has several, each + is named ``kc-<8hex>/`` so no result entry hides another. A + socket can outlive its daemon (the CLI unlinks it on a failed connect, not + on exit), so "candidate" is the honest word; the exchange run against it + reports the truth. Never raises; an unreadable root is an empty result. + """ + by_session: dict[str, list[tuple[str, SocketKey]]] = {} + for root in _lifecycle_roots(): + try: + entries = sorted(root.iterdir()) + except OSError: + continue + for entry in entries: + name = f"{_SESSION_PREFIX}{entry.name}" + if not _session_leaf(name): + continue + # Every directory on the way to the socket is agent-writable too, so + # none of them may be a symlink: one would lead the walk to sockets + # outside the agent's own tree (the owner's panel browser). + if not _real_dirs(entry, entry / "s", entry / "s" / "cli"): + continue + try: + socks = sorted((entry / "s" / "cli").iterdir()) + except OSError: + continue + for sock in socks: + if sock.suffix != ".sock": + continue + try: + # lstat, never stat: a symlink the agent plants here could + # point at another browser's socket (the owner's panel + # browser), and an import or clear would then write into it. + st = sock.lstat() + except OSError: + continue + if not stat.S_ISSOCK(st.st_mode): + continue + key: SocketKey = (str(sock), st.st_ino, st.st_ctime_ns) + by_session.setdefault(name, []).append((sock.stem, key)) + found: dict[str, SocketKey] = {} + for name, members in by_session.items(): + if len(members) == 1: + found[name] = members[0][1] + continue + for stem, member_key in members: + # The same session can hold a socket of one stem under two roots + # (started before and after the operator root was set): number the + # repeat rather than let it overwrite, so neither is skipped. + label = f"{name}/{stem}" + repeat = 2 + while label in found: + label = f"{name}/{stem}#{repeat}" + repeat += 1 + found[label] = member_key + return found + + +def _cookie_set_args(cookie: Mapping[str, Any]) -> dict[str, Any]: + """The ``cookie-set`` request for one normalised cookie, as the daemon parses it. + + This is the object the CLI client builds from its own argv with minimist and + sends as ``params.args`` (verified against the bundled CLI's + ``parseCliCommand``): ``_`` holds the command and its two positionals, and + every option is a key the command's strict schema declares. ``expires`` is a + number and is omitted for a session cookie (``-1``); the two flags are sent + only when true. A name or value is a JSON string field, so nothing in it can + be read as an option. + """ + args: dict[str, Any] = { + "_": ["cookie-set", str(cookie["name"]), str(cookie.get("value", ""))], + "domain": str(cookie["domain"]), + "path": str(cookie.get("path") or "/"), + "sameSite": str(cookie.get("sameSite") or "Lax"), + } + expires = cookie.get("expires") + if isinstance(expires, (int, float)) and not isinstance(expires, bool) and expires > 0: + args["expires"] = int(expires) if float(expires).is_integer() else float(expires) + if cookie.get("httpOnly"): + args["httpOnly"] = True + if cookie.get("secure"): + args["secure"] = True + return args + + +def _connect(socket_path: str, timeout: float) -> socket.socket: + """Connect to one daemon control socket (a seam the tests replace).""" + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.settimeout(timeout) + try: + sock.connect(socket_path) + except BaseException: + sock.close() + raise + return sock + + +def _read_reply(sock: socket.socket, buf: bytearray, deadline: float) -> bytes: + """One newline-terminated reply line; leftover bytes stay in *buf*. + + *deadline* is the exchange's absolute ``time.monotonic()`` bound, re-applied + before every receive: a peer that trickles bytes with no newline would + otherwise get a fresh per-``recv`` timeout each time and hold the exchange + open far past it. + """ + while True: + end = buf.find(b"\n") + if end != -1: + line = bytes(buf[:end]) + del buf[: end + 1] + return line + if len(buf) > _REPLY_MAX_BYTES: + raise ValueError("reply too long") + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("reply deadline passed") + sock.settimeout(remaining) + chunk = sock.recv(65536) + if not chunk: + raise ConnectionError("socket closed") + buf += chunk + + +def _load_cookies() -> list[dict[str, Any]] | None: + """The stored cookies, or ``None`` when there is no readable state file.""" + try: + data = json.loads(storage_state_path().read_text(encoding="utf-8")) + except (OSError, ValueError, RecursionError): + return None + cookies = data.get("cookies") if isinstance(data, dict) else None + if not isinstance(cookies, list): + return None + return [c for c in cookies if isinstance(c, dict) and c.get("name") and c.get("domain")] + + +def _exchange( + socket_path: str, + requests: Iterable[Mapping[str, Any]], + timeout: float = _CLI_TIMEOUT_S, +) -> tuple[str, int]: + """Run each request on one daemon over its control socket; ``(failure, refused)``. + + Speaks the daemon's own protocol: one JSON object per line, + ``{"id", "method": "run", "params": {"args", "cwd"}}``, answered by + ``{"id", "result": {"isError", "text"}}`` or ``{"id", "error"}``. One + connection carries every request and the whole exchange is bounded by + :data:`_CLI_TIMEOUT_S`. No process is spawned, so a cookie value never + appears on an argv. + + ``failure`` is ``""`` when every request got a well-formed reply, else a + fixed, value-free reason (the reply text echoes the cookie, so it is never + quoted); a transport or protocol failure stops the exchange, since nothing + after it can be trusted. ``refused`` counts the requests the daemon answered + with an error. A refusal does NOT stop the exchange: one cookie the browser + rejects (Chromium refuses ``SameSite=None`` without ``Secure``) must not keep + every cookie after it out of the session. + """ + if not delivery_supported(): + return "unsupported platform", 0 + timeout = min(timeout, _CLI_TIMEOUT_S) + if timeout <= 0: + return "timed out", 0 + deadline = time.monotonic() + timeout + try: + sock = _connect(socket_path, timeout) + except TimeoutError: + return "timed out", 0 + except (ConnectionRefusedError, FileNotFoundError): + # A socket file that outlived its daemon: no browser behind it. + return _NOT_LIVE, 0 + except OSError as exc: + return type(exc).__name__, 0 + buf = bytearray() + refused = 0 + with sock: + for message_id, args in enumerate(requests, start=1): + remaining = deadline - time.monotonic() + if remaining <= 0: + return "timed out", refused + message = { + "id": message_id, + "method": "run", + "params": {"args": dict(args), "cwd": "/"}, + } + try: + sock.settimeout(remaining) + sock.sendall(json.dumps(message).encode("utf-8") + b"\n") + line = _read_reply(sock, buf, deadline) + except TimeoutError: + return "timed out", refused + except (OSError, ValueError) as exc: + return type(exc).__name__, refused + try: + reply = json.loads(line) + except (ValueError, RecursionError): + # A reply nested deeper than the parser recurses is no daemon + # reply either, and must not escape a function that never raises. + return "malformed reply", refused + if not isinstance(reply, dict) or reply.get("id") != message_id: + return "malformed reply", refused + result = reply.get("result") + if "error" in reply or not isinstance(result, dict) or result.get("isError"): + refused += 1 + return "", refused + + +def _inject_into_session( + socket_path: str, + cookies: Iterable[Mapping[str, Any]], + timeout: float = _CLI_TIMEOUT_S, +) -> str: + """Send every cookie to one daemon as ``cookie-set``; ``""`` or a value-free reason. + + Every cookie is sent even when the browser refuses one of them; the reason + then counts the refusals. :data:`_NOT_LIVE` means no daemon is behind the + socket. See :func:`_exchange` for the protocol. + """ + requests = [_cookie_set_args(cookie) for cookie in cookies] + failure, refused = _exchange(socket_path, requests, timeout) + if failure: + return failure + if refused: + return f"the browser refused {refused} of {len(requests)} cookies" + return "" + + +def _clear_session(socket_path: str, timeout: float = _CLI_TIMEOUT_S) -> str: + """Run ``cookie-clear`` on one daemon over its control socket; ``""`` or a reason.""" + failure, refused = _exchange(socket_path, [{"_": ["cookie-clear"]}], timeout) + if failure: + return failure + if refused: + return "the browser refused to clear its cookies" + return "" + + +def inject_into_live_sessions() -> dict[str, Any]: + """Inject the stored cookies into every live ``kc-*`` session, best effort. + + The import handler's half of the two injection points (the + :class:`SessionWatcher` is the other). NEVER raises. Returns ``{"loaded": + [names], "failed": {name: reason}, "deliverable": bool}`` plus a one-line + ``note`` whenever NOTHING loaded, so the caller can show why rather than an + empty success. ``deliverable`` reports whether new agent sessions can receive + the saved set, independently of what the live sessions accepted. + Also records the sockets it reached with the watcher, so the next tick does + not repeat the work. + """ + if not delivery_supported(): + return { + "loaded": [], + "failed": {}, + "deliverable": False, + "note": "this host cannot reach the agent's browser", + } + deliverable = _new_sessions_reachable() + cookies = _load_cookies() + if not cookies: + return { + "loaded": [], + "failed": {}, + "deliverable": deliverable, + "note": "no imported cookies to apply", + } + # Where the launch will not put agent sockets under the scanned roots, no + # new session can be reached, so "apply automatically" would be false. + later = "the cookies apply to new sessions automatically" if deliverable else _UNREACHABLE_NOTE + sockets = _live_sockets() + loaded: list[str] = [] + failed: dict[str, str] = {} + # Align the watcher with the file just written, so the sockets recorded + # below belong to THIS state and its next tick does not redo them. + if sockets: + _WATCHER.sync_state() + deadline = time.monotonic() + _FANOUT_DEADLINE_S + for name, key in sockets.items(): + remaining = deadline - time.monotonic() + if remaining <= 0: + failed[name] = _NOT_REACHED + continue + reason = _inject_into_session(key[0], cookies, remaining) + if reason == _NOT_LIVE: + continue + if reason: + failed[name] = reason + else: + loaded.append(name) + _WATCHER.mark_injected(key) + result: dict[str, Any] = {"loaded": loaded, "failed": failed, "deliverable": deliverable} + if not loaded and not failed: + result["note"] = f"no live browser sessions; {later}" + elif not loaded: + result["note"] = f"no open browser session accepted the cookies; {later}" + return result + + +#: Import note for a host whose agent sessions the gateway cannot enumerate. +_UNREACHABLE_NOTE = ( + "the agent's browser sessions are not under the gateway's reach on this " + "host, so imported cookies cannot be delivered" +) + + +def _new_sessions_reachable() -> bool: + """Whether a NEW agent session's sockets will land under :func:`_lifecycle_roots`. + + The agent's env is built from the gateway's, including its generated session + name, and the launcher's shared validation decides whether its lifecycle + roots are usable. Directory-creation failure cannot be predicted without + side effects; this check never creates directories. Never raises. + """ + try: + env = {**os.environ, **browser_session_env(os.environ)} + return _lifecycle_paths(env) is not None + except Exception: # noqa: BLE001 - a note must never fail the import + logger.debug("browser cookie reach check failed", exc_info=True) + return False + + +def clear_live_sessions() -> dict[str, Any]: + """Clear the cookies of every live ``kc-*`` session, best effort. + + Companion of :func:`clear_storage_state`: deleting the file stops NEW + sessions from receiving the cookies, but a browser already open stays signed + in until it is closed. This sends ``cookie-clear`` over the control socket + of every session :func:`_live_sockets` finds -- the same sessions an import + reaches -- so a clear from the dashboard means what it says, and forgets the + watcher's record so a later import injects again. NEVER raises. Returns + ``{"cleared": [names], "failed": {name: reason}}`` plus a ``note`` when + there was nothing to clear. + """ + _WATCHER.reset() + if not delivery_supported(): + return {"cleared": [], "failed": {}, "note": "this host cannot reach the agent's browser"} + sockets = _live_sockets() + if not sockets: + return {"cleared": [], "failed": {}, "note": "no live browser sessions"} + cleared: list[str] = [] + failed: dict[str, str] = {} + deadline = time.monotonic() + _FANOUT_DEADLINE_S + for name, key in sockets.items(): + remaining = deadline - time.monotonic() + if remaining <= 0: + failed[name] = _NOT_REACHED + continue + reason = _clear_session(key[0], remaining) + if reason == _NOT_LIVE: + continue + if reason: + failed[name] = reason + else: + cleared.append(name) + if not cleared and not failed: + return {"cleared": [], "failed": {}, "note": "no live browser sessions"} + return {"cleared": cleared, "failed": failed} + + +# ── SessionWatcher: cookies for daemons that appear after the import ───────── + + +class SessionWatcher: + """Gateway-side thread that injects the stored cookies into new daemon sockets. + + Invariant 1 (the module docstring) rules out starting a daemon for the agent, + so a session the agent opens AFTER an import has to be reached once it + exists. Every :data:`WATCH_INTERVAL_S` seconds the watcher lists + ``pw/<8hex>/s/cli/*.sock`` and, for any socket it has not injected into yet + (keyed by path + inode + ctime), sends ``cookie-set`` per cookie over that + socket and records the key. It only does work while the storage-state file + exists; when the file's mtime changes (a re-import) the record is dropped so + every live session is injected again, and :meth:`reset` (the clear handler) + drops it too. + + Entirely off the event loop: a daemon thread the aiohttp app only starts and + stops. Bounded: at most :data:`WATCH_MAX_SESSIONS_PER_TICK` sessions per + tick, :data:`_CLI_TIMEOUT_S` per session and :data:`_FANOUT_DEADLINE_S` for + the whole tick's injection, :data:`WATCH_MAX_ATTEMPTS` tries per socket. + Never raises out of a tick; everything is logged at debug. + """ + + def __init__(self, interval: float = WATCH_INTERVAL_S) -> None: + self._interval = interval + self._lock = threading.Lock() + self._done: set[SocketKey] = set() + self._attempts: dict[SocketKey, int] = {} + self._state_sig: tuple[int, int] | None = None + self._stop = threading.Event() + self._thread: threading.Thread | None = None + + # -- record --------------------------------------------------------------- + + def mark_injected(self, key: SocketKey) -> None: + """Record that *key* already carries the current cookies.""" + with self._lock: + self._done.add(key) + self._attempts.pop(key, None) + + def reset(self) -> None: + """Forget every injected socket, so the next tick with a file injects again.""" + with self._lock: + self._done.clear() + self._attempts.clear() + self._state_sig = None + + def injected(self) -> frozenset[SocketKey]: + """The sockets recorded as injected (for tests and diagnostics).""" + with self._lock: + return frozenset(self._done) + + def sync_state(self) -> bool: + """Align the record with the state file; ``True`` when a file exists. + + A changed mtime/size (a re-import) drops the record so every live + session is due again; a missing file drops it too, since a later import + must reach every session. Called by the import path before it records + what it reached, and by every tick. + """ + try: + st = storage_state_path().stat() + except OSError: + self.reset() + return False + sig = (st.st_mtime_ns, st.st_size) + with self._lock: + if sig != self._state_sig: + self._done.clear() + self._attempts.clear() + self._state_sig = sig + return True + + # -- one pass ------------------------------------------------------------- + + def tick(self) -> int: + """One pass; returns how many sessions were injected into. Never raises.""" + try: + return self._tick() + except Exception: # noqa: BLE001 - a watcher pass must never take the thread down + logger.debug("browser cookie watcher tick failed", exc_info=True) + return 0 + + def _tick(self) -> int: + with _TRANSACTION_LOCK: + return self._tick_locked() + + def _tick_locked(self) -> int: + if not self.sync_state(): + return 0 + sockets = _live_sockets() + if not sockets: + return 0 + pending: list[tuple[str, SocketKey]] = [] + with self._lock: + for name, key in sockets.items(): + if key in self._done or self._attempts.get(key, 0) >= WATCH_MAX_ATTEMPTS: + continue + pending.append((name, key)) + # Drop records for sockets that are gone, so the sets stay + # bounded by the live population. + live = set(sockets.values()) + self._done &= live + self._attempts = {k: n for k, n in self._attempts.items() if k in live} + if not pending: + return 0 + cookies = _load_cookies() + if not cookies: + return 0 + injected = 0 + deadline = time.monotonic() + _FANOUT_DEADLINE_S + for name, key in pending[:WATCH_MAX_SESSIONS_PER_TICK]: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + reason = _inject_into_session(key[0], cookies, remaining) + with self._lock: + if reason: + # A stale socket (no daemon behind it) still spends an + # attempt, so a pile of them cannot starve live sockets of + # the per-tick budget; it is logged as not live, not failed. + self._attempts[key] = self._attempts.get(key, 0) + 1 + logger.debug( + "browser cookie watcher: %s %s, attempt %d", + name, + "is not live" if reason == _NOT_LIVE else f"not injected ({reason})", + self._attempts[key], + ) + else: + self._done.add(key) + self._attempts.pop(key, None) + injected += 1 + logger.debug( + "browser cookie watcher: injected %d cookies into %s", len(cookies), name + ) + return injected + + # -- thread --------------------------------------------------------------- + + def _run(self) -> None: + while not self._stop.is_set(): + self.tick() + self._stop.wait(self._interval) + + def start(self) -> None: + """Start the thread (idempotent).""" + with self._lock: + if self._thread is not None and self._thread.is_alive(): + return + self._stop.clear() + self._thread = threading.Thread( + target=self._run, name="browser-cookie-watcher", daemon=True + ) + self._thread.start() + + def stop(self, timeout: float = 5.0) -> None: + """Stop the thread and wait for it (idempotent).""" + with self._lock: + thread = self._thread + self._thread = None + self._stop.set() + if thread is not None and thread is not threading.current_thread(): + thread.join(timeout) + + @property + def running(self) -> bool: + thread = self._thread + return thread is not None and thread.is_alive() + + +#: The one watcher per gateway. The dashboard starts it where the browser-view +#: cleanup is registered and stops it on cleanup; the handlers only touch its +#: record. +_WATCHER = SessionWatcher() + + +def start_session_watcher() -> None: + """Start the gateway's cookie watcher when it has work (idempotent). + + A no-op until a storage-state file exists: gateway start calls this, and so + does the import handler after it saves, so a gateway that never + imports cookies runs no thread at all. Also a no-op where the platform has no + ``AF_UNIX`` sockets, since :func:`_inject_into_session` cannot reach a daemon + there and every tick would only fail. + """ + if not hasattr(socket, "AF_UNIX"): + return + try: + present = storage_state_path().is_file() + except OSError: + return + if present: + _WATCHER.start() + + +def stop_session_watcher() -> None: + """Stop the gateway's cookie watcher (idempotent).""" + _WATCHER.stop() diff --git a/src/kiro_crew/browser_cli/launch.py b/src/kiro_crew/browser_cli/launch.py index e05a79bd568..6aa0f4ea5db 100644 --- a/src/kiro_crew/browser_cli/launch.py +++ b/src/kiro_crew/browser_cli/launch.py @@ -61,7 +61,7 @@ SeamSupport, cli_lifecycle_env_support, ) -from kiro_crew.config.paths import config_dir +from kiro_crew.config.paths import config_dir, peek_data_home logger = logging.getLogger(__name__) @@ -203,34 +203,20 @@ def _warn_lifecycle_loss(support: SeamSupport, detail: str) -> None: logger.warning("%s", message) -def browser_socket_env(env: Mapping[str, str]) -> dict[str, str]: - """Environment additions keeping daemon sockets reachable and discoverable. +def _lifecycle_paths(env: Mapping[str, str], *, warn: bool = False) -> tuple[Path, Path] | None: + """Validate lifecycle paths without creating directories or changing permissions. - ``playwright-cli`` launches its daemon after Kiro Crew has pointed - ``TMPDIR`` at a per-process scratch directory. Without ``SOCKETS_ENV`` the - socket disappears when that scratch is reclaimed. ``DAEMON_DIR_ENV`` fixes - the corresponding session registry location, letting Kiro Crew find and - validate the exact generated session file at controlled teardown without - executing the user-writable CLI wrapper. - - This helper is called only when Kiro Crew generated ``SESSION_ENV``. - A location variable holding a FOREIGN value is treated as an - operator-selected BASE root and namespaced by the generated session; one - holding a value already under our own lifecycle root arrived by inheritance - and is ignored in favour of that root, so namespaces stay siblings instead - of nesting (see :func:`_operator_base`). Non-generated operator sessions - never call this helper. Both final directories are owner-restricted. If - validation or preparation fails, no partial additions are returned and the - current TMPDIR/default-registry behavior remains. This helper performs - filesystem I/O and event-loop callers MUST offload it. + Read-only callers stay silent; the launcher opts into its existing warnings. + ``peek_data_home`` is essential: ``config_dir`` can create the data home. """ session_name = env.get(SESSION_ENV, "").strip() if not _session_leaf(session_name): - return {} + return None support, detail = cli_lifecycle_env_support() if support is not SeamSupport.SUPPORTED: - _warn_lifecycle_loss(support, detail) - return {} + if warn: + _warn_lifecycle_loss(support, detail) + return None configured_sockets = env.get(SOCKETS_ENV, "").strip() configured_daemons = env.get(DAEMON_DIR_ENV, "").strip() if ( @@ -239,11 +225,20 @@ def browser_socket_env(env: Mapping[str, str]) -> dict[str, str]: or configured_daemons and not Path(configured_daemons).is_absolute() ): - logger.warning("Playwright lifecycle root overrides must be absolute paths") - return {} - sockets_path = socket_dir(session_name, _operator_base(configured_sockets)) - daemons_path = daemon_dir(session_name, _operator_base(configured_daemons)) - additions: dict[str, str] = {} + if warn: + logger.warning("Playwright lifecycle root overrides must be absolute paths") + return None + sockets_base = _operator_base(configured_sockets) + daemons_base = _operator_base(configured_daemons) + if sockets_base is None or daemons_base is None: + # The launcher (warn=True) keeps resolving through ``config_dir`` as it + # always has; a read-only caller peeks so it never creates the home. + home = config_dir() if warn else peek_data_home() + root = home / _LIFECYCLE_DIR + sockets_base = sockets_base if sockets_base is not None else root + daemons_base = daemons_base if daemons_base is not None else root + sockets_path = socket_dir(session_name, sockets_base) + daemons_path = daemon_dir(session_name, daemons_base) # Upstream builds `/cli/<16-char-workspace>-<11-char-session>.sock`. # Check the complete shortest non-trimmed form; if even that cannot fit, # makeSocketPath raises and browsing fails before cleanup can help. @@ -252,12 +247,42 @@ def browser_socket_env(env: Mapping[str, str]) -> dict[str, str]: not platform_compat.IS_WINDOWS and len(os.fsencode(str(worst_case))) > _UNIX_SOCKET_PATH_MAX_BYTES ): - logger.warning( - "Playwright socket directory is too long for AF_UNIX (%d bytes): %s", - len(os.fsencode(str(worst_case))), - sockets_path, - ) + if warn: + logger.warning( + "Playwright socket directory is too long for AF_UNIX (%d bytes): %s", + len(os.fsencode(str(worst_case))), + sockets_path, + ) + return None + return sockets_path, daemons_path + + +def browser_socket_env(env: Mapping[str, str]) -> dict[str, str]: + """Environment additions keeping daemon sockets reachable and discoverable. + + ``playwright-cli`` launches its daemon after Kiro Crew has pointed + ``TMPDIR`` at a per-process scratch directory. Without ``SOCKETS_ENV`` the + socket disappears when that scratch is reclaimed. ``DAEMON_DIR_ENV`` fixes + the corresponding session registry location, letting Kiro Crew find and + validate the exact generated session file at controlled teardown without + executing the user-writable CLI wrapper. + + This helper is called only when Kiro Crew generated ``SESSION_ENV``. + A location variable holding a FOREIGN value is treated as an + operator-selected BASE root and namespaced by the generated session; one + holding a value already under our own lifecycle root arrived by inheritance + and is ignored in favour of that root, so namespaces stay siblings instead + of nesting (see :func:`_operator_base`). Non-generated operator sessions + never call this helper. Both final directories are owner-restricted. If + validation or preparation fails, no partial additions are returned and the + current TMPDIR/default-registry behavior remains. This helper performs + filesystem I/O and event-loop callers MUST offload it. + """ + paths = _lifecycle_paths(env, warn=True) + if paths is None: return {} + sockets_path, daemons_path = paths + additions: dict[str, str] = {} for key, path in ((SOCKETS_ENV, sockets_path), (DAEMON_DIR_ENV, daemons_path)): try: platform_compat.make_owner_only_dir(path) @@ -368,11 +393,21 @@ def launch_config_path() -> Path: def desired_config() -> dict[str, object]: - """The config Kiro Crew generates. + """The config Kiro Crew generates for the AGENT's ``playwright-cli``. Deliberately minimal: it names the engine and nothing else. Every key added here becomes a default an operator has to discover in order to override, and the engine is the only one the product's own install flow already decided. + + It never names the imported-cookies file. That file + (:func:`kiro_crew.browser_cli.cookies.storage_state_path`) is bind-masked out + of every agent sandbox, and the daemon an AGENT's command starts runs inside + that sandbox: a ``contextOptions.storageState`` pointing at a path the daemon + cannot open fails every browse with ENOENT. There is no second, gateway-side + config either -- a daemon the gateway started for the agent would be a + sandbox escape. The cookies reach the agent's daemon over its control + socket instead, as ``cookie-set`` data from the gateway + (:mod:`kiro_crew.browser_cli.cookies`). """ return {"browser": {"browserName": LAUNCH_ENGINE}} diff --git a/src/kiro_crew/dashboard/handlers/__init__.py b/src/kiro_crew/dashboard/handlers/__init__.py index a30eb833d67..6abe01f491f 100644 --- a/src/kiro_crew/dashboard/handlers/__init__.py +++ b/src/kiro_crew/dashboard/handlers/__init__.py @@ -336,6 +336,9 @@ def sel(): api_browser_command, api_browser_command_drain, api_browser_command_result, + api_browser_cookies_clear, + api_browser_cookies_get, + api_browser_cookies_import, api_browser_engine_install, api_browser_install_get, api_browser_install_start, diff --git a/src/kiro_crew/dashboard/handlers/messaging.py b/src/kiro_crew/dashboard/handlers/messaging.py index df5f8332077..733706b7f92 100644 --- a/src/kiro_crew/dashboard/handlers/messaging.py +++ b/src/kiro_crew/dashboard/handlers/messaging.py @@ -33,6 +33,7 @@ QueueFullError, get_command_bus, ) +from kiro_crew.browser_cli import cookies as browser_cli_cookies # noqa: F401 from kiro_crew.browser_cli import install as browser_cli_install # noqa: F401 from kiro_crew.browser_cli import install_job as browser_install_job # noqa: F401 from kiro_crew.browser_cli import launcher as browser_cli_launcher # noqa: F401 @@ -74,6 +75,7 @@ subagent_event_slot, ) from kiro_crew.dashboard.handlers._shared import ( # noqa: F401 + _is_restricted_session, _pip_install_channel_available, guard_owner_surface_routes, internal_memory_scope, @@ -114,11 +116,16 @@ _browser_install_status, _browser_view_payload, _deny_non_owner_browser_request, + _get_cookie_lock, + _restricted_cookie_denial, _start_browser_install_job, _terminate_install_scope, api_browser_command, api_browser_command_drain, api_browser_command_result, + api_browser_cookies_clear, + api_browser_cookies_get, + api_browser_cookies_import, api_browser_engine_install, api_browser_install_get, api_browser_install_start, @@ -437,6 +444,15 @@ class _CredentialTupleChanged(Exception): #: a rollback has to put back rather than delete. _ABSENT: Any = object() +#: One cookie transaction at a time. Import is persist-then-inject and clear is +#: delete-then-clear-live; an overlapping POST and DELETE interleaving at the +#: ``to_thread`` boundary could leave a state the owner just cleared active in +#: the live sessions (or the reverse). Held across the WHOLE transaction of +#: both handlers. Created lazily so importing this module does not need a +#: running loop (same shape as ``ui_prefs._get_lock``). Read and set by +#: ``messaging_api.browser_routes._get_cookie_lock``. +_cookie_lock: asyncio.Lock | None = None + def _sel(): """Late-binding _sel() for test monkeypatch compatibility.""" diff --git a/src/kiro_crew/dashboard/messaging_api/browser_routes.py b/src/kiro_crew/dashboard/messaging_api/browser_routes.py index 33a8e05a5b9..2861ad97a8a 100644 --- a/src/kiro_crew/dashboard/messaging_api/browser_routes.py +++ b/src/kiro_crew/dashboard/messaging_api/browser_routes.py @@ -3,9 +3,10 @@ ``PUT /api/browser/token``; ``POST /api/browser/command`` with its ``command-drain`` and ``command-result`` legs; ``GET``/``POST /api/browser/install`` -and ``POST /api/browser/engine``; ``GET /api/browser/view`` with ``view/start``; and -``POST /api/browser/open``. Also the owner check every mutation runs and the install -job the gateway stops at shutdown (``stop_browser_install``). +and ``POST /api/browser/engine``; ``GET /api/browser/view`` with ``view/start``; +``POST /api/browser/open``; and ``GET``/``POST``/``DELETE /api/browser/cookies`` for +the imported cookies (``browser_cli/cookies.py``). Also the owner check every mutation +runs and the install job the gateway stops at shutdown (``stop_browser_install``). """ from __future__ import annotations @@ -25,7 +26,9 @@ KiroCrewConfig, NoPanelError, QueueFullError, + _is_restricted_session, _sel, + browser_cli_cookies, browser_cli_install, browser_cli_launcher, browser_cli_token, @@ -33,8 +36,12 @@ browser_install_job, get_command_bus, logger, + read_bounded_json, ) + # The facade's module state, rebound by ``_get_cookie_lock`` below. + _cookie_lock: asyncio.Lock | None + def _deny_non_owner_browser_request(request: web.Request, operation: str) -> web.Response | None: """Require the dashboard owner on browser MUTATION endpoints. 403 or None. @@ -645,6 +652,200 @@ def _start_view() -> None: return web.json_response(await asyncio.to_thread(_browser_view_payload)) +async def api_browser_cookies_get(request: web.Request) -> web.Response: + """GET /api/browser/cookies -- report whether imported cookies are present. + + Owner-only, like the view routes: the summary names the domains a logged-in + session covers, which is not something an app token or a non-owner should be + able to enumerate. Read of a security-relevant state, so it goes through the + same guard the mutations do rather than staying open like the install probe. + Refused in a restricted (incognito/temporary) session for the same reason + the import and clear are: the domain list says which sites a credential + unlocks, and an ephemeral session must not learn that either. The refusal + carries the same ``restricted_session`` body the mutations return, so the + panel hides the control on one shape. + + Body: ``{"present": bool, "summary": }``. ``summary`` carries + counts, domains and expiry only -- never a cookie value. + """ + denied = _deny_non_owner_browser_request(request, "browser_cookies_status") + if denied is not None: + return denied + state: DashboardState = request.app["state"] + if _is_restricted_session(state, request): + return _restricted_cookie_denial(request, "browser_cookies_status") + summary = await asyncio.to_thread(browser_cli_cookies.storage_state_summary) + return web.json_response({"present": summary is not None, "summary": summary}) + + +def _get_cookie_lock() -> asyncio.Lock: + """The one lock both cookie transactions hold (state: ``messaging._cookie_lock``).""" + global _cookie_lock + if _cookie_lock is None: + _cookie_lock = asyncio.Lock() + return _cookie_lock + + +async def api_browser_cookies_import(request: web.Request) -> web.Response: + """POST /api/browser/cookies -- import cookies for the agent's browser sessions. + + Body: ``{"content": str}``. ``content`` is a Playwright + ``storageState`` object, a Cookie-Editor / "Get cookies.txt" JSON array, or + a Netscape ``cookies.txt`` body. The cookies are normalised, written as an + owner-only storageState under the data home (a file every agent sandbox + masks), then injected into every live ``kc-*`` session over its daemon + socket (``cookie-set`` per cookie, from the gateway; the daemon opens no + file). Sessions that appear later are reached by the gateway's + ``SessionWatcher``. ``hot_load`` reports the live outcome; ``deliverable`` + says whether new sessions can receive the saved set, independently of the + current sessions' results, and ``note`` says why when nothing was reached. + + Owner-only (a browser credential write), and refused in a restricted + (incognito/temporary) session the same way the other credential writes are: + an ephemeral session must not persist a logged-in browser state. + + Responses: 200 ``{"ok": true, "summary": , "hot_load": {...}}``; + 400 on malformed or over-2-MiB content; 413 on an oversized JSON body; 403 non-owner; + 501 ``unsupported_platform`` when this host cannot reach an agent's browser + at all (no ``AF_UNIX``), in which case nothing is stored: a saved set that + no browser will ever receive must not read as a successful import. + """ + denied = _deny_non_owner_browser_request(request, "browser_cookies_import") + if denied is not None: + return denied + state: DashboardState = request.app["state"] + if _is_restricted_session(state, request): + return _restricted_cookie_denial(request, "browser_cookies_import") + # JSON escaping doubles tabs, newlines, quotes and backslashes in exports. + # Allow transport overhead; parse_cookie_import still caps content at 2 MiB. + body, error = await read_bounded_json( + request, max_bytes=2 * browser_cli_cookies.MAX_IMPORT_BYTES + 1024 + ) + if error is not None: + return error + assert body is not None + content = body.get("content") + if not isinstance(content, str): + return web.json_response( + {"error": "content must be a string", "code": "invalid_content"}, status=400 + ) + try: + cookies = browser_cli_cookies.parse_cookie_import(content) + except browser_cli_cookies.CookieImportError as exc: + return web.json_response({"error": str(exc), "code": "invalid_cookies"}, status=400) + if not browser_cli_cookies.delivery_supported(): + return web.json_response( + { + "error": ( + "The computer running Kiro Crew cannot hand cookies to the " + "agent's browser, so nothing was imported" + ), + "code": "unsupported_platform", + }, + status=501, + ) + + def _persist() -> tuple[dict[str, Any] | None, dict[str, Any]]: + # The module's transaction lock keeps a watcher tick out of the middle. + with browser_cli_cookies.transaction(): + browser_cli_cookies.save_storage_state(cookies) + hot = browser_cli_cookies.inject_into_live_sessions() + summary = browser_cli_cookies.storage_state_summary() + # The watcher runs only once there is something to inject. + browser_cli_cookies.start_session_watcher() + return summary, hot + + # The lock spans persist AND inject so a concurrent DELETE cannot slip in + # between them and leave the live sessions carrying a cleared state. + async with _get_cookie_lock(): + summary, hot_load = await asyncio.to_thread(_persist) + # A permission DECISION whose damaging path is the ALLOWED write -- audit it + # with count and domains only, never a value (mirrors the install audit). + # The domains come from the imported file, so they are untrusted text and up + # to MAX_COOKIES of them: name the first 20, count the rest, and drop every + # non-printable character (controls, format characters, line separators) so + # one import cannot write an unbounded or forged audit record. Inline, not a + # module helper: this function runs on the messaging facade's globals. + audited_resources = request.path + if summary: + raw_domains = summary.get("domains") + names = [str(d) for d in raw_domains] if isinstance(raw_domains, list) else [] + audited = ",".join("".join(ch for ch in n if ch.isprintable()) for n in names[:20]) + if len(names) > 20: + audited += f",+{len(names) - 20} more" + audited_resources += f" cookies={summary.get('cookie_count')} domains={audited}" + _sel().log_api_access( + caller=str(request.get("user") or "owner"), + operation="browser_cookies_import", + outcome="allowed", + source="browser_api", + resources=audited_resources, + ) + return web.json_response({"ok": True, "summary": summary, "hot_load": hot_load}) + + +async def api_browser_cookies_clear(request: web.Request) -> web.Response: + """DELETE /api/browser/cookies -- remove the imported cookies. + + Deletes the storageState file (so the ``SessionWatcher`` has nothing to + inject into a new session and forgets what it injected, so a later import + reaches every session again). Then, best effort, clears the cookies of every + browser session that is ALREADY open (``cookie-clear`` over its control + socket): + deleting the file alone would leave a live session signed in until it + closed, which is not what "clear" says. Owner-only, refused in a restricted + session, and SEL-audited. Serialised with the import under one lock so an + overlapping POST cannot re-inject between the delete and the live clear. + + Responses: 200 ``{"ok": true, "present": false, "live": {"cleared": [...], + "failed": {...}}}``; 403 non-owner. + """ + denied = _deny_non_owner_browser_request(request, "browser_cookies_clear") + if denied is not None: + return denied + state: DashboardState = request.app["state"] + if _is_restricted_session(state, request): + return _restricted_cookie_denial(request, "browser_cookies_clear") + + def _clear() -> dict[str, Any]: + with browser_cli_cookies.transaction(): + browser_cli_cookies.clear_storage_state() + return browser_cli_cookies.clear_live_sessions() + + async with _get_cookie_lock(): + live = await asyncio.to_thread(_clear) + _sel().log_api_access( + caller=str(request.get("user") or "owner"), + operation="browser_cookies_clear", + outcome="allowed", + source="browser_api", + resources=request.path, + ) + return web.json_response({"ok": True, "present": False, "live": live}) + + +def _restricted_cookie_denial(request: web.Request, operation: str) -> web.Response: + """403 for a cookie route reached from a restricted session, audited. + + A restricted (incognito/temporary) session must not persist a logged-in + browser state, the same rule the other credential writes follow -- nor + learn which sites the stored one unlocks, which is why the status read + shares this denial. + """ + _sel().log_api_access( + caller=str(request.get("user") or "owner"), + operation=operation, + outcome="denied", + source="browser_api", + resources=request.path, + error="browser cookies are not available from a restricted session", + ) + return web.json_response( + {"error": "not available in a restricted session", "code": "restricted_session"}, + status=403, + ) + + async def api_browser_open(request: web.Request) -> web.Response: """POST /api/browser/open -- show a URL the owner typed in the gateway's browser. diff --git a/src/kiro_crew/dashboard/server.py b/src/kiro_crew/dashboard/server.py index 06292e0067f..d91893c3e6a 100644 --- a/src/kiro_crew/dashboard/server.py +++ b/src/kiro_crew/dashboard/server.py @@ -37,6 +37,7 @@ from kiro_crew.apps.manager import cleanup_migrated_builtin, register_builtin_apps from kiro_crew.autonudge import get_instance as _autonudge_get # noqa: F401 from kiro_crew.autonudge_authz import authorize_and_add_nudge # noqa: F401 +from kiro_crew.browser_cli import cookies as browser_cli_cookies # noqa: F401 from kiro_crew.browser_cli import launch as browser_cli_launch from kiro_crew.browser_cli import launcher as browser_cli_launcher # noqa: F401 from kiro_crew.browser_cli import snapshots as browser_cli_snapshots @@ -228,6 +229,7 @@ ) from kiro_crew.dashboard.server_runtime.browser_lifecycle import ( # noqa: F401 _prune_browser_snapshots_loop, + _register_browser_cookie_watcher, _register_browser_install_cleanup, _register_browser_view_cleanup, ) @@ -1149,6 +1151,7 @@ async def _contrib_shutdown(app_: web.Application) -> None: # Install cleanup stays first, before browser relay/session shutdown. _register_browser_install_cleanup(app, state) _register_browser_view_cleanup(app, state) + _register_browser_cookie_watcher(app, state) _register_connections_warm_lifecycle(app, state) _register_workflow_lifecycle(app, state) _register_crewmate_prune_gate(app, state) diff --git a/src/kiro_crew/dashboard/server_runtime/api_entrypoint.py b/src/kiro_crew/dashboard/server_runtime/api_entrypoint.py index 51fcbdc8963..45c8dddea4e 100644 --- a/src/kiro_crew/dashboard/server_runtime/api_entrypoint.py +++ b/src/kiro_crew/dashboard/server_runtime/api_entrypoint.py @@ -49,6 +49,7 @@ _note_listener_sidecar, _precompute_telemetry, _reconcile_listener_publication, + _register_browser_cookie_watcher, _register_browser_install_cleanup, _register_config_watch, _register_connections_warm_lifecycle, @@ -357,6 +358,9 @@ async def start_api_server( _register_prevent_sleep_shutdown(app, state) _register_listener_guard_shutdown(app, state) _register_browser_install_cleanup(app, state) + # Same cookie watcher as start_dashboard: cookies imported before a headless + # restart must still reach the browsers this gateway's agents open. + _register_browser_cookie_watcher(app, state) _register_connections_warm_lifecycle(app, state) _register_workflow_lifecycle(app, state) diff --git a/src/kiro_crew/dashboard/server_runtime/browser_lifecycle.py b/src/kiro_crew/dashboard/server_runtime/browser_lifecycle.py index f92c2f0d5ec..81eecf92c39 100644 --- a/src/kiro_crew/dashboard/server_runtime/browser_lifecycle.py +++ b/src/kiro_crew/dashboard/server_runtime/browser_lifecycle.py @@ -1,7 +1,8 @@ """The browser CLI's lifecycle around the gateway. -The loop that keeps the snapshot directory bounded, and the cleanup hooks that stop a -browser install and the CLI dashboard process when the gateway shuts down. +The loop that keeps the snapshot directory bounded, the imported-cookies watcher, and +the cleanup hooks that stop a browser install and the CLI dashboard process when the +gateway shuts down. """ from __future__ import annotations @@ -14,6 +15,7 @@ if TYPE_CHECKING: from kiro_crew.dashboard.server import ( DashboardState, + browser_cli_cookies, browser_cli_launcher, browser_cli_snapshots, browser_cli_view, @@ -141,3 +143,41 @@ async def _browser_view_shutdown(app_: web.Application) -> None: app.on_startup.append(_browser_sessions_startup) app.on_cleanup.append(_browser_view_shutdown) + + +def _register_browser_cookie_watcher(app: web.Application, state: DashboardState) -> None: + """Start the imported-cookies watcher after startup and stop it on cleanup. + + The ``SessionWatcher`` (``browser_cli.cookies``) is the gateway-side thread + that injects imported cookies into agent browser daemons that appear after + the import, over their control sockets. One per gateway, a daemon thread + entirely off the event loop; the app only starts and stops it. Starting it + is a no-op until a state file exists (the first import starts it), so a + gateway that never imports cookies runs no thread. + + Both entrypoints register it, the dashboard and the headless API server: a + gateway restarted headless after an import must keep reaching new browsers + without a second import. The start runs in a background task, so nothing + before the port bind waits on a thread hop. Registered BEFORE + ``runner.setup()`` freezes the app's signal lists. + """ + + async def _cookie_watcher_startup(app_: web.Application) -> None: + async def _start() -> None: + try: + await asyncio.to_thread(browser_cli_cookies.start_session_watcher) + except Exception: # noqa: BLE001 - startup must not raise + logger.debug("browser cookie watcher failed to start", exc_info=True) + + task = asyncio.create_task(_start()) + state._background_tasks.add(task) + task.add_done_callback(state._background_tasks.discard) + + async def _cookie_watcher_shutdown(app_: web.Application) -> None: + try: + await asyncio.to_thread(browser_cli_cookies.stop_session_watcher) + except Exception: # noqa: BLE001 - shutdown must not raise + logger.debug("browser cookie watcher stop failed during shutdown", exc_info=True) + + app.on_startup.append(_cookie_watcher_startup) + app.on_cleanup.append(_cookie_watcher_shutdown) diff --git a/src/kiro_crew/dashboard/server_runtime/mcp_routes.py b/src/kiro_crew/dashboard/server_runtime/mcp_routes.py index 76074e47414..f739b525d4d 100644 --- a/src/kiro_crew/dashboard/server_runtime/mcp_routes.py +++ b/src/kiro_crew/dashboard/server_runtime/mcp_routes.py @@ -494,6 +494,14 @@ def _register_mcp_routes(app: web.Application) -> None: app.router.add_post("/api/browser/engine", handlers.api_browser_engine_install) app.router.add_get("/api/browser/view", handlers.api_browser_view_get) app.router.add_post("/api/browser/view/start", handlers.api_browser_view_start) + # Imported cookies: the owner exports a logged-in browser's cookies and + # imports them here; the gateway stores them as a Playwright storageState and + # applies them to the agent's playwright-cli sessions. Owner-only (a browser + # credential write) and refused in a restricted session, like the view/token + # routes -- see each handler's guard. + app.router.add_get("/api/browser/cookies", handlers.api_browser_cookies_get) + app.router.add_post("/api/browser/cookies", handlers.api_browser_cookies_import) + app.router.add_delete("/api/browser/cookies", handlers.api_browser_cookies_clear) # Same-origin relay for the CLI browser view: the panel frames this path # instead of the raw loopback URL, so the live view is reachable wherever # the dashboard is (SSH forward, tunnel) with no second forwarded port. diff --git a/src/kiro_crew/docs/browser-control.md b/src/kiro_crew/docs/browser-control.md index 9d26b1fb36a..6f4b4000082 100644 --- a/src/kiro_crew/docs/browser-control.md +++ b/src/kiro_crew/docs/browser-control.md @@ -103,6 +103,28 @@ itself (`--with-deps`), and retries without that step when the host refuses it. Treat an attached browser as borrowed. The agent should not navigate a tab away from what you were doing, and closing it would take your own windows with it. +## Import cookies + +The Live view has an **Import cookies** button. You export the cookies from the +browser where you are already logged in — a Playwright storageState file, a +Cookie-Editor / "Get cookies.txt" export, or a Netscape `cookies.txt` — and paste +or upload them. The gateway stores them and applies them to the agent's own +browser sessions, so a fresh session starts already logged in. This is how a +logged-in session reaches a gateway running on a different machine from your +laptop. + +The cookie values are saved only on the computer running Kiro Crew and are +never shown back to you in the dashboard, and +the agent never reads the stored file — the gateway hands the cookies to the +agent's browser over that browser's own control socket, one `cookie-set` request +per cookie, so the browser receives them as data and opens no file. A session that +is already open receives them at import time (best effort; the panel reports any +session that could not be reached). A session the agent opens later receives them +within about two seconds of its browser starting, so the very first page load of +a brand-new session can arrive before its cookies do; the next one has them. +Clear them at any time from the same control: the stored file is removed and +every open session's cookies are cleared too. + ## Related docs - [Dashboard](dashboard.md): the side panel and where its tabs live diff --git a/src/kiro_crew/sandbox_leaves.py b/src/kiro_crew/sandbox_leaves.py index 0e6624582fb..05f0a5eca3f 100644 --- a/src/kiro_crew/sandbox_leaves.py +++ b/src/kiro_crew/sandbox_leaves.py @@ -237,11 +237,16 @@ # sandboxed process. _MD_NOTEBOOK_STAGING_LEAF, # Browser session material. The extension token reaches the CLI through the - # environment, never by ``open()``, so masking the file costs nothing; the other - # four are retired leaves with no reader left in the tree. The LIVE browser paths - # (``browser-state``, ``playwright-snapshots``, ``pw``, ``playwright-cli-config.json``) - # are deliberately absent from the sensitive list and stay fully visible. + # environment, never by ``open()``, so masking the file costs nothing; the + # imported-cookies directory (``browser-cookie-import``) holds live + # session cookies and is read ONLY by the gateway, which hands them to the + # agent's daemon over its control socket (``browser_cli/cookies.py``), so the + # agent's own shell never needs to read it; the other four are retired leaves + # with no reader left in the tree. The LIVE browser paths (``browser-state``, + # ``playwright-snapshots``, ``pw``, ``playwright-cli-config.json``) are + # deliberately absent from the sensitive list and stay fully visible. "browser-cookies.txt", + "browser-cookie-import", "playwright-storage-state.json", "playwright-extension-token", "browser-mode-enabled", diff --git a/src/kiro_crew/sandbox_precreate.py b/src/kiro_crew/sandbox_precreate.py index 4a24d8fceda..c910715e80c 100644 --- a/src/kiro_crew/sandbox_precreate.py +++ b/src/kiro_crew/sandbox_precreate.py @@ -422,6 +422,11 @@ "file-delivery-consent-pending", "appearance-library", "quarantined-clones", + # The imported browser cookies (``browser_cli/cookies.py``), created on the + # owner's first import. Left to lazy creation, a sandbox spawned before then + # finds it absent and the live session cookies written later appear unmasked + # INSIDE that running sandbox. Only the gateway reads it; empty means "none". + "browser-cookie-import", # First created by the first member-born session's vouch, so a sandbox spawned # before then would otherwise see the directory appear unmasked. "vouched-executions", diff --git a/src/kiro_crew/security/paths_lists.py b/src/kiro_crew/security/paths_lists.py index d25d52347d0..63b1432c7f2 100644 --- a/src/kiro_crew/security/paths_lists.py +++ b/src/kiro_crew/security/paths_lists.py @@ -268,6 +268,7 @@ def _leaf_segments(spec: str) -> list[str]: "quarantined-clones", "vouched-executions", # member-store vouches; also sandbox-masked "browser-cookies.txt", + "browser-cookie-import", # imported cookies (browser_cli/cookies.py) "playwright-storage-state.json", # The refused-inbound spool (messaging/inbound_spool.py). Not a secret: it is # an OUTBOUND SOURCE. Each entry names a conversation and carries text the diff --git a/test/test_browser_cli_journeys.py b/test/test_browser_cli_journeys.py index 12a8eb7f38b..c5292371a2f 100644 --- a/test/test_browser_cli_journeys.py +++ b/test/test_browser_cli_journeys.py @@ -63,16 +63,23 @@ def test_the_package_has_exactly_one_destructive_operation(self): # a config write, this fails and the reviewer has to justify it rather than # discovering it from a support report. # - # TWO writes are sanctioned, named by module so a SECOND write in either - # still fails here: - # token.py -- persists the optional attach token, which has to survive a - # restart to be worth configuring. - # launch.py -- generates Kiro Crew's OWN launch config under the data - # home, naming the engine the product installs. It never - # writes, reads, or supersedes the operator's - # .playwright/cli.config.json; see - # test_the_operator_config_path_is_never_written. - sanctioned = {"token.py", "launch.py"} + # THREE writes are sanctioned, named by module so a SECOND write in any of + # them still fails here: + # token.py -- persists the optional attach token, which has to survive a + # restart to be worth configuring. + # launch.py -- generates Kiro Crew's OWN launch config under the data + # home, naming the engine the product installs. It never + # writes, reads, or supersedes the operator's + # .playwright/cli.config.json; see + # test_the_operator_config_path_is_never_written. + # cookies.py -- persists the storage state the dashboard owner imported + # (Browser panel > Import cookies) under the data home, + # 0600. That is its ONLY write: no config names the file + # (it is masked from every agent sandbox, and the agent's + # daemon must never be pointed at it); the cookies reach a + # daemon over its control socket as ``cookie-set`` data. + # Kiro Crew's own file, not the operator's CLI config. + sanctioned = {"token.py", "launch.py", "cookies.py"} pkg = Path(install.__file__).parent writes = [] for source in sorted(pkg.glob("*.py")): @@ -929,6 +936,14 @@ def test_every_browser_route_has_a_deliberate_app_token_stance(): # request input, owner-only, and it refuses internal-secret callers too # (agent browsing must stay behind the shell approval ladder). "api_browser_open": "owner", + # Imported browser cookies are a credential: the summary names the + # sites they unlock, the import writes them, the clear removes them. + # All three owner-only; import and clear also refuse restricted + # (incognito/temporary) sessions and are SEL-audited (count + domains, + # never values). + "api_browser_cookies_get": "owner", + "api_browser_cookies_import": "owner", + "api_browser_cookies_clear": "owner", # Presence/version reporting only. No credential, no URL, no mutation -- # and an app that cannot read it cannot tell "absent" from "broken". "api_browser_install_get": "open", diff --git a/test/test_browser_cli_launch.py b/test/test_browser_cli_launch.py index 0e2c492e21b..b9a942ecd55 100644 --- a/test/test_browser_cli_launch.py +++ b/test/test_browser_cli_launch.py @@ -409,6 +409,28 @@ def test_the_session_name_does_not_travel_as_extra_env() -> None: assert "browser_session_env" in source +def test_lifecycle_paths_are_read_only_and_silent( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + home = tmp_path / "uncreated-home" + monkeypatch.setattr(mod, "peek_data_home", lambda: home) + monkeypatch.setattr(mod, "_UNIX_SOCKET_PATH_MAX_BYTES", 10_000) + monkeypatch.setattr(mod, "cli_lifecycle_env_support", lambda: (mod.SeamSupport.SUPPORTED, "")) + env = {mod.SESSION_ENV: "kc-a1b2c3d4"} + assert mod._lifecycle_paths(env) == ( + home / "pw" / "a1b2c3d4" / "s", + home / "pw" / "a1b2c3d4" / "d", + ) + assert not home.exists() + assert mod._lifecycle_paths({**env, mod.SOCKETS_ENV: "relative"}) is None + assert mod._lifecycle_paths({mod.SESSION_ENV: "operator"}) is None + monkeypatch.setattr(mod, "_UNIX_SOCKET_PATH_MAX_BYTES", 1) + monkeypatch.setattr(mod.platform_compat, "IS_WINDOWS", False) + assert mod._lifecycle_paths(env) is None + assert _lifecycle_warnings(caplog) == [] + assert not home.exists() + + def test_browser_socket_env_prepares_stable_owner_only_dirs( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -471,6 +493,7 @@ def test_browser_socket_env_ignores_an_inherited_root( """ home = tmp_path / "home" monkeypatch.setattr(mod, "config_dir", lambda: home) + monkeypatch.setattr(mod, "peek_data_home", lambda: home) monkeypatch.setattr(mod.platform_compat, "make_owner_only_dir", lambda _path: None) monkeypatch.setattr(mod.platform_compat, "restrict_dir_to_owner", lambda _path: None) monkeypatch.setattr(mod, "_UNIX_SOCKET_PATH_MAX_BYTES", 10_000) @@ -499,6 +522,7 @@ def test_nesting_cannot_deepen_however_long_the_chain( """ home = tmp_path / "home" monkeypatch.setattr(mod, "config_dir", lambda: home) + monkeypatch.setattr(mod, "peek_data_home", lambda: home) monkeypatch.setattr(mod.platform_compat, "make_owner_only_dir", lambda _path: None) monkeypatch.setattr(mod.platform_compat, "restrict_dir_to_owner", lambda _path: None) monkeypatch.setattr(mod, "_UNIX_SOCKET_PATH_MAX_BYTES", 10_000) @@ -520,6 +544,7 @@ def test_a_foreign_configured_root_is_still_honoured( home = tmp_path / "home" elsewhere = tmp_path / "operator-chosen" monkeypatch.setattr(mod, "config_dir", lambda: home) + monkeypatch.setattr(mod, "peek_data_home", lambda: home) monkeypatch.setattr(mod.platform_compat, "make_owner_only_dir", lambda _path: None) monkeypatch.setattr(mod.platform_compat, "restrict_dir_to_owner", lambda _path: None) monkeypatch.setattr(mod, "_UNIX_SOCKET_PATH_MAX_BYTES", 10_000) @@ -546,6 +571,7 @@ def test_browser_socket_env_ignores_an_inherited_root_from_another_home( parent_home = tmp_path / "parent-home" child_home = tmp_path / "pod-home" monkeypatch.setattr(mod, "config_dir", lambda: child_home) + monkeypatch.setattr(mod, "peek_data_home", lambda: child_home) monkeypatch.setattr(mod.platform_compat, "make_owner_only_dir", lambda _path: None) monkeypatch.setattr(mod.platform_compat, "restrict_dir_to_owner", lambda _path: None) monkeypatch.setattr(mod, "_UNIX_SOCKET_PATH_MAX_BYTES", 10_000) diff --git a/test/test_browser_cookies.py b/test/test_browser_cookies.py new file mode 100644 index 00000000000..12b12fd0613 --- /dev/null +++ b/test/test_browser_cookies.py @@ -0,0 +1,1704 @@ +"""Backend tests for the imported-cookies feature. + +Covers the three parser shapes and their normalisation (including the strict +``secure``/``httpOnly`` boolean parsing), expiry dropping and the size/count +limits, the owner-only 0600 storageState write, the engine-only launch config, +the value-free summary, the socket-shaped live-session enumeration, the +``cookie-set`` request the gateway writes to a daemon socket (and that no +process is spawned for it), the best-effort injection and clear and their +value-free failure reporting, the +``SessionWatcher`` (new sockets, re-import, reset, bounds, thread lifecycle), +and the three HTTP handlers (status, import, clear) through the aiohttp client +fixture including the non-owner 403, the restricted-session 403, and the shared +lock that serialises an overlapping import and clear. + +No real ``playwright-cli`` is ever spawned or connected to: the daemon socket +is faked at ``_connect`` (one test drives a real ``socketpair``), for the +``cookie-set`` of an import and the ``cookie-clear`` of a clear alike. +""" + +from __future__ import annotations + +import asyncio +import json +import os +import socket +import stat +import threading +import time +from pathlib import Path +from typing import Any, Iterator +from unittest.mock import MagicMock, patch + +import pytest +from aiohttp import web +from aiohttp.test_utils import TestClient, TestServer +from dashboard_owner_helpers import as_owner +from tmpdir_helpers import SHORT_TMP_PREFIX, short_tmp_base + +from kiro_crew.browser_cli import cookies as mod +from kiro_crew.browser_cli import launch as launch_mod +from kiro_crew.platform_compat import IS_WINDOWS +from kiro_crew.testing.wait import async_wait_until, until_parked + +#: Injection speaks to the daemon over an ``AF_UNIX`` stream socket; where the +#: platform has none (Windows CPython), :func:`mod._inject_into_session` answers +#: ``unsupported platform`` and the watcher never starts, so the tests that drive +#: a real injection have no subject there. +needs_af_unix = pytest.mark.skipif( + not hasattr(socket, "AF_UNIX"), reason="cookie injection needs AF_UNIX sockets" +) + + +@pytest.fixture() +def home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[Path]: + h = tmp_path / "home" + monkeypatch.setenv("KIROCREW_HOME", str(h)) + # A host where the launch puts agent sockets under the scanned roots: a + # generated session name and a CLI that honours the lifecycle hooks. The + # tests that pin the other hosts override these. + monkeypatch.delenv(launch_mod.SESSION_ENV, raising=False) + monkeypatch.delenv(launch_mod.SOCKETS_ENV, raising=False) + monkeypatch.delenv(launch_mod.DAEMON_DIR_ENV, raising=False) + monkeypatch.setattr( + launch_mod, "cli_lifecycle_env_support", lambda: (launch_mod.SeamSupport.SUPPORTED, "") + ) + # Keep ordinary cases independent of pytest's long temporary-directory names. + monkeypatch.setattr(launch_mod, "_UNIX_SOCKET_PATH_MAX_BYTES", 10_000) + # The module-level watcher is shared across tests; start each from a clean + # record and never leave a thread running. + mod._WATCHER.stop() + mod._WATCHER.reset() + yield h + mod._WATCHER.stop() + mod._WATCHER.reset() + + +# ── parse_cookie_import: the three shapes ── + + +class TestParseShapes: + def test_playwright_storage_state(self, home: Path) -> None: + text = json.dumps( + { + "cookies": [{"name": "sid", "value": "v", "domain": "example.com", "path": "/"}], + "origins": [], + } + ) + cookies = mod.parse_cookie_import(text) + assert len(cookies) == 1 + assert cookies[0]["name"] == "sid" + assert cookies[0]["domain"] == "example.com" + + def test_bare_json_array_extension_export(self, home: Path) -> None: + text = json.dumps( + [ + { + "name": "auth", + "value": "tok", + "domain": ".example.com", + "path": "/app", + "expirationDate": 9999999999, + "sameSite": "no_restriction", + "secure": True, + "httpOnly": True, + } + ] + ) + cookies = mod.parse_cookie_import(text) + assert cookies[0]["expires"] == 9999999999.0 + assert cookies[0]["sameSite"] == "None" + assert cookies[0]["secure"] is True + assert cookies[0]["httpOnly"] is True + + def test_netscape_cookies_txt(self, home: Path) -> None: + text = ( + "# Netscape HTTP Cookie File\n" + "#HttpOnly_.example.com\tTRUE\t/\tTRUE\t9999999999\tsid\tsecret\n" + "example.org\tFALSE\t/\tFALSE\t0\tplain\tv\n" + ) + cookies = mod.parse_cookie_import(text) + assert len(cookies) == 2 + http_only = next(c for c in cookies if c["name"] == "sid") + assert http_only["httpOnly"] is True + assert http_only["secure"] is True + assert http_only["domain"] == ".example.com" + plain = next(c for c in cookies if c["name"] == "plain") + # `0` expiry means a session cookie -> normalised to -1. + assert plain["expires"] == -1.0 + + def test_netscape_cookie_with_an_empty_value_is_kept(self, home: Path) -> None: + """An empty value is a trailing tab, which curl and the export extensions + write; stripping the whole line would leave six columns and drop it.""" + text = ( + ".example.com\tTRUE\t/\tTRUE\t0\tflag\t\r\n" + "#HttpOnly_.example.com\tTRUE\t/\tTRUE\t0\tmarker\t\n" + ) + cookies = mod.parse_cookie_import(text) + assert [(c["name"], c["value"]) for c in cookies] == [("flag", ""), ("marker", "")] + assert cookies[1]["httpOnly"] is True + + +class TestNormalisation: + @pytest.mark.parametrize( + "raw, expected", + [ + ("Strict", "Strict"), + ("lax", "Lax"), + ("no_restriction", "None"), + ("none", "None"), + ("unspecified", "Lax"), + ("garbage", "Lax"), + (None, "Lax"), + ], + ) + def test_same_site(self, home: Path, raw: object, expected: str) -> None: + text = json.dumps([{"name": "n", "domain": "d.com", "sameSite": raw}]) + assert mod.parse_cookie_import(text)[0]["sameSite"] == expected + + def test_missing_expiry_is_session_cookie(self, home: Path) -> None: + text = json.dumps([{"name": "n", "domain": "d.com"}]) + assert mod.parse_cookie_import(text)[0]["expires"] == -1.0 + + @pytest.mark.parametrize( + "raw, expected", + [ + (True, True), + (False, False), + (None, False), + ("true", True), + ("TRUE", True), + (" True ", True), + ("1", True), + ("false", False), + ("False", False), + ("0", False), + ], + ) + def test_boolean_flags_accept_booleans_and_their_string_spellings( + self, home: Path, raw: object, expected: bool + ) -> None: + # Cookie-Editor sometimes exports "secure": "true" as a STRING; bool("false") + # would be True, so the strings are parsed, not coerced. + text = json.dumps([{"name": "n", "domain": "d.com", "secure": raw, "httpOnly": raw}]) + cookie = mod.parse_cookie_import(text)[0] + assert cookie["secure"] is expected + assert cookie["httpOnly"] is expected + + @pytest.mark.parametrize("raw", ["yes", "no", "", "2", 1, 0, 1.5, [], {}]) + @pytest.mark.parametrize("field", ["secure", "httpOnly"]) + def test_other_boolean_spellings_are_rejected( + self, home: Path, raw: object, field: str + ) -> None: + text = json.dumps([{"name": "n", "domain": "d.com", field: raw}]) + with pytest.raises(mod.CookieImportError, match=f"invalid {field} value"): + mod.parse_cookie_import(text) + + @pytest.mark.parametrize("raw", ["inf", "-inf", "nan", "Infinity", "NaN"]) + def test_non_finite_expiry_string_is_session_cookie(self, home: Path, raw: str) -> None: + # A non-finite float would be written verbatim into the storage state and + # make it unreadable to the daemon's strict JSON parser. + text = json.dumps([{"name": "n", "domain": "d.com", "expirationDate": raw}]) + assert mod.parse_cookie_import(text)[0]["expires"] == -1.0 + + @pytest.mark.parametrize("constant", ["NaN", "Infinity", "-Infinity"]) + def test_json_constants_are_rejected(self, home: Path, constant: str) -> None: + text = f'[{{"name": "n", "domain": "d.com", "expirationDate": {constant}}}]' + with pytest.raises(mod.CookieImportError, match="invalid JSON constant"): + mod.parse_cookie_import(text) + + def test_deeply_nested_json_is_a_user_error(self, home: Path) -> None: + # json.loads recurses per nesting level; the handler must see a + # CookieImportError (400), never a RecursionError (500). + depth = 100_000 + text = "[" * depth + "]" * depth + with pytest.raises(mod.CookieImportError, match="nested too deeply|not valid JSON"): + mod.parse_cookie_import(text) + + def test_expired_cookie_dropped(self, home: Path) -> None: + past = time.time() - 3600 + text = json.dumps( + [ + {"name": "old", "domain": "d.com", "expirationDate": past}, + {"name": "live", "domain": "d.com"}, + ] + ) + cookies = mod.parse_cookie_import(text) + assert [c["name"] for c in cookies] == ["live"] + + +class TestLimits: + def test_over_size_limit_rejected(self, home: Path) -> None: + big = "x" * (mod.MAX_IMPORT_BYTES + 1) + with pytest.raises(mod.CookieImportError, match="too large"): + mod.parse_cookie_import(big) + + def test_over_cookie_count_rejected(self, home: Path) -> None: + many = [{"name": f"n{i}", "domain": "d.com"} for i in range(mod.MAX_COOKIES + 1)] + with pytest.raises(mod.CookieImportError, match="too many"): + mod.parse_cookie_import(json.dumps(many)) + + def test_missing_name_rejected(self, home: Path) -> None: + with pytest.raises(mod.CookieImportError, match="name"): + mod.parse_cookie_import(json.dumps([{"domain": "d.com"}])) + + def test_missing_domain_rejected(self, home: Path) -> None: + with pytest.raises(mod.CookieImportError, match="domain"): + mod.parse_cookie_import(json.dumps([{"name": "n"}])) + + def test_empty_rejected(self, home: Path) -> None: + with pytest.raises(mod.CookieImportError, match="empty"): + mod.parse_cookie_import(" ") + + def test_all_expired_rejected(self, home: Path) -> None: + text = json.dumps([{"name": "old", "domain": "d.com", "expirationDate": 1.0}]) + with pytest.raises(mod.CookieImportError, match="no unexpired"): + mod.parse_cookie_import(text) + + def test_unrecognisable_rejected(self, home: Path) -> None: + with pytest.raises(mod.CookieImportError): + mod.parse_cookie_import("this is not json or a cookies.txt") + + +# ── save / summary / clear ── + + +class TestSaveAndSummary: + def test_save_writes_owner_only_storage_state(self, home: Path) -> None: + cookies = mod.parse_cookie_import( + json.dumps([{"name": "n", "value": "v", "domain": "d.com"}]) + ) + path = mod.save_storage_state(cookies) + assert path == mod.storage_state_path() + data = json.loads(path.read_text(encoding="utf-8")) + assert data == {"cookies": cookies, "origins": []} + if not IS_WINDOWS: + assert stat.S_IMODE(os.stat(path).st_mode) == 0o600 + + def test_summary_has_no_values(self, home: Path) -> None: + cookies = mod.parse_cookie_import( + json.dumps( + [ + {"name": "a", "value": "SECRET", "domain": ".example.com"}, + { + "name": "b", + "value": "TOKEN", + "domain": "other.com", + "expirationDate": 9999999999, + }, + ] + ) + ) + mod.save_storage_state(cookies) + summary = mod.storage_state_summary() + assert summary is not None + assert summary["cookie_count"] == 2 + # Leading dot stripped, sorted, distinct. + assert summary["domains"] == ["example.com", "other.com"] + assert summary["earliest_expiry"] == 9999999999.0 + assert "imported_at" in summary + blob = json.dumps(summary) + assert "SECRET" not in blob and "TOKEN" not in blob + + def test_summary_none_when_absent(self, home: Path) -> None: + assert mod.storage_state_summary() is None + + def test_summary_session_only_earliest_is_none(self, home: Path) -> None: + cookies = mod.parse_cookie_import(json.dumps([{"name": "n", "domain": "d.com"}])) + mod.save_storage_state(cookies) + summary = mod.storage_state_summary() + assert summary is not None + assert summary["earliest_expiry"] is None + + def test_clear_removes_file(self, home: Path) -> None: + cookies = mod.parse_cookie_import(json.dumps([{"name": "n", "domain": "d.com"}])) + mod.save_storage_state(cookies) + assert mod.clear_storage_state() is True + assert not mod.storage_state_path().exists() + assert mod.clear_storage_state() is False + + +# ── One config: the agent's launch config never names the masked file ── + + +class TestLaunchConfig: + def test_agent_config_is_engine_only_without_file(self, home: Path) -> None: + assert launch_mod.desired_config() == {"browser": {"browserName": "chromium"}} + + def test_agent_config_never_names_the_state_even_when_present(self, home: Path) -> None: + """The agent's daemon runs inside the sandbox that masks the file: naming it + there would make every browse fail on ENOENT, so the key must never appear.""" + mod.save_storage_state( + mod.parse_cookie_import(json.dumps([{"name": "n", "domain": "d.com"}])) + ) + assert launch_mod.desired_config() == {"browser": {"browserName": "chromium"}} + path = launch_mod.write_config() + assert path is not None + assert "storageState" not in path.read_text(encoding="utf-8") + + def test_no_gateway_side_config_or_prewarm_exists(self, home: Path) -> None: + """Design A, invariants 1 and 2: the gateway never starts a daemon for an + agent session and writes no config naming the hidden file. A revival of + either would reopen the sandbox escape the redesign closed.""" + for name in ( + "prewarm_session", + "_run_prewarm", + "gateway_config", + "gateway_config_path", + "write_gateway_config", + "_GATEWAY_CONFIG_FILE", + "hot_load_into_live_sessions", + ): + assert not hasattr(mod, name), name + mod.save_storage_state( + mod.parse_cookie_import(json.dumps([{"name": "n", "domain": "d.com"}])) + ) + leaves = sorted(p.name for p in home.iterdir()) + assert leaves == [mod.STORAGE_STATE_DIR] + assert [p.name for p in (home / mod.STORAGE_STATE_DIR).iterdir()] == [ + mod.STORAGE_STATE_FILE + ] + + +# ── The state file is masked from every agent sandbox ── + + +def test_storage_state_leaf_is_hidden_and_tool_gated() -> None: + from kiro_crew import sandbox, security + + assert mod.STORAGE_STATE_DIR in sandbox._CREW_HIDDEN_LEAVES + assert mod.STORAGE_STATE_DIR in security._CREW_SECRET_LEAVES + inside = f"~/.kiro/crew/{mod.STORAGE_STATE_DIR}/{mod.STORAGE_STATE_FILE}" + assert security.is_sensitive_path(inside) is True + + +def test_storage_state_dir_is_precreated_so_its_mask_always_mounts() -> None: + """A mask over an absent name is skipped at spawn, so a sandbox started before + the first import would see the cookies written later. The directory is made + before every spawn instead, and the file lives inside it.""" + from kiro_crew import sandbox + + assert mod.STORAGE_STATE_DIR in sandbox._CREW_PRECREATE_HIDDEN_DIR_LEAVES + assert mod.storage_state_path().parent.name == mod.STORAGE_STATE_DIR + + +# ── Live-session enumeration by lifecycle-root shape ── + + +def _make_unix_socket(path: Path) -> Path: + """Leave a real (unlistened) ``AF_UNIX`` socket file at *path*. + + Discovery accepts only socket inodes, so a plain file cannot stand in. The + socket is bound by its bare name from inside its directory, because + ``tmp_path`` can exceed the AF_UNIX path budget. + """ + if not hasattr(socket, "AF_UNIX"): + pytest.skip("no AF_UNIX on this platform") + cwd = os.getcwd() + os.chdir(path.parent) + try: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s: + s.bind(path.name) + finally: + os.chdir(cwd) + return path + + +def _fake_socket(home: Path, leaf: str, name: str = "0123456789abcdef") -> Path: + cli = home / "pw" / leaf / "s" / "cli" + cli.mkdir(parents=True, exist_ok=True) + return _make_unix_socket(cli / f"{name}.sock") + + +def _key(sock: Path) -> mod.SocketKey: + st = sock.stat() + return (str(sock), st.st_ino, st.st_ctime_ns) + + +class TestLiveSessions: + def test_sockets_under_generated_roots_are_sessions(self, home: Path) -> None: + a = _fake_socket(home, "aaaa1111") + b = _fake_socket(home, "bbbb2222") + (home / "pw" / "cccc3333" / "s" / "cli").mkdir(parents=True) # no socket + (home / "pw" / "ui" / "s" / "cli").mkdir(parents=True) # the gateway's own root + (home / "pw" / "ui" / "s" / "cli" / "x-panel.sock").write_bytes(b"") + sockets = mod._live_sockets() + assert sockets == {"kc-aaaa1111": _key(a), "kc-bbbb2222": _key(b)} + + def test_every_named_browser_of_a_session_is_its_own_entry(self, home: Path) -> None: + """An agent that opens a second named browser (``-s=``) gets a second + socket under the same session root. Both are returned under distinct + names, so an import or a clear cannot reach only the newer one.""" + first = _fake_socket(home, "aaaa1111", "first") + second = _fake_socket(home, "aaaa1111", "second") + only = _fake_socket(home, "bbbb2222") + assert mod._live_sockets() == { + "kc-aaaa1111/first": _key(first), + "kc-aaaa1111/second": _key(second), + "kc-bbbb2222": _key(only), + } + + def test_one_stem_under_two_roots_is_two_entries( + self, home: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A session started before and after the operator root was set holds a + socket of the same stem under each root; neither may overwrite the other.""" + operator = tmp_path / "operator-root" + monkeypatch.setenv(launch_mod.SOCKETS_ENV, str(operator)) + default = _fake_socket(home, "aaaa1111") + cli = launch_mod.socket_dir("kc-aaaa1111", operator) / "cli" + cli.mkdir(parents=True) + moved = _make_unix_socket(cli / default.name) + assert sorted(mod._live_sockets().values()) == sorted([_key(default), _key(moved)]) + + def test_socket_key_is_path_inode_and_ctime(self, home: Path) -> None: + """A daemon restart makes a NEW socket file; the key must change with it + so the watcher injects into the new daemon rather than trusting the path.""" + sock = _fake_socket(home, "aaaa1111") + first = mod._live_sockets()["kc-aaaa1111"] + # Swap a NEW file in under the same path while the old one still exists, + # so the two inodes are distinct by construction. + fresh = _make_unix_socket(sock.with_name("fresh.tmp")) + os.replace(fresh, sock) + second = mod._live_sockets()["kc-aaaa1111"] + assert first[0] == second[0] + assert first != second + + def test_an_operator_socket_root_is_scanned_as_the_launch_resolves_it( + self, home: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The agent launch honours an absolute foreign ``SOCKETS_ENV`` as the BASE + of every session's socket root, so a session started under it lives at + ``/<8hex>/s``; discovery must look there as well as under the + default root, or an import and the watcher never reach that browser.""" + operator = tmp_path / "operator-root" + monkeypatch.setenv(launch_mod.SOCKETS_ENV, str(operator)) + default = _fake_socket(home, "aaaa1111") + cli = launch_mod.socket_dir("kc-bbbb2222", operator) / "cli" + cli.mkdir(parents=True) + custom = _make_unix_socket(cli / "0123456789abcdef.sock") + assert mod._live_sockets() == {"kc-aaaa1111": _key(default), "kc-bbbb2222": _key(custom)} + + @pytest.mark.skipif(IS_WINDOWS, reason="symlinks and AF_UNIX socket files") + def test_a_symlink_or_plain_file_is_never_a_session_socket( + self, home: Path, tmp_path: Path + ) -> None: + """The session roots are writable by the agent: a symlink planted at + ``<8hex>/s/cli/a.sock`` pointing at another browser's socket (the owner's + panel browser) must not be followed, or an import or clear writes into + that browser. A plain file named ``*.sock`` is no socket either.""" + real = _fake_socket(home, "aaaa1111") + target = _make_unix_socket(tmp_path / "panel.sock") + cli = home / "pw" / "bbbb2222" / "s" / "cli" + cli.mkdir(parents=True) + (cli / "a.sock").symlink_to(target) + plain = home / "pw" / "cccc3333" / "s" / "cli" + plain.mkdir(parents=True) + (plain / "a.sock").write_bytes(b"") + assert mod._live_sockets() == {"kc-aaaa1111": _key(real)} + + @pytest.mark.parametrize("link_at", ["session", "s", "cli"]) + def test_a_symlinked_directory_on_the_way_is_never_followed( + self, home: Path, tmp_path: Path, link_at: str + ) -> None: + """The directories above the socket are agent-writable as well: a + session, ``s`` or ``cli`` directory that is a symlink into another tree + holding a real socket must not lead the walk there.""" + real = _fake_socket(home, "aaaa1111") + elsewhere = tmp_path / "elsewhere" + (elsewhere / "s" / "cli").mkdir(parents=True) + _make_unix_socket(elsewhere / "s" / "cli" / "a.sock") + session = home / "pw" / "bbbb2222" + if link_at == "session": + session.symlink_to(elsewhere) + elif link_at == "s": + session.mkdir(parents=True) + (session / "s").symlink_to(elsewhere / "s") + else: + (session / "s").mkdir(parents=True) + (session / "s" / "cli").symlink_to(elsewhere / "s" / "cli") + assert mod._live_sockets() == {"kc-aaaa1111": _key(real)} + + def test_an_inherited_session_root_is_not_an_operator_root( + self, home: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """One of our own ``/<8hex>/s`` roots arriving by inheritance is not + an operator base (the launch regenerates it), so it adds no root.""" + monkeypatch.setenv(launch_mod.SOCKETS_ENV, str(home / "pw" / "aaaa1111" / "s")) + assert mod._lifecycle_roots() == [home / "pw"] + monkeypatch.setenv(launch_mod.SOCKETS_ENV, "relative/root") + assert mod._lifecycle_roots() == [home / "pw"] + + +# ── cookie-set request: what actually crosses the daemon socket ── + + +class TestCookieSetArgs: + def test_full_cookie(self) -> None: + cookie = { + "name": "sid", + "value": "-abc=def", + "domain": ".example.com", + "path": "/", + "expires": 1900000000.0, + "httpOnly": True, + "secure": True, + "sameSite": "Strict", + } + # The object the CLI client builds from its argv with minimist and sends + # as ``params.args``: the command and its two positionals under ``_``, + # every option a key the command's strict schema declares. + assert mod._cookie_set_args(cookie) == { + "_": ["cookie-set", "sid", "-abc=def"], + "domain": ".example.com", + "path": "/", + "sameSite": "Strict", + "expires": 1900000000, + "httpOnly": True, + "secure": True, + } + + def test_session_cookie_omits_expires_and_flags(self) -> None: + cookie = {"name": "n", "value": "v", "domain": "d.com", "path": "/x", "expires": -1.0} + assert mod._cookie_set_args(cookie) == { + "_": ["cookie-set", "n", "v"], + "domain": "d.com", + "path": "/x", + "sameSite": "Lax", + } + + def test_fractional_expiry_is_kept(self) -> None: + cookie = {"name": "n", "value": "v", "domain": "d.com", "expires": 1900000000.5} + assert mod._cookie_set_args(cookie)["expires"] == 1900000000.5 + + +# ── injection over the daemon socket: best effort, value-free, never raise ── + + +def _import_cookies(home: Path, *names: str) -> list[dict]: + text = json.dumps( + [{"name": n, "value": f"secret-{n}", "domain": "d.com", "secure": True} for n in names] + ) + cookies = mod.parse_cookie_import(text) + mod.save_storage_state(cookies) + return cookies + + +class _FakeSock: + """One fake control-socket connection, answering each request line in turn.""" + + def __init__(self, daemon: "_FakeDaemon", path: str) -> None: + self.daemon = daemon + self.path = path + self.out = bytearray() + self.closed = False + + def settimeout(self, timeout: float) -> None: + assert timeout > 0 + + def sendall(self, data: bytes) -> None: + message = json.loads(data.decode("utf-8")) + self.daemon.messages.append((self.path, message)) + mode = self.daemon.modes.get(self.path, "ok") + if mode == "timeout": + raise TimeoutError("timed out") + positionals = message["params"]["args"]["_"] + value = positionals[2] if len(positionals) > 2 else "" + if mode == "refuse_a": + # Refuse only the cookie named "a" (Chromium refusing SameSite=None + # without Secure), answer every other request. + mode = "error" if positionals[1:2] == ["a"] else "ok" + if mode == "deep": + # Below the byte cap, deeper than the JSON parser recurses. + self.out += b"[" * 100_000 + b"]" * 100_000 + b"\n" + return + if mode == "error": + reply = {"id": message["id"], "error": f"cookie {value} rejected"} + elif mode == "is_error": + reply = {"id": message["id"], "result": {"isError": True, "text": value}} + elif mode == "wrong_id": + reply = {"id": message["id"] + 7, "result": {"isError": False, "text": ""}} + elif mode == "garbage": + self.out += b"not json\n" + return + else: + # The daemon's reply echoes the cookie it was handed. + reply = {"id": message["id"], "result": {"isError": False, "text": value}} + self.out += json.dumps(reply).encode("utf-8") + b"\n" + + def recv(self, size: int) -> bytes: + chunk, self.out = bytes(self.out[:size]), self.out[size:] + return chunk + + def close(self) -> None: + self.closed = True + + def __enter__(self) -> "_FakeSock": + return self + + def __exit__(self, *exc: object) -> None: + self.close() + + +class _FakeDaemon: + """Stands in for every daemon control socket (patched over ``mod._connect``). + + ``modes`` maps a socket path to how its daemon answers: ``ok`` (default), + ``error``, ``is_error``, ``refuse_a`` (refuses only cookie ``a``), + ``wrong_id``, ``garbage``, ``deep`` (a reply nested past the parser's + recursion limit), ``timeout`` (the reply never comes), ``refused`` (the + connect fails: a socket file that outlived its daemon), ``gone`` (the file + vanished before the connect), ``connect_oserror``, ``connect_timeout`` or + ``accept_and_stall`` (calls ``on_connect``, then answers normally). + """ + + def __init__(self, modes: dict[str, str] | None = None) -> None: + self.modes = modes or {} + self.on_connect: Any = lambda path, timeout: None + self.messages: list[tuple[str, dict]] = [] + self.connections: list[_FakeSock] = [] + + def __call__(self, path: str, timeout: float) -> _FakeSock: + mode = self.modes.get(path, "ok") + if mode == "refused": + raise ConnectionRefusedError("refused") + if mode == "connect_timeout": + raise TimeoutError("timed out") + if mode == "gone": + raise FileNotFoundError("gone") + if mode == "connect_oserror": + raise PermissionError("denied") + if mode == "accept_and_stall": + self.on_connect(path, timeout) + sock = _FakeSock(self, path) + self.connections.append(sock) + return sock + + def path_messages(self, path: Path | str) -> list[dict]: + return [m for p, m in self.messages if p == str(path)] + + +def _no_spawn(*args: object, **kwargs: object) -> None: + raise AssertionError("cookie injection must not spawn a process") + + +class TestInjectIntoLiveSessions: + @pytest.mark.parametrize("reachable", [True, False]) + @pytest.mark.parametrize( + ("cookies", "reasons"), + [ + ([], {}), + ([{}], {}), + ([{}], {"kc-a": ""}), + ([{}], {"kc-a": "timed out"}), + ([{}], {"kc-a": "", "kc-b": "timed out"}), + ([{}], {"kc-a": mod._NOT_LIVE}), + ], + ids=["no_import", "no_sessions", "loaded", "failed", "mixed", "stale"], + ) + def test_deliverable_on_every_return_path( + self, home: Path, reachable: bool, cookies: list[dict[str, Any]], reasons: dict[str, str] + ) -> None: + sockets = {name: (f"/{name}.sock", i, i) for i, name in enumerate(reasons)} + with ( + patch.object(mod, "delivery_supported", return_value=True), + patch.object(mod, "_new_sessions_reachable", return_value=reachable), + patch.object(mod, "_load_cookies", return_value=cookies), + patch.object(mod, "_live_sockets", return_value=sockets), + patch.object(mod, "_inject_into_session", side_effect=list(reasons.values())), + ): + result = mod.inject_into_live_sessions() + assert result["deliverable"] is reachable + assert result["loaded"] == [name for name, reason in reasons.items() if not reason] + assert result["failed"] == { + name: reason for name, reason in reasons.items() if reason and reason != mod._NOT_LIVE + } + if cookies and not result["loaded"]: + assert (mod._UNREACHABLE_NOTE in result["note"]) is (not reachable) + + @needs_af_unix + def test_no_file_and_no_sessions_return_notes(self, home: Path) -> None: + assert "no imported cookies" in mod.inject_into_live_sessions()["note"] + _import_cookies(home, "a") + result = mod.inject_into_live_sessions() + assert result["loaded"] == [] and result["failed"] == {} + assert "new sessions" in result["note"] + + @needs_af_unix + def test_one_request_per_cookie_per_session_over_its_own_socket(self, home: Path) -> None: + _import_cookies(home, "a", "b") + a = _fake_socket(home, "aaaa1111") + b = _fake_socket(home, "bbbb2222") + daemon = _FakeDaemon({str(b): "error"}) + with ( + patch.object(mod, "_connect", side_effect=daemon), + patch("subprocess.run", side_effect=_no_spawn), + patch("subprocess.Popen", side_effect=_no_spawn), + ): + result = mod.inject_into_live_sessions() + assert result["loaded"] == ["kc-aaaa1111"] + assert result["failed"] == {"kc-bbbb2222": "the browser refused 2 of 2 cookies"} + assert "note" not in result + a_messages = daemon.path_messages(a) + assert [m["id"] for m in a_messages] == [1, 2] + for message in a_messages: + assert message["method"] == "run" + args = message["params"]["args"] + assert args["_"][0] == "cookie-set" + assert args["secure"] is True + assert args["domain"] == "d.com" + assert {m["params"]["args"]["_"][1] for m in a_messages} == {"a", "b"} + assert {m["params"]["args"]["_"][2] for m in a_messages} == {"secret-a", "secret-b"} + # One connection per session, closed afterwards. + assert len(daemon.connections) == 2 and all(c.closed for c in daemon.connections) + # A refusal is counted, not a stop: the refusing session was still sent + # every cookie. + assert len(daemon.path_messages(b)) == 2 + + @needs_af_unix + def test_one_refused_cookie_does_not_keep_the_rest_out(self, home: Path) -> None: + _import_cookies(home, "a", "b", "c") + sock = _fake_socket(home, "aaaa1111") + daemon = _FakeDaemon({str(sock): "refuse_a"}) + with patch.object(mod, "_connect", side_effect=daemon): + result = mod.inject_into_live_sessions() + sent = [m["params"]["args"]["_"][1] for m in daemon.path_messages(sock)] + assert sorted(sent) == ["a", "b", "c"] + assert result["failed"] == {"kc-aaaa1111": "the browser refused 1 of 3 cookies"} + assert "secret" not in json.dumps(result) + + @needs_af_unix + def test_a_reply_nested_past_the_parser_limit_is_a_malformed_reply(self, home: Path) -> None: + """The control socket is the agent's own; a reply it crafts to recurse the + JSON parser out must come back as a fixed reason, never as a raise out of + a function the import handler relies on never raising.""" + _import_cookies(home, "a") + sock = _fake_socket(home, "aaaa1111") + with patch.object(mod, "_connect", side_effect=_FakeDaemon({str(sock): "deep"})): + result = mod.inject_into_live_sessions() + assert result["failed"] == {"kc-aaaa1111": "malformed reply"} + + @needs_af_unix + @pytest.mark.parametrize( + ("mode", "reason"), + [ + ("error", "the browser refused 1 of 1 cookies"), + ("is_error", "the browser refused 1 of 1 cookies"), + ("wrong_id", "malformed reply"), + ("garbage", "malformed reply"), + ("timeout", "timed out"), + ("connect_timeout", "timed out"), + ("connect_oserror", "PermissionError"), + ], + ) + def test_failure_reasons_are_fixed_and_never_carry_a_value( + self, home: Path, mode: str, reason: str + ) -> None: + """The daemon's reply echoes the cookie; a reason that quoted it (or the + request) could put a value in the HTTP response or a log line.""" + _import_cookies(home, "a") + sock = _fake_socket(home, "aaaa1111") + with patch.object(mod, "_connect", side_effect=_FakeDaemon({str(sock): mode})): + result = mod.inject_into_live_sessions() + assert result["loaded"] == [] + assert result["failed"] == {"kc-aaaa1111": reason} + assert "secret" not in json.dumps(result) + assert "new sessions" in result["note"] + + @needs_af_unix + def test_a_trickled_reply_cannot_outlive_the_exchange_deadline(self, home: Path) -> None: + """A peer that sends a byte at a time with no newline gets a fresh + per-receive timeout unless the absolute deadline is re-applied before every + receive; with it, the exchange ends at the deadline with a fixed reason.""" + _import_cookies(home, "a") + clock = [1000.0] + timeouts: list[float] = [] + + class _Trickle: + def settimeout(self, timeout: float) -> None: + timeouts.append(timeout) + + def sendall(self, data: bytes) -> None: + pass + + def recv(self, size: int) -> bytes: + clock[0] += 10.0 # each byte lands just inside a per-call timeout + return b"x" + + def __enter__(self) -> "_Trickle": + return self + + def __exit__(self, *exc: object) -> None: + pass + + fake_time = MagicMock(wraps=time) + fake_time.monotonic = lambda: clock[0] + with ( + patch.object(mod, "time", fake_time), + patch.object(mod, "_connect", return_value=_Trickle()), + ): + reason = mod._inject_into_session("/unused.sock", mod._load_cookies() or []) + assert reason == "timed out" + # Two receives fit in the 15 s budget; every timeout handed out shrinks. + assert timeouts == sorted(timeouts, reverse=True) + assert all(t <= mod._CLI_TIMEOUT_S for t in timeouts) + assert clock[0] - 1000.0 <= mod._CLI_TIMEOUT_S + 10.0 + + def test_a_missing_socket_file_is_a_value_free_failure(self, home: Path) -> None: + """The real connect against a socket path nothing listens on.""" + _import_cookies(home, "a") + if not hasattr(__import__("socket"), "AF_UNIX"): + pytest.skip("no AF_UNIX on this platform") + import tempfile + + # A short directory: tmp_path can exceed the AF_UNIX path budget. + with tempfile.TemporaryDirectory( + prefix=SHORT_TMP_PREFIX + "cookies-", dir=short_tmp_base() + ) as short: + absent = str(Path(short) / "absent.sock") + reason = mod._inject_into_session(absent, mod._load_cookies() or []) + assert reason == mod._NOT_LIVE + # A socket file whose daemon is gone (bound, nothing listening). + stale = _make_unix_socket(Path(short) / "stale.sock") + assert mod._inject_into_session(str(stale), mod._load_cookies() or []) == mod._NOT_LIVE + + @needs_af_unix + def test_a_stale_socket_left_by_a_dead_daemon_is_not_a_failure(self, home: Path) -> None: + """A socket file outlives its daemon. The real connect is refused, and + that browser does not exist: it is neither loaded nor failed, so the UI + does not say an open browser refused the cookies.""" + _import_cookies(home, "a") + stale = _fake_socket(home, "aaaa1111") + live = _fake_socket(home, "bbbb2222") + gone = _fake_socket(home, "cccc3333") + daemon = _FakeDaemon({str(stale): "refused", str(gone): "gone"}) + with patch.object(mod, "_connect", side_effect=daemon): + result = mod.inject_into_live_sessions() + assert result == {"loaded": ["kc-bbbb2222"], "failed": {}, "deliverable": True} + assert mod._WATCHER.injected() == {_key(live)} + + @needs_af_unix + def test_only_stale_sockets_read_as_no_live_sessions(self, home: Path) -> None: + _import_cookies(home, "a") + sock = _fake_socket(home, "aaaa1111") + with patch.object(mod, "_connect", side_effect=_FakeDaemon({str(sock): "refused"})): + result = mod.inject_into_live_sessions() + assert result["loaded"] == [] and result["failed"] == {} + assert result["note"].startswith("no live browser sessions") + assert "new sessions automatically" in result["note"] + + @needs_af_unix + def test_the_fan_out_has_one_overall_deadline(self, home: Path) -> None: + """Agent-planted listeners that accept and stall must not hold the lock for + the per-socket bound times their number: one deadline covers the whole + import, and a socket it leaves unreached is a fixed, value-free failure.""" + _import_cookies(home, "a") + leaves = ("aaaa1111", "bbbb2222", "cccc3333", "dddd4444") + socks = [_fake_socket(home, leaf) for leaf in leaves] + clock = [1000.0] + handed: list[float] = [] + daemon = _FakeDaemon({str(s): "accept_and_stall" for s in socks}) + + def stall(path: str, timeout: float) -> None: + handed.append(timeout) + clock[0] += 14.0 + + daemon.on_connect = stall + fake_time = MagicMock(wraps=time) + fake_time.monotonic = lambda: clock[0] + with ( + patch.object(mod, "_FANOUT_DEADLINE_S", 30.0), + patch.object(mod, "time", fake_time), + patch.object(mod, "_connect", side_effect=daemon), + ): + result = mod.inject_into_live_sessions() + assert result["loaded"] == ["kc-aaaa1111", "kc-bbbb2222"] + # The third socket gets only what is left of the overall budget and runs + # out inside it; the fourth is never reached. + assert handed == [15.0, 15.0, 2.0] + assert result["failed"] == {"kc-cccc3333": "timed out", "kc-dddd4444": mod._NOT_REACHED} + assert len(daemon.connections) == 3 + assert "secret" not in json.dumps(result) + + @needs_af_unix + def test_new_session_deliverability_uses_launch_path_validation( + self, home: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(launch_mod, "_UNIX_SOCKET_PATH_MAX_BYTES", 103) + monkeypatch.setattr(launch_mod.platform_compat, "IS_WINDOWS", False) + long_home = home / ("x" * 100) + monkeypatch.setenv("KIROCREW_HOME", str(long_home)) + with patch.object(mod, "_load_cookies", return_value=[]): + assert mod.inject_into_live_sessions()["deliverable"] is False + assert not long_home.exists() + + @needs_af_unix + @pytest.mark.parametrize("override", [launch_mod.SOCKETS_ENV, launch_mod.DAEMON_DIR_ENV]) + def test_relative_lifecycle_override_means_no_delivery_promise( + self, home: Path, monkeypatch: pytest.MonkeyPatch, override: str + ) -> None: + _import_cookies(home, "a") + monkeypatch.setenv(override, "relative/root") + assert mod.inject_into_live_sessions()["deliverable"] is False + + def test_supported_new_session_reach_check_is_read_only( + self, home: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(launch_mod, "peek_data_home", lambda: home) + with ( + patch.object(launch_mod, "config_dir", side_effect=AssertionError("must not maintain")), + patch.object(Path, "mkdir", side_effect=AssertionError("must not create")), + patch.object(launch_mod.platform_compat, "make_owner_only_dir") as prepare, + patch.object(launch_mod.platform_compat, "restrict_dir_to_owner") as restrict, + patch.object(launch_mod.logger, "warning") as warning, + ): + assert mod._new_sessions_reachable() is True + prepare.assert_not_called() + restrict.assert_not_called() + warning.assert_not_called() + assert not home.exists() + + @needs_af_unix + def test_an_operator_fixed_session_name_means_no_delivery_promise( + self, home: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """With an operator-chosen session name the launch adds no socket root, so + new agent sessions are never under the scanned roots: the note must not + promise that the cookies apply to them.""" + _import_cookies(home, "a") + monkeypatch.setenv(launch_mod.SESSION_ENV, "shared") + result = mod.inject_into_live_sessions() + assert result["note"].endswith(mod._UNREACHABLE_NOTE) + assert "automatically" not in result["note"] + + @needs_af_unix + @pytest.mark.parametrize("support", ["UNVERIFIED", "UNSUPPORTED"]) + def test_a_cli_without_the_lifecycle_hooks_means_no_delivery_promise( + self, home: Path, monkeypatch: pytest.MonkeyPatch, support: str + ) -> None: + _import_cookies(home, "a") + sock = _fake_socket(home, "aaaa1111") + monkeypatch.setattr( + launch_mod, "cli_lifecycle_env_support", lambda: (launch_mod.SeamSupport[support], "x") + ) + with patch.object(mod, "_connect", side_effect=_FakeDaemon({str(sock): "error"})): + result = mod.inject_into_live_sessions() + assert result["note"].endswith(mod._UNREACHABLE_NOTE) + assert "automatically" not in result["note"] + + @pytest.mark.skipif(IS_WINDOWS, reason="AF_UNIX socketpair") + def test_real_socket_round_trip_speaks_the_daemon_protocol(self, home: Path) -> None: + """A real stream socket on both ends: requests are newline-delimited JSON, + replies may arrive split across reads, and the exchange completes.""" + import socket + + _import_cookies(home, "a", "b") + ours, theirs = socket.socketpair() + received: list[dict] = [] + + def daemon() -> None: + buf = b"" + with theirs: + while len(received) < 2: + data = theirs.recv(4096) + if not data: + return + buf += data + while b"\n" in buf: + line, buf = buf.split(b"\n", 1) + message = json.loads(line) + received.append(message) + reply = json.dumps( + {"id": message["id"], "result": {"isError": False, "text": "ok"}} + ).encode() + # Split the reply across two writes to exercise buffering. + theirs.sendall(reply[:5]) + theirs.sendall(reply[5:] + b"\n") + + thread = threading.Thread(target=daemon, daemon=True) + thread.start() + with patch.object(mod, "_connect", return_value=ours): + assert mod._inject_into_session("/unused.sock", mod._load_cookies() or []) == "" + thread.join(5) + assert [m["id"] for m in received] == [1, 2] + assert {m["params"]["args"]["_"][1] for m in received} == {"a", "b"} + + @needs_af_unix + def test_injects_into_every_named_browser_of_a_session(self, home: Path) -> None: + _import_cookies(home, "a") + older = _fake_socket(home, "aaaa1111", "older") + newer = _fake_socket(home, "aaaa1111", "newer") + daemon = _FakeDaemon() + with patch.object(mod, "_connect", side_effect=daemon): + result = mod.inject_into_live_sessions() + assert sorted(result["loaded"]) == ["kc-aaaa1111/newer", "kc-aaaa1111/older"] + assert len(daemon.path_messages(older)) == 1 + assert len(daemon.path_messages(newer)) == 1 + assert mod._WATCHER.injected() == {_key(older), _key(newer)} + + @needs_af_unix + def test_records_reached_sockets_with_the_watcher(self, home: Path) -> None: + _import_cookies(home, "a") + sock = _fake_socket(home, "aaaa1111") + daemon = _FakeDaemon() + with patch.object(mod, "_connect", side_effect=daemon): + assert mod.inject_into_live_sessions()["loaded"] == ["kc-aaaa1111"] + assert mod._WATCHER.injected() == {_key(sock)} + # The watcher's next tick has nothing left to do for that socket. + before = len(daemon.messages) + mod._WATCHER.tick() + assert len(daemon.messages) == before + + +class TestClearLiveSessions: + def test_no_sessions_carry_a_note(self, home: Path) -> None: + result = mod.clear_live_sessions() + assert result["cleared"] == [] and result["failed"] == {} and "note" in result + + @needs_af_unix + def test_clears_over_the_same_sockets_an_import_reaches(self, home: Path) -> None: + """``cookie-clear`` goes over each session's control socket, to exactly the + sessions :func:`mod._live_sockets` finds, with no process spawned.""" + a = _fake_socket(home, "aaaa1111") + b = _fake_socket(home, "bbbb2222") + daemon = _FakeDaemon({str(b): "error"}) + with ( + patch.object(mod, "_connect", side_effect=daemon), + patch("subprocess.run", side_effect=_no_spawn), + patch("subprocess.Popen", side_effect=_no_spawn), + ): + result = mod.clear_live_sessions() + assert result == { + "cleared": ["kc-aaaa1111"], + "failed": {"kc-bbbb2222": "the browser refused to clear its cookies"}, + } + for path in (a, b): + [message] = daemon.path_messages(path) + assert message["method"] == "run" + assert message["params"]["args"] == {"_": ["cookie-clear"]} + + @needs_af_unix + def test_clears_every_named_browser_of_a_session(self, home: Path) -> None: + """A session with an older and a newer named browser: ``cookie-clear`` goes + to both, so the older one is not left signed in.""" + older = _fake_socket(home, "aaaa1111", "older") + newer = _fake_socket(home, "aaaa1111", "newer") + daemon = _FakeDaemon() + with patch.object(mod, "_connect", side_effect=daemon): + result = mod.clear_live_sessions() + assert sorted(result["cleared"]) == ["kc-aaaa1111/newer", "kc-aaaa1111/older"] + assert result["failed"] == {} + for path in (older, newer): + [message] = daemon.path_messages(path) + assert message["params"]["args"] == {"_": ["cookie-clear"]} + + def test_resets_the_watcher_record(self, home: Path) -> None: + mod._WATCHER.mark_injected(("/x.sock", 1, 2)) + mod.clear_live_sessions() + assert mod._WATCHER.injected() == frozenset() + + @needs_af_unix + def test_never_raises(self, home: Path) -> None: + sock = _fake_socket(home, "aaaa1111") + with patch.object(mod, "_connect", side_effect=_FakeDaemon({str(sock): "timeout"})): + assert mod.clear_live_sessions()["failed"] == {"kc-aaaa1111": "timed out"} + + @needs_af_unix + def test_a_stale_socket_is_not_reported_as_still_signed_in(self, home: Path) -> None: + stale = _fake_socket(home, "aaaa1111") + live = _fake_socket(home, "bbbb2222") + daemon = _FakeDaemon({str(stale): "refused"}) + with patch.object(mod, "_connect", side_effect=daemon): + assert mod.clear_live_sessions() == {"cleared": ["kc-bbbb2222"], "failed": {}} + both_gone = _FakeDaemon({str(stale): "refused", str(live): "gone"}) + with patch.object(mod, "_connect", side_effect=both_gone): + only_stale = mod.clear_live_sessions() + assert only_stale["cleared"] == [] and only_stale["failed"] == {} + assert only_stale["note"] == "no live browser sessions" + + @needs_af_unix + def test_the_clear_fan_out_has_one_overall_deadline(self, home: Path) -> None: + leaves = ("aaaa1111", "bbbb2222", "cccc3333", "dddd4444") + socks = [_fake_socket(home, leaf) for leaf in leaves] + clock = [1000.0] + daemon = _FakeDaemon({str(s): "accept_and_stall" for s in socks}) + + def stall(path: str, timeout: float) -> None: + clock[0] += 14.0 + + daemon.on_connect = stall + fake_time = MagicMock(wraps=time) + fake_time.monotonic = lambda: clock[0] + with ( + patch.object(mod, "_FANOUT_DEADLINE_S", 30.0), + patch.object(mod, "time", fake_time), + patch.object(mod, "_connect", side_effect=daemon), + ): + result = mod.clear_live_sessions() + assert result == { + "cleared": ["kc-aaaa1111", "kc-bbbb2222"], + "failed": {"kc-cccc3333": "timed out", "kc-dddd4444": mod._NOT_REACHED}, + } + assert len(daemon.connections) == 3 + + +# ── SessionWatcher: cookies for daemons that appear after the import ── + + +@needs_af_unix +class TestSessionWatcher: + def test_no_file_means_no_work_and_a_clean_record(self, home: Path) -> None: + watcher = mod.SessionWatcher() + watcher.mark_injected(("/x.sock", 1, 2)) + _fake_socket(home, "aaaa1111") + daemon = _FakeDaemon() + with patch.object(mod, "_connect", side_effect=daemon): + assert watcher.tick() == 0 + assert daemon.messages == [] + assert watcher.injected() == frozenset() + + def test_injects_new_sockets_once_and_skips_them_afterwards(self, home: Path) -> None: + _import_cookies(home, "a", "b") + a = _fake_socket(home, "aaaa1111") + watcher = mod.SessionWatcher() + fake = _FakeDaemon() + with patch.object(mod, "_connect", side_effect=fake): + assert watcher.tick() == 1 + assert len(fake.path_messages(a)) == 2 # one cookie-set per cookie + assert fake.messages[0][0] == str(home / "pw" / "aaaa1111" / "s" / "cli" / a.name) + assert watcher.injected() == {_key(a)} + assert watcher.tick() == 0 + assert len(fake.messages) == 2 + # A second session appears: only it is injected. + b = _fake_socket(home, "bbbb2222") + assert watcher.tick() == 1 + assert len(fake.path_messages(b)) == 2 + assert len(fake.path_messages(a)) == 2 + assert watcher.injected() == {_key(a), _key(b)} + + def test_a_new_daemon_socket_for_the_same_session_is_injected_again(self, home: Path) -> None: + _import_cookies(home, "a") + sock = _fake_socket(home, "aaaa1111") + watcher = mod.SessionWatcher() + fake = _FakeDaemon() + with patch.object(mod, "_connect", side_effect=fake): + assert watcher.tick() == 1 + sock.unlink() + _fake_socket(home, "aaaa1111", name="fedcba9876543210") + assert watcher.tick() == 1 + assert len(fake.messages) == 2 + + def test_reimport_changes_the_file_and_reinjects_everything(self, home: Path) -> None: + _import_cookies(home, "a") + _fake_socket(home, "aaaa1111") + watcher = mod.SessionWatcher() + fake = _FakeDaemon() + with patch.object(mod, "_connect", side_effect=fake): + assert watcher.tick() == 1 + assert watcher.tick() == 0 + _import_cookies(home, "a", "b", "c") # a new size changes the signature + assert watcher.tick() == 1 + assert len(fake.messages) == 1 + 3 + + def test_reset_forgets_the_record(self, home: Path) -> None: + _import_cookies(home, "a") + _fake_socket(home, "aaaa1111") + watcher = mod.SessionWatcher() + fake = _FakeDaemon() + with patch.object(mod, "_connect", side_effect=fake): + assert watcher.tick() == 1 + watcher.reset() + assert watcher.injected() == frozenset() + assert watcher.tick() == 1 + assert len(fake.messages) == 2 + + def test_failed_sockets_are_retried_a_bounded_number_of_times(self, home: Path) -> None: + _import_cookies(home, "a") + sock = _fake_socket(home, "aaaa1111") + watcher = mod.SessionWatcher() + fake = _FakeDaemon({str(sock): "error"}) + with patch.object(mod, "_connect", side_effect=fake): + for _ in range(mod.WATCH_MAX_ATTEMPTS + 3): + assert watcher.tick() == 0 + assert len(fake.messages) == mod.WATCH_MAX_ATTEMPTS + assert watcher.injected() == frozenset() + + def test_a_tick_is_bounded_to_a_few_sessions(self, home: Path) -> None: + _import_cookies(home, "a") + leaves = [f"{i:08x}" for i in range(mod.WATCH_MAX_SESSIONS_PER_TICK + 3)] + for leaf in leaves: + _fake_socket(home, leaf) + watcher = mod.SessionWatcher() + fake = _FakeDaemon() + with patch.object(mod, "_connect", side_effect=fake): + assert watcher.tick() == mod.WATCH_MAX_SESSIONS_PER_TICK + assert watcher.tick() == 3 + assert watcher.tick() == 0 + assert len(watcher.injected()) == len(leaves) + + def test_a_tick_has_one_deadline_and_does_not_spend_untried_attempts( + self, home: Path, manual_clock, monkeypatch: pytest.MonkeyPatch + ) -> None: + _import_cookies(home, "a") + socks = [_fake_socket(home, f"{i:08x}") for i in range(4)] + sockets = {f"kc-{i:08x}": _key(sock) for i, sock in enumerate(socks)} + watcher = mod.SessionWatcher() + handed: list[float] = [] + manual_clock.install(monkeypatch, mod) + monkeypatch.setattr(mod, "_FANOUT_DEADLINE_S", 30.0) + + def stall(path: str, cookies, timeout: float) -> str: + assert mod.transaction().locked() + handed.append(timeout) + manual_clock.advance(min(14.0, timeout)) + return "timed out" + + with ( + patch.object(mod, "_live_sockets", return_value=sockets), + patch.object(mod, "_inject_into_session", side_effect=stall) as inject, + ): + assert watcher.tick() == 0 + assert handed == [30.0, 16.0, 2.0] + assert inject.call_count == 3 + assert watcher._attempts == {_key(sock): 1 for sock in socks[:3]} + assert _key(socks[3]) not in watcher._attempts + assert not mod.transaction().locked() + + def test_gone_sockets_leave_the_record(self, home: Path) -> None: + _import_cookies(home, "a") + sock = _fake_socket(home, "aaaa1111") + watcher = mod.SessionWatcher() + with patch.object(mod, "_connect", side_effect=_FakeDaemon()): + watcher.tick() + assert watcher.injected() == {_key(sock)} + sock.unlink() + _fake_socket(home, "bbbb2222") + watcher.tick() + assert all( + key[0].endswith("bbbb2222/s/cli/0123456789abcdef.sock") for key in watcher.injected() + ) + + def test_a_tick_reads_and_injects_under_the_transaction_lock(self, home: Path) -> None: + """A clear holds the same lock across delete and ``cookie-clear``, so a tick + can never inject a set it loaded before the clear after the clear returned.""" + _import_cookies(home, "a") + sock = _fake_socket(home, "aaaa1111") + held: list[bool] = [] + daemon = _FakeDaemon() + + def connect(path: str, timeout: float) -> _FakeSock: + held.append(mod.transaction().locked()) + return daemon(path, timeout) + + watcher = mod.SessionWatcher() + with patch.object(mod, "_connect", side_effect=connect): + assert watcher.tick() == 1 + assert held == [True] + assert not mod.transaction().locked() + assert daemon.path_messages(sock) + + def test_a_tick_never_raises(self, home: Path) -> None: + _import_cookies(home, "a") + _fake_socket(home, "aaaa1111") + watcher = mod.SessionWatcher() + with patch.object(mod, "_live_sockets", side_effect=RuntimeError("boom")): + assert watcher.tick() == 0 + + def test_thread_lifecycle_is_idempotent_and_off_the_loop(self, home: Path) -> None: + _import_cookies(home, "a") + _fake_socket(home, "aaaa1111") + ticked = threading.Event() + watcher = mod.SessionWatcher(interval=0.01) + fake = _FakeDaemon() + + def run(path, timeout): + ticked.set() + return fake(path, timeout) + + with patch.object(mod, "_connect", side_effect=run): + watcher.start() + first = watcher._thread + watcher.start() # idempotent + assert watcher._thread is first + assert watcher.running + assert first is not None and first.daemon is True + assert first.name == "browser-cookie-watcher" + assert ticked.wait(5) + watcher.stop() + watcher.stop() # idempotent + assert not watcher.running + assert len(fake.messages) == 1 + + def test_module_singleton_start_stop(self, home: Path) -> None: + mod.start_session_watcher() + assert not mod._WATCHER.running # nothing imported: no thread + _import_cookies(home, "a") + mod.start_session_watcher() + assert mod._WATCHER.running + mod.stop_session_watcher() + assert not mod._WATCHER.running + mod.stop_session_watcher() + + +def test_no_af_unix_means_no_delivery_and_a_fixed_reason( + home: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Without AF_UNIX nothing can reach a daemon: delivery is reported as + unsupported, the watcher never starts, and every live outcome is a fixed, + value-free reason rather than a promise that new sessions get the cookies.""" + monkeypatch.delattr(mod.socket, "AF_UNIX", raising=False) + assert mod.delivery_supported() is False + _import_cookies(home, "a") + mod.start_session_watcher() + assert not mod._WATCHER.running + assert mod._inject_into_session("/unused.sock", mod._load_cookies() or []) == ( + "unsupported platform" + ) + injected = mod.inject_into_live_sessions() + assert injected["deliverable"] is False + assert injected["loaded"] == [] and "new sessions" not in injected["note"] + assert "cannot reach" in mod.clear_live_sessions()["note"] + + +# ── HTTP handlers ── + + +@pytest.fixture() +def mock_sel(): + try: + import kiro_crew.dashboard.handlers # noqa: F401 + except ImportError: + pytest.skip("dashboard handler deps not available locally") + m = MagicMock() + m.log_api_access = MagicMock() + with patch("kiro_crew.dashboard.handlers.sel", return_value=m): + yield m + + +def _make_state(restricted: bool = False) -> MagicMock: + state = MagicMock() + # Empty owner_id -> the standalone-local path, where as_owner's default + # ``local-app`` caller reads as the owner (the same shape NoConfiguredOwner + # gives). A MagicMock owner_id would stringify to a non-empty value and deny. + state.owner_id = "" + state._restricted_keys = {"dashboard:guest"} if restricted else set() + state._slots = {} + # inherited_session_memory_mode reads these; keep them concrete so it does + # not iterate a MagicMock (which would raise) before the _restricted_keys + # check runs. + state.context_builder = None + state.subagents = None + return state + + +@pytest.fixture() +def app(home: Path, mock_sel): + from kiro_crew.dashboard.handlers import messaging + + application = web.Application() + application.router.add_get("/api/browser/cookies", messaging.api_browser_cookies_get) + application.router.add_post("/api/browser/cookies", messaging.api_browser_cookies_import) + application.router.add_delete("/api/browser/cookies", messaging.api_browser_cookies_clear) + as_owner(application) + # as_owner installs NoConfiguredOwner only when there is no state; give a real + # enough state so the restricted-session predicate has its maps. + application["state"] = _make_state() + # The handlers' own behaviour, on every platform: a host without AF_UNIX + # refuses the import outright, which its own test below pins. + with patch.object(mod, "delivery_supported", return_value=True): + yield application + + +@pytest.mark.asyncio +async def test_status_absent_then_present(app, home: Path) -> None: + async with TestClient(TestServer(app)) as client: + resp = await client.get("/api/browser/cookies") + assert resp.status == 200 + body = await resp.json() + assert body["present"] is False + assert body["summary"] is None + # No host path in the answer: nothing renders it. + assert set(body) == {"present", "summary"} + + with patch( + "kiro_crew.browser_cli.cookies.inject_into_live_sessions", + return_value={"loaded": [], "failed": {}}, + ): + imp = await client.post( + "/api/browser/cookies", + json={"content": json.dumps([{"name": "n", "value": "v", "domain": "d.com"}])}, + ) + assert imp.status == 200 + imp_body = await imp.json() + assert imp_body["ok"] is True + assert imp_body["summary"]["cookie_count"] == 1 + + resp2 = await client.get("/api/browser/cookies") + assert (await resp2.json())["present"] is True + + +@pytest.mark.asyncio +async def test_import_on_a_host_without_af_unix_fails_and_stores_nothing(app, home: Path) -> None: + """No browser on this host could ever receive the cookies, so the import must + not answer with a success: it is refused, and nothing is written.""" + async with TestClient(TestServer(app)) as client: + with patch.object(mod, "delivery_supported", return_value=False): + resp = await client.post( + "/api/browser/cookies", + json={"content": json.dumps([{"name": "n", "value": "v", "domain": "d.com"}])}, + ) + assert resp.status == 501 + body = await resp.json() + assert body["code"] == "unsupported_platform" + assert "nothing was imported" in body["error"] + assert not mod.storage_state_path().exists() + assert not mod._WATCHER.running + + +@pytest.mark.asyncio +@pytest.mark.parametrize("reachable", [True, False]) +async def test_import_reports_deliverable_from_injection(app, home: Path, reachable: bool) -> None: + content = json.dumps([{"name": "n", "domain": "d.com"}]) + async with TestClient(TestServer(app)) as client: + with patch.object(mod, "_new_sessions_reachable", return_value=reachable): + resp = await client.post("/api/browser/cookies", json={"content": content}) + assert resp.status == 200 + body = await resp.json() + assert body["ok"] is True and body["hot_load"]["loaded"] == [] + assert body["hot_load"]["deliverable"] is reachable + assert (mod._UNREACHABLE_NOTE in body["hot_load"]["note"]) is (not reachable) + assert body["summary"]["cookie_count"] == 1 and mod.storage_state_path().exists() + + +@pytest.mark.asyncio +async def test_import_malformed_is_400(app, home: Path) -> None: + async with TestClient(TestServer(app)) as client: + resp = await client.post("/api/browser/cookies", json={"content": "not-a-cookie"}) + assert resp.status == 400 + assert (await resp.json())["code"] == "invalid_cookies" + + +@pytest.mark.asyncio +async def test_import_missing_content_is_400(app, home: Path) -> None: + async with TestClient(TestServer(app)) as client: + resp = await client.post("/api/browser/cookies", json={"filename": "x"}) + assert resp.status == 400 + assert (await resp.json())["code"] == "invalid_content" + + +@pytest.mark.asyncio +async def test_import_json_escaped_export_reaches_parser(app, home: Path) -> None: + content = "\t" * (19 * 1024 * 1024 // 10) + "invalid-export" + payload = json.dumps({"content": content}) + assert len(content.encode()) < mod.MAX_IMPORT_BYTES < len(payload.encode()) + async with TestClient(TestServer(app)) as client: + resp = await client.post( + "/api/browser/cookies", + data=payload, + headers={"Content-Type": "application/json"}, + ) + assert resp.status == 400 + assert (await resp.json())["code"] == "invalid_cookies" + assert not mod.storage_state_path().exists() + + +@pytest.mark.asyncio +async def test_import_content_over_limit_is_refused_by_parser(app, home: Path) -> None: + async with TestClient(TestServer(app)) as client: + resp = await client.post( + "/api/browser/cookies", json={"content": "x" * (mod.MAX_IMPORT_BYTES + 1)} + ) + assert resp.status == 400 + body = await resp.json() + assert body["code"] == "invalid_cookies" + assert "2 MiB" in body["error"] + assert not mod.storage_state_path().exists() + + +@pytest.mark.asyncio +async def test_import_oversize_is_413(app, home: Path) -> None: + async with TestClient(TestServer(app)) as client: + payload = json.dumps({"content": "x" * (2 * mod.MAX_IMPORT_BYTES + 1025)}) + resp = await client.post( + "/api/browser/cookies", + data=payload, + headers={"Content-Type": "application/json"}, + ) + assert resp.status == 413 + + +@pytest.mark.asyncio +async def test_clear_removes(app, home: Path) -> None: + mod.save_storage_state(mod.parse_cookie_import(json.dumps([{"name": "n", "domain": "d.com"}]))) + live = {"cleared": ["kc-aaaa1111"], "failed": {"kc-bbbb2222": "not open"}} + held: list[bool] = [] + + def clear_locked() -> dict: + held.append(mod.transaction().locked()) + return live + + async with TestClient(TestServer(app)) as client: + with patch( + "kiro_crew.browser_cli.cookies.clear_live_sessions", side_effect=clear_locked + ) as clear_live: + resp = await client.delete("/api/browser/cookies") + assert resp.status == 200 + # Live sessions are cleared too, and the outcome is reported, not hidden. + assert (await resp.json()) == {"ok": True, "present": False, "live": live} + clear_live.assert_called_once_with() + assert not mod.storage_state_path().exists() + # Delete and cookie-clear run under the lock a watcher tick takes. + assert held == [True] + + +@pytest.mark.asyncio +async def test_import_injects_live_sessions_and_reports_the_note(app, home: Path) -> None: + hot = {"loaded": [], "failed": {"kc-aaaa1111": "exit status 1"}, "note": "nothing reached"} + held: list[bool] = [] + + def inject_locked() -> dict: + held.append(mod.transaction().locked()) + return hot + + async with TestClient(TestServer(app)) as client: + with patch( + "kiro_crew.browser_cli.cookies.inject_into_live_sessions", side_effect=inject_locked + ) as inject: + resp = await client.post( + "/api/browser/cookies", + json={"content": json.dumps([{"name": "n", "value": "v", "domain": "d.com"}])}, + ) + assert resp.status == 200 + assert (await resp.json())["hot_load"] == hot + # Injection runs AFTER the file is written, from the stored state. + inject.assert_called_once_with() + assert held == [True] # save and inject run under the watcher's lock + assert mod.storage_state_path().exists() + # The first import starts the watcher, so later sessions get the cookies. + assert mod._WATCHER.running is hasattr(socket, "AF_UNIX") + # Nothing but the state file appears under the data home: no config the + # agent or its daemon reads ever names it. + agent = launch_mod.launch_config_path() + assert not agent.exists() or "storageState" not in agent.read_text(encoding="utf-8") + + +@pytest.mark.asyncio +async def test_import_and_clear_are_serialised_by_one_lock(app, home: Path) -> None: + """An overlapping POST and DELETE must not interleave at the to_thread + boundary: with the lock, the DELETE's delete+clear runs only after the POST's + save+inject has finished (or the reverse), so a cleared state can never be + left active in the live sessions.""" + from kiro_crew.dashboard.handlers import messaging + + # A lock left by another test's loop must not be reused on this one. + messaging._cookie_lock = None + events: list[str] = [] + import_started = threading.Event() + release_import = threading.Event() + + def slow_inject(): + events.append("import:inject:start") + import_started.set() + assert release_import.wait(5) + events.append("import:inject:end") + return {"loaded": [], "failed": {}} + + def clear_live(): + events.append("clear:live") + return {"cleared": [], "failed": {}} + + async with TestClient(TestServer(app)) as client: + with ( + patch( + "kiro_crew.browser_cli.cookies.inject_into_live_sessions", side_effect=slow_inject + ), + patch("kiro_crew.browser_cli.cookies.clear_live_sessions", side_effect=clear_live), + ): + post = asyncio.ensure_future( + client.post( + "/api/browser/cookies", + json={"content": json.dumps([{"name": "n", "value": "v", "domain": "d.com"}])}, + ) + ) + # Wait until the import is inside its transaction, then fire the clear. + await async_wait_until(import_started.is_set, describe=lambda: events) + delete = asyncio.ensure_future(client.delete("/api/browser/cookies")) + # The DELETE is parked on the lock the import holds, so the clear has + # NOT run. + await until_parked(messaging._get_cookie_lock()) + assert events == ["import:inject:start"] + assert mod.storage_state_path().exists() + release_import.set() + post_resp, delete_resp = await asyncio.gather(post, delete) + assert post_resp.status == 200 and delete_resp.status == 200 + assert events == ["import:inject:start", "import:inject:end", "clear:live"] + assert not mod.storage_state_path().exists() + + +@pytest.mark.asyncio +async def test_non_owner_forbidden(app, home: Path) -> None: + async with TestClient(TestServer(app)) as client: + headers = {"X-Test-User": "someone-else"} + assert (await client.get("/api/browser/cookies", headers=headers)).status == 403 + assert ( + await client.post("/api/browser/cookies", json={"content": "x"}, headers=headers) + ).status == 403 + assert (await client.delete("/api/browser/cookies", headers=headers)).status == 403 + + +@pytest.mark.asyncio +async def test_restricted_session_forbidden(home: Path, mock_sel) -> None: + from kiro_crew.dashboard.handlers import messaging + + application = web.Application() + application.router.add_get("/api/browser/cookies", messaging.api_browser_cookies_get) + application.router.add_post("/api/browser/cookies", messaging.api_browser_cookies_import) + application.router.add_delete("/api/browser/cookies", messaging.api_browser_cookies_clear) + as_owner(application) + application["state"] = _make_state(restricted=True) + headers = {"X-Session-Key": "dashboard:guest"} + async with TestClient(TestServer(application)) as client: + # The status read names the sites a credential unlocks, so it is refused + # on the SAME body shape as the mutations (the panel hides on one code). + status = await client.get("/api/browser/cookies", headers=headers) + assert status.status == 403 + assert (await status.json())["code"] == "restricted_session" + imp = await client.post( + "/api/browser/cookies", + json={"content": json.dumps([{"name": "n", "domain": "d.com"}])}, + headers=headers, + ) + assert imp.status == 403 + assert (await imp.json())["code"] == "restricted_session" + clr = await client.delete("/api/browser/cookies", headers=headers) + assert clr.status == 403 + + +@pytest.mark.asyncio +async def test_import_audit_caps_the_domains_it_names(app, home: Path, mock_sel) -> None: + """An import of thousands of domains must not become one unbounded SEL + resources string: the first ``_AUDIT_MAX_DOMAINS`` are named, the rest + counted.""" + cap = 20 + cookies = [{"name": "n", "value": "v", "domain": f"d{i:03d}.example"} for i in range(cap + 5)] + # Sorted first in the summary, so it is among the named ones. + cookies.append({"name": "n", "value": "v", "domain": "a\n.forged\x1b\u2028.example"}) + content = json.dumps(cookies) + async with TestClient(TestServer(app)) as client: + with patch( + "kiro_crew.browser_cli.cookies.inject_into_live_sessions", + return_value={"loaded": [], "failed": {}}, + ): + resp = await client.post("/api/browser/cookies", json={"content": content}) + assert resp.status == 200 + [audit] = [ + c.kwargs + for c in mock_sel.log_api_access.call_args_list + if c.kwargs.get("operation") == "browser_cookies_import" + and " cookies=" in c.kwargs.get("resources", "") + ] + resources = audit["resources"] + assert resources.endswith(",+6 more") + assert resources.count(".example") == cap + assert "a.forged.example," in resources + assert all(ch.isprintable() for ch in resources) diff --git a/test/test_dashboard_server_composition_contract_reach.py b/test/test_dashboard_server_composition_contract_reach.py index bd2e4fd5be1..398c27c4a9f 100644 --- a/test/test_dashboard_server_composition_contract_reach.py +++ b/test/test_dashboard_server_composition_contract_reach.py @@ -701,6 +701,7 @@ def test_the_whole_census_holds_the_known_rebinds_and_an_owner_s_definitions() - "_own_host_warm", "_instances_startup", "_browser_sessions_startup", + "_cookie_watcher_startup", ), "on_cleanup": ( "_tunnel_shutdown", @@ -721,6 +722,7 @@ def test_the_whole_census_holds_the_known_rebinds_and_an_owner_s_definitions() - "_crew_log_drain", "_browser_install_shutdown", "_browser_view_shutdown", + "_cookie_watcher_shutdown", "_connections_warm_shutdown", "_workflow_shutdown", "_unlink_unix_socket", @@ -729,7 +731,7 @@ def test_the_whole_census_holds_the_known_rebinds_and_an_owner_s_definitions() - "on_response_prepare": ("_finalize_asset_cache_control",), } _API_HOOKS = { - "on_startup": ("_stt_startup", "_own_host_warm"), + "on_startup": ("_stt_startup", "_own_host_warm", "_cookie_watcher_startup"), "on_cleanup": ( "_kiro_prerequisite_shutdown", "_kas_login_shutdown", @@ -740,6 +742,7 @@ def test_the_whole_census_holds_the_known_rebinds_and_an_owner_s_definitions() - "_prevent_sleep_shutdown", "_listener_guard_shutdown", "_browser_install_shutdown", + "_cookie_watcher_shutdown", "_connections_warm_shutdown", "_workflow_shutdown", "_unlink_unix_socket", @@ -791,7 +794,8 @@ def test_the_whole_census_holds_the_known_rebinds_and_an_owner_s_definitions() - _insert_enterprise_gate _register_prevent_sleep_shutdown _register_listener_guard_shutdown _register_stt_hooks _register_own_host_warm _register_config_watch _register_instances_hooks _register_browser_install_cleanup - _register_browser_view_cleanup _register_connections_warm_lifecycle + _register_browser_view_cleanup _register_browser_cookie_watcher + _register_connections_warm_lifecycle _register_workflow_lifecycle _register_crewmate_prune_gate _register_change_card_hook _register_unix_socket_cleanup build_hardened_runner on_gateway_startup init_hook_reconciler async_safe_context_call current_context register_pre_exec_stop @@ -851,6 +855,7 @@ def test_the_whole_census_holds_the_known_rebinds_and_an_owner_s_definitions() - _register_deploy_routes _register_stt_hooks _register_own_host_warm _register_config_watch _register_prevent_sleep_shutdown _register_listener_guard_shutdown _register_browser_install_cleanup + _register_browser_cookie_watcher _register_connections_warm_lifecycle _register_workflow_lifecycle _register_unix_socket_cleanup build_hardened_runner bind_address_for _start_site _arm_listener_guard _kick_pinned_order_load _export_bound_port _resolved_bound_port @@ -1062,8 +1067,8 @@ def _routes(app: web.Application) -> list[tuple[str, str, str]]: #: SHA-256 of the MCP route table's ``" "`` rows in #: registration order, and their count. The table is shared by both entrypoints, so a #: route added to it on purpose updates these with it. -_MCP_TABLE_ROWS = 312 -_MCP_TABLE_DIGEST = "f0f47a39f898c08d39490dd0ad300dac3fe717153772371d458295134180c185" +_MCP_TABLE_ROWS = 316 +_MCP_TABLE_DIGEST = "12b8596ce66bd9e06e8bd1069908d37039af7459dbfe8ffc4bb5ca6d60dd5430" def test_the_mcp_route_table_keeps_its_rows_and_order() -> None: diff --git a/test/test_dashboard_server_coverage.py b/test/test_dashboard_server_coverage.py index 7ca335e298f..f4776af8f33 100644 --- a/test/test_dashboard_server_coverage.py +++ b/test/test_dashboard_server_coverage.py @@ -700,6 +700,48 @@ async def _to_thread(func: Any, *args: Any) -> Any: assert calls == ["reclaim"] assert state._background_tasks == set() # the done callback discards it + @pytest.mark.asyncio + async def test_the_cookie_watcher_start_waits_on_nothing_before_the_bind( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """The startup hook runs before the port binds, so it must not await the + cookie watcher's start: the start runs in a background task, which cannot + have run yet when the hook returns. Cleanup stops the watcher.""" + calls: list[str] = [] + + async def _to_thread(func: Any, *args: Any) -> Any: + return func(*args) + + monkeypatch.setattr( + srv.browser_cli_cookies, "start_session_watcher", lambda: calls.append("start") + ) + monkeypatch.setattr( + srv.browser_cli_cookies, "stop_session_watcher", lambda: calls.append("stop") + ) + monkeypatch.setattr(asyncio, "to_thread", _to_thread) + app = web.Application() + state = _state() + srv._register_browser_cookie_watcher(app, state) + [startup] = [h for h in app.on_startup if h.__name__ == "_cookie_watcher_startup"] + [shutdown] = [h for h in app.on_cleanup if h.__name__ == "_cookie_watcher_shutdown"] + + await startup(app) + + assert calls == [] + await asyncio.gather(*state._background_tasks) + assert calls == ["start"] + await shutdown(app) + assert calls == ["start", "stop"] + + @pytest.mark.parametrize("entrypoint", ["start_dashboard", "start_api_server"]) + def test_both_entrypoints_register_the_cookie_watcher(self, entrypoint: str) -> None: + """A gateway restarted headless (``--slack-only``) after an import must keep + injecting the stored cookies into new browsers, so the API-only server + registers the watcher exactly as the dashboard does.""" + source = inspect.getsource(getattr(srv, entrypoint)) + assert "_register_browser_cookie_watcher(app, state)" in source + def test_the_gateway_passes_its_state_to_the_registration(self) -> None: source = inspect.getsource(srv.start_dashboard) assert "_register_browser_view_cleanup(app, state)" in source diff --git a/test/test_handlers_messaging_composition_contract.py b/test/test_handlers_messaging_composition_contract.py index 8e793a5f9d2..aa2eb631580 100644 --- a/test/test_handlers_messaging_composition_contract.py +++ b/test/test_handlers_messaging_composition_contract.py @@ -413,7 +413,7 @@ def test_the_routes_and_the_package_reach_the_facade_objects() -> None: assert len({id(h) for h in served}) >= 28 assert [h.__name__ for h in served if getattr(msg, h.__name__) is not h] == [] reexports = _package_reexports() - assert len(reexports) == 59 + assert len(reexports) == 62 assert [n for n in reexports if getattr(handlers_pkg, n) is not getattr(msg, n)] == [] server_names = _server_handler_names() assert {"api_spawn", "api_send_message", "stop_browser_install"} <= server_names @@ -1329,11 +1329,12 @@ def test_the_whole_census_holds_the_known_rebinds_and_an_owner_s_definitions() - # ── the path-keyed guards keep their reach ──────────────────────────────────── #: Constructs repository guards read by path, each with the owners that hold it beside -#: ``dashboard/handlers/messaging.py``: the two bounded body reads the JSON-body register keys +#: ``dashboard/handlers/messaging.py``: the bounded body reads the JSON-body register keys #: by function -- the Teams activity route's in the facade, the agent push's in -#: ``agent_push``, a row the register names by that path. An owner that grew one would -#: move it out of the guard's sight, so each stays in exactly these files. -_STAYS_IN_THE_FACADE = ((r"await read_bounded_json\(", ("agent_push",)),) +#: ``agent_push`` and the cookie import's in ``browser_routes``, each a row the register +#: names by that path. An owner that grew one would move it out of the guard's sight, so +#: each stays in exactly these files. +_STAYS_IN_THE_FACADE = ((r"await read_bounded_json\(", ("agent_push", "browser_routes")),) @pytest.mark.parametrize( diff --git a/test/test_json_object_body_guard.py b/test/test_json_object_body_guard.py index 913f3647071..e9f340788c6 100644 --- a/test/test_json_object_body_guard.py +++ b/test/test_json_object_body_guard.py @@ -201,6 +201,17 @@ async def json(self): "TEAMS_MAX_ACTIVITY_BYTES", _BOUNDED_EXPLICIT, ), + # A cookie export is user content that legitimately exceeds 64 KB (a browser + # profile with a few hundred cookies, or a storageState document carrying + # origins), so the route owns its cap beside the parser's own limits in + # kiro_crew/browser_cli/cookies.py: MAX_IMPORT_BYTES (2 MiB) and the 5000-cookie + # ceiling are the same decision, made once. The body cap is twice that plus + # 1 KiB because the export travels JSON-escaped (tabs, newlines and quotes + # double); the parser still refuses content over MAX_IMPORT_BYTES. + "messaging_api/browser_routes.py::api_browser_cookies_import": ( + "2 * browser_cli_cookies.MAX_IMPORT_BYTES + 1024", + _BOUNDED_EXPLICIT, + ), # The UI-preference backup stores values up to MAX_VALUE_BYTES (64 KB) and a # document up to MAX_TOTAL_BYTES, so the shared 64 KB default -- exactly one # legal value, with no room for the JSON envelope -- would 413 a patch the diff --git a/test/test_sandbox_governance_mask.py b/test/test_sandbox_governance_mask.py index 9ef6fe84579..7a139d7981c 100644 --- a/test/test_sandbox_governance_mask.py +++ b/test/test_sandbox_governance_mask.py @@ -392,6 +392,9 @@ class TestSecretsAreMaskedInEveryMode: "member-records", "backup", "browser-cookies.txt", + # Imported cookies: read only by the gateway (browser_cli/cookies.py), + # so the agent's shell has no reader for the directory. + "browser-cookie-import", "playwright-storage-state.json", "playwright-extension-token", "ops_mission_control_secrets.json", diff --git a/test/test_sandbox_protected_name_holds.py b/test/test_sandbox_protected_name_holds.py index 25297e87de8..d601861bec0 100644 --- a/test/test_sandbox_protected_name_holds.py +++ b/test/test_sandbox_protected_name_holds.py @@ -387,13 +387,19 @@ class TestLeafOnlyPopulationIsRecorded: #: data-home root beside ``crew-teams`` rather than under the sandbox #: read-write ``trust/``, so leaf-only for the same reason. #: - #: One more root-level directory, three entries per tier: + #: Two more root-level directories, three entries per tier each: #: #: * ``web-push`` -- the Web Push VAPID key and device subscription secrets #: (``notifications/push_store.py``), masked as a whole directory so the #: ``atomic_write`` temps they publish through are hidden too. At the data-home #: root beside writable siblings, so leaf-only is the only hold. - EXPECTED: dict[str, int] = {"standard": 296, "cc": 303, "strict": 304} + #: + #: * ``browser-cookie-import`` -- the imported browser cookies + #: (``browser_cli/cookies.py``), masked and pre-created. A directory so that + #: the gateway's ``atomic_write`` of the state renames INSIDE the masked name, + #: never over it; at the data-home root beside writable siblings, so + #: leaf-only is the only hold available for the directory itself. + EXPECTED: dict[str, int] = {"standard": 299, "cc": 306, "strict": 307} @pytest.mark.parametrize("tier", TIERS) def test_leaf_only_count_has_not_grown(self, tier: str) -> None: diff --git a/test/test_security_gate_liveness.py b/test/test_security_gate_liveness.py index 9e64c33933a..877f50b1317 100644 --- a/test/test_security_gate_liveness.py +++ b/test/test_security_gate_liveness.py @@ -532,7 +532,11 @@ def _url_payload_command(n: int) -> str: #: substitution may expand to nothing in the program word, and #: ``#`` / ``<<`` inside an unpaired ``${…}`` / ``$[…]`` open no comment or heredoc. #: Each command is read with and without that tracking. Deny-only: no allow widened. -_PACKAGE_LINE_BUDGET = 32_635 +#: +#: Raised by 1, from 32,635, for the ``browser-cookie-import`` entry in +#: ``_CREW_SECRET_LEAVES``: the imported browser cookies, fenced at the tool gate as +#: well as masked. +_PACKAGE_LINE_BUDGET = 32_636 #: Ceiling on any ONE file in the package. This is what the bound is really for -- #: a package total says nothing about a single file growing back into a second diff --git a/website/capture/cookie-import.html b/website/capture/cookie-import.html new file mode 100644 index 00000000000..9697612ca3d --- /dev/null +++ b/website/capture/cookie-import.html @@ -0,0 +1,11 @@ + + + + + cookie-import capture + + +
+ + + diff --git a/website/capture/cookie-import.tsx b/website/capture/cookie-import.tsx new file mode 100644 index 00000000000..0a5ed10c342 --- /dev/null +++ b/website/capture/cookie-import.tsx @@ -0,0 +1,128 @@ +/** + * Evidence for the Import-cookies control (browser cookie import). + * + * Mounts the REAL CookieDialog, CookieChip, CookieMenuItem and + * CookieClearMenuItem from WebPreviewCookies.tsx with the REAL i18n keys, so the + * frame photographs the shipped markup and strings. The `cookies` hook result + * is stubbed (no gateway); the components are pure over it. + * + * ?scene=dialog the import dialog (opening sentence, How to export, file, paste, actions) + * ?scene=chip the imported-cookies status chip in a mock toolbar (&compact=1 for the Live header) + * ?scene=menu the More actions menu with Import cookies and Clear cookies (&status=fail + * for a failed status read, &clear=fail or &clear=live for a failed clear) + * &import=fail|live what the dialog's Import answers: a refused request, or saved with one + * live session refusing it + * &file=fail every FileReader read fails, for the "couldn't read that file" state + */ +import { createRoot } from 'react-dom/client' +import { MoreHorizontal, Monitor } from 'lucide-react' + +import { CookieDialog, CookieChip, CookieMenuItem, CookieClearMenuItem, CookieNewSessionHint } from '../src/components/WebPreviewCookies' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '../src/components/ui/dropdown-menu' +import { initI18n } from '../src/i18n' +import { applyFallbackTheme } from '../src/apps/mochi/src/shared/themes' +import '../src/index.css' + +const params = new URLSearchParams(location.search) +const scene = params.get('scene') ?? 'dialog' + +document.documentElement.setAttribute('data-theme', 'kiro-dark') +applyFallbackTheme() +initI18n('en') + +if (params.get('file') === 'fail') { + class FailingReader { + result: string | null = null + onload: (() => void) | null = null + onerror: (() => void) | null = null + readAsText() { setTimeout(() => this.onerror?.(), 0) } + } + ;(window as unknown as { FileReader: unknown }).FileReader = FailingReader +} + +const SUMMARY = { + cookie_count: 34, + domains: ['example.com', 'api.example.com', 'auth.example.com', 'cdn.example.com'], + earliest_expiry: Math.floor(Date.now() / 1000) + 19 * 3600, + imported_at: Math.floor(Date.now() / 1000), +} + +const importAnswer = async () => { + if (params.get('import') === 'fail') { + throw new Error("The computer running Kiro Crew cannot hand cookies to the agent's browser, so nothing was imported") + } + const failed: Record = params.get('import') === 'live' ? { 'kc-0a1b2c3d': 'timed out' } : {} + return { ok: true as const, summary: SUMMARY, hot_load: { loaded: ['kc-9f8e7d6c'], failed } } +} + +const clearAnswer = async () => { + if (params.get('clear') === 'fail') throw new Error("Couldn't clear the cookies") + const failed: Record = params.get('clear') === 'live' ? { 'kc-0a1b2c3d': 'timed out' } : {} + return { ok: true as const, present: false as const, live: { cleared: [], failed } } +} + +// A stub of the useBrowserCookies result. The components only read these fields. +const stub = (present: boolean) => ({ + data: { present, summary: present ? SUMMARY : null }, + pending: false, + error: params.get('status') === 'fail' ? new Error("Couldn't read the imported cookies") : null, + forbidden: false, + importCookies: importAnswer, + importing: false, + importError: null, + lastImport: null, + clear: clearAnswer, + clearing: false, + clearError: null, +}) as unknown as Parameters[0]['cookies'] + +function Scene() { + if (scene === 'chip') { + const compact = params.get('compact') === '1' + return ( +
+ + +
+ ) + } + if (scene === 'menu') { + const cookies = stub(params.get('present') !== '0') + return ( +
+ + + + + + + + Browser view + + { /* capture only */ }} /> + + + +
+ ) + } + return ( +
+ { /* capture only */ }} + onImported={() => { /* capture only */ }} + /> +
+ ) +} + +createRoot(document.getElementById('root')!).render() diff --git a/website/src/api/client.ts b/website/src/api/client.ts index 540433c74fe..0342804ce9f 100644 --- a/website/src/api/client.ts +++ b/website/src/api/client.ts @@ -234,6 +234,10 @@ export type { BrowserInstallJob, BrowserViewData, BrowserOpenData, + BrowserCookiesSummary, + BrowserCookiesStatus, + BrowserCookiesImportResult, + BrowserCookiesClearResult, ComputerUsePermissions, ComputerUseConfigData, ComputerUseConfigSave, diff --git a/website/src/api/client/browserAndComputerUse.ts b/website/src/api/client/browserAndComputerUse.ts index c7c6e34b54c..cf1d57aa53e 100644 --- a/website/src/api/client/browserAndComputerUse.ts +++ b/website/src/api/client/browserAndComputerUse.ts @@ -150,6 +150,57 @@ export interface BrowserOpenData { view: BrowserViewData } +/** Summary of the imported Playwright storageState the gateway holds. NEVER + * carries cookie values — only the counts, domains and expiries needed to + * describe the set to the user. `imported_at` and `earliest_expiry` are unix + * seconds; `earliest_expiry` is null when every cookie is session-scoped. */ +export interface BrowserCookiesSummary { + cookie_count: number + domains: string[] + earliest_expiry: number | null + imported_at: number +} + +/** Answer of GET /api/browser/cookies: whether an imported cookie set exists and, + * if so, its value-free summary. */ +export interface BrowserCookiesStatus { + present: boolean + summary: BrowserCookiesSummary | null +} + +/** Answer of POST /api/browser/cookies. `summary` is the value-free description + * of the newly-stored set. `hot_load` reports the best-effort attempt to apply + * the cookies to the agent's LIVE browser sessions: `loaded` names the sessions + * that took them, `failed` maps a session name to why it did not, and `note` + * explains a case where no session could be enumerated at all. `deliverable` + * reports whether new agent sessions can receive the saved set. Only a + * deliverable import without live failures and with an empty `loaded` can tell + * the user the cookies apply to NEW sessions when the view is running. */ +export interface BrowserCookiesImportResult { + ok: true + summary: BrowserCookiesSummary + hot_load: { + deliverable: boolean + loaded: string[] + failed: Record + note?: string + } +} + +/** Answer of DELETE /api/browser/cookies. The stored set is gone (`present: + * false`); `live` reports the best-effort `cookie-clear` on the agent's LIVE + * browser sessions: `cleared` names the sessions that took it, `failed` maps a + * session name to why it did not. A non-empty `failed` means a live session may + * still be signed in until it closes — the panel says so. */ +export interface BrowserCookiesClearResult { + ok: true + present: false + live: { + cleared: string[] + failed: Record + } +} + /** ADVISORY macOS permission rows. Never a gate — macOS attributes a TCC grant * to the responsible parent process, so `missing` can coexist with a working * capture, and `unknown` means the probe could not be run. */ @@ -215,7 +266,7 @@ export interface ComputerUseConfigSave { extra_denied_apps: string[] } -export function createBrowserAndComputerUseEndpoints({ get, post, put, j }: ClientTransport) { +export function createBrowserAndComputerUseEndpoints({ get, post, put, del, j }: ClientTransport) { const hostAutomation = { // Playwright CLI browser view. GET reports; POST /start is idempotent and // returns the SAME shape, so a start needs no follow-up read. @@ -232,6 +283,25 @@ export function createBrowserAndComputerUseEndpoints({ get, post, put, j }: Clie // alongside the verdict, so a success frames the view with no follow-up read. openInBrowser: (url: string, sessionKey: string) => post('/api/browser/open', { url, session_key: sessionKey }).then(j) as Promise, + // Imported browser cookies (a Playwright storageState the gateway stores and + // applies to the agent's browser sessions). GET reports a value-free summary; + // POST stores a pasted/loaded export and hot-loads it into live sessions; + // DELETE clears it. All three are owner-only (403 to a non-owner), which the + // hook treats as "hide the control" rather than an error. + // `sessionKey` MUST carry the active slot's key when one is active: the + // server's restricted-session guard reads X-Session-Key, and the shared + // `dashboard:ui` default answers "not restricted" — which would let an + // incognito/temporary slot read the sites a stored credential unlocks, or + // persist a logged-in browser state the slot promised to keep nothing of. + // Same contract as `openInBrowser` and the mobile-link surface; the guard + // answers 403, which the hook maps to "hide the control". + getBrowserCookies: (sessionKey?: string) => + get('/api/browser/cookies', sessionKey).then(j) as Promise, + importBrowserCookies: (content: string, sessionKey?: string) => + post('/api/browser/cookies', { content }, sessionKey) + .then(j) as Promise, + clearBrowserCookies: (sessionKey?: string) => + del('/api/browser/cookies', undefined, sessionKey).then(j) as Promise, // Computer use (desktop automation). The PUT returns the refreshed snapshot so // the panel re-renders from server truth rather than its optimistic guess. getComputerUseConfig: () => get('/api/computer-use/config').then(j) as Promise, diff --git a/website/src/components/WebPreviewCookies.tsx b/website/src/components/WebPreviewCookies.tsx new file mode 100644 index 00000000000..415ba236717 --- /dev/null +++ b/website/src/components/WebPreviewCookies.tsx @@ -0,0 +1,537 @@ +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' +import { Cookie, Upload, Loader2, AlertTriangle, Check, Trash2, FileText } from 'lucide-react' + +import { ApiError } from '../api/apiError' +import { fmtNumber, fmtRelative } from '../i18n/format' +import { i18nT } from '../i18n/t' +import { previewCookieExport } from '../lib/cookieExportPreview' +import type { useBrowserCookies } from '../hooks/useBrowserCookies' +import { DropdownMenuItem } from './ui/dropdown-menu' +import ErrorNotice, { ErrorNoticeMenuItem } from './ErrorNotice' +import Modal from './Modal' + +/** The shared shape returned by `useBrowserCookies`, passed to each piece so the + * panel holds ONE hook instance (one status read, one dialog) while the trigger, + * chip and dialog render in their own places. */ +type Cookies = ReturnType + +/** How many domains the status-chip tooltip lists before it caps with "+N more". */ +const DOMAIN_TOOLTIP_CAP = 15 + +/** Extract the user-readable message from a failed request. `friendlyErrText` + * (in the api layer) already unwraps a `{"error": "…"}` 400 body into + * `ApiError.message`, so the message is the thing to show. */ +function requestErrorText(e: unknown, fallback: string): string { + if (e instanceof ApiError && e.message) return e.message + if (e instanceof Error && e.message) return e.message + return fallback +} + +/** How long an armed Clear stays armed before it disarms itself. */ +const CLEAR_ARM_MS = 3000 + +/** The domain tooltip: distinct domains, capped, with a localized "+N more". */ +function domainsTooltip(domains: string[]): string { + if (domains.length <= DOMAIN_TOOLTIP_CAP) return domains.join('\n') + const shown = domains.slice(0, DOMAIN_TOOLTIP_CAP) + const rest = domains.length - DOMAIN_TOOLTIP_CAP + return `${shown.join('\n')}\n${i18nT('components.webPreviewPanel.cookies_domains_more', { count: fmtNumber(rest) })}` +} + +/** How many domains the dialog's pre-import preview names before "+N more". */ +const PREVIEW_DOMAIN_CAP = 3 + +/** The preview's domain list: the first few, then a localized "+N more". */ +function previewDomains(domains: string[]): string { + const shown = domains.slice(0, PREVIEW_DOMAIN_CAP).join(', ') + const rest = domains.length - PREVIEW_DOMAIN_CAP + if (rest <= 0) return shown + return `${shown}, ${i18nT('components.webPreviewPanel.cookies_domains_more', { count: fmtNumber(rest) })}` +} + +/** The full chip line: "34 cookies · 12 sites · expires in 19h". Counts follow + * the active locale (fmtNumber); the expiry is a relative time (fmtRelative), + * omitted when every cookie is session-scoped. */ +function cookieChipText(summary: { + cookie_count: number + domains: string[] + earliest_expiry: number | null +}): string { + const parts = [ + i18nT('components.webPreviewPanel.cookies_count', { count: fmtNumber(summary.cookie_count) }), + i18nT('components.webPreviewPanel.cookies_sites', { count: fmtNumber(summary.domains.length) }), + ] + if (summary.earliest_expiry != null) { + parts.push(i18nT('components.webPreviewPanel.cookies_expires', { + when: fmtRelative(summary.earliest_expiry), + })) + } + return parts.join(' · ') +} + +/** + * The overflow-menu entry that opens the import dialog. Lives INSIDE the preview + * toolbar's existing "More actions" dropdown rather than as another toolbar + * button, so the row's sibling-button count (guarded for the + * max-two-buttons-per-row rule) does not grow. Hidden for a non-owner. + */ +export function CookieMenuItem({ cookies, onOpen }: { cookies: Cookies; onOpen: () => void }) { + const errorId = useId() + if (cookies.forbidden) return null + // A status read that kept failing (retries are bounded in the hook) is said + // here, beside the control it affects, with the hand-off as its own focus stop + // the way `CookieClearMenuItem` renders a failed clear. + const failure = cookies.error + ? requestErrorText(cookies.error, i18nT('components.webPreviewPanel.cookies_status_failed')) + : null + return ( + <> + + + {i18nT('components.webPreviewPanel.import_cookies')} + {failure && ( + e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + > + + + )} + + {failure && ( + + )} + + ) +} + +/** + * The overflow-menu entry that clears the stored set. Sits beside + * `CookieMenuItem` in the same "More actions" dropdown, so clearing costs the + * toolbar and the Live-view header no extra button (max-two-buttons-per-row). + * + * Two-step: the first select ARMS the item (the menu stays open via + * `preventDefault`, matching `ExportSessionItem`, and the label turns into the + * confirm wording); the second select within `CLEAR_ARM_MS` sends the DELETE. + * The menu also stays open on the second select so the outcome — a spinner, + * then either the item disappearing with the chip, or a failure — renders on + * the row rather than vanishing with the menu. + * + * A failed DELETE renders through `ErrorNotice` (the passive alert, `askAgent` + * off) with the hand-off as a sibling `ErrorNoticeMenuItem` whose + * `describedBy` is the notice's `id`: inside Radix menu content a nested button + * is skipped by the roving focus, so the hand-off has to be its own focus stop. + * + * A DELETE that succeeded on disk but whose `live.failed` is non-empty still + * failed to clear some open browsers. That partial failure uses the same + * passive alert and sibling hand-off, naming what remains and when it ends. + * The row stays mounted (disabled — there is nothing further to clear) while + * that notice is showing, so the outcome does not vanish with the chip. + * + * Hidden for a non-owner and when nothing is imported (and no notice is up). + */ +export function CookieClearMenuItem({ cookies }: { cookies: Cookies }) { + const [armed, setArmed] = useState(false) + const armTimer = useRef | null>(null) + const errorId = useId() + const failure = cookies.clearError + ? requestErrorText(cookies.clearError, i18nT('components.webPreviewPanel.cookies_clear_failed')) + : null + const stillHolding = Object.keys(cookies.lastClear?.live?.failed ?? {}).length + const partial = stillHolding > 0 + ? i18nT('components.webPreviewPanel.cookies_clear_live_failed', { count: fmtNumber(stillHolding) }) + : null + + useEffect(() => () => { if (armTimer.current) clearTimeout(armTimer.current) }, []) + + const onSelect = useCallback((event: Event) => { + // Keep the menu open: the armed state and the outcome both render here. + event.preventDefault() + if (cookies.clearing) return + if (!armed) { + setArmed(true) + cookies.resetClear() + armTimer.current = setTimeout(() => setArmed(false), CLEAR_ARM_MS) + return + } + if (armTimer.current) clearTimeout(armTimer.current) + setArmed(false) + void cookies.clear().catch(() => {}) + }, [armed, cookies]) + + const present = Boolean(cookies.data?.present) + if (cookies.forbidden || (!present && !failure && !partial)) return null + + const label = armed + ? i18nT('components.webPreviewPanel.cookies_clear_confirm') + : i18nT('components.webPreviewPanel.cookies_clear') + const clearError = failure ?? partial + + return ( + <> + + {cookies.clearing + ? + : } + {/* The armed colour sits on the row's children, not the menu item: the + item's own colour belongs to the primitive. */} + {label} + {clearError && ( + // The passive alert. This wrapper swallows pointer events so a click on + // the notice does not bubble to the row and re-arm the clear. + e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + > + + + )} + + {clearError && ( + + )} + + ) +} + +/** + * The READ-ONLY status chip: "34 cookies · 12 sites · expires in 19h" with a + * domain tooltip. It carries no action — Clear lives in the overflow menu + * (`CookieClearMenuItem`) so neither the toolbar nor the Live-view header grows + * a button. Shown only when a set is imported. `compact` drops the text to just + * the count for the tight overlay header (full detail stays in the tooltip). + * Hidden for a non-owner or when nothing is imported. + */ +export function CookieChip({ cookies, compact = false }: { cookies: Cookies; compact?: boolean }) { + const summary = cookies.data?.summary ?? null + if (cookies.forbidden || !cookies.data?.present || !summary) return null + + const label = compact + ? i18nT('components.webPreviewPanel.cookies_chip_compact', { count: fmtNumber(summary.cookie_count) }) + : cookieChipText(summary) + const tooltip = compact + ? `${cookieChipText(summary)}\n${domainsTooltip(summary.domains)}` + : domainsTooltip(summary.domains) + + return ( + + + {label} + + ) +} + +/** + * The "applies to new sessions" hint, shown after a successful import that + * reached no live browser session while the view is running. + */ +export function CookieNewSessionHint({ show }: { show: boolean }) { + if (!show) return null + return ( + + + {i18nT('components.webPreviewPanel.cookies_apply_to_new_sessions')} + + ) +} + +/** + * The import dialog, on the shared `Modal`: a plain opening sentence (which + * sites, for how long, how to undo), a short "How to export" list, the file + * picker (.json/.txt via FileReader) and the paste textarea, then Import/Cancel. + * Rendered ONCE by the panel. `Modal` gives it the keyboard isolation every + * dialog needs: the page's global shortcuts never see a keystroke typed here, so + * no chord can navigate away and unmount the dialog with the unsaved paste in + * it. While the paste holds text the backdrop and Escape do not dismiss it, and + * while the import is in flight nothing does. + * + * A failed operation (the request, or reading the chosen file) renders through + * `ErrorNotice` and keeps the dialog open; only "nothing to import yet" is an + * inline hint, because nothing has failed. `onImported` reports whether the + * successful import reached no live session (so the panel can show the + * new-session hint). + */ +export function CookieDialog({ + cookies, + viewRunning, + open, + onClose, + onImported, +}: { + cookies: Cookies + viewRunning: boolean + open: boolean + onClose: () => void + onImported: (reachedNewSessionsOnly: boolean) => void +}) { + const [paste, setPaste] = useState('') + const [filename, setFilename] = useState(undefined) + // errors-use-error-notice draws the line by whether something FAILED: + // `validationHint` is "paste something first", where nothing has, and is not + // an error surface; `failure` is an operation that did fail (reading the + // chosen file, or the import request) and renders through ErrorNotice. + const [validationHint, setValidationHint] = useState(null) + const [failure, setFailure] = useState(null) + // The set was saved, but delivery to new or open browsers failed. + const [liveNotice, setLiveNotice] = useState(null) + const [saved, setSaved] = useState(false) + const fileRef = useRef(null) + const preview = useMemo(() => previewCookieExport(paste), [paste]) + + const reset = useCallback(() => { + setPaste('') + setFilename(undefined) + setValidationHint(null) + setFailure(null) + setLiveNotice(null) + setSaved(false) + if (fileRef.current) fileRef.current.value = '' + }, []) + + const close = useCallback(() => { reset(); onClose() }, [reset, onClose]) + + const onFile = useCallback((file: File) => { + const reader = new FileReader() + reader.onload = () => { + setPaste(typeof reader.result === 'string' ? reader.result : '') + setFilename(file.name) + setValidationHint(null) + setFailure(null) + setLiveNotice(null) + setSaved(false) + } + reader.onerror = () => { + setValidationHint(null) + setFailure(i18nT('components.webPreviewPanel.cookies_file_read_failed')) + } + reader.readAsText(file) + }, []) + + const submit = useCallback(async () => { + if (saved) return + // Trim only to test for blank input. The export is sent unchanged: a + // Netscape line whose value is empty ends in a tab, and trimming it away + // leaves six columns, so the parser would drop that cookie. + if (!paste.trim()) { + setValidationHint(i18nT('components.webPreviewPanel.cookies_paste_or_choose_a_file')) + return + } + setValidationHint(null) + setFailure(null) + setLiveNotice(null) + try { + const result = await cookies.importCookies(paste) + setSaved(true) + // Saving is not delivery: an unreachable host must never promise that + // a new browser session will receive the imported set. + if (result.hot_load.deliverable === false) { + onImported(false) + setLiveNotice(i18nT('components.webPreviewPanel.cookies_import_unreachable')) + return + } + // Saved, but some live sessions refused it: say so and keep the dialog + // open, rather than closing as if every browser now carries the set. + const refused = Object.keys(result.hot_load.failed ?? {}).length + if (refused > 0) { + onImported(false) + setLiveNotice(i18nT('components.webPreviewPanel.cookies_import_live_failed', { + count: fmtNumber(refused), + })) + return + } + const reachedNone = result.hot_load.loaded.length === 0 + onImported(viewRunning && reachedNone) + close() + } catch (e) { + // A failed request (a 400 for malformed input, a host that cannot reach + // the agent's browser, or anything else); the dialog stays open so the + // user can fix the paste and retry. + setFailure(requestErrorText(e, i18nT('components.webPreviewPanel.cookies_import_failed_generic'))) + } + }, [paste, saved, cookies, viewRunning, onImported, close]) + + const footer = ( + <> + + {!saved && ( + + )} + + ) + + return ( + +
+

+ {i18nT('components.webPreviewPanel.cookies_dialog_intro')} +

+

+ {i18nT('components.webPreviewPanel.cookies_dialog_values_stay')} +

+ +
+ + {i18nT('components.webPreviewPanel.cookies_how_to_export')} + +
    +
  • {i18nT('components.webPreviewPanel.cookies_export_extension')}
  • +
  • {i18nT('components.webPreviewPanel.cookies_export_cookies_txt')}
  • +
  • {i18nT('components.webPreviewPanel.cookies_export_playwright')}
  • +
+
+ +
+ + {filename && ( + {filename} + )} + { + const file = e.target.files?.[0] + e.target.value = '' + if (file) onFile(file) + }} + /> +
+ +