Terminal sessions can now start from a selected safe project worktree. - Add a Terminal modal worktree selector with searchable branch/path context and responsive styling. - Thread worktree selection through session hooks, terminal routes, and terminal service creation. - Centralize safe git worktree discovery and reuse it from git and terminal routes. - Cover the picker, hook behavior, service worktree validation, and route wiring with tests. - Document the terminal worktree picker and add a release changeset. Files changed: .changeset/fn-7253-terminal-worktree-picker.md | 7 + docs/dashboard-guide.md | 13 +- .../dashboard/app/components/TerminalModal.css | 189 +++++++++++++++- .../dashboard/app/components/TerminalModal.tsx | 190 ++++++++++++++++- .../components/__tests__/TerminalModal.test.tsx | 169 +++++++++++++++ .../hooks/__tests__/useTerminalSessions.test.ts | 237 ++++++++++++++++++++- .../dashboard/app/hooks/useTerminalSessions.ts | 59 +++-- .../src/__tests__/routes-automation.test.ts | 37 ++-- .../src/__tests__/terminal-service.test.ts | 125 +++++++++++ packages/dashboard/src/git-worktree-safety.ts | 113 ++++++++++ .../dashboard/src/routes/register-git-github.ts | 54 +---- .../src/routes/register-messaging-scripts.ts | 1 + .../src/routes/register-terminal-routes.ts | 1 + packages/dashboard/src/terminal-service.ts | 66 ++++-- 14 files changed, 1157 insertions(+), 104 deletions(-) Fusion-Task-Id: FN-7253 Fusion-Task-Lineage: 6a0c0b4f-74cc-42b3-8bbd-2ca94c4c26d9 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
114 lines
4.0 KiB
TypeScript
114 lines
4.0 KiB
TypeScript
import { isAbsolute, relative, resolve } from "node:path";
|
|
import type { TaskStore } from "@fusion/core";
|
|
import { badRequest } from "./api-error.js";
|
|
import { runGitCommand } from "./routes/resolve-diff-base.js";
|
|
|
|
export function isPathWithin(parent: string, candidate: string): boolean {
|
|
const rel = relative(parent, candidate);
|
|
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
}
|
|
|
|
export async function listRegisteredWorktreePaths(rootDir: string): Promise<string[]> {
|
|
const output = await runGitCommand(["worktree", "list", "--porcelain"], rootDir, 10_000);
|
|
const paths: string[] = [];
|
|
for (const line of output.split("\n")) {
|
|
if (!line.startsWith("worktree ")) continue;
|
|
const worktreePath = line.slice("worktree ".length).trim();
|
|
if (!worktreePath) continue;
|
|
paths.push(resolve(worktreePath));
|
|
}
|
|
return paths;
|
|
}
|
|
|
|
export async function assertWorktreePathSafe(
|
|
scopedStore: Pick<TaskStore, "getRootDir">,
|
|
worktreePath: string,
|
|
cache: Map<string, string[]>,
|
|
): Promise<string> {
|
|
if (typeof worktreePath !== "string" || worktreePath.trim().length === 0) {
|
|
throw badRequest("worktreePath is required");
|
|
}
|
|
|
|
if (!isAbsolute(worktreePath)) {
|
|
throw badRequest("worktreePath must be an absolute path");
|
|
}
|
|
const rootDir = resolve(scopedStore.getRootDir());
|
|
const resolved = resolve(worktreePath);
|
|
if (resolved !== worktreePath) {
|
|
throw badRequest("worktreePath must be normalized");
|
|
}
|
|
if (isPathWithin(rootDir, resolved)) {
|
|
return resolved;
|
|
}
|
|
|
|
let allowlisted = cache.get(rootDir);
|
|
const hadCachedAllowlist = Boolean(allowlisted);
|
|
if (!allowlisted) {
|
|
allowlisted = await listRegisteredWorktreePaths(rootDir);
|
|
cache.set(rootDir, allowlisted);
|
|
}
|
|
|
|
const cachedMatch = allowlisted.some((allowed) => isPathWithin(allowed, resolved));
|
|
if (cachedMatch && !hadCachedAllowlist) {
|
|
return resolved;
|
|
}
|
|
|
|
/*
|
|
FNXC:TerminalWorktrees 2026-06-29-23:37:
|
|
Registered worktrees can be created or removed while a long-lived dashboard server is already running. Refresh the Git worktree allowlist on cached hits or misses before authorizing an outside-root path so stale cached registrations do not keep arbitrary recreated directories authorized.
|
|
*/
|
|
allowlisted = await listRegisteredWorktreePaths(rootDir);
|
|
cache.set(rootDir, allowlisted);
|
|
if (allowlisted.some((allowed) => isPathWithin(allowed, resolved))) {
|
|
return resolved;
|
|
}
|
|
|
|
throw badRequest("worktreePath outside project");
|
|
}
|
|
|
|
export async function isAuthorizedProjectOrRegisteredWorktreePath(
|
|
rootDir: string,
|
|
candidatePath: string,
|
|
cache: Map<string, string[]> = new Map(),
|
|
): Promise<boolean> {
|
|
/*
|
|
FNXC:TerminalWorktrees 2026-06-29-00:00:
|
|
Terminal cwd may target the project root or Git-registered worktrees for that project, including task worktrees outside the root. Normalize absolute paths and authorize them against the shared git-worktree policy before PTY spawn so arbitrary filesystem paths remain blocked.
|
|
*/
|
|
const root = resolve(rootDir);
|
|
const candidate = resolve(candidatePath);
|
|
|
|
if (isPathWithin(root, candidate)) {
|
|
return true;
|
|
}
|
|
|
|
let allowlisted = cache.get(root);
|
|
const hadCachedAllowlist = Boolean(allowlisted);
|
|
if (!allowlisted) {
|
|
try {
|
|
allowlisted = await listRegisteredWorktreePaths(root);
|
|
} catch {
|
|
return false;
|
|
}
|
|
cache.set(root, allowlisted);
|
|
}
|
|
|
|
const cachedMatch = allowlisted.some((allowed) => isPathWithin(allowed, candidate));
|
|
if (cachedMatch && !hadCachedAllowlist) {
|
|
return true;
|
|
}
|
|
|
|
/*
|
|
FNXC:TerminalWorktrees 2026-06-29-23:37:
|
|
Revalidate outside-root terminal cwd authorization against current `git worktree list` output on cached hits or misses. Without this fresh check, removing a worktree and later recreating a directory at the same path would leave the PTY cwd authorization stale for the server lifetime.
|
|
*/
|
|
try {
|
|
allowlisted = await listRegisteredWorktreePaths(root);
|
|
} catch {
|
|
return false;
|
|
}
|
|
cache.set(root, allowlisted);
|
|
|
|
return allowlisted.some((allowed) => isPathWithin(allowed, candidate));
|
|
}
|