diff --git a/.changeset/553c3037.md b/.changeset/553c3037.md deleted file mode 100644 index 10bdce07..00000000 --- a/.changeset/553c3037.md +++ /dev/null @@ -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 `/`, 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. diff --git a/.changeset/fix-claude-response-viewer.md b/.changeset/fix-claude-response-viewer.md deleted file mode 100644 index 53cf018b..00000000 --- a/.changeset/fix-claude-response-viewer.md +++ /dev/null @@ -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-` 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. diff --git a/.changeset/mobile-filesystem-path-picker.md b/.changeset/mobile-filesystem-path-picker.md deleted file mode 100644 index 9427c9ad..00000000 --- a/.changeset/mobile-filesystem-path-picker.md +++ /dev/null @@ -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`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d3f3fd3..5a2be4c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `/`, 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/slide'`) escaped every rewrite layer at once: `` 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-` 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 diff --git a/CLAUDE.md b/CLAUDE.md index 9abf9837..0e5cc5a5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 6f954c77..ec2a5826 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -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.** `` 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 `` 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 `` (an early return there silently breaks exactly the pages that need it most). +1. **Root-absolute URLs built at runtime.** `` 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 `` 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 `` (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 = ''` and `img.src = '/api/slide'` untouched, and neither of the other layers can reach those either (`` 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 `` and rebase root-absolute `src`/`href`/`action`. `resolveUpstreamUrl()` returns null on anything escaping the upstream origin. diff --git a/docs/web-tabs-fixes-plan.md b/docs/web-tabs-fixes-plan.md new file mode 100644 index 00000000..3b7d6578 --- /dev/null +++ b/docs/web-tabs-fixes-plan.md @@ -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=/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.src = +slideSrc(...)` returning `/api/slide?owner=...`, `/api/story`, `/api/video`, and a +nested `