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 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 03:13:52 +01:00
1 parent f464a22bc3
commit 7de4201955
22 files changed
+269 -42

No files matched your search

+3 -1
View File
@@ -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<string, GroupMember[]>();
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);
+1 -1
View File
@@ -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[];
+2 -2
View File
@@ -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. */
+5 -2
View File
@@ -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
)
+3
View File
@@ -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;
}
+5 -3
View File
@@ -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<string[]> {
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[] = [];
+5 -3
View File
@@ -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();
+23 -17
View File
@@ -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);
},
+1 -1
View File
@@ -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');
},
+1 -1
View File
@@ -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);
+1
View File
@@ -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(),
};
+8 -2
View File
@@ -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.
+4 -2
View File
@@ -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);
+41 -3
View File
@@ -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<void> {
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.
+3 -2
View File
@@ -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);
}
}
+5 -1
View File
@@ -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<void> {
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({
+35
View File
@@ -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<typeof Session>[0]);
expect(build().guardedInputSnapshot()).not.toBeNull();
expect(
build({
location: 'local',
socketPath: '/tmp/tmux-1000/default',
targetSession: 'work',
viewSession: 'codeman-view-00000000',
}).guardedInputSnapshot()
).toBeNull();
});
+1
View File
@@ -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');
});
});
+1 -1
View File
@@ -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<boolean> }).getWorkspaceHooksEnabled = async () =>
true;
});
+2
View File
@@ -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);
+10
View File
@@ -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);
+109
View File
@@ -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();
});
});
});