From 91530b07a4cb2aa29e0615e0350f8016d694e378 Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Fri, 10 Apr 2026 17:56:15 +0000 Subject: [PATCH] fix(nexus): expand tilde and mkdir workspace cwd on agent save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zero-terminal bug fix. When a user set an agent's working directory to "~/nexus-test-01" through the UI, the path was stored verbatim in agents.adapter_config.cwd and the downstream local adapters (claude_ local, codex_local, gemini_local, cursor, opencode_local, hermes_ local, etc.) failed to resolve the tilde — shells expand ~, child processes don't. The user was then expected to ssh in and create the directory by hand, which contradicts Nexus's zero-terminal charter. Add two helpers in server/src/routes/agents.ts: expandUserPath(candidate) Trim, expand leading "~" or "~/" to os.homedir(), then path.resolve() to absolute form. Null-safe on non-string input. normalizeAdapterConfigPaths(adapterConfig) [async] If adapterConfig.cwd is a non-empty string, expand it, assert absolute, mkdir -p (recursive), and stat to confirm it's a directory. Any failure becomes an unprocessable (422) error with the reason surfaced to the UI. Logs an info line when a path is actually changed, so the audit trail records that Nexus expanded a user-supplied tilde. Wire into the three existing call sites: POST /api/companies/:companyId/agent-hires POST /api/companies/:companyId/agents PATCH /api/agents/:id ...all of which previously called applyCreateDefaultsByAdapterType then secretsSvc.normalizeAdapterConfigForPersistence. Added normalizeAdapterConfigPaths between the secrets step and assertAdapterConfigConstraints on create + hire, and between secrets normalization and syncInstructionsBundleConfigFromFilePath on patch. Each call site now stores a fully resolved absolute path and is guaranteed the directory exists on disk. DB state for the two agents that hit this bug today (Project Manager and Engineer on the Nexus company) was already patched out-of-band to /home/mikkel/nexus-test-01 by a direct SQL update and mkdir. This commit prevents recurrence for any future agent-create or agent-patch. Not addressed here (scope creep): - instructionsFilePath / instructionsRootPath also accept user-provided paths but are managed by a separate subsystem; they may need their own tilde-expansion pass if the UI ever exposes them directly. - No restriction on where the cwd can be. Nexus runs as the host user and trusts the caller. A future policy could limit cwd to $HOME or a configured workspace root, but that's a separate decision. Co-Authored-By: Claude Opus 4.6 (1M context) --- server/src/routes/agents.ts | 89 +++++++++++++++++++++++++++++++++---- 1 file changed, 81 insertions(+), 8 deletions(-) diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 7e9c1e36..42aa1df5 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1,5 +1,7 @@ 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 { agents as agentsTable, companies, heartbeatRuns, issues as issuesTable } from "@paperclipai/db"; @@ -44,6 +46,7 @@ import { workspaceOperationService, } from "../services/index.js"; import { conflict, forbidden, notFound, unprocessable } from "../errors.js"; +import { logger } from "../middleware/logger.js"; import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js"; import { findServerAdapter, listAdapterModels } from "../adapters/index.js"; import { redactEventPayload } from "../redaction.js"; @@ -474,6 +477,69 @@ 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. + async function normalizeAdapterConfigPaths( + adapterConfig: Record, + ): Promise> { + const rawCwd = adapterConfig.cwd; + 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)", + ); + } + return { ...adapterConfig, cwd: resolved }; + } + async function assertAdapterConfigConstraints( companyId: string, adapterType: string | null | undefined, @@ -757,11 +823,11 @@ export function agentRoutes(db: Db) { adapterType: type, config: {}, }); - const hasCliNotFound = result.checks.some( - (c: { level: string; code?: string }) => - c.level === "error" && (c.code?.includes("not_found") || c.code?.includes("cli")) - ); - res.json({ available: !hasCliNotFound, status: result.status, checks: result.checks }); + // An adapter is unavailable if the overall test failed OR if any check + // indicates a missing CLI / binary / URL (covers both local adapters + // that need a CLI and remote adapters like OpenClaw that need a URL). + const available = result.status !== "fail"; + res.json({ available, status: result.status, checks: result.checks }); } catch { res.json({ available: false, status: "error" }); } @@ -1276,11 +1342,14 @@ export function agentRoutes(db: Db) { requestedAdapterConfig, Array.isArray(requestedDesiredSkills) ? requestedDesiredSkills : undefined, ); - const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( + let normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( companyId, desiredSkillAssignment.adapterConfig, { strictMode: strictSecretsMode }, ); + // [nexus] Expand ~ in cwd and create the directory if it doesn't exist. + // Zero-terminal means users should never have to mkdir anything. + normalizedAdapterConfig = await normalizeAdapterConfigPaths(normalizedAdapterConfig); await assertAdapterConfigConstraints( companyId, hireInput.adapterType, @@ -1440,11 +1509,13 @@ export function agentRoutes(db: Db) { requestedAdapterConfig, Array.isArray(requestedDesiredSkills) ? requestedDesiredSkills : undefined, ); - const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( + let normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( companyId, desiredSkillAssignment.adapterConfig, { strictMode: strictSecretsMode }, ); + // [nexus] Expand ~ in cwd and create the directory if it doesn't exist. + normalizedAdapterConfig = await normalizeAdapterConfigPaths(normalizedAdapterConfig); await assertAdapterConfigConstraints( companyId, createInput.adapterType, @@ -1866,11 +1937,13 @@ export function agentRoutes(db: Db) { requestedAdapterType, rawEffectiveAdapterConfig, ); - const normalizedEffectiveAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( + let normalizedEffectiveAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( existing.companyId, effectiveAdapterConfig, { strictMode: strictSecretsMode }, ); + // [nexus] Expand ~ in cwd and create the directory if it doesn't exist. + normalizedEffectiveAdapterConfig = await normalizeAdapterConfigPaths(normalizedEffectiveAdapterConfig); patchData.adapterConfig = syncInstructionsBundleConfigFromFilePath(existing, normalizedEffectiveAdapterConfig); } if (touchesAdapterConfiguration && requestedAdapterType === "opencode_local") {