diff --git a/docs/api-reference.md b/docs/api-reference.md index a7bb3c34..94e917b6 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -974,9 +974,12 @@ unresolved) and survives a server restart. Session state (`GET /api/v1/sessions`, the `session:updated` event) may carry `cwd`, the directory the session's local tmux pane is in now (`#{pane_current_path}`), which the web UI uses as the base for a relative path printed in the terminal. It is -runtime-only (never persisted, so a restart starts without it), absent for remote, +never restored after a restart (it is written to `state.json` with the rest of the +session state but not read back), absent for remote, Docker, direct-PTY and rediscovered sessions and for a session split into several panes, and never a confinement root: file routes stay confined to `workingDir`. +tmux reports the resolved directory, so for a workspace reached through a symlink a +directory at or under the resolved workspace is reported in `workingDir`'s spelling. ## Session model (`displayModel`) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index e61d4320..22408053 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -409,7 +409,7 @@ 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 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 a base (`.`/`..` collapsed, so `../x.png` lands outside and routes like any external path) and passes absolute and `~/` paths through. The base is `relativeResolutionBase(session)`: the pane's live directory `SessionState.cwd` when known, else `workingDir`, which is fixed at create time and never follows a `cd`. `cwd` is `#{pane_current_path}` from the pane-exit watcher's existing batched `list-panes -a` read (`derivePaneCurrentPaths()`, single-pane sessions and absolute paths only; no new tmux call and no new SSE event: the server applies it on the `paneExitsUpdated` tick and broadcasts `session:updated` only on a change). tmux reports the cwd of the pane's FOREGROUND process-group leader, so a shell's `cd` moves it, while an agent CLI's tool subprocesses (Claude's Bash tool, codex's exec) do not: for those sessions it stays the CLI's own directory. ⚠️ `cwd` is a resolution BASE only, never a confinement root: `_isExternalPreviewPath`, `file-content`/`file-raw` and the log viewer's `tail -f` allowlist all still judge against `workingDir`, so a pane that `cd`s outside the workspace sends its relative links through the attachment routes like any other external path, and what can be opened is unchanged (an absolute path to the same file already opened). ⚠️ `Session.setCwd()` uses the `paneExitApplies` scoping rule and fails closed the same way: a remote session's local pane runs the ssh client and a docker case's runs `docker exec`, so their pane directory is a host directory the agent was never in; direct-PTY and rediscovered sessions get none either, and the browser re-checks `remote`/`docker`. ⚠️ Runtime-only: serialized by `toState()` but never restored, and a directory change alone never persists. A failed or empty read keeps every last directory, and a blank `#{pane_current_path}` on a single-pane session keeps that session's (`derivePaneCurrentPaths()` carries it forward); an authoritative read that lists the session as split into several panes, or not at all, clears it, so a hand-split session falls back to `workingDir`. The server applies the manager's answer as given, `undefined` included, and `setCwd()` reports a clear once. Trade-off: the base is the pane's directory at CLICK time, so a relative path printed before a `cd` resolves against the new directory. Tests: `test/pane-cwd-tracking.test.ts`. ⚠️ `~` 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 a base (`.`/`..` collapsed, so `../x.png` lands outside and routes like any external path) and passes absolute and `~/` paths through. The base is `relativeResolutionBase(session)`: the pane's live directory `SessionState.cwd` when known, else `workingDir`, which is fixed at create time and never follows a `cd`. `cwd` is `#{pane_current_path}` from the pane-exit watcher's existing batched `list-panes -a` read (`derivePaneCurrentPaths()`, single-pane sessions and absolute paths only; no new tmux call and no new SSE event: the server applies it on the `paneExitsUpdated` tick and broadcasts `session:updated` only on a change). tmux reports the cwd of the pane's FOREGROUND process-group leader, so a shell's `cd` moves it, while an agent CLI's tool subprocesses (Claude's Bash tool, codex's exec) do not: for those sessions it stays the CLI's own directory. ⚠️ `cwd` is a resolution BASE only, never a confinement root: `_isExternalPreviewPath`, `file-content`/`file-raw` and the log viewer's `tail -f` allowlist all still judge against `workingDir`, so a pane that `cd`s outside the workspace sends its relative links through the attachment routes like any other external path, and what can be opened is unchanged (an absolute path to the same file already opened). ⚠️ `Session.setCwd()` uses the `paneExitApplies` scoping rule and fails closed the same way: a remote session's local pane runs the ssh client and a docker case's runs `docker exec`, so their pane directory is a host directory the agent was never in; direct-PTY and rediscovered sessions get none either, and the browser re-checks `remote`/`docker`. ⚠️ tmux reads the directory from `/proc//cwd`, always the RESOLVED path, while `workingDir` keeps the spelling the case was created with (a symlinked `~/codeman-cases`, a case linked through a symlink, macOS `/tmp`), and `_isExternalPreviewPath` compares lexically, so `setCwd()` writes a reading at or under the workspace's resolved path back in `workingDir`'s spelling. The resolution runs once per session in the background, bounded (`probePathKind()` first, then a `realpath()` raced against the same timeout, never on the watcher tick and never `realpathSync`); an `unknown` probe or a failed or slow `realpath()` leaves the reading untranslated and retries after `WORKSPACE_REALPATH_RETRY_MS`. Until it settles, only a reading lexically inside `workingDir` is taken, so the first tick cannot mis-route links. ⚠️ Never restored after a restart: written to `state.json` by `toState()` like the rest of the session state but never read back, and a directory change alone never persists. A failed or empty read keeps every last directory, and a blank `#{pane_current_path}` on a single-pane session keeps that session's (`derivePaneCurrentPaths()` carries it forward); an authoritative read that lists the session as split into several panes, or not at all, clears it, so a hand-split session falls back to `workingDir`. The server applies the manager's answer as given, `undefined` included, and `setCwd()` reports a clear once. Trade-off: the base is the pane's directory at CLICK time, so a relative path printed before a `cd` resolves against the new directory. Tests: `test/pane-cwd-tracking.test.ts`. ⚠️ `~` 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 detectors cannot link fragments. Relative and `~/` spaced paths require an explicit path label (`Path:`, `File:`, `Files:`, `Saved to:`, `Saved as:`, `Wrote:`, `Written to:`, `Output:`, `Created:`, `Location:`, `Attachment:`), optionally preceded by a marker. Generic labels and bare bullets defer to ordinary detection, including slash-containing prose such as `CI/CD` or `45/45`. A later `/`, `~/`, `./` or `../` word starts a separate path; an earlier word ending in `/` is a separate folder. Absolute paths keep the own-line and marker forms plus the same path labels; any other label (`Error:`, `Note:`, `Saved the report to:`) defers an absolute path too. A rooted candidate needs a directory before its first space, so `/review the changes in src/a.ts` is prose. An earlier word holding a known extension anywhere (`a.ts#L12 foo.ts`) ends the path at that word, a `:` followed by a digit inside an earlier word is a line suffix (`a.ts:42:See b.md`, grep and compiler output) and ends the path there, a run of spaces is a column gap rather than a name, and a leading run of `./` or `../` is not a segment (`Saved to: ../My Docs/plan.md` links whole; an interior `..` is still refused). An unlinkable candidate reserves its span only while its last word holds a `/`, so `/usr/bin/env node build.js` keeps its ordinary links while `/etc/secret files/passwords.txt` links nothing. A path label vouches that a path follows, not for the words before it: a relative candidate whose only `/` word is its last is prose after the label (`Wrote: 120 lines to src/foo.ts`, `Path: see src/foo.ts`) and defers to the ordinary detectors. The cost is a labelled relative path whose only space is in its top directory, `Saved to: My Docs/plan.md` (and `Saved to: 03 Attachments/plan.pptx`), which splits as on master; `03 Attachments/Build SIPOC Design/Build-SIPOC.pptx`, `notes/a b/c.md`, `~/`, `./` and `../` forms and absolute paths still link whole. An earlier word that is a lowercase connective or verb (`SPACED_PATH_PROSE_WORD`: `and`, `or`, `to`, `in`, `the`, `is`, `has`, `contains`, `then`, `as` and a few more) or a bare `=` makes the candidate a sentence, in every shape: `/home/me/proj contains a.md`, `- /tmp/out has the logs in run.log`, `Path: /home/me/proj/out contains a.md`, `Files: src/components and utils/x.ts`, `Wrote: 3 files to docs/design notes/overview.md`. The cost is a name that uses one of those words (`Pros and Cons.md`, `How to deploy.md`, `Documents and Settings`), which splits as on master; `at` (`Screenshot 2026-10-24 at 10.00.00.png`) and `of` (`Copy of report.docx`) are left out of the list on purpose. What remains ambiguous is an own-line absolute path followed by words that hold none of them (`/home/me/proj/out report.pdf` reads like `/home/me/docs/Read Me.txt`), which links as one path. A command line is refused too: a first word that is an executable under `bin/`, `.bin/`, `scripts/` or `tools/` (`/opt/homebrew/bin/node server.js`, `/home/me/proj/scripts/deploy prod.yml`) or any `-x`/`--x` flag word (`/home/me/proj/node_modules/.bin/tsc -p tsconfig.json`); a bare `-` (`Docs - Copy`, `OneDrive - Contoso`) is not a flag. So is a shell prompt that prints an absolute working directory (WSL's `/mnt/c/...` is outside `$HOME`, so every earlier command in a shell's scrollback has one): a first word ending in `$` (`/home/me/proj$ cat notes.md`) and any earlier word with no letter or digit other than a bare `-` (`/mnt/c/proj ❯ code notes.md`, `/home/me/proj $ cat notes.md`, `/home/me/proj/out ✓ report.pdf`) refuse the candidate, while `Program Files (x86)` and `Docs - Copy` still link whole. The response viewer carries any leading `` label in a paragraph or list item, or one starting a later line of a paragraph (after a `
`, since chat renders with `breaks: true`), into detection without changing the label or displayed text, and `spacedFilePathOnLine()` judges it as it judges the same line in the terminal (`**Note:** /home/me/proj/out report.pdf` splits). A text node that follows any other inline element starts mid-line, so a placeholder word stands in for what precedes it (``Use `--config` /home/me/proj/cfg data.json`` splits as in the terminal), while a path inside inline code still links whole; paths split across inline elements remain detected per text node. Tests feed vendored marked output into the jsdom linkifier, so consumed bullets and bold labels are covered. Trailing whitespace uses linear `trimEnd()` before the length cap. diff --git a/src/config/path-probe.ts b/src/config/path-probe.ts index e69e9642..c5a79141 100644 --- a/src/config/path-probe.ts +++ b/src/config/path-probe.ts @@ -46,3 +46,10 @@ export const MAX_STALLED_PATH_PROBES = Math.min( PATH_PROBE_STALL_CEILING, envInt('CODEMAN_PATH_PROBE_MAX_STALLED', Math.max(1, PATH_PROBE_STALL_CEILING - 1), 1, 64) ); + +/** + * How long a session waits before trying again to resolve its workspace's symlinks + * after the folder did not answer, so a mount that recovers is picked up (the pane + * directory reading stays in the resolved spelling meanwhile). + */ +export const WORKSPACE_REALPATH_RETRY_MS = 60_000; diff --git a/src/session.ts b/src/session.ts index 361da01f..8fe3bc9c 100644 --- a/src/session.ts +++ b/src/session.ts @@ -30,6 +30,7 @@ import { EventEmitter } from 'node:events'; import { basename } from 'node:path'; +import { realpath } from 'node:fs/promises'; import { execSync, execFileSync } from 'node:child_process'; import { v4 as uuidv4 } from 'uuid'; import * as pty from 'node-pty'; @@ -71,6 +72,8 @@ import { import type { GitFetchStatus } from './git-fetch.js'; import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js'; import { claudeTranscriptExists } from './utils/claude-transcript.js'; +import { probePathKind } from './utils/bounded-path-probe.js'; +import { PATH_PROBE_TIMEOUT_MS, WORKSPACE_REALPATH_RETRY_MS } from './config/path-probe.js'; import { matchesPattern } from './config/cli-registry/patterns.js'; import { probeDockerCliVersion } from './docker-hosts.js'; import { probeRemoteCliVersion } from './remote-hosts.js'; @@ -398,6 +401,21 @@ const DOCKER_CLI_VERSION_PROBE_DELAY_MS = 3000; /** Delay before the over-ssh Claude CLI version probe (keeps session start off the ssh round-trip). */ const REMOTE_CLI_VERSION_PROBE_DELAY_MS = 3000; +/** Whether absolute `path` is `root` or lies under it (a lexical compare). */ +function isSameOrUnder(path: string, root: string): boolean { + return path === root || path.startsWith(root === '/' ? '/' : `${root}/`); +} + +/** + * Rewrite `path` from under `from` to the same place under `to`; a path not at or + * under `from` comes back unchanged. Exported for unit testing. + */ +export function rebasePath(path: string, from: string, to: string): string { + if (path === from) return to; + if (path.startsWith(`${from}/`)) return to + path.slice(from.length); + return path; +} + /** * Ask tmux for the current window geometry of `muxName` so a re-attaching PTY * client can spawn at the same size and avoid the resize-flicker / scrollback @@ -748,6 +766,16 @@ export class Session extends EventEmitter { * serialized by toState() but never restored by the constructor. */ private _cwd: string | undefined; + /** + * `workingDir` with symlinks resolved, so a pane reading (tmux reads the + * process cwd, always the resolved path) can be put back into `workingDir`'s + * spelling. `undefined` until the first resolution settles; `null` when there + * is nothing to translate (no symlink in the path) or it could not be resolved. + */ + private _workspaceRealPath: string | null | undefined; + private _workspaceRealPathPending = false; + /** When a resolution that met an unreachable folder may be tried again (0 = never). */ + private _workspaceRealPathRetryAt = 0; /** * How many starts, attaches or relaunches are running for this session's * pane. While one is, a dead-pane reading may describe a pane that is being @@ -1504,14 +1532,85 @@ export class Session extends EventEmitter { * `docker exec`, so their pane directory is a host directory the agent was * never in, and a direct-PTY or rediscovered session has no pane Codeman can * vouch for. A relative value is refused rather than stored. + * + * tmux reads the pane's directory from the process cwd, which is always the + * resolved path, while `workingDir` keeps the spelling the case was created + * with. A workspace reached through a symlink would otherwise read as outside + * itself, and the browser's preview routing (a lexical compare against + * `workingDir`) would send every relative link to the attachment route. So a + * reading at or under the workspace's resolved path is written back in + * `workingDir`'s spelling. Until that resolution has settled, only a reading + * lexically inside `workingDir` is accepted. */ setCwd(next: string | undefined): boolean { - const resolved = this.paneExitApplies && next?.startsWith('/') ? next : undefined; + let resolved = this.paneExitApplies && next?.startsWith('/') ? next : undefined; + if (resolved !== undefined) { + if ( + this._workspaceRealPath === undefined || + (this._workspaceRealPathRetryAt !== 0 && Date.now() >= this._workspaceRealPathRetryAt) + ) { + this._resolveWorkspaceRealPath(); + } + if (this._workspaceRealPath === undefined) { + // The workspace's resolved path is not known yet. A reading outside + // `workingDir` could be the workspace itself seen through its symlink, so + // it waits for the next tick rather than mis-route links for one. + if (!isSameOrUnder(resolved, this._workspaceSpelling())) return false; + } else if (this._workspaceRealPath !== null) { + resolved = rebasePath(resolved, this._workspaceRealPath, this._workspaceSpelling()); + } + } if (resolved === this._cwd) return false; this._cwd = resolved; return true; } + /** `workingDir` without a trailing separator (the root stays `/`). */ + private _workspaceSpelling(): string { + return this.workingDir.length > 1 ? this.workingDir.replace(/\/+$/, '') : this.workingDir; + } + + /** + * Resolve `workingDir`'s symlinks in the background for {@link setCwd}. + * + * Bounded: the folder can sit on a network mount that stopped answering, so it + * is probed with `probePathKind()` first and the `realpath()` is raced against + * the same timeout. An `unknown` probe, a failed or slow `realpath()` leave no + * translation and allow one more try after `WORKSPACE_REALPATH_RETRY_MS`; a + * folder that is absent or not a directory is settled for good. + */ + private _resolveWorkspaceRealPath(): void { + if (this._workspaceRealPathPending) return; + this._workspaceRealPathPending = true; + const spelling = this._workspaceSpelling(); + void (async () => { + let real: string | null = null; + let retry = false; + try { + const kind = await probePathKind(spelling); + if (kind === 'unknown') { + retry = true; + } else if (kind === 'directory') { + let timer: ReturnType | undefined; + const answer = await Promise.race([ + realpath(spelling).catch(() => undefined), + new Promise((resolveTimeout) => { + timer = setTimeout(() => resolveTimeout(undefined), PATH_PROBE_TIMEOUT_MS); + timer.unref?.(); + }), + ]).finally(() => clearTimeout(timer)); + if (answer === undefined) retry = true; + else if (answer !== spelling && answer !== '/') real = answer; + } + } catch { + retry = true; + } + this._workspaceRealPath = real; + this._workspaceRealPathRetryAt = retry ? Date.now() + WORKSPACE_REALPATH_RETRY_MS : 0; + this._workspaceRealPathPending = false; + })(); + } + /** * Record what the mux layer observed of this pane's agent, and say whether * that changed the answer. The caller persists and broadcasts on a true. diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 8853ad82..8df06aa4 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -432,9 +432,11 @@ export function derivePaneExits(rows: PaneRow[], now: number): Map { expect(app.external(app.target('src/a.js'))).toBe(true); }); }); + +describe('a workspace reached through a symlink', () => { + // tmux reads `/proc//cwd`, always the resolved path, while `workingDir` + // keeps the spelling the case was created with. Without the rewrite, the + // browser's lexical routing check would call every relative link external. + let root: string; + let physical: string; + let link: string; + + const read = (name: string) => readFileSync(resolve(import.meta.dirname, '../src/web/public', name), 'utf8'); + const routingFor = (session: Record) => { + const context = vm.createContext({ + console, + CodemanApp: class CodemanApp {}, + document: { addEventListener: vi.fn(), getElementById: () => null }, + window: { addEventListener: vi.fn(), removeEventListener: vi.fn() }, + localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} }, + }); + vm.runInContext( + `${read('constants.js')}\n${read('terminal-ui.js')}\n${read('panels-ui.js')}\nglobalThis.__proto = CodemanApp.prototype;`, + context + ); + const proto = ( + context as unknown as { + __proto: { + _resolveLinkedFilePath: (p: string, id: string) => string; + _isExternalPreviewPath: (p: string, id: string) => boolean; + }; + } + ).__proto; + const app = { sessions: new Map([['s1', session]]) }; + return (p: string) => proto._isExternalPreviewPath.call(app, proto._resolveLinkedFilePath.call(app, p, 's1'), 's1'); + }; + + /** Feed a reading until the background resolution has settled and it is taken. */ + const settle = async (session: Session, reading: string) => { + await vi.waitFor(() => { + session.setCwd(reading); + expect(session.cwd).toBeDefined(); + }); + }; + + afterEach(() => rmSync(root, { recursive: true, force: true })); + + const setup = () => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'pane-cwd-link-'))); + physical = join(root, 'data', 'app'); + mkdirSync(join(physical, 'src'), { recursive: true }); + mkdirSync(join(root, 'other'), { recursive: true }); + link = join(root, 'cases-app'); + symlinkSync(physical, link); + }; + + it('writes a reading of the resolved workspace back in workingDir spelling', async () => { + setup(); + const session = localMuxSession({ workingDir: link }); + await settle(session, physical); + expect(session.cwd).toBe(link); + expect(session.setCwd(join(physical, 'src'))).toBe(true); + expect(session.cwd).toBe(join(link, 'src')); + }); + + it('keeps an in-workspace relative link on the workspace route from the first reading', async () => { + setup(); + const session = localMuxSession({ workingDir: link }); + await settle(session, physical); + const external = routingFor(session.toState() as unknown as Record); + expect(external('src/index.ts')).toBe(false); + }); + + it('still routes a cd outside the workspace through the attachment route', async () => { + setup(); + const session = localMuxSession({ workingDir: link }); + await settle(session, join(root, 'other')); + expect(session.cwd).toBe(join(root, 'other')); + const external = routingFor(session.toState() as unknown as Record); + expect(external('notes.md')).toBe(true); + }); + + it('withholds an out-of-workspace reading until the resolution settles', () => { + setup(); + const session = localMuxSession({ workingDir: link }); + // The first call starts the resolution; a physical reading cannot be told + // apart from a real cd yet, so it is not stored this tick. + expect(session.setCwd(physical)).toBe(false); + expect(session.cwd).toBeUndefined(); + // A reading lexically inside workingDir needs no translation and is taken. + expect(session.setCwd(join(link, 'src'))).toBe(true); + }); + + it('leaves the reading alone when workingDir has no symlink in it', async () => { + setup(); + const session = localMuxSession({ workingDir: physical }); + await settle(session, join(physical, 'src')); + expect(session.cwd).toBe(join(physical, 'src')); + }); + + it('takes readings untranslated once a missing workingDir has settled', async () => { + setup(); + const session = localMuxSession({ workingDir: join(root, 'gone') }); + await settle(session, join(root, 'other')); + expect(session.cwd).toBe(join(root, 'other')); + }); +}); + +describe('rebasePath', () => { + it('rewrites the prefix itself and paths under it, and nothing else', () => { + expect(rebasePath('/mnt/data/app', '/mnt/data/app', '/home/u/app')).toBe('/home/u/app'); + expect(rebasePath('/mnt/data/app/src', '/mnt/data/app', '/home/u/app')).toBe('/home/u/app/src'); + expect(rebasePath('/mnt/data/application', '/mnt/data/app', '/home/u/app')).toBe('/mnt/data/application'); + expect(rebasePath('/mnt/other', '/mnt/data/app', '/home/u/app')).toBe('/mnt/other'); + }); +});