chore: version packages

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-07-29 09:01:18 +02:00
parent 77bcbc9b94
commit a406aef2fa
16 changed files with 732 additions and 58 deletions
-9
View File
@@ -1,9 +0,0 @@
---
'aicodeman': patch
---
Fix two multi-user scoping holes in the new filesystem path picker. `GET /api/filesystem/browse` and `GET /api/filesystem/preview` accept an optional `sessionId` that contributes the session's working directory as a browse root, but they resolved it straight off the session map without an ownership check, unlike the nine other session-scoped handlers in the same route file. A non-admin could therefore pin another user's working directory as a root simply by passing their session id, then list and preview files under it. Both endpoints now run `canAccessOwned` and report 404, which also avoids confirming that a session id exists.
Separately, `Home` and `CASES_DIR` were unconditional browse roots for every caller. Per-user spaces live at `<USER_SPACES_DIR>/<username>`, which is inside `homedir()`, so the `Home` root alone exposed every other user's workspace to any authenticated user. In multi-user mode a non-admin now gets only their own space plus anything explicitly listed in `CODEMAN_FILE_PICKER_ROOTS`; `/mnt/d` is no longer offered by default, since a broad host mount should be an explicit operator decision in a multi-user deployment. Admins keep the host-wide roots, and single-user mode is unchanged.
Both holes are regression-guarded in `test/routes/file-routes.test.ts`, verified to fail against the previous code. Multi-user mode is opt-in and off by default, so single-user installs were never affected.
-9
View File
@@ -1,9 +0,0 @@
---
'aicodeman': patch
---
Normalize Claude conversations in the response viewer. A Claude transcript is an append-only event log, so one logical exchange spans many JSONL rows: tool-result rows, meta/image/skill rows, compact summaries, task and team notifications, sidechains, replayed assistant snapshots, and multi-block assistant output. The viewer rendered a card per row, which produced duplicate and truncated cards that read as lost responses. Cards are now built at real human-turn boundaries, replayed assistant snapshots are deduplicated, and sidechain rows (which belong to subagents, not the main conversation) no longer leak in. An identical prompt that legitimately recurs after an assistant reply is still kept as its own turn.
Measured over 40 real transcripts: 3108 cards became 621, duplicate cards dropped from 74 to 8 (all of them genuinely repeated turns), no assistant text was lost, and the non-`context=full` last-response text was byte-identical on every file.
Also rebinds recovered sessions to their transcript. `reconcileSessions()` can recover a lost mux session as a `restored-<uuid8>` placeholder with a stale working directory, which made transcript lookup by cwd find nothing. The placeholder still carries the first eight characters of the conversation UUID, so the viewer now rebinds to the matching top-level transcript when exactly one candidate matches.
@@ -1,10 +0,0 @@
---
"aicodeman": minor
---
feat(mobile): browse and insert local file and folder paths
Add a root-confined filesystem picker to Link Existing and the extended mobile
keyboard bar. Selected paths remain editable at the active prompt, supported
images/documents/text files open in a safe inline preview, and a new one-tap
action clears only the current unsent input without invoking `/clear`.
+37
View File
@@ -1,5 +1,42 @@
# aicodeman
## 1.9.0
### Minor Changes
- 2667150: feat(mobile): browse and insert local file and folder paths
Add a root-confined filesystem picker to Link Existing and the extended mobile
keyboard bar. Selected paths remain editable at the active prompt, supported
images/documents/text files open in a safe inline preview, and a new one-tap
action clears only the current unsent input without invoking `/clear`.
### Patch Changes
- 3cff98f: Fix two multi-user scoping holes in the new filesystem path picker. `GET /api/filesystem/browse` and `GET /api/filesystem/preview` accept an optional `sessionId` that contributes the session's working directory as a browse root, but they resolved it straight off the session map without an ownership check, unlike the nine other session-scoped handlers in the same route file. A non-admin could therefore pin another user's working directory as a root simply by passing their session id, then list and preview files under it. Both endpoints now run `canAccessOwned` and report 404, which also avoids confirming that a session id exists.
Separately, `Home` and `CASES_DIR` were unconditional browse roots for every caller. Per-user spaces live at `<USER_SPACES_DIR>/<username>`, which is inside `homedir()`, so the `Home` root alone exposed every other user's workspace to any authenticated user. In multi-user mode a non-admin now gets only their own space plus anything explicitly listed in `CODEMAN_FILE_PICKER_ROOTS`; `/mnt/d` is no longer offered by default, since a broad host mount should be an explicit operator decision in a multi-user deployment. Admins keep the host-wide roots, and single-user mode is unchanged.
Both holes are regression-guarded in `test/routes/file-routes.test.ts`, verified to fail against the previous code. Multi-user mode is opt-in and off by default, so single-user installs were never affected.
- Web tabs: delete saved URLs from the Run dropdown, and fix images in proxied dashboards.
**Saved URLs are now manageable from the dropdown.** Each row under "Web / URL" gains a gear and an `x`, so a URL can be edited or deleted without first opening it as a tab. Previously the only delete path ran through the gear on an open tab, which was a dead end for a URL you no longer wanted open at all. Both controls stay permanently visible rather than hover-revealed, because the same menu is used on touch, and they get a larger hit box there. Deleting leaves the dropdown open on the remaining rows, and deleting the dashboard that is currently open also closes its tab and unmounts its frame.
**Runtime-injected images no longer 404.** A dashboard that renders its own markup from script (`card.innerHTML = '<img src="/api/hero?slug=x">'`, `img.src = '/api/slide'`) escaped every rewrite layer at once: `<base href>` never applies to a root-absolute URL, the server-side attribute rewrite only ever sees the initial document, and `runtimeUrlShim()` patched only `fetch`, `XMLHttpRequest`, `WebSocket` and `EventSource`. Those requests landed on Codeman's own root and 404'd, with a symptom that reads as an upstream fault: the dashboard's data loaded while every image stayed broken.
The shim now also covers the DOM URL sinks, so the request is never emitted in the first place and neither the `/api` fence in the 404 fallback nor the one in the auth middleware had to move. It wraps `innerHTML`, `outerHTML`, `insertAdjacentHTML` (including on `ShadowRoot`), `setAttribute`/`setAttributeNS`, and the `src`/`srcset`/`href`/`poster`/`data`/`action` property setters on img, source, media, video poster, script, iframe, embed, track, link, anchor, area, object and form, with a `MutationObserver` as a last net for sinks not patched above. Every rewrite routes through the same idempotent helper, which matters because unlike the server-side rewrite this one sees markup that may already be proxied, and a page re-injecting its own `outerHTML` would otherwise double-prefix. Everything is defensively guarded and marked so a double injection cannot wrap an already-wrapped setter.
Measured against a real dashboard: 693 image elements, 0 of them under the proxy prefix and 0 of 23 in-viewport images decoded before, 693 and 23 of 23 after. Covered by a new jsdom suite over the shim's DOM half and a new frontend suite over the dropdown rows. Known remaining gaps are documented in `docs/web-tabs.md`: a root-absolute `url()` inside a stylesheet injected at runtime, and self-navigation via `location.href`, which cannot be patched because `Location.href` is unforgeable.
Also in this release: a value-first README overhaul pointing at getcodeman.com, and the QR-auth distribution test now uses a chi-square check instead of a max-deviation threshold that failed on random variance.
- bca56b4: Normalize Claude conversations in the response viewer. A Claude transcript is an append-only event log, so one logical exchange spans many JSONL rows: tool-result rows, meta/image/skill rows, compact summaries, task and team notifications, sidechains, replayed assistant snapshots, and multi-block assistant output. The viewer rendered a card per row, which produced duplicate and truncated cards that read as lost responses. Cards are now built at real human-turn boundaries, replayed assistant snapshots are deduplicated, and sidechain rows (which belong to subagents, not the main conversation) no longer leak in. An identical prompt that legitimately recurs after an assistant reply is still kept as its own turn.
Measured over 40 real transcripts: 3108 cards became 621, duplicate cards dropped from 74 to 8 (all of them genuinely repeated turns), no assistant text was lost, and the non-`context=full` last-response text was byte-identical on every file.
Also rebinds recovered sessions to their transcript. `reconcileSessions()` can recover a lost mux session as a `restored-<uuid8>` placeholder with a stale working directory, which made transcript lookup by cwd find nothing. The placeholder still carries the first eight characters of the conversation UUID, so the viewer now rebinds to the matching top-level transcript when exactly one candidate matches.
## 1.8.3
### Patch Changes
+1 -1
View File
@@ -74,7 +74,7 @@ When user says "COM":
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
**Version**: 1.8.3 (must match `package.json`)
**Version**: 1.9.0 (must match `package.json`)
## Project Overview
+1 -1
View File
@@ -117,7 +117,7 @@ The general rule: **any new endpoint that turns a caller-supplied `sessionId` in
**Two things a sandboxed frame breaks that are invisible to `curl`.** Both were found only by driving a real dashboard in a real browser, and both present identically as the dashboard's own "Failed to fetch" while the page itself renders fine:
1. **Root-absolute URLs built at runtime.** `<base href>` only governs URLs the HTML parser resolves; `fetch('/api/data')` bypasses it and lands on Codeman's root. That is how most dashboards talk to their own backend. The `Referer`-keyed 404 fallback deliberately refuses `/api`, `/ws`, `/q` (widening it there would let a request-supplied header skip auth on Codeman's own API), so the fix is `runtimeUrlShim()`: a small script injected right after `<base>` that patches `fetch`, `XMLHttpRequest.open`, `WebSocket` and `EventSource` to rebase root-absolute and same-origin-absolute URLs into the prefix. It removes the whole class inside the iframe instead of trading security for it. ⚠️ It must be injected even when the page ships its OWN `<base>` (an early return there silently breaks exactly the pages that need it most).
1. **Root-absolute URLs built at runtime.** `<base href>` only governs URLs the HTML parser resolves; `fetch('/api/data')` bypasses it and lands on Codeman's root. That is how most dashboards talk to their own backend. The `Referer`-keyed 404 fallback deliberately refuses `/api`, `/ws`, `/q` (widening it there would let a request-supplied header skip auth on Codeman's own API), so the fix is `runtimeUrlShim()`: a small script injected right after `<base>` that rebases root-absolute and same-origin-absolute URLs into the prefix. It removes the whole class inside the iframe instead of trading security for it. ⚠️ It must be injected even when the page ships its OWN `<base>` (an early return there silently breaks exactly the pages that need it most). ⚠️ **The DOM sinks are as load-bearing as `fetch`.** Patching only `fetch`/`XHR`/`WebSocket`/`EventSource` leaves `container.innerHTML = '<img src="/api/hero?slug=x">'` and `img.src = '/api/slide'` untouched, and neither of the other layers can reach those either (`<base>` never applies to root-absolute URLs, and `rewriteHtml()` only ever sees the INITIAL document, never markup built later by page script). The symptom is precise and easy to misdiagnose as an upstream fault: the dashboard's **data** loads while every **image** stays broken. So the shim also wraps `innerHTML`/`outerHTML`/`insertAdjacentHTML`, `setAttribute`/`setAttributeNS`, and the `src`/`srcset`/`href`/`poster`/`data`/`action` property setters, with a `MutationObserver` as a last net for sinks not patched above. Every rewrite routes through the same idempotent `rw()`, which matters because unlike the server-side rewrite this one sees markup that may ALREADY be proxied (a page re-injecting its own `outerHTML` would otherwise double-prefix). The DOM half is pinned in jsdom by `test/webview-proxy.test.ts`; `curl` cannot see any of it.
2. **CORS on same-host requests.** An opaque-origin document treats EVERY request as cross-origin, including to the very host it was served from, so its `fetch`/XHR are CORS-checked and its preflights carry `Origin: null`. Static subresources (script/css/img) are NOT CORS-checked, which is why the page renders while its API calls die with an opaque `net::ERR_FAILED`. `buildProxyCorsHeaders()` echoes the origin (omitting `allow-credentials` for `null`, which browsers reject in combination), upstream `access-control-*` headers are dropped (they describe the dashboard's origin, not the frame's), and the proxy answers preflights itself rather than relaying them. ⚠️ `registerSecurityHeaders` answers EVERY `OPTIONS` with a bare 204 before routing, and its CORS block only emits headers for localhost origins, so that short-circuit **must** exempt a valid webview capability or every preflight fails. `curl` cannot reproduce any of this because curl does not enforce CORS.
**Rewrites, each load-bearing** (pure + unit-tested in `src/web/webview-proxy.ts`): drop `x-frame-options` and the CSP `frame-ancestors` directive (the point of the proxy); drop `content-encoding`/`content-length` because undici's `fetch` already decoded the body (forwarding them makes the browser gunzip plaintext); rewrite `Location` for same-origin redirects only, handing CROSS-origin redirects back unchanged so this never becomes an open relay; rebase `Set-Cookie` `Path` onto the prefix and drop `Domain`; inject `<base href>` and rebase root-absolute `src`/`href`/`action`. `resolveUpstreamUrl()` returns null on anything escaping the upstream origin.
+151
View File
@@ -0,0 +1,151 @@
# Web tabs: two fixes (planned + implemented 2026-07-28)
Both found against the saved dashboard
`https://macminis-mac-mini.tailf80371.ts.net:4000` (Bio-Hacking-Dashboard).
Kept because the root-cause analysis of the second one is not obvious from the
resulting diff.
Status: **both implemented and verified end-to-end.** The one deliberate
non-change is recorded at the bottom.
---
## Bug 1: saved URLs could not be deleted from the Run dropdown
### What happened
The "Web / URL" section of the Run dropdown listed every saved dashboard as a
single clickable row whose only action was "open". Deleting required opening the
dashboard as a tab, clicking the tab's gear, then Delete in the modal, so a URL
you no longer wanted open at all could not be removed without first opening it.
### What shipped
- `renderWebviewMenuItems()` (`src/web/public/webview-tabs.js`) now renders each
saved URL as a `.run-mode-row--web` flex row: the open button, a gear
(`showWebviewModal`), and an `x` (`deleteWebviewById`). Nested buttons are
invalid HTML, hence the wrapper rather than a button inside a button.
- `deleteWebview()` split into the modal entry point, the new row entry point
`deleteWebviewById(id)`, and the shared `_confirmAndDeleteWebview(id)`.
- Both side buttons call `event.stopPropagation()` so the click does not also
open the dashboard.
- The dropdown's outside-click handler (`session-ui.js`) closes when the click
target is not inside `#runModeMenu`, and the row is gone by the time the delete
resolves, so `deleteWebviewById` re-asserts `.active` on the menu. Verified in a
browser: deleting one of several URLs leaves you looking at the rest of the list.
- CSS in `styles.css` (`.run-mode-row--web`, `.run-mode-row-btn`) plus a larger
touch target in `mobile.css`. The side buttons are permanently visible rather
than hover-revealed, because this menu is used on touch.
No server change: `DELETE /api/webviews/:id` already existed, owner-scoped, and
already revoked the capability and broadcast `WebviewChanged`.
---
## Bug 2: images did not load in a proxied dashboard
### Reproduction (before the fix)
```
CAP=<from POST /api/webviews/<id>/open>
# A) upstream direct -> 200 image/jpeg 118150
curl -sk "https://macminis-mac-mini.tailf80371.ts.net:4000/api/hero?slug=120-minutes-in-nature"
# B) through the proxy prefix -> 200 image/jpeg 118150
curl -sk "https://localhost:3000/webview/$CAP/api/hero?slug=120-minutes-in-nature"
# C) what the browser ACTUALLY requested -> 404 {"errorCode":"NOT_FOUND"}
curl -sk -H "Referer: https://localhost:3000/webview/$CAP/" \
"https://localhost:3000/api/hero?slug=120-minutes-in-nature"
# D) same shape but NOT under /api -> 200 (referer fallback rescues it)
curl -sk -H "Referer: https://localhost:3000/webview/$CAP/" "https://localhost:3000/styles.css"
```
The proxy itself was fine (B). The failure was entirely about which URL the
browser ended up requesting (C).
### Root cause
The dashboard builds its image markup at runtime with root-absolute URLs:
`c.innerHTML = '<img class="thumb" src="/api/hero?slug=...">'`, `img.src =
slideSrc(...)` returning `/api/slide?owner=...`, `/api/story`, `/api/video`, and a
nested `<iframe src="/api/preview?slug=...">`.
All three rewrite layers missed that shape:
1. `<base href="/webview/<cap>/">` only affects **relative** URLs. A root-absolute
`/api/hero` ignores the base path and resolves against Codeman's origin.
2. `rewriteHtml()` only runs over the **initial HTML document**. This markup is
created later by page script. (The static header `<img src="/api/logo">` DID
work, having been rewritten at proxy time, which is why only the
runtime-injected images were broken.)
3. `runtimeUrlShim()` patched only `fetch`, `XMLHttpRequest.open`, `WebSocket` and
`EventSource`, so the dashboard's **data** loaded while its **pictures** did
not.
The safety net was fenced off from `/api` in two places, both deliberate:
`server.ts`'s not-found handler returns the API-envelope 404 before reaching
`tryWebviewRefererFallback`, and `middleware/auth.ts` refuses the Referer-form
auth exemption for `/api/`, `/ws/`, `/q/`.
### What shipped
`runtimeUrlShim()` in `src/web/webview-proxy.ts` now also covers the DOM sinks, so
a root-absolute `/api/...` request is never emitted in the first place and neither
security fence had to move:
- `innerHTML` / `outerHTML` / `insertAdjacentHTML` (and `ShadowRoot.innerHTML`),
- `setAttribute` / `setAttributeNS`,
- the `src`/`srcset`/`href`/`poster`/`data`/`action` property setters on img,
source, media, video poster, script, iframe, embed, track, link, anchor, area,
object and form,
- a `MutationObserver` as a last net for any sink not patched above (it costs one
wasted 404 per node, since the browser starts fetching on insert, so it is a net
and not the mechanism).
Two details that mattered:
- Every rewrite routes through the existing idempotent `rw()` rather than a blind
prefix concat. The first draft used the server-side regex shape and
double-prefixed markup that was already proxied (a page re-injecting its own
`outerHTML`); the jsdom test caught it.
- Everything stays inside `try`/`catch` and is marked `__cmrw`, so a double
injection cannot wrap an already-wrapped setter, and nothing can throw into a
page we do not control.
### Verification
- `test/webview-proxy.test.ts` gained a jsdom `runtimeUrlShim DOM sinks` block:
innerHTML, insertAdjacentHTML, property setters, setAttribute, srcset candidate
lists, the MutationObserver net via an unpatched sink
(`createContextualFragment`), idempotence, re-injected markup, empty `src`, and
the pass-throughs (relative, cross-origin, `#hash`, `data:`). 73 tests pass.
- End-to-end in a real browser against an isolated instance
(`CODEMAN_INSTANCE=wvtest`, port 3151), with prod's old build as the negative
control:
| | before (prod, old build) | after (fixed) |
| --- | --- | --- |
| images found | 693 | 693 |
| src under the proxy prefix | 0 | 693 |
| in-viewport images decoded | 0 / 23 | 23 / 23 |
| sample src | `/api/hero?slug=...` | `/webview/<cap>/api/hero?slug=...` |
(The dashboard marks thumbs `loading="lazy"`, so only in-viewport images are
ever fetched. All 27 proxied image responses returned 200.)
---
## Deliberately NOT done: widening the `/api` referer fallback
Considered as defense in depth, and skipped. It would have to change **both**
gates or it is useless on a password-protected instance, and the auth gate is the
security-sensitive one: auth runs in `onRequest`, before routing, so it cannot
tell a real Codeman API route from a 404. Dropping the `/api` fence would let a
page holding a capability forge a `Referer` and reach Codeman's **real** API
unauthenticated. Doing it safely means exempting only paths that match no
registered route (via Fastify's `hasRoute`/`findRoute`), which is a separate
change deserving its own review and its own cases in
`test/webview-auth-exemption.test.ts`.
The remaining gap this leaves is narrow and documented in `docs/web-tabs.md`: a
root-absolute `url(/img.png)` inside a stylesheet injected at runtime. Non-`/api`
ones are already rescued by the existing referer fallback.
+21 -6
View File
@@ -15,7 +15,9 @@ on. Web tabs sit in the same strip as session tabs, continue the same `Alt+1..9`
numbering, and carry a globe icon so they never read as a running agent.
Closing a tab (the `x`) only closes it. The saved dashboard stays in the dropdown.
Deleting for good is behind the gear on the tab, or the gear on its dropdown row.
To delete it for good, use the `x` on its **dropdown row** (the tab's own `x` is
close, not delete). Each dropdown row also has a gear for editing, so a saved URL
can be changed or removed without opening it first.
Switching tabs does **not** reload a dashboard. Frames stay alive in the background,
so a dashboard that took a while to authenticate is still there when you come back.
@@ -97,9 +99,15 @@ Worth knowing, because it is where this feature does its least obvious work. Thr
layers cooperate so a dashboard talking to its own backend just works:
1. `<base href>` handles relative URLs in the markup.
2. Attribute rewriting handles root-absolute `src`/`href`/`action`.
3. A small injected script rebases URLs built at **runtime** (`fetch('/api/data')`,
`new WebSocket('/live')`), which the first two cannot see.
2. Attribute rewriting handles root-absolute `src`/`href`/`action` in the page the
proxy serves.
3. A small injected script rebases URLs built at **runtime**, which the first two
cannot see: `fetch('/api/data')` and `new WebSocket('/live')`, but equally
`card.innerHTML = '<img src="/api/hero">'` and `img.src = '/api/slide'`. That
second group is why images are covered too. A dashboard that renders its
thumbnails from script would otherwise show all its data and none of its
pictures, because `<base>` does not apply to root-absolute URLs and the
attribute rewriting only ever saw the initial document.
On top of that, the proxy answers those requests with CORS headers. That sounds
wrong for same-host requests, but a sandboxed iframe has an *opaque* origin, so the
@@ -110,8 +118,15 @@ then every API call fails, which looks like the dashboard being broken.
## Known limits
- **Exotic loaders.** The three layers above cover normal `fetch`/XHR/WebSocket/
EventSource and normal markup. Something that constructs requests by an unusual
route can still slip through. Symptom: the page renders but a panel stays empty.
EventSource, normal markup, and the DOM sinks a page uses to build markup at
runtime. Something that constructs requests by an unusual route can still slip
through. Symptom: the page renders but a panel stays empty. The known remaining
gap is a root-absolute `url(/img.png)` inside a stylesheet the page injects at
runtime; one under `/api` has no fallback and will 404.
- **Root-absolute `location` navigation.** A dashboard that navigates itself with
`location.href = '/login'` escapes the prefix, because `Location.href` is
unforgeable and cannot be patched the way the other sinks are. A relative
`location.href = 'login'` is fine (`<base>` covers it).
- **Cross-origin redirects are not followed.** If a dashboard bounces to a different
host (an external SSO provider, say), the proxy hands the redirect back unchanged
rather than relaying it, because relaying would make this an open proxy. Use
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.8.3",
"version": "1.9.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.8.3",
"version": "1.9.0",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+17 -6
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.8.3",
"version": "1.9.0",
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
@@ -48,17 +48,28 @@
"packages/*"
],
"keywords": [
"claude",
"claude-code",
"claude-ai",
"claude",
"anthropic",
"ai-agent",
"automation",
"opencode",
"codex",
"gemini-cli",
"ai-agents",
"agent",
"session-manager",
"self-hosted",
"developer-tools",
"tmux",
"terminal",
"xterm",
"docker",
"mosh",
"local-echo",
"web-dashboard",
"cli",
"llm",
"autonomous-agent",
"ralph-loop"
"automation"
],
"author": "arkon",
"license": "MIT",
+11
View File
@@ -855,6 +855,17 @@ html.mobile-init .file-browser-panel {
-webkit-tap-highlight-color: rgba(255, 255, 255, 0.1);
}
/* Per-URL edit/delete in the Web / URL list need a real touch target, and they
sit next to the row's own tap area, so they get sized up rather than relying
on the 24px desktop hit box. */
.run-mode-row-btn {
width: 34px;
height: 34px;
font-size: 1rem;
-webkit-tap-highlight-color: rgba(255, 255, 255, 0.1);
}
.run-mode-webview-delete { font-size: 1.2rem; }
.run-mode-history {
-webkit-overflow-scrolling: touch;
touch-action: manipulation;
+38
View File
@@ -12561,6 +12561,44 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
.run-mode-dot.web { background: #38bdf8; }
.run-mode-webviews { max-height: 180px; overflow-y: auto; }
/* A saved URL is a ROW: open on the left, edit + delete on the right, so a URL can
be changed or removed without first opening it as a tab. The side buttons stay
permanently visible rather than hover-revealed, because this menu is used on
touch devices where there is no hover to reveal them with. */
.run-mode-row--web {
display: flex;
align-items: center;
gap: 2px;
}
.run-mode-row--web .run-mode-option--web {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.run-mode-row-btn {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
background: none;
border: none;
border-radius: var(--btn-radius);
color: var(--text-dim);
font-size: 0.9rem;
line-height: 1;
cursor: pointer;
}
.run-mode-row-btn:hover {
background: rgba(255, 255, 255, 0.07);
color: var(--text);
}
.run-mode-webview-delete { font-size: 1.05rem; }
.run-mode-webview-delete:hover { color: var(--danger, #ef4444); }
.run-mode-empty {
padding: 4px 10px 6px;
font-size: 0.75em;
+43 -13
View File
@@ -290,7 +290,13 @@ Object.assign(CodemanApp.prototype, {
// ── Run-menu entries ──────────────────────────────────────────────────────
/** Saved dashboards listed inside the Run dropdown, under "Web / URL". */
/**
* Saved dashboards listed inside the Run dropdown, under "Web / URL".
*
* Each row carries its own edit and delete buttons. Without them the only way to
* change or remove a saved URL was to open it as a tab first and go through the
* tab's gear, which is a dead end for a URL you no longer want open at all.
*/
renderWebviewMenuItems() {
const container = document.getElementById('runModeWebviews');
if (!container) return;
@@ -300,15 +306,20 @@ Object.assign(CodemanApp.prototype, {
return;
}
container.innerHTML = list
.map(
(w) => `<button class="run-mode-option run-mode-option--web" onclick="app.openWebviewFromMenu(${escapeHtml(
JSON.stringify(w.id)
)})" title="${escapeHtml(w.url)}">
<span class="run-mode-menu-icon">${w.icon ? escapeHtml(w.icon) : '<span class="run-mode-dot web"></span>'}</span>${escapeHtml(
w.name
)}
</button>`
)
.map((w) => {
const jsonId = escapeHtml(JSON.stringify(w.id));
const name = escapeHtml(w.name);
const icon = w.icon ? escapeHtml(w.icon) : '<span class="run-mode-dot web"></span>';
return `<div class="run-mode-row run-mode-row--web">
<button class="run-mode-option run-mode-option--web" onclick="app.openWebviewFromMenu(${jsonId})" title="${escapeHtml(w.url)}">
<span class="run-mode-menu-icon">${icon}</span>${name}
</button>
<button class="run-mode-row-btn run-mode-webview-edit" onclick="event.stopPropagation(); app.showWebviewModal(${jsonId})"
title="Edit URL" aria-label="Edit ${name}">&#x2699;</button>
<button class="run-mode-row-btn run-mode-webview-delete" onclick="event.stopPropagation(); app.deleteWebviewById(${jsonId})"
title="Delete URL" aria-label="Delete ${name}">&times;</button>
</div>`;
})
.join('');
},
@@ -429,17 +440,36 @@ Object.assign(CodemanApp.prototype, {
async deleteWebview() {
const id = this._editingWebviewId;
if (!id) return;
if (await this._confirmAndDeleteWebview(id)) this.closeWebviewModal();
},
/**
* Delete straight from a Run-dropdown row, without opening the editor first.
*
* The dropdown's outside-click handler closes the menu when the click target is
* not inside it, and by the time the delete resolves this row is gone, so the
* menu is re-asserted open: deleting one of several saved URLs should leave you
* looking at the rest of the list.
*/
async deleteWebviewById(id) {
if (!id) return;
if (!(await this._confirmAndDeleteWebview(id))) return;
document.getElementById('runModeMenu')?.classList.add('active');
},
/** Shared by the row button and the editor modal. @returns true when deleted. */
async _confirmAndDeleteWebview(id) {
const webview = this.webviews.get(id);
if (!confirm(`Delete "${webview?.name || id}"?`)) return;
if (!confirm(`Delete "${webview?.name || id}"?`)) return false;
const res = await this._apiDelete(`/api/webviews/${encodeURIComponent(id)}`);
if (!res || !res.ok) {
this.showToast?.('Could not delete URL', 'error');
return;
return false;
}
this._removeWebviewTab(id);
this.webviews.delete(id);
this.closeWebviewModal();
this.renderWebviewMenuItems();
this.renderSessionTabs();
return true;
},
});
+129 -1
View File
@@ -372,9 +372,26 @@ export function buildDownstreamResponseHeaders(
* whole class instead of trading security for it: the page never emits a
* root-absolute request in the first place.
*
* ## Why the DOM sinks are patched too, not just fetch/XHR
*
* A dashboard that renders `container.innerHTML = '<img src="/api/hero?slug=x">'`
* or `img.src = '/api/slide?n=01'` produces exactly the same root-absolute request,
* and NONE of the other layers can reach it: `<base>` does not apply to
* root-absolute URLs at all, and `rewriteHtml()` only ever sees the initial
* document, not markup built later by page script. The visible symptom is very
* specific and easy to misread: the dashboard's DATA loads (reads go through
* `fetch`, which was already patched) while every IMAGE stays broken. So the same
* `rw()` is applied to `innerHTML`/`outerHTML`/`insertAdjacentHTML`, to
* `setAttribute`, and to the `src`/`href`/`srcset`/... property setters, with a
* `MutationObserver` as a last net for any sink not patched above (that one costs a
* wasted 404 per node, since the browser starts fetching on insert, so it is a net
* and not the mechanism).
*
* Runs before any page script because it is injected immediately after `<base>`.
* Only same-origin, non-prefixed, root-absolute URLs are touched; relative URLs
* (already handled by `<base>`) and cross-origin URLs are passed through.
* (already handled by `<base>`) and cross-origin URLs are passed through. Every
* rewrite is idempotent, so a value that passes through two layers is unchanged by
* the second.
*/
export function runtimeUrlShim(prefix: string): string {
// Kept dependency-free and defensive: it runs inside a page we do not control,
@@ -420,6 +437,117 @@ if(window.XMLHttpRequest&&XMLHttpRequest.prototype.open){
['CONNECTING','OPEN','CLOSING','CLOSED'].forEach(function(s){if(s in C)W[s]=C[s];});
window[k]=W;
});
var A=['src','href','action','poster','data','formaction','srcset'];
function rwSet(v){
try{
return String(v).split(',').map(function(p){
var t=p.trim();if(!t)return t;
var i=t.search(/\\s/);
return i===-1?rw(t):rw(t.slice(0,i))+t.slice(i);
}).join(', ');
}catch(e){return v;}
}
function rwAttr(n,v){
try{
if(v==null)return v;
var k=String(n).toLowerCase();
if(k==='srcset')return rwSet(v);
return A.indexOf(k)===-1?v:rw(v);
}catch(e){return v;}
}
// Each value goes through rw() rather than a blind prefix concat, because unlike
// the server-side rewriteHtml() this runs on markup that may ALREADY be proxied
// (a page re-injecting its own outerHTML), and rw() is the idempotent one.
function rwHtml(s){
try{
if(typeof s!=='string')return s;
return s
.replace(/(\\s(?:src|href|action|poster|formaction|data)\\s*=\\s*")([^"]*)(")/gi,function(m,a,v,q){return a+rw(v)+q;})
.replace(/(\\s(?:src|href|action|poster|formaction|data)\\s*=\\s*')([^']*)(')/gi,function(m,a,v,q){return a+rw(v)+q;})
.replace(/(\\ssrcset\\s*=\\s*")([^"]*)(")/gi,function(m,a,v,q){return a+rwSet(v)+q;})
.replace(/(\\ssrcset\\s*=\\s*')([^']*)(')/gi,function(m,a,v,q){return a+rwSet(v)+q;});
}catch(e){return s;}
}
// Marked with __cmrw so a double injection (a page that re-runs the shim) cannot
// wrap an already-wrapped setter and rewrite twice.
function patchProp(C,prop,conv){
try{
if(!C||!C.prototype)return;
var d=Object.getOwnPropertyDescriptor(C.prototype,prop);
if(!d||!d.set||d.set.__cmrw)return;
var s=d.set;
var ns=function(v){var w=v;try{w=conv(v);}catch(e){}return s.call(this,w);};
ns.__cmrw=1;
Object.defineProperty(C.prototype,prop,{get:d.get,set:ns,configurable:true,enumerable:d.enumerable});
}catch(e){}
}
function patchHtmlProp(O,prop){
try{
if(!O)return;
var d=Object.getOwnPropertyDescriptor(O,prop);
if(!d||!d.set||d.set.__cmrw)return;
var s=d.set;
var ns=function(v){return s.call(this,rwHtml(v));};
ns.__cmrw=1;
Object.defineProperty(O,prop,{get:d.get,set:ns,configurable:true,enumerable:d.enumerable});
}catch(e){}
}
function patchFn(O,name,wrap){
try{
var f=O&&O[name];
if(typeof f!=='function'||f.__cmrw)return;
var nf=wrap(f);nf.__cmrw=1;O[name]=nf;
}catch(e){}
}
[['HTMLImageElement','src'],['HTMLImageElement','srcset'],['HTMLSourceElement','src'],
['HTMLSourceElement','srcset'],['HTMLMediaElement','src'],['HTMLVideoElement','poster'],
['HTMLScriptElement','src'],['HTMLIFrameElement','src'],['HTMLEmbedElement','src'],
['HTMLTrackElement','src'],['HTMLLinkElement','href'],['HTMLAnchorElement','href'],
['HTMLAreaElement','href'],['HTMLObjectElement','data'],['HTMLFormElement','action']
].forEach(function(p){patchProp(window[p[0]],p[1],p[1]==='srcset'?rwSet:rw);});
var EP=window.Element&&window.Element.prototype;
patchHtmlProp(EP,'innerHTML');
patchHtmlProp(EP,'outerHTML');
patchHtmlProp(window.ShadowRoot&&window.ShadowRoot.prototype,'innerHTML');
patchFn(EP,'insertAdjacentHTML',function(f){return function(p,h){return f.call(this,p,rwHtml(h));};});
patchFn(EP,'setAttribute',function(f){return function(n,v){return f.call(this,n,rwAttr(n,v));};});
patchFn(EP,'setAttributeNS',function(f){return function(ns,n,v){
var k=String(n==null?'':n),i=k.indexOf(':');
return f.call(this,ns,n,rwAttr(i===-1?k:k.slice(i+1),v));
};});
// Last net: anything inserted by a sink not patched above still gets corrected.
// setAttribute below is the patched one, so this stays idempotent and terminates.
try{
var doc=window.document,MO=window.MutationObserver;
if(MO&&doc&&doc.documentElement){
var fix=function(el){
try{
if(!el||el.nodeType!==1||!el.hasAttribute)return;
for(var i=0;i<A.length;i++){
var n=A[i];if(!el.hasAttribute(n))continue;
var c=el.getAttribute(n),x=rwAttr(n,c);
if(x!=null&&x!==c)el.setAttribute(n,x);
}
}catch(e){}
};
var scan=function(node){
try{
fix(node);
if(node&&node.querySelectorAll){
var l=node.querySelectorAll('[src],[href],[action],[poster],[data],[srcset],[formaction]');
for(var i=0;i<l.length;i++)fix(l[i]);
}
}catch(e){}
};
new MO(function(ms){
for(var i=0;i<ms.length;i++){
var m=ms[i];
if(m.type==='attributes')fix(m.target);
else for(var j=0;j<m.addedNodes.length;j++)scan(m.addedNodes[j]);
}
}).observe(doc.documentElement,{subtree:true,childList:true,attributes:true,attributeFilter:A});
}
}catch(e){}
}catch(e){}})();</script>`;
}
+157
View File
@@ -0,0 +1,157 @@
/**
* @fileoverview Frontend test for the "Web / URL" rows in the Run dropdown
* (webview-tabs.js).
*
* A saved URL used to render as a single open-button, so the ONLY way to remove one
* was to open it as a tab and go through the tab's gear, which is a dead end for a
* URL you no longer want open. These pin the per-row edit/delete affordance and the
* delete path behind it, because a UI affordance is exactly the kind of thing a
* later render refactor drops silently.
*
* Builds a JSDOM window in-test under the default node env, same shape as
* test/admin-ui.test.ts. Do NOT declare a per-file jsdom environment: it
* externalizes node:fs under vite and the readFileSync calls below stop working.
* ⚠ Do not name that directive in a comment either, vitest matches the string
* anywhere in the file.
*/
import { describe, it, expect, vi } from 'vitest';
import { readFileSync } from 'node:fs';
import { JSDOM } from 'jsdom';
const CONSTANTS = readFileSync(new URL('../src/web/public/constants.js', import.meta.url), 'utf-8');
const WEBVIEW_TABS = readFileSync(new URL('../src/web/public/webview-tabs.js', import.meta.url), 'utf-8');
interface AppLike {
webviews: Map<string, { id: string; name: string; url: string; icon?: string }>;
webviewOrder: string[];
activeWebviewId: string | null;
renderWebviewMenuItems(): void;
renderSessionTabs(): void;
deleteWebviewById(id: string): Promise<void>;
_confirmAndDeleteWebview(id: string): Promise<boolean>;
_removeWebviewTab(id: string): void;
_apiDelete(path: string): Promise<{ ok: boolean } | null>;
showToast?: (msg: string, kind: string) => void;
showWebviewModal(id?: string): void;
}
function boot(deleteOk = true) {
const dom = new JSDOM(
`<!doctype html><body>
<div class="run-mode-menu active" id="runModeMenu">
<div class="run-mode-webviews" id="runModeWebviews"></div>
</div>
<div id="sessionTabs"></div>
<div id="webviewLayer"></div>
</body>`,
{ url: 'http://localhost/', runScripts: 'outside-only' }
);
const win = dom.window as unknown as Window &
typeof globalThis & { app: AppLike; CodemanApp: new () => AppLike; confirm: () => boolean };
// webview-tabs.js is a prototype mixin, so it needs the class it extends plus the
// escapeHtml global from constants.js. Everything else it touches is stubbed.
// One eval, not three: lexical declarations in a global eval do not survive into
// the next one, and the class must be a window property for the same reason.
(win as unknown as { eval: (s: string) => void }).eval(
['window.CodemanApp = class CodemanApp {};', CONSTANTS, WEBVIEW_TABS].join('\n')
);
const deleted: string[] = [];
const app = new win.CodemanApp();
app.webviews = new Map([
['id-a', { id: 'id-a', name: 'Bio Dashboard', url: 'https://box.ts.net:4000', icon: '📈' }],
['id-b', { id: 'id-b', name: 'Grafana', url: 'http://127.0.0.1:3000/d/x' }],
]);
app.webviewOrder = [];
app.activeWebviewId = null;
app.renderSessionTabs = () => {};
app._apiDelete = async (path: string) => {
deleted.push(path);
return deleteOk ? { ok: true } : { ok: false };
};
win.app = app;
win.confirm = () => true;
app.renderWebviewMenuItems();
return { dom, win, app, deleted };
}
const rows = (win: Window) => win.document.querySelectorAll('#runModeWebviews .run-mode-row--web');
describe('Run dropdown Web/URL rows', () => {
it('gives every saved URL an open, edit and delete control', () => {
const { win } = boot();
expect(rows(win)).toHaveLength(2);
expect(win.document.querySelectorAll('#runModeWebviews .run-mode-option--web')).toHaveLength(2);
expect(win.document.querySelectorAll('#runModeWebviews .run-mode-webview-edit')).toHaveLength(2);
expect(win.document.querySelectorAll('#runModeWebviews .run-mode-webview-delete')).toHaveLength(2);
});
it('escapes the name and url rather than interpolating them raw', () => {
const { win, app } = boot();
const name = '<img src=x onerror=alert(1)>';
const url = 'https://h/"onmouseover="x';
app.webviews.set('id-x', { id: 'id-x', name, url });
app.renderWebviewMenuItems();
// Assert on the DOM, not on innerHTML: attribute serialization does not
// re-escape `<`, so a string check reads as a breakout when there is none.
expect(win.document.querySelectorAll('#runModeWebviews img')).toHaveLength(0);
const row = rows(win)[2];
expect(row.querySelector('.run-mode-option--web')!.textContent).toContain(name);
expect(row.querySelector('.run-mode-option--web')!.getAttribute('title')).toBe(url);
expect(row.querySelector('.run-mode-webview-delete')!.getAttribute('aria-label')).toBe(`Delete ${name}`);
});
it('stops the delete click from also opening the dashboard', () => {
const { win, app } = boot();
let opened = 0;
(app as unknown as { openWebviewFromMenu: () => void }).openWebviewFromMenu = () => {
opened++;
};
const del = win.document.querySelector<HTMLElement>('#runModeWebviews .run-mode-webview-delete')!;
expect(del.getAttribute('onclick')).toContain('event.stopPropagation()');
del.click();
expect(opened).toBe(0);
});
it('deletes server-side and drops the row, leaving the menu open', async () => {
const { win, app, deleted } = boot();
app._removeWebviewTab = () => {};
await app.deleteWebviewById('id-a');
expect(deleted).toEqual(['/api/webviews/id-a']);
expect(app.webviews.has('id-a')).toBe(false);
expect(rows(win)).toHaveLength(1);
// Deleting one of several URLs should leave you looking at the rest of the list.
expect(win.document.getElementById('runModeMenu')!.classList.contains('active')).toBe(true);
});
it('does nothing when the confirm is declined', async () => {
const { win, app, deleted } = boot();
win.confirm = () => false;
await app.deleteWebviewById('id-a');
expect(deleted).toEqual([]);
expect(app.webviews.has('id-a')).toBe(true);
expect(rows(win)).toHaveLength(2);
});
it('keeps the row and warns when the server refuses the delete', async () => {
const { win, app } = boot(false);
const toast = vi.fn();
app.showToast = toast;
app._removeWebviewTab = () => {};
await app.deleteWebviewById('id-a');
expect(toast).toHaveBeenCalledWith('Could not delete URL', 'error');
expect(app.webviews.has('id-a')).toBe(true);
expect(rows(win)).toHaveLength(2);
});
it('still renders the empty state when nothing is saved', () => {
const { win, app } = boot();
app.webviews.clear();
app.renderWebviewMenuItems();
expect(rows(win)).toHaveLength(0);
expect(win.document.querySelector('.run-mode-empty')).toBeTruthy();
});
});
+124
View File
@@ -6,6 +6,7 @@
*/
import { describe, it, expect } from 'vitest';
import { JSDOM } from 'jsdom';
import {
buildDownstreamResponseHeaders,
buildProxyCorsHeaders,
@@ -474,6 +475,129 @@ describe('runtimeUrlShim', () => {
});
});
/**
* The DOM half of the shim, run in a real jsdom document rather than the fake
* window above, because these patches ARE DOM behavior: what matters is the URL the
* browser would end up requesting after `innerHTML = ...`, not whether some
* function got wrapped.
*
* The bug being pinned: a dashboard rendering `<img src="/api/hero?slug=x">` from
* page script escapes `<base>` (which never applies to root-absolute URLs) and
* escapes `rewriteHtml()` (which only sees the initial document), so every image
* 404s on Codeman's own root while the dashboard's fetch-driven data loads fine.
*
* Node environment on purpose, like test/markdown-sanitizer.test.ts: a per-file
* `@vitest-environment jsdom` externalizes node builtins under vite.
*/
describe('runtimeUrlShim DOM sinks', () => {
const body = runtimeUrlShim(PREFIX)
.replace(/^<script>/, '')
.replace(/<\/script>$/, '');
function newDom() {
const dom = new JSDOM('<!doctype html><html><head></head><body><div id="box"></div></body></html>', {
url: `https://codeman.local${PREFIX}page`,
runScripts: 'outside-only',
});
dom.window.eval(body);
return dom;
}
/** src of the first <img> in #box, as the attribute the browser would fetch. */
function imgSrc(dom: JSDOM): string | null {
return dom.window.document.querySelector('#box img')!.getAttribute('src');
}
it('rewrites a root-absolute img src injected via innerHTML', () => {
const dom = newDom();
dom.window.document.getElementById('box')!.innerHTML =
'<img class="thumb" loading="lazy" src="/api/hero?slug=x" alt="">';
expect(imgSrc(dom)).toBe(`${PREFIX}api/hero?slug=x`);
});
it('rewrites single-quoted markup and insertAdjacentHTML too', () => {
const dom = newDom();
dom.window.document.getElementById('box')!.insertAdjacentHTML('beforeend', "<img src='/api/logo'>");
expect(imgSrc(dom)).toBe(`${PREFIX}api/logo`);
});
it('rewrites the img.src property setter', () => {
const dom = newDom();
const img = new dom.window.Image();
img.src = '/api/slide?owner=o&n=01';
expect(img.getAttribute('src')).toBe(`${PREFIX}api/slide?owner=o&n=01`);
});
it('rewrites setAttribute and media src/poster', () => {
const dom = newDom();
const video = dom.window.document.createElement('video');
video.setAttribute('src', '/api/video?owner=o');
video.poster = '/thumb.png';
expect(video.getAttribute('src')).toBe(`${PREFIX}api/video?owner=o`);
expect(video.getAttribute('poster')).toBe(`${PREFIX}thumb.png`);
});
it('rewrites every candidate in a srcset, leaving cross-origin ones alone', () => {
const dom = newDom();
const img = dom.window.document.createElement('img');
img.setAttribute('srcset', '/a.png 1x, /b.png 2x, https://cdn.example/c.png 3x');
expect(img.getAttribute('srcset')).toBe(`${PREFIX}a.png 1x, ${PREFIX}b.png 2x, https://cdn.example/c.png 3x`);
});
it('leaves relative, cross-origin, hash, data: and already-proxied URLs untouched', () => {
const dom = newDom();
const box = dom.window.document.getElementById('box')!;
box.innerHTML = [
'<img id="rel" src="api/rel.png">',
'<img id="cross" src="https://cdn.example/z.png">',
'<img id="data" src="data:image/gif;base64,AAAA">',
`<img id="done" src="${PREFIX}api/hero">`,
'<a id="hash" href="#top">t</a>',
].join('');
const at = (id: string, attr: string) => dom.window.document.getElementById(id)!.getAttribute(attr);
expect(at('rel', 'src')).toBe('api/rel.png');
expect(at('cross', 'src')).toBe('https://cdn.example/z.png');
expect(at('data', 'src')).toBe('data:image/gif;base64,AAAA');
expect(at('done', 'src')).toBe(`${PREFIX}api/hero`);
expect(at('hash', 'href')).toBe('#top');
});
it('catches a node built through an UNPATCHED sink via the MutationObserver net', async () => {
const dom = newDom();
const { document } = dom.window;
// createContextualFragment parses markup without going through innerHTML or
// setAttribute, so only the observer can fix this one.
const frag = document.createRange().createContextualFragment('<img id="net" src="/api/net.png">');
expect(frag.querySelector('img')!.getAttribute('src')).toBe('/api/net.png');
document.getElementById('box')!.appendChild(frag);
await new Promise((resolve) => setTimeout(resolve, 10));
expect(document.getElementById('net')!.getAttribute('src')).toBe(`${PREFIX}api/net.png`);
});
it('does not double-prefix when a page re-injects its own markup', () => {
const dom = newDom();
const box = dom.window.document.getElementById('box')!;
box.innerHTML = '<img src="/api/hero">';
const roundTrip = box.innerHTML;
box.innerHTML = roundTrip;
expect(imgSrc(dom)).toBe(`${PREFIX}api/hero`);
});
it('leaves an empty src empty, the "no image for this row" case', () => {
const dom = newDom();
dom.window.document.getElementById('box')!.innerHTML = '<img class="thumb" src="" alt="">';
expect(imgSrc(dom)).toBe('');
});
it('is idempotent when a value passes through two layers', () => {
const dom = newDom();
const img = dom.window.document.createElement('img');
img.src = '/api/hero';
img.setAttribute('src', img.getAttribute('src')!);
expect(img.getAttribute('src')).toBe(`${PREFIX}api/hero`);
});
});
describe('misc helpers', () => {
it('identifies HTML content types, parameters included', () => {
expect(isHtmlContentType('text/html; charset=utf-8')).toBe(true);