From 7de420195500402ee94c6632660cd1c980cc2934 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 6 Nov 2026 03:13:52 +0100 Subject: [PATCH] fix(adopt): an adopted session has no workspace of ours The wrapper's workingDir is the neutral /tmp, and everything master grew since 1.33.2 that branches on session.remote or session.docker read that as a local session working in /tmp, because an adopted session keeps its connection on adopt.remote and adopt.docker. So uploads wrote into /tmp and closing the tab recursively deleted the shared folder; the Git panel, its fetch and the fetch scheduler ran git on whatever sat there; the image watcher, Read My Mind and the Discord bridge used that folder; last-response paired a transcript by it and could show another conversation's answer as this one's; a magic link in a container's output registered a file from this host's /tmp; and guarded input could type into a human's composer. Each now refuses or answers unsupported, the browser hides the rows that cannot work, and Session Options shows where the foreign pane was instead of /tmp. The custom-model route refuses as well, since it restarts the CLI in the pane. The local version probe is skipped: the local binary says nothing about a process someone else started, possibly on another host. Also moves the guard test to port 0, which test-ports-guard now requires. Co-Authored-By: Claude Opus 5.5 --- src/git-fetch-scheduler.ts | 4 +- src/git-fetch.ts | 2 +- src/git-workspace-status.ts | 4 +- src/session.ts | 7 +- src/web/edit-history-sync.ts | 3 + src/web/paste-image-gc.ts | 8 +- src/web/public/git-status-ui.js | 8 +- src/web/public/session-ui.js | 40 ++++--- src/web/public/terminal-ui.js | 2 +- src/web/routes/discord-routes.ts | 2 +- src/web/routes/edit-history-routes.ts | 1 + src/web/routes/git-status-routes.ts | 10 +- src/web/routes/readmymind-routes.ts | 6 +- src/web/routes/session-routes.ts | 44 +++++++- src/web/routes/system-routes.ts | 5 +- src/web/server.ts | 6 +- test/agent-staging-session.test.ts | 35 +++++++ test/edit-history-sync.test.ts | 1 + test/foreign-adoption-guards.test.ts | 2 +- test/git-fetch-scheduler.test.ts | 2 + test/paste-image-gc.test.ts | 10 ++ test/routes/foreign-adoption-routes.test.ts | 109 ++++++++++++++++++++ 22 files changed, 269 insertions(+), 42 deletions(-) diff --git a/src/git-fetch-scheduler.ts b/src/git-fetch-scheduler.ts index a563fd1b..1b4207f4 100644 --- a/src/git-fetch-scheduler.ts +++ b/src/git-fetch-scheduler.ts @@ -79,6 +79,8 @@ export interface GitFetchSessionView { /** `unknown`, not `boolean`: a session's remote/Docker markers are objects, and the only question here is whether either is set at all. */ remote?: unknown; docker?: unknown; + /** Set on a wrapper around a foreign tmux session: its `workingDir` is a neutral folder, never a repository. */ + adopt?: unknown; gitFetchEnabled?: boolean; gitFetchIntervalSeconds?: number; } @@ -176,7 +178,7 @@ export class GitFetchScheduler { const groups = new Map(); for (const id of this.ports.sessionIds()) { const view = this.ports.getSession(id); - if (!view || view.remote || view.docker) continue; + if (!view || view.remote || view.docker || view.adopt) continue; const effective = effectiveGitFetch(view, settings); if (!effective.enabled) continue; const folder = resolve(view.workingDir); diff --git a/src/git-fetch.ts b/src/git-fetch.ts index a2f4b2ba..cfd201f7 100644 --- a/src/git-fetch.ts +++ b/src/git-fetch.ts @@ -346,7 +346,7 @@ export interface GitFetchEntry { export interface GitFetchReport { /** `ok` when at least one repository was fetched (or attempted); the other states are as in the overview. */ state: 'ok' | 'not-a-repo' | 'unsupported' | 'error'; - reason?: 'remote' | 'docker'; + reason?: 'remote' | 'docker' | 'adopted'; error?: string; /** One entry per repository fetched, in the same order the panel lists them. */ repos: GitFetchEntry[]; diff --git a/src/git-workspace-status.ts b/src/git-workspace-status.ts index 2a5a8230..9be051be 100644 --- a/src/git-workspace-status.ts +++ b/src/git-workspace-status.ts @@ -95,7 +95,7 @@ export interface GitWorkspaceStatus { * `unsupported`: a remote or Docker session (never inspected). `error`: git failed; see `error`. */ state: 'ok' | 'not-a-repo' | 'unsupported' | 'error'; - reason?: 'remote' | 'docker'; + reason?: 'remote' | 'docker' | 'adopted'; error?: string; repoRoot?: string; /** Null when HEAD is detached. */ @@ -482,7 +482,7 @@ export interface GitRepoEntry { export interface GitWorkspaceOverview { /** `ok` when at least one repository was found; the other states are as in `GitWorkspaceStatus`. */ state: 'ok' | 'not-a-repo' | 'unsupported' | 'error'; - reason?: 'remote' | 'docker'; + reason?: 'remote' | 'docker' | 'adopted'; error?: string; repos: GitRepoEntry[]; /** More than `repoLimit` repositories were found; only the first are reported. */ diff --git a/src/session.ts b/src/session.ts index 68199cfd..b309768e 100644 --- a/src/session.ts +++ b/src/session.ts @@ -3181,8 +3181,10 @@ export class Session extends EventEmitter { // wheel-forwarding to Claude's own transcript (the only route to history in // repaint/alt-screen mode; issue #154). Remote sessions run claude on // another host, so a local probe wouldn't reflect their version; they get - // their own over-ssh probe below. Cached process-wide, best-effort. - if (cliNeedsVersionProbe(this.mode) && !this._remote && !this._docker && !this._cliVersion) { + // their own over-ssh probe below. Cached process-wide, best-effort. An adopted + // session's agent is a process someone else started, possibly elsewhere, so + // the local binary says nothing about it either. + if (cliNeedsVersionProbe(this.mode) && !this._remote && !this._docker && !this._adopt && !this._cliVersion) { const probedVersion = resolveSessionCliVersion(this.mode); if (probedVersion) { this._cliVersion = probedVersion; @@ -5133,6 +5135,7 @@ export class Session extends EventEmitter { (!allowLease && this.guardedInputHeld) || this.remote || this.docker || + this._adopt || this.paneExit || !this._muxSession ) diff --git a/src/web/edit-history-sync.ts b/src/web/edit-history-sync.ts index ad525c77..52073884 100644 --- a/src/web/edit-history-sync.ts +++ b/src/web/edit-history-sync.ts @@ -75,6 +75,8 @@ export interface EditHistorySession { mode: string; remote: SessionRemote | undefined; docker?: unknown; + /** Set on a wrapper around a foreign tmux session: Codeman holds no transcript id for what runs there. */ + adopt?: unknown; claudeSessionId: string | null; /** The session's own `CLAUDE_CONFIG_DIR` (Codeman env override), if it has one. */ claudeConfigDir?: string; @@ -106,6 +108,7 @@ export interface SyncOptions { export function unsupportedReason(session: EditHistorySession): string | undefined { if (session.remote) return 'remote'; if (session.docker) return 'docker'; + if (session.adopt) return 'adopted'; if (getCli(session.mode)?.capabilities.transcript !== 'claude-jsonl') return 'mode'; return undefined; } diff --git a/src/web/paste-image-gc.ts b/src/web/paste-image-gc.ts index 8fef5806..9a75b4bf 100644 --- a/src/web/paste-image-gc.ts +++ b/src/web/paste-image-gc.ts @@ -57,7 +57,9 @@ export const DISCORD_RECEIVED_DIR = '.codeman-discord'; * and is listed, or the uploads of a workspace like `~/.codeman/app` would * never be collected), and nothing for a remote (SSH) session, whose * workingDir is the remote path and would name a same-named LOCAL directory - * here. The working directory is a user-chosen path, so it is probed BOUNDED + * here, or for an adopted session, whose workingDir is the neutral `/tmp` that + * other sessions may really work in (it receives no uploads). The working + * directory is a user-chosen path, so it is probed BOUNDED * first (#516): one on a mount that stopped answering reads `unknown` and is * skipped, never touched. The sweep keeps the probe's stall cap; the delete, * acting on one path at the user's request, passes `pastCap`. The check is @@ -66,10 +68,10 @@ export const DISCORD_RECEIVED_DIR = '.codeman-discord'; * can. */ export async function uploadDirs( - session: { workingDir: string; remote?: unknown }, + session: { workingDir: string; remote?: unknown; adopt?: unknown }, probe: PathProbeOptions = {} ): Promise { - if (session.remote) return []; + if (session.remote || session.adopt) return []; if ((await probePathKind(session.workingDir, probe)) !== 'directory') return []; const dataDir = await realDir(getDataDir()); const dirs: string[] = []; diff --git a/src/web/public/git-status-ui.js b/src/web/public/git-status-ui.js index 599e4a99..46684ab5 100644 --- a/src/web/public/git-status-ui.js +++ b/src/web/public/git-status-ui.js @@ -425,9 +425,11 @@ Object.assign(CodemanApp.prototype, { overview.state === 'not-a-repo' ? 'No git repository here: this session’s folder is not one, and none was found inside it (up to two levels down).' : overview.state === 'unsupported' - ? overview.reason === 'docker' - ? 'Git status is not available for Docker sessions, or for folders inside a Docker case workspace.' - : 'Git status is not available for remote (SSH) sessions.' + ? overview.reason === 'adopted' + ? 'Git status is not available for a tmux session opened outside Codeman.' + : overview.reason === 'docker' + ? 'Git status is not available for Docker sessions, or for folders inside a Docker case workspace.' + : 'Git status is not available for remote (SSH) sessions.' : `Could not read the repository: ${overview.error || 'git failed'}`; body.append(el('div', 'git-status-empty', why)); clearChrome(); diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index fc0d2582..6456a82b 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -2757,8 +2757,9 @@ Object.assign(CodemanApp.prototype, { /** Copy the open session's working folder (Session Options > Session > Working folder). */ async copySessionPath() { const session = this.sessions.get(this.editingSessionId); - if (!session?.workingDir) return; - const ok = await this._copyText(session.workingDir); + const path = session?.adopt?.paneCurrentPath || session?.workingDir; + if (!path) return; + const ok = await this._copyText(path); this.showToast(ok ? 'Path copied' : 'Copy failed', ok ? 'success' : 'error'); }, @@ -2833,8 +2834,9 @@ Object.assign(CodemanApp.prototype, { this.updateGitFetchStatus(sessionId); const imageWatcherToggle = document.getElementById('modalImageWatcherEnabled'); imageWatcherToggle.checked = session.imageWatcherEnabled ?? true; - // The server never watches a remote session's directory (it is on another host), so the row is hidden. - imageWatcherToggle.closest('.set-row').style.display = session.remote ? 'none' : ''; + // The server never watches a remote session's directory (it is on another host), nor an adopted + // session's (a neutral wrapper folder), so the row is hidden. + imageWatcherToggle.closest('.set-row').style.display = session.remote || session.adopt ? 'none' : ''; document.getElementById('modalFlickerFilterEnabled').checked = session.flickerFilterEnabled ?? false; // Populate session name input with prefix/suffix split @@ -2853,17 +2855,21 @@ Object.assign(CodemanApp.prototype, { } // Working folder: the path the CLI was launched in on the Codeman host + // (an adopted session's is the neutral wrapper folder, so the row shows where the pane was) const _pathEl = document.getElementById('modalSessionPath'); - _pathEl.textContent = session.workingDir || ''; - _pathEl.title = session.workingDir || ''; - document.getElementById('modalSessionPathCopy').disabled = !session.workingDir; + const _shownPath = session.adopt?.paneCurrentPath || session.workingDir || ''; + _pathEl.textContent = _shownPath; + _pathEl.title = _shownPath; + document.getElementById('modalSessionPathCopy').disabled = !_shownPath; // A remote session's workingDir is the REMOTE path; a docker session's is the // host bind-mount source the container sees. - document.getElementById('modalSessionPathDesc').textContent = session.remote - ? "Where this session's CLI runs, on the remote host it reaches over SSH, not on this machine." - : session.docker - ? "On the machine hosting Codeman, mounted into the Docker container this session's CLI runs in." - : "Where this session's CLI runs, on the machine hosting Codeman."; + document.getElementById('modalSessionPathDesc').textContent = session.adopt + ? 'Where the tmux session was when it was opened here. It was started outside Codeman and keeps running if this tab closes.' + : session.remote + ? "Where this session's CLI runs, on the remote host it reaches over SSH, not on this machine." + : session.docker + ? "On the machine hosting Codeman, mounted into the Docker container this session's CLI runs in." + : "Where this session's CLI runs, on the machine hosting Codeman."; // Initialize color picker with current session color const currentColor = session.color || 'default'; @@ -2890,11 +2896,11 @@ Object.assign(CodemanApp.prototype, { if (respawnTabBtn) respawnTabBtn.style.display = ''; } - // Git is local git only: a remote (SSH) or Docker case answers 'unsupported' on both git routes and is - // never fetched by the interval scheduler, so its Git tab has nothing to say and its rail entry goes - // hidden the same way. + // Git is local git only: a remote (SSH) or Docker case, or an adopted session, answers 'unsupported' on + // both git routes and is never fetched by the interval scheduler, so its Git tab has nothing to say and + // its rail entry goes hidden the same way. const gitTabBtn = document.querySelector('#sessionOptionsModal .set-rail-item[data-tab="git"]'); - if (gitTabBtn) gitTabBtn.style.display = session.remote || session.docker ? 'none' : ''; + if (gitTabBtn) gitTabBtn.style.display = session.remote || session.docker || session.adopt ? 'none' : ''; // Populate Ralph Wiggum form with current session values (skip for external CLI sessions) if (!isExternalCli) { @@ -6111,7 +6117,7 @@ Object.assign(CodemanApp.prototype, { /** The Codeman-managed worktree a session runs in, or null (see managedWorktreeOfDir). */ _managedWorktreeOfSession(session) { - if (!session || session.remote || session.docker) return null; + if (!session || session.remote || session.docker || session.adopt) return null; return managedWorktreeOfDir(session.workingDir, this.cases); }, diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index e5578dec..e4bf2387 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -6711,7 +6711,7 @@ Object.assign(CodemanApp.prototype, { _keyboardHoldEligible(sessionId = this.activeSessionId) { if (this.loadAppSettingsFromStorage?.()?.keyboardHoldsRows === false) return false; const session = sessionId ? this.sessions?.get(sessionId) : null; - if (!session || session.remote || session.docker || session.cliMouseTracking) return false; + if (!session || session.remote || session.docker || session.adopt || session.cliMouseTracking) return false; const modes = window.__codemanKeyboardHoldModes; return Array.isArray(modes) && modes.includes(session.mode || 'claude'); }, diff --git a/src/web/routes/discord-routes.ts b/src/web/routes/discord-routes.ts index 2ab85511..2259d6d8 100644 --- a/src/web/routes/discord-routes.ts +++ b/src/web/routes/discord-routes.ts @@ -197,7 +197,7 @@ export function registerDiscordRoutes(app: FastifyInstance, deps: DiscordRouteDe } let upload: { name: string; data: Uint8Array } | undefined; if (file !== undefined) { - const problem = await fileProblem(session.workingDir, !!session.remote, file); + const problem = await fileProblem(session.workingDir, !!session.remote || session.isAdopted, file); if (typeof problem === 'string') { refundNotifyToken(session.id); reply.code(400); diff --git a/src/web/routes/edit-history-routes.ts b/src/web/routes/edit-history-routes.ts index 227a16a1..2d3b8543 100644 --- a/src/web/routes/edit-history-routes.ts +++ b/src/web/routes/edit-history-routes.ts @@ -99,6 +99,7 @@ export function registerEditHistoryRoutes(app: FastifyInstance, ctx: SessionPort mode: session.mode, remote: session.remote, docker: session.docker, + adopt: session.adopt, claudeSessionId: session.claudeSessionId, claudeConfigDir: session.claudeConfigDir(), }; diff --git a/src/web/routes/git-status-routes.ts b/src/web/routes/git-status-routes.ts index aff43d23..5cd1a5a6 100644 --- a/src/web/routes/git-status-routes.ts +++ b/src/web/routes/git-status-routes.ts @@ -81,6 +81,8 @@ export function registerGitStatusRoutes( const session = findSessionOrFail(ctx, id, req); if (session.remote) return { success: true, data: emptyOverview('unsupported', { reason: 'remote' }) }; if (session.docker) return { success: true, data: emptyOverview('unsupported', { reason: 'docker' }) }; + // An adopted wrapper's folder is the neutral `/tmp`, so a scan would run git on whatever sits there. + if (session.isAdopted) return { success: true, data: emptyOverview('unsupported', { reason: 'adopted' }) }; return { success: true, data: await getGitWorkspaceOverview(session.workingDir, { @@ -103,9 +105,12 @@ export function registerGitStatusRoutes( const { repo, path, kind } = query; const limits = limitsFrom(query); const session = findSessionOrFail(ctx, id, req); - if (session.remote || session.docker) { + if (session.remote || session.docker || session.isAdopted) { reply.code(400); - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Git is not available for remote or Docker sessions'); + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + 'Git is not available for remote, Docker or adopted sessions' + ); } const repoRoot = repo ? await findWorkspaceRepo(session.workingDir, repo, { @@ -159,6 +164,7 @@ export function registerGitStatusRoutes( const session = findSessionOrFail(ctx, id, req); if (session.remote) return { success: true, data: emptyReport('unsupported', { reason: 'remote' }) }; if (session.docker) return { success: true, data: emptyReport('unsupported', { reason: 'docker' }) }; + if (session.isAdopted) return { success: true, data: emptyReport('unsupported', { reason: 'adopted' }) }; // Fail CLOSED on a Docker case list that cannot be read: a fetch runs the repository's hooks, so it never // runs without knowing which folders a container can write to. Answered as a failed run (`state: 'error'`), // published like one, so the Session Options Git row shows it. diff --git a/src/web/routes/readmymind-routes.ts b/src/web/routes/readmymind-routes.ts index 27a1da60..cc0ebc12 100644 --- a/src/web/routes/readmymind-routes.ts +++ b/src/web/routes/readmymind-routes.ts @@ -91,8 +91,10 @@ export function registerReadMyMindRoutes(app: FastifyInstance, ctx: SessionPort const transcriptPath = ctx.getTranscriptPath(id); const transcript = transcriptPath ? await readTranscriptSignals(transcriptPath) : null; // Remote-SSH cases skip git: workingDir is not local. Docker cases are - // fine (the workspace is bind-mounted at the same host path). - const workspace = session.remote ? null : await collectWorkspaceSignals(session.workingDir); + // fine (the workspace is bind-mounted at the same host path). An adopted + // session's workingDir is a neutral wrapper folder, so it skips git too. + const workspace = + session.remote || session.isAdopted ? null : await collectWorkspaceSignals(session.workingDir); const lastPromptTs = profile.recentPrompts[profile.recentPrompts.length - 1]?.ts; const tracker = ctx.runSummaryTrackers.get(id); diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index e61bf953..35834cb9 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -1742,6 +1742,13 @@ export function registerSessionRoutes( 'Custom model endpoints are not supported for remote (SSH) or Docker sessions yet' ); } + // Its own check: this restarts the CLI in the pane, and an adopted pane's CLI is not ours to restart. + if (session.isAdopted) { + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + 'Custom model endpoints are not available for adopted sessions: Codeman did not start this agent' + ); + } if (session.isBusy()) { return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy'); } @@ -3042,7 +3049,13 @@ export function registerSessionRoutes( * effort: the tab rename has already happened and must not fail on this. */ async function syncClaudeTitle(session: Session): Promise { - if (getCli(session.mode)?.capabilities.transcript !== 'claude-jsonl' || session.remote || session.docker) return; + if ( + getCli(session.mode)?.capabilities.transcript !== 'claude-jsonl' || + session.remote || + session.docker || + session.isAdopted + ) + return; try { const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects'); const hookPath = ctx.getTranscriptPath(session.id); @@ -3120,6 +3133,17 @@ export function registerSessionRoutes( // The body of GET /api/sessions/:id/last-response, so the Discord bridge reads the same transcript // the response viewer does (per-CLI reader, pane fallback) without a second copy of this logic. const lastResponseFor = async (session: Session, context?: string) => { + // ⚠️ An adopted session has no transcript Codeman can name. Every reader below pairs one by + // the id Codeman launched the CLI with or by the session's folder, and neither means anything + // here: the CLI chose its own id, and the folder is the neutral `/tmp`, so the newest file for + // that folder would be another conversation's answer shown as this one's. Nothing said yet is + // the honest answer. + if (session.isAdopted) { + return context === 'full' || context === 'turn' + ? { text: '', timestamp: '', messages: [] } + : { text: '', timestamp: '' }; + } + // Codex sessions don't write to ~/.claude/projects — their transcripts // live in ~/.codex/sessions/**. Branch to a Codex-specific reader so the // response-viewer works for Codex panes too. @@ -3953,8 +3977,11 @@ export function registerSessionRoutes( const { id } = req.params as { id: string }; const body = parseBody(GitFetchSettingsSchema, req.body, 'Invalid request body'); const session = findSessionOrFail(ctx, id, req); - if (session.remote || session.docker) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Git fetch does not run for remote or Docker sessions'); + if (session.remote || session.docker || session.isAdopted) { + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + 'Git fetch does not run for remote, Docker or adopted sessions' + ); } const enabled = 'enabled' in body ? (body.enabled ?? undefined) : session.gitFetchEnabled; @@ -4060,6 +4087,10 @@ export function registerSessionRoutes( if (body.enabled && session.remote) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Image watching does not run for remote sessions'); } + // An adopted session's folder is the neutral wrapper folder, not the foreign pane's work. + if (body.enabled && session.isAdopted) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Image watching does not run for adopted sessions'); + } if (body.enabled) { imageWatcher.watchSession(session.id, session.workingDir); @@ -6025,6 +6056,13 @@ export function registerSessionRoutes( 'Prompt uploads are not supported for remote (SSH) sessions' ); } + // An adopted session has no workspace of ours: its folder is the neutral `/tmp`, shared with + // whatever else runs there, and an agent in a container or on an ssh host could not read it. + // The foreign pane's own folder is not ours to write into either. + if (session.isAdopted) { + reply.code(400); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Prompt uploads are not supported for adopted sessions'); + } // The upload is written under session.workingDir (ensureUploadDir below), and // a live session's workspace can sit on a network mount that stopped answering. diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index 4623d75f..a3262add 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -1185,9 +1185,10 @@ export function registerSystemRoutes( // Handle image watcher toggle dynamically toggleService((merged.imageWatcherEnabled as boolean) ?? false, imageWatcher, 'Image watcher', () => { // Re-watch all active sessions that have image watcher enabled (never a remote session: its - // workingDir is a path on the remote host, not on this disk) + // workingDir is a path on the remote host, not on this disk; nor an adopted one, whose folder + // is the neutral wrapper folder and not the foreign pane's work) for (const session of ctx.sessions.values()) { - if (session.imageWatcherEnabled && !session.remote) { + if (session.imageWatcherEnabled && !session.remote && !session.isAdopted) { imageWatcher.watchSession(session.id, session.workingDir); } } diff --git a/src/web/server.ts b/src/web/server.ts index 36654339..9bd45f8d 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -552,9 +552,10 @@ export class WebServer extends EventEmitter { agentExited: (id) => !!this.sessions.get(id)?.paneExit, awaitingChoice: (id) => approvalInbox.listPending().some((item) => item.sessionId === id && item.kind !== 'idle'), // Remote (ssh) sessions keep their folder on another host, so received files have nowhere local to go. + // An adopted session's folder is the neutral wrapper folder, which its agent need not be able to read. sessionDir: (sessionId) => { const session = this.sessions.get(sessionId); - return session && !session.remote ? session.workingDir : undefined; + return session && !session.remote && !session.isAdopted ? session.workingDir : undefined; }, }); private teamWatcher: TeamWatcher = new TeamWatcher(); @@ -2281,6 +2282,9 @@ export class WebServer extends EventEmitter { ): Promise { const session = this.sessions.get(sessionId); if (!session) return; + // ⚠️ A link scanned from an adopted pane was printed by a process on another host or in a + // container, while confinement here would be to this host's `/tmp`, the wrapper's folder. + if (session.isAdopted) return; const event = source === 'codex-generated' ? await registerGeneratedArtifactAttachment({ diff --git a/test/agent-staging-session.test.ts b/test/agent-staging-session.test.ts index 38d2d852..ab964386 100644 --- a/test/agent-staging-session.test.ts +++ b/test/agent-staging-session.test.ts @@ -3,6 +3,7 @@ import { expect, it, vi } from 'vitest'; import { formatPaneSnapshot } from '../src/tmux-manager.js'; import { Session } from '../src/session.js'; import { promptSha256 } from '../src/agent-staging.js'; +import type { SessionAdopt } from '../src/types/session.js'; it('fences writes and reused-pid pane lifecycles without persisting prompt content', async () => { let screen = '› \n GPT-6-Luna default · ~/case\n'; const mux = { @@ -542,3 +543,37 @@ it('stages through a real session whose first post-write capture is the torn fra await session.stop(false); } }); + +it('offers no guarded input on an adopted pane, however much it looks like a composer', () => { + // Stage and submit type a prompt on an agent's behalf, here into a pane a human is using. + const screen = '\u203a \n GPT-6-Luna default \u00b7 ~/case\n'; + const mux = { + isAvailable: () => true, + capturePaneBuffer: ( + _name: string, + _pane?: string, + opts?: { capturedGeometry?: { cols: number; rows: number } } + ) => { + if (opts) opts.capturedGeometry = { cols: 100, rows: 20 }; + return formatPaneSnapshot(screen.trimEnd().split('\n'), { cols: 100, rows: 20, cursorX: 2, cursorY: 0 }); + }, + sendInput: async () => true, + }; + const build = (adopt?: SessionAdopt) => + new Session({ + workingDir: '/tmp', + mode: 'codex', + mux, + adopt, + muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 12345 }, + } as unknown as ConstructorParameters[0]); + expect(build().guardedInputSnapshot()).not.toBeNull(); + expect( + build({ + location: 'local', + socketPath: '/tmp/tmux-1000/default', + targetSession: 'work', + viewSession: 'codeman-view-00000000', + }).guardedInputSnapshot() + ).toBeNull(); +}); diff --git a/test/edit-history-sync.test.ts b/test/edit-history-sync.test.ts index 60cd745b..91a7e251 100644 --- a/test/edit-history-sync.test.ts +++ b/test/edit-history-sync.test.ts @@ -164,6 +164,7 @@ describe('unsupportedReason', () => { expect(unsupportedReason(local)).toBeUndefined(); expect(unsupportedReason(remote)).toBe('remote'); expect(unsupportedReason({ ...local, docker: { image: 'x' } })).toBe('docker'); + expect(unsupportedReason({ ...local, adopt: { location: 'local' } })).toBe('adopted'); expect(unsupportedReason({ ...local, mode: 'shell' })).toBe('mode'); }); }); diff --git a/test/foreign-adoption-guards.test.ts b/test/foreign-adoption-guards.test.ts index dbf87b85..bceaad7b 100644 --- a/test/foreign-adoption-guards.test.ts +++ b/test/foreign-adoption-guards.test.ts @@ -38,7 +38,7 @@ describe('the boot workspace-hook sweep', () => { beforeEach(() => { applyWorkspaceHooks.mockClear(); - server = new WebServer({ port: 3197 }); + server = new WebServer(0); (server as unknown as { getWorkspaceHooksEnabled: () => Promise }).getWorkspaceHooksEnabled = async () => true; }); diff --git a/test/git-fetch-scheduler.test.ts b/test/git-fetch-scheduler.test.ts index 3c7add06..92832834 100644 --- a/test/git-fetch-scheduler.test.ts +++ b/test/git-fetch-scheduler.test.ts @@ -263,6 +263,8 @@ describe('who runs', () => { const h = harness([ VIEW('ssh', { remote: { host: 'x' } }), VIEW('docker', { docker: { container: 'c' } }), + // A wrapper around a foreign tmux session sits on the neutral `/tmp`, no repository of its own. + VIEW('adopted', { adopt: { location: 'local' } }), VIEW('local'), ]); await h.scheduler.tick(0); diff --git a/test/paste-image-gc.test.ts b/test/paste-image-gc.test.ts index 37241d07..203ea8af 100644 --- a/test/paste-image-gc.test.ts +++ b/test/paste-image-gc.test.ts @@ -30,6 +30,16 @@ describe('sweepPasteImagesOnce', () => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); }); + it('lists nothing for an adopted session, whose folder is the /tmp others may work in', async () => { + // The delete path removes what this lists, recursively: closing an adopted tab + // must not take the uploads of a real session sitting in that same folder. + const workingDir = mkdtempSync(join(tmpdir(), 'codeman-gc-')); + dirs.push(workingDir); + mkdirSync(join(workingDir, UPLOADS_DIR)); + expect(await uploadDirs({ workingDir })).toHaveLength(1); + expect(await uploadDirs({ workingDir, adopt: { location: 'local' } })).toEqual([]); + }); + it('ages paste-* files out of both the current and the legacy upload dir', async () => { const workingDir = mkdtempSync(join(tmpdir(), 'codeman-gc-')); dirs.push(workingDir); diff --git a/test/routes/foreign-adoption-routes.test.ts b/test/routes/foreign-adoption-routes.test.ts index 0c4e5cde..ba1eee51 100644 --- a/test/routes/foreign-adoption-routes.test.ts +++ b/test/routes/foreign-adoption-routes.test.ts @@ -14,6 +14,8 @@ * * Port: none (app.inject()). */ +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; // The adopt route RE-RESOLVES the candidate rather than trusting the body, and @@ -37,6 +39,7 @@ import { registerSessionRoutes } from '../../src/web/routes/session-routes.js'; import { registerRespawnRoutes } from '../../src/web/routes/respawn-routes.js'; import { registerRalphRoutes } from '../../src/web/routes/ralph-routes.js'; import { registerMuxRoutes } from '../../src/web/routes/mux-routes.js'; +import { registerGitStatusRoutes } from '../../src/web/routes/git-status-routes.js'; import { adoptTargetIdentity, foreignTargetKey } from '../../src/foreign-tmux.js'; import type { SessionAdopt } from '../../src/types/session.js'; @@ -377,3 +380,109 @@ describe('same-named sessions in different locations', () => { await app.close(); }); }); + +describe('an adopted session has no workspace of ours', () => { + // Its `workingDir` is the neutral `/tmp`, which local-looking code would take for + // the session's folder: uploads landed there and the tab's close deleted them, and + // a Git panel or fetch ran git on whatever sat there. Each refusal is paired with a + // plain session, or a broken route would pass it too. + const adopted = (s: { isAdopted: boolean; adopt?: SessionAdopt }) => { + s.isAdopted = true; + s.adopt = localAdopt('/tmp/tmux-1000/default', 'work'); + }; + + it('refuses a prompt upload before touching disk', async () => { + const { app, ctx } = await createRouteTestHarness(registerSessionRoutes); + adopted(ctx._session); + const res = await app.inject({ + method: 'POST', + url: `/api/sessions/${ctx._sessionId}/paste-image`, + headers: { 'x-codeman-csrf': '1' }, + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toMatch(/adopted/); + await app.close(); + }); + + it('refuses the image watcher, and leaves it alone being switched off', async () => { + const { app, ctx } = await createRouteTestHarness(registerSessionRoutes); + adopted(ctx._session); + const post = (enabled: boolean) => + app.inject({ method: 'POST', url: `/api/sessions/${ctx._sessionId}/image-watcher`, payload: { enabled } }); + expect((await post(true)).json().success).toBe(false); + expect((await post(false)).json().success).toBe(true); + await app.close(); + }); + + it('refuses the per-session git fetch settings', async () => { + const { app, ctx } = await createRouteTestHarness(registerSessionRoutes); + adopted(ctx._session); + const res = await app.inject({ + method: 'POST', + url: `/api/sessions/${ctx._sessionId}/git-fetch-settings`, + payload: { enabled: true }, + }); + expect(res.json().success).toBe(false); + expect(res.json().error).toMatch(/adopted/); + await app.close(); + }); + + it('refuses a custom model, which restarts the CLI in the pane', async () => { + const { app, ctx } = await createRouteTestHarness(registerSessionRoutes); + adopted(ctx._session); + const res = await app.inject({ + method: 'POST', + url: `/api/sessions/${ctx._sessionId}/custom-model`, + payload: { clear: true }, + }); + expect(res.json().success).toBe(false); + expect(res.json().error).toMatch(/adopted/); + await app.close(); + }); + + it('answers unsupported on the git routes, running no git', async () => { + const git = vi.fn(async () => ({ stdout: '', stderr: '' })); + const { app, ctx } = await createRouteTestHarness((a, c) => + registerGitStatusRoutes(a, c, git as never, async () => []) + ); + adopted(ctx._session); + const status = await app.inject({ method: 'GET', url: `/api/sessions/${ctx._sessionId}/git-status` }); + expect(status.json()).toMatchObject({ success: true, data: { state: 'unsupported', reason: 'adopted' } }); + const fetch = await app.inject({ method: 'POST', url: `/api/sessions/${ctx._sessionId}/git-fetch` }); + expect(fetch.json()).toMatchObject({ success: true, data: { state: 'unsupported', reason: 'adopted' } }); + const diff = await app.inject({ method: 'GET', url: `/api/sessions/${ctx._sessionId}/git-diff?repo=x&path=y&kind=z` }); + expect(diff.statusCode).toBe(400); + expect(git).not.toHaveBeenCalled(); + await app.close(); + }); + + describe('last-response', () => { + let project: string; + beforeEach(() => { + project = join(process.env.HOME!, '.claude', 'projects', '-tmp'); + mkdirSync(project, { recursive: true }); + }); + afterEach(() => { + rmSync(project, { recursive: true, force: true }); + }); + + it('never pairs a transcript by the wrapper folder or the id we launched nothing with', async () => { + const { app, ctx } = await createRouteTestHarness(registerSessionRoutes); + (ctx._session as unknown as { mode: string }).mode = 'claude'; + writeFileSync( + join(project, `${ctx._sessionId}.jsonl`), + JSON.stringify({ + type: 'assistant', + timestamp: '2026-07-21T00:00:01Z', + message: { content: [{ type: 'text', text: 'an answer from another conversation' }] }, + }) + ); + const read = async () => + (await app.inject({ method: 'GET', url: `/api/sessions/${ctx._sessionId}/last-response` })).json(); + expect((await read()).text).toBe('an answer from another conversation'); + adopted(ctx._session); + expect((await read()).text).toBe(''); + await app.close(); + }); + }); +});