fix: merge-time follow-ups for #400, #401, #362 and #388

Each item is from the pre-merge review of the PR it names, applied on master
rather than by pushing to a contributor branch.

#400 (response viewer, shenlvkang-collab)
- The brief view opened at `scrollTop = 0`, right when it was a single card
  holding the last row. Now that it renders the whole turn, the top is the
  turn's first narration line and the answer can be screens below it, while
  loadFullContext already scrolls to the bottom of the same turn. A multi-row
  turn now opens at its newest text; a single card still opens at the top.

#401 (loopback links as web tabs, shenlvkang-collab)
- Drop `*.localhost` from the auto-route set. Every other member is an address
  literal that can only mean this box; a `*.localhost` DNS name is not one, and
  a resolver with a search domain retries `evil.localhost` as
  `evil.localhost.<search domain>`. The link source is agent-written terminal
  output, so that set is the whole confinement on a tap that makes Codeman
  fetch a URL server-side and persist it. The page-side test stays broader
  (`isOnBoxHostname`), where a false positive only declines to proxy.
- A link to the origin root navigated nothing: the path was flattened to '',
  which openWebview reads as "no deep link", leaving an open frame where it was.
- `this.webviews` being set does not mean it is loaded. initWebviews() assigns a
  truthy empty map and only then awaits the list, so a tap during page load
  found nothing to reuse and POSTed a duplicate record. Join the in-flight
  refresh instead.
- One dashboard per dev server rather than per host spelling, which is what the
  method's own comment already promised.
- Toast on the auto-create: it writes webviews.json, broadcasts over SSE and
  adds a Run-dropdown row on every signed-in device, with a new tab as its only
  previous signal.

#362 (remote omp continuation, timkjr)
- Accept the allowlisted `mode === 'omp'` arm as-is; a blanket registry render
  would hand deepseek a locally-resolved --profile and bypass claude's own
  overlay. A registry-declared switch is the follow-up if a third mode needs it.
- Revert the whole-file Prettier reformat of docs/remote-sessions.md (docs/ is
  hand-formatted and outside `npm run format`), keeping only the two new
  sections.
- Correct three stale passages: architecture-invariants' `exec claude
  --dangerously-skip-permissions`, the `exec <cli>` paragraph (claude and omp
  now have their own arms, and the claude pane's PID is the login shell), and
  omp-integration's `-c 'omp'`. RemoteCommandMode gains deepseek and omp.
- Add the missing `_maybeCaptureOmpSessionId` remote-guard test; the sibling
  guard in `_pinOmpRespawnId` had one and this path runs earlier, on the first
  idle turn.

#388 (keyCode 229 recovery, aakhter)
- Gate notifyCanonicalData on shouldSuppressTerminalQueryResponse and
  isTerminalFocusOrMouseReport. onData also carries the DA/DSR/CPR/OSC replies
  xterm answers during Ink redraws and its SGR mouse and focus reports; any of
  those landing between the keydown and the candidate's resolution was read as
  "xterm spoke for this keystroke", standing the recovery down and leaving the
  character dropped, worst on a busy agent pane. Reached through
  window.CodemanTerminalInput: the predicates live in a module IIFE that closes
  long before this call site, so bare references would throw into the
  surrounding try/catch and stop the notify from ever running.

Every fix has a test that fails without it (verified by reverting each).
Full gate green on the combined tree: 358 files, 6849 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-09-12 05:15:41 +02:00
parent 9d664ffe01
commit 02b0e27898
11 changed files with 368 additions and 51 deletions
+1 -1
View File
@@ -280,7 +280,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Cross-session search**: `GET /api/search` federates an in-memory search over session metadata, run-summary events, and attachment-history entries. The pure core `searchSources()` does substring matching with hard per-type caps: **no regex (so no ReDoS) and no filesystem reads (so no traversal)**. The server-private `externalPath` is never read. PAST sessions (#261) come from `session-history-index.ts`, a capped snapshot of the unified list filled **outside** the request path (`/api/sessions/unified` publishes it; a stale one is rebuilt fire-and-forget), that indirection is what keeps the no-fs property. ⚠️ The snapshot is stored UNSCOPED with a per-row owner and MUST be re-filtered through `canAccessOwned()` on read; history rows carry `jumpTo.kind:'resume-session'`, since a closed session has no tab to select. → [architecture-invariants#cross-session-search](docs/architecture-invariants.md#cross-session-search) **Cross-session search**: `GET /api/search` federates an in-memory search over session metadata, run-summary events, and attachment-history entries. The pure core `searchSources()` does substring matching with hard per-type caps: **no regex (so no ReDoS) and no filesystem reads (so no traversal)**. The server-private `externalPath` is never read. PAST sessions (#261) come from `session-history-index.ts`, a capped snapshot of the unified list filled **outside** the request path (`/api/sessions/unified` publishes it; a stale one is rebuilt fire-and-forget), that indirection is what keeps the no-fs property. ⚠️ The snapshot is stored UNSCOPED with a per-row owner and MUST be re-filtered through `canAccessOwned()` on read; history rows carry `jumpTo.kind:'resume-session'`, since a closed session has no tab to select. → [architecture-invariants#cross-session-search](docs/architecture-invariants.md#cross-session-search)
**Web tabs** (dashboard URLs as tabs): a saved URL renders as a tab beside agent sessions. **NOT a `SessionMode` of its own** (no PTY, no tmux, no respawn), same reasoning that keeps Docker/remote-SSH as case overlays. Dashboards are **proxied through Codeman's own origin** by default, because a direct iframe fails three ways at once: prod is HTTPS so `http://` targets are blocked as mixed content, many dashboards send `X-Frame-Options: DENY`, and our own `default-src 'self'` CSP blocks cross-origin frames. Proxying leaves the prod CSP unchanged (`/webview/...` is `'self'`). ⚠️ The proxy is **NOT an API surface**: it authenticates on an in-memory capability in the path and is correspondingly exempt from the cookie + Origin checks; that exemption is fenced to safe methods and non-`/api` paths and is pinned by `test/webview-auth-exemption.test.ts`. ⚠️ Iframes omit `allow-same-origin` unless a dashboard is explicitly marked `trusted`, and `Authorization`/`codeman_session` are stripped upstream in **both** modes so `CODEMAN_PASSWORD` cannot leak. ⚠️ A sandboxed frame is **opaque-origin**, which breaks two things `curl` can never reproduce: its runtime-built root-absolute URLs escape `<base>` (fixed by an injected `runtimeUrlShim()`), and its same-host `fetch`/XHR are CORS-checked with `Origin: null` (fixed by `buildProxyCorsHeaders()` plus exempting the proxy from the global `OPTIONS`-204 short-circuit in `registerSecurityHeaders`). Both present as the dashboard's own "Failed to fetch" while the page renders fine. ⚠️ **Egress guard**: link-local and cloud-metadata targets (`169.254.0.0/16`, `fe80::/10`, `fd00:ec2::254`, the Azure/Alibaba fixed addresses, `metadata.google.internal`) are refused at save time AND on the RESOLVED address at connect time (`webview-egress-policy.ts`, pure, plus `webview-egress.ts`: a `lookup` hook on the undici Agent behind `webviewFetch()` and on the `ws` client). An IP literal never reaches a lookup hook (`net.connect` skips DNS for it), so the synchronous hostname check at each connect site is NOT redundant. Loopback and RFC1918 stay allowed on purpose: a `localhost` Grafana is the feature. The proxy uses the `undici` PACKAGE's own `fetch` + `Agent`, never Node's global fetch with a foreign dispatcher (Node bundles its own copy; a protocol mismatch fails silently). Capabilities are revoked on logout / admin logout / user deletion (`revokeOwner`, which had NO caller for two releases while its docstring said otherwise), and proxied responses carry `Referrer-Policy: same-origin` so a dashboard cannot hand the capability-bearing URL to a third party. → [architecture-invariants#web-tabs](docs/architecture-invariants.md#web-tabs), `docs/web-tabs.md` **Web tabs** (dashboard URLs as tabs): a saved URL renders as a tab beside agent sessions. **NOT a `SessionMode` of its own** (no PTY, no tmux, no respawn), same reasoning that keeps Docker/remote-SSH as case overlays. Dashboards are **proxied through Codeman's own origin** by default, because a direct iframe fails three ways at once: prod is HTTPS so `http://` targets are blocked as mixed content, many dashboards send `X-Frame-Options: DENY`, and our own `default-src 'self'` CSP blocks cross-origin frames. Proxying leaves the prod CSP unchanged (`/webview/...` is `'self'`). ⚠️ The proxy is **NOT an API surface**: it authenticates on an in-memory capability in the path and is correspondingly exempt from the cookie + Origin checks; that exemption is fenced to safe methods and non-`/api` paths and is pinned by `test/webview-auth-exemption.test.ts`. ⚠️ Iframes omit `allow-same-origin` unless a dashboard is explicitly marked `trusted`, and `Authorization`/`codeman_session` are stripped upstream in **both** modes so `CODEMAN_PASSWORD` cannot leak. ⚠️ A sandboxed frame is **opaque-origin**, which breaks two things `curl` can never reproduce: its runtime-built root-absolute URLs escape `<base>` (fixed by an injected `runtimeUrlShim()`), and its same-host `fetch`/XHR are CORS-checked with `Origin: null` (fixed by `buildProxyCorsHeaders()` plus exempting the proxy from the global `OPTIONS`-204 short-circuit in `registerSecurityHeaders`). Both present as the dashboard's own "Failed to fetch" while the page renders fine. ⚠️ **Egress guard**: link-local and cloud-metadata targets (`169.254.0.0/16`, `fe80::/10`, `fd00:ec2::254`, the Azure/Alibaba fixed addresses, `metadata.google.internal`) are refused at save time AND on the RESOLVED address at connect time (`webview-egress-policy.ts`, pure, plus `webview-egress.ts`: a `lookup` hook on the undici Agent behind `webviewFetch()` and on the `ws` client). An IP literal never reaches a lookup hook (`net.connect` skips DNS for it), so the synchronous hostname check at each connect site is NOT redundant. Loopback and RFC1918 stay allowed on purpose: a `localhost` Grafana is the feature. The proxy uses the `undici` PACKAGE's own `fetch` + `Agent`, never Node's global fetch with a foreign dispatcher (Node bundles its own copy; a protocol mismatch fails silently). ⚠️ **A loopback link in agent output opens as a proxied web tab** (`openLinkThroughWebTabIfLoopback` in webview-tabs.js, called from the terminal link provider and the response viewer): a phone cannot reach the box's `localhost:5173`, so the tap routes through Codeman's origin instead, reusing a saved same-origin dashboard or saving one under `host:port`. Two rules that look cosmetic and are not. **`*.localhost` is deliberately NOT in the auto-route set** even though it IS loopback to a browser: the link source is agent-written terminal output and prompt-injectable, every other member of that set is an address literal that can only mean this box, and a `*.localhost` DNS name is not one (a resolver with a search domain retries `evil.localhost` as `evil.localhost.<search domain>`, which an attacker can control, turning agent output plus one tap into a persisted server-side fetch of an agent-chosen origin). A user who really runs `api.localhost` saves it by hand, which is an explicit action. The PAGE-side test is deliberately broader (`isOnBoxHostname`), since a false positive there only declines to proxy. And **`this.webviews` being set does NOT mean it is loaded**: `initWebviews()` assigns a truthy EMPTY map synchronously and only then awaits the list, so a tap during page load must join the in-flight refresh (`_webviewsRefresh`/`_webviewsLoaded`) or it finds nothing to reuse and POSTs a duplicate record for an origin that already exists. Capabilities are revoked on logout / admin logout / user deletion (`revokeOwner`, which had NO caller for two releases while its docstring said otherwise), and proxied responses carry `Referrer-Policy: same-origin` so a dashboard cannot hand the capability-bearing URL to a third party. → [architecture-invariants#web-tabs](docs/architecture-invariants.md#web-tabs), `docs/web-tabs.md`
**Multi-user mode** (opt-in `--multiuser` / `CODEMAN_MULTIUSER=1`, OFF by default): named users with scrypt-hashed passwords in `~/.codeman/users.json`. Gated everywhere by `isMultiUserMode()`; when OFF, behavior is byte-identical to single-user because every scoping helper short-circuits. ⚠️ **Not a security boundary at the agent layer**: every session still runs as the SAME OS account. This separates WORKSPACES; it does not sandbox users (Docker cases are the isolation story). Ownership threads through `Session.owner` and is enforced in `findSessionOrFail`, list endpoints, SSE routing (fail-closed), WS, search, and file-preview. → [architecture-invariants#multi-user-mode](docs/architecture-invariants.md#multi-user-mode), `docs/multi-user-plan.md` **Multi-user mode** (opt-in `--multiuser` / `CODEMAN_MULTIUSER=1`, OFF by default): named users with scrypt-hashed passwords in `~/.codeman/users.json`. Gated everywhere by `isMultiUserMode()`; when OFF, behavior is byte-identical to single-user because every scoping helper short-circuits. ⚠️ **Not a security boundary at the agent layer**: every session still runs as the SAME OS account. This separates WORKSPACES; it does not sandbox users (Docker cases are the isolation story). Ownership threads through `Session.owner` and is enforced in `findSessionOrFail`, list endpoints, SSE routing (fail-closed), WS, search, and file-preview. → [architecture-invariants#multi-user-mode](docs/architecture-invariants.md#multi-user-mode), `docs/multi-user-plan.md`
+1 -1
View File
@@ -52,7 +52,7 @@ Model is NOT a session field: it is a composition entry in the profile's config
### Remote SSH cases ### Remote SSH cases
**Remote SSH cases** (COD-94/#145): cases can point at a **remote host** (`~/.codeman/remote-hosts.json` + `remote-cases.json` via `src/remote-hosts.ts`; CRUD under `/api/cases` — cases route file). A remote session launches a LOCAL tmux pane running `ssh <host>` that creates a durable REMOTE tmux session on a **dedicated socket** `-L codeman-remote` with name `codeman-ssh-<id>` — deliberately failing the remote Codeman's `SAFE_MUX_NAME_PATTERN` so a Codeman instance on the target host never adopts it; no `-g` global tmux options are set remotely. `remotePath`/`identityFile` are schema-guarded against shell injection (backticks/`$` rejected — same approach as `extraSshOptions`); remote tmux availability is probed via `checkRemoteTmuxAvailable()` in quick-start (ssh args carry `-o ConnectTimeout=10`). Remote claude defaults to `exec claude --dangerously-skip-permissions`; per-host `commands.*` override. Session kill best-effort kills the remote tmux too. `SessionState.remote`/`MuxSession.remote` round-trip through recovery (`restoreMuxSessions` passes `remote` back into the Session constructor). ⚠️ Run flows must route remote cases through `POST /api/quick-start` (which resolves the remote case and skips LOCAL CLI availability gates) — `POST /api/sessions` stat-validates `workingDir` locally and has no `caseName`. `envOverrides`/`effort`/`modelOverride`/`codexConfig`/`geminiConfig` are rejected for remote quick-starts (not silently dropped). UI: Create Case modal → Remote tab. Tests: `test/remote-hosts.test.ts`, `test/remote-ssh-options.test.ts`. **Remote SSH cases** (COD-94/#145): cases can point at a **remote host** (`~/.codeman/remote-hosts.json` + `remote-cases.json` via `src/remote-hosts.ts`; CRUD under `/api/cases` — cases route file). A remote session launches a LOCAL tmux pane running `ssh <host>` that creates a durable REMOTE tmux session on a **dedicated socket** `-L codeman-remote` with name `codeman-ssh-<id>` — deliberately failing the remote Codeman's `SAFE_MUX_NAME_PATTERN` so a Codeman instance on the target host never adopts it; no `-g` global tmux options are set remotely. `remotePath`/`identityFile` are schema-guarded against shell injection (backticks/`$` rejected — same approach as `extraSshOptions`); remote tmux availability is probed via `checkRemoteTmuxAvailable()` in quick-start (ssh args carry `-o ConnectTimeout=10`). Remote claude defaults to an idempotent `claude --session-id <id> || claude --resume <id>` pair under a login shell, so a respawn or reattach continues the SAME conversation rather than starting a fresh one (remote omp gets the same treatment via `--continue`; ⚠️ because the claude arm is an `a || b` pair under `-c`, that pane's PID is the login shell, not the agent); per-host `commands.*` override. Session kill best-effort kills the remote tmux too. `SessionState.remote`/`MuxSession.remote` round-trip through recovery (`restoreMuxSessions` passes `remote` back into the Session constructor). ⚠️ Run flows must route remote cases through `POST /api/quick-start` (which resolves the remote case and skips LOCAL CLI availability gates) — `POST /api/sessions` stat-validates `workingDir` locally and has no `caseName`. `envOverrides`/`effort`/`modelOverride`/`codexConfig`/`geminiConfig` are rejected for remote quick-starts (not silently dropped). UI: Create Case modal → Remote tab. Tests: `test/remote-hosts.test.ts`, `test/remote-ssh-options.test.ts`.
### Docker cases ### Docker cases
+8
View File
@@ -153,6 +153,14 @@ shared nor seeded.
include `~/.local/bin`. Per-session config and `envOverrides` do not cross ssh and are include `~/.local/bin`. Per-session config and `envOverrides` do not cross ssh and are
rejected rather than silently ignored; use the per-host command override instead. rejected rather than silently ignored; use the per-host command override instead.
⚠️ A **respawn or reattach** of a remote omp session runs `omp --continue`, not a
bare `omp`, so it lands back in the same conversation. It is deliberately
`--continue` rather than the exact `--resume <id>` the local and docker paths
pin: `omp-session-resolver.ts` only ever reads THIS host's `~/.omp/agent/sessions/`,
and a remote conversation's session file lives on the remote host under the
remote user's home, so resolving locally would pin a stranger's id. See
[Respawn / reattach continuation](remote-sessions.md#respawn--reattach-continuation).
## Known gaps ## Known gaps
- **No idle/completion hook.** Idle detection falls back to output-stabilization - **No idle/completion hook.** Idle detection falls back to output-stabilization
+16 -11
View File
@@ -25,12 +25,12 @@ custom port, identity file, `-J` jump host, `-o ProxyCommand`).
Types live in `src/types/session.ts`; persistence in `src/remote-hosts.ts`. Types live in `src/types/session.ts`; persistence in `src/remote-hosts.ts`.
| Type | Role | | Type | Role |
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | |------|------|
| `RemoteSshOptions` | The **HOW-to-reach** fields, shared by host + session: `identityFile`, `socksProxy` (`host:port`), `jumpHost` (`[user@]host[:port]`), `extraSshOptions` (`KEY=VALUE[]`). Every field optional — all-absent reproduces port-22, default-identity, directly-SSH-able behavior. | | `RemoteSshOptions` | The **HOW-to-reach** fields, shared by host + session: `identityFile`, `socksProxy` (`host:port`), `jumpHost` (`[user@]host[:port]`), `extraSshOptions` (`KEY=VALUE[]`). Every field optional — all-absent reproduces port-22, default-identity, directly-SSH-able behavior. |
| `RemoteHost` (extends `RemoteSshOptions`) | A saved host: `id`, `label`, `host`, `username`, `port?`, `commands?` (per-mode launch command override). | | `RemoteHost` (extends `RemoteSshOptions`) | A saved host: `id`, `label`, `host`, `username`, `port?`, `commands?` (per-mode launch command override). |
| `RemoteCase` | A working directory on a host: `name`, `type: 'remote'`, `hostId`, `remotePath`. | | `RemoteCase` | A working directory on a host: `name`, `type: 'remote'`, `hostId`, `remotePath`. |
| `SessionRemote` (extends `RemoteSshOptions`) | The resolved bundle stamped onto a live session: host coordinates + `remotePath` + `commands`, plus **`owned?`** and **`remoteSessionName?`** (COD-105 — see [Ownership](#ownership-launched-vs-discovered-and-attached-cod-105)). Built by `toSessionRemote(host, case)` (sets `owned: true`) for the launch path, or `toAttachedSessionRemote(host, name, path)` (sets `owned: false`) for the attach path. Both copy the advanced SSH options through so every connection is identical. | | `SessionRemote` (extends `RemoteSshOptions`) | The resolved bundle stamped onto a live session: host coordinates + `remotePath` + `commands`, plus **`owned?`** and **`remoteSessionName?`** (COD-105 — see [Ownership](#ownership-launched-vs-discovered-and-attached-cod-105)). Built by `toSessionRemote(host, case)` (sets `owned: true`) for the launch path, or `toAttachedSessionRemote(host, name, path)` (sets `owned: false`) for the attach path. Both copy the advanced SSH options through so every connection is identical. |
| `RemoteCommandMode` | `Extract<SessionMode, 'shell' \| 'claude' \| 'opencode' \| 'codex' \| 'gemini' \| 'antigravity' \| 'pi' \| 'grok'>` — the modes that can run remotely. | | `RemoteCommandMode` | `Extract<SessionMode, 'shell' \| 'claude' \| 'opencode' \| 'codex' \| 'gemini' \| 'antigravity' \| 'pi' \| 'grok' \| 'deepseek' \| 'omp'>` — the modes that can run remotely. |
| `RemoteSessionInfo` (COD-105) | One discovered remote tmux session: `name` (always `codeman-*`), `attached` (a client is connected), `created` (epoch s), `windows`. Returned by `listRemoteCodemanSessions()`. | | `RemoteSessionInfo` (COD-105) | One discovered remote tmux session: `name` (always `codeman-*`), `attached` (a client is connected), `created` (epoch s), `windows`. Returned by `listRemoteCodemanSessions()`. |
Persistence is two flat JSON arrays in the instance data dir: Persistence is two flat JSON arrays in the instance data dir:
@@ -73,7 +73,7 @@ Rules that keep this safe — **do not bypass them by hand-building an ssh line
single-quote `shellescape`d (`'…'` with embedded `'\''`). The helper mirrors single-quote `shellescape`d (`'…'` with embedded `'\''`). The helper mirrors
the one in `tmux-manager.ts`. the one in `tmux-manager.ts`.
- **`~`/`$HOME` in `identityFile` is expanded at build time** (`expandIdentityPath`), - **`~`/`$HOME` in `identityFile` is expanded at build time** (`expandIdentityPath`),
_before_ escaping — ssh does not expand `~` inside `-i`, and the escaped value *before* escaping — ssh does not expand `~` inside `-i`, and the escaped value
never reaches a shell that would. never reaches a shell that would.
- **The ProxyCommand is one shellescaped `-o KEY=VALUE` token**, so its spaces and - **The ProxyCommand is one shellescaped `-o KEY=VALUE` token**, so its spaces and
the `%h`/`%p` placeholders reach ssh as a single argument. `%h %p` survive the `%h`/`%p` placeholders reach ssh as a single argument. `%h %p` survive
@@ -112,10 +112,15 @@ Key points:
asymmetry: **discovery/attach (COD-105) target the canonical `-L codeman` asymmetry: **discovery/attach (COD-105) target the canonical `-L codeman`
socket** — they join sessions the remote's own Codeman manages, while owned socket** — they join sessions the remote's own Codeman manages, while owned
durable launches live on `-L codeman-remote`. durable launches live on `-L codeman-remote`.
- **`exec <cli>`** replaces the pane shell with the agent, so the pane PID _is_ - **`exec <cli>`** replaces the pane shell with the agent, so the pane PID *is*
the agent. The per-mode command comes from `remote.commands?.[mode]` or the agent. The per-mode command comes from `remote.commands?.[mode]` or
`defaultRemoteCommandForMode(mode)` (`exec claude` / `exec opencode` / `defaultRemoteCommandForMode(mode)` (`exec claude` / `exec opencode` /
`exec codex` / `exec gemini` / `exec agy` / `exec bash -l`). `exec codex` / `exec gemini` / `exec agy` / `exec bash -l`).
⚠️ **claude and omp no longer take that path**: both have their own arm in
`buildRemoteLaunchCommand` so a respawn can continue the same conversation
(see [Respawn / reattach continuation](#respawn--reattach-continuation)), and
because the claude arm is an `a || b` pair under `-c`, its pane PID is the
**login shell**, not the agent.
- The **whole tmux invocation is a single shell-quoted ssh argument**, and the - The **whole tmux invocation is a single shell-quoted ssh argument**, and the
pane command is independently quoted, so a `remotePath` with spaces is safe. pane command is independently quoted, so a `remotePath` with spaces is safe.
- Connection options come from the **same `buildSshConnectionArgs(remote)`** as - Connection options come from the **same `buildSshConnectionArgs(remote)`** as
@@ -128,9 +133,9 @@ Because durable remote sessions require tmux on the remote host,
`checkRemoteTmuxAvailable(host)` runs `command -v tmux` over SSH **before** `checkRemoteTmuxAvailable(host)` runs `command -v tmux` over SSH **before**
creating a remote case/session and returns a structured, never-throwing result: creating a remote case/session and returns a structured, never-throwing result:
- empty stdout / non-zero exit → _"remote host `<host>` needs tmux installed for - empty stdout / non-zero exit → *"remote host `<host>` needs tmux installed for
durable remote sessions"_ durable remote sessions"*
- stderr present → _"could not verify tmux on remote host `<host>`: `<stderr>`"_ - stderr present → *"could not verify tmux on remote host `<host>`: `<stderr>`"*
(a real connection failure, surfaced to the operator) (a real connection failure, surfaced to the operator)
- success → `{ ok: true, tmuxPath }` - success → `{ ok: true, tmuxPath }`
@@ -147,7 +152,7 @@ skipped; command construction is still asserted by unit tests.
## Ownership: launched vs. discovered-and-attached (COD-105) ## Ownership: launched vs. discovered-and-attached (COD-105)
COD-104 (above) was Phase 1 — Codeman _launches_ a remote session and owns it. COD-104 (above) was Phase 1 — Codeman *launches* a remote session and owns it.
COD-105 is Phase 2 — Codeman can also **discover** `codeman-*` tmux sessions COD-105 is Phase 2 — Codeman can also **discover** `codeman-*` tmux sessions
already running on a remote host (created by the remote's own Codeman or another already running on a remote host (created by the remote's own Codeman or another
instance) and **attach** to one it didn't launch. Ownership decides what happens instance) and **attach** to one it didn't launch. Ownership decides what happens
@@ -188,7 +193,7 @@ remote command line by ownership:
- **`owned === false`** → `buildRemoteAttachCommand(remote, name)` — emits - **`owned === false`** → `buildRemoteAttachCommand(remote, name)` — emits
`ssh … -t … 'tmux -L codeman attach -t <remoteSessionName>'`. It uses **`attach`, `ssh … -t … 'tmux -L codeman attach -t <remoteSessionName>'`. It uses **`attach`,
NOT `new-session -A`**, so it only _joins_ an existing session and never creates NOT `new-session -A`**, so it only *joins* an existing session and never creates
one. one.
- **owned (default)** → `buildRemoteLaunchCommand` (the COD-104 path above). - **owned (default)** → `buildRemoteLaunchCommand` (the COD-104 path above).
@@ -196,7 +201,7 @@ remote command line by ownership:
`TmuxManager.killSession()` has an **early return for non-owned remote sessions**: `TmuxManager.killSession()` has an **early return for non-owned remote sessions**:
it tears down **only the LOCAL pane** holding the ssh client (`tmux -L codeman it tears down **only the LOCAL pane** holding the ssh client (`tmux -L codeman
kill-session` on _this_ host's socket). Killing the local ssh sends SIGHUP to the kill-session` on *this* host's socket). Killing the local ssh sends SIGHUP to the
remote `tmux attach`, which **detaches** — the durable remote session survives. remote `tmux attach`, which **detaches** — the durable remote session survives.
The early return is a structural guarantee that **no code path can ever issue a The early return is a structural guarantee that **no code path can ever issue a
remote `kill-session` for a session we don't own** — the only `kill-session` run is remote `kill-session` for a session we don't own** — the only `kill-session` run is
@@ -251,7 +256,7 @@ stale `true` from one transport drop can never revive the NEXT clean exit.
Routes are registered in `src/web/routes/case-routes.ts`: Routes are registered in `src/web/routes/case-routes.ts`:
| Method | Path | Purpose | | Method | Path | Purpose |
| -------- | ------------------------------------ | ---------------------------------------------------------------------------------------------- | |--------|------|---------|
| `GET` | `/api/remote-hosts` | List saved hosts | | `GET` | `/api/remote-hosts` | List saved hosts |
| `POST` | `/api/remote-hosts` | Create a host | | `POST` | `/api/remote-hosts` | Create a host |
| `PUT` | `/api/remote-hosts/:id` | Update a host | | `PUT` | `/api/remote-hosts/:id` | Update a host |
+6 -1
View File
@@ -2398,7 +2398,12 @@ class CodemanApp {
viewer.classList.add('visible'); viewer.classList.add('visible');
backdrop.classList.add('visible'); backdrop.classList.add('visible');
body.scrollTop = 0; // A multi-row turn opens at its NEWEST text, matching loadFullContext's
// "scroll to bottom (latest message)". `scrollTop = 0` was right when the
// brief view was a single card holding the last row; with the whole turn
// rendered, the top is the turn's first narration line and the answer the
// eye button exists to show can be several screens down.
body.scrollTop = turnMessages.length > 1 ? body.scrollHeight : 0;
} catch (err) { } catch (err) {
console.error('Failed to load response:', err); console.error('Failed to load response:', err);
} }
+13
View File
@@ -1400,8 +1400,21 @@ Object.assign(CodemanApp.prototype, {
this.terminal.onData((data) => { this.terminal.onData((data) => {
// Canonical xterm data. Telling the controller is what lets it know a // Canonical xterm data. Telling the controller is what lets it know a
// keystroke was already delivered and needs no recovery. // keystroke was already delivered and needs no recovery.
//
// ⚠️ onData ALSO fires for output xterm produces on its own initiative:
// the DA/DSR/CPR/OSC replies it answers during Ink redraws, and the SGR
// mouse and focus reports (see the two predicates above, used for exactly
// this question at the send sites). Any one of those landing between the
// keydown and the candidate's zero-delay resolution would be read as
// "xterm spoke for this keystroke", standing the recovery down and
// leaving the character dropped, worst on a busy agent pane, which is
// the case this exists for. Narrowing the counter cannot cause a
// duplicate: it only ever makes the controller less sure it can stand down.
try { try {
const input = window.CodemanTerminalInput;
if (!input?.shouldSuppressTerminalQueryResponse(data) && !input?.isTerminalFocusOrMouseReport(data)) {
this._keyCode229Recovery?.notifyCanonicalData?.(); this._keyCode229Recovery?.notifyCanonicalData?.();
}
} catch { } catch {
// Bookkeeping must never block real input. // Bookkeeping must never block real input.
} }
+68 -10
View File
@@ -30,18 +30,53 @@
const LOOPBACK_HOSTNAMES = new Set(['localhost', '0.0.0.0', '::1', '[::1]', '::', '[::]']); const LOOPBACK_HOSTNAMES = new Set(['localhost', '0.0.0.0', '::1', '[::1]', '::', '[::]']);
/** `localhost`, `*.localhost`, 127.0.0.0/8, 0.0.0.0 and the IPv6 loopback forms. */ function normalizeHostname(hostname) {
function isLoopbackHostname(hostname) { return String(hostname || '')
const host = String(hostname || '')
.trim() .trim()
.toLowerCase() .toLowerCase()
.replace(/\.$/, ''); .replace(/\.$/, '');
}
/**
* `localhost`, 127.0.0.0/8, 0.0.0.0 and the IPv6 loopback forms: names that can
* only ever mean this box.
*
* ⚠️ `*.localhost` is deliberately NOT here. The link source is agent-written
* terminal output and response-viewer markdown, i.e. prompt-injectable, and
* this set is the whole confinement on a tap that makes Codeman fetch a URL
* server-side and persist it. Every other member is an address literal; a
* `*.localhost` DNS name is not one: on a resolver that does not synthesise it
* locally and has a search domain configured, `evil.localhost` NXDOMAINs as
* absolute and is retried as `evil.localhost.<search domain>`, which an
* attacker can control. A user who really runs `api.localhost` dev hosts can
* still save that dashboard by hand, which is an explicit action.
*/
function isLoopbackHostname(hostname) {
const host = normalizeHostname(hostname);
if (!host) return false; if (!host) return false;
if (LOOPBACK_HOSTNAMES.has(host) || host.endsWith('.localhost')) return true; if (LOOPBACK_HOSTNAMES.has(host)) return true;
const ipv4 = /^(\d{1,3})\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.exec(host); const ipv4 = /^(\d{1,3})\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.exec(host);
return !!ipv4 && Number(ipv4[1]) === 127; return !!ipv4 && Number(ipv4[1]) === 127;
} }
/**
* Whether the PAGE is being viewed on the box itself. Broader than the
* auto-route set on purpose, and safe in the opposite direction: a false
* positive here only ever DECLINES to proxy, leaving the caller's direct open.
*/
function isOnBoxHostname(hostname) {
const host = normalizeHostname(hostname);
return isLoopbackHostname(host) || host.endsWith('.localhost');
}
/**
* One key per dev server, so `localhost:5173` and `127.0.0.1:5173` reuse a
* single saved dashboard and a single tab instead of one per host spelling.
*/
function webTabOriginKey(url) {
return isLoopbackHostname(url.hostname) ? `${url.protocol}//loopback:${url.port}` : url.origin;
}
/** /**
* Whether a link should open through a proxied web tab rather than directly: * Whether a link should open through a proxied web tab rather than directly:
* an http(s) URL on a loopback host, viewed from a page that is NOT itself on * an http(s) URL on a loopback host, viewed from a page that is NOT itself on
@@ -57,11 +92,11 @@ function linkNeedsWebTabProxy(rawUrl, pageHostname) {
} }
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false; if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
if (!isLoopbackHostname(url.hostname)) return false; if (!isLoopbackHostname(url.hostname)) return false;
return !isLoopbackHostname(pageHostname); return !isOnBoxHostname(pageHostname);
} }
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.CodemanWebviewLinks = { isLoopbackHostname, linkNeedsWebTabProxy }; window.CodemanWebviewLinks = { isLoopbackHostname, isOnBoxHostname, webTabOriginKey, linkNeedsWebTabProxy };
} }
Object.assign(CodemanApp.prototype, { Object.assign(CodemanApp.prototype, {
@@ -91,17 +126,24 @@ Object.assign(CodemanApp.prototype, {
} catch { } catch {
return; return;
} }
if (!this.webviews) await this.refreshWebviews(); // ⚠️ "is `this.webviews` set" does NOT answer "is it loaded": initWebviews()
// assigns a truthy EMPTY map synchronously and only then awaits the list, so
// a tap during page load used to find nothing to reuse and POST a duplicate
// record for an origin that already exists server-side. Join an in-flight
// load; start one only when none has ever run.
if (this._webviewsRefresh) await this._webviewsRefresh;
else if (!this._webviewsLoaded) await this.refreshWebviews();
if (!this.webviews) { if (!this.webviews) {
this.showToast?.('Could not open URL', 'error'); this.showToast?.('Could not open URL', 'error');
return; return;
} }
const wantedKey = webTabOriginKey(url);
let existing = null; let existing = null;
for (const webview of this.webviews.values()) { for (const webview of this.webviews.values()) {
if (webview.managed || (webview.embedMode ?? 'proxy') !== 'proxy') continue; if (webview.managed || (webview.embedMode ?? 'proxy') !== 'proxy') continue;
try { try {
if (new URL(webview.url).origin === url.origin) { if (webTabOriginKey(new URL(webview.url)) === wantedKey) {
existing = webview; existing = webview;
break; break;
} }
@@ -122,9 +164,17 @@ Object.assign(CodemanApp.prototype, {
} }
await this.refreshWebviews(); await this.refreshWebviews();
id = created.id; id = created.id;
// The create is a persisted record: it writes webviews.json, broadcasts
// over SSE, adds a Run-dropdown row on every device this owner is signed
// in on and counts toward MAX_WEBVIEWS. Adding one by hand goes through a
// modal; a tap should not do all that with a new tab as its only signal.
this.showToast?.(`Saved ${url.host} as a web tab`, 'success');
} }
// `/` is passed through rather than flattened to '': openWebview reads an
// empty path as "no deep link" and leaves an already-open frame on whatever
// page it was showing, so a link to the origin root did nothing visible.
const path = `${url.pathname}${url.search}${url.hash}`; const path = `${url.pathname}${url.search}${url.hash}`;
await this.openWebview(id, { path: path === '/' ? '' : path }); await this.openWebview(id, { path: path || '/' });
}, },
// ── State ───────────────────────────────────────────────────────────────── // ── State ─────────────────────────────────────────────────────────────────
@@ -152,11 +202,19 @@ Object.assign(CodemanApp.prototype, {
}, },
async refreshWebviews() { async refreshWebviews() {
const data = await this._apiJson('/api/webviews'); const inFlight = this._apiJson('/api/webviews').then((data) => {
if (!data) return; if (!data) return;
this.webviews = new Map((data.webviews || []).map((w) => [w.id, w])); this.webviews = new Map((data.webviews || []).map((w) => [w.id, w]));
if (typeof data.maxLiveFrames === 'number') this._webviewMaxFrames = data.maxLiveFrames; if (typeof data.maxLiveFrames === 'number') this._webviewMaxFrames = data.maxLiveFrames;
this._webviewsLoaded = true;
this.renderWebviewMenuItems(); this.renderWebviewMenuItems();
});
this._webviewsRefresh = inFlight;
try {
await inFlight;
} finally {
if (this._webviewsRefresh === inFlight) this._webviewsRefresh = null;
}
}, },
/** SSE: the saved list changed (possibly on another device). */ /** SSE: the saved list changed (possibly on another device). */
+43
View File
@@ -170,4 +170,47 @@ describe('OMP: fresh session vs. reattach must not share resumeSessionId resolut
expect(state.ompConfig?.continueSession).toBe(true); expect(state.ompConfig?.continueSession).toBe(true);
expect(session.claudeSessionId).toBe(session.id); expect(session.claudeSessionId).toBe(session.id);
}); });
it('_maybeCaptureOmpSessionId() is subject to the same remote guard, so a first idle turn cannot alias a remote session onto a local conversation', () => {
// The sibling guard in _pinOmpRespawnId has the test above; this one runs on
// the FIRST turn going idle, before any respawn, and reads the same local
// ~/.omp tree. Without the `this._remote` early return it would claim this
// unrelated local conversation's uuid as the remote session's identity, and
// every later respawn would then inherit the wrong pin.
seedOmpSessionFile('wrong-local-conversation-id');
const remote: SessionRemote = {
hostId: 'remote-box',
label: 'remote-box',
host: 'remote-box',
username: 'someone',
remotePath: workingDir,
owned: true,
};
const muxSession: MuxSession = {
sessionId: 'placeholder',
muxName: 'codeman-deadbeef',
pid: 1,
createdAt: Date.now(),
workingDir,
mode: 'omp',
attached: false,
};
const session = new Session({
workingDir,
mode: 'omp',
mux: new TmuxManager(),
useMux: true,
muxSession,
remote,
});
sessions.push(session);
(session as unknown as { _maybeCaptureOmpSessionId(): void })._maybeCaptureOmpSessionId();
expect(session.claudeSessionId).toBe(session.id);
expect(session.toState().ompConfig?.resumeSessionId).toBeUndefined();
});
}); });
+41
View File
@@ -145,6 +145,47 @@ describe('response viewer brief view (last answered turn)', () => {
expect(viewer.classList.contains('visible')).toBe(true); expect(viewer.classList.contains('visible')).toBe(true);
}); });
it('opens a multi-row turn at its NEWEST text, and a single card at the top', async () => {
// `body.scrollTop = 0` was right when the brief view was one card holding
// the last row. With the whole turn rendered, the top of the scroller is
// the turn's FIRST narration line and the answer the eye button exists to
// show can be several screens below it; loadFullContext already scrolls to
// the bottom for the same turn, so the two views disagreed.
// jsdom does no layout, so scrollHeight is stubbed and the write recorded.
const spyScroll = (body: HTMLElement) => {
const writes: number[] = [];
Object.defineProperty(body, 'scrollHeight', { configurable: true, get: () => 4200 });
Object.defineProperty(body, 'scrollTop', {
configurable: true,
get: () => writes[writes.length - 1] ?? 0,
set: (v: number) => void writes.push(v),
});
return writes;
};
const many = mountViewer();
const manyWrites = spyScroll(many.body);
await makeApp({
text: 'Done.',
timestamp: 't',
messages: [
{ role: 'assistant', text: 'Looking at the file.', turn: 2 },
{ role: 'assistant', text: 'Done.', turn: 2 },
],
}).app.toggleResponseViewer();
expect(manyWrites.at(-1)).toBe(4200);
document.body.innerHTML = '';
const one = mountViewer();
const oneWrites = spyScroll(one.body);
await makeApp({
text: 'Done.',
timestamp: 't',
messages: [{ role: 'assistant', text: 'Done.', turn: 2 }],
}).app.toggleResponseViewer();
expect(oneWrites.at(-1)).toBe(0);
});
it('falls back to text when the server sends no messages, keeping one badged card', async () => { it('falls back to text when the server sends no messages, keeping one badged card', async () => {
const { body } = mountViewer(); const { body } = mountViewer();
const { app } = makeApp({ text: 'Only the last row.', timestamp: 't' }); const { app } = makeApp({ text: 'Only the last row.', timestamp: 't' });
+78
View File
@@ -89,6 +89,27 @@ function harness({ screenReader = false } = {}) {
}; };
} }
/** terminal-ui.js's exported predicates, loaded the same way as in test/mobile-shell-keyboard.test.ts. */
function loadTerminalInput() {
const source = readFileSync(new URL('../src/web/public/terminal-ui.js', import.meta.url), 'utf8');
const win: Record<string, any> = {
addEventListener() {},
matchMedia: () => ({ matches: false, addEventListener() {} }),
};
const sandbox: Record<string, any> = {
window: win,
globalThis: win,
document: { addEventListener() {} },
CodemanApp: class {},
};
win.CodemanApp = sandbox.CodemanApp;
vm.runInNewContext(source, sandbox, { filename: 'terminal-ui.js' });
return win.CodemanTerminalInput as {
shouldSuppressTerminalQueryResponse(data: string): boolean;
isTerminalFocusOrMouseReport(data: string): boolean;
};
}
describe('orphaned terminal input recovery', () => { describe('orphaned terminal input recovery', () => {
it('forwards the committed text when xterm stayed silent', () => { it('forwards the committed text when xterm stayed silent', () => {
const h = harness(); const h = harness();
@@ -276,3 +297,60 @@ describe('orphaned terminal input recovery', () => {
expect(h.emitted).toEqual([]); expect(h.emitted).toEqual([]);
}); });
}); });
describe('terminal-ui wiring: what counts as "xterm spoke for this keystroke"', () => {
const terminalSource = readFileSync(new URL('../src/web/public/terminal-ui.js', import.meta.url), 'utf8');
it('gates notifyCanonicalData on the two predicates this file already owns', () => {
// onData does NOT only carry keystrokes: xterm answers DA/DSR/CPR/OSC
// queries through it during Ink redraws, and emits SGR mouse and focus
// reports on its own initiative. Counting one of those as canonical data
// for the pending keystroke stands the recovery down and leaves the
// character dropped, worst on a busy agent pane, which is the case this
// exists for. Same gate, same two predicates, as the one-shot Ctrl
// modifier uses for the same question (test/mobile-shell-keyboard.test.ts).
const notify = terminalSource.indexOf('_keyCode229Recovery?.notifyCanonicalData?.()');
expect(notify).toBeGreaterThan(0);
const gate = terminalSource.lastIndexOf(
'!input?.shouldSuppressTerminalQueryResponse(data) && !input?.isTerminalFocusOrMouseReport(data)',
notify
);
expect(gate).toBeGreaterThan(0);
expect(gate).toBeLessThan(notify);
// ⚠️ The predicates live inside the module IIFE that ends long before this
// call site, so they are reachable ONLY through the global. Bare references
// would throw a ReferenceError straight into the surrounding try/catch,
// which swallows it, and notifyCanonicalData would then NEVER run: the
// recovery would re-emit a character xterm already delivered.
expect(terminalSource.slice(gate - 120, notify)).toContain('window.CodemanTerminalInput');
});
it('stands down for a real keystroke, but not for a mouse report or a query reply', () => {
// The gate as terminal-ui.js writes it. The wiring test above pins the real
// source; this proves the behaviour it buys.
const input = loadTerminalInput();
const onData = (h: ReturnType<typeof harness>, data: string) => {
if (!input.shouldSuppressTerminalQueryResponse(data) && !input.isTerminalFocusOrMouseReport(data)) {
h.controller.notifyCanonicalData();
}
};
for (const noise of ['\x1b[<0;10;5M', '\x1b[I', '\x1b[?1;2c']) {
const h = harness();
h.keydown();
h.input('x');
onData(h, noise);
h.flushTimers();
expect(h.emitted, `${JSON.stringify(noise)} must not stand the recovery down`).toEqual(['x']);
}
const typed = harness();
typed.keydown();
typed.input('x');
onData(typed, 'x');
typed.flushTimers();
expect(typed.emitted, 'xterm really did deliver this one').toEqual([]);
});
});
+76 -10
View File
@@ -95,6 +95,8 @@ function boot(pageUrl = 'http://192.168.1.135:8095/') {
interface WebviewLinks { interface WebviewLinks {
isLoopbackHostname(host: string): boolean; isLoopbackHostname(host: string): boolean;
isOnBoxHostname(host: string): boolean;
webTabOriginKey(url: URL): string;
linkNeedsWebTabProxy(url: string, pageHostname: string): boolean; linkNeedsWebTabProxy(url: string, pageHostname: string): boolean;
} }
@@ -108,16 +110,7 @@ describe('loopback link decision', () => {
const links = win.CodemanWebviewLinks; const links = win.CodemanWebviewLinks;
it('recognises every loopback spelling and nothing else', () => { it('recognises every loopback spelling and nothing else', () => {
for (const host of [ for (const host of ['localhost', 'LOCALHOST', '127.0.0.1', '127.1.2.3', '0.0.0.0', '[::1]', '::1']) {
'localhost',
'LOCALHOST',
'app.localhost',
'127.0.0.1',
'127.1.2.3',
'0.0.0.0',
'[::1]',
'::1',
]) {
expect(links.isLoopbackHostname(host), host).toBe(true); expect(links.isLoopbackHostname(host), host).toBe(true);
} }
for (const host of [ for (const host of [
@@ -133,6 +126,31 @@ describe('loopback link decision', () => {
} }
}); });
it('keeps *.localhost OUT of the auto-route set, because it is a DNS name an attacker can steer', () => {
// Every other member of the set is an address literal that can only mean
// this box. `evil.localhost` is not: on a resolver that does not synthesise
// *.localhost locally and has a search domain configured, it NXDOMAINs as
// absolute and is retried as `evil.localhost.<search domain>`. The link
// source is agent-written terminal output, so this set is the whole
// confinement on a tap that makes Codeman fetch a URL and persist it.
expect(links.isLoopbackHostname('app.localhost')).toBe(false);
expect(links.isLoopbackHostname('evil.localhost')).toBe(false);
expect(links.linkNeedsWebTabProxy('http://evil.localhost/', '192.168.1.135')).toBe(false);
// The PAGE-side test stays broader: a false positive there only ever
// DECLINES to proxy, leaving the caller's own direct open untouched.
expect(links.isOnBoxHostname('app.localhost')).toBe(true);
expect(links.linkNeedsWebTabProxy('http://localhost:5173/', 'app.localhost')).toBe(false);
});
it('keys a dashboard per dev server, not per host spelling', () => {
const key = (u: string) => links.webTabOriginKey(new URL(u));
expect(key('http://localhost:5173/')).toBe(key('http://127.0.0.1:5173/'));
expect(key('http://localhost:5173/')).not.toBe(key('http://localhost:5174/'));
expect(key('http://localhost:5173/')).not.toBe(key('https://localhost:5173/'));
expect(key('http://box.ts.net:3000/')).toBe('http://box.ts.net:3000');
});
it('proxies a loopback http(s) link only when the page is not on that box', () => { it('proxies a loopback http(s) link only when the page is not on that box', () => {
expect(links.linkNeedsWebTabProxy('http://localhost:5173/', '192.168.1.135')).toBe(true); expect(links.linkNeedsWebTabProxy('http://localhost:5173/', '192.168.1.135')).toBe(true);
expect(links.linkNeedsWebTabProxy('https://127.0.0.1:8443/x?y=1', 'box.ts.net')).toBe(true); expect(links.linkNeedsWebTabProxy('https://127.0.0.1:8443/x?y=1', 'box.ts.net')).toBe(true);
@@ -171,6 +189,54 @@ describe('openLinkThroughWebTabIfLoopback', () => {
expect(win.document.querySelectorAll('.webview-frame').length).toBe(1); expect(win.document.querySelectorAll('.webview-frame').length).toBe(1);
}); });
it('navigates an already-mounted frame back to the origin ROOT, which used to do nothing', async () => {
// openUrlInWebTab used to flatten '/' to '', and openWebview reads an empty
// path as "no deep link", so it mounted with navigate:false and an open
// frame stayed on whatever page it was showing. Deep links navigated; a tap
// on the bare origin silently did not.
const { win, app } = boot();
await app.openUrlInWebTab('http://localhost:5173/deep/page?a=1');
expect(frameSrc(win, 'dev')).toBe('/webview/cap-dev/deep/page?a=1');
await app.openUrlInWebTab('http://localhost:5173/');
expect(frameSrc(win, 'dev')).toBe('/webview/cap-dev/');
expect(win.document.querySelectorAll('.webview-frame').length).toBe(1);
});
it('reuses one dashboard across host spellings of the same dev server', async () => {
const { win, app, calls } = boot();
// The saved dashboard is http://localhost:5173/; a 127.0.0.1 link to the
// same port is the same server and must not mint a second tab.
await app.openUrlInWebTab('http://127.0.0.1:5173/status');
expect(frameSrc(win, 'dev')).toBe('/webview/cap-dev/status');
expect(calls.find((c) => c.method === 'POST' && c.path === '/api/webviews')).toBeUndefined();
expect(win.document.querySelectorAll('.webview-frame').length).toBe(1);
});
it('waits for an in-flight webview load instead of POSTing a duplicate record', async () => {
// initWebviews() assigns a truthy EMPTY map synchronously and only then
// awaits GET /api/webviews, so "is this.webviews set" answered "is it
// loaded" wrongly: a tap inside that round trip found nothing to reuse and
// saved a second dashboard for an origin that already existed server-side.
const { win, app, calls } = boot();
const loaded = app.webviews;
app.webviews = new Map();
let release: () => void = () => {};
const gate = new Promise<void>((resolve) => {
release = resolve;
});
(app as unknown as { _webviewsRefresh: Promise<void> })._webviewsRefresh = gate.then(() => {
app.webviews = loaded;
});
const tap = app.openUrlInWebTab('http://localhost:5173/late');
release();
await tap;
expect(calls.find((c) => c.method === 'POST' && c.path === '/api/webviews')).toBeUndefined();
expect(frameSrc(win, 'dev')).toBe('/webview/cap-dev/late');
});
it('saves an unknown origin under its host:port, then opens it', async () => { it('saves an unknown origin under its host:port, then opens it', async () => {
const { win, app, calls } = boot(); const { win, app, calls } = boot();
expect(app.openLinkThroughWebTabIfLoopback('http://127.0.0.1:3000/')).toBe(true); expect(app.openLinkThroughWebTabIfLoopback('http://127.0.0.1:3000/')).toBe(true);