From 245ee68c7ced6392cd965dd6a266d5dba1763d0a Mon Sep 17 00:00:00 2001 From: d fei Date: Tue, 29 Sep 2026 18:01:47 -0700 Subject: [PATCH 001/699] feat(adopt): adopt tmux sessions a human started, in all three locations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tmux session someone opened by hand (`tmux new -s work`, then `claude`, or just a shell) is invisible to Codeman: it lives on the default socket, not the instance-scoped one Codeman owns. The home screen now lists those, and one click wraps one in a Codeman tab. Local, in-container and over-ssh all work. The shape is a WRAPPER session, not a direct attach: adoption creates an ordinary `codeman-<8hex>` session on our own socket whose pane runs the attach. That makes "detach, never kill the foreign session" structural rather than a matter of discipline — `killSession` can only ever reach our own wrapper, so no shape of caller bug reaches the foreign server. View reclamation differs by location: local relies on client death and ssh on SIGHUP, but a `docker exec` does NOT die with its client, so the in-container view has to be reclaimed explicitly on kill or every adoption leaks one. Measured facts, recorded in the code comments: - A grouped session (`new-session -t`) buys `status off` and an independent current window. It does NOT buy an independent size. A window is one object with one size and a group shares it: on tmux 3.3a a bare attach and a grouped attach both shrink a 200x49 target to 80x23. Only `window-size largest` holds it, and that is a shared window option which survives our detach, so setting it would permanently rewrite the owner's configuration. Left unset. - Session names come from other people, while the local launch chain ends in `bash -c` + `JSON.stringify`, which escapes neither `$` nor backticks — the outer shell performs command substitution before the inner single quotes are considered (verified: both substitutions inside the inner quotes ran). So session names and socket paths go through a character allowlist and are dropped at DISCOVERY, which means a non-conforming candidate never gets an id and the adopt endpoint fails closed when it re-resolves. - `pane_current_command` is `node` for both claude and codex, so the mode can only come from a bounded process-tree walk of `pane_pid`'s argv; anything unrecognized is treated as a shell. An adopted session has to say no to every assumption that Codeman owns the pane: respawn, Ralph, the orchestrator and hook waits all refuse. The hook check runs BEFORE the mode check, because adoption asks who launched the process and no mode can vouch for a workspace Codeman never touched. `paneExit` is forced to unknown, since the pane runs `tmux attach` rather than the agent and its exiting with 0 only means the connection ended. Reboot restore refuses too: all Codeman ever had was an attach command, and replaying it would build a connection to a foreign session a reboot has almost certainly taken with it, while presenting whatever it lands on as a session we restored. Adoption is admin-only under multi-user mode: adopting someone's shell is equivalent to arbitrary host execution. Discovery is read-only, never creates a session, and degrades to an empty list whenever a probe cannot reach its target. --- src/config/foreign-tmux.ts | 38 ++ src/foreign-tmux-discovery.ts | 325 ++++++++++++++ src/foreign-tmux.ts | 597 ++++++++++++++++++++++++++ src/mux-interface.ts | 12 + src/reboot-restore.ts | 10 + src/session.ts | 67 ++- src/tmux-manager.ts | 161 ++++++- src/types/foreign-tmux.ts | 102 +++++ src/types/index.ts | 1 + src/types/session.ts | 80 ++++ src/web/public/app.js | 36 +- src/web/public/foreign-sessions.js | 359 ++++++++++++++++ src/web/public/index.html | 15 +- src/web/public/mobile-overview.js | 10 + src/web/public/mobile.css | 12 + src/web/public/styles.css | 170 ++++++++ src/web/public/terminal-ui.js | 8 + src/web/routes/mux-routes.ts | 63 ++- src/web/routes/ralph-routes.ts | 11 + src/web/routes/respawn-routes.ts | 33 ++ src/web/routes/session-routes.ts | 153 +++++++ src/web/schemas.ts | 21 + src/web/server.ts | 21 +- src/web/session-wait-registry.ts | 17 + test/docker-adopted-container.test.ts | 11 +- test/foreign-tmux.test.ts | 314 ++++++++++++++ test/reboot-restore.test.ts | 10 + 27 files changed, 2622 insertions(+), 35 deletions(-) create mode 100644 src/config/foreign-tmux.ts create mode 100644 src/foreign-tmux-discovery.ts create mode 100644 src/foreign-tmux.ts create mode 100644 src/types/foreign-tmux.ts create mode 100644 src/web/public/foreign-sessions.js create mode 100644 test/foreign-tmux.test.ts diff --git a/src/config/foreign-tmux.ts b/src/config/foreign-tmux.ts new file mode 100644 index 00000000..e4c838c2 --- /dev/null +++ b/src/config/foreign-tmux.ts @@ -0,0 +1,38 @@ +/** + * @fileoverview Bounds for FOREIGN tmux discovery (sessions a human started + * outside Codeman). + * + * Two facts drive every number here. First, the number of tmux sockets and panes + * on a machine is NOT under Codeman's control — a discovery walk with no ceiling + * is an unbounded loop over data someone else produces, so sockets and panes are + * both hard-capped. Second, an ssh handshake is an order of magnitude slower than + * a local `exec`; reusing the shared 5s `EXEC_TIMEOUT_MS` would classify every + * remote host as unreachable, so the probe gets its own timeout. + * + * @module config/foreign-tmux + */ + +/** How often the browser re-polls `/api/mux/foreign` while the home screen is visible. */ +export const FOREIGN_POLL_INTERVAL_MS = 8000; + +/** + * Server-side cache TTL for a LOCAL scan. This, not the poll interval, is what + * bounds the real cost: N open tabs polling at 8s still trigger at most one scan + * per TTL. + */ +export const FOREIGN_CACHE_TTL_MS = 5000; + +/** Timeout for one probe invocation (local exec, `docker exec`, or one ssh). */ +export const FOREIGN_PROBE_TIMEOUT_MS = 12000; + +/** Max tmux sockets inspected per location, oldest-first by directory order. */ +export const FOREIGN_MAX_SOCKETS = 16; + +/** Max pane rows parsed from one probe. Panes past this are dropped, not errors. */ +export const FOREIGN_MAX_PANES = 400; + +/** Max process rows parsed from one probe's `ps` snapshot. */ +export const FOREIGN_MAX_PROCS = 4000; + +/** Max bytes of probe stdout kept. A runaway `ps` must not become a heap problem. */ +export const FOREIGN_PROBE_MAX_BYTES = 2 * 1024 * 1024; diff --git a/src/foreign-tmux-discovery.ts b/src/foreign-tmux-discovery.ts new file mode 100644 index 00000000..bd66b0a7 --- /dev/null +++ b/src/foreign-tmux-discovery.ts @@ -0,0 +1,325 @@ +/** + * @fileoverview Runs the foreign-tmux probe at the three locations Codeman + * already knows how to reach: this host, inside a container, and across ssh. + * + * All the judgement lives in `foreign-tmux.ts` (pure). This module is only + * transport + caching + the promise that it never throws. + * + * ## Cost policy + * + * The home screen shows LOCAL results automatically, so a local scan runs on a + * TTL cache: N browser tabs polling every few seconds still trigger at most one + * scan per `FOREIGN_CACHE_TTL_MS`. Docker and remote are ON DEMAND only — + * opening the home page must never fan out one ssh connection per saved host. + * + * ## ⚠️ The probe must not cross an extra shell + * + * The script contains `$TMUX_TMPDIR` and `$(id -u)`, which the INNER shell has to + * expand. Local and docker therefore spawn with an argv array (`execFile`), where + * no outer shell exists to eat them first. Remote is the exception: the ssh + * command line is a string like every other ssh call in this codebase, so the + * script is wrapped in SINGLE quotes there — which is exactly why + * `buildForeignProbeScript()` may not contain one. + * + * This was measured, not reasoned: running the probe through `execSync` (which + * spawns `sh -c`) expanded `$TMUX_TMPDIR` and `$U` in the OUTER shell, leaving the + * inner loop iterating over the literal `/tmux-/*` and reporting zero sessions on + * a machine that had three. + * + * @module foreign-tmux-discovery + */ + +import { execFile, exec } from 'node:child_process'; +import { promisify } from 'node:util'; +import { + buildForeignProbeScript, + classifyForeignPaneMode, + foreignSessionId, + isAdoptableSessionName, + isAdoptableSocketPath, + isCodemanOwnedPane, + parseForeignProbeOutput, +} from './foreign-tmux.js'; +import { buildDockerBaseArgs, shellescape } from './docker-hosts.js'; +import { buildSshConnectionArgs, remoteSshTarget } from './remote-hosts.js'; +import { readDockerCases, readDockerHosts } from './docker-hosts.js'; +import { readRemoteHosts } from './remote-hosts.js'; +import { resolveTmuxSocketName, getDataDir } from './config/instance.js'; +import { FOREIGN_CACHE_TTL_MS, FOREIGN_PROBE_MAX_BYTES, FOREIGN_PROBE_TIMEOUT_MS } from './config/foreign-tmux.js'; +import type { ForeignDiscoveryResult, ForeignTmuxSession } from './types/foreign-tmux.js'; +import type { DockerCase, DockerHost, RemoteHost, SessionDocker } from './types/session.js'; + +const execFileAsync = promisify(execFile); +const execAsync = promisify(exec); + +const IS_TEST_MODE = process.env.VITEST === 'true' || process.env.NODE_ENV === 'test'; + +const PROBE_OPTS = { + encoding: 'utf-8' as const, + timeout: FOREIGN_PROBE_TIMEOUT_MS, + maxBuffer: FOREIGN_PROBE_MAX_BYTES, +}; + +/** + * Turn one probe's stdout into candidates. Shared by all three transports, so a + * container's sessions and the host's are classified by identical rules. + */ +function candidatesFrom( + stdout: string, + location: ForeignTmuxSession['location'], + hostKey: string, + extra: Pick, + notes?: string[] +): ForeignTmuxSession[] { + const probe = parseForeignProbeOutput(stdout); + const ownSocket = resolveTmuxSocketName(); + const out: ForeignTmuxSession[] = []; + const seen = new Set(); + let unsafe = 0; + + for (const pane of probe.panes) { + if (isCodemanOwnedPane(pane, ownSocket)) continue; + // ⚠️ THE security gate, and it lives here rather than at the adopt endpoint + // on purpose: a name that cannot cross the launch chain safely never gets an + // id, so there is nothing for a caller to send. Adoption re-resolves through + // this same function, which makes the endpoint fail closed for free. + // See `isAdoptableSessionName` for the injection this prevents. + if (!isAdoptableSessionName(pane.sessionName) || !isAdoptableSocketPath(pane.socketPath)) { + unsafe += 1; + continue; + } + // One row per SESSION, not per pane: the first pane is the one we would land + // on, and a multi-pane session is still a single thing to adopt. + // \u0000 as an ESCAPE, never the literal byte: a raw NUL in the source makes git + // treat this whole file as binary (no diff, no review), and the allowlists above + // already guarantee neither half can contain one. + const key = `${pane.socketPath}\u0000${pane.sessionName}`; + if (seen.has(key)) continue; + seen.add(key); + + const classified = classifyForeignPaneMode(pane, probe); + out.push({ + id: foreignSessionId(location, hostKey, pane.socketPath, pane.sessionName), + location, + ...extra, + socketPath: pane.socketPath, + sessionName: pane.sessionName, + windows: pane.windows, + attached: pane.sessionAttached, + createdAt: pane.sessionCreated * 1000, + mode: classified.mode, + command: classified.command, + workingDir: pane.paneCurrentPath, + }); + } + if (unsafe && notes) { + notes.push( + `${unsafe} session(s) skipped: the tmux session name or socket path contains characters Codeman will not pass to a shell` + ); + } + return out; +} + +// =========================================================================== +// Local +// =========================================================================== + +let localCache: { at: number; result: ForeignDiscoveryResult } | null = null; +let localInFlight: Promise | null = null; + +/** + * Scan this host. TTL-cached and single-flight, because this is the one location + * the home screen polls on its own. + */ +export async function discoverLocalForeign(force = false): Promise { + if (IS_TEST_MODE) return { sessions: [], scannedAt: Date.now(), notes: [] }; + + const now = Date.now(); + if (!force && localCache && now - localCache.at < FOREIGN_CACHE_TTL_MS) return localCache.result; + if (localInFlight) return localInFlight; + + localInFlight = (async (): Promise => { + const notes: string[] = []; + let sessions: ForeignTmuxSession[] = []; + try { + // argv array: no outer shell, so the script's own `$TMUX_TMPDIR`/`$(id -u)` + // reach the inner sh intact (see @fileoverview). + const { stdout } = await execFileAsync('sh', ['-c', buildForeignProbeScript()], PROBE_OPTS); + sessions = candidatesFrom(stdout, 'local', 'local', {}, notes); + } catch { + // No tmux, no sockets, or a probe that timed out. "Nothing to adopt here" + // is the only honest answer and it is never an error to the caller. + notes.push('local: tmux probe produced no result'); + } + const result: ForeignDiscoveryResult = { sessions, scannedAt: Date.now(), notes }; + localCache = { at: Date.now(), result }; + return result; + })().finally(() => { + localInFlight = null; + }); + + return localInFlight; +} + +/** Drop the local cache so the next scan is real (used right after an adopt). */ +export function invalidateForeignCache(): void { + localCache = null; +} + +// =========================================================================== +// Docker +// =========================================================================== + +/** Connection facts for one adoptable container, resolved from the registries. */ +interface DockerTargetInfo { + hostId: string; + label: string; + containerName: string; + base: Pick; +} + +function dockerTargets(cases: DockerCase[], hosts: DockerHost[]): DockerTargetInfo[] { + const byId = new Map(hosts.map((h) => [h.id, h])); + const out: DockerTargetInfo[] = []; + const seen = new Set(); + for (const c of cases) { + const container = c.container || `codeman-case-${c.name}`; + if (seen.has(container)) continue; + seen.add(container); + const host = byId.get(c.hostId); + out.push({ + hostId: c.hostId, + label: host?.label || c.name, + containerName: container, + base: { + engine: host?.engine ?? 'docker', + context: host?.context, + daemonHost: host?.daemonHost, + }, + }); + } + return out; +} + +/** + * Scan the containers behind the caller's docker cases. On demand only. + * + * Read-only by construction: one `docker exec` running the probe. Never inspects + * lifecycle, never starts anything — a stopped or missing container simply + * contributes a note, mirroring `probeAdoptableContainer`'s "engine unreachable + * degrades to an empty list" rule. + */ +export async function discoverDockerForeign(cases: DockerCase[]): Promise { + if (IS_TEST_MODE) return { sessions: [], scannedAt: Date.now(), notes: [] }; + + const hosts = await readDockerHosts(getDataDir()).catch(() => [] as DockerHost[]); + const targets = dockerTargets(cases, hosts); + const notes: string[] = []; + const sessions: ForeignTmuxSession[] = []; + + await Promise.all( + targets.map(async (t) => { + const [engine, ...baseFlags] = buildDockerBaseArgs(t.base); + try { + const { stdout } = await execFileAsync( + engine, + [...baseFlags, 'exec', t.containerName, 'sh', '-lc', buildForeignProbeScript()], + PROBE_OPTS + ); + sessions.push( + ...candidatesFrom( + stdout, + 'docker', + t.containerName, + { hostId: t.hostId, hostLabel: t.label, containerName: t.containerName }, + notes + ) + ); + } catch { + notes.push(`docker ${t.containerName}: not running, no tmux, or engine unreachable`); + } + }) + ); + + return { sessions, scannedAt: Date.now(), notes }; +} + +// =========================================================================== +// Remote +// =========================================================================== + +/** + * Scan saved ssh hosts. On demand only — this is the expensive one. + * + * ⚠️ The ssh command is a STRING (as every ssh call in this codebase is), so the + * probe crosses one extra shell and must be single-quoted. `shellescape` does + * that, and `buildForeignProbeScript()` is single-quote-free precisely so the + * escaping stays a wrap rather than a rewrite. + */ +export async function discoverRemoteForeign(hosts: RemoteHost[]): Promise { + if (IS_TEST_MODE) return { sessions: [], scannedAt: Date.now(), notes: [] }; + + const notes: string[] = []; + const sessions: ForeignTmuxSession[] = []; + + await Promise.all( + hosts.map(async (host) => { + const cmd = [...buildSshConnectionArgs(host), remoteSshTarget(host), shellescape(buildForeignProbeScript())].join( + ' ' + ); + try { + const { stdout } = await execAsync(cmd, PROBE_OPTS); + sessions.push(...candidatesFrom(stdout, 'remote', host.id, { hostId: host.id, hostLabel: host.label })); + } catch { + notes.push(`remote ${host.label}: unreachable, or no tmux`); + } + }) + ); + + return { sessions, scannedAt: Date.now(), notes }; +} + +// =========================================================================== +// Facade +// =========================================================================== + +export interface ForeignDiscoveryRequest { + /** Always scanned (cheap, cached). */ + local?: boolean; + /** Docker cases the CALLER may see — ownership filtering happens in the route. */ + dockerCases?: DockerCase[]; + /** Remote hosts the CALLER may see. */ + remoteHosts?: RemoteHost[]; + /** Skip the local TTL cache. */ + force?: boolean; +} + +/** + * One scan across the requested locations. Merges notes, never throws. + * + * The caller supplies the docker cases and remote hosts rather than this module + * reading the registries itself: which of them a user may see is an ownership + * question, and ownership belongs to the route layer. + */ +export async function discoverForeignSessions(req: ForeignDiscoveryRequest): Promise { + const parts: ForeignDiscoveryResult[] = []; + if (req.local !== false) parts.push(await discoverLocalForeign(req.force)); + if (req.dockerCases?.length) parts.push(await discoverDockerForeign(req.dockerCases)); + if (req.remoteHosts?.length) parts.push(await discoverRemoteForeign(req.remoteHosts)); + + return { + sessions: parts.flatMap((p) => p.sessions), + scannedAt: parts.length ? Math.max(...parts.map((p) => p.scannedAt)) : Date.now(), + notes: parts.flatMap((p) => p.notes), + }; +} + +/** Re-read the caller-visible docker cases, for routes that only hold a username. */ +export async function readAllDockerCases(): Promise { + return readDockerCases(getDataDir()).catch(() => [] as DockerCase[]); +} + +/** Re-read the saved remote hosts. */ +export async function readAllRemoteHosts(): Promise { + return readRemoteHosts(getDataDir()).catch(() => [] as RemoteHost[]); +} diff --git a/src/foreign-tmux.ts b/src/foreign-tmux.ts new file mode 100644 index 00000000..cb54bc4c --- /dev/null +++ b/src/foreign-tmux.ts @@ -0,0 +1,597 @@ +/** + * @fileoverview Pure core for FOREIGN tmux sessions — the ones a human started + * by hand, which Codeman neither created nor owns. + * + * Everything here is a string in / structure out, so all three locations (local, + * inside a container, across ssh) go through ONE probe script, ONE parser and ONE + * classifier. Writing a second copy per location is exactly how the two would + * drift into disagreeing about what a session is. + * + * ## Why the probe script is dumb + * + * It runs two commands and prints them: `tmux list-panes` per socket, and one + * `ps` snapshot. No filtering, no logic. All judgement happens in Node, where it + * is pure and unit-testable, instead of in a shell string that is embedded three + * different ways and can only be debugged against a real host. + * + * ⚠️ The script MUST NOT contain a single quote. It is wrapped in single quotes + * to cross `ssh ' + diff --git a/src/web/public/mobile-overview.js b/src/web/public/mobile-overview.js index 764697a6..24f116ff 100644 --- a/src/web/public/mobile-overview.js +++ b/src/web/public/mobile-overview.js @@ -507,6 +507,16 @@ Object.assign(CodemanApp.prototype, { ) ); + // Sessions a human opened outside Codeman. Its own container, rebuilt by the + // ONE renderer in foreign-sessions.js — the phone must not grow a second row + // builder that could describe the same session differently from the desktop. + const foreign = document.createElement('div'); + foreign.className = 'foreign-sessions mobile-foreign-sessions'; + foreign.id = 'mobileForeignSessions'; + foreign.hidden = true; + el.appendChild(foreign); + this.renderForeignSessions?.(foreign); + el.appendChild( this._buildMobileOverviewSection( 'Past sessions', diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index 5d47f317..445db7f2 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -4027,6 +4027,18 @@ html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-close { } } +/* Foreign sessions block inside the phone overview (foreign-sessions.js). + The desktop block sits inside the welcome column; here it is a full-width + section between CURRENT and PAST, so it only needs the surrounding spacing — + every row style is shared with styles.css on purpose. */ +.mobile-foreign-sessions { + margin: 0.75rem 0.75rem 0; +} + +.mobile-foreign-sessions .foreign-list { + max-height: none; +} + /* Folding devices, tabletop pose: cap the response viewer to the bottom segment. Twin of the rule at the end of styles.css, needed here because the phone block (under 600px) above sets `max-height: 92dvh` on the same selector at the same diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 050671a4..e7c4135a 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -19088,3 +19088,173 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle font-size: 13px; color: var(--text-muted); } + +/* ═══════════════════════════════════════════════════════════════ + Foreign sessions — tmux sessions a human started outside Codeman + (foreign-sessions.js). Rendered on the welcome screen and, with the + same row builder, inside the phone overview. + + Colour vocabulary is deliberately the session-tab one: a mode dot on + the left, name over a dim meta line, action pinned right. A block that + invented its own language here would read as a different product. + ═══════════════════════════════════════════════════════════════ */ + +.welcome-foreign { + width: 100%; + margin-top: 0.75rem; +} + +.foreign-sessions { + display: flex; + flex-direction: column; + gap: 0.4rem; + text-align: left; +} + +/* `.foreign-sessions` is a flex container, so `[hidden]` needs re-asserting or + the module's only visibility lever does nothing (same trap as .home-sessions). */ +.foreign-sessions[hidden] { + display: none; +} + +.foreign-header { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.foreign-title { + font-size: 0.85rem; + color: var(--text-dim); + font-weight: 500; + text-align: left; +} + +.foreign-count { + font-size: 0.68rem; + color: var(--text-dim); + background: rgba(255, 255, 255, 0.05); + border-radius: 999px; + padding: 0.1rem 0.45rem; + white-space: nowrap; +} + +.foreign-scan-toggle { + margin-left: auto; + font-size: 0.68rem; + color: var(--text-dim); + background: transparent; + border: 1px solid var(--border); + border-radius: 999px; + padding: 0.12rem 0.5rem; + cursor: pointer; +} + +.foreign-scan-toggle[aria-pressed='true'] { + color: var(--session-blue, #4a9eff); + border-color: var(--session-blue, #4a9eff); +} + +.foreign-list { + display: flex; + flex-direction: column; + gap: 0.3rem; + max-height: min(40vh, 320px); + overflow-y: auto; +} + +.foreign-row { + display: flex; + align-items: center; + gap: 0.55rem; + padding: 0.4rem 0.55rem; + border: 1px solid var(--border); + border-radius: 6px; + background: rgba(255, 255, 255, 0.02); + min-width: 0; +} + +.foreign-row--open { + opacity: 0.62; +} + +.foreign-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex: 0 0 auto; + background: var(--text-muted, #888); +} + +.foreign-dot--claude { + background: #d97757; +} +.foreign-dot--codex { + background: #9b8cff; +} +.foreign-dot--shell { + background: #4caf7d; +} + +.foreign-row-body { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1 1 auto; +} + +.foreign-row-name { + font-size: 0.82rem; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.foreign-row-sub { + font-size: 0.68rem; + color: var(--text-dim); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.foreign-open-btn { + flex: 0 0 auto; + font-size: 0.7rem; + padding: 0.22rem 0.6rem; + border-radius: 5px; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.04); + color: var(--text); + cursor: pointer; +} + +.foreign-open-btn:hover:not(:disabled) { + border-color: var(--session-blue, #4a9eff); + color: var(--session-blue, #4a9eff); +} + +.foreign-open-btn:disabled { + opacity: 0.55; + cursor: default; +} + +.foreign-empty { + font-size: 0.72rem; + color: var(--text-dim); + padding: 0.3rem 0.1rem; +} + +.foreign-notes { + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.foreign-note { + font-size: 0.66rem; + color: var(--text-dim); + opacity: 0.85; + line-height: 1.35; +} diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 657e1624..4bfe4d12 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -2215,6 +2215,9 @@ Object.assign(CodemanApp.prototype, { if (overlay) overlay.classList.remove('visible'); this.hideHomeSessions?.(); this.showMobileOverview(); + // The phone overview hosts the same list in its own container. + this.wireForeignSessions?.(); + this.startForeignPolling?.(); this._updateCjkInputState?.(); return; } @@ -2229,6 +2232,10 @@ Object.assign(CodemanApp.prototype, { // Open tabs down the left gutter. Self-gating: a window too narrow to hold // the column without overlapping the content leaves it hidden. this.showHomeSessions?.(); + // Sessions a human opened outside Codeman. Polls only while this screen is + // up (stopped in hideWelcome) — see foreign-sessions.js. + this.wireForeignSessions?.(); + this.startForeignPolling?.(); } // Home screen has no input target — hide the CJK textarea (activeSessionId // is null by the time we get here). Guarded: defined on the app object. @@ -2238,6 +2245,7 @@ Object.assign(CodemanApp.prototype, { hideWelcome() { this.hideMobileOverview?.(); this.hideHomeSessions?.(); + this.stopForeignPolling?.(); const overlay = document.getElementById('welcomeOverlay'); if (overlay) { overlay.classList.remove('visible'); diff --git a/src/web/routes/mux-routes.ts b/src/web/routes/mux-routes.ts index 12b22229..b4e42ce9 100644 --- a/src/web/routes/mux-routes.ts +++ b/src/web/routes/mux-routes.ts @@ -1,6 +1,13 @@ /** * @fileoverview Mux (tmux) session management routes. - * Provides mux session listing, killing, reconciliation, and stats control. + * Provides mux session listing, killing, reconciliation, stats control, and + * discovery of FOREIGN tmux sessions (ones a human started outside Codeman). + * + * Discovery lives here rather than beside the adopt endpoint on purpose: like + * every other route in this file it exposes cross-user process state — other + * people's session names, commands and working directories — so it inherits the + * admin gate this file already applies. Adoption is a session CREATE and stays in + * `session-routes.ts`, where the owner, capacity and case-space gates live. */ import { FastifyInstance } from 'fastify'; @@ -8,6 +15,8 @@ import type { InfraPort } from '../ports/index.js'; import { STATS_COLLECTION_INTERVAL_MS } from '../../config/server-timing.js'; import { requireAdmin } from '../route-helpers.js'; import { isMultiUserMode } from '../../config/multiuser.js'; +import { discoverForeignSessions, readAllDockerCases, readAllRemoteHosts } from '../../foreign-tmux-discovery.js'; +import { FOREIGN_POLL_INTERVAL_MS } from '../../config/foreign-tmux.js'; export function registerMuxRoutes(app: FastifyInstance, ctx: InfraPort): void { app.get('/api/mux-sessions', async (req, reply) => { @@ -36,6 +45,58 @@ export function registerMuxRoutes(app: FastifyInstance, ctx: InfraPort): void { return result; }); + /** + * Foreign tmux sessions available for adoption. + * + * LOCAL results are always included and are TTL-cached, because the home screen + * polls this endpoint while it is open. DOCKER and REMOTE are opt-in per + * request (`?docker=1`, `?remote=1`): each costs one `docker exec` or one ssh + * per target, and having the home page fan those out on every load is the one + * cost this design refuses to pay. + * + * `adoptedBy` is filled from the live mux sessions, so a target Codeman already + * wraps renders as "open" rather than offering a second wrapper. + */ + app.get('/api/mux/foreign', async (req, reply) => { + if (isMultiUserMode() && !requireAdmin(req, reply)) return; + const q = (req.query ?? {}) as Record; + const wantDocker = q.docker === '1' || q.docker === 'true'; + const wantRemote = q.remote === '1' || q.remote === 'true'; + + // Read the registries either way: `canScanWide` tells the browser whether the + // expensive scan has anywhere to go. Without it the UI hides an empty block — + // and with it the toggle that is the ONLY way to populate that block, which on + // a host with containers but no local tmux sessions made the feature invisible. + const dockerCases = await readAllDockerCases(); + const remoteHosts = await readAllRemoteHosts(); + + const result = await discoverForeignSessions({ + local: true, + force: q.force === '1', + dockerCases: wantDocker ? dockerCases : undefined, + remoteHosts: wantRemote ? remoteHosts : undefined, + }); + + // Match on the (socket, session) pair rather than on our opaque candidate id: + // the id encodes a host key that a restored wrapper does not carry, while the + // pair is exactly what the wrapper stores and what it re-attaches to. + const wrapped = new Map(); + for (const m of ctx.mux.getSessions()) { + if (m.adopt) wrapped.set(`${m.adopt.socketPath}\u0000${m.adopt.targetSession}`, m.sessionId); + } + + return { + sessions: result.sessions.map((f) => ({ + ...f, + adoptedBy: wrapped.get(`${f.socketPath}\u0000${f.sessionName}`), + })), + scannedAt: result.scannedAt, + notes: result.notes, + pollIntervalMs: FOREIGN_POLL_INTERVAL_MS, + canScanWide: dockerCases.length > 0 || remoteHosts.length > 0, + }; + }); + app.post('/api/mux-sessions/stats/start', async (req, reply) => { // Multi-user: process-wide stats collection toggle → admin-only. if (isMultiUserMode() && !requireAdmin(req, reply)) return; diff --git a/src/web/routes/ralph-routes.ts b/src/web/routes/ralph-routes.ts index 24b08a48..d7c9e27e 100644 --- a/src/web/routes/ralph-routes.ts +++ b/src/web/routes/ralph-routes.ts @@ -53,6 +53,17 @@ export function registerRalphRoutes( }; const session = findSessionOrFail(ctx, id, req); + // ⚠️ Adoption gate, kept SEPARATE from the external-CLI gate above: an adopted + // session can be `mode: 'claude'` and still be a process we never launched. + // Everything below drives the pane on the assumption Codeman owns what runs + // in it — sending `/clear`, killing and relaunching the agent — which against + // someone else's live session is destructive, not merely unsupported. + if (session.isAdopted) { + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + 'The Ralph tracker is not available for adopted sessions: Codeman did not start this agent and must not drive its lifecycle' + ); + } // Ralph tracker is not supported for external-CLI sessions (opencode/codex) if (isExternalCliMode(session.mode)) { return createErrorResponse( diff --git a/src/web/routes/respawn-routes.ts b/src/web/routes/respawn-routes.ts index 56543997..04902813 100644 --- a/src/web/routes/respawn-routes.ts +++ b/src/web/routes/respawn-routes.ts @@ -98,6 +98,17 @@ export function registerRespawnRoutes( } const session = findSessionOrFail(ctx, id, req); + // ⚠️ Adoption gate, kept SEPARATE from the external-CLI gate above: an adopted + // session can be `mode: 'claude'` and still be a process we never launched. + // Everything below drives the pane on the assumption Codeman owns what runs + // in it — sending `/clear`, killing and relaunching the agent — which against + // someone else's live session is destructive, not merely unsupported. + if (session.isAdopted) { + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + 'Respawn is not available for adopted sessions: Codeman did not start this agent and must not drive its lifecycle' + ); + } // Respawn is not supported for external-CLI sessions (opencode/codex) if (isExternalCliMode(session.mode)) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Respawn is not supported for ${session.mode} sessions`); @@ -241,6 +252,17 @@ export function registerRespawnRoutes( return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy'); } + // ⚠️ Adoption gate, kept SEPARATE from the external-CLI gate above: an adopted + // session can be `mode: 'claude'` and still be a process we never launched. + // Everything below drives the pane on the assumption Codeman owns what runs + // in it — sending `/clear`, killing and relaunching the agent — which against + // someone else's live session is destructive, not merely unsupported. + if (session.isAdopted) { + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + 'Respawn is not available for adopted sessions: Codeman did not start this agent and must not drive its lifecycle' + ); + } // Respawn is not supported for external-CLI sessions (opencode/codex) if (isExternalCliMode(session.mode)) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Respawn is not supported for ${session.mode} sessions`); @@ -310,6 +332,17 @@ export function registerRespawnRoutes( const body = reResult.data as { config?: Partial; durationMinutes?: number }; const session = findSessionOrFail(ctx, id, req); + // ⚠️ Adoption gate, kept SEPARATE from the external-CLI gate above: an adopted + // session can be `mode: 'claude'` and still be a process we never launched. + // Everything below drives the pane on the assumption Codeman owns what runs + // in it — sending `/clear`, killing and relaunching the agent — which against + // someone else's live session is destructive, not merely unsupported. + if (session.isAdopted) { + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + 'Respawn is not available for adopted sessions: Codeman did not start this agent and must not drive its lifecycle' + ); + } // Respawn is not supported for external-CLI sessions (opencode/codex) if (isExternalCliMode(session.mode)) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Respawn is not supported for ${session.mode} sessions`); diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 6a7b78ad..0d72c87d 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -30,6 +30,16 @@ import { type OmpConfig, type RemoteHost, } from '../../types.js'; +import { AdoptForeignSessionSchema } from '../schemas.js'; +import { + discoverForeignSessions, + invalidateForeignCache, + readAllDockerCases, + readAllRemoteHosts, +} from '../../foreign-tmux-discovery.js'; +import { foreignViewSessionName } from '../../foreign-tmux.js'; +import { requireAdmin } from '../route-helpers.js'; +import type { SessionAdopt } from '../../types/session.js'; import { Session, isAltScreenStripMode, isExternalCliMode, isMuxAltScreenOnlyStripMode } from '../../session.js'; import type { PaneCaptureOptions } from '../../mux-interface.js'; import { SseEvent } from '../sse-events.js'; @@ -1156,6 +1166,149 @@ export function registerSessionRoutes( return { session: lightState }; }); + // ========== Adopt a foreign tmux session ========== + + /** + * Wrap a tmux session a HUMAN started (local, in a container, or over ssh) in a + * Codeman session, so it appears as a tab and can be driven from the browser. + * + * Four things make this safe, and each is load-bearing: + * + * 1. **The body carries only an opaque id.** The socket path, session name and + * host are re-resolved by re-running discovery here. A browser therefore + * never supplies a fragment of the command we are about to run, which is the + * same rule that keeps docker-adopt and remote-attach injection-free. + * 2. **The candidate must still exist.** Discovery is re-run rather than cached, + * so a session that died between the listing and the click fails with a 404 + * instead of producing a wrapper attached to nothing. + * 3. **One wrapper per target.** Two wrappers on one foreign session would each + * create their own grouped view and each think they own the tab; the guard + * is here rather than in the button's in-flight lock, which only stops a + * double-click on one device. + * 4. **Admin-only under multi-user.** Discovery already is (it exposes other + * users' processes), and adopting someone's `shell` is arbitrary execution + * as the server account — which is exactly what the `can-bypass-permissions` + * grant gates elsewhere. The admin gate subsumes it, so there is deliberately + * no second grant check here. + */ + app.post('/api/sessions/adopt', async (req, reply) => { + if (isMultiUserMode() && !requireAdmin(req, reply)) return; + + const owner = ownerFor(req); + const capMsg = sessionCapacityMessage(ctx.sessions, owner); + if (capMsg) return createErrorResponse(ApiErrorCode.SESSION_BUSY, capMsg); + + const body = parseBody(AdoptForeignSessionSchema, req.body, 'Invalid request body'); + + // Re-resolve rather than trust: point 1 and 2 above. + const found = await discoverForeignSessions({ + local: true, + force: true, + dockerCases: body.docker ? await readAllDockerCases() : undefined, + remoteHosts: body.remote ? await readAllRemoteHosts() : undefined, + }); + const target = found.sessions.find((f) => f.id === body.id); + if (!target) { + // ⚠️ "Not in the re-resolve" has two very different causes and they must not + // be reported as one. The session really being gone is the ordinary case; + // the OTHER case is a location we could not reach this time, which on a + // flaky link makes a perfectly live remote session read as deleted. Measured + // against a real VM whose ssh path dropped ~10% of connections: clicking + // Open failed with "no longer there" while the session was sitting right + // there. Discovery already knows which it was — it wrote a note — so say so. + const reach = found.notes.filter((n) => !/skipped/.test(n)); + return createErrorResponse( + ApiErrorCode.NOT_FOUND, + reach.length + ? `Could not reach it just now (${reach.join('; ')}). It may still be running — try again.` + : 'That tmux session is no longer there. Refresh the list and try again.' + ); + } + + // Point 3 — one wrapper per (socket, session). + const existing = ctx.mux + .getSessions() + .find((m) => m.adopt?.socketPath === target.socketPath && m.adopt?.targetSession === target.sessionName); + if (existing) { + const live = ctx.sessions.get(existing.sessionId); + if (live) return { session: ctx.getSessionStateWithRespawn(live), alreadyAdopted: true }; + } + + // Connection facts are copied onto the session rather than referenced by id: + // a wrapper restored after a server restart must be able to rebuild its + // command even if the host registry was edited in the meantime. + const adopt: SessionAdopt = { + location: target.location, + socketPath: target.socketPath, + targetSession: target.sessionName, + viewSession: '', + paneCurrentPath: target.workingDir, + }; + + if (target.location === 'docker') { + const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR); + const host = hosts.find((h) => h.id === target.hostId); + if (!target.containerName) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Container name missing for a docker candidate'); + } + adopt.docker = { + hostId: target.hostId ?? '', + label: target.hostLabel ?? target.containerName, + engine: host?.engine ?? 'docker', + containerName: target.containerName, + daemonHost: host?.daemonHost, + context: host?.context, + }; + } else if (target.location === 'remote') { + const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((h) => h.id === target.hostId); + if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found'); + adopt.remote = { + hostId: host.id, + label: host.label, + host: host.host, + username: host.username, + port: host.port, + identityFile: host.identityFile, + socksProxy: host.socksProxy, + jumpHost: host.jumpHost, + extraSshOptions: host.extraSshOptions, + }; + } + + const adoptHistoryConfig = await ctx.getTerminalHistoryConfig(); + + // ⚠️ `workingDir` for an adopted session is the FOREIGN pane's cwd, which may + // not exist on this host (a container path, a remote path). It is recorded as + // an observation for display; the wrapper pane is never `cd`'d into it, and + // the case-space confinement that guards a real workingDir does not apply + // because nothing is created there. + const session = new Session({ + workingDir: target.workingDir || process.cwd(), + mode: target.mode, + name: body.name || target.sessionName, + mux: ctx.mux, + useMux: true, + tmuxHistoryLimit: adoptHistoryConfig.tmuxHistoryLimit, + adopt, + owner, + parentSessionId: resolveParentSessionId(ctx, req, body.parentSessionId, owner), + }); + // The view session name is derived from the Codeman session id, so it can only + // be filled once the Session exists. + adopt.viewSession = foreignViewSessionName(session.id); + + await ctx.addSession(session); + ctx.store.incrementSessionsCreated(); + ctx.persistSessionState(session); + await ctx.setupSessionListeners(session); + getLifecycleLog().log({ event: 'created', sessionId: session.id, name: session.name }); + invalidateForeignCache(); + + const lightState = ctx.getSessionStateWithRespawn(session); + ctx.broadcast(SseEvent.SessionCreated, lightState); + return { session: lightState, adopted: true }; + }); + // ========== Rename Session ========== app.put('/api/sessions/:id/name', async (req) => { diff --git a/src/web/schemas.ts b/src/web/schemas.ts index c12a83a9..0726bdab 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -2067,3 +2067,24 @@ export const CustomModelSelectionSchema = z.union([ }), z.object({ clear: z.literal(true) }), ]); +/** + * Adopt a FOREIGN tmux session (one a human started outside Codeman). + * + * ⚠️ The body carries ONLY the opaque candidate id from `GET /api/mux/foreign`. + * The socket path, session name and host are re-resolved server-side by re-running + * discovery, so a browser can never hand the launch chain a path or a session name + * to interpolate. That is the same discipline that keeps the docker-adopt and + * remote-attach paths free of caller-supplied command fragments. + */ +export const AdoptForeignSessionSchema = z + .object({ + id: z.string().min(1).max(64), + /** Optional tab name; defaults to the foreign session's own name. */ + name: z.string().max(128).optional(), + /** Include docker locations in the re-resolve (must match the listing call). */ + docker: z.boolean().optional(), + /** Include remote locations in the re-resolve. */ + remote: z.boolean().optional(), + parentSessionId: z.string().max(64).optional(), + }) + .strict(); diff --git a/src/web/server.ts b/src/web/server.ts index d70577e6..1ab399fa 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1821,13 +1821,20 @@ export class WebServer extends EventEmitter { this.runSummaryTrackers.set(session.id, summaryTracker); summaryTracker.recordSessionStarted(session.mode, session.workingDir); - // Set working directory for Ralph tracker to auto-load @fix_plan.md (not supported for external CLIs) - if (!isExternalCliMode(session.mode)) { + // Set working directory for Ralph tracker to auto-load @fix_plan.md (not supported for external CLIs). + // ⚠️ Also skipped for an ADOPTED session, and for two reasons: Ralph is refused + // for one anyway, and its `workingDir` is the FOREIGN pane's cwd — a path that + // need not exist on this host at all. Watching it logged a caught ENOENT on + // every in-container adoption (`watch '/workspace/pythonserver'`), which is + // noise pointing at a real category error rather than a real failure. + if (!isExternalCliMode(session.mode) && !session.isAdopted) { session.ralphTracker.setWorkingDir(session.workingDir); } // Start watching for new images in this session's working directory (if enabled globally and per-session) - if ((await this.isImageWatcherEnabled()) && session.imageWatcherEnabled) { + if ((await this.isImageWatcherEnabled()) && session.imageWatcherEnabled && !session.isAdopted) { + // Same reason as the Ralph watcher above: an adopted session's workingDir is + // an observation about ANOTHER host's (or container's) filesystem. imageWatcher.watchSession(session.id, session.workingDir); } @@ -3524,6 +3531,14 @@ export class WebServer extends EventEmitter { // MuxSession.docker; state.json carries SessionState.docker), so recovery // rebuilds the `docker exec` launch instead of a broken local command. docker: muxSession.docker ?? savedState?.docker, + // Adoption metadata round-trips for the same reason remote/docker do, + // and one more: it is the ONLY thing that marks this session as + // wrapping a process Codeman never launched. Dropping it on recovery + // silently re-enabled respawn, Ralph and hook-backed waits against + // someone else's live tmux session after every server restart — + // measured, not hypothetical. The mux record is preferred because it + // is what `killSession`'s detach-not-kill guard already reads. + adopt: muxSession.adopt ?? savedState?.adopt, owner: recoveredOwner, // Tab lineage survives a restart. It is only decoration, so a parent // that did NOT come back is harmless: the frontend draws an edge only diff --git a/src/web/session-wait-registry.ts b/src/web/session-wait-registry.ts index e734927d..2507f226 100644 --- a/src/web/session-wait-registry.ts +++ b/src/web/session-wait-registry.ts @@ -190,6 +190,18 @@ export interface HookCapabilityOptions { * timeout on every turn. */ deepSeekBridgeUnreachable?: boolean; + /** + * True when the session is a WRAPPER around a tmux session a human started + * outside Codeman. + * + * This one overrides the mode entirely, and it has to: an adopted session can + * be `mode: 'claude'` and still have no hooks, because hooks are installed into + * a WORKSPACE at session-create time (`applyWorkspaceHooks`) and we never + * created this one. Answering from the mode there would promise `stop` and + * `blocked` for a process that can never post either — the exact + * infinite-wait-dressed-as-a-timeout this predicate exists to prevent. + */ + adopted?: boolean; } /** @@ -224,6 +236,9 @@ export interface HookCapabilityOptions { * function only about hook SIGNALS. */ export function hooksAvailableForMode(mode: SessionMode, options: HookCapabilityOptions = {}): boolean { + // Checked BEFORE the mode: adoption is about who launched the process, and no + // mode can vouch for a workspace Codeman never touched. See `adopted` above. + if (options.adopted) return false; // A TRI-state capability, not a boolean, because the three answers are genuinely // different questions — see CliCapabilities.hooks. switch (getCli(mode)?.capabilities.hooks) { @@ -259,10 +274,12 @@ export function sessionHookOptions(session: { deepSeekStatusReporting?: boolean; docker?: unknown; remote?: unknown; + adopt?: unknown; }): HookCapabilityOptions { return { deepSeekStatusReporting: session.deepSeekStatusReporting, deepSeekBridgeUnreachable: Boolean(session.docker || session.remote), + adopted: Boolean(session.adopt), }; } diff --git a/test/docker-adopted-container.test.ts b/test/docker-adopted-container.test.ts index 1f3c4afb..c8576819 100644 --- a/test/docker-adopted-container.test.ts +++ b/test/docker-adopted-container.test.ts @@ -218,8 +218,15 @@ describe('adopted container: the host is not required to have the CLI', () => { expect(src.match(/if \(mode === '[a-z]+' && !cliDir\)/g)).toBeNull(); }); - it('derives the flag from the docker metadata the session already carries', () => { - expect(src).toContain('const cliRunsInContainer = !!docker;'); + it('derives the flag from the location metadata the session already carries', () => { + // Adoption joined the condition for the same reason docker is in it: an + // adopted session's CLI was started by a human in a process Codeman never + // spawned, so the host binary is irrelevant there too — and demanding it + // would reject adopting a claude that lives in a container, on an ssh host, + // or simply outside the server process's PATH (the systemd/launchd case). + // What the assertion still pins is that the flag comes from the session's + // OWN metadata rather than from anything ambient. + expect(src).toContain('const cliRunsInContainer = !!docker || !!adopt;'); }); }); diff --git a/test/foreign-tmux.test.ts b/test/foreign-tmux.test.ts new file mode 100644 index 00000000..b11216f8 --- /dev/null +++ b/test/foreign-tmux.test.ts @@ -0,0 +1,314 @@ +/** + * Foreign tmux adoption — the pure core. + * + * These pin the properties that were established by MEASUREMENT against a real + * tmux (3.3a) while the feature was built, and that a plausible-looking refactor + * would quietly undo. Each one has a comment naming what actually went wrong. + */ + +import { describe, it, expect } from 'vitest'; +import { + buildForeignProbeScript, + parseForeignProbeOutput, + classifyForeignPaneMode, + isCodemanOwnedPane, + foreignSessionId, + foreignViewSessionName, + isAdoptableSessionName, + isAdoptableSocketPath, + buildForeignAttachCommand, + buildForeignDockerAttachCommand, + buildForeignRemoteAttachCommand, + buildForeignTmuxInvocation, +} from '../src/foreign-tmux.js'; + +// A probe transcript in exactly the shape a real run produces. The pane rows use +// the LITERAL backslash-t that tmux's `-F` emits (verified on next-3.7 and 3.3a), +// while the socket line is space-separated because `sh`'s builtin `echo` expands +// a backslash-t to a real TAB — two different meanings for one escape, two lines +// apart, which is why the socket marker carries no separator at all. +const PROBE = [ + 'CMFS /tmp/tmux-0/default', + 'CMFP\\t/tmp/tmux-0/default\\t631\\t0\\t1\\t1788092494\\t1\\t%0\\tclaude\\twork\\t/srv/app', + 'CMFP\\t/tmp/tmux-0/default\\t900\\t0\\t1\\t1788092500\\t0\\t%1\\tbash\\tscratch\\t/home/me', + 'CMFP\\t/tmp/tmux-0/default\\t950\\t0\\t2\\t1788092600\\t0\\t%2\\tnode\\tcodex-work\\t/srv/app', + 'CMFQ', + ' 631 630 -bash', + ' 4056 631 claude --dangerously-skip-permissions', + ' 4104 4056 /usr/local/bin/ortg --repo /ortg mcp', + ' 900 630 -bash', + ' 950 630 node /opt/homebrew/bin/codex', +].join('\n'); + +describe('buildForeignProbeScript', () => { + it('contains no single quote — it is wrapped in one to cross ssh and docker exec', () => { + // The script is embedded as `ssh host ' + diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 65755b90..a8c175fa 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -2249,6 +2249,97 @@ html[data-tab-orientation='vertical'].tab-rail-compact margin-left: auto; padding-left: 0.75rem; border-left: 1px solid var(--glass-border); + position: relative; +} + +/* Draggable edge of the toolbar (header-divider.js). Absolute, so it takes no layout room and the + existing border-left stays the visible line. */ +.header-divider { + position: absolute; + top: 0; + bottom: 0; + left: -6px; + width: 12px; + cursor: col-resize; + touch-action: none; + z-index: 2; +} + +.header-divider:hover, +.header-divider:focus-visible { + background: color-mix(in srgb, var(--accent) 24%, transparent); + outline: none; +} + +.header-divider:focus-visible { + box-shadow: inset 0 0 0 2px var(--accent); +} + +/* A width the user chose: the toolbar keeps it and wraps onto a second line when its content is wider. */ +html[data-header-right-width]:not([data-tab-orientation='vertical']):not([data-session-list='sidebar']) + body:not(.solo-mode) + .header-right { + flex: 0 0 var(--header-right-width); + width: var(--header-right-width); + flex-wrap: wrap; + justify-content: flex-end; + align-content: center; + row-gap: 0.25rem; + min-height: var(--header-height); +} + +/* The narrower the toolbar, the less each usage reading shows (data-header-density, header-divider.js): + tight drops the window and CPU/MEM labels, dense shortens the provider names, tiny keeps only the rings. + The same rules apply under every header-stats style. */ +html[data-header-density] .header-system-stats .stat-label, +html[data-header-density] .header-plan-usage .pu-label { + display: none; +} + +html[data-header-density] .header-system-stats, +html[data-header-density] .header-plan-usage { + gap: 8px; + padding: 0 8px; +} + +html[data-header-density='dense'] .header-plan-usage .pu-provider, +html[data-header-density='tiny'] .header-plan-usage .pu-provider { + font-size: 0; +} + +html[data-header-density='dense'] .header-plan-usage .pu-provider::after, +html[data-header-density='tiny'] .header-plan-usage .pu-provider::after { + content: attr(data-short); + font-size: 0.6rem; +} + +html[data-header-density='tiny'] .header-system-stats .stat-value, +html[data-header-density='tiny'] .header-plan-usage .pu-val { + display: none; +} + +body.header-divider-dragging, +body.header-divider-dragging * { + cursor: col-resize !important; + user-select: none !important; +} + +/* Only where the tabs share the header with the toolbar. */ +html[data-tab-orientation='vertical'] .header-divider, +html[data-session-list='sidebar'] .header-divider, +body.solo-mode .header-divider { + display: none; +} + +@media (max-width: 768px) { + .header-divider { + display: none; + } + + html[data-header-right-width] .header-right { + flex: 0 1 auto; + width: auto; + } } /* Connection Indicator */ diff --git a/test/header-divider.browser.test.ts b/test/header-divider.browser.test.ts new file mode 100644 index 00000000..1aa105a6 --- /dev/null +++ b/test/header-divider.browser.test.ts @@ -0,0 +1,130 @@ +/** Real Chromium: dragging the header divider resizes the toolbar and wraps it onto a second line. */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { chromium, type Browser, type Page } from 'playwright'; + +const dir = resolve(import.meta.dirname, '../src/web/public'); +const styles = readFileSync(resolve(dir, 'styles.css'), 'utf8'); +const controller = readFileSync(resolve(dir, 'header-divider.js'), 'utf8'); + +const page = (root = 'data-tab-orientation="horizontal" data-session-list="header"') => ` + +
+
+
+
+
usage
+
stats
+ +
+
+`; + +describe('header divider (real Chromium)', () => { + let browser: Browser; + let p: Page; + beforeAll(async () => { + browser = await chromium.launch(); + }); + afterAll(async () => { + await browser.close(); + }); + + async function open(html: string) { + p = await browser.newPage({ viewport: { width: 1400, height: 600 } }); + await p.setContent(html); + await p.addScriptTag({ content: controller }); + } + const box = (sel: string) => p.locator(sel).evaluate((el) => el.getBoundingClientRect().toJSON()); + + it('narrows the toolbar and wraps it onto a second line when dragged right, and resets on double-click', async () => { + await open(page()); + const before = await box('#headerRight'); + const handle = await box('.header-divider'); + const y = handle.top + handle.height / 2; + await p.mouse.move(handle.left + 6, y); + await p.mouse.down(); + await p.mouse.move(handle.left + 6 + 330, y, { steps: 6 }); + await p.mouse.up(); + const after = await box('#headerRight'); + expect(after.width).toBeLessThan(before.width - 250); + expect(after.height).toBeGreaterThan(before.height + 10); + // setContent gives an opaque origin: storage is blocked, and the drag must still work. + expect(await p.evaluate(() => document.documentElement.style.getPropertyValue('--header-right-width'))).not.toBe( + '' + ); + const tabs = await box('.session-tabs-host'); + expect(tabs.width).toBeGreaterThan(before.width); + + await p.locator('.header-divider').dblclick(); + const reset = await box('#headerRight'); + expect(Math.abs(reset.width - before.width)).toBeLessThan(2); + expect(await p.evaluate(() => document.documentElement.style.getPropertyValue('--header-right-width'))).toBe(''); + await p.close(); + }); + + it('drops usage labels, then provider names, then percentages as the toolbar narrows', async () => { + const chip = + '
Claude' + + '5h49%
'; + await open(page('data-header-stats="compact" data-tab-orientation="horizontal" data-session-list="header"')); + await p.evaluate((html) => document.getElementById('headerRight')!.insertAdjacentHTML('beforeend', html), chip); + const visible = (sel: string) => p.locator(sel).isVisible(); + expect(await visible('.pu-label')).toBe(true); + const sizes: Array<[number, string]> = [ + [450, 'tight'], + [330, 'dense'], + [200, 'tiny'], + ]; + for (const [width, density] of sizes) { + const handle = await box('.header-divider'); + const header = await box('.header'); + await p.mouse.move(handle.left + 6, handle.top + handle.height / 2); + await p.mouse.down(); + await p.mouse.move(header.right - width - 12, handle.top + handle.height / 2, { steps: 5 }); + await p.mouse.up(); + expect(await p.evaluate(() => document.documentElement.dataset.headerDensity)).toBe(density); + } + expect(await visible('.pu-label')).toBe(false); + expect(await visible('.pu-val')).toBe(false); + expect(await p.locator('.pu-provider').evaluate((el) => getComputedStyle(el, '::after').content)).toBe('"CL"'); + await p.close(); + }); + + it('stops the divider at the widest reading so nothing pokes out past it', async () => { + await open(page()); + const handle = await box('.header-divider'); + const header = await box('.header'); + await p.mouse.move(handle.left + 6, handle.top + handle.height / 2); + await p.mouse.down(); + await p.mouse.move(header.right - 20, handle.top + handle.height / 2, { steps: 8 }); + await p.mouse.up(); + const right = await box('#headerRight'); + const edge = right.left + 1; + const lefts = await p + .locator('#headerRight > :not(.header-divider)') + .evaluateAll((els) => els.map((el) => el.getBoundingClientRect().left)); + for (const left of lefts) expect(left).toBeGreaterThanOrEqual(edge); + await p.close(); + }); + + it('resizes from the keyboard', async () => { + await open(page()); + const before = await box('#headerRight'); + await p.locator('.header-divider').focus(); + await p.keyboard.press('ArrowRight'); + await p.keyboard.press('ArrowRight'); + const after = await box('#headerRight'); + expect(after.width).toBeLessThan(before.width - 20); + await p.keyboard.press('Home'); + expect(Math.abs((await box('#headerRight')).width - before.width)).toBeLessThan(2); + await p.close(); + }); + + it('is hidden when the tabs live in the sidebar', async () => { + await open(page('data-tab-orientation="horizontal" data-session-list="sidebar"')); + expect(await p.locator('.header-divider').isVisible()).toBe(false); + await p.close(); + }); +}); diff --git a/test/header-divider.test.ts b/test/header-divider.test.ts new file mode 100644 index 00000000..72a988e0 --- /dev/null +++ b/test/header-divider.test.ts @@ -0,0 +1,74 @@ +/** + * @fileoverview The draggable divider at the left edge of the header toolbar: the width clamp keeps the tabs + * some room, the controller is wired into the page and build, and the stored width is device-local. + */ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; + +const publicDir = resolve(import.meta.dirname, '../src/web/public'); +const read = (name: string) => readFileSync(resolve(publicDir, name), 'utf8'); +const buildJs = readFileSync(resolve(import.meta.dirname, '../scripts/build.mjs'), 'utf8'); + +function load() { + const window: Record = {}; + const context = vm.createContext({ + window, + document: { readyState: 'loading', addEventListener: () => {}, querySelector: () => null }, + localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} }, + }); + vm.runInContext(read('header-divider.js'), context, { filename: 'header-divider.js' }); + return window.CodemanHeaderDivider as { + clampWidth: (w: number, header: number, brand: number) => number; + densityFor: (w: number) => string; + MIN_WIDTH: number; + MIN_TABS_ROOM: number; + }; +} + +describe('header divider', () => { + it('clamps the toolbar between its minimum and what leaves the tabs room', () => { + const { clampWidth, MIN_WIDTH, MIN_TABS_ROOM } = load(); + expect(clampWidth(10, 1600, 120)).toBe(MIN_WIDTH); + expect(clampWidth(5000, 1600, 120)).toBe(1600 - 120 - MIN_TABS_ROOM); + expect(clampWidth(420.4, 1600, 120)).toBe(420); + }); + + it('shows less of the usage readings the narrower the toolbar gets', () => { + const { densityFor } = load(); + expect([700, 560, 559, 400, 399, 280, 279, 160].map(densityFor)).toEqual([ + '', + '', + 'tight', + 'tight', + 'dense', + 'dense', + 'tiny', + 'tiny', + ]); + }); + + it('never goes below the minimum even in a header too narrow for the tabs room', () => { + const { clampWidth, MIN_WIDTH } = load(); + expect(clampWidth(400, 300, 100)).toBe(MIN_WIDTH); + }); + + it('is wired into the page and the build, and hides where the tabs are not in the header', () => { + expect(read('index.html')).toContain(''); + expect(buildJs).toContain("run('minify header-divider.js'"); + expect(buildJs).toMatch(/HASHABLE[\s\S]{0,1500}'header-divider\.js'/); + const css = read('styles.css'); + expect(css).toContain('.header-divider'); + expect(css).toMatch(/data-session-list='sidebar'\] \.header-divider/); + expect(css).toMatch(/data-tab-orientation='vertical'\] \.header-divider/); + }); + + it('keeps the width on the device only and offers a keyboard reset', () => { + const js = read('header-divider.js'); + expect(js).toContain('localStorage'); + expect(js).not.toContain('/api/settings'); + expect(js).toMatch(/key === 'Home'/); + expect(js).toContain("addEventListener('dblclick'"); + }); +}); From efa647d2578d59d8a4dc721ced1ed9b683b705c5 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Fri, 23 Oct 2026 07:26:15 -0400 Subject: [PATCH 176/699] fix(terminal): exclude relative prose and trim spaced paths linearly --- CLAUDE.md | 4 +++- docs/architecture-invariants.md | 4 +++- src/web/public/constants.js | 15 ++++++++++----- test/spaced-file-path-links.test.ts | 26 ++++++++++++++++++++++++++ 4 files changed, 42 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 02704d0f..d341d772 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -222,7 +222,9 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Prompt uploads** (`POST /api/sessions/:id/paste-image`: clipboard paste, drop, the Compose Attach key and the phone toolbar's Attach button; client wiring in `image-input.js` and `keyboard-accessory.js`): a pick is written to `/.codeman-uploads/` and its path lands on the prompt as unsent text. The folder is IN the workspace because that is the only path identical for a local and a container agent, FLAT because `/.codeman/` would BE the data dir when the workspace is the home directory, and self-ignoring through a `.gitignore` of `*` written ONCE with `wx` (a file already there is the user's and is never overwritten; the user's own ignore file is never touched). ⚠️ The directory names live ONLY in `paste-image-gc.ts` (`UPLOAD_DIR_NAMES`): the pre-move `.claude-images/` receives nothing but stays readable for one release, so the GC sweep and the delete cleanup (through `uploadDirs()`) and the image watcher's ignore filter (reading the names) cover BOTH; retire the legacy entry there, never by hand elsewhere. ⚠️ `uploadDirs()` is async and probes the workspace BOUNDED first (`probePathKind()`, the #516 rule: an `unknown` path is skipped, never touched; the sweep keeps the stall cap and the delete passes `pastCap`), then returns only REAL directories (lstat, so a planted symlink is never one), none that is or contains `getDataDir()` (one strictly inside it only holds uploads and is listed), and none for a remote session, since both consumers delete; the check is made at list time, and the same-user race after it is accepted. Images are buffered in memory under `MAX_PASTE_IMAGE_BYTES` (50MB) after a client-side canvas re-encode; a DOCUMENT (pdf, docx, pptx, xlsx, zip and the text family txt/md/csv/tsv/json/log/yaml/yml/xml; the curated `ALLOWED_DOCUMENT_EXTS`, never `SUPPORTED_ATTACHMENT_EXTENSIONS` or `EDITABLE_EXTENSIONS`, which carry sh/py/js/html) is admitted by its name's extension alone, buffered like an image, judged on its bytes by the same fail-closed `imageMagicMatchesExt` (`%PDF-`, `PK\x03\x04` for the OOXML trio and zip, UTF-8 without a NUL byte for text: a SIGNATURE or ENCODING check, never a structure or content check, so generic zip bytes pass as `.docx` and a script renamed `.txt` is text; the sh/py/js/html exclusion is a SCOPE choice by extension, keeping the server from storing what it would serve or run, not a content filter: the agent can already write any of those) and saved as `paste---` with a sanitized stem (`[A-Za-z0-9._-]`, 40 chars, a wholly non-ASCII name leaves no stem; the `paste-` prefix is what the GC matches). A VIDEO is never buffered: `?kind=video` streams it to disk under `MAX_PASTE_VIDEO_BYTES` (1GB, env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), judges the container on the first 12 bytes as they arrive (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv; a mislabeled body is drained unwritten from there, never parked on disk first) and unlinks on any failure, a dead transfer included. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's `VIDEO_ATTACHMENT_EXTENSIONS`, single-sourced: mp4/webm/mov/m4v/ogv, so an uploaded video is one the file viewer can also play; 3gp/mkv are out, since a phone's camera roll produces neither and neither previews), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. Busboy takes its size limit before the part is seen, which is why the choice rides the URL and not the file. ⚠️ A video draws on its OWN per-(IP, session) rate bucket (`consumeVideoToken`, 3/min) beside the image bucket (`consumePasteToken`, 30/min): at 1GB each, 30/min would be 30GB of disk a minute, so the lower rate bounds it to 3GB; the image path keeps the 30/min bucket and is unchanged without the flag. A remote (SSH) session is refused (400) before any disk touch: its `workingDir` is the remote path, so the file would land on THIS host where the agent cannot read it (Docker is fine, the workspace is bind-mounted at the same absolute path). ⚠️ A live session's workspace can still sit on a network mount that stopped answering, so the route probes it BOUNDED (`probePathKind(session.workingDir, { pastCap: true })`, the #516 rule) before the first disk touch and refuses an `unknown` mount through `describeUnknownPath()` (OPERATION_FAILED), the way session create does, so a mount already unreachable cannot freeze a libuv worker on every upload (a mount that dies after the probe is the same residual race create accepts). ⚠️ Dispatch mirrors the client: an `image/*` MIME takes the IMAGE path whatever the name says (a GIF named `animation.txt` stays a GIF), a document extension takes the document branch, and anything else takes the image path exactly as before, where the HEIC sniff still recovers a HEIC under a generic name; a foreign extension with no image MIME gets its `Unsupported file type` 400 only AFTER that sniff fails, never before, so nothing the image path ever accepted by its bytes is refused. A text mismatch is refused as not UTF-8 text (UTF-16 when the file opens with a UTF-16 BOM, as Excel's "Unicode Text" and Notepad's "Unicode" do), telling the user to save it as UTF-8, and its log line names the failure (`utf16-bom`/`nul-byte`/`not-utf8`) and never prints the bytes, since the head of a text file is user content. ⚠️ The client classifies a pick through ONE helper, `_promptAttachKind()` (MIME type first, then the name's extension, because some Android file managers hand over a File with none; returns `image`, `video` or `document`); every pick and paste filter (Attach, drop, the tile grid's drop in `tile-grid.js`, the Compose and paste-dialog pickers, the clipboard handlers), the flag and the re-encode gate read it, never `file.type` directly. Those surfaces ADMIT through `_admitPromptAttachments()` (image-input.js), which calls it and toasts what a pick skipped (how many files of a mixed pick, `Unsupported file type` when none pass, nothing for a text paste); a new surface admits through that helper too, never by filtering on `_promptAttachKind()` itself, or it loses the toasts. ⚠️ A pick that lands while the tile grid owns the terminal goes to the PTY, never the main terminal's overlay: that terminal is parked and hidden then, and a tile's Enter cannot flush it (`_tilesOwnTerminal()` in the `_attachFromToolbar` guard). Tests: `test/toolbar-attach.test.ts`, `test/mobile-prompt-composer.test.ts`, `test/paste-image-gc.test.ts`, `test/paste-image-dir-shared.test.ts`, `test/upload-document-list-parity.test.ts` (the browser's copies of `ALLOWED_DOCUMENT_EXTS`: `_promptAttachKind()`, the three pickers' `accept=` and the two file-path link patterns), the paste-image block of `test/routes/session-routes.test.ts`. → [architecture-invariants#buffers-uploads-and-terminal-history](docs/architecture-invariants.md#buffers-uploads-and-terminal-history) -**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). Both call `findFilePathLinks()`, which adds `~/`, relative and folder paths without overlapping a rooted match or a URL; a relative path resolves against `workingDir` (`resolveLinkedFilePath`), `~/` only server-side on the session's host (attachment route), and `terminalPathLine()` joins a path a program hard-wrapped short of the edge. ⚠️ A folder link opens the **folder grid**: `POST /api/sessions/:id/folders` guards + GRANTS the folder (`folder-listing.ts`, own registry, never the 200-capped attachment registry), files come by bare name via `…/folders/:folderId/files/:name/*` through `resolveServableAttachmentPath` plus the same extension/regular-file gate on the symlink's resolved target, so no browser request carries a path; `~/` is refused in a Docker case (the container's home is not this host's). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. ⚠️ The ONE place HTML renders is `/html-view//...` (`html-view-routes.ts`, `html-view-capabilities.ts`): `POST /api/sessions/:id/html-view` mints a capability for the file's own directory, every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin` (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API) and with NO fetch directive (open network, owner decision 2026-10-13: never restrict it or add `allow-same-origin` without the owner), and only non-dot, asset-allowlisted files under that directory are served (GET/HEAD only; capabilities revoked with the web-tab ones on logout). That tree is served recursively, so minting is refused for a hidden directory or a broad root (`isBroadHtmlViewRoot`); the preview renders by default and the **Page** pill (or a refused mint) falls through to the source view and its Edit button. Never serve HTML same-origin anywhere else. Tests: `test/routes/html-view-routes.test.ts`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server; the admission limits that keep a hostile workbook from freezing the worker live in `src/web/CLAUDE.md` (Spreadsheet preview). → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer) +**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). Both call `findFilePathLinks()`, which reserves spaced-path spans first (even unlinkable ones), then adds rooted, `~/`, relative and folder paths without overlapping a reservation, earlier match or URL; a relative path resolves against `workingDir` (`resolveLinkedFilePath`), `~/` only server-side on the session's host (attachment route), and `terminalPathLine()` joins a path a program hard-wrapped short of the edge. ⚠️ A folder link opens the **folder grid**: `POST /api/sessions/:id/folders` guards + GRANTS the folder (`folder-listing.ts`, own registry, never the 200-capped attachment registry), files come by bare name via `…/folders/:folderId/files/:name/*` through `resolveServableAttachmentPath` plus the same extension/regular-file gate on the symlink's resolved target, so no browser request carries a path; `~/` is refused in a Docker case (the container's home is not this host's). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. ⚠️ The ONE place HTML renders is `/html-view//...` (`html-view-routes.ts`, `html-view-capabilities.ts`): `POST /api/sessions/:id/html-view` mints a capability for the file's own directory, every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin` (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API) and with NO fetch directive (open network, owner decision 2026-10-13: never restrict it or add `allow-same-origin` without the owner), and only non-dot, asset-allowlisted files under that directory are served (GET/HEAD only; capabilities revoked with the web-tab ones on logout). That tree is served recursively, so minting is refused for a hidden directory or a broad root (`isBroadHtmlViewRoot`); the preview renders by default and the **Page** pill (or a refused mint) falls through to the source view and its Edit button. Never serve HTML same-origin anywhere else. Tests: `test/routes/html-view-routes.test.ts`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server; the admission limits that keep a hostile workbook from freezing the worker live in `src/web/CLAUDE.md` (Spreadsheet preview). → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer) + +Spaced paths are considered first by `findFilePathLinks()`: accepted candidates reserve their entire span even when unlinkable, so later rooted, relative and folder detectors cannot link fragments. Relative candidates whose first slash-containing word is their last word defer to ordinary detection to exclude list/label prose. This deliberately gives up spaces only in the top directory (`Saved to: 03 Attachments/plan.pptx`); prose shaped like deeper spaced paths (`- Fixed bug in src/My Docs/a.md`, `/home/me/proj contains a.md`) remains ambiguous. Trailing whitespace uses linear `trimEnd()` before the length cap. **Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index f446f4a1..0a4ac07e 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -407,7 +407,9 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli ⚠️ **The terminal routes an out-of-workspace path to the preview, not the log viewer.** The log viewer spawns `tail -f` and allows only the workspace, `/var/log` and `~/logs`, so an external `.log`/`.json`/code path answered `Path must be within working directory or allowed log directories` while the SAME path clicked in the response viewer previewed fine. `activate()` now checks `_isExternalPreviewPath` alongside `previewsInFileViewer`. In-workspace text keeps the tail viewer, which is the point of it (live follow), except markdown (`.md`/`.markdown`, in `FILE_PREVIEW_EXTENSIONS` since the owner's 2026-10-13 decision, rendered as a document) and `.html`/`.htm` (`rendersAsPage()`, rendered as a page, see below), which always go to the preview; nothing widened `file-stream-manager`'s allowlist, so no `tail -f` is spawned on an arbitrary host path. -**Path forms beyond the rooted one.** `findFilePathLinks(text)` (constants.js) is the single entry point both consumers call; it returns the rooted matches first, then `~/` and relative paths (`RELATIVE_FILE_PATH_LINK_PATTERN`, same extension list, pinned equal by `test/link-provider-regex.test.ts`), then folder paths, each only where it overlaps neither an earlier match nor an `http(s)` URL. A relative match must start at a boundary (line start, whitespace, quote, bracket, `=`, `,`, `>`), so it can never begin inside a word, an absolute path or a URL; brackets, `=` and `,` end it (`(shot.png)`, `OUT=shots/a.png`). `resolveLinkedFilePath()` resolves a relative path against the session's `workingDir` (`.`/`..` collapsed, so `../x.png` lands outside and routes like any external path) and passes absolute and `~/` paths through. ⚠️ `~` is the home of the SESSION'S host, which only the server knows: `~/` paths always take the attachment route, where `registerExternalAttachment` expands them (`homedir()` locally, `$HOME` inside the remote probe script), and the guard runs on the expanded, realpath-resolved path as before. ⚠️ Programs hard-wrap their own output short of the terminal edge (Claude Code breaks tool output at 59-118 columns), so xterm's `isWrapped` never fires. `terminalPathLine()` (link provider only; touch selection keeps `terminalLogicalLine`) joins the next row when the previous row ends in a path token with a `/` and no extension and the join reaches an extension; a row that starts a new path is never joined: rooted or `~/`, `./`/`../`, or a relative one that opens on the same top directory the broken token did (the next entry of `git ls-files`/`find`/`ls -R`: `src/components` + `src/index.ts` are two paths, not `src/componentssrc/index.ts`), while a wrapped path continues INSIDE a name, so `…/captures/2` + `026-10-10/…` still joins; a 3+ row chain is kept only if a later row completes the path, and the broken row must be at least as long as the continuing one (keeps `cd /mnt/foo` + a longer next line apart). Tests: `test/file-path-link-forms.test.ts`. +**Path forms beyond the rooted one.** `findFilePathLinks(text)` (constants.js) is the single entry point both consumers call; it reserves spaced candidates first (including unlinkable spans), then detects rooted matches, then `~/` and relative paths (`RELATIVE_FILE_PATH_LINK_PATTERN`, same extension list, pinned equal by `test/link-provider-regex.test.ts`), then folder paths, each only where it overlaps neither an earlier match nor an `http(s)` URL. A relative match must start at a boundary (line start, whitespace, quote, bracket, `=`, `,`, `>`), so it can never begin inside a word, an absolute path or a URL; brackets, `=` and `,` end it (`(shot.png)`, `OUT=shots/a.png`). `resolveLinkedFilePath()` resolves a relative path against the session's `workingDir` (`.`/`..` collapsed, so `../x.png` lands outside and routes like any external path) and passes absolute and `~/` paths through. ⚠️ `~` is the home of the SESSION'S host, which only the server knows: `~/` paths always take the attachment route, where `registerExternalAttachment` expands them (`homedir()` locally, `$HOME` inside the remote probe script), and the guard runs on the expanded, realpath-resolved path as before. ⚠️ Programs hard-wrap their own output short of the terminal edge (Claude Code breaks tool output at 59-118 columns), so xterm's `isWrapped` never fires. `terminalPathLine()` (link provider only; touch selection keeps `terminalLogicalLine`) joins the next row when the previous row ends in a path token with a `/` and no extension and the join reaches an extension; a row that starts a new path is never joined: rooted or `~/`, `./`/`../`, or a relative one that opens on the same top directory the broken token did (the next entry of `git ls-files`/`find`/`ls -R`: `src/components` + `src/index.ts` are two paths, not `src/componentssrc/index.ts`), while a wrapped path continues INSIDE a name, so `…/captures/2` + `026-10-10/…` still joins; a 3+ row chain is kept only if a later row completes the path, and the broken row must be at least as long as the continuing one (keeps `cd /mnt/foo` + a longer next line apart). Tests: `test/file-path-link-forms.test.ts`. + +Spaced paths are considered first by `findFilePathLinks()`: accepted candidates reserve their entire span even when unlinkable, so later rooted, relative and folder detectors cannot link fragments. Relative candidates whose first slash-containing word is their last word defer to ordinary detection to exclude list/label prose. This deliberately gives up spaces only in the top directory (`Saved to: 03 Attachments/plan.pptx`); prose shaped like deeper spaced paths (`- Fixed bug in src/My Docs/a.md`, `/home/me/proj contains a.md`) remains ambiguous. Trailing whitespace uses linear `trimEnd()` before the length cap. **Folder grid: a folder is granted, then served by bare file name.** A folder path (rooted or `~/` whose last segment has no extension, relative only with a trailing `/`; `FOLDER_PATH_LINK_PATTERN` + `folderLinkPath()`) opens `openFolderGrid()` (panels-ui.js), as does the preview's "Show folder" button. `POST /api/sessions/:id/folders {path}` (`src/folder-listing.ts`) resolves the folder (locally a bounded `probePathKind(…, { pastCap: true })` first, then async `fs.realpath`; remotely one ssh round trip via `remoteListFolder`, through `runWithRemoteSshLimit`), runs the attachment guard on it (blocklist, `confineToWorkspace`, and in multi-user mode `folderAllowedForUser`: a non-admin only inside their own `userSpacePath` or the session's `workingDir`, as the filesystem path picker), locally BEFORE reading it, lists non-hidden subfolders and files of a supported attachment type (each file also blocklist-checked, newest first, capped at `MAX_FOLDER_ENTRIES`), and GRANTS it an id. Files are then fetched as `GET …/folders/:folderId/files/:name/(raw|preview|thumbnail)`: `:name` must be a bare file name of a supported type (no `/`, `\`, NUL, no leading `.`, since the listing hides those), the local file is probed bounded (`unknown` → 503, never 404), and the joined path goes through the SAME `resolveServableAttachmentPath` as an attachment by id (symlink re-resolution, remote re-probe, guard), then the same gate registration applies to what a symlink RESOLVES to (a supported extension on the resolved path and a regular file: locally `fs.stat`, remotely the probe kind; else 400, so `shot.png -> ../secret.bin` is not served and a FIFO or device behind a supported name never reaches `open`), then `folderAllowedForUser` once more on the resolved file, before `serveServableRaw` (the attachment raw route's body, shared) streams it. So browser requests still never carry a path. ⚠️ Grants live in their own registry (`folderGrants`, 32 per session, cleared with the session's attachments), NOT the attachment registry: that one is capped at 200 per session, and paging a folder of 300 renders would evict the session's real attachments. ⚠️ A local folder is never touched unbounded (#516 rule): a folder on a network mount that stopped answering answers 503 with `describeUnknownPath()`, not 404 and not a frozen server. ⚠️ The remote listing script tells "missing" (`E|n`, 404) from "is a file" (`E|f`, 400 `Not a folder`), because the client falls back to the file preview on `Not a folder`; a remote extensionless file otherwise answered "Folder not found". ⚠️ `.folder-grid-overlay` sits at `z-index: 5090`, one step BELOW the preview (5100) and above the response viewer (5000): a tile's preview opens on top and closing it lands back on the grid. ⚠️ ←/→/Esc are handled in a document CAPTURE listener with `stopPropagation`, because the terminal keeps keyboard focus under the overlays and an arrow that reached xterm would be typed into the session; inputs other than xterm's helper textarea keep their keys. ⚠️ A route PARAMETER is capped by the router (Fastify's default is 100 characters, answered as 404 "Route not found"), and a file name goes up to 255, percent-encoded: the server sets `routerOptions.maxParamLength` to `MAX_ROUTE_PARAM_LENGTH` (route-helpers.ts), the route test harness uses the same constant, and a wiring guard pins it. ⚠️ `~/` in a Docker case is refused with 400 (attachment POST and `POST …/folders`): the session is local for these routes, but `~` inside the container is the container's own home, and expanding it on the host would open a different file than the one the agent printed. Tests: `test/routes/folder-grid-routes.test.ts` (incl. the listing script on a real `sh`), `test/folder-grid-ui.test.ts`. diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 3308dfd2..60b8a8bc 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -2154,9 +2154,9 @@ const RELATIVE_FILE_PATH_LINK_PATTERN = const FILE_LINK_EXTENSION_GROUP = /\\\.(\(\?:[^()]*\))\)\\b$/.exec(FILE_PATH_LINK_PATTERN.source)[1]; /** - * Every linkable file path in `text`, in order: absolute (rooted) paths first, - * then `~/` and relative ones, then folder paths — each only where it overlaps - * neither an earlier one nor a URL. + * Every linkable file path in `text`, sorted by position. Spaced candidates + * reserve their whole span first, even when unlinkable; rooted, `~/`, relative + * and folder detectors then skip reserved spans, earlier matches and URLs. * * The one entry point for both consumers (terminal link provider, response * viewer), so a path form is clickable in both or in neither. @@ -2227,7 +2227,7 @@ function findFilePathLinks(text) { * * /mnt/c/My Docs/report.docx the whole line is the path * - /mnt/c/My Docs/report.docx after a list marker (`-`, `*`, `•`, `1.`, `2)`) - * Saved to: 03 Attachments/plan.pptx after a short label (one to four words + `:`) + * Saved to: 03 Attachments/Build SIPOC Design/plan.pptx after a short label (one to four words + `:`) * * An absolute path may stand alone; a relative or `~/` one needs a marker or a * label, since a bare spaced relative candidate is ordinary prose. One trailing @@ -2248,7 +2248,7 @@ function spacedFilePathOnLine(line) { const text = String(line || ''); const prefix = /^[ \t]*((?:\d{1,3}[.)]|[-*•])[ \t]+)?((?:[A-Za-z][\w-]{0,30}(?: [\w-]{1,30}){0,3}):[ \t]+)?/.exec(text); const index = prefix[0].length; - const path = text.slice(index).replace(/\s+$/, '').replace(/[.,;:!?]$/, ''); + const path = text.slice(index).trimEnd().replace(/[.,;:!?]$/, ''); if (!path.includes(' ') || !path.includes('/') || path.length > 1000) return null; if (!path.startsWith('/') && !prefix[1] && !prefix[2]) return null; if (!PATH_TOKEN_ENDS_WITH_EXTENSION.test(path)) return null; @@ -2262,6 +2262,11 @@ function spacedFilePathOnLine(line) { const rooted = SPACED_PATH_ROOT.test(path) || path.startsWith('~/'); const segments = path.replace(/^~?\//, '').split('/'); const link = (rooted || !path.startsWith('/')) && segments.every((seg) => seg && seg !== '.' && seg !== '..'); + // A lone slash-containing final word after plain words is usually prose + // ("- Updated src/foo.ts"), not a spaced top-level directory. Let the + // ordinary detectors handle it, including explicit relative paths. + const firstSlash = words.findIndex((word) => word.includes('/')); + if (link && firstSlash === words.length - 1 && !path.startsWith('/')) return null; return { path, index, link }; } diff --git a/test/spaced-file-path-links.test.ts b/test/spaced-file-path-links.test.ts index b799a888..8f49b89c 100644 --- a/test/spaced-file-path-links.test.ts +++ b/test/spaced-file-path-links.test.ts @@ -91,6 +91,32 @@ describe('spaced file paths', () => { expect(paths(`• Design committed in the isolated worktree: ${rel}`)).toEqual([rel]); }); + it.each([ + ['- Updated src/foo.ts', 'src/foo.ts'], + ['1. Added a test in test/foo.test.ts', 'test/foo.test.ts'], + ['• Ran npm test -- test/foo.test.ts', 'test/foo.test.ts'], + ['Error: cannot open src/foo.ts', 'src/foo.ts'], + ['Note: see src/foo.ts', 'src/foo.ts'], + ['- Moved ../a/b.ts', '../a/b.ts'], + ['Saved to: 03 Attachments/plan.pptx', 'Attachments/plan.pptx'], + ])('defers ambiguous relative prose to ordinary detectors: %s', (line, path) => { + expect(paths(line)).toEqual([path]); + }); + + it('pins residual ambiguity when spaces occur below the first directory', () => { + // Without filesystem knowledge these still have the shape of spaced paths. + expect(paths('- Fixed bug in src/My Docs/a.md')).toEqual(['Fixed bug in src/My Docs/a.md']); + expect(paths('/home/me/proj contains a.md')).toEqual(['/home/me/proj contains a.md']); + expect(paths('Saved to: ~/My Docs/plan.md')).toEqual(['~/My Docs/plan.md']); + }); + + it.each([40000, 80000])('stays linear with %i interior spaces', (length) => { + const line = '/home/a' + ' '.repeat(length) + 'x'; + const start = performance.now(); + findFilePathLinks(line); + expect(performance.now() - start).toBeLessThan(500); + }); + it('leaves unspaced paths to the ordinary detectors', () => { expect(paths('- /mnt/c/proj/docs/report.docx')).toEqual(['/mnt/c/proj/docs/report.docx']); expect(paths('• committed as /tmp/x/docs/specs/design.md:1 (e7ae1b4).')).toEqual(['/tmp/x/docs/specs/design.md']); From 40a9528be11b294b22e076d89624d8e9cda0ac38 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Fri, 23 Oct 2026 07:26:15 -0400 Subject: [PATCH 177/699] fix(terminal): keep unrelated rows out of wrapped links --- CLAUDE.md | 2 ++ docs/architecture-invariants.md | 2 ++ src/web/public/constants.js | 21 ++++++++++++------ src/web/public/terminal-ui.js | 5 +++-- test/link-provider-regex.test.ts | 11 +++++----- test/terminal-cell-wrapped-paths.test.ts | 27 ++++++++++++++++++++++++ test/terminal-logical-line.test.ts | 6 ++++++ test/terminal-url-continuation.test.ts | 19 ++++++++++++++++- 8 files changed, 77 insertions(+), 16 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 02704d0f..f528681b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -224,6 +224,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). Both call `findFilePathLinks()`, which adds `~/`, relative and folder paths without overlapping a rooted match or a URL; a relative path resolves against `workingDir` (`resolveLinkedFilePath`), `~/` only server-side on the session's host (attachment route), and `terminalPathLine()` joins a path a program hard-wrapped short of the edge. ⚠️ A folder link opens the **folder grid**: `POST /api/sessions/:id/folders` guards + GRANTS the folder (`folder-listing.ts`, own registry, never the 200-capped attachment registry), files come by bare name via `…/folders/:folderId/files/:name/*` through `resolveServableAttachmentPath` plus the same extension/regular-file gate on the symlink's resolved target, so no browser request carries a path; `~/` is refused in a Docker case (the container's home is not this host's). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. ⚠️ The ONE place HTML renders is `/html-view//...` (`html-view-routes.ts`, `html-view-capabilities.ts`): `POST /api/sessions/:id/html-view` mints a capability for the file's own directory, every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin` (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API) and with NO fetch directive (open network, owner decision 2026-10-13: never restrict it or add `allow-same-origin` without the owner), and only non-dot, asset-allowlisted files under that directory are served (GET/HEAD only; capabilities revoked with the web-tab ones on logout). That tree is served recursively, so minting is refused for a hidden directory or a broad root (`isBroadHtmlViewRoot`); the preview renders by default and the **Page** pill (or a refused mint) falls through to the source view and its Edit button. Never serve HTML same-origin anywhere else. Tests: `test/routes/html-view-routes.test.ts`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server; the admission limits that keep a hostile workbook from freezing the worker live in `src/web/CLAUDE.md` (Spreadsheet preview). → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer) +The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers and version tokens never continue a URL. `terminalCellWrappedPaths()` follows each table cell independently, gated by `pathContinuation()` and the per-cell row-length rule; accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs. + **Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker) **File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place — `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that WRITES**: read-path confinement (realpath + workspace + ownership) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md` diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index f446f4a1..6dc61ed7 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -409,6 +409,8 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli **Path forms beyond the rooted one.** `findFilePathLinks(text)` (constants.js) is the single entry point both consumers call; it returns the rooted matches first, then `~/` and relative paths (`RELATIVE_FILE_PATH_LINK_PATTERN`, same extension list, pinned equal by `test/link-provider-regex.test.ts`), then folder paths, each only where it overlaps neither an earlier match nor an `http(s)` URL. A relative match must start at a boundary (line start, whitespace, quote, bracket, `=`, `,`, `>`), so it can never begin inside a word, an absolute path or a URL; brackets, `=` and `,` end it (`(shot.png)`, `OUT=shots/a.png`). `resolveLinkedFilePath()` resolves a relative path against the session's `workingDir` (`.`/`..` collapsed, so `../x.png` lands outside and routes like any external path) and passes absolute and `~/` paths through. ⚠️ `~` is the home of the SESSION'S host, which only the server knows: `~/` paths always take the attachment route, where `registerExternalAttachment` expands them (`homedir()` locally, `$HOME` inside the remote probe script), and the guard runs on the expanded, realpath-resolved path as before. ⚠️ Programs hard-wrap their own output short of the terminal edge (Claude Code breaks tool output at 59-118 columns), so xterm's `isWrapped` never fires. `terminalPathLine()` (link provider only; touch selection keeps `terminalLogicalLine`) joins the next row when the previous row ends in a path token with a `/` and no extension and the join reaches an extension; a row that starts a new path is never joined: rooted or `~/`, `./`/`../`, or a relative one that opens on the same top directory the broken token did (the next entry of `git ls-files`/`find`/`ls -R`: `src/components` + `src/index.ts` are two paths, not `src/componentssrc/index.ts`), while a wrapped path continues INSIDE a name, so `…/captures/2` + `026-10-10/…` still joins; a 3+ row chain is kept only if a later row completes the path, and the broken row must be at least as long as the continuing one (keeps `cd /mnt/foo` + a longer next line apart). Tests: `test/file-path-link-forms.test.ts`. +The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers and version tokens never continue a URL. `terminalCellWrappedPaths()` follows each table cell independently, gated by `pathContinuation()` and the per-cell row-length rule; accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs. + **Folder grid: a folder is granted, then served by bare file name.** A folder path (rooted or `~/` whose last segment has no extension, relative only with a trailing `/`; `FOLDER_PATH_LINK_PATTERN` + `folderLinkPath()`) opens `openFolderGrid()` (panels-ui.js), as does the preview's "Show folder" button. `POST /api/sessions/:id/folders {path}` (`src/folder-listing.ts`) resolves the folder (locally a bounded `probePathKind(…, { pastCap: true })` first, then async `fs.realpath`; remotely one ssh round trip via `remoteListFolder`, through `runWithRemoteSshLimit`), runs the attachment guard on it (blocklist, `confineToWorkspace`, and in multi-user mode `folderAllowedForUser`: a non-admin only inside their own `userSpacePath` or the session's `workingDir`, as the filesystem path picker), locally BEFORE reading it, lists non-hidden subfolders and files of a supported attachment type (each file also blocklist-checked, newest first, capped at `MAX_FOLDER_ENTRIES`), and GRANTS it an id. Files are then fetched as `GET …/folders/:folderId/files/:name/(raw|preview|thumbnail)`: `:name` must be a bare file name of a supported type (no `/`, `\`, NUL, no leading `.`, since the listing hides those), the local file is probed bounded (`unknown` → 503, never 404), and the joined path goes through the SAME `resolveServableAttachmentPath` as an attachment by id (symlink re-resolution, remote re-probe, guard), then the same gate registration applies to what a symlink RESOLVES to (a supported extension on the resolved path and a regular file: locally `fs.stat`, remotely the probe kind; else 400, so `shot.png -> ../secret.bin` is not served and a FIFO or device behind a supported name never reaches `open`), then `folderAllowedForUser` once more on the resolved file, before `serveServableRaw` (the attachment raw route's body, shared) streams it. So browser requests still never carry a path. ⚠️ Grants live in their own registry (`folderGrants`, 32 per session, cleared with the session's attachments), NOT the attachment registry: that one is capped at 200 per session, and paging a folder of 300 renders would evict the session's real attachments. ⚠️ A local folder is never touched unbounded (#516 rule): a folder on a network mount that stopped answering answers 503 with `describeUnknownPath()`, not 404 and not a frozen server. ⚠️ The remote listing script tells "missing" (`E|n`, 404) from "is a file" (`E|f`, 400 `Not a folder`), because the client falls back to the file preview on `Not a folder`; a remote extensionless file otherwise answered "Folder not found". ⚠️ `.folder-grid-overlay` sits at `z-index: 5090`, one step BELOW the preview (5100) and above the response viewer (5000): a tile's preview opens on top and closing it lands back on the grid. ⚠️ ←/→/Esc are handled in a document CAPTURE listener with `stopPropagation`, because the terminal keeps keyboard focus under the overlays and an arrow that reached xterm would be typed into the session; inputs other than xterm's helper textarea keep their keys. ⚠️ A route PARAMETER is capped by the router (Fastify's default is 100 characters, answered as 404 "Route not found"), and a file name goes up to 255, percent-encoded: the server sets `routerOptions.maxParamLength` to `MAX_ROUTE_PARAM_LENGTH` (route-helpers.ts), the route test harness uses the same constant, and a wiring guard pins it. ⚠️ `~/` in a Docker case is refused with 400 (attachment POST and `POST …/folders`): the session is local for these routes, but `~` inside the container is the container's own home, and expanding it on the host would open a different file than the one the agent printed. Tests: `test/routes/folder-grid-routes.test.ts` (incl. the listing script on a real `sh`), `test/folder-grid-ui.test.ts`. **Text reuses the edit-mode allowlist; markup stays download-only.** `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (`config/file-editing.ts`) rather than a second curated list that would drift from it: if the viewer would open a file for editing inside the workspace, the same file outside it can be read. The justification for widening is that the agent in the session can already `cat` any of these and the picker already previews them, so the suffix was never the confidentiality gate; the path guard is (sensitive-file blocklist, `/root` and `/etc` trees, realpath first). ⚠️ Two consequences had to be handled at the same time: `~/.codeman*/state.json` joined `isSensitivePath` (it persists `SessionState.envOverrides`, and the env allowlist admits key-shaped names like `GEMINI_API_KEY`, so it can hold a live credential), and `html`/`htm` joined `svg` in `serveRawFile`'s **download-only** branch so that widening what can be READ never widens what can RUN on our own origin. Text with no dedicated MIME entry goes out as inert `text/plain; charset=utf-8` + `nosniff`, matching the picker. ⚠️ **The one place HTML renders is `/html-view//...`** (`html-view-routes.ts`, `html-view-capabilities.ts`), never these routes: `POST /api/sessions/:id/html-view` mints a capability for the HTML file's own directory, and every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin`, so the page is opaque-origin even as a top-level tab and cannot read Codeman or drive its API. ⚠️ **Network access is open, by the owner's decision (2026-10-13, #547):** `HTML_VIEW_CSP` is the `sandbox` directive alone, which REPLACES Codeman's own CSP, with no fetch directive, so a page loads scripts, styles, fonts, images and media from anywhere and may fetch, XHR, open WebSockets, send beacons and post forms anywhere, like a file opened from disk. It can read its own folder through the capability and nothing else on this server; it cannot use Codeman's origin, cookies or API, or reach other folders. Never add a fetch directive or `allow-same-origin` without the owner; the exact header is pinned in `test/routes/html-view-routes.test.ts`. The directory is served recursively to the page's scripts (non-dot, asset-allowlisted, realpath-confined, blocklist applied, GET/HEAD only; the dot and extension rules run on the requested name AND on the symlink-resolved target, or a `cfg.json -> .git/config` link would serve the git config), which is why minting is refused for an HTML file in a hidden directory (for a file inside the workspace, a hidden directory below the workspace root, so a workspace that itself lives under one still renders) or directly in a broad root (`/`, home, `/tmp`, `/var/tmp` and `$TMPDIR` (named literally, since macOS sets `TMPDIR` in every terminal), the cases and user-space roots, a drive's mount point such as WSL's `/mnt/d`) or in any directory above one of them (`/home`, `/Users`, macOS's `/private`; `isBroadHtmlViewRoot`). A capability minted for a folder outside the workspace records that, and the serving route refuses it per request once `attachmentConfineToWorkspace` is on, the same re-check the attachment serve path makes. The preview renders it by default; the **Page** pill (`htmlRendered`, per-device like `mdRendered`) and any refused mint fall through to the source view with its Edit button. Tests: `test/routes/html-view-routes.test.ts`, the html-view block in `test/webview-auth-exemption.test.ts`, `test/webview-capability-revocation.test.ts`. The by-id text preview is bounded like the workspace one: a `Range` request for the first 512KB (a real partial read, not a discarded 50MB download) plus a 500-line cap, with the footer saying so. diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 6e8d4bff..b89bfdb7 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -2568,7 +2568,8 @@ function findTerminalUrls(text) { * - the next row is indented no further right than the column the URL * started in (a table cell or list item continuation, not unrelated text); * - that row's first token looks like URL material (contains one of - * `% & = / ? . - _` or a digit) and is not itself a new `scheme://`. + * `% & = / ? . - _`), is not a bare number/version or list marker, + * and is not itself a new `scheme://`. * The last rule is what stops prose such as "…/files/" + "then run it" from * being glued into one link. * @@ -2609,7 +2610,7 @@ function terminalUrlContinuationExtent(buffer, row, cols, maxRows) { const nextText = next.translateToString(true) || ''; const indent = nextText.length - nextText.replace(/^\s+/, '').length; const token = nextText.slice(indent).split(/\s/, 1)[0]; - if (!token || indent > urlStartCol) break; + if (!token || indent > urlStartCol || /^(?:\d+[.)]|[-*•])(?:\s|$)/.test(nextText.slice(indent))) break; // When other table columns follow the URL, the next row must align with // the start of its cell. A new numbered row must not become URL text. if ( @@ -2621,7 +2622,7 @@ function terminalUrlContinuationExtent(buffer, row, cols, maxRows) { ) break; if (/^[a-z][a-z0-9+.-]*:\/\//i.test(token) || !/^[A-Za-z0-9%_~]/.test(token)) break; - if (!/[%&=/?.\-_0-9]/.test(token)) break; + if (!/[%&=/?.\-_]/.test(token) || /^v?\d+(?:\.\d+)*(?:[-+][\w.-]+)?[.,;:!?)]?$/i.test(token)) break; const nextLogical = terminalLogicalLine(buffer, endRow + 1, cols, bound); if (!nextLogical) break; if (trailingColumns) firstRowUrlEnd = last.rawEnd; @@ -2716,9 +2717,9 @@ function terminalLinkLine(buffer, row, cols, maxRows) { * edge is where its text starts after the previous ≥2-space column gap, and the * continuation is the token starting at exactly that column on the next row. * A fragment qualifies only when it is the last thing in its cell on that row - * (row end or a ≥2-space gap follows), and the join is accepted only when the - * whole joined string matches the path pattern from its first character, which - * is what keeps unrelated cell text from being glued on. + * (row end or a ≥2-space gap follows). Every continuation must pass + * pathContinuation and its cell must be no wider than the preceding cell's + * text, so independent listing rows and longer prose never displace links. * * Pure: reads rows only. Cells are taken one per string character (xterm rows * with wide glyphs would shift columns; such rows simply fail the alignment). @@ -2768,6 +2769,7 @@ function terminalCellWrappedPaths(buffer, row, maxRows) { while ((e = edge.exec(upToPath)) !== null) cellCol = e.index + e[0].length; let joined = text.slice(start, end); + let previousCellLength = end - cellCol; const segments = [{ row: startRow, startCol: start, endCol: end }]; for (let r = startRow + 1; r < startRow + bound; r++) { const line = buffer.getLine(r); @@ -2776,7 +2778,11 @@ function terminalCellWrappedPaths(buffer, row, maxRows) { if (!next || !isPathChar(next[cellCol]) || (cellCol > 0 && next[cellCol - 1] !== ' ')) break; let tokenEnd = cellCol; while (tokenEnd < next.length && isPathChar(next[tokenEnd])) tokenEnd++; - const candidate = joined + next.slice(cellCol, tokenEnd); + const token = next.slice(cellCol, tokenEnd); + // Include prose after the token in this cell, but not another column. + const cellText = next.slice(cellCol).split(/ {2,}|[│┃║|]/, 1)[0].trimEnd(); + if (cellText.length > previousCellLength || !pathContinuation(joined, token)) break; + const candidate = joined + token; const match = absoluteFilePathPattern().exec(candidate); if (match && match.index === 0 && match[0].length > joined.length) { segments.push({ row: r, startCol: cellCol, endCol: cellCol + match[0].length - joined.length }); @@ -2785,6 +2791,7 @@ function terminalCellWrappedPaths(buffer, row, maxRows) { } if (!endsCell(next, tokenEnd)) break; joined = candidate; + previousCellLength = cellText.length; segments.push({ row: r, startCol: cellCol, endCol: tokenEnd }); } } diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index e4794be6..6d6ac4d8 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -2408,13 +2408,14 @@ Object.assign(CodemanApp.prototype, { /** * xterm's handler for OSC 8 hyperlinks (a program's own `ESC ]8;;url` links), * which take precedence over the text link provider. Routed through the same - * opener, and only for web URLs: any other scheme a program sends is ignored. + * opener after confirming the real target (the label may hide it), and only + * for web URLs: any other scheme a program sends is ignored. */ _terminalOsc8LinkHandler() { return { activate: (_event, text) => { if (!/^https?:\/\/[^\s\x00-\x1f]+$/i.test(text)) return; - this._openTerminalUrl(text); + if (window.confirm(`Open this link?\n\n${text}`)) this._openTerminalUrl(text); }, }; }, diff --git a/test/link-provider-regex.test.ts b/test/link-provider-regex.test.ts index 7b896097..7eaeb1f8 100644 --- a/test/link-provider-regex.test.ts +++ b/test/link-provider-regex.test.ts @@ -17,6 +17,7 @@ import { describe, it, expect } from 'vitest'; import { readFileSync } from 'fs'; import { join } from 'path'; +import vm from 'node:vm'; const publicFile = (name: string) => readFileSync(join(__dirname, '..', 'src', 'web', 'public', name), 'utf-8'); @@ -99,8 +100,9 @@ describe('terminal link-provider regexes (shipped source)', () => { // Excluding `&` truncated every real query string: a WordPress edit link // resolved to `?post=1479` and opened the wrong page, and Claude Code's OAuth // login URL (many `&` params) was not clickable at all. - const url = shippedPattern('urlPattern'); - const strip = (u: string) => u.replace(/[.,;:!?)&]+$/, ''); + const context = vm.createContext({ console, window: undefined }); + vm.runInContext(`${CONSTANTS_SOURCE}\nglobalThis.findUrls = findTerminalUrls;`, context); + const findUrls = context.findUrls as (text: string) => Array<{ url: string }>; const cases: Array<[string, string]> = [ [ 'updated in place: https://bio-hacking.blog/wp-admin/post.php?post=1479&action=edit', @@ -115,10 +117,7 @@ describe('terminal link-provider regexes (shipped source)', () => { ['trailing https://x.com/a?b=1& next', 'https://x.com/a?b=1'], ]; for (const [line, want] of cases) { - url.lastIndex = 0; - const m = url.exec(line); - expect(m, line).not.toBeNull(); - expect(strip(m![0]), line).toBe(want); + expect(findUrls(line).map((match) => match.url), line).toEqual([want]); } }); diff --git a/test/terminal-cell-wrapped-paths.test.ts b/test/terminal-cell-wrapped-paths.test.ts index 6ed0da6a..7b25672d 100644 --- a/test/terminal-cell-wrapped-paths.test.ts +++ b/test/terminal-cell-wrapped-paths.test.ts @@ -57,6 +57,14 @@ const TABLE: Row[] = [ { text: ROW2 }, { text: '' }, ]; +const LISTINGS = [ + ['/tmp/x', '/tmp/x/dir', '/tmp/x/dir/a.png'], + [' ⎿ /home/u/proj/src', ' /home/u/proj/src/a.ts'], + ['4.0K /tmp/foo/bar/', '8.0K /tmp/foo/baz.png'], + ['/home/u/proj/docs:', 'guide.md notes.md'], + ['Created /tmp/out', 'report.md written next to it'], +]; + const BEFORE = '/tmp/demo-check/docs/review/BLDMESH-243/before-overview.png'; const AFTER = '/tmp/demo-check/docs/review/BLDMESH-243/after-overview.png'; @@ -70,6 +78,11 @@ describe('terminalCellWrappedPaths', () => { } }); + it.each(LISTINGS.map((lines) => [lines]))('does not join independent listing rows: %j', (lines) => { + const rows = lines.map((text) => ({ text })); + for (let row = 0; row < rows.length; row++) expect(find(fakeBuffer(rows, COLS), row)).toEqual([]); + }); + it('places the segments on the path cells only', () => { const hits = find(fakeBuffer(TABLE, COLS), 1); const before = hits.find((h) => h.path === BEFORE)!; @@ -188,6 +201,20 @@ describe('terminal link provider with cell-wrapped paths', () => { return { provide, app }; } + it.each(LISTINGS.map((lines) => [lines]))('preserves ordinary listing links: %j', (lines) => { + const { provide } = harness(lines.map((text) => ({ text }))); + const expected = [ + ['/tmp/x', '/tmp/x/dir', '/tmp/x/dir/a.png'], + ['/home/u/proj/src', '/home/u/proj/src/a.ts'], + ['/tmp/foo/bar/', '/tmp/foo/baz.png'], + ['/home/u/proj/docs', 'guide.md'], + ['/tmp/out', 'report.md'], + ][LISTINGS.indexOf(lines)]; + for (let row = 0; row < lines.length; row++) { + expect(provide(row + 1).map((link) => link.text)).toContain(expected[row]); + } + }); + it('makes both screenshot paths clickable from either row and opens the whole path', () => { const { provide, app } = harness(TABLE); for (const line of [2, 3]) { diff --git a/test/terminal-logical-line.test.ts b/test/terminal-logical-line.test.ts index 6832216d..9cb3ca9c 100644 --- a/test/terminal-logical-line.test.ts +++ b/test/terminal-logical-line.test.ts @@ -64,6 +64,12 @@ describe('terminalLogicalLine', () => { const ROW1 = ' ackage/thing'; const HARD = [{ text: ROW0 }, { text: ROW1 }]; + it('keeps full-width dividers out of link and touch-selection text', () => { + const rows = buffer([{ text: '─'.repeat(COLS) }, { text: 'https://example.com/docs' }]); + expect(terminalLogicalLine(rows, 0, COLS)).toMatchObject({ startRow: 0, endRow: 0, text: '─'.repeat(COLS) }); + expect(terminalLogicalLine(rows, 1, COLS)).toMatchObject({ startRow: 1, endRow: 1, text: 'https://example.com/docs' }); + }); + it('stitches a hard wrap and drops the indent the continuation carries', () => { const line = terminalLogicalLine(buffer(HARD), 0, COLS)!; diff --git a/test/terminal-url-continuation.test.ts b/test/terminal-url-continuation.test.ts index 9616ac53..6d97ac2f 100644 --- a/test/terminal-url-continuation.test.ts +++ b/test/terminal-url-continuation.test.ts @@ -168,6 +168,16 @@ describe('terminalUrlContinuationExtent (wrap joining)', () => { expect(terminalUrlContinuationExtent(buffer, 4, COLS, 12)).toBeNull(); }); + it.each([ + ['1. https://example.com/one/', '2. https://example.com/two/'], + ['Docs: https://docs.example.com/guide/', '1. Install the package'], + ['Fetching https://registry.example.com/pkg/', 'v1.2.3 is the latest'], + ['GET http://localhost:3000/api/', '200 OK in 12ms'], + ])('keeps list, version and status rows separate after %s', (first, next) => { + const buffer = fakeBuffer([{ text: first }, { text: next }], 100); + for (const row of [0, 1]) expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toBeNull(); + }); + it('does not glue a following prose line onto a URL that ends a line', () => { const buffer = fakeBuffer( [{ text: 'Download from https://example.com/files/' }, { text: 'then run the installer' }], @@ -204,11 +214,12 @@ describe('terminalUrlContinuationExtent (wrap joining)', () => { describe('native OSC 8 terminal URLs', () => { it('opens complete HTTP links through the terminal opener and rejects unsafe schemes', () => { const open = vi.fn(); + const confirm = vi.fn(() => true); const context = vm.createContext({ console, CodemanApp: class CodemanApp {}, document: { addEventListener: vi.fn() }, - window: { addEventListener: vi.fn(), removeEventListener: vi.fn() }, + window: { addEventListener: vi.fn(), removeEventListener: vi.fn(), confirm }, }); vm.runInContext(`${CONSTANTS}\n${TERMINAL_UI}\nglobalThis.__proto = CodemanApp.prototype;`, context); const proto = ( @@ -220,10 +231,16 @@ describe('native OSC 8 terminal URLs', () => { const url = 'https://docs.example.com/Doc.aspx?file=Q3%20Plan&action=default&mobileredirect=true'; handler.activate(null, url); expect(open).toHaveBeenCalledExactlyOnceWith(url); + expect(confirm).toHaveBeenCalledExactlyOnceWith(`Open this link?\n\n${url}`); + confirm.mockReturnValue(false); + handler.activate(null, 'http://localhost:3000/private'); + expect(confirm).toHaveBeenLastCalledWith('Open this link?\n\nhttp://localhost:3000/private'); + expect(open).toHaveBeenCalledTimes(1); for (const unsafe of ['javascript:alert(1)', 'data:text/html,hello', 'file:///etc/passwd']) { handler.activate(null, unsafe); } expect(open).toHaveBeenCalledTimes(1); + expect(confirm).toHaveBeenCalledTimes(2); }); }); From f3c90abeb0d2a36ac97ff680fdb5fe9113d30681 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:58:40 +0800 Subject: [PATCH 178/699] fix(header): settle the divider when readings change, keep density to the header layout, restore the chosen width --- docs/wiki/The-Dashboard.md | 6 + src/web/public/header-divider.js | 100 +++++++--- src/web/public/styles.css | 94 +++++---- test/header-divider.browser.test.ts | 300 ++++++++++++++++++++-------- 4 files changed, 347 insertions(+), 153 deletions(-) diff --git a/docs/wiki/The-Dashboard.md b/docs/wiki/The-Dashboard.md index b40ab49b..764afc94 100644 --- a/docs/wiki/The-Dashboard.md +++ b/docs/wiki/The-Dashboard.md @@ -178,6 +178,12 @@ The connection readout, CPU, MEM and the plan usage windows can be drawn three w Hiding System Stats or Plan Usage still hides them in every style. +The line between the session tabs and the toolbar is a handle: drag it right to narrow the toolbar +(it wraps onto a second line and the usage readings shed their labels, then their provider names, +then their percentages), left to widen it. Arrow keys resize it, and double-click or Home resets it. +It stops at the widest reading, the width is kept per device, and it is not shown on phones, in the +sidebar layout, in the vertical rail or in a solo window. + New header controls never appear on phones. Phone layout is deliberately minimal and is covered in [Mobile Guide](Mobile-Guide). diff --git a/src/web/public/header-divider.js b/src/web/public/header-divider.js index ded3ea86..33fc5603 100644 --- a/src/web/public/header-divider.js +++ b/src/web/public/header-divider.js @@ -63,9 +63,14 @@ handle.setAttribute('role', 'separator'); handle.setAttribute('aria-orientation', 'vertical'); handle.setAttribute('aria-label', 'Resize the toolbar (double-click to reset)'); + handle.setAttribute('aria-valuemin', String(MIN_WIDTH)); handle.tabIndex = 0; right.insertBefore(handle, right.firstChild); + // The width the user asked for. The applied width can be smaller (a narrow window) or larger (a reading + // that needs more room), and the request is what comes back when the window grows again. + let wanted = readStored(); + const bounds = () => ({ header: header.clientWidth, brand: brand ? brand.offsetWidth : 0 }); const current = () => right.getBoundingClientRect().width; @@ -87,20 +92,20 @@ else root.removeAttribute('data-header-density'); } - function apply(width, persist) { - if (width === null) { + /** Apply `wanted`, settled on a width no reading overflows at the density that width gets. */ + function settle() { + if (wanted === null) { root.style.removeProperty('--header-right-width'); root.removeAttribute('data-header-right-width'); root.removeAttribute('data-header-density'); handle.removeAttribute('aria-valuenow'); - if (persist) writeStored(null); + handle.removeAttribute('aria-valuemax'); return; } const { header: h, brand: b } = bounds(); - let clamped = clampWidth(width, h, b); + let clamped = clampWidth(wanted, h, b); root.setAttribute('data-header-right-width', 'set'); - // A narrower toolbar sheds labels, which shrinks the readings, which lowers the minimum: settle - // on a width no reading overflows at the density that width gets. + // A narrower toolbar sheds labels, which shrinks the readings, which lowers the minimum. for (let i = 0; i < 3; i++) { root.style.setProperty('--header-right-width', clamped + 'px'); setDensity(clamped); @@ -109,49 +114,92 @@ clamped = floor; } handle.setAttribute('aria-valuenow', String(clamped)); - if (persist) writeStored(clamped); + handle.setAttribute('aria-valuemax', String(clampWidth(Infinity, h, b))); } + /** Several changes in one frame (a chip refill, a resize) settle once. */ + let settleFrame = 0; + function scheduleSettle() { + if (wanted === null || settleFrame) return; + settleFrame = requestAnimationFrame(() => { + settleFrame = 0; + settle(); + }); + } + + /** The tab strip and the subagent lines measure the header, so they re-measure after a user resize. */ + function relayout() { + window.app?.updateTabOverflowMode?.(); + window.app?.updateConnectionLines?.(); + } + + function apply(width) { + wanted = width === null ? null : Math.round(width); + writeStored(wanted); + settle(); + relayout(); + } + + // Readings arrive after init (the plan-usage chip is filled in asynchronously, a Codex or Copilot row + // can start reporting later, a header button can be switched on): re-settle when any child changes size. + const resizeObserver = new ResizeObserver(scheduleSettle); + const watch = () => { + for (const child of right.children) if (child !== handle) resizeObserver.observe(child); + }; + watch(); + new MutationObserver(() => { + watch(); + scheduleSettle(); + }).observe(right, { childList: true }); + handle.addEventListener('pointerdown', (event) => { if (event.button !== 0) return; event.preventDefault(); handle.setPointerCapture?.(event.pointerId); document.body.classList.add('header-divider-dragging'); - const onMove = (e) => apply(header.getBoundingClientRect().right - e.clientX - 12, false); - const onUp = () => { + const padRight = parseFloat(getComputedStyle(header).paddingRight) || 0; + let moveFrame = 0; + let lastX = event.clientX; + const onMove = (e) => { + lastX = e.clientX; + if (moveFrame) return; + moveFrame = requestAnimationFrame(() => { + moveFrame = 0; + wanted = Math.round(header.getBoundingClientRect().right - lastX - padRight); + settle(); + }); + }; + const finish = () => { handle.removeEventListener('pointermove', onMove); - handle.removeEventListener('pointerup', onUp); - handle.removeEventListener('pointercancel', onUp); + handle.removeEventListener('pointerup', finish); + handle.removeEventListener('pointercancel', finish); + handle.removeEventListener('lostpointercapture', finish); + if (moveFrame) cancelAnimationFrame(moveFrame); + moveFrame = 0; document.body.classList.remove('header-divider-dragging'); - const width = Number.parseFloat(root.style.getPropertyValue('--header-right-width')); - if (Number.isFinite(width)) writeStored(width); + if (wanted !== null) apply(wanted); }; handle.addEventListener('pointermove', onMove); - handle.addEventListener('pointerup', onUp); - handle.addEventListener('pointercancel', onUp); + handle.addEventListener('pointerup', finish); + handle.addEventListener('pointercancel', finish); + handle.addEventListener('lostpointercapture', finish); }); - handle.addEventListener('dblclick', () => apply(null, true)); + handle.addEventListener('dblclick', () => apply(null)); handle.addEventListener('keydown', (event) => { if (event.key === 'Home' || event.key === 'Escape') { - apply(null, true); + apply(null); } else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { - const step = event.key === 'ArrowLeft' ? KEY_STEP : -KEY_STEP; - apply(current() + step, true); + apply((wanted ?? current()) + (event.key === 'ArrowLeft' ? KEY_STEP : -KEY_STEP)); } else { return; } event.preventDefault(); }); - window.addEventListener('resize', () => { - const width = Number.parseFloat(root.style.getPropertyValue('--header-right-width')); - if (Number.isFinite(width)) apply(width, false); - }); - - const stored = readStored(); - if (stored !== null) apply(stored, false); + window.addEventListener('resize', scheduleSettle); + settle(); } window.CodemanHeaderDivider = { clampWidth, densityFor, MIN_WIDTH, MIN_TABS_ROOM }; diff --git a/src/web/public/styles.css b/src/web/public/styles.css index a8c175fa..de886aae 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -2275,47 +2275,66 @@ html[data-tab-orientation='vertical'].tab-rail-compact box-shadow: inset 0 0 0 2px var(--accent); } +/* Everything below applies only where the tabs share the header with the toolbar, i.e. where the handle is + visible to undo it: not in the sidebar layout, the vertical rail or a solo window, and not on a phone. */ + /* A width the user chose: the toolbar keeps it and wraps onto a second line when its content is wider. */ -html[data-header-right-width]:not([data-tab-orientation='vertical']):not([data-session-list='sidebar']) - body:not(.solo-mode) - .header-right { - flex: 0 0 var(--header-right-width); - width: var(--header-right-width); - flex-wrap: wrap; - justify-content: flex-end; - align-content: center; - row-gap: 0.25rem; - min-height: var(--header-height); -} +@media (min-width: 769px) { + html[data-header-right-width]:not([data-tab-orientation='vertical']):not([data-session-list='sidebar']) + body:not(.solo-mode) + .header-right { + flex: 0 0 var(--header-right-width); + width: var(--header-right-width); + flex-wrap: wrap; + justify-content: flex-end; + align-content: center; + row-gap: 0.25rem; + min-height: var(--header-height); + } -/* The narrower the toolbar, the less each usage reading shows (data-header-density, header-divider.js): - tight drops the window and CPU/MEM labels, dense shortens the provider names, tiny keeps only the rings. - The same rules apply under every header-stats style. */ -html[data-header-density] .header-system-stats .stat-label, -html[data-header-density] .header-plan-usage .pu-label { - display: none; -} + /* The narrower the toolbar, the less each usage reading shows (data-header-density, header-divider.js): + tight drops the window and CPU/MEM labels, dense shortens the provider names, tiny keeps only the rings. + The prefix outweighs the per-style rules further down, so every header-stats style follows it. */ + html[data-header-density]:not([data-tab-orientation='vertical']):not([data-session-list='sidebar']) + body:not(.solo-mode) + :is(.header-system-stats .stat-label, .header-plan-usage .pu-label) { + display: none; + } -html[data-header-density] .header-system-stats, -html[data-header-density] .header-plan-usage { - gap: 8px; - padding: 0 8px; -} + html[data-header-density]:not([data-tab-orientation='vertical']):not([data-session-list='sidebar']) + body:not(.solo-mode) + :is(.header-system-stats, .header-plan-usage) { + gap: 8px; + padding: 0 8px; + } -html[data-header-density='dense'] .header-plan-usage .pu-provider, -html[data-header-density='tiny'] .header-plan-usage .pu-provider { - font-size: 0; -} + html:is([data-header-density='dense'], [data-header-density='tiny']):not([data-tab-orientation='vertical']):not( + [data-session-list='sidebar'] + ) + body:not(.solo-mode) + .header-plan-usage + .pu-provider { + font-size: 0; + } -html[data-header-density='dense'] .header-plan-usage .pu-provider::after, -html[data-header-density='tiny'] .header-plan-usage .pu-provider::after { - content: attr(data-short); - font-size: 0.6rem; -} + html:is([data-header-density='dense'], [data-header-density='tiny']):not([data-tab-orientation='vertical']):not( + [data-session-list='sidebar'] + ) + body:not(.solo-mode) + .header-plan-usage + .pu-provider::after { + content: attr(data-short); + font-size: 0.6rem; + } -html[data-header-density='tiny'] .header-system-stats .stat-value, -html[data-header-density='tiny'] .header-plan-usage .pu-val { - display: none; + /* Rings-only is a Compact-style reading: the other styles have no rings, so they keep their values. */ + html[data-header-stats='compact'][data-header-density='tiny']:not([data-tab-orientation='vertical']):not( + [data-session-list='sidebar'] + ) + body:not(.solo-mode) + :is(.header-system-stats .stat-value, .header-plan-usage .pu-val) { + display: none; + } } body.header-divider-dragging, @@ -2335,11 +2354,6 @@ body.solo-mode .header-divider { .header-divider { display: none; } - - html[data-header-right-width] .header-right { - flex: 0 1 auto; - width: auto; - } } /* Connection Indicator */ diff --git a/test/header-divider.browser.test.ts b/test/header-divider.browser.test.ts index 1aa105a6..7f12f298 100644 --- a/test/header-divider.browser.test.ts +++ b/test/header-divider.browser.test.ts @@ -1,4 +1,8 @@ -/** Real Chromium: dragging the header divider resizes the toolbar and wraps it onto a second line. */ +/** + * Real Chromium, against the real header markup from index.html and the real styles.css: dragging the divider + * resizes the toolbar and wraps it onto a second line, the readings shed detail as it narrows, nothing pokes + * out past the divider (even for content that arrives later), and none of it applies where the handle is hidden. + */ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; @@ -7,19 +11,28 @@ import { chromium, type Browser, type Page } from 'playwright'; const dir = resolve(import.meta.dirname, '../src/web/public'); const styles = readFileSync(resolve(dir, 'styles.css'), 'utf8'); const controller = readFileSync(resolve(dir, 'header-divider.js'), 'utf8'); +const header = /
[\s\S]*?<\/header>/.exec(readFileSync(resolve(dir, 'index.html'), 'utf8'))![0]; -const page = (root = 'data-tab-orientation="horizontal" data-session-list="header"') => ` - -
-
-
-
-
usage
-
stats
- -
-
-`; +const CHIP = + 'Claude' + + '5h49%' + + '7d72%' + + 'Copilot' + + 'mo81%'; + +const ORIGIN = 'http://app.test/'; + +/** The default layout attributes, with any `name="value"` in `extra` replacing the matching default. */ +function rootAttrs(extra = '') { + const attrs = new Map([ + ['data-header-stats', 'compact'], + ['data-tab-orientation', 'horizontal'], + ['data-session-list', 'header'], + ]); + for (const [, name, value] of extra.matchAll(/([\w-]+)="([^"]*)"/g)) attrs.set(name, value); + return [...attrs].map(([name, value]) => `${name}="${value}"`).join(' '); +} +type Attrs = { root?: string; body?: string }; describe('header divider (real Chromium)', () => { let browser: Browser; @@ -31,100 +44,213 @@ describe('header divider (real Chromium)', () => { await browser.close(); }); - async function open(html: string) { - p = await browser.newPage({ viewport: { width: 1400, height: 600 } }); - await p.setContent(html); + /** Open the real header; `stored` is the width kept from an earlier visit, `chip` fills the plan-usage chip. */ + async function open(options: { root?: string; body?: string; stored?: number; chip?: boolean; width?: number } = {}) { + p = await browser.newPage({ viewport: { width: options.width ?? 1400, height: 600 } }); + await p.route(ORIGIN, (route) => + route.fulfill({ + contentType: 'text/html', + body: `${header}`, + }) + ); + await p.goto(ORIGIN); + if (options.stored) + await p.evaluate((w) => localStorage.setItem('codeman-header-right-width', String(w)), options.stored); + if (options.chip) { + await p.evaluate((html) => { + const chip = document.getElementById('planUsageChip')!; + chip.classList.remove('header-plan-usage--hidden'); + chip.innerHTML = html; + }, CHIP); + } await p.addScriptTag({ content: controller }); } const box = (sel: string) => p.locator(sel).evaluate((el) => el.getBoundingClientRect().toJSON()); - - it('narrows the toolbar and wraps it onto a second line when dragged right, and resets on double-click', async () => { - await open(page()); - const before = await box('#headerRight'); + const settled = () => p.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))); + const dragTo = async (toolbarWidth: number) => { const handle = await box('.header-divider'); - const y = handle.top + handle.height / 2; - await p.mouse.move(handle.left + 6, y); + const head = await box('.header'); + await p.mouse.move(handle.left + 6, handle.top + 10); await p.mouse.down(); - await p.mouse.move(handle.left + 6 + 330, y, { steps: 6 }); + await p.mouse.move(head.right - 12 - toolbarWidth, handle.top + 10, { steps: 6 }); await p.mouse.up(); - const after = await box('#headerRight'); - expect(after.width).toBeLessThan(before.width - 250); - expect(after.height).toBeGreaterThan(before.height + 10); - // setContent gives an opaque origin: storage is blocked, and the drag must still work. - expect(await p.evaluate(() => document.documentElement.style.getPropertyValue('--header-right-width'))).not.toBe( - '' - ); - const tabs = await box('.session-tabs-host'); - expect(tabs.width).toBeGreaterThan(before.width); - - await p.locator('.header-divider').dblclick(); - const reset = await box('#headerRight'); - expect(Math.abs(reset.width - before.width)).toBeLessThan(2); - expect(await p.evaluate(() => document.documentElement.style.getPropertyValue('--header-right-width'))).toBe(''); - await p.close(); - }); - - it('drops usage labels, then provider names, then percentages as the toolbar narrows', async () => { - const chip = - '
Claude' + - '5h49%
'; - await open(page('data-header-stats="compact" data-tab-orientation="horizontal" data-session-list="header"')); - await p.evaluate((html) => document.getElementById('headerRight')!.insertAdjacentHTML('beforeend', html), chip); - const visible = (sel: string) => p.locator(sel).isVisible(); - expect(await visible('.pu-label')).toBe(true); - const sizes: Array<[number, string]> = [ - [450, 'tight'], - [330, 'dense'], - [200, 'tiny'], - ]; - for (const [width, density] of sizes) { - const handle = await box('.header-divider'); - const header = await box('.header'); - await p.mouse.move(handle.left + 6, handle.top + handle.height / 2); - await p.mouse.down(); - await p.mouse.move(header.right - width - 12, handle.top + handle.height / 2, { steps: 5 }); - await p.mouse.up(); - expect(await p.evaluate(() => document.documentElement.dataset.headerDensity)).toBe(density); - } - expect(await visible('.pu-label')).toBe(false); - expect(await visible('.pu-val')).toBe(false); - expect(await p.locator('.pu-provider').evaluate((el) => getComputedStyle(el, '::after').content)).toBe('"CL"'); - await p.close(); - }); - - it('stops the divider at the widest reading so nothing pokes out past it', async () => { - await open(page()); - const handle = await box('.header-divider'); - const header = await box('.header'); - await p.mouse.move(handle.left + 6, handle.top + handle.height / 2); - await p.mouse.down(); - await p.mouse.move(header.right - 20, handle.top + handle.height / 2, { steps: 8 }); - await p.mouse.up(); - const right = await box('#headerRight'); - const edge = right.left + 1; + await settled(); + }; + const readingsInside = async () => { + const edge = (await box('#headerRight')).left + 1; const lefts = await p .locator('#headerRight > :not(.header-divider)') - .evaluateAll((els) => els.map((el) => el.getBoundingClientRect().left)); - for (const left of lefts) expect(left).toBeGreaterThanOrEqual(edge); + .evaluateAll((els) => + els.filter((el) => (el as HTMLElement).offsetParent !== null).map((el) => el.getBoundingClientRect().left) + ); + return { edge, lefts }; + }; + const display = (sel: string) => + p + .locator(sel) + .first() + .evaluate((el) => getComputedStyle(el).display); + + it('narrows the toolbar and wraps it when dragged right, and resets on double-click', async () => { + await open({ chip: true }); + const before = await box('#headerRight'); + await dragTo(330); + const after = await box('#headerRight'); + expect(after.width).toBeLessThan(before.width - 100); + expect(after.height).toBeGreaterThan(before.height + 10); + expect((await box('.session-tabs-host')).width).toBeGreaterThan(before.width / 2); + expect(await p.evaluate(() => localStorage.getItem('codeman-header-right-width'))).not.toBeNull(); + await p.locator('.header-divider').dblclick(); + expect(Math.abs((await box('#headerRight')).width - before.width)).toBeLessThan(2); + expect(await p.evaluate(() => localStorage.getItem('codeman-header-right-width'))).toBeNull(); await p.close(); }); - it('resizes from the keyboard', async () => { - await open(page()); + it('resizes from the keyboard and tells the tab strip to re-measure', async () => { + await open({ chip: true }); + await p.evaluate(() => { + (window as any).relayouts = 0; + (window as any).app = { + updateTabOverflowMode: () => (window as any).relayouts++, + updateConnectionLines: () => {}, + }; + }); const before = await box('#headerRight'); await p.locator('.header-divider').focus(); await p.keyboard.press('ArrowRight'); await p.keyboard.press('ArrowRight'); - const after = await box('#headerRight'); - expect(after.width).toBeLessThan(before.width - 20); + expect((await box('#headerRight')).width).toBeLessThan(before.width - 20); + expect(await p.evaluate(() => (window as any).relayouts)).toBeGreaterThanOrEqual(2); await p.keyboard.press('Home'); expect(Math.abs((await box('#headerRight')).width - before.width)).toBeLessThan(2); await p.close(); }); - it('is hidden when the tabs live in the sidebar', async () => { - await open(page('data-tab-orientation="horizontal" data-session-list="sidebar"')); - expect(await p.locator('.header-divider').isVisible()).toBe(false); + it('stops the divider at the widest reading so nothing pokes out past it', async () => { + await open({ chip: true }); + await dragTo(20); + const { edge, lefts } = await readingsInside(); + for (const left of lefts) expect(left).toBeGreaterThanOrEqual(edge); + await p.close(); + }); + + it('keeps a reading inside the divider when it arrives after the width was applied', async () => { + await open({ stored: 160 }); + await settled(); + await p.evaluate((html) => { + const chip = document.getElementById('planUsageChip')!; + chip.classList.remove('header-plan-usage--hidden'); + chip.innerHTML = html; + }, CHIP); + await settled(); + const { edge, lefts } = await readingsInside(); + for (const left of lefts) expect(left).toBeGreaterThanOrEqual(edge); + await p.close(); + }); + + it('keeps a wide reading that is switched on later inside the divider', async () => { + await open({ stored: 160 }); + await settled(); + await p.evaluate(() => { + const wide = document.createElement('div'); + wide.id = 'lateReading'; + wide.style.cssText = 'width:320px;flex:none;height:24px'; + document.getElementById('headerRight')!.appendChild(wide); + }); + await settled(); + const { edge, lefts } = await readingsInside(); + for (const left of lefts) expect(left).toBeGreaterThanOrEqual(edge); + expect((await box('#headerRight')).width).toBeGreaterThanOrEqual(320); + await p.close(); + }); + + it('sheds labels, then provider names, then percentages as the toolbar narrows', async () => { + await open({ chip: true }); + expect(await display('.pu-label')).not.toBe('none'); + const seen: string[] = []; + for (const width of [450, 330, 200]) { + await dragTo(width); + seen.push((await p.evaluate(() => document.documentElement.dataset.headerDensity)) ?? ''); + } + expect(seen).toEqual(['tight', 'dense', 'tiny']); + expect(await display('.pu-label')).toBe('none'); + expect(await display('.pu-val')).toBe('none'); + expect( + await p + .locator('.pu-provider') + .first() + .evaluate((el) => getComputedStyle(el, '::after').content) + ).toBe('"CL"'); + await p.close(); + }); + + for (const style of ['tiles', 'classic']) { + it(`follows the density steps under the ${style} style, keeping values at the narrowest step`, async () => { + await open({ chip: true, root: `data-header-stats="${style}"` }); + await dragTo(330); + expect( + await p + .locator('.pu-provider') + .first() + .evaluate((el) => getComputedStyle(el).fontSize) + ).toBe('0px'); + await dragTo(200); + expect(await display('.pu-val')).not.toBe('none'); + await p.close(); + }); + } + + for (const [name, root, body] of [ + ['the sidebar layout', 'data-session-list="sidebar"', ''], + ['the vertical rail', 'data-tab-orientation="vertical"', ''], + ['a solo window', '', 'solo-mode'], + ] as const) { + it(`leaves a stored narrow width alone in ${name}, where the handle is hidden`, async () => { + await open({ chip: true, stored: 180, root, body }); + // the stored width is read at init, which already ran inside open() before storage was set + await p.evaluate(() => localStorage.setItem('codeman-header-right-width', '180')); + await p.reload(); + await p.addScriptTag({ content: controller }); + await settled(); + expect(await p.locator('.header-divider').isVisible()).toBe(false); + expect(await display('.stat-label')).not.toBe('none'); + expect(await p.locator('.header-right').evaluate((el) => getComputedStyle(el).flexBasis)).not.toBe('180px'); + await p.close(); + }); + } + + it('does not pin the toolbar to a stored width on a narrow viewport', async () => { + await open({ chip: true, width: 700 }); + await p.evaluate(() => localStorage.setItem('codeman-header-right-width', '300')); + await p.reload(); + await p.addScriptTag({ content: controller }); + await settled(); + expect(await p.locator('.header-right').evaluate((el) => getComputedStyle(el).flexBasis)).not.toBe('300px'); + await p.close(); + }); + + it('gets the chosen width back when the window shrinks and grows again', async () => { + await open({ chip: true }); + await dragTo(700); + const chosen = (await box('#headerRight')).width; + await p.setViewportSize({ width: 1000, height: 600 }); + await settled(); + expect((await box('#headerRight')).width).toBeLessThan(chosen); + await p.setViewportSize({ width: 1400, height: 600 }); + await settled(); + expect(Math.abs((await box('#headerRight')).width - chosen)).toBeLessThan(2); + await p.close(); + }); + + it('clears the dragging state when the pointer capture is lost', async () => { + await open({ chip: true }); + const handle = await box('.header-divider'); + await p.mouse.move(handle.left + 6, handle.top + 10); + await p.mouse.down(); + expect(await p.evaluate(() => document.body.classList.contains('header-divider-dragging'))).toBe(true); + await p.locator('.header-divider').evaluate((el) => el.dispatchEvent(new PointerEvent('lostpointercapture'))); + expect(await p.evaluate(() => document.body.classList.contains('header-divider-dragging'))).toBe(false); + await p.mouse.up(); await p.close(); }); }); From 95e2272f893589fb01fbc198155fa948c8235cbd Mon Sep 17 00:00:00 2001 From: Randalix Date: Fri, 23 Oct 2026 14:00:48 +0200 Subject: [PATCH 179/699] feat(edit-history): edit history panel for local claude sessions Part 1 of #643 (local sessions only; remote follows as its own PR). An opt-in, per-device header button opens a panel with the active session's edits, newest first: every Edit/Write/MultiEdit/NotebookEdit of the main agent and its subagents with its diff, plus images a Bash call rendered or the agent sent. Remote, Docker and non-claude sessions answer `unsupported`. - Reads only on request: `?sync=1` when the panel opens; while it is open on that session it re-reads after an edit-capable `transcript:tool_end` or a `transcript:complete` from the existing transcript watcher. No hook, timer, second watcher or new SSE event; a wiring guard pins it. - Main transcript: the path the transcript watcher follows (hook-reported), else `.jsonl` under the session's own CLAUDE_CONFIG_DIR override or the server's config dir. A config dir set outside Codeman is found with the next hook; until then the panel says so. - Store: dataPath('edit-history')/.json, 0600 via tmp+rename, capped at 500 entries and 4 MiB of diff text per session, deleted with the session, blocked in isSensitivePath, never fed to /api/search. Every route goes through findSessionOrFail with req. - Images only through existing file surfaces: file-raw inside the workspace, the attachment route with notify:false outside it. - textContent rendering, bounded byte-offset reads, marker-class header button, hidden on phones (locked in the mobile policy test). Docs: Working-With-Files, Settings-Reference, architecture invariants, API reference, CLAUDE.md key pattern. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 2 + config/test-suites.ts | 1 + docs/api-reference.md | 12 + docs/architecture-invariants.md | 16 + docs/wiki/Settings-Reference.md | 8 +- docs/wiki/Working-With-Files.md | 46 ++ src/edit-history-local-source.ts | 218 ++++++++ src/edit-history-parse.ts | 330 +++++++++++ src/edit-history-store.ts | 451 +++++++++++++++ src/session.ts | 9 +- src/web/edit-history-sync.ts | 307 +++++++++++ src/web/public/app.js | 6 + src/web/public/edit-history-ui.js | 643 ++++++++++++++++++++++ src/web/public/i18n.js | 33 ++ src/web/public/index.html | 25 + src/web/public/mobile.css | 2 + src/web/public/settings-ui.js | 8 +- src/web/public/styles.css | 291 ++++++++++ src/web/routes/edit-history-routes.ts | 130 +++++ src/web/routes/index.ts | 1 + src/web/sensitive-path.ts | 4 + src/web/server.ts | 31 ++ test/edit-history-local-source.test.ts | 134 +++++ test/edit-history-parse.test.ts | 355 ++++++++++++ test/edit-history-store.test.ts | 445 +++++++++++++++ test/edit-history-sync.test.ts | 379 +++++++++++++ test/edit-history.browser.test.ts | 468 ++++++++++++++++ test/mobile-header-buttons-policy.test.ts | 10 +- test/mocks/mock-session.ts | 7 + test/routes/edit-history-routes.test.ts | 261 +++++++++ test/sensitive-path.test.ts | 4 + 31 files changed, 4631 insertions(+), 6 deletions(-) create mode 100644 src/edit-history-local-source.ts create mode 100644 src/edit-history-parse.ts create mode 100644 src/edit-history-store.ts create mode 100644 src/web/edit-history-sync.ts create mode 100644 src/web/public/edit-history-ui.js create mode 100644 src/web/routes/edit-history-routes.ts create mode 100644 test/edit-history-local-source.test.ts create mode 100644 test/edit-history-parse.test.ts create mode 100644 test/edit-history-store.test.ts create mode 100644 test/edit-history-sync.test.ts create mode 100644 test/edit-history.browser.test.ts create mode 100644 test/routes/edit-history-routes.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 02704d0f..15dd8e25 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -232,6 +232,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Git status indicator** (`showGitStatus`, per-device, default OFF; `src/git-workspace-status.ts`, `routes/git-status-routes.ts`, `git-status-ui.js`): the bottom-bar indicator and its panel read `GET /api/sessions/:id/git-status` and `/git-diff`, read-only and offline (it never fetches and never writes). ⚠️ git never runs on a repository at or inside a Docker case workspace (walk-up, scan and diff alike, and a cached repository list is re-checked against the CURRENT Docker roots), since a container could plant a clean filter that runs on the host; remote and Docker sessions answer `unsupported`. ⚠️ `git-diff` takes `repo`/`path`/`kind` only as keys matched against the workspace's own repository list (`findWorkspaceRepo()`) and that repository's current status, never as paths. ⚠️ Keep `--no-optional-locks`, `core.fsmonitor=false` and `log.showSignature=false` on every call and `--no-ext-diff --no-textconv` on diffs; clean filters still run, which is why the Docker rule exists. ⚠️ An enclosing repository at `$HOME` or above is ignored (`isUnrelatedAncestor()`), and is identified by one cached `rev-parse` before any full status runs. Everything git supplies renders via `textContent`. Tests: `test/git-workspace-status.test.ts`, `test/routes/git-status-routes.test.ts`, and `test/git-status.browser.test.ts` (browser suite, not in the gate). +**Edit history panel** (`showEditHistory`, per-device, default OFF; `src/edit-history-parse.ts`, `edit-history-store.ts`, `edit-history-local-source.ts`, `web/edit-history-sync.ts`, `routes/edit-history-routes.ts`, `edit-history-ui.js`): which files a LOCAL claude session edited (main agent and subagents, labelled), each edit's diff, and images a Bash call produced. Read from the session's transcripts by byte offset, never from git. `GET /api/sessions/:id/edits[?sync=1]`, `GET …/edits/:entryId` (unified diff), `POST …/edits/older`. ⚠️ **Reads only on request:** the panel sends `?sync=1` when it opens and, while it is open on that session, after an edit-capable `transcript:tool_end` or a `transcript:complete` (debounced). The server never syncs on its own: no hook, no timer, no second watcher; `server.ts` hands the reader to the routes and only calls `remove`/`flushAll` itself (wiring-guard test). The main transcript is the path the session's `TranscriptWatcher` follows (reported by the hooks), else `.jsonl` under the session's `CLAUDE_CONFIG_DIR` override or the server's (`resolveClaudeTranscript`); a config dir set outside Codeman is found with the next hook, until then `unavailable` / `no-transcript`. ⚠️ The store holds every diff, secrets included: `dataPath('edit-history')/.json`, 0600 via tmp + rename, capped per session (500 entries, 4 MiB diff text), deleted with the session, blocked in `isSensitivePath`, never fed to `/api/search`. Images use the existing file surfaces only (workspace `file-raw`; outside it the attachment route with `notify: false` and its extension allowlist); no route here serves a file. A Bash image counts only when the file's mtime lies in the call's window (±2 s) and no entry already covers that file version. Remote, Docker and non-claude sessions answer `unsupported`. Tests: `test/edit-history-*.test.ts`, `test/routes/edit-history-routes.test.ts`, `test/edit-history.browser.test.ts` (browser suite). + **Raw file bodies are streamed and range-aware**: `file-raw`, the attachments `/raw` route, `GET /api/download` and the rendered-HTML route `GET /html-view//...` share `sendFileBody()`, advertise `Accept-Ranges: bytes` and answer `Range` with `206` + `Content-Range` (single-range, parser in `src/web/http-range.ts`); without it `