mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
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:
1 parent
e9b2ecd552
commit
c9b79097b0
18 files changed
+1030
-56
No files matched your search
@@ -4264,7 +4264,24 @@ Object.assign(CodemanApp.prototype, {
|
||||
const workingDir = this.sessions.get(sessionId)?.workingDir;
|
||||
if (!workingDir) return false;
|
||||
const root = workingDir.endsWith('/') ? workingDir : `${workingDir}/`;
|
||||
return filePath !== workingDir && !filePath.startsWith(root);
|
||||
if (filePath === workingDir || filePath.startsWith(root)) return false;
|
||||
// A worktree outside `workingDir` that the agent is in (#765) is a second root the file
|
||||
// routes serve, so a path inside it previews (and edits) like a workspace path.
|
||||
return this._isOutsideWorktreePath?.(filePath, sessionId) !== true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether an absolute path lies inside the worktree OUTSIDE `workingDir` that the session's agent
|
||||
* is in (`liveWorktree.outside`, #765). The file routes serve it as a second root, while the log
|
||||
* viewer's `tail-file` stays confined to `workingDir`, so the terminal opens such a path in the
|
||||
* file preview.
|
||||
*/
|
||||
_isOutsideWorktreePath(filePath, sessionId) {
|
||||
if (typeof filePath !== 'string' || !filePath.startsWith('/')) return false;
|
||||
const worktree = this.sessions?.get?.(sessionId)?.liveWorktree;
|
||||
if (worktree?.outside !== true || typeof worktree.path !== 'string' || !worktree.path) return false;
|
||||
const root = worktree.path.endsWith('/') ? worktree.path : `${worktree.path}/`;
|
||||
return filePath === worktree.path || filePath.startsWith(root);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -5197,6 +5214,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
_rebaseFilePreviewMarkdownRefs(root, { sessionId, filePath, attachmentId }) {
|
||||
const dir = filePath.includes('/') ? filePath.slice(0, filePath.lastIndexOf('/') + 1) : '';
|
||||
const unresolvable = !!attachmentId && !filePath.startsWith('/');
|
||||
// A root-relative ref (`/docs/x.png`) resolves from the top of the checkout the file is in, as
|
||||
// on GitHub: the workspace, or the worktree outside it that the agent is in (#765).
|
||||
const worktree = this.sessions?.get?.(sessionId)?.liveWorktree;
|
||||
const rootPrefix =
|
||||
worktree?.outside === true && this._isOutsideWorktreePath?.(filePath, sessionId)
|
||||
? `${worktree.path.replace(/\/+$/, '')}/`
|
||||
: '';
|
||||
// Workspace ref = no scheme, not protocol-relative (//host), not a fragment.
|
||||
const isWorkspaceRef = (ref) =>
|
||||
!!ref && !/^[a-z][a-z0-9+.-]*:/i.test(ref) && !ref.startsWith('//') && !ref.startsWith('#');
|
||||
@@ -5216,7 +5240,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
/* malformed escape: keep the ref as written */
|
||||
}
|
||||
const parts = [];
|
||||
for (const seg of (rel.startsWith('/') ? rel.slice(1) : dir + rel).split('/')) {
|
||||
for (const seg of (rel.startsWith('/') ? rootPrefix + rel.slice(1) : dir + rel).split('/')) {
|
||||
if (seg === '.' || (seg === '' && parts.length)) continue;
|
||||
if (seg === '..' && parts.length && parts[parts.length - 1] !== '..' && parts[parts.length - 1] !== '') parts.pop();
|
||||
else parts.push(seg);
|
||||
|
||||
@@ -2367,7 +2367,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
// An HTML file renders as a page in the preview, never as source
|
||||
// in the log viewer (markdown is in FILE_PREVIEW_EXTENSIONS).
|
||||
if (previewsInFileViewer(target) || rendersAsPage(target) || self._isExternalPreviewPath(target, sessionId)) {
|
||||
// The log viewer's tail-file stays confined to workingDir, so a path in a worktree outside it previews.
|
||||
if (
|
||||
previewsInFileViewer(target) ||
|
||||
rendersAsPage(target) ||
|
||||
self._isExternalPreviewPath(target, sessionId) ||
|
||||
self._isOutsideWorktreePath?.(target, sessionId)
|
||||
) {
|
||||
self.openFilePreview(target, sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -101,6 +101,42 @@ export function validateSessionFilePath(
|
||||
return confineToRoot(resolvedWorkingDir, resolvedPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* The outside worktree (#765) a LOCAL session's file routes may serve `filePath` from, else null:
|
||||
* a recorded worktree with `outside` set, an absolute `filePath` lexically inside it, and a
|
||||
* folder that still resolves to the very path that was recorded. `findOutsideWorktree()` records
|
||||
* the real path, so a symlink that took the folder's place (or a parent's) since then fails the
|
||||
* last check and the path is judged against `workingDir` alone.
|
||||
*/
|
||||
export function outsideWorktreeRootFor(
|
||||
liveWorktree: { path: string; outside?: true } | undefined,
|
||||
filePath: string
|
||||
): string | null {
|
||||
if (!liveWorktree?.outside || !isAbsolute(filePath)) return null;
|
||||
const root = liveWorktree.path;
|
||||
const rel = relative(root, resolve(filePath));
|
||||
if (rel.startsWith('..') || isAbsolute(rel)) return null;
|
||||
try {
|
||||
return realpathSync(root) === root ? root : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link validateSessionFilePath} for a LOCAL session that may have a worktree outside its folder
|
||||
* (`liveWorktree.outside`): an absolute path inside that worktree is confined to it, and, when
|
||||
* that refuses it (a worktree file that is a symlink into the session's own folder), to
|
||||
* `workingDir`, which is served anyway. Every other path is judged against `workingDir` alone.
|
||||
*/
|
||||
export function validateLocalSessionFilePath(
|
||||
scope: { workingDir: string; liveWorktree?: { path: string; outside?: true } },
|
||||
filePath: string
|
||||
): { resolvedPath: string; relativePath: string } | null {
|
||||
const root = outsideWorktreeRootFor(scope.liveWorktree, filePath);
|
||||
return (root && validateSessionFilePath(root, filePath)) || validateSessionFilePath(scope.workingDir, filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* The lexical half of {@link validateSessionFilePath}: same containment rule, but
|
||||
* WITHOUT touching the filesystem.
|
||||
|
||||
@@ -58,11 +58,13 @@ import {
|
||||
getAuthUser,
|
||||
parseBody,
|
||||
resolveCasesDir,
|
||||
validateLocalSessionFilePath,
|
||||
validateSessionFilePath,
|
||||
validateSessionFilePathLexical,
|
||||
} from '../route-helpers.js';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import type { SessionAttachmentHistoryItem, SessionRemote, SessionState } from '../../types/session.js';
|
||||
import type { LiveWorktree } from '../../live-worktree.js';
|
||||
import {
|
||||
RemoteFileAccessError,
|
||||
remoteCreateReadStream,
|
||||
@@ -574,6 +576,11 @@ function getKnownSessionWorkingDir(
|
||||
/** Where a session's files live: this host, or a host reachable over ssh. */
|
||||
interface SessionFileScope {
|
||||
workingDir: string;
|
||||
/**
|
||||
* A live local session's worktree (#765). Only one with `outside` set changes anything here: it
|
||||
* is a second root that ABSOLUTE paths inside it are confined to (`validateLocalSessionFilePath`).
|
||||
*/
|
||||
liveWorktree?: LiveWorktree;
|
||||
remote?: SessionRemote;
|
||||
/** The session runs inside a Docker case: a local session for these routes, but `~` is the container's. */
|
||||
docker?: boolean;
|
||||
@@ -606,7 +613,12 @@ function getKnownSessionFileScope(
|
||||
const user = getAuthUser(req);
|
||||
const liveSession = ctx.sessions.get(sessionId);
|
||||
if (liveSession && canAccessOwned(user, liveSession.owner)) {
|
||||
return { workingDir: liveSession.workingDir, remote: liveSession.remote, docker: !!liveSession.docker };
|
||||
return {
|
||||
workingDir: liveSession.workingDir,
|
||||
liveWorktree: liveSession.liveWorktree,
|
||||
remote: liveSession.remote,
|
||||
docker: !!liveSession.docker,
|
||||
};
|
||||
}
|
||||
|
||||
const stored = ctx.store.getSession(sessionId);
|
||||
@@ -657,7 +669,7 @@ type FileTargetResolution =
|
||||
*/
|
||||
async function resolveFileTarget(scope: SessionFileScope, filePath: string): Promise<FileTargetResolution> {
|
||||
if (!scope.remote) {
|
||||
const validated = validateSessionFilePath(scope.workingDir, filePath);
|
||||
const validated = validateLocalSessionFilePath(scope, filePath);
|
||||
if (!validated) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -876,6 +888,21 @@ async function canWalkLiveWorktree(workingDir: string, worktreePath: string): Pr
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a `root=live` listing may start at a worktree OUTSIDE `workingDir` (`liveWorktree.outside`).
|
||||
* `findOutsideWorktree()` recorded its real path, and a symlink can take the folder's place, or a
|
||||
* parent's, without moving the pane, so the folder must still resolve to exactly that path. Probed
|
||||
* first (#516).
|
||||
*/
|
||||
async function canWalkOutsideWorktree(worktreePath: string): Promise<boolean> {
|
||||
if ((await probePathKind(worktreePath, { pastCap: true })) !== 'directory') return false;
|
||||
try {
|
||||
return (await fs.realpath(worktreePath)) === worktreePath;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function findMatchingPickerRoot(roots: FilesystemBrowseRoot[], candidate: string): FilesystemBrowseRoot | undefined {
|
||||
return roots
|
||||
.filter((root) => isPathWithinRoot(root.path, candidate))
|
||||
@@ -1708,11 +1735,20 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
// `workingDir`, so file-content, file-raw and the edit route need nothing new and judge these
|
||||
// paths against the same root as any other. Only the search predicate reads a path relative
|
||||
// to the listing, since that is the tree the person is looking at.
|
||||
// A worktree OUTSIDE `workingDir` (`liveWorktree.outside`) is a second root: walked only while
|
||||
// it still resolves to the real path recorded for it, and listed with ABSOLUTE `path`s, since a
|
||||
// relative one resolves against `workingDir` on every other route. Those routes confine an
|
||||
// absolute path inside it to it (`validateLocalSessionFilePath`).
|
||||
const liveWorktree = session.liveWorktree;
|
||||
const walkRoot =
|
||||
root === 'live' && liveWorktree && (await canWalkLiveWorktree(workingDir, liveWorktree.path))
|
||||
? liveWorktree.path
|
||||
: workingDir;
|
||||
const walksOutside =
|
||||
root === 'live' && !!liveWorktree?.outside && (await canWalkOutsideWorktree(liveWorktree.path));
|
||||
const walksInside =
|
||||
root === 'live' &&
|
||||
!!liveWorktree &&
|
||||
!liveWorktree.outside &&
|
||||
(await canWalkLiveWorktree(workingDir, liveWorktree.path));
|
||||
const walkRoot = liveWorktree && (walksOutside || walksInside) ? liveWorktree.path : workingDir;
|
||||
const nodePath = (fullPath: string): string => (walksOutside ? fullPath : fullPath.slice(workingDir.length + 1));
|
||||
// null for an empty/whitespace query, which is what keeps the default
|
||||
// tree response byte-identical when no search is requested.
|
||||
const matcher = compileFileQuery(q ?? '');
|
||||
@@ -1802,7 +1838,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
while (cursor.index < endIndex && totalFiles + totalDirectories < maxFiles && matches.length < maxMatches) {
|
||||
const entry = cursor.entries[cursor.index++];
|
||||
const fullPath = join(cursor.dirPath, entry.name);
|
||||
const relativePath = relative(workingDir, fullPath);
|
||||
const relativePath = walksOutside ? fullPath : relative(workingDir, fullPath);
|
||||
const listedPath = relative(walkRoot, fullPath);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
@@ -1893,7 +1929,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
while (cursor.index < endIndex && totalFiles + totalDirectories < maxFiles) {
|
||||
const entry = cursor.entries[cursor.index++];
|
||||
const fullPath = join(cursor.dirPath, entry.name);
|
||||
const relativePath = fullPath.slice(workingDir.length + 1);
|
||||
const relativePath = nodePath(fullPath);
|
||||
if (entry.isDirectory()) {
|
||||
totalDirectories++;
|
||||
const node: FileTreeNode = {
|
||||
@@ -1958,7 +1994,10 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
// Validate path is within working directory (security: resolve symlinks to prevent traversal)
|
||||
// For a remote (SSH) case the same boundary is resolved on the remote host (#415),
|
||||
// where the path actually lives.
|
||||
const resolution = await resolveFileTarget({ workingDir: session.workingDir, remote: session.remote }, filePath);
|
||||
const resolution = await resolveFileTarget(
|
||||
{ workingDir: session.workingDir, liveWorktree: session.liveWorktree, remote: session.remote },
|
||||
filePath
|
||||
);
|
||||
if (!resolution.ok) {
|
||||
// An unreachable remote is thrown so the shared handler answers a real 502 (and
|
||||
// the test can assert it); a refusal keeps this route's historical returned
|
||||
@@ -2230,7 +2269,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
throwFileEditError(413, ApiErrorCode.INVALID_INPUT, `Content too large (${MAX_EDITABLE_BYTES / 1024}KB limit)`);
|
||||
}
|
||||
|
||||
const validated = validateSessionFilePath(session.workingDir, body.path);
|
||||
const validated = validateLocalSessionFilePath(session, body.path);
|
||||
if (!validated) {
|
||||
// Covers missing files, traversal, and symlink escapes alike — a write
|
||||
// target that fails confinement is reported identically to a missing
|
||||
@@ -2333,7 +2372,10 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
}
|
||||
|
||||
// Validate path is within working directory (security: resolve symlinks to prevent traversal)
|
||||
const resolution = await resolveFileTarget({ workingDir: session.workingDir, remote: session.remote }, filePath);
|
||||
const resolution = await resolveFileTarget(
|
||||
{ workingDir: session.workingDir, liveWorktree: session.liveWorktree, remote: session.remote },
|
||||
filePath
|
||||
);
|
||||
if (!resolution.ok) {
|
||||
reply.code(resolution.status).send(createErrorResponse(resolution.errorCode, resolution.message));
|
||||
return;
|
||||
@@ -2972,7 +3014,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
}
|
||||
|
||||
const session = findSessionOrFail(ctx, sessionId, req);
|
||||
const validated = validateSessionFilePath(session.workingDir, filePath);
|
||||
const validated = validateLocalSessionFilePath(session, filePath);
|
||||
if (!validated) {
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
|
||||
return;
|
||||
|
||||
@@ -33,7 +33,7 @@ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep }
|
||||
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
||||
import { isBlockedAttachmentPath, isUnderTree, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
|
||||
import { HTML_VIEW_PREFIX, htmlViewCapabilities } from '../../html-view-capabilities.js';
|
||||
import { CASES_DIR, findSessionOrFail, ownerFor, parseBody, validateSessionFilePath } from '../route-helpers.js';
|
||||
import { CASES_DIR, findSessionOrFail, ownerFor, parseBody, validateLocalSessionFilePath } from '../route-helpers.js';
|
||||
import { HtmlViewOpenSchema } from '../schemas.js';
|
||||
import { DOCKER_HOME_PATH_ERROR, localFileSource, sendFileBody } from './file-routes.js';
|
||||
import { getUserSpacesDir } from '../../config/multiuser.js';
|
||||
@@ -175,7 +175,7 @@ export function registerHtmlViewRoutes(app: FastifyInstance, ctx: SessionPort):
|
||||
// Inside the workspace: the same check every file route uses. Outside it: the
|
||||
// attachment guard, exactly as an external file preview is admitted.
|
||||
let resolvedPath: string;
|
||||
const inWorkspace = validateSessionFilePath(session.workingDir, requestedPath);
|
||||
const inWorkspace = validateLocalSessionFilePath(session, requestedPath);
|
||||
if (inWorkspace) {
|
||||
resolvedPath = inWorkspace.resolvedPath;
|
||||
} else {
|
||||
|
||||
+26
-4
@@ -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
|
||||
|
||||
Reference in new issue
Block a user