feat(files): follow the agent into a worktree outside the session folder

#772 made the Files panel follow an agent into a worktree inside the
session's folder. A worktree beside the repository (git worktree add
../feature, or an EnterWorktree into one that already exists there)
stayed unfollowed, since serving it needs a second confinement root.

findOutsideWorktree() answers that case without running git. The
nearest .git above the pane must name <common dir>/worktrees/<id> of the
session's own repository, and that registry entry must name the
candidate back (or the .git folder must be the common dir itself). It
refuses /, the home folder and above, any folder holding workingDir and
Docker case workspaces, and multi-user mode applies the owner's
folderAllowedForUser rule. The answer is liveWorktree with outside: true
and the folder's real path.

The file routes serve an absolute path inside that worktree, and only
while the folder still resolves to the recorded path. A relative path
still resolves against workingDir alone. The browser routes such paths
to the editable preview, and root-relative markdown refs resolve from
the top of the worktree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Michael GrundbergandClaude Opus 5.5 committed 2026-11-06 17:54:59 +01:00
1 parent e9b2ecd552
commit c9b79097b0
18 files changed
+1030 -56

No files matched your search

+26 -4
View File
@@ -35,7 +35,7 @@ import fastifyWebsocket from '@fastify/websocket';
import fastifyMultipart from '@fastify/multipart';
import { pasteImageDirInUseByOtherSession, startPasteImageGc, uploadDirs } from './paste-image-gc.js';
import { CLEAN_EXIT_CLOSE_REASON, shouldCloseCleanlyExitedSession } from '../pane-exit-sweep.js';
import { findLiveWorktree } from '../live-worktree.js';
import { findLiveWorktree, type OutsideWorktreeOptions } from '../live-worktree.js';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { existsSync, mkdirSync, readFileSync, chmodSync, statSync } from 'node:fs';
@@ -80,7 +80,7 @@ import {
import { imageWatcher } from '../image-watcher.js';
import { workflowRunWatcher, summarizeRun } from '../workflow-run-watcher.js';
import { attachmentRegistry, buildFileThumbnailRoute, registerExternalAttachment } from '../attachment-registry.js';
import { folderGrants } from '../folder-listing.js';
import { folderAllowedForUser, folderGrants } from '../folder-listing.js';
import { getCli, enabledClis, listClis } from '../config/cli-registry/registry.js';
import { isCliEntryInstalled, probeStockCliAvailability } from '../utils/cli-installed-probes.js';
import { readCustomModelHosts, resolveCustomModelContextLength } from '../custom-model-hosts.js';
@@ -213,7 +213,7 @@ import {
sendLostWebviewFramePage,
} from './middleware/auth.js';
import { isMultiUserMode } from '../config/multiuser.js';
import { bootstrapInitialAdmin, hasUsers, resolveClaudeModeForUsername } from '../user-store.js';
import { bootstrapInitialAdmin, findUser, hasUsers, resolveClaudeModeForUsername } from '../user-store.js';
import { installRouteErrorHandler } from './route-error-handler.js';
import { installEmptyApiPostParser } from './request-body-parser.js';
import { isExplicitlyEnabled, isLoopbackBindHost, buildHostPolicy, type HostPolicy } from './network-auth-policy.js';
@@ -3021,11 +3021,33 @@ export class WebServer extends EventEmitter {
*/
private async refreshLiveWorktree(session: Session): Promise<void> {
const cwd = session.paneCwd;
const worktree = await findLiveWorktree(session.workingDir, cwd);
const worktree = await findLiveWorktree(session.workingDir, cwd, this.outsideWorktreeOptions(session));
if (session.paneCwd !== cwd) return;
if (session.setLiveWorktree(worktree)) this.broadcastSessionStateDebounced(session.id);
}
/**
* The rules a worktree OUTSIDE the session's folder must pass to be followed (#765 follow-up):
* none in a Docker case workspace (the list is read only when a candidate needs it, and an
* unreadable list follows nothing), and in multi-user mode only inside the owner's space or
* `workingDir`. Undefined for a remote or Docker session, whose pane directory is not a path the
* agent works in on this host (it has no `paneCwd` either, so this is belt and braces).
*/
private outsideWorktreeOptions(session: Session): OutsideWorktreeOptions | undefined {
if (session.remote || session.docker) return undefined;
return {
dockerWorkspaces: defaultDockerWorkspaces,
...(isMultiUserMode() && {
allowRoot: async (root: string): Promise<boolean> => {
const owner = session.owner ? await findUser(session.owner) : undefined;
return folderAllowedForUser(root, owner ? { username: owner.username, role: owner.role } : undefined, {
workingDir: session.workingDir,
});
},
}),
};
}
/**
* Close every session whose agent exited cleanly, through the same
* `cleanupSession()` the X button uses (Ark0N/Codeman#446). A pinned session