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>
79 lines
3 KiB
TypeScript
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 };
|
|
}
|