From 22e14545d4f843cf5faec69e553c6b6279563381 Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Fri, 10 Apr 2026 18:22:33 +0000 Subject: [PATCH] fix(nexus): extend tilde expansion and mkdir to all user path endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to commit 91530b07 which only covered agents.adapter_config .cwd. An audit found three additional user-facing endpoints that accept filesystem paths without normalization. Same zero-terminal bug in each: user supplies "~/foo", server stores it raw, downstream consumers can't resolve the tilde. Extract the two helpers (expandUserPath, normalizeWorkspaceDir) from the agents.ts closure into a shared utility so all endpoints use the same primitive. new: server/src/utils/path-normalization.ts - expandUserPath(candidate): resolves ~ / ~/foo to homedir() and path.resolve() to absolute. Null-safe on non-string input. - normalizeWorkspaceDir(rawPath, { field }): expand + assert absolute + mkdir -p + stat isDirectory + log the change. Throws unprocessable (422) on any filesystem failure with a field-aware error message. changed: server/src/routes/agents.ts - Replaced the inline expandUserPath + normalizeAdapterConfigPaths helpers with a narrow wrapper that delegates to the shared utility. Three call sites (create, hire, patch) unchanged in behavior. - Removed now-unused imports: mkdir, stat, homedir. changed: server/src/routes/projects.ts - POST /projects/:id/workspaces: normalize req.body.cwd before the service call. - PATCH /projects/:id/workspaces/:workspaceId: same. - Added import. changed: server/src/routes/execution-workspaces.ts - PATCH /execution-workspaces/:id: normalize req.body.cwd before the patch object is built. - Added import. changed: server/src/services/nexus-settings.ts - In set(): expand ~ in piperBinaryPath and whisperBinaryPath before merging and validating. These are executable paths so we expand but don't mkdir — caller still has to install the binary itself, but the stored path is now resolvable by the server. - Added import. Not extended: - Storage provider baseDir: computed at startup from environment, not user request body. Sandboxed. No change needed. - Instructions bundle paths: indirectly covered — the legacy path resolver depends on cwd being absolute, which 91530b07 ensures. - Chat file upload object keys: system-generated, not user-supplied. Verification: npx tsc --noEmit on server — zero errors introduced in any touched file. Dev server on :6100 still returns 200. Co-Authored-By: Claude Opus 4.6 (1M context) --- server/src/routes/agents.ts | 60 ++--------------- server/src/routes/execution-workspaces.ts | 5 ++ server/src/routes/projects.ts | 9 +++ server/src/services/nexus-settings.ts | 14 +++- server/src/utils/path-normalization.ts | 82 +++++++++++++++++++++++ 5 files changed, 116 insertions(+), 54 deletions(-) create mode 100644 server/src/utils/path-normalization.ts diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 42aa1df5..e0938bfd 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1,9 +1,8 @@ import { Router, type Request } from "express"; import { generateKeyPairSync, randomUUID } from "node:crypto"; -import { mkdir, stat } from "node:fs/promises"; -import { homedir } from "node:os"; import path from "node:path"; import type { Db } from "@paperclipai/db"; +import { normalizeWorkspaceDir } from "../utils/path-normalization.js"; import { agents as agentsTable, companies, heartbeatRuns, issues as issuesTable } from "@paperclipai/db"; import { and, desc, eq, inArray, not, sql } from "drizzle-orm"; import { @@ -477,30 +476,13 @@ export function agentRoutes(db: Db) { return ensureGatewayDeviceKey(adapterType, next); } - // [nexus] Expand a user-provided path: resolve `~` / `~/foo` to the server - // process's home directory and convert to absolute form. Returns null if - // the input is not a non-empty string. - function expandUserPath(candidate: unknown): string | null { - if (typeof candidate !== "string") return null; - const trimmed = candidate.trim(); - if (trimmed.length === 0) return null; - const home = homedir(); - let expanded: string; - if (trimmed === "~") { - expanded = home; - } else if (trimmed.startsWith("~/")) { - expanded = path.join(home, trimmed.slice(2)); - } else { - expanded = trimmed; - } - return path.resolve(expanded); - } - // [nexus] Zero-terminal bug fix: when a user sets a working directory via // the UI, Nexus must expand `~` and create the directory if it doesn't - // exist — we can't make the user ssh in and mkdir anything. Mutates - // adapter_config.cwd in place (returning a new object) and ensures the - // directory exists on disk. Throws unprocessable on filesystem errors. + // exist — we can't make the user ssh in and mkdir anything. Returns a new + // adapter config object with cwd normalized to an absolute path that + // definitely exists on disk. Delegates to the shared path-normalization + // helper so projects / execution-workspaces / nexus-settings can use the + // same primitive. async function normalizeAdapterConfigPaths( adapterConfig: Record, ): Promise> { @@ -508,35 +490,7 @@ export function agentRoutes(db: Db) { if (typeof rawCwd !== "string" || rawCwd.trim().length === 0) { return adapterConfig; } - const resolved = expandUserPath(rawCwd); - if (!resolved) return adapterConfig; - if (!path.isAbsolute(resolved)) { - throw unprocessable( - `adapterConfig.cwd must be an absolute path after tilde expansion (got ${resolved})`, - ); - } - try { - await mkdir(resolved, { recursive: true }); - } catch (err) { - const reason = err instanceof Error ? err.message : String(err); - throw unprocessable(`Could not create workspace directory ${resolved}: ${reason}`); - } - try { - const stats = await stat(resolved); - if (!stats.isDirectory()) { - throw unprocessable(`Workspace path ${resolved} exists but is not a directory`); - } - } catch (err) { - if (err && typeof err === "object" && "status" in err) throw err; - const reason = err instanceof Error ? err.message : String(err); - throw unprocessable(`Could not stat workspace directory ${resolved}: ${reason}`); - } - if (resolved !== rawCwd) { - logger.info( - { original: rawCwd, resolved }, - "normalized agent adapter_config.cwd (tilde expansion / absolute path / mkdir)", - ); - } + const resolved = await normalizeWorkspaceDir(rawCwd, { field: "adapterConfig.cwd" }); return { ...adapterConfig, cwd: resolved }; } diff --git a/server/src/routes/execution-workspaces.ts b/server/src/routes/execution-workspaces.ts index 4fa20425..6441709f 100644 --- a/server/src/routes/execution-workspaces.ts +++ b/server/src/routes/execution-workspaces.ts @@ -14,6 +14,7 @@ import { stopRuntimeServicesForExecutionWorkspace, } from "../services/workspace-runtime.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; +import { normalizeWorkspaceDir } from "../utils/path-normalization.js"; export function executionWorkspaceRoutes(db: Db) { const router = Router(); @@ -248,6 +249,10 @@ export function executionWorkspaceRoutes(db: Db) { return; } assertCompanyAccess(req, existing.companyId); + // [nexus] Zero-terminal: expand ~ and mkdir -p before storing cwd. + if (typeof req.body.cwd === "string" && req.body.cwd.trim().length > 0) { + req.body.cwd = await normalizeWorkspaceDir(req.body.cwd, { field: "executionWorkspace.cwd" }); + } const patch: Record = { ...(req.body.name === undefined ? {} : { name: req.body.name }), ...(req.body.cwd === undefined ? {} : { cwd: req.body.cwd }), diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index 482a6983..8b0d59ec 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -11,6 +11,7 @@ import { trackProjectCreated } from "@paperclipai/shared/telemetry"; import { validate } from "../middleware/validate.js"; import { projectService, logActivity, workspaceOperationService } from "../services/index.js"; import { conflict } from "../errors.js"; +import { normalizeWorkspaceDir } from "../utils/path-normalization.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; import { startRuntimeServicesForWorkspaceControl, stopRuntimeServicesForProjectWorkspace } from "../services/workspace-runtime.js"; import { getTelemetryClient } from "../telemetry.js"; @@ -169,6 +170,10 @@ export function projectRoutes(db: Db) { return; } assertCompanyAccess(req, existing.companyId); + // [nexus] Zero-terminal: expand ~ and mkdir -p before storing. + if (typeof req.body?.cwd === "string" && req.body.cwd.trim().length > 0) { + req.body.cwd = await normalizeWorkspaceDir(req.body.cwd, { field: "projectWorkspace.cwd" }); + } const workspace = await svc.createWorkspace(id, req.body); if (!workspace) { res.status(422).json({ error: "Invalid project workspace payload" }); @@ -212,6 +217,10 @@ export function projectRoutes(db: Db) { res.status(404).json({ error: "Project workspace not found" }); return; } + // [nexus] Zero-terminal: expand ~ and mkdir -p before storing. + if (typeof req.body?.cwd === "string" && req.body.cwd.trim().length > 0) { + req.body.cwd = await normalizeWorkspaceDir(req.body.cwd, { field: "projectWorkspace.cwd" }); + } const workspace = await svc.updateWorkspace(id, workspaceId, req.body); if (!workspace) { res.status(422).json({ error: "Invalid project workspace payload" }); diff --git a/server/src/services/nexus-settings.ts b/server/src/services/nexus-settings.ts index 524d79b2..f944ebc6 100644 --- a/server/src/services/nexus-settings.ts +++ b/server/src/services/nexus-settings.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { z } from "zod"; import { resolvePaperclipInstanceRoot } from "../home-paths.js"; +import { expandUserPath } from "../utils/path-normalization.js"; export const NEXUS_MODES = ["personal_ai", "project_builder", "both"] as const; export type NexusMode = (typeof NEXUS_MODES)[number]; @@ -53,7 +54,18 @@ export function nexusSettingsService() { async function set(patch: Partial): Promise { const current = await get(); - const merged = { ...current, ...patch }; + // [nexus] Zero-terminal: expand ~ in user-supplied binary paths before + // persisting. These are executable paths (voice pipeline binaries), so + // we expand but don't mkdir — caller still has to install the binary + // itself, but at least the stored path is resolvable by the server. + const pathNormalizedPatch: Partial = { ...patch }; + if (typeof patch.piperBinaryPath === "string" && patch.piperBinaryPath.trim().length > 0) { + pathNormalizedPatch.piperBinaryPath = expandUserPath(patch.piperBinaryPath) ?? patch.piperBinaryPath; + } + if (typeof patch.whisperBinaryPath === "string" && patch.whisperBinaryPath.trim().length > 0) { + pathNormalizedPatch.whisperBinaryPath = expandUserPath(patch.whisperBinaryPath) ?? patch.whisperBinaryPath; + } + const merged = { ...current, ...pathNormalizedPatch }; // Validate — will throw ZodError if invalid const validated = nexusSettingsSchema.parse(merged); diff --git a/server/src/utils/path-normalization.ts b/server/src/utils/path-normalization.ts new file mode 100644 index 00000000..f81f185b --- /dev/null +++ b/server/src/utils/path-normalization.ts @@ -0,0 +1,82 @@ +// [nexus] Zero-terminal helpers for user-provided filesystem paths. +// Extracted from server/src/routes/agents.ts so projects, execution- +// workspaces, and nexus-settings can share the same expansion + mkdir +// logic. The agent route endpoints still call these via the same +// wrapper names for source-diff minimalism. + +import { mkdir, stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import path from "node:path"; +import { unprocessable } from "../errors.js"; +import { logger } from "../middleware/logger.js"; + +/** + * Expand a user-provided path: resolve `~` / `~/foo` to the server + * process's home directory and convert to absolute form. Returns null + * if the input is not a non-empty string. + */ +export function expandUserPath(candidate: unknown): string | null { + if (typeof candidate !== "string") return null; + const trimmed = candidate.trim(); + if (trimmed.length === 0) return null; + const home = homedir(); + let expanded: string; + if (trimmed === "~") { + expanded = home; + } else if (trimmed.startsWith("~/")) { + expanded = path.join(home, trimmed.slice(2)); + } else { + expanded = trimmed; + } + return path.resolve(expanded); +} + +/** + * Normalize a user-provided directory path and ensure it exists on + * disk. Expands tildes, resolves to absolute, creates the directory + * recursively if missing, and stats to confirm it's a directory. + * Throws a 422 unprocessable error with the reason on failure. + * + * Returns the normalized absolute path. Use at any endpoint that + * accepts a user-provided cwd / workspace / working-directory field + * before persisting it to the DB or passing it to a child process. + */ +export async function normalizeWorkspaceDir( + rawPath: string, + context: { field: string; reason?: string } = { field: "cwd" }, +): Promise { + const resolved = expandUserPath(rawPath); + if (!resolved) { + throw unprocessable( + `${context.field} must be a non-empty string${context.reason ? ` (${context.reason})` : ""}`, + ); + } + if (!path.isAbsolute(resolved)) { + throw unprocessable( + `${context.field} must resolve to an absolute path after tilde expansion (got ${resolved})`, + ); + } + try { + await mkdir(resolved, { recursive: true }); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + throw unprocessable(`Could not create directory ${resolved}: ${reason}`); + } + try { + const stats = await stat(resolved); + if (!stats.isDirectory()) { + throw unprocessable(`Path ${resolved} exists but is not a directory`); + } + } catch (err) { + if (err && typeof err === "object" && "status" in err) throw err; + const reason = err instanceof Error ? err.message : String(err); + throw unprocessable(`Could not stat ${resolved}: ${reason}`); + } + if (resolved !== rawPath) { + logger.info( + { field: context.field, original: rawPath, resolved }, + "normalized user-supplied workspace path (tilde expansion / absolute path / mkdir)", + ); + } + return resolved; +}