fix(nexus): extend tilde expansion and mkdir to all user path endpoints

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/🆔 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) <noreply@anthropic.com>
This commit is contained in:
Nexus Dev 2026-04-10 18:22:33 +00:00
parent d478cc3daf
commit 22e14545d4
5 changed files with 116 additions and 54 deletions

View file

@ -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<string, unknown>,
): Promise<Record<string, unknown>> {
@ -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 };
}

View file

@ -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<string, unknown> = {
...(req.body.name === undefined ? {} : { name: req.body.name }),
...(req.body.cwd === undefined ? {} : { cwd: req.body.cwd }),

View file

@ -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" });

View file

@ -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<NexusSettings>): Promise<NexusSettings> {
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<NexusSettings> = { ...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);

View file

@ -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<string> {
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;
}