fix(nexus): expand tilde and mkdir workspace cwd on agent save
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) <noreply@anthropic.com>
This commit is contained in:
parent
50e91d8732
commit
91530b07a4
1 changed files with 81 additions and 8 deletions
|
|
@ -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<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
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") {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue