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") {