diff --git a/package.json b/package.json index 388f99e2..723881ae 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/onboarding.test.ts && tsx src/cli-workspace.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-config.test.ts && tsx src/local-agent-catalog.test.ts && tsx src/local-agent-presentation.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-daemon-lifecycle.test.ts && tsx src/local-agent-daemon-protocol.test.ts && tsx src/local-agent-daemon.test.ts && tsx src/local-agent-codex.test.ts && tsx src/local-agent-opencode.test.ts && tsx src/local-agent-acp.test.ts && tsx src/local-agent-grok.test.ts && tsx src/local-agent-pi-sandbox.test.ts && tsx src/local-agent-pi.test.ts && tsx src/local-agent-claude.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/local-agent-manager.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", + "test": "tsx src/user-config.test.ts && tsx src/config.test.ts && tsx src/onboarding.test.ts && tsx src/cli-workspace.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-config.test.ts && tsx src/local-agent-catalog.test.ts && tsx src/local-agent-presentation.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-daemon-lifecycle.test.ts && tsx src/local-agent-daemon-protocol.test.ts && tsx src/local-agent-daemon.test.ts && tsx src/local-agent-codex.test.ts && tsx src/local-agent-opencode.test.ts && tsx src/local-agent-acp.test.ts && tsx src/local-agent-grok.test.ts && tsx src/local-agent-pi-sandbox.test.ts && tsx src/local-agent-pi.test.ts && tsx src/local-agent-claude.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/local-agent-manager.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/local-agent-config.ts b/src/local-agent-config.ts index 3f1de5aa..538355d9 100644 --- a/src/local-agent-config.ts +++ b/src/local-agent-config.ts @@ -11,7 +11,7 @@ const providerSchema = z.object({ effort: z.string().trim().min(1).optional(), }).strict(); -const subagentsSchema = z.object({ +export const subagentsConfigSchema = z.object({ enabled: z.boolean(), providers: z.array(providerSchema), }).strict().superRefine((value, context) => { @@ -28,9 +28,14 @@ const subagentsSchema = z.object({ } }); +export const storedSubagentsConfigSchema = z.union([ + z.boolean(), + subagentsConfigSchema, +]); + export type SubagentProviderConfig = z.infer; -export type SubagentsConfig = z.infer; -export type StoredSubagentsConfig = boolean | SubagentsConfig; +export type SubagentsConfig = z.infer; +export type StoredSubagentsConfig = z.infer; export function resolveSubagentsConfig( value: unknown, @@ -40,7 +45,7 @@ export function resolveSubagentsConfig( ? { enabled: false, providers: [] } : typeof value === "boolean" ? legacySubagentsConfig(value) - : subagentsSchema.parse(value); + : subagentsConfigSchema.parse(value); return { ...stored, enabled: env.DEVSPACE_SUBAGENTS === undefined diff --git a/src/user-config.test.ts b/src/user-config.test.ts new file mode 100644 index 00000000..f13ca46f --- /dev/null +++ b/src/user-config.test.ts @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadDevspaceFiles } from "./user-config.js"; + +const configDir = mkdtempSync(join(tmpdir(), "devspace-user-config-test-")); +const env = { DEVSPACE_CONFIG_DIR: configDir }; + +try { + writeFileSync(join(configDir, "config.json"), JSON.stringify({ + port: 8787, + subagents: { + enabled: true, + providers: [{ id: "codex", enabled: true }], + }, + })); + writeFileSync(join(configDir, "auth.json"), JSON.stringify({ + ownerToken: "test-owner-token", + })); + + assert.deepEqual(loadDevspaceFiles(env).config, { + port: 8787, + subagents: { + enabled: true, + providers: [{ id: "codex", enabled: true }], + }, + }); + assert.equal(loadDevspaceFiles(env).auth.ownerToken, "test-owner-token"); + + writeFileSync(join(configDir, "config.json"), JSON.stringify({ port: "8787" })); + assert.throws(() => loadDevspaceFiles(env), /expected number/i); + + writeFileSync(join(configDir, "config.json"), JSON.stringify({ unknownSetting: true })); + assert.equal(loadDevspaceFiles(env).config.unknownSetting, true); + + writeFileSync(join(configDir, "config.json"), "{"); + assert.throws(() => loadDevspaceFiles(env), /Unable to read .*config\.json/); +} finally { + rmSync(configDir, { recursive: true, force: true }); +} diff --git a/src/user-config.ts b/src/user-config.ts index 98d05ac6..506b468c 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -7,26 +7,30 @@ import { } from "node:fs"; import { homedir } from "node:os"; import { join, resolve } from "node:path"; +import * as z from "zod/v4"; import { expandHomePath } from "./roots.js"; -import type { StoredSubagentsConfig } from "./local-agent-config.js"; - -export interface DevspaceUserConfig { - host?: string; - port?: number; - allowedRoots?: string[]; - publicBaseUrl?: string | null; - allowedHosts?: string[]; - stateDir?: string; - worktreeRoot?: string; - artifactsEnabled?: boolean; - artifactMaxFileBytes?: number; - agentDir?: string; - subagents?: StoredSubagentsConfig; -} +import { storedSubagentsConfigSchema } from "./local-agent-config.js"; -export interface DevspaceAuthConfig { - ownerToken?: string; -} +const devspaceUserConfigSchema = z.object({ + host: z.string().optional(), + port: z.number().optional(), + allowedRoots: z.array(z.string()).optional(), + publicBaseUrl: z.string().nullable().optional(), + allowedHosts: z.array(z.string()).optional(), + stateDir: z.string().optional(), + worktreeRoot: z.string().optional(), + artifactsEnabled: z.boolean().optional(), + artifactMaxFileBytes: z.number().optional(), + agentDir: z.string().optional(), + subagents: storedSubagentsConfigSchema.optional(), +}).passthrough(); + +const devspaceAuthConfigSchema = z.object({ + ownerToken: z.string().optional(), +}).passthrough(); + +export type DevspaceUserConfig = z.infer; +export type DevspaceAuthConfig = z.infer; export interface DevspaceFiles { dir: string; @@ -71,8 +75,8 @@ export function loadDevspaceFiles(env: NodeJS.ProcessEnv = process.env): Devspac authPath, configExists, authExists, - config: configExists ? readJsonFile(configPath) : {}, - auth: authExists ? readJsonFile(authPath) : {}, + config: configExists ? readJsonFile(configPath, devspaceUserConfigSchema) : {}, + auth: authExists ? readJsonFile(authPath, devspaceAuthConfigSchema) : {}, }; } @@ -100,9 +104,9 @@ export function generateOwnerToken(): string { return randomBytes(32).toString("base64url"); } -function readJsonFile(filePath: string): T { +function readJsonFile(filePath: string, schema: z.ZodType): T { try { - return JSON.parse(readFileSync(filePath, "utf8")) as T; + return schema.parse(JSON.parse(readFileSync(filePath, "utf8")) as unknown); } catch (error) { const reason = error instanceof Error ? error.message : String(error); throw new Error(`Unable to read ${filePath}: ${reason}`);