mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
@@ -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)
|
||||
|
||||
**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`
|
||||
|
||||
|
||||
@@ -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** (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
|
||||
|
||||
|
||||
@@ -153,6 +153,14 @@ shared nor seeded.
|
||||
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.
|
||||
|
||||
⚠️ 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
|
||||
|
||||
- **No idle/completion hook.** Idle detection falls back to output-stabilization
|
||||
|
||||
+28
-23
@@ -24,14 +24,14 @@ custom port, identity file, `-J` jump host, `-o ProxyCommand`).
|
||||
|
||||
Types live in `src/types/session.ts`; persistence in `src/remote-hosts.ts`.
|
||||
|
||||
| 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. |
|
||||
| `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`. |
|
||||
| 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. |
|
||||
| `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`. |
|
||||
| `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. |
|
||||
| `RemoteSessionInfo` (COD-105) | One discovered remote tmux session: `name` (always `codeman-*`), `attached` (a client is connected), `created` (epoch s), `windows`. Returned by `listRemoteCodemanSessions()`. |
|
||||
| `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()`. |
|
||||
|
||||
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
|
||||
the one in `tmux-manager.ts`.
|
||||
- **`~`/`$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.
|
||||
- **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
|
||||
@@ -112,10 +112,15 @@ Key points:
|
||||
asymmetry: **discovery/attach (COD-105) target the canonical `-L codeman`
|
||||
socket** — they join sessions the remote's own Codeman manages, while owned
|
||||
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
|
||||
`defaultRemoteCommandForMode(mode)` (`exec claude` / `exec opencode` /
|
||||
`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
|
||||
pane command is independently quoted, so a `remotePath` with spaces is safe.
|
||||
- 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**
|
||||
creating a remote case/session and returns a structured, never-throwing result:
|
||||
|
||||
- empty stdout / non-zero exit → _"remote host `<host>` needs tmux installed for
|
||||
durable remote sessions"_
|
||||
- stderr present → _"could not verify tmux on remote host `<host>`: `<stderr>`"_
|
||||
- empty stdout / non-zero exit → *"remote host `<host>` needs tmux installed for
|
||||
durable remote sessions"*
|
||||
- stderr present → *"could not verify tmux on remote host `<host>`: `<stderr>`"*
|
||||
(a real connection failure, surfaced to the operator)
|
||||
- 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)
|
||||
|
||||
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
|
||||
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
|
||||
@@ -188,7 +193,7 @@ remote command line by ownership:
|
||||
|
||||
- **`owned === false`** → `buildRemoteAttachCommand(remote, name)` — emits
|
||||
`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.
|
||||
- **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**:
|
||||
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.
|
||||
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
|
||||
@@ -250,14 +255,14 @@ stale `true` from one transport drop can never revive the NEXT clean exit.
|
||||
|
||||
Routes are registered in `src/web/routes/case-routes.ts`:
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| -------- | ------------------------------------ | ---------------------------------------------------------------------------------------------- |
|
||||
| `GET` | `/api/remote-hosts` | List saved hosts |
|
||||
| `POST` | `/api/remote-hosts` | Create a host |
|
||||
| `PUT` | `/api/remote-hosts/:id` | Update a host |
|
||||
| `DELETE` | `/api/remote-hosts/:id` | Delete a host |
|
||||
| `GET` | `/api/remote-hosts/:hostId/sessions` | Discover `codeman-*` sessions on the host (COD-105; `listRemoteCodemanSessions`, never errors) |
|
||||
| `POST` | `/api/cases/remote-link` | Link a case to a remote host (creates the `RemoteCase`) |
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `GET` | `/api/remote-hosts` | List saved hosts |
|
||||
| `POST` | `/api/remote-hosts` | Create a host |
|
||||
| `PUT` | `/api/remote-hosts/:id` | Update a host |
|
||||
| `DELETE` | `/api/remote-hosts/:id` | Delete a host |
|
||||
| `GET` | `/api/remote-hosts/:hostId/sessions` | Discover `codeman-*` sessions on the host (COD-105; `listRemoteCodemanSessions`, never errors) |
|
||||
| `POST` | `/api/cases/remote-link` | Link a case to a remote host (creates the `RemoteCase`) |
|
||||
|
||||
Attaching to a discovered session is a **session-create** path, not a host route:
|
||||
`POST /api/sessions` accepts `attachRemoteSession: { hostId, remoteSessionName }`
|
||||
|
||||
@@ -2398,7 +2398,12 @@ class CodemanApp {
|
||||
|
||||
viewer.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) {
|
||||
console.error('Failed to load response:', err);
|
||||
}
|
||||
|
||||
@@ -1400,8 +1400,21 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.terminal.onData((data) => {
|
||||
// Canonical xterm data. Telling the controller is what lets it know a
|
||||
// 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 {
|
||||
this._keyCode229Recovery?.notifyCanonicalData?.();
|
||||
const input = window.CodemanTerminalInput;
|
||||
if (!input?.shouldSuppressTerminalQueryResponse(data) && !input?.isTerminalFocusOrMouseReport(data)) {
|
||||
this._keyCode229Recovery?.notifyCanonicalData?.();
|
||||
}
|
||||
} catch {
|
||||
// Bookkeeping must never block real input.
|
||||
}
|
||||
|
||||
@@ -30,18 +30,53 @@
|
||||
|
||||
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 isLoopbackHostname(hostname) {
|
||||
const host = String(hostname || '')
|
||||
function normalizeHostname(hostname) {
|
||||
return String(hostname || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.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 (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);
|
||||
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:
|
||||
* 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 (!isLoopbackHostname(url.hostname)) return false;
|
||||
return !isLoopbackHostname(pageHostname);
|
||||
return !isOnBoxHostname(pageHostname);
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.CodemanWebviewLinks = { isLoopbackHostname, linkNeedsWebTabProxy };
|
||||
window.CodemanWebviewLinks = { isLoopbackHostname, isOnBoxHostname, webTabOriginKey, linkNeedsWebTabProxy };
|
||||
}
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
@@ -91,17 +126,24 @@ Object.assign(CodemanApp.prototype, {
|
||||
} catch {
|
||||
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) {
|
||||
this.showToast?.('Could not open URL', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const wantedKey = webTabOriginKey(url);
|
||||
let existing = null;
|
||||
for (const webview of this.webviews.values()) {
|
||||
if (webview.managed || (webview.embedMode ?? 'proxy') !== 'proxy') continue;
|
||||
try {
|
||||
if (new URL(webview.url).origin === url.origin) {
|
||||
if (webTabOriginKey(new URL(webview.url)) === wantedKey) {
|
||||
existing = webview;
|
||||
break;
|
||||
}
|
||||
@@ -122,9 +164,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
await this.refreshWebviews();
|
||||
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}`;
|
||||
await this.openWebview(id, { path: path === '/' ? '' : path });
|
||||
await this.openWebview(id, { path: path || '/' });
|
||||
},
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────
|
||||
@@ -152,11 +202,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
async refreshWebviews() {
|
||||
const data = await this._apiJson('/api/webviews');
|
||||
if (!data) return;
|
||||
this.webviews = new Map((data.webviews || []).map((w) => [w.id, w]));
|
||||
if (typeof data.maxLiveFrames === 'number') this._webviewMaxFrames = data.maxLiveFrames;
|
||||
this.renderWebviewMenuItems();
|
||||
const inFlight = this._apiJson('/api/webviews').then((data) => {
|
||||
if (!data) return;
|
||||
this.webviews = new Map((data.webviews || []).map((w) => [w.id, w]));
|
||||
if (typeof data.maxLiveFrames === 'number') this._webviewMaxFrames = data.maxLiveFrames;
|
||||
this._webviewsLoaded = true;
|
||||
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). */
|
||||
|
||||
@@ -170,4 +170,47 @@ describe('OMP: fresh session vs. reattach must not share resumeSessionId resolut
|
||||
expect(state.ompConfig?.continueSession).toBe(true);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -145,6 +145,47 @@ describe('response viewer brief view (last answered turn)', () => {
|
||||
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 () => {
|
||||
const { body } = mountViewer();
|
||||
const { app } = makeApp({ text: 'Only the last row.', timestamp: 't' });
|
||||
|
||||
@@ -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', () => {
|
||||
it('forwards the committed text when xterm stayed silent', () => {
|
||||
const h = harness();
|
||||
@@ -276,3 +297,60 @@ describe('orphaned terminal input recovery', () => {
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,6 +95,8 @@ function boot(pageUrl = 'http://192.168.1.135:8095/') {
|
||||
|
||||
interface WebviewLinks {
|
||||
isLoopbackHostname(host: string): boolean;
|
||||
isOnBoxHostname(host: string): boolean;
|
||||
webTabOriginKey(url: URL): string;
|
||||
linkNeedsWebTabProxy(url: string, pageHostname: string): boolean;
|
||||
}
|
||||
|
||||
@@ -108,16 +110,7 @@ describe('loopback link decision', () => {
|
||||
const links = win.CodemanWebviewLinks;
|
||||
|
||||
it('recognises every loopback spelling and nothing else', () => {
|
||||
for (const host of [
|
||||
'localhost',
|
||||
'LOCALHOST',
|
||||
'app.localhost',
|
||||
'127.0.0.1',
|
||||
'127.1.2.3',
|
||||
'0.0.0.0',
|
||||
'[::1]',
|
||||
'::1',
|
||||
]) {
|
||||
for (const host of ['localhost', 'LOCALHOST', '127.0.0.1', '127.1.2.3', '0.0.0.0', '[::1]', '::1']) {
|
||||
expect(links.isLoopbackHostname(host), host).toBe(true);
|
||||
}
|
||||
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', () => {
|
||||
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);
|
||||
@@ -171,6 +189,54 @@ describe('openLinkThroughWebTabIfLoopback', () => {
|
||||
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 () => {
|
||||
const { win, app, calls } = boot();
|
||||
expect(app.openLinkThroughWebTabIfLoopback('http://127.0.0.1:3000/')).toBe(true);
|
||||
|
||||
Reference in New Issue
Block a user