nexus/server/src/services/nexus-settings.ts
Nexus Dev 22e14545d4 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>
2026-04-10 18:22:33 +00:00

79 lines
3 KiB
TypeScript

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];
export const VOICE_MODES = ["text", "voice_input", "full_voice"] as const;
export type VoiceMode = (typeof VOICE_MODES)[number];
const paletteRoleSchema = z.object({
name: z.string(),
dark: z.object({ oklch: z.string(), hex: z.string(), wcagAA: z.boolean() }),
light: z.object({ oklch: z.string(), hex: z.string(), wcagAA: z.boolean() }),
});
export const nexusSettingsSchema = z.object({
mode: z.enum(NEXUS_MODES).default("both"),
voiceEnabled: z.boolean().default(false),
voiceMode: z.enum(VOICE_MODES).default("text"),
telegramToken: z.string().optional(),
piperBinaryPath: z.string().optional(),
whisperBinaryPath: z.string().optional(),
customTheme: z
.object({
seedHex: z.string(),
palette: z.array(paletteRoleSchema),
})
.optional(),
});
type NexusSettings = z.infer<typeof nexusSettingsSchema>;
function resolveNexusSettingsPath(): string {
return path.resolve(resolvePaperclipInstanceRoot(), "data", "nexus-settings.json");
}
export function nexusSettingsService() {
async function get(): Promise<NexusSettings> {
const filePath = resolveNexusSettingsPath();
try {
const raw = fs.readFileSync(filePath, "utf-8");
const parsed = nexusSettingsSchema.safeParse(JSON.parse(raw));
if (parsed.success) {
return parsed.data;
}
return nexusSettingsSchema.parse({});
} catch {
return nexusSettingsSchema.parse({});
}
}
async function set(patch: Partial<NexusSettings>): Promise<NexusSettings> {
const current = await get();
// [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);
const filePath = resolveNexusSettingsPath();
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(validated, null, 2), "utf-8");
return validated;
}
return { get, set };
}