fix(nexus): extend tilde expansion and mkdir to all user path endpoints
Follow-up to commit91530b07which 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, which91530b07ensures. - 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:
parent
d478cc3daf
commit
22e14545d4
5 changed files with 116 additions and 54 deletions
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
82
server/src/utils/path-normalization.ts
Normal file
82
server/src/utils/path-normalization.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue