fix: refuse a case folder move while a session is still being created in it (release review)

The rename lock is checked once, in the Session constructor, but a session
reaches the server's map (where the rename route builds its list of open
sessions) only later, after the create awaits the owner's layout lock and a
fresh settings read (and, for POST /api/sessions, inferLocalCaseName). A
rename in that gap neither listed nor stopped the session, moved the folder,
and the create then registered a session whose pane starts with
`cd "<old path>"`, skipping the 409 requiresStop / stopSessions guarantee.

- case-rename.ts keeps a record of sessions constructed but not yet
  registered (`noteCaseSessionStarting` / `caseSessionSettled` /
  `isCaseSessionStarting`), with a 30 s TTL for one that never registers
  (a failed create, a one-shot prompt), swept on every note and check.
- The Session constructor records itself (local sessions only, as the
  route ignores remote ones); registerSessionWithLayout()'s register
  callback, the boot-restore registration and stop() settle it.
- The rename route answers 409 CONFLICT with renameCode BUSY ("A session is
  starting in this case") for a folder move while one is recorded at or
  under the folder. The check runs in the same tick that takes the lock, so
  every interleaving is either refused here or by the constructor. An
  alias-only rename is not refused.

A create whose path was validated before the move but whose Session is
constructed after it is still the ordinary check-then-use race of any
create path; CLAUDE.md now says so instead of "nothing starts mid-move".

Tests: unit (record, settle, stop, TTL), route (BUSY, then requiresStop once
registered; alias-only unaffected) and an end-to-end WebServer test that
holds the create between constructor and registration (it fails without
the route check: the folder moves under the session).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-24 16:57:20 +02:00
1 parent 40158ce1ef
commit f8029c2a9e
10 files changed
+238 -7

No files matched your search

+9
View File
@@ -55,6 +55,7 @@ import { generateClaudeMd } from '../../templates/claude-md.js';
import { prepareNewCasePath } from '../case-path.js';
import {
CaseRenameError,
isCaseSessionStarting,
moveCaseFolder,
moveClaudeHistory,
renameInOrder,
@@ -1848,6 +1849,14 @@ export function registerCaseRoutes(
if (open.some((s) => !canAccessOwned(user, s.owner))) {
return fail(403, ApiErrorCode.FORBIDDEN, "Another user's session has this case open");
}
// A session being created in the folder is not in ctx.sessions yet, so it could be neither listed nor
// stopped: it would start in the old path after the move. Checked in the same tick that takes the lock
// below (nothing is awaited in between), and the Session constructor refuses the folder from then on.
if (moveFolder && isCaseSessionStarting(oldPath)) {
return fail(409, ApiErrorCode.CONFLICT, 'A session is starting in this case. Try again in a moment.', {
renameCode: 'BUSY',
});
}
if (open.length > 0 && !body.stopSessions) {
return fail(
409,
+4
View File
@@ -242,6 +242,7 @@ import {
} from './routes/index.js';
import { isLostWebviewFrameNavigation } from './webview-proxy.js';
import { CronService } from '../cron/cron-service.js';
import { caseSessionSettled } from '../case-rename.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -806,6 +807,8 @@ export class WebServer extends EventEmitter {
private async registerSessionWithLayout(session: Session, placement?: SessionPlacementHint): Promise<TabLayout> {
return this.tabLayouts.sessionCreated(ownerLayoutKey(session.owner), session.id, placement, () => {
this.sessions.set(session.id, session);
// In the map now, so a case rename lists (and on request stops) it instead of refusing the move.
caseSessionSettled(session);
return () => {
if (this.sessions.get(session.id) === session) this.sessions.delete(session.id);
};
@@ -3957,6 +3960,7 @@ export class WebServer extends EventEmitter {
}
this.sessions.set(session.id, session);
caseSessionSettled(session);
await this.setupSessionListeners(session);
// Auto-attach PTY to the surviving tmux session immediately.