From f38b69ef3f6a4003080b7958f70f790c28c036df Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:59:26 +0530 Subject: [PATCH 01/14] refactor(config): decode persisted configuration --- src/cli.ts | 4 +-- src/config.ts | 31 +++++++++++------ src/local-agent-config.ts | 18 +++++++--- src/user-config.ts | 72 ++++++++++++++++++++++++++------------- 4 files changed, 85 insertions(+), 40 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 7cf723f8..20e61649 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -230,7 +230,7 @@ async function runInit({ force }: { force: boolean }): Promise { ownerToken: files.auth.ownerToken ?? generateOwnerToken(), }; - writeDevspaceConfig(config); + writeDevspaceConfig(config, process.env, files.configDocument); writeDevspaceAuth(auth); const lines = [ @@ -372,7 +372,7 @@ function runConfigCommand(args: string[]): void { writeDevspaceConfig({ ...files.config, publicBaseUrl: normalizeOptionalPublicBaseUrl(value), - }); + }, process.env, files.configDocument); console.log(`Updated ${files.configPath}`); } diff --git a/src/config.ts b/src/config.ts index 54a131c9..aa82a66e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -81,8 +81,13 @@ function normalizeAllowedHosts(rawHosts: string[], derivedHosts: string[]): stri return Array.from(new Set(hosts.map((host) => host.trim()).filter(Boolean))); } -function parseBoolean(value: string | undefined): boolean { - return ["1", "true", "yes", "on"].includes(value?.toLowerCase() ?? ""); +function parseBoolean(value: string | undefined, name: string): boolean { + if (value === undefined) return false; + + const normalized = value.toLowerCase(); + if (["1", "true", "yes", "on"].includes(normalized)) return true; + if (["0", "false", "no", "off"].includes(normalized)) return false; + throw new Error(`Invalid ${name}: ${value}`); } function parseToolMode(env: NodeJS.ProcessEnv): ToolMode { @@ -91,7 +96,7 @@ function parseToolMode(env: NodeJS.ProcessEnv): ToolMode { if (mode) throw new Error(`Invalid DEVSPACE_TOOL_MODE: ${mode}`); if (env.DEVSPACE_MINIMAL_TOOLS !== undefined) { - return parseBoolean(env.DEVSPACE_MINIMAL_TOOLS) ? "minimal" : "full"; + return parseBoolean(env.DEVSPACE_MINIMAL_TOOLS, "DEVSPACE_MINIMAL_TOOLS") ? "minimal" : "full"; } return "minimal"; } @@ -148,11 +153,15 @@ function parseLoggingConfig(env: NodeJS.ProcessEnv): LoggingConfig { return { level: parseLogLevel(env.DEVSPACE_LOG_LEVEL), format: parseLogFormat(env.DEVSPACE_LOG_FORMAT), - requests: env.DEVSPACE_LOG_REQUESTS === undefined ? true : parseBoolean(env.DEVSPACE_LOG_REQUESTS), - assets: parseBoolean(env.DEVSPACE_LOG_ASSETS), - toolCalls: env.DEVSPACE_LOG_TOOL_CALLS === undefined ? true : parseBoolean(env.DEVSPACE_LOG_TOOL_CALLS), - shellCommands: parseBoolean(env.DEVSPACE_LOG_SHELL_COMMANDS), - trustProxy: parseBoolean(env.DEVSPACE_TRUST_PROXY), + requests: env.DEVSPACE_LOG_REQUESTS === undefined + ? true + : parseBoolean(env.DEVSPACE_LOG_REQUESTS, "DEVSPACE_LOG_REQUESTS"), + assets: parseBoolean(env.DEVSPACE_LOG_ASSETS, "DEVSPACE_LOG_ASSETS"), + toolCalls: env.DEVSPACE_LOG_TOOL_CALLS === undefined + ? true + : parseBoolean(env.DEVSPACE_LOG_TOOL_CALLS, "DEVSPACE_LOG_TOOL_CALLS"), + shellCommands: parseBoolean(env.DEVSPACE_LOG_SHELL_COMMANDS, "DEVSPACE_LOG_SHELL_COMMANDS"), + trustProxy: parseBoolean(env.DEVSPACE_TRUST_PROXY, "DEVSPACE_TRUST_PROXY"), }; } @@ -238,13 +247,15 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { artifactsEnabled: env.DEVSPACE_ARTIFACTS === undefined ? files.config.artifactsEnabled === true - : parseBoolean(env.DEVSPACE_ARTIFACTS), + : parseBoolean(env.DEVSPACE_ARTIFACTS, "DEVSPACE_ARTIFACTS"), artifactMaxFileBytes: parsePositiveInteger( env.DEVSPACE_ARTIFACT_MAX_FILE_BYTES ?? numberConfigValue(files.config.artifactMaxFileBytes), DEFAULT_ARTIFACT_MAX_FILE_BYTES, "DEVSPACE_ARTIFACT_MAX_FILE_BYTES", ), - skillsEnabled: env.DEVSPACE_SKILLS === undefined ? true : parseBoolean(env.DEVSPACE_SKILLS), + skillsEnabled: env.DEVSPACE_SKILLS === undefined + ? true + : parseBoolean(env.DEVSPACE_SKILLS, "DEVSPACE_SKILLS"), skillPaths: parsePathList(env.DEVSPACE_SKILL_PATHS), devspaceSkillsDir: devspaceSkillsDir(env), devspaceAgentsDir: devspaceAgentsDir(env), diff --git a/src/local-agent-config.ts b/src/local-agent-config.ts index 3f1de5aa..a0db8612 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) => { @@ -29,8 +29,13 @@ const subagentsSchema = z.object({ }); export type SubagentProviderConfig = z.infer; -export type SubagentsConfig = z.infer; -export type StoredSubagentsConfig = boolean | SubagentsConfig; +export const storedSubagentsConfigSchema = z.union([ + z.boolean(), + subagentsConfigSchema, +]); + +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 @@ -73,5 +78,8 @@ function legacySubagentsConfig(enabled: boolean): SubagentsConfig { } function parseBoolean(value: string): boolean { - return ["1", "true", "yes", "on"].includes(value.toLowerCase()); + const normalized = value.toLowerCase(); + if (["1", "true", "yes", "on"].includes(normalized)) return true; + if (["0", "false", "no", "off"].includes(normalized)) return false; + throw new Error(`Invalid DEVSPACE_SUBAGENTS: ${value}`); } diff --git a/src/user-config.ts b/src/user-config.ts index 98d05ac6..d8c89f16 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -7,26 +7,31 @@ 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(), +}); + +const devspaceAuthConfigSchema = z.object({ + ownerToken: z.string().optional(), +}); + +export type DevspaceUserConfig = z.infer; + +export type DevspaceAuthConfig = z.infer; export interface DevspaceFiles { dir: string; @@ -36,6 +41,7 @@ export interface DevspaceFiles { authExists: boolean; config: DevspaceUserConfig; auth: DevspaceAuthConfig; + configDocument: Record; } export function devspaceConfigDir(env: NodeJS.ProcessEnv = process.env): string { @@ -65,24 +71,29 @@ export function loadDevspaceFiles(env: NodeJS.ProcessEnv = process.env): Devspac const configExists = existsSync(configPath); const authExists = existsSync(authPath); + const configDocument = configExists ? readJsonObject(configPath) : {}; + const authDocument = authExists ? readJsonObject(authPath) : {}; + return { dir, configPath, authPath, configExists, authExists, - config: configExists ? readJsonFile(configPath) : {}, - auth: authExists ? readJsonFile(authPath) : {}, + config: parseDocument(devspaceUserConfigSchema, configDocument, configPath), + auth: parseDocument(devspaceAuthConfigSchema, authDocument, authPath), + configDocument, }; } export function writeDevspaceConfig( config: DevspaceUserConfig, env: NodeJS.ProcessEnv = process.env, + existingDocument: Record = {}, ): string { const filePath = devspaceConfigPath(env); mkdirSync(devspaceConfigDir(env), { recursive: true }); - writeJsonFile(filePath, config, 0o600); + writeJsonFile(filePath, { ...existingDocument, ...config }, 0o600); return filePath; } @@ -100,15 +111,30 @@ export function generateOwnerToken(): string { return randomBytes(32).toString("base64url"); } -function readJsonFile(filePath: string): T { +function readJsonObject(filePath: string): Record { try { - return JSON.parse(readFileSync(filePath, "utf8")) as T; + const parsed: unknown = JSON.parse(readFileSync(filePath, "utf8")); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("expected a JSON object"); + } + return parsed as Record; } catch (error) { const reason = error instanceof Error ? error.message : String(error); throw new Error(`Unable to read ${filePath}: ${reason}`); } } +function parseDocument( + schema: z.ZodType, + document: Record, + filePath: string, +): T { + const result = schema.safeParse(document); + if (result.success) return result.data; + + throw new Error(`Invalid ${filePath}: ${z.prettifyError(result.error)}`); +} + function writeJsonFile(filePath: string, value: unknown, mode: number): void { writeFileSync(filePath, JSON.stringify(value, null, 2) + "\n", { mode }); } From eec6b7c1d39c7f5283a81c21260dd2dd5cfdad0c Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:59:26 +0530 Subject: [PATCH 02/14] test(config): reject ambiguous environment booleans --- src/config.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/config.test.ts b/src/config.test.ts index 7b3eeeb6..9cbc261b 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -21,6 +21,10 @@ assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "full" }).toolMode, "f assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "codex" }).toolMode, "codex"); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_MINIMAL_TOOLS: "0" }).toolMode, "full"); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_MINIMAL_TOOLS: "1" }).toolMode, "minimal"); +assert.throws( + () => loadConfig({ ...baseEnv, DEVSPACE_MINIMAL_TOOLS: "maybe" }), + /Invalid DEVSPACE_MINIMAL_TOOLS: maybe/, +); assert.equal(loadConfig(baseEnv).skillsEnabled, true); assert.equal(loadConfig(baseEnv).devspaceSkillsDir, join(emptyConfigDir, "skills")); assert.equal(loadConfig(baseEnv).devspaceAgentsDir, join(emptyConfigDir, "agents")); @@ -34,10 +38,18 @@ assert.equal( ); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "0" }).skillsEnabled, false); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "1" }).skillsEnabled, true); +assert.throws( + () => loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "treu" }), + /Invalid DEVSPACE_SKILLS: treu/, +); assert.deepEqual(loadConfig({ ...baseEnv, DEVSPACE_SUBAGENTS: "1" }).subagents, { enabled: true, providers: [], }); +assert.throws( + () => loadConfig({ ...baseEnv, DEVSPACE_SUBAGENTS: "sometimes" }), + /Invalid DEVSPACE_SUBAGENTS: sometimes/, +); assert.throws( () => loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "invalid" }), /Invalid DEVSPACE_WIDGETS: invalid/, From 0d266b4547a8d67f770efbdbe0f41a6fecf322ac Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:00:49 +0530 Subject: [PATCH 03/14] refactor(harness): model coding harness configuration --- src/config.test.ts | 29 +++++++++++++++++++++++------ src/config.ts | 12 ++++++++---- src/harness.ts | 25 +++++++++++++++++++++++++ src/server.ts | 17 +++++++++-------- 4 files changed, 65 insertions(+), 18 deletions(-) create mode 100644 src/harness.ts diff --git a/src/config.test.ts b/src/config.test.ts index 9cbc261b..fd8ed336 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -15,12 +15,29 @@ assert.equal(loadConfig(baseEnv).widgets, "full"); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "changes" }).widgets, "changes"); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "full" }).widgets, "full"); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "off" }).widgets, "off"); -assert.equal(loadConfig(baseEnv).toolMode, "minimal"); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "minimal" }).toolMode, "minimal"); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "full" }).toolMode, "full"); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "codex" }).toolMode, "codex"); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_MINIMAL_TOOLS: "0" }).toolMode, "full"); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_MINIMAL_TOOLS: "1" }).toolMode, "minimal"); +assert.deepEqual(loadConfig(baseEnv).harness, { + kind: "claude-code", + inspection: "shell", +}); +assert.deepEqual(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "minimal" }).harness, { + kind: "claude-code", + inspection: "shell", +}); +assert.deepEqual(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "full" }).harness, { + kind: "claude-code", + inspection: "dedicated", +}); +assert.deepEqual(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "codex" }).harness, { + kind: "codex", +}); +assert.deepEqual(loadConfig({ ...baseEnv, DEVSPACE_MINIMAL_TOOLS: "0" }).harness, { + kind: "claude-code", + inspection: "dedicated", +}); +assert.deepEqual(loadConfig({ ...baseEnv, DEVSPACE_MINIMAL_TOOLS: "1" }).harness, { + kind: "claude-code", + inspection: "shell", +}); assert.throws( () => loadConfig({ ...baseEnv, DEVSPACE_MINIMAL_TOOLS: "maybe" }), /Invalid DEVSPACE_MINIMAL_TOOLS: maybe/, diff --git a/src/config.ts b/src/config.ts index aa82a66e..99723cf1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,8 +5,12 @@ import type { LoggingConfig, LogFormat, LogLevel } from "./logger.js"; import type { OAuthConfig } from "./oauth-provider.js"; import { devspaceAgentsDir, devspaceSkillsDir, loadDevspaceFiles } from "./user-config.js"; import { resolveSubagentsConfig, type SubagentsConfig } from "./local-agent-config.js"; +import { + harnessFromLegacyToolMode, + type HarnessConfig, + type LegacyToolMode, +} from "./harness.js"; -export type ToolMode = "minimal" | "full" | "codex"; export type WidgetMode = "off" | "changes" | "full"; const DEFAULT_OAUTH_ACCESS_TOKEN_TTL_SECONDS = 60 * 60; const DEFAULT_OAUTH_REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60; @@ -19,7 +23,7 @@ export interface ServerConfig { allowedRoots: string[]; allowedHosts: string[]; publicBaseUrl: string; - toolMode: ToolMode; + harness: HarnessConfig; widgets: WidgetMode; stateDir: string; worktreeRoot: string; @@ -90,7 +94,7 @@ function parseBoolean(value: string | undefined, name: string): boolean { throw new Error(`Invalid ${name}: ${value}`); } -function parseToolMode(env: NodeJS.ProcessEnv): ToolMode { +function parseLegacyToolMode(env: NodeJS.ProcessEnv): LegacyToolMode { const mode = env.DEVSPACE_TOOL_MODE; if (mode === "minimal" || mode === "full" || mode === "codex") return mode; if (mode) throw new Error(`Invalid DEVSPACE_TOOL_MODE: ${mode}`); @@ -240,7 +244,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { allowedRoots: parseAllowedRoots(env.DEVSPACE_ALLOWED_ROOTS ?? files.config.allowedRoots), allowedHosts: parseAllowedHosts(env.DEVSPACE_ALLOWED_HOSTS, derivedAllowedHosts), publicBaseUrl, - toolMode: parseToolMode(env), + harness: harnessFromLegacyToolMode(parseLegacyToolMode(env)), widgets: parseWidgetMode(env.DEVSPACE_WIDGETS), stateDir: resolve(expandHomePath(env.DEVSPACE_STATE_DIR ?? files.config.stateDir ?? defaultStateDir())), worktreeRoot: resolve(expandHomePath(env.DEVSPACE_WORKTREE_ROOT ?? files.config.worktreeRoot ?? defaultWorktreeRoot())), diff --git a/src/harness.ts b/src/harness.ts new file mode 100644 index 00000000..ab7e5eb7 --- /dev/null +++ b/src/harness.ts @@ -0,0 +1,25 @@ +export type HarnessConfig = + | { + kind: "claude-code"; + inspection: "shell" | "dedicated"; + } + | { + kind: "codex"; + }; + +export type LegacyToolMode = "minimal" | "full" | "codex"; + +export function harnessFromLegacyToolMode(mode: LegacyToolMode): HarnessConfig { + switch (mode) { + case "minimal": + return { kind: "claude-code", inspection: "shell" }; + case "full": + return { kind: "claude-code", inspection: "dedicated" }; + case "codex": + return { kind: "codex" }; + } +} + +export function usesDedicatedInspection(harness: HarnessConfig): boolean { + return harness.kind === "claude-code" && harness.inspection === "dedicated"; +} diff --git a/src/server.ts b/src/server.ts index 16c2010d..df338733 100644 --- a/src/server.ts +++ b/src/server.ts @@ -23,6 +23,7 @@ import { registerArtifactTools, } from "./artifact-tools.js"; import { loadConfig, type ServerConfig, type WidgetMode } from "./config.js"; +import { usesDedicatedInspection } from "./harness.js"; import { createOpenAIIncomingArtifactAdapter, type IncomingArtifactAdapter, @@ -202,11 +203,11 @@ function serverInstructions(config: ServerConfig): string { ? " If the turn successfully modifies files by creating, editing, overwriting, deleting, moving, or applying patches, call show_changes exactly once for that workspace after the final related file change and before your final response so the user can inspect the aggregate diff for that turn. Do not call it after every individual file change; do not skip it because individual file-change tools already returned diffs." : ""; - if (config.toolMode === "codex") { + if (config.harness.kind === "codex") { return `Use DevSpace for coding work. Call ${toolNames.openWorkspace} once for each project folder or isolated worktree, then keep using its workspaceId. During continued work in the same project or worktree, do not call ${toolNames.openWorkspace} again. Open another workspace only when changing projects, switching checkout/worktree mode, creating another isolated worktree, or when the current workspaceId is rejected. Use ${toolNames.read} for direct file reads, apply_patch for all file modifications, exec_command for inspection, tests, builds, and other commands, and write_stdin to poll or interact with running processes. Follow instructions returned by ${toolNames.openWorkspace}; read applicable instruction and skill files before working in their scope.${artifactInstruction}${showChangesInstruction}`; } - const inspection = config.toolMode !== "full" + const inspection = !usesDedicatedInspection(config.harness) ? `In minimal tool mode, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} are disabled; use ${toolNames.shell} with command-line tools such as grep, rg, find, ls, and tree for search and directory inspection. ` : `Prefer ${toolNames.read}, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} for file inspection. `; @@ -1055,7 +1056,7 @@ export function createMcpServer( }, ); - if (config.toolMode !== "codex") { + if (config.harness.kind === "claude-code") { registerAppTool( server, toolNames.write, @@ -1221,7 +1222,7 @@ export function createMcpServer( ); } - if (config.toolMode === "codex") { + if (config.harness.kind === "codex") { registerAppTool( server, "apply_patch", @@ -1351,7 +1352,7 @@ export function createMcpServer( ); } - if (config.toolMode === "full") { + if (usesDedicatedInspection(config.harness)) { registerAppTool( server, toolNames.grep, @@ -1562,13 +1563,13 @@ export function createMcpServer( ); } - if (config.toolMode !== "codex") { + if (config.harness.kind === "claude-code") { registerAppTool( server, toolNames.shell, { title: "Bash", - description: config.toolMode !== "full" + description: !usesDedicatedInspection(config.harness) ? `Run a shell command in a workspace. Use only for tests, builds, git inspection, package scripts, search, file discovery, and directory inspection. In minimal tool mode, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} are disabled; use command-line tools such as grep, rg, find, ls, and tree for those read-only inspection actions. Do not use ${toolNames.shell} to create or modify files. Do not use shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or generated scripts to write project files; use ${toolNames.edit} for targeted changes and ${toolNames.write} for new files or full rewrites. Prefer ${toolNames.read} for direct file reads. This is powerful execution and should only be exposed behind strong authentication.` : `Run a shell command in a workspace. Use only for tests, builds, git inspection, package scripts, and commands that are better executed by the shell. Do not use ${toolNames.shell} to create or modify files. Do not use shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or generated scripts to write project files; use ${toolNames.edit} for targeted changes and ${toolNames.write} for new files or full rewrites. Prefer ${toolNames.read}, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} for file inspection. This is powerful execution and should only be exposed behind strong authentication.`, inputSchema: { @@ -1654,7 +1655,7 @@ export function createMcpServer( ); } - if (config.toolMode === "codex") { + if (config.harness.kind === "codex") { registerCodexProcessTools(server, config, workspaces, processSessions); } From 812f613a4e27611eb5c15fdc3ac718a65c11db34 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:00:49 +0530 Subject: [PATCH 04/14] test(harness): lock tool contracts --- src/server.test.ts | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/server.test.ts b/src/server.test.ts index cb29d11c..b35de516 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -80,6 +80,30 @@ test("open_workspace keeps lifecycle flags out of model output and preserves com assert.ok(Array.isArray(card.agents)); }); +test("legacy tool modes resolve to the intended coding harness tool contracts", async (t) => { + const cases = [ + { + mode: "minimal" as const, + tools: ["open_workspace", "read", "write", "edit", "bash"], + }, + { + mode: "full" as const, + tools: ["open_workspace", "read", "write", "edit", "grep", "glob", "ls", "bash"], + }, + { + mode: "codex" as const, + tools: ["open_workspace", "read", "apply_patch", "exec_command", "write_stdin"], + }, + ]; + + for (const { mode, tools } of cases) { + const context = await fixture(t, { toolMode: mode }); + const listed = await context.client.listTools(); + assert.deepEqual(listed.tools.map((tool) => tool.name), tools); + await context.close(); + } +}); + test("open_workspace refreshes provider availability for each catalog", async (t) => { let available = false; const context = await fixture(t, { @@ -247,6 +271,7 @@ async function fixture( git?: boolean; localAgentProviders?: LocalAgentProviderAvailability[] | (() => LocalAgentProviderAvailability[]); subagents?: SubagentsConfig; + toolMode?: "minimal" | "full" | "codex"; } = {}, ): Promise { const root = await mkdtemp(join(tmpdir(), "devspace-server-test-")); @@ -285,7 +310,7 @@ async function fixture( DEVSPACE_WORKTREE_ROOT: join(root, ".worktrees"), DEVSPACE_AGENT_DIR: agentDir, DEVSPACE_WIDGETS: "full", - DEVSPACE_TOOL_MODE: "full", + DEVSPACE_TOOL_MODE: options.toolMode ?? "full", DEVSPACE_SUBAGENTS: options.localAgentProviders ? "1" : "0", DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", PORT: "1", From 176a31dba37a2fdfad7b5dec98938e991fe39a8d Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:04:18 +0530 Subject: [PATCH 05/14] refactor(runtime): compile harness and artifact capabilities --- src/harness.ts | 47 ++++++++++++++++++++++++++++++++ src/runtime-config.ts | 32 ++++++++++++++++++++++ src/server.test.ts | 5 ++-- src/server.ts | 63 ++++++++++++++++++++----------------------- 4 files changed, 111 insertions(+), 36 deletions(-) create mode 100644 src/runtime-config.ts diff --git a/src/harness.ts b/src/harness.ts index ab7e5eb7..931d7eac 100644 --- a/src/harness.ts +++ b/src/harness.ts @@ -9,6 +9,19 @@ export type HarnessConfig = export type LegacyToolMode = "minimal" | "full" | "codex"; +export type HarnessToolGroup = + | "write-edit" + | "dedicated-inspection" + | "bash" + | "apply-patch" + | "process-session"; + +export interface CompiledHarness { + toolGroups: readonly HarnessToolGroup[]; + instructions: string; + bashDescription?: string; +} + export function harnessFromLegacyToolMode(mode: LegacyToolMode): HarnessConfig { switch (mode) { case "minimal": @@ -23,3 +36,37 @@ export function harnessFromLegacyToolMode(mode: LegacyToolMode): HarnessConfig { export function usesDedicatedInspection(harness: HarnessConfig): boolean { return harness.kind === "claude-code" && harness.inspection === "dedicated"; } + +export function compileHarness( + harness: HarnessConfig, + options: { skillsEnabled: boolean }, +): CompiledHarness { + if (harness.kind === "codex") { + return { + toolGroups: ["apply-patch", "process-session"], + instructions: + "Use DevSpace for coding work. Call open_workspace once for each project folder or isolated worktree, then keep using its workspaceId. During continued work in the same project or worktree, do not call open_workspace again. Open another workspace only when changing projects, switching checkout/worktree mode, creating another isolated worktree, or when the current workspaceId is rejected. Use read for direct file reads, apply_patch for all file modifications, exec_command for inspection, tests, builds, and other commands, and write_stdin to poll or interact with running processes. Follow instructions returned by open_workspace; read applicable instruction and skill files before working in their scope.", + }; + } + + const dedicatedInspection = harness.inspection === "dedicated"; + const inspectionInstruction = dedicatedInspection + ? "Prefer read, grep, glob, and ls for file inspection. " + : "In shell inspection mode, grep, glob, and ls are disabled; use bash with command-line tools such as grep, rg, find, ls, and tree for search and directory inspection. "; + const skillsInstruction = options.skillsEnabled + ? "When open_workspace returns available skills and a task matches a skill, use read to read that skill's path before proceeding. Skill paths may be outside the workspace, but read only permits advertised SKILL.md files and files under already-loaded skill directories. " + : ""; + const commonInstruction = + "Use DevSpace for coding work. Call open_workspace once for each project folder or isolated worktree, then keep using its workspaceId. During continued work in the same project or worktree, do not call open_workspace again. Open another workspace only when changing projects, switching checkout/worktree mode, creating another isolated worktree, or when the current workspaceId is rejected. Follow instructions returned by open_workspace. Before working under a path listed in availableAgentsFiles, use read to inspect that instruction file and follow it. "; + + return { + toolGroups: dedicatedInspection + ? ["write-edit", "dedicated-inspection", "bash"] + : ["write-edit", "bash"], + instructions: + `${commonInstruction}${skillsInstruction}${inspectionInstruction}Prefer edit for targeted modifications, write only for new files or complete rewrites, and bash for tests, builds, git inspection, package scripts, and commands that are better executed by the shell. Do not create or modify files with bash; avoid shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or any command whose purpose is to write project files.`, + bashDescription: dedicatedInspection + ? "Run a shell command in a workspace. Use only for tests, builds, git inspection, package scripts, and commands that are better executed by the shell. Do not use bash to create or modify files. Do not use shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or generated scripts to write project files; use edit for targeted changes and write for new files or full rewrites. Prefer read, grep, glob, and ls for file inspection. This is powerful execution and should only be exposed behind strong authentication." + : "Run a shell command in a workspace. Use only for tests, builds, git inspection, package scripts, search, file discovery, and directory inspection. In shell inspection mode, grep, glob, and ls are disabled; use command-line tools such as grep, rg, find, ls, and tree for those read-only inspection actions. Do not use bash to create or modify files. Do not use shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or generated scripts to write project files; use edit for targeted changes and write for new files or full rewrites. Prefer read for direct file reads. This is powerful execution and should only be exposed behind strong authentication.", + }; +} diff --git a/src/runtime-config.ts b/src/runtime-config.ts new file mode 100644 index 00000000..1a58bc48 --- /dev/null +++ b/src/runtime-config.ts @@ -0,0 +1,32 @@ +import type { ServerConfig } from "./config.js"; +import { compileHarness, type CompiledHarness } from "./harness.js"; + +export type ArtifactCapability = + | { + status: "available"; + maxFileBytes: number; + } + | { + status: "unavailable"; + reason: "disabled" | "unsupported-platform"; + }; + +export interface RuntimeConfig extends ServerConfig { + runtimeHarness: CompiledHarness; + artifactCapability: ArtifactCapability; +} + +export function compileRuntime( + config: ServerConfig, + environment: { artifactDownloadSupported: boolean }, +): RuntimeConfig { + return { + ...config, + runtimeHarness: compileHarness(config.harness, { skillsEnabled: config.skillsEnabled }), + artifactCapability: !config.artifactsEnabled + ? { status: "unavailable", reason: "disabled" } + : environment.artifactDownloadSupported + ? { status: "available", maxFileBytes: config.artifactMaxFileBytes } + : { status: "unavailable", reason: "unsupported-platform" }, + }; +} diff --git a/src/server.test.ts b/src/server.test.ts index b35de516..2e027071 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -14,6 +14,7 @@ import type { SubagentsConfig } from "./local-agent-config.js"; import { createReviewCheckpointManager } from "./review-checkpoints.js"; import { ProcessSessionManager } from "./process-sessions.js"; import { createMcpServer } from "./server.js"; +import { compileRuntime } from "./runtime-config.js"; import { SqliteWorkspaceStore } from "./workspace-store.js"; import { WorkspaceRegistry } from "./workspaces.js"; @@ -224,7 +225,7 @@ test("checkout reuse and context suppression survive a registry restart", async const restoredStore = new SqliteWorkspaceStore(context.stateDir); const restoredServer = createMcpServer( - context.config, + compileRuntime(context.config, { artifactDownloadSupported: true }), new WorkspaceRegistry(context.config, restoredStore), createReviewCheckpointManager(), new ProcessSessionManager(), @@ -338,7 +339,7 @@ async function fixture( const store = new SqliteWorkspaceStore(stateDir); const workspaces = new WorkspaceRegistry(config, store); const server = createMcpServer( - config, + compileRuntime(config, { artifactDownloadSupported: true }), workspaces, createReviewCheckpointManager(), new ProcessSessionManager(), diff --git a/src/server.ts b/src/server.ts index df338733..70adb85e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -23,7 +23,8 @@ import { registerArtifactTools, } from "./artifact-tools.js"; import { loadConfig, type ServerConfig, type WidgetMode } from "./config.js"; -import { usesDedicatedInspection } from "./harness.js"; +import type { HarnessToolGroup } from "./harness.js"; +import { compileRuntime, type RuntimeConfig } from "./runtime-config.js"; import { createOpenAIIncomingArtifactAdapter, type IncomingArtifactAdapter, @@ -194,8 +195,8 @@ interface ToolLogFields { error?: string; } -function serverInstructions(config: ServerConfig): string { - const artifactInstruction = config.artifactsEnabled && isArtifactDownloadSupportedPlatform() +function serverInstructions(config: RuntimeConfig): string { + const artifactInstruction = config.artifactCapability.status === "available" ? " When the user supplies or generates a file that is not present on the DevSpace host, use download_artifact with its native file value, the existing workspace ID, and a suitable relative destination path chosen from the user's request and project structure. The tool refuses to overwrite an existing destination and returns the normalized workspace-relative path. Use normal workspace tools when explicit inspection, replacement, movement, renaming, or deletion is needed. Do not recreate binary files with write/edit calls or place signed URLs, native file objects, base64 content, or invented host paths in shell commands or logs." : ""; const showChangesInstruction = @@ -203,21 +204,7 @@ function serverInstructions(config: ServerConfig): string { ? " If the turn successfully modifies files by creating, editing, overwriting, deleting, moving, or applying patches, call show_changes exactly once for that workspace after the final related file change and before your final response so the user can inspect the aggregate diff for that turn. Do not call it after every individual file change; do not skip it because individual file-change tools already returned diffs." : ""; - if (config.harness.kind === "codex") { - return `Use DevSpace for coding work. Call ${toolNames.openWorkspace} once for each project folder or isolated worktree, then keep using its workspaceId. During continued work in the same project or worktree, do not call ${toolNames.openWorkspace} again. Open another workspace only when changing projects, switching checkout/worktree mode, creating another isolated worktree, or when the current workspaceId is rejected. Use ${toolNames.read} for direct file reads, apply_patch for all file modifications, exec_command for inspection, tests, builds, and other commands, and write_stdin to poll or interact with running processes. Follow instructions returned by ${toolNames.openWorkspace}; read applicable instruction and skill files before working in their scope.${artifactInstruction}${showChangesInstruction}`; - } - - const inspection = !usesDedicatedInspection(config.harness) - ? `In minimal tool mode, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} are disabled; use ${toolNames.shell} with command-line tools such as grep, rg, find, ls, and tree for search and directory inspection. ` - : `Prefer ${toolNames.read}, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} for file inspection. `; - - const skills = config.skillsEnabled - ? `When ${toolNames.openWorkspace} returns available skills and a task matches a skill, use ${toolNames.read} to read that skill's path before proceeding. Skill paths may be outside the workspace, but ${toolNames.read} only permits advertised SKILL.md files and files under already-loaded skill directories. ` - : ""; - - const agentsMd = `Follow instructions returned by ${toolNames.openWorkspace}. Before working under a path listed in availableAgentsFiles, use ${toolNames.read} to inspect that instruction file and follow it. `; - - return `Use DevSpace for coding work. Call ${toolNames.openWorkspace} once for each project folder or isolated worktree, then keep using its workspaceId. During continued work in the same project or worktree, do not call ${toolNames.openWorkspace} again. Open another workspace only when changing projects, switching checkout/worktree mode, creating another isolated worktree, or when the current workspaceId is rejected. ${agentsMd}${skills}${inspection}Prefer ${toolNames.edit} for targeted modifications, ${toolNames.write} only for new files or complete rewrites, and ${toolNames.shell} for tests, builds, git inspection, package scripts, and commands that are better executed by the shell. Do not create or modify files with ${toolNames.shell}; avoid shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or any command whose purpose is to write project files.${artifactInstruction}${showChangesInstruction}`; + return `${config.runtimeHarness.instructions}${artifactInstruction}${showChangesInstruction}`; } function formatVisibleAgent(agent: { @@ -706,7 +693,7 @@ function registerCodexProcessTools( } export function createMcpServer( - config: ServerConfig, + config: RuntimeConfig, workspaces: WorkspaceRegistry, reviewCheckpoints: ReturnType, processSessions: ProcessSessionManager, @@ -1056,7 +1043,7 @@ export function createMcpServer( }, ); - if (config.harness.kind === "claude-code") { + const registerWriteEditTools = () => { registerAppTool( server, toolNames.write, @@ -1220,9 +1207,9 @@ export function createMcpServer( }; }, ); - } + }; - if (config.harness.kind === "codex") { + const registerApplyPatchTool = () => { registerAppTool( server, "apply_patch", @@ -1295,7 +1282,7 @@ export function createMcpServer( }; }, ); - } + }; if (config.widgets === "changes") { registerAppTool( @@ -1352,7 +1339,7 @@ export function createMcpServer( ); } - if (usesDedicatedInspection(config.harness)) { + const registerDedicatedInspectionTools = () => { registerAppTool( server, toolNames.grep, @@ -1561,17 +1548,15 @@ export function createMcpServer( }; }, ); - } + }; - if (config.harness.kind === "claude-code") { + const registerBashTool = () => { registerAppTool( server, toolNames.shell, { title: "Bash", - description: !usesDedicatedInspection(config.harness) - ? `Run a shell command in a workspace. Use only for tests, builds, git inspection, package scripts, search, file discovery, and directory inspection. In minimal tool mode, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} are disabled; use command-line tools such as grep, rg, find, ls, and tree for those read-only inspection actions. Do not use ${toolNames.shell} to create or modify files. Do not use shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or generated scripts to write project files; use ${toolNames.edit} for targeted changes and ${toolNames.write} for new files or full rewrites. Prefer ${toolNames.read} for direct file reads. This is powerful execution and should only be exposed behind strong authentication.` - : `Run a shell command in a workspace. Use only for tests, builds, git inspection, package scripts, and commands that are better executed by the shell. Do not use ${toolNames.shell} to create or modify files. Do not use shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or generated scripts to write project files; use ${toolNames.edit} for targeted changes and ${toolNames.write} for new files or full rewrites. Prefer ${toolNames.read}, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} for file inspection. This is powerful execution and should only be exposed behind strong authentication.`, + description: config.runtimeHarness.bashDescription ?? "Run a shell command in a workspace.", inputSchema: { workspaceId: z .string() @@ -1653,13 +1638,20 @@ export function createMcpServer( }; }, ); - } + }; - if (config.harness.kind === "codex") { - registerCodexProcessTools(server, config, workspaces, processSessions); + const harnessRegistrations: Record void> = { + "write-edit": registerWriteEditTools, + "dedicated-inspection": registerDedicatedInspectionTools, + bash: registerBashTool, + "apply-patch": registerApplyPatchTool, + "process-session": () => registerCodexProcessTools(server, config, workspaces, processSessions), + }; + for (const group of config.runtimeHarness.toolGroups) { + harnessRegistrations[group](); } - if (config.artifactsEnabled && isArtifactDownloadSupportedPlatform()) { + if (config.artifactCapability.status === "available") { registerArtifactTools(server, { config, workspaces, @@ -1678,6 +1670,9 @@ export function createServer( config = loadConfig(), options: CreateServerOptions = {}, ): RunningServer { + const runtime = compileRuntime(config, { + artifactDownloadSupported: isArtifactDownloadSupportedPlatform(), + }); const incomingArtifactAdapters = options.incomingArtifactAdapters ?? [createOpenAIIncomingArtifactAdapter()]; const allowedHosts = config.allowedHosts.includes("*") @@ -1863,7 +1858,7 @@ export function createServer( }; const server = createMcpServer( - config, + runtime, workspaces, reviewCheckpoints, processSessions, From 66f1af156b8c82b13b9fc3f911544fe937580d6b Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:04:18 +0530 Subject: [PATCH 06/14] test(runtime): cover compiled capability plans --- package.json | 2 +- src/runtime-config.test.ts | 55 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 src/runtime-config.test.ts diff --git a/package.json b/package.json index 388f99e2..fcd5734a 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/config.test.ts && tsx src/runtime-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/runtime-config.test.ts b/src/runtime-config.test.ts new file mode 100644 index 00000000..21499a8d --- /dev/null +++ b/src/runtime-config.test.ts @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadConfig } from "./config.js"; +import { compileRuntime } from "./runtime-config.js"; + +const configDir = mkdtempSync(join(tmpdir(), "devspace-runtime-config-test-")); +const baseEnv = { + DEVSPACE_CONFIG_DIR: configDir, + DEVSPACE_ALLOWED_ROOTS: process.cwd(), + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", +}; + +const minimal = compileRuntime(loadConfig(baseEnv), { artifactDownloadSupported: true }); +assert.deepEqual(minimal.runtimeHarness.toolGroups, ["write-edit", "bash"]); + +const full = compileRuntime( + loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "full" }), + { artifactDownloadSupported: true }, +); +assert.deepEqual(full.runtimeHarness.toolGroups, ["write-edit", "dedicated-inspection", "bash"]); + +const codex = compileRuntime( + loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "codex" }), + { artifactDownloadSupported: true }, +); +assert.deepEqual(codex.runtimeHarness.toolGroups, ["apply-patch", "process-session"]); + +assert.deepEqual(minimal.artifactCapability, { + status: "unavailable", + reason: "disabled", +}); + +const unsupportedArtifacts = compileRuntime( + loadConfig({ ...baseEnv, DEVSPACE_ARTIFACTS: "1" }), + { artifactDownloadSupported: false }, +); +assert.deepEqual(unsupportedArtifacts.artifactCapability, { + status: "unavailable", + reason: "unsupported-platform", +}); + +const availableArtifacts = compileRuntime( + loadConfig({ + ...baseEnv, + DEVSPACE_ARTIFACTS: "1", + DEVSPACE_ARTIFACT_MAX_FILE_BYTES: "123", + }), + { artifactDownloadSupported: true }, +); +assert.deepEqual(availableArtifacts.artifactCapability, { + status: "available", + maxFileBytes: 123, +}); From eba4db46d70038c5b0fb72b589ebc180216a1eb1 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:15:44 +0530 Subject: [PATCH 07/14] refactor(runtime): remove superseded harness helper --- src/harness.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/harness.ts b/src/harness.ts index 931d7eac..fff3320c 100644 --- a/src/harness.ts +++ b/src/harness.ts @@ -33,10 +33,6 @@ export function harnessFromLegacyToolMode(mode: LegacyToolMode): HarnessConfig { } } -export function usesDedicatedInspection(harness: HarnessConfig): boolean { - return harness.kind === "claude-code" && harness.inspection === "dedicated"; -} - export function compileHarness( harness: HarnessConfig, options: { skillsEnabled: boolean }, From 92e6f166355dd6c64ecb3f4b9abdf8e29ef12e4e Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:06:24 +0530 Subject: [PATCH 08/14] refactor(presentation): model review presentation profiles --- src/config.test.ts | 14 +++++--- src/config.ts | 12 ++++--- src/presentation.ts | 75 +++++++++++++++++++++++++++++++++++++++++++ src/runtime-config.ts | 3 ++ 4 files changed, 96 insertions(+), 8 deletions(-) create mode 100644 src/presentation.ts diff --git a/src/config.test.ts b/src/config.test.ts index fd8ed336..58de89e2 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -11,10 +11,16 @@ const baseEnv = { DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", }; -assert.equal(loadConfig(baseEnv).widgets, "full"); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "changes" }).widgets, "changes"); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "full" }).widgets, "full"); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "off" }).widgets, "off"); +assert.deepEqual(loadConfig(baseEnv).presentation, { mode: "inline" }); +assert.deepEqual(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "changes" }).presentation, { + mode: "change-review", +}); +assert.deepEqual(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "full" }).presentation, { + mode: "inline", +}); +assert.deepEqual(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "off" }).presentation, { + mode: "off", +}); assert.deepEqual(loadConfig(baseEnv).harness, { kind: "claude-code", inspection: "shell", diff --git a/src/config.ts b/src/config.ts index 99723cf1..11340013 100644 --- a/src/config.ts +++ b/src/config.ts @@ -10,8 +10,12 @@ import { type HarnessConfig, type LegacyToolMode, } from "./harness.js"; +import { + presentationFromLegacyWidgetMode, + type LegacyWidgetMode, + type PresentationConfig, +} from "./presentation.js"; -export type WidgetMode = "off" | "changes" | "full"; const DEFAULT_OAUTH_ACCESS_TOKEN_TTL_SECONDS = 60 * 60; const DEFAULT_OAUTH_REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60; const DEFAULT_ARTIFACT_MAX_FILE_BYTES = 100 * 1024 * 1024; @@ -24,7 +28,7 @@ export interface ServerConfig { allowedHosts: string[]; publicBaseUrl: string; harness: HarnessConfig; - widgets: WidgetMode; + presentation: PresentationConfig; stateDir: string; worktreeRoot: string; artifactsEnabled: boolean; @@ -169,7 +173,7 @@ function parseLoggingConfig(env: NodeJS.ProcessEnv): LoggingConfig { }; } -function parseWidgetMode(value: string | undefined): WidgetMode { +function parseLegacyWidgetMode(value: string | undefined): LegacyWidgetMode { if (!value || value === "full") return "full"; if (value === "off" || value === "changes") return value; @@ -245,7 +249,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { allowedHosts: parseAllowedHosts(env.DEVSPACE_ALLOWED_HOSTS, derivedAllowedHosts), publicBaseUrl, harness: harnessFromLegacyToolMode(parseLegacyToolMode(env)), - widgets: parseWidgetMode(env.DEVSPACE_WIDGETS), + presentation: presentationFromLegacyWidgetMode(parseLegacyWidgetMode(env.DEVSPACE_WIDGETS)), stateDir: resolve(expandHomePath(env.DEVSPACE_STATE_DIR ?? files.config.stateDir ?? defaultStateDir())), worktreeRoot: resolve(expandHomePath(env.DEVSPACE_WORKTREE_ROOT ?? files.config.worktreeRoot ?? defaultWorktreeRoot())), artifactsEnabled: diff --git a/src/presentation.ts b/src/presentation.ts new file mode 100644 index 00000000..e1a84b07 --- /dev/null +++ b/src/presentation.ts @@ -0,0 +1,75 @@ +export type PresentationConfig = + | { mode: "off" } + | { mode: "inline" } + | { mode: "change-review" }; + +export type LegacyWidgetMode = "off" | "changes" | "full"; + +export type PresentationToolKind = + | "workspace" + | "read" + | "write" + | "edit" + | "search" + | "directory" + | "shell" + | "show_changes"; + +export type PresentationToolGroup = "change-review"; +export type WorkspacePresentationBehavior = "initialize-review"; + +export interface CompiledPresentation { + widgetKinds: readonly PresentationToolKind[]; + toolGroups: readonly PresentationToolGroup[]; + workspaceBehaviors: readonly WorkspacePresentationBehavior[]; + instructions: string; +} + +const INLINE_WIDGET_KINDS: readonly PresentationToolKind[] = [ + "workspace", + "read", + "write", + "edit", + "search", + "directory", + "shell", + "show_changes", +]; + +export function presentationFromLegacyWidgetMode(mode: LegacyWidgetMode): PresentationConfig { + switch (mode) { + case "off": + return { mode: "off" }; + case "full": + return { mode: "inline" }; + case "changes": + return { mode: "change-review" }; + } +} + +export function compilePresentation(config: PresentationConfig): CompiledPresentation { + switch (config.mode) { + case "off": + return { + widgetKinds: [], + toolGroups: [], + workspaceBehaviors: [], + instructions: "", + }; + case "inline": + return { + widgetKinds: INLINE_WIDGET_KINDS, + toolGroups: [], + workspaceBehaviors: [], + instructions: "", + }; + case "change-review": + return { + widgetKinds: ["workspace", "show_changes"], + toolGroups: ["change-review"], + workspaceBehaviors: ["initialize-review"], + instructions: + " If the turn successfully modifies files by creating, editing, overwriting, deleting, moving, or applying patches, call show_changes exactly once for that workspace after the final related file change and before your final response so the user can inspect the aggregate diff for that turn. Do not call it after every individual file change; do not skip it because individual file-change tools already returned diffs.", + }; + } +} diff --git a/src/runtime-config.ts b/src/runtime-config.ts index 1a58bc48..09788550 100644 --- a/src/runtime-config.ts +++ b/src/runtime-config.ts @@ -1,5 +1,6 @@ import type { ServerConfig } from "./config.js"; import { compileHarness, type CompiledHarness } from "./harness.js"; +import { compilePresentation, type CompiledPresentation } from "./presentation.js"; export type ArtifactCapability = | { @@ -13,6 +14,7 @@ export type ArtifactCapability = export interface RuntimeConfig extends ServerConfig { runtimeHarness: CompiledHarness; + runtimePresentation: CompiledPresentation; artifactCapability: ArtifactCapability; } @@ -23,6 +25,7 @@ export function compileRuntime( return { ...config, runtimeHarness: compileHarness(config.harness, { skillsEnabled: config.skillsEnabled }), + runtimePresentation: compilePresentation(config.presentation), artifactCapability: !config.artifactsEnabled ? { status: "unavailable", reason: "disabled" } : environment.artifactDownloadSupported From e9d02385d683380c3a47ab43be3c7932e9aef361 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:06:24 +0530 Subject: [PATCH 09/14] refactor(review): compose presentation behavior --- src/server.test.ts | 30 +++++++++++++++++++++- src/server.ts | 63 +++++++++++++++++++--------------------------- 2 files changed, 55 insertions(+), 38 deletions(-) diff --git a/src/server.test.ts b/src/server.test.ts index 2e027071..c98f2ccb 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -105,6 +105,29 @@ test("legacy tool modes resolve to the intended coding harness tool contracts", } }); +test("legacy widget modes resolve to the intended presentation contracts", async (t) => { + const full = await fixture(t, { widgetMode: "full" }); + const fullTools = await full.client.listTools(); + assert.equal(fullTools.tools.some((tool) => tool.name === "show_changes"), false); + assert.ok(toolUiMeta(fullTools.tools.find((tool) => tool.name === "open_workspace"))); + assert.ok(toolUiMeta(fullTools.tools.find((tool) => tool.name === "read"))); + await full.close(); + + const changes = await fixture(t, { widgetMode: "changes" }); + const changeTools = await changes.client.listTools(); + assert.ok(toolUiMeta(changeTools.tools.find((tool) => tool.name === "open_workspace"))); + assert.equal(toolUiMeta(changeTools.tools.find((tool) => tool.name === "read")), undefined); + assert.ok(toolUiMeta(changeTools.tools.find((tool) => tool.name === "show_changes"))); + await changes.close(); + + const off = await fixture(t, { widgetMode: "off" }); + const offTools = await off.client.listTools(); + assert.equal(offTools.tools.some((tool) => tool.name === "show_changes"), false); + assert.equal(toolUiMeta(offTools.tools.find((tool) => tool.name === "open_workspace")), undefined); + assert.equal(toolUiMeta(offTools.tools.find((tool) => tool.name === "read")), undefined); + await off.close(); +}); + test("open_workspace refreshes provider availability for each catalog", async (t) => { let available = false; const context = await fixture(t, { @@ -273,6 +296,7 @@ async function fixture( localAgentProviders?: LocalAgentProviderAvailability[] | (() => LocalAgentProviderAvailability[]); subagents?: SubagentsConfig; toolMode?: "minimal" | "full" | "codex"; + widgetMode?: "off" | "changes" | "full"; } = {}, ): Promise { const root = await mkdtemp(join(tmpdir(), "devspace-server-test-")); @@ -310,7 +334,7 @@ async function fixture( DEVSPACE_ALLOWED_ROOTS: root, DEVSPACE_WORKTREE_ROOT: join(root, ".worktrees"), DEVSPACE_AGENT_DIR: agentDir, - DEVSPACE_WIDGETS: "full", + DEVSPACE_WIDGETS: options.widgetMode ?? "full", DEVSPACE_TOOL_MODE: options.toolMode ?? "full", DEVSPACE_SUBAGENTS: options.localAgentProviders ? "1" : "0", DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", @@ -370,6 +394,10 @@ async function fixture( return { client, project, config, stateDir, close }; } +function toolUiMeta(tool: { _meta?: Record } | undefined): unknown { + return tool?._meta?.ui; +} + async function git(cwd: string, args: string[]): Promise { await execFileAsync("git", args, { cwd }); } diff --git a/src/server.ts b/src/server.ts index 70adb85e..aa78563e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -22,8 +22,13 @@ import { isArtifactDownloadSupportedPlatform, registerArtifactTools, } from "./artifact-tools.js"; -import { loadConfig, type ServerConfig, type WidgetMode } from "./config.js"; +import { loadConfig, type ServerConfig } from "./config.js"; import type { HarnessToolGroup } from "./harness.js"; +import type { + PresentationToolGroup, + PresentationToolKind, + WorkspacePresentationBehavior, +} from "./presentation.js"; import { compileRuntime, type RuntimeConfig } from "./runtime-config.js"; import { createOpenAIIncomingArtifactAdapter, @@ -117,16 +122,6 @@ interface DiffStats { removals: number; } -type ToolWidgetKind = - | "workspace" - | "read" - | "write" - | "edit" - | "search" - | "directory" - | "shell" - | "show_changes"; - interface ToolDefinitionMeta extends Record { ui: { resourceUri: string; @@ -142,22 +137,11 @@ interface ToolWidgetDescriptorMeta { _meta: ToolDefinitionMeta | EmptyToolDefinitionMeta; } -function shouldAttachWidget(mode: WidgetMode, kind: ToolWidgetKind): boolean { - switch (mode) { - case "off": - return false; - case "changes": - return kind === "workspace" || kind === "show_changes"; - case "full": - return true; - } -} - function toolWidgetDescriptorMeta( - config: ServerConfig, - kind: ToolWidgetKind, + config: RuntimeConfig, + kind: PresentationToolKind, ): ToolWidgetDescriptorMeta { - if (!shouldAttachWidget(config.widgets, kind)) return { _meta: {} }; + if (!config.runtimePresentation.widgetKinds.includes(kind)) return { _meta: {} }; return { _meta: { @@ -199,12 +183,7 @@ function serverInstructions(config: RuntimeConfig): string { const artifactInstruction = config.artifactCapability.status === "available" ? " When the user supplies or generates a file that is not present on the DevSpace host, use download_artifact with its native file value, the existing workspace ID, and a suitable relative destination path chosen from the user's request and project structure. The tool refuses to overwrite an existing destination and returns the normalized workspace-relative path. Use normal workspace tools when explicit inspection, replacement, movement, renaming, or deletion is needed. Do not recreate binary files with write/edit calls or place signed URLs, native file objects, base64 content, or invented host paths in shell commands or logs." : ""; - const showChangesInstruction = - config.widgets === "changes" - ? " If the turn successfully modifies files by creating, editing, overwriting, deleting, moving, or applying patches, call show_changes exactly once for that workspace after the final related file change and before your final response so the user can inspect the aggregate diff for that turn. Do not call it after every individual file change; do not skip it because individual file-change tools already returned diffs." - : ""; - - return `${config.runtimeHarness.instructions}${artifactInstruction}${showChangesInstruction}`; + return `${config.runtimeHarness.instructions}${artifactInstruction}${config.runtimePresentation.instructions}`; } function formatVisibleAgent(agent: { @@ -550,7 +529,7 @@ function processToolResponse( function registerCodexProcessTools( server: McpServer, - config: ServerConfig, + config: RuntimeConfig, workspaces: WorkspaceRegistry, processSessions: ProcessSessionManager, ): void { @@ -806,11 +785,14 @@ export function createMcpServer( { path, mode, baseRef }, { conversationScopeId: openAiConversationScopeId(_meta) }, ); - if (config.widgets === "changes") { - await reviewCheckpoints.initializeWorkspace({ + const presentationBehaviors: Record Promise> = { + "initialize-review": () => reviewCheckpoints.initializeWorkspace({ workspaceId: workspace.id, root: workspace.root, - }); + }), + }; + for (const behavior of config.runtimePresentation.workspaceBehaviors) { + await presentationBehaviors[behavior](); } const cardSkills = workspace.skills .filter((skill) => !skill.disableModelInvocation) @@ -1284,7 +1266,7 @@ export function createMcpServer( ); }; - if (config.widgets === "changes") { + const registerChangeReviewTool = () => { registerAppTool( server, "show_changes", @@ -1337,7 +1319,7 @@ export function createMcpServer( }; }, ); - } + }; const registerDedicatedInspectionTools = () => { registerAppTool( @@ -1651,6 +1633,13 @@ export function createMcpServer( harnessRegistrations[group](); } + const presentationRegistrations: Record void> = { + "change-review": registerChangeReviewTool, + }; + for (const group of config.runtimePresentation.toolGroups) { + presentationRegistrations[group](); + } + if (config.artifactCapability.status === "available") { registerArtifactTools(server, { config, From 8f8ff21c9594aaf00ddee3e3f4de9c41ec4f16d6 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:15:36 +0530 Subject: [PATCH 10/14] feat(config): load versioned jsonc configuration --- package-lock.json | 6 ++ package.json | 1 + src/cli.ts | 38 ++++--- src/config.ts | 114 ++++++++++++-------- src/harness.ts | 14 +++ src/presentation.ts | 8 ++ src/user-config.ts | 247 +++++++++++++++++++++++++++++++++++++++++--- 7 files changed, 354 insertions(+), 74 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5b5c247a..e8d69ce2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "diff": "^8.0.3", "drizzle-orm": "^0.45.2", "express": "^5.2.1", + "jsonc-parser": "^3.3.1", "lucide": "^1.24.0", "react": "^19.2.6", "react-dom": "^19.2.6", @@ -4450,6 +4451,11 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==" + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", diff --git a/package.json b/package.json index fcd5734a..73ab3485 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "diff": "^8.0.3", "drizzle-orm": "^0.45.2", "express": "^5.2.1", + "jsonc-parser": "^3.3.1", "lucide": "^1.24.0", "react": "^19.2.6", "react-dom": "^19.2.6", diff --git a/src/cli.ts b/src/cli.ts index 20e61649..13b4e3ce 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -143,7 +143,7 @@ async function runInit({ force }: { force: boolean }): Promise { hint: "Use DevSpace from Codex, Claude Code, OpenCode, Pi, and similar tools.", }, ], - initialValues: files.config.publicBaseUrl ? ["chatgpt"] : ["coding-agents"], + initialValues: files.config.server?.publicBaseUrl ? ["chatgpt"] : ["coding-agents"], required: true, }); if (prompts.isCancel(destinationAnswer)) throw new SetupCancelledError(); @@ -153,7 +153,7 @@ async function runInit({ force }: { force: boolean }): Promise { let allowedRoots: string[] | undefined; if (useChatGpt) { - const defaultRoots = files.config.allowedRoots?.join(", ") || process.cwd(); + const defaultRoots = files.config.server?.allowedRoots?.join(", ") || process.cwd(); const rootsAnswer = await textPrompt({ message: `Which project folders can DevSpace access? Press Enter to use ${defaultRoots}`, placeholder: defaultRoots, @@ -166,7 +166,7 @@ async function runInit({ force }: { force: boolean }): Promise { .filter(Boolean); } - const port = isValidPort(files.config.port) ? files.config.port : 7676; + const port = isValidPort(files.config.server?.port) ? files.config.server?.port : 7676; let publicBaseUrl: string | null = null; if (useChatGpt) { @@ -180,11 +180,11 @@ async function runInit({ force }: { force: boolean }): Promise { "Connect ChatGPT", ); publicBaseUrl = normalizePublicBaseUrl(await textPrompt({ - message: files.config.publicBaseUrl - ? `What public URL will ChatGPT connect to? Press Enter to keep ${files.config.publicBaseUrl}` + message: files.config.server?.publicBaseUrl + ? `What public URL will ChatGPT connect to? Press Enter to keep ${files.config.server.publicBaseUrl}` : "What public URL will ChatGPT connect to?", - placeholder: files.config.publicBaseUrl ?? "https://your-tunnel-host.example.com", - defaultValue: files.config.publicBaseUrl ?? "", + placeholder: files.config.server?.publicBaseUrl ?? "https://your-tunnel-host.example.com", + defaultValue: files.config.server?.publicBaseUrl ?? "", validate: validateRequiredPublicBaseUrl, })); } @@ -220,17 +220,21 @@ async function runInit({ force }: { force: boolean }): Promise { const config: DevspaceUserConfig = { ...files.config, - host: files.config.host ?? "127.0.0.1", - port, - ...(allowedRoots ? { allowedRoots } : {}), - publicBaseUrl, + version: 1, + server: { + ...files.config.server, + host: files.config.server?.host ?? "127.0.0.1", + port, + ...(allowedRoots ? { allowedRoots } : {}), + publicBaseUrl, + }, subagents, }; const auth = { ownerToken: files.auth.ownerToken ?? generateOwnerToken(), }; - writeDevspaceConfig(config, process.env, files.configDocument); + writeDevspaceConfig(config, process.env, files); writeDevspaceAuth(auth); const lines = [ @@ -322,7 +326,7 @@ async function serve(): Promise { async function runDoctor(): Promise { const files = loadDevspaceFiles(); console.log(`Config dir: ${files.dir}`); - console.log(`Config file: ${files.configExists ? files.configPath : "missing"}`); + console.log(`Config file: ${files.configSourcePath ?? "missing"}`); console.log(`Auth file: ${files.authExists ? files.authPath : "missing"}`); console.log(`Node: ${process.version} (${nodeVersionStatus()})`); console.log(`Node ABI: ${process.versions.modules}`); @@ -371,8 +375,12 @@ function runConfigCommand(args: string[]): void { writeDevspaceConfig({ ...files.config, - publicBaseUrl: normalizeOptionalPublicBaseUrl(value), - }, process.env, files.configDocument); + version: 1, + server: { + ...files.config.server, + publicBaseUrl: normalizeOptionalPublicBaseUrl(value), + }, + }, process.env, files); console.log(`Updated ${files.configPath}`); } diff --git a/src/config.ts b/src/config.ts index 11340013..65520342 100644 --- a/src/config.ts +++ b/src/config.ts @@ -98,7 +98,7 @@ function parseBoolean(value: string | undefined, name: string): boolean { throw new Error(`Invalid ${name}: ${value}`); } -function parseLegacyToolMode(env: NodeJS.ProcessEnv): LegacyToolMode { +function parseLegacyToolModeOverride(env: NodeJS.ProcessEnv): LegacyToolMode | undefined { const mode = env.DEVSPACE_TOOL_MODE; if (mode === "minimal" || mode === "full" || mode === "codex") return mode; if (mode) throw new Error(`Invalid DEVSPACE_TOOL_MODE: ${mode}`); @@ -106,24 +106,27 @@ function parseLegacyToolMode(env: NodeJS.ProcessEnv): LegacyToolMode { if (env.DEVSPACE_MINIMAL_TOOLS !== undefined) { return parseBoolean(env.DEVSPACE_MINIMAL_TOOLS, "DEVSPACE_MINIMAL_TOOLS") ? "minimal" : "full"; } - return "minimal"; + return undefined; } -function parseLogLevel(value: string | undefined): LogLevel { - if (!value || value === "info") return "info"; +function parseLogLevel(value: string | undefined, fallback: LogLevel = "info"): LogLevel { + if (!value) return fallback; + if (value === "info") return "info"; if (["silent", "error", "warn", "debug"].includes(value)) return value as LogLevel; throw new Error(`Invalid DEVSPACE_LOG_LEVEL: ${value}`); } -function parseLogFormat(value: string | undefined): LogFormat { - if (!value || value === "json") return "json"; +function parseLogFormat(value: string | undefined, fallback: LogFormat = "json"): LogFormat { + if (!value) return fallback; + if (value === "json") return "json"; if (value === "pretty") return "pretty"; throw new Error(`Invalid DEVSPACE_LOG_FORMAT: ${value}`); } -function parsePathList(value: string | undefined): string[] { +function parsePathList(value: string | undefined, fallback: string[] = []): string[] { + if (value === undefined) return fallback; return ( value ?.split(",") @@ -157,24 +160,34 @@ function parsePositiveInteger( return parsed; } -function parseLoggingConfig(env: NodeJS.ProcessEnv): LoggingConfig { +function parseLoggingConfig( + env: NodeJS.ProcessEnv, + stored: Partial = {}, +): LoggingConfig { return { - level: parseLogLevel(env.DEVSPACE_LOG_LEVEL), - format: parseLogFormat(env.DEVSPACE_LOG_FORMAT), + level: parseLogLevel(env.DEVSPACE_LOG_LEVEL, stored.level), + format: parseLogFormat(env.DEVSPACE_LOG_FORMAT, stored.format), requests: env.DEVSPACE_LOG_REQUESTS === undefined - ? true + ? stored.requests ?? true : parseBoolean(env.DEVSPACE_LOG_REQUESTS, "DEVSPACE_LOG_REQUESTS"), - assets: parseBoolean(env.DEVSPACE_LOG_ASSETS, "DEVSPACE_LOG_ASSETS"), + assets: env.DEVSPACE_LOG_ASSETS === undefined + ? stored.assets ?? false + : parseBoolean(env.DEVSPACE_LOG_ASSETS, "DEVSPACE_LOG_ASSETS"), toolCalls: env.DEVSPACE_LOG_TOOL_CALLS === undefined - ? true + ? stored.toolCalls ?? true : parseBoolean(env.DEVSPACE_LOG_TOOL_CALLS, "DEVSPACE_LOG_TOOL_CALLS"), - shellCommands: parseBoolean(env.DEVSPACE_LOG_SHELL_COMMANDS, "DEVSPACE_LOG_SHELL_COMMANDS"), - trustProxy: parseBoolean(env.DEVSPACE_TRUST_PROXY, "DEVSPACE_TRUST_PROXY"), + shellCommands: env.DEVSPACE_LOG_SHELL_COMMANDS === undefined + ? stored.shellCommands ?? false + : parseBoolean(env.DEVSPACE_LOG_SHELL_COMMANDS, "DEVSPACE_LOG_SHELL_COMMANDS"), + trustProxy: env.DEVSPACE_TRUST_PROXY === undefined + ? stored.trustProxy ?? false + : parseBoolean(env.DEVSPACE_TRUST_PROXY, "DEVSPACE_TRUST_PROXY"), }; } -function parseLegacyWidgetMode(value: string | undefined): LegacyWidgetMode { - if (!value || value === "full") return "full"; +function parseLegacyWidgetModeOverride(value: string | undefined): LegacyWidgetMode | undefined { + if (!value) return undefined; + if (value === "full") return "full"; if (value === "off" || value === "changes") return value; throw new Error(`Invalid DEVSPACE_WIDGETS: ${value}`); @@ -191,25 +204,33 @@ function parseRequiredSecret(value: string | undefined, name: string): string { return secret; } -function parseOAuthConfig(env: NodeJS.ProcessEnv, ownerToken: string | undefined): OAuthConfig { +function parseOAuthConfig( + env: NodeJS.ProcessEnv, + ownerToken: string | undefined, + stored: { + accessTokenTtlSeconds?: number; + refreshTokenTtlSeconds?: number; + scopes?: string[]; + allowedRedirectHosts?: string[]; + } = {}, +): OAuthConfig { return { ownerToken: parseRequiredSecret(env.DEVSPACE_OAUTH_OWNER_TOKEN ?? ownerToken, "DEVSPACE_OAUTH_OWNER_TOKEN"), accessTokenTtlSeconds: parsePositiveInteger( - env.DEVSPACE_OAUTH_ACCESS_TOKEN_TTL_SECONDS, + env.DEVSPACE_OAUTH_ACCESS_TOKEN_TTL_SECONDS ?? numberConfigValue(stored.accessTokenTtlSeconds), DEFAULT_OAUTH_ACCESS_TOKEN_TTL_SECONDS, "DEVSPACE_OAUTH_ACCESS_TOKEN_TTL_SECONDS", ), refreshTokenTtlSeconds: parsePositiveInteger( - env.DEVSPACE_OAUTH_REFRESH_TOKEN_TTL_SECONDS, + env.DEVSPACE_OAUTH_REFRESH_TOKEN_TTL_SECONDS ?? numberConfigValue(stored.refreshTokenTtlSeconds), DEFAULT_OAUTH_REFRESH_TOKEN_TTL_SECONDS, "DEVSPACE_OAUTH_REFRESH_TOKEN_TTL_SECONDS", ), - scopes: parseStringList(env.DEVSPACE_OAUTH_SCOPES, ["devspace"]), - allowedRedirectHosts: parseStringList(env.DEVSPACE_OAUTH_ALLOWED_REDIRECT_HOSTS, [ - "chatgpt.com", - "localhost", - "127.0.0.1", - ]), + scopes: parseStringList(env.DEVSPACE_OAUTH_SCOPES, stored.scopes ?? ["devspace"]), + allowedRedirectHosts: parseStringList( + env.DEVSPACE_OAUTH_ALLOWED_REDIRECT_HOSTS, + stored.allowedRedirectHosts ?? ["chatgpt.com", "localhost", "127.0.0.1"], + ), }; } @@ -227,10 +248,13 @@ function defaultAgentDir(): string { export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { const files = loadDevspaceFiles(env); - const host = env.HOST ?? files.config.host ?? "127.0.0.1"; - const port = parsePort(env.PORT ?? files.config.port); + const storedServer = files.config.server ?? {}; + const storedSkills = files.config.skills ?? {}; + const storedArtifacts = files.config.artifacts ?? {}; + const host = env.HOST ?? storedServer.host ?? "127.0.0.1"; + const port = parsePort(env.PORT ?? storedServer.port); const publicBaseUrl = parsePublicBaseUrl( - env.DEVSPACE_PUBLIC_BASE_URL ?? files.config.publicBaseUrl ?? localPublicBaseUrl(host, port), + env.DEVSPACE_PUBLIC_BASE_URL ?? storedServer.publicBaseUrl ?? localPublicBaseUrl(host, port), ); const derivedAllowedHosts = [ "localhost", @@ -238,38 +262,44 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { "::1", host, new URL(publicBaseUrl).hostname, - ...(files.config.allowedHosts ?? []), + ...(storedServer.allowedHosts ?? []), ]; + const legacyToolMode = parseLegacyToolModeOverride(env); + const legacyWidgetMode = parseLegacyWidgetModeOverride(env.DEVSPACE_WIDGETS); return { host, port, - oauth: parseOAuthConfig(env, files.auth.ownerToken), - allowedRoots: parseAllowedRoots(env.DEVSPACE_ALLOWED_ROOTS ?? files.config.allowedRoots), + oauth: parseOAuthConfig(env, files.auth.ownerToken, files.config.oauth), + allowedRoots: parseAllowedRoots(env.DEVSPACE_ALLOWED_ROOTS ?? storedServer.allowedRoots), allowedHosts: parseAllowedHosts(env.DEVSPACE_ALLOWED_HOSTS, derivedAllowedHosts), publicBaseUrl, - harness: harnessFromLegacyToolMode(parseLegacyToolMode(env)), - presentation: presentationFromLegacyWidgetMode(parseLegacyWidgetMode(env.DEVSPACE_WIDGETS)), - stateDir: resolve(expandHomePath(env.DEVSPACE_STATE_DIR ?? files.config.stateDir ?? defaultStateDir())), - worktreeRoot: resolve(expandHomePath(env.DEVSPACE_WORKTREE_ROOT ?? files.config.worktreeRoot ?? defaultWorktreeRoot())), + harness: legacyToolMode + ? harnessFromLegacyToolMode(legacyToolMode) + : files.config.harness ?? { kind: "claude-code", inspection: "shell" }, + presentation: legacyWidgetMode + ? presentationFromLegacyWidgetMode(legacyWidgetMode) + : files.config.presentation ?? { mode: "inline" }, + stateDir: resolve(expandHomePath(env.DEVSPACE_STATE_DIR ?? storedServer.stateDir ?? defaultStateDir())), + worktreeRoot: resolve(expandHomePath(env.DEVSPACE_WORKTREE_ROOT ?? storedServer.worktreeRoot ?? defaultWorktreeRoot())), artifactsEnabled: env.DEVSPACE_ARTIFACTS === undefined - ? files.config.artifactsEnabled === true + ? storedArtifacts.enabled === true : parseBoolean(env.DEVSPACE_ARTIFACTS, "DEVSPACE_ARTIFACTS"), artifactMaxFileBytes: parsePositiveInteger( - env.DEVSPACE_ARTIFACT_MAX_FILE_BYTES ?? numberConfigValue(files.config.artifactMaxFileBytes), + env.DEVSPACE_ARTIFACT_MAX_FILE_BYTES ?? numberConfigValue(storedArtifacts.maxFileBytes), DEFAULT_ARTIFACT_MAX_FILE_BYTES, "DEVSPACE_ARTIFACT_MAX_FILE_BYTES", ), skillsEnabled: env.DEVSPACE_SKILLS === undefined - ? true + ? storedSkills.enabled ?? true : parseBoolean(env.DEVSPACE_SKILLS, "DEVSPACE_SKILLS"), - skillPaths: parsePathList(env.DEVSPACE_SKILL_PATHS), + skillPaths: parsePathList(env.DEVSPACE_SKILL_PATHS, storedSkills.paths ?? []), devspaceSkillsDir: devspaceSkillsDir(env), devspaceAgentsDir: devspaceAgentsDir(env), subagents: resolveSubagentsConfig(files.config.subagents, env), - agentDir: resolve(expandHomePath(env.DEVSPACE_AGENT_DIR ?? files.config.agentDir ?? defaultAgentDir())), - logging: parseLoggingConfig(env), + agentDir: resolve(expandHomePath(env.DEVSPACE_AGENT_DIR ?? storedSkills.agentDir ?? defaultAgentDir())), + logging: parseLoggingConfig(env, files.config.logging), }; } diff --git a/src/harness.ts b/src/harness.ts index fff3320c..c9db89dd 100644 --- a/src/harness.ts +++ b/src/harness.ts @@ -1,3 +1,17 @@ +import * as z from "zod/v4"; + +export const harnessConfigSchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("claude-code").describe("Expose the Claude Code-style coding harness."), + inspection: z + .enum(["shell", "dedicated"]) + .describe("Use shell inspection or expose dedicated grep/glob/ls tools."), + }), + z.object({ + kind: z.literal("codex").describe("Expose the Codex-style coding harness."), + }), +]); + export type HarnessConfig = | { kind: "claude-code"; diff --git a/src/presentation.ts b/src/presentation.ts index e1a84b07..a971b844 100644 --- a/src/presentation.ts +++ b/src/presentation.ts @@ -1,3 +1,11 @@ +import * as z from "zod/v4"; + +export const presentationConfigSchema = z.discriminatedUnion("mode", [ + z.object({ mode: z.literal("off") }), + z.object({ mode: z.literal("inline") }), + z.object({ mode: z.literal("change-review") }), +]); + export type PresentationConfig = | { mode: "off" } | { mode: "inline" } diff --git a/src/user-config.ts b/src/user-config.ts index d8c89f16..15db5407 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -7,11 +7,88 @@ import { } from "node:fs"; import { homedir } from "node:os"; import { join, resolve } from "node:path"; +import { + applyEdits, + modify, + parse as parseJsonc, + printParseErrorCode, + type ParseError, +} from "jsonc-parser"; import * as z from "zod/v4"; +import { harnessConfigSchema } from "./harness.js"; +import { + resolveSubagentsConfig, + storedSubagentsConfigSchema, + subagentsConfigSchema, +} from "./local-agent-config.js"; +import { presentationConfigSchema } from "./presentation.js"; import { expandHomePath } from "./roots.js"; -import { storedSubagentsConfigSchema } from "./local-agent-config.js"; -const devspaceUserConfigSchema = z.object({ +export const DEVSPACE_CONFIG_VERSION = 1 as const; +export const DEVSPACE_CONFIG_SCHEMA_URL = + "https://raw.githubusercontent.com/Waishnav/devspace/refs/tags/v1.1.0/schema/devspace-config.schema.json"; + +const serverConfigSchema = z.object({ + host: z.string().optional().describe("Local bind host. Defaults to 127.0.0.1."), + port: z.number().int().min(1).max(65535).optional().describe("Local MCP server port."), + allowedRoots: z.array(z.string()).optional().describe("Project roots DevSpace may open."), + publicBaseUrl: z.string().nullable().optional().describe("Public origin used by remote MCP hosts."), + allowedHosts: z.array(z.string()).optional().describe("Optional HTTP Host allowlist."), + stateDir: z.string().optional().describe("Directory containing persisted DevSpace state."), + worktreeRoot: z.string().optional().describe("Directory for DevSpace-managed Git worktrees."), +}); + +const skillsConfigSchema = z.object({ + enabled: z.boolean().optional().describe("Whether skills are exposed to the host model."), + paths: z.array(z.string()).optional().describe("Additional skill directories."), + agentDir: z.string().optional().describe("Compatibility agent directory. Defaults to ~/.codex."), +}); + +const artifactsConfigSchema = z.object({ + enabled: z.boolean().optional().describe("Enable native MCP-host artifact download."), + maxFileBytes: z.number().int().positive().optional().describe("Maximum bytes accepted for one artifact."), +}); + +const loggingConfigSchema = z.object({ + level: z.enum(["silent", "error", "warn", "info", "debug"]).optional(), + format: z.enum(["json", "pretty"]).optional(), + requests: z.boolean().optional(), + assets: z.boolean().optional(), + toolCalls: z.boolean().optional(), + shellCommands: z.boolean().optional(), + trustProxy: z.boolean().optional(), +}); + +const oauthConfigSchema = z.object({ + accessTokenTtlSeconds: z.number().int().positive().optional(), + refreshTokenTtlSeconds: z.number().int().positive().optional(), + scopes: z.array(z.string().min(1)).optional(), + allowedRedirectHosts: z.array(z.string().min(1)).optional(), +}); + +export const devspaceConfigSchema = z.object({ + $schema: z.string().optional().describe("JSON Schema URL used by editors."), + version: z.literal(DEVSPACE_CONFIG_VERSION), + server: serverConfigSchema.optional(), + harness: harnessConfigSchema.optional(), + presentation: presentationConfigSchema.optional(), + skills: skillsConfigSchema.optional(), + artifacts: artifactsConfigSchema.optional(), + subagents: subagentsConfigSchema.optional(), + logging: loggingConfigSchema.optional(), + oauth: oauthConfigSchema.optional(), +}); + +export function createDevspaceConfigJsonSchema(): Record { + return { + ...(z.toJSONSchema(devspaceConfigSchema, { target: "draft-2020-12" }) as Record), + $id: DEVSPACE_CONFIG_SCHEMA_URL, + title: "DevSpace configuration", + description: "Versioned configuration for the DevSpace server and coding harness.", + }; +} + +const legacyDevspaceUserConfigSchema = z.object({ host: z.string().optional(), port: z.number().optional(), allowedRoots: z.array(z.string()).optional(), @@ -29,19 +106,23 @@ const devspaceAuthConfigSchema = z.object({ ownerToken: z.string().optional(), }); -export type DevspaceUserConfig = z.infer; - +export type DevspaceUserConfig = z.infer; export type DevspaceAuthConfig = z.infer; export interface DevspaceFiles { dir: string; configPath: string; + legacyConfigPath: string; authPath: string; configExists: boolean; + jsoncConfigExists: boolean; + legacyConfigExists: boolean; authExists: boolean; config: DevspaceUserConfig; auth: DevspaceAuthConfig; configDocument: Record; + configSourcePath?: string; + configSourceText?: string; } export function devspaceConfigDir(env: NodeJS.ProcessEnv = process.env): string { @@ -49,6 +130,10 @@ export function devspaceConfigDir(env: NodeJS.ProcessEnv = process.env): string } export function devspaceConfigPath(env: NodeJS.ProcessEnv = process.env): string { + return join(devspaceConfigDir(env), "config.jsonc"); +} + +export function devspaceLegacyConfigPath(env: NodeJS.ProcessEnv = process.env): string { return join(devspaceConfigDir(env), "config.json"); } @@ -66,34 +151,63 @@ export function devspaceAgentsDir(env: NodeJS.ProcessEnv = process.env): string export function loadDevspaceFiles(env: NodeJS.ProcessEnv = process.env): DevspaceFiles { const dir = devspaceConfigDir(env); - const configPath = join(dir, "config.json"); + const configPath = join(dir, "config.jsonc"); + const legacyConfigPath = join(dir, "config.json"); const authPath = join(dir, "auth.json"); - const configExists = existsSync(configPath); + const jsoncConfigExists = existsSync(configPath); + const legacyConfigExists = existsSync(legacyConfigPath); const authExists = existsSync(authPath); - - const configDocument = configExists ? readJsonObject(configPath) : {}; + const configSourcePath = jsoncConfigExists + ? configPath + : legacyConfigExists + ? legacyConfigPath + : undefined; + const configSourceText = configSourcePath ? readFileSync(configSourcePath, "utf8") : undefined; + const configDocument = configSourceText + ? readConfigDocument(configSourceText, configSourcePath!, jsoncConfigExists) + : {}; const authDocument = authExists ? readJsonObject(authPath) : {}; + const config = jsoncConfigExists + ? parseDocument(devspaceConfigSchema, configDocument, configPath) + : legacyConfigExists + ? migrateLegacyConfig(parseDocument(legacyDevspaceUserConfigSchema, configDocument, legacyConfigPath)) + : { version: DEVSPACE_CONFIG_VERSION }; return { dir, configPath, + legacyConfigPath, authPath, - configExists, + configExists: jsoncConfigExists || legacyConfigExists, + jsoncConfigExists, + legacyConfigExists, authExists, - config: parseDocument(devspaceUserConfigSchema, configDocument, configPath), + config, auth: parseDocument(devspaceAuthConfigSchema, authDocument, authPath), configDocument, + configSourcePath, + configSourceText, }; } export function writeDevspaceConfig( config: DevspaceUserConfig, env: NodeJS.ProcessEnv = process.env, - existingDocument: Record = {}, + source: Pick | undefined = undefined, ): string { const filePath = devspaceConfigPath(env); mkdirSync(devspaceConfigDir(env), { recursive: true }); - writeJsonFile(filePath, { ...existingDocument, ...config }, 0o600); + const canonical = { + ...config, + $schema: DEVSPACE_CONFIG_SCHEMA_URL, + version: DEVSPACE_CONFIG_VERSION, + } satisfies DevspaceUserConfig; + + const existingJsonc = source?.jsoncConfigExists ? source.configSourceText : undefined; + const content = existingJsonc + ? updateJsoncDocument(existingJsonc, canonical) + : JSON.stringify(canonical, null, 2) + "\n"; + writeFileSync(filePath, content, { mode: 0o600 }); return filePath; } @@ -111,19 +225,78 @@ export function generateOwnerToken(): string { return randomBytes(32).toString("base64url"); } +function migrateLegacyConfig(config: z.infer): DevspaceUserConfig { + const server = compactObject({ + host: config.host, + port: config.port, + allowedRoots: config.allowedRoots, + publicBaseUrl: config.publicBaseUrl, + allowedHosts: config.allowedHosts, + stateDir: config.stateDir, + worktreeRoot: config.worktreeRoot, + }); + const artifacts = compactObject({ + enabled: config.artifactsEnabled, + maxFileBytes: config.artifactMaxFileBytes, + }); + const skills = compactObject({ agentDir: config.agentDir }); + + return { + version: DEVSPACE_CONFIG_VERSION, + ...(Object.keys(server).length > 0 ? { server } : {}), + ...(Object.keys(artifacts).length > 0 ? { artifacts } : {}), + ...(Object.keys(skills).length > 0 ? { skills } : {}), + ...(config.subagents !== undefined + ? { subagents: resolveSubagentsConfig(config.subagents, {}) } + : {}), + }; +} + +function compactObject>(value: T): Partial { + return Object.fromEntries( + Object.entries(value).filter(([, entry]) => entry !== undefined), + ) as Partial; +} + +function readConfigDocument( + source: string, + filePath: string, + allowComments: boolean, +): Record { + if (!allowComments) return readJsonText(source, filePath); + + const errors: ParseError[] = []; + const parsed: unknown = parseJsonc(source, errors, { + allowTrailingComma: true, + disallowComments: false, + }); + if (errors.length > 0) { + const reason = errors.map((error) => `${printParseErrorCode(error.error)} at offset ${error.offset}`).join(", "); + throw new Error(`Unable to read ${filePath}: ${reason}`); + } + return assertRecord(parsed, filePath); +} + function readJsonObject(filePath: string): Record { + return readJsonText(readFileSync(filePath, "utf8"), filePath); +} + +function readJsonText(source: string, filePath: string): Record { try { - const parsed: unknown = JSON.parse(readFileSync(filePath, "utf8")); - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - throw new Error("expected a JSON object"); - } - return parsed as Record; + return assertRecord(JSON.parse(source) as unknown, filePath); } catch (error) { const reason = error instanceof Error ? error.message : String(error); throw new Error(`Unable to read ${filePath}: ${reason}`); } } +function assertRecord(value: unknown, filePath: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`Unable to read ${filePath}: expected a configuration object`); + } + return value as Record; +} + function parseDocument( schema: z.ZodType, document: Record, @@ -135,6 +308,46 @@ function parseDocument( throw new Error(`Invalid ${filePath}: ${z.prettifyError(result.error)}`); } +function updateJsoncDocument(source: string, config: DevspaceUserConfig): string { + let updated = source; + const formattingOptions = { insertSpaces: true, tabSize: 2, eol: "\n" }; + + for (const [path, value] of configEntries(config)) { + updated = applyEdits(updated, modify(updated, path, value, { formattingOptions })); + } + + return updated.endsWith("\n") ? updated : `${updated}\n`; +} + +function configEntries(config: DevspaceUserConfig): Array<[Array, unknown]> { + const entries: Array<[Array, unknown]> = [ + [["$schema"], config.$schema], + [["version"], config.version], + ]; + + const sectionKeys = { + server: ["host", "port", "allowedRoots", "publicBaseUrl", "allowedHosts", "stateDir", "worktreeRoot"], + harness: ["kind", "inspection"], + presentation: ["mode"], + skills: ["enabled", "paths", "agentDir"], + artifacts: ["enabled", "maxFileBytes"], + subagents: ["enabled", "providers"], + logging: ["level", "format", "requests", "assets", "toolCalls", "shellCommands", "trustProxy"], + oauth: ["accessTokenTtlSeconds", "refreshTokenTtlSeconds", "scopes", "allowedRedirectHosts"], + } as const; + + for (const [section, keys] of Object.entries(sectionKeys) as Array< + [keyof typeof sectionKeys, readonly string[]] + >) { + const value = config[section] as Record | undefined; + if (value === undefined) continue; + for (const key of keys) { + entries.push([[section, key], value?.[key]]); + } + } + return entries; +} + function writeJsonFile(filePath: string, value: unknown, mode: number): void { writeFileSync(filePath, JSON.stringify(value, null, 2) + "\n", { mode }); } From 2b87315d9fd9e17a073ef04aa35b670bf01ca483 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:16:11 +0530 Subject: [PATCH 11/14] feat(config): publish v1 json schema --- package.json | 1 + schema/devspace-config.schema.json | 308 +++++++++++++++++++++++++++++ src/config-schema.test.ts | 9 + 3 files changed, 318 insertions(+) create mode 100644 schema/devspace-config.schema.json create mode 100644 src/config-schema.test.ts diff --git a/package.json b/package.json index 73ab3485..3d192489 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "dist", "docs", "examples", + "schema", "scripts", "skills", "README.md" diff --git a/schema/devspace-config.schema.json b/schema/devspace-config.schema.json new file mode 100644 index 00000000..23342e06 --- /dev/null +++ b/schema/devspace-config.schema.json @@ -0,0 +1,308 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "$schema": { + "description": "JSON Schema URL used by editors.", + "type": "string" + }, + "version": { + "type": "number", + "const": 1 + }, + "server": { + "type": "object", + "properties": { + "host": { + "description": "Local bind host. Defaults to 127.0.0.1.", + "type": "string" + }, + "port": { + "description": "Local MCP server port.", + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "allowedRoots": { + "description": "Project roots DevSpace may open.", + "type": "array", + "items": { + "type": "string" + } + }, + "publicBaseUrl": { + "description": "Public origin used by remote MCP hosts.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "allowedHosts": { + "description": "Optional HTTP Host allowlist.", + "type": "array", + "items": { + "type": "string" + } + }, + "stateDir": { + "description": "Directory containing persisted DevSpace state.", + "type": "string" + }, + "worktreeRoot": { + "description": "Directory for DevSpace-managed Git worktrees.", + "type": "string" + } + }, + "additionalProperties": false + }, + "harness": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "claude-code", + "description": "Expose the Claude Code-style coding harness." + }, + "inspection": { + "type": "string", + "enum": [ + "shell", + "dedicated" + ], + "description": "Use shell inspection or expose dedicated grep/glob/ls tools." + } + }, + "required": [ + "kind", + "inspection" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "codex", + "description": "Expose the Codex-style coding harness." + } + }, + "required": [ + "kind" + ], + "additionalProperties": false + } + ] + }, + "presentation": { + "oneOf": [ + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "const": "off" + } + }, + "required": [ + "mode" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "const": "inline" + } + }, + "required": [ + "mode" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "const": "change-review" + } + }, + "required": [ + "mode" + ], + "additionalProperties": false + } + ] + }, + "skills": { + "type": "object", + "properties": { + "enabled": { + "description": "Whether skills are exposed to the host model.", + "type": "boolean" + }, + "paths": { + "description": "Additional skill directories.", + "type": "array", + "items": { + "type": "string" + } + }, + "agentDir": { + "description": "Compatibility agent directory. Defaults to ~/.codex.", + "type": "string" + } + }, + "additionalProperties": false + }, + "artifacts": { + "type": "object", + "properties": { + "enabled": { + "description": "Enable native MCP-host artifact download.", + "type": "boolean" + }, + "maxFileBytes": { + "description": "Maximum bytes accepted for one artifact.", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "additionalProperties": false + }, + "subagents": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "providers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "enum": [ + "codex", + "claude", + "opencode", + "pi", + "cursor", + "copilot", + "grok" + ] + }, + "enabled": { + "type": "boolean" + }, + "model": { + "type": "string", + "minLength": 1 + }, + "effort": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "id", + "enabled" + ], + "additionalProperties": false + } + } + }, + "required": [ + "enabled", + "providers" + ], + "additionalProperties": false + }, + "logging": { + "type": "object", + "properties": { + "level": { + "type": "string", + "enum": [ + "silent", + "error", + "warn", + "info", + "debug" + ] + }, + "format": { + "type": "string", + "enum": [ + "json", + "pretty" + ] + }, + "requests": { + "type": "boolean" + }, + "assets": { + "type": "boolean" + }, + "toolCalls": { + "type": "boolean" + }, + "shellCommands": { + "type": "boolean" + }, + "trustProxy": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "oauth": { + "type": "object", + "properties": { + "accessTokenTtlSeconds": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "refreshTokenTtlSeconds": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "scopes": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "allowedRedirectHosts": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "additionalProperties": false + } + }, + "required": [ + "version" + ], + "additionalProperties": false, + "$id": "https://raw.githubusercontent.com/Waishnav/devspace/refs/tags/v1.1.0/schema/devspace-config.schema.json", + "title": "DevSpace configuration", + "description": "Versioned configuration for the DevSpace server and coding harness." +} diff --git a/src/config-schema.test.ts b/src/config-schema.test.ts new file mode 100644 index 00000000..da1a7552 --- /dev/null +++ b/src/config-schema.test.ts @@ -0,0 +1,9 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { createDevspaceConfigJsonSchema } from "./user-config.js"; + +const checkedIn = JSON.parse( + readFileSync(new URL("../schema/devspace-config.schema.json", import.meta.url), "utf8"), +) as Record; + +assert.deepEqual(checkedIn, createDevspaceConfigJsonSchema()); From a8eb6be19721461cf5437f18ac038478b769a23d Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:16:11 +0530 Subject: [PATCH 12/14] test(config): cover jsonc migration and preservation --- package.json | 2 +- src/config.test.ts | 66 +++++++++++++++++++++++++ src/user-config.test.ts | 106 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 src/user-config.test.ts diff --git a/package.json b/package.json index 3d192489..c3209e95 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,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/runtime-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/config.test.ts && tsx src/user-config.test.ts && tsx src/config-schema.test.ts && tsx src/runtime-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/config.test.ts b/src/config.test.ts index 58de89e2..81196677 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -221,3 +221,69 @@ assert.deepEqual(fileConfig.allowedHosts, [ "::1", "devspace.example.com", ]); + +const jsoncConfigDir = mkdtempSync(join(tmpdir(), "devspace-jsonc-load-config-test-")); +writeFileSync( + join(jsoncConfigDir, "config.jsonc"), + `{ + // Structured v1 configuration. + "version": 1, + "server": { + "port": 8989, + "allowedRoots": [${JSON.stringify(process.cwd())}], + "publicBaseUrl": "https://jsonc.devspace.example.com" + }, + "harness": { + "kind": "codex" + }, + "presentation": { + "mode": "change-review" + }, + "skills": { + "enabled": false, + "paths": ["~/.custom-skills"] + }, + "artifacts": { + "enabled": true, + "maxFileBytes": 456 + }, + "logging": { + "level": "debug", + "requests": false + }, + "oauth": { + "accessTokenTtlSeconds": 120, + "scopes": ["devspace", "admin"] + } + }`, +); +writeFileSync( + join(jsoncConfigDir, "auth.json"), + JSON.stringify({ ownerToken: "jsonc-owner-token-long-enough" }), +); + +const jsoncConfig = loadConfig({ DEVSPACE_CONFIG_DIR: jsoncConfigDir }); +assert.equal(jsoncConfig.port, 8989); +assert.equal(jsoncConfig.publicBaseUrl, "https://jsonc.devspace.example.com"); +assert.deepEqual(jsoncConfig.harness, { kind: "codex" }); +assert.deepEqual(jsoncConfig.presentation, { mode: "change-review" }); +assert.equal(jsoncConfig.skillsEnabled, false); +assert.deepEqual(jsoncConfig.skillPaths, ["~/.custom-skills"]); +assert.equal(jsoncConfig.artifactsEnabled, true); +assert.equal(jsoncConfig.artifactMaxFileBytes, 456); +assert.equal(jsoncConfig.logging.level, "debug"); +assert.equal(jsoncConfig.logging.requests, false); +assert.equal(jsoncConfig.oauth.accessTokenTtlSeconds, 120); +assert.deepEqual(jsoncConfig.oauth.scopes, ["devspace", "admin"]); + +const envOverride = loadConfig({ + DEVSPACE_CONFIG_DIR: jsoncConfigDir, + DEVSPACE_TOOL_MODE: "full", + DEVSPACE_WIDGETS: "off", + DEVSPACE_SKILLS: "1", + DEVSPACE_LOG_LEVEL: "warn", +}); +assert.deepEqual(envOverride.harness, { kind: "claude-code", inspection: "dedicated" }); +assert.deepEqual(envOverride.presentation, { mode: "off" }); +assert.equal(envOverride.skillsEnabled, true); +assert.equal(envOverride.logging.level, "warn"); diff --git a/src/user-config.test.ts b/src/user-config.test.ts new file mode 100644 index 00000000..19ea4d3e --- /dev/null +++ b/src/user-config.test.ts @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + DEVSPACE_CONFIG_SCHEMA_URL, + loadDevspaceFiles, + writeDevspaceConfig, +} from "./user-config.js"; + +const legacyRoot = mkdtempSync(join(tmpdir(), "devspace-legacy-config-test-")); +writeFileSync( + join(legacyRoot, "config.json"), + JSON.stringify({ + host: "127.0.0.1", + port: 8787, + publicBaseUrl: "https://legacy.example.com", + artifactsEnabled: true, + artifactMaxFileBytes: 123, + futureLegacyKey: { keep: true }, + }, null, 2), +); + +const legacyFiles = loadDevspaceFiles({ DEVSPACE_CONFIG_DIR: legacyRoot }); +assert.equal(legacyFiles.jsoncConfigExists, false); +assert.equal(legacyFiles.legacyConfigExists, true); +assert.deepEqual(legacyFiles.config.server, { + host: "127.0.0.1", + port: 8787, + publicBaseUrl: "https://legacy.example.com", +}); +assert.deepEqual(legacyFiles.config.artifacts, { + enabled: true, + maxFileBytes: 123, +}); + +writeDevspaceConfig({ + ...legacyFiles.config, + server: { + ...legacyFiles.config.server, + publicBaseUrl: "https://jsonc.example.com", + }, +}, { DEVSPACE_CONFIG_DIR: legacyRoot }, legacyFiles); + +const migratedPath = join(legacyRoot, "config.jsonc"); +assert.equal(existsSync(migratedPath), true); +assert.equal(existsSync(join(legacyRoot, "config.json")), true); +const migrated = readFileSync(migratedPath, "utf8"); +assert.match(migrated, /"\$schema": "https:\/\/raw\.githubusercontent\.com/); +assert.match(migrated, /"version": 1/); +assert.match(migrated, /"publicBaseUrl": "https:\/\/jsonc\.example\.com"/); + +const jsoncRoot = mkdtempSync(join(tmpdir(), "devspace-jsonc-config-test-")); +const jsoncPath = join(jsoncRoot, "config.jsonc"); +writeFileSync(jsoncPath, `{ + // Keep this top-level comment. + "$schema": "${DEVSPACE_CONFIG_SCHEMA_URL}", + "version": 1, + "server": { + // Keep this server comment. + "host": "127.0.0.1", + "futureSetting": true, + }, + "harness": { + "kind": "claude-code", + "inspection": "shell", + "futureHarnessSetting": true, + }, + "futureTopLevel": { + "keep": true, + }, +} +`); +writeFileSync(join(jsoncRoot, "config.json"), JSON.stringify({ port: 9999 })); + +const jsoncFiles = loadDevspaceFiles({ DEVSPACE_CONFIG_DIR: jsoncRoot }); +assert.equal(jsoncFiles.jsoncConfigExists, true); +assert.equal(jsoncFiles.legacyConfigExists, true); +assert.equal(jsoncFiles.config.server?.host, "127.0.0.1"); +assert.equal(jsoncFiles.config.server?.port, undefined); + +writeDevspaceConfig({ + ...jsoncFiles.config, + harness: { kind: "codex" }, + server: { + ...jsoncFiles.config.server, + publicBaseUrl: "https://preserved.example.com", + }, +}, { DEVSPACE_CONFIG_DIR: jsoncRoot }, jsoncFiles); + +const rewritten = readFileSync(jsoncPath, "utf8"); +assert.match(rewritten, /Keep this top-level comment/); +assert.match(rewritten, /Keep this server comment/); +assert.match(rewritten, /"futureSetting": true/); +assert.match(rewritten, /"futureHarnessSetting": true/); +assert.match(rewritten, /"futureTopLevel"/); +assert.doesNotMatch(rewritten, /"inspection":/); +assert.match(rewritten, /"publicBaseUrl": "https:\/\/preserved\.example\.com"/); +assert.deepEqual(loadDevspaceFiles({ DEVSPACE_CONFIG_DIR: jsoncRoot }).config.harness, { kind: "codex" }); + +const malformedRoot = mkdtempSync(join(tmpdir(), "devspace-malformed-jsonc-test-")); +writeFileSync(join(malformedRoot, "config.jsonc"), "{ version: 1,, }"); +assert.throws( + () => loadDevspaceFiles({ DEVSPACE_CONFIG_DIR: malformedRoot }), + /Unable to read .*config\.jsonc/, +); From ddc35d27870191a3b5609cd504232caa987e75a0 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:20:23 +0530 Subject: [PATCH 13/14] docs(config): publish the v1.1 configuration contract --- .env.example | 66 +++--- AGENTS.md | 9 +- docs/chatgpt-coding-workflow.md | 41 ++-- docs/configuration.md | 386 +++++++++++++++++--------------- docs/gotchas.md | 14 +- docs/setup.md | 4 +- src/cli.ts | 2 +- 7 files changed, 283 insertions(+), 239 deletions(-) diff --git a/.env.example b/.env.example index e42f2130..089160c2 100644 --- a/.env.example +++ b/.env.example @@ -1,38 +1,48 @@ +# Persistent product configuration belongs in ~/.devspace/config.jsonc. +# Environment values are useful for bootstrap, secrets, deployment overrides, +# and compatibility with pre-v1.1 setups. + HOST=127.0.0.1 PORT=7676 + +# Locate an alternate config.jsonc/auth.json directory. +# DEVSPACE_CONFIG_DIR=/path/to/devspace-config + # Owner password generated by `devspace init` in ~/.devspace/auth.json. Set this # only for non-interactive or fully env-driven deployments. # DEVSPACE_OAUTH_OWNER_TOKEN=change-me-to-a-long-random-secret -# Optional OAuth tuning. -# DEVSPACE_OAUTH_ACCESS_TOKEN_TTL_SECONDS=3600 -# DEVSPACE_OAUTH_REFRESH_TOKEN_TTL_SECONDS=2592000 -# DEVSPACE_OAUTH_SCOPES=devspace -# DEVSPACE_OAUTH_ALLOWED_REDIRECT_HOSTS=chatgpt.com,localhost,127.0.0.1 -DEVSPACE_ALLOWED_ROOTS=/home/waishnav/personal,/home/waishnav/work -# For temporary tunnels, prefer setting this per run. DevSpace derives the -# inbound Host allowlist from this URL. + +# Common deployment overrides. +# DEVSPACE_ALLOWED_ROOTS=/home/me/personal,/home/me/work # DEVSPACE_PUBLIC_BASE_URL=https://your-public-host.example.com -# Advanced escape hatch. `*` disables Host header allowlist protection. # DEVSPACE_ALLOWED_HOSTS=localhost,127.0.0.1,your-public-host.example.com -DEVSPACE_TOOL_MODE=full -# off | changes | full. Defaults to changes. -# changes creates one aggregate review widget via review_changes instead of per-tool iframes. -DEVSPACE_WIDGETS=changes -DEVSPACE_LOG_LEVEL=info -DEVSPACE_LOG_FORMAT=json -DEVSPACE_LOG_REQUESTS=1 -DEVSPACE_LOG_ASSETS=0 -DEVSPACE_LOG_TOOL_CALLS=1 -DEVSPACE_LOG_SHELL_COMMANDS=0 -# DEVSPACE_TRUST_PROXY=1 -# DEVSPACE_STATE_DIR=/home/waishnav/.local/share/devspace -# DEVSPACE_WORKTREE_ROOT=/home/waishnav/.devspace/worktrees -# Native-file download is opt-in. Files stream to a model-selected relative -# path inside an already-open workspace without overwriting existing files. +# DEVSPACE_STATE_DIR=/home/me/.local/share/devspace +# DEVSPACE_WORKTREE_ROOT=/home/me/.devspace/worktrees + +# Legacy harness/presentation overrides. Prefer config.jsonc for persistent use. +# DEVSPACE_TOOL_MODE=minimal +# DEVSPACE_MINIMAL_TOOLS=1 +# DEVSPACE_WIDGETS=full + +# Optional feature overrides. # DEVSPACE_ARTIFACTS=1 # DEVSPACE_ARTIFACT_MAX_FILE_BYTES=104857600 -# DEVSPACE_AUTO_LOAD_AGENTS_MD=1 -# Skills are enabled by default. Set DEVSPACE_SKILLS=0 to hide them. # DEVSPACE_SKILLS=0 -# DEVSPACE_AGENT_DIR=/home/waishnav/.codex -# DEVSPACE_SKILL_PATHS=/home/waishnav/.codex/skills,/home/waishnav/.claude/skills +# DEVSPACE_AGENT_DIR=/home/me/.codex +# DEVSPACE_SKILL_PATHS=/home/me/.claude/skills,/home/me/company/skills +# DEVSPACE_SUBAGENTS=1 + +# OAuth policy overrides. +# DEVSPACE_OAUTH_ACCESS_TOKEN_TTL_SECONDS=3600 +# DEVSPACE_OAUTH_REFRESH_TOKEN_TTL_SECONDS=2592000 +# DEVSPACE_OAUTH_SCOPES=devspace +# DEVSPACE_OAUTH_ALLOWED_REDIRECT_HOSTS=chatgpt.com,localhost,127.0.0.1 + +# Logging overrides. +# DEVSPACE_LOG_LEVEL=info +# DEVSPACE_LOG_FORMAT=json +# DEVSPACE_LOG_REQUESTS=1 +# DEVSPACE_LOG_ASSETS=0 +# DEVSPACE_LOG_TOOL_CALLS=1 +# DEVSPACE_LOG_SHELL_COMMANDS=0 +# DEVSPACE_TRUST_PROXY=1 diff --git a/AGENTS.md b/AGENTS.md index fa13d508..73da3468 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,8 @@ These ideas should stay true as the project evolves: - **Allowed root** — a configured filesystem boundary within which a workspace may be opened. It is not itself necessarily a workspace. - **Checkout mode** — operating on an existing checkout supplied by the user. - **Worktree mode** — operating in an isolated Git worktree. -- **Tool surface** — the tools exposed by a configured mode, such as minimal, full, or Codex-compatible. +- **Coding harness** — the model-facing coding tool contract. DevSpace currently composes a Claude Code-style harness or a Codex-style harness. +- **Presentation profile** — the host-rendered UI/review behavior: inline tool UI, aggregate change review, or off. - **Process session** — a long-running command tracked for later input, output, or termination. - **Instruction file** — an `AGENTS.md` or `CLAUDE.md` discovered while navigating a workspace. - **Subagent** — a bounded model invocation delegated and coordinated by the host. @@ -62,8 +63,8 @@ Determine how the user will consume the change and verify that path. Behavior ma - a fresh process and a server or host that needs restarting; - checkout mode and worktree mode; - Linux, macOS, and Windows Bash environments; -- minimal, full, and Codex-compatible tool surfaces; -- widgets enabled, disabled, or limited to change review. +- Claude Code shell-inspection, Claude Code dedicated-inspection, and Codex harnesses; +- inline, change-review, and off presentation profiles. State clearly when only a narrower proxy was verified. For model-facing schemas, inspect what the host receives. For UI and artifacts, inspect the rendered result rather than inferring success from the producing command. @@ -115,4 +116,4 @@ Start at the boundary named by the problem and follow the data. Keep policy in D - Preserve host and provider data unless DevSpace has a concrete reason to normalize it. - Add compatibility behavior only for an identified consumer with a real upgrade path. - Reuse glossary terms in schemas, types, documentation, and errors. -- Keep the execution layer small, reliable, and unsurprising. \ No newline at end of file +- Keep the execution layer small, reliable, and unsurprising. diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index d7a5d13c..e71a4a7d 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -148,9 +148,9 @@ configuration. The bundled `subagents` skill teaches the minimal comes from `open_workspace`; `devspace agents ls` lists existing subagent sessions for that workspace. -## Tool Names +## Coding Harness -DevSpace exposes these tool names: +The default `claude-code` harness with `inspection: "shell"` exposes: - `open_workspace` - `read` @@ -158,14 +158,23 @@ DevSpace exposes these tool names: - `edit` - `bash` -By default, DevSpace also runs in `DEVSPACE_TOOL_MODE=minimal`, so dedicated -`grep`, `glob`, and `ls` tools are hidden. Use `bash` with command-line tools -such as `rg`, `find`, and `ls` for search and directory inspection. +Dedicated `grep`, `glob`, and `ls` tools are hidden in that configuration. Use +`bash` with command-line tools such as `rg`, `find`, and `ls` for search and +directory inspection. -Use `DEVSPACE_TOOL_MODE=full` to restore dedicated search and directory tools. +Set `harness.inspection` to `"dedicated"` in `~/.devspace/config.jsonc` to expose +the dedicated inspection tools. -The experimental Codex-style surface is enabled with -`DEVSPACE_TOOL_MODE=codex`. It exposes: +The Codex harness is selected with: + +```jsonc +{ + "version": 1, + "harness": { "kind": "codex" } +} +``` + +It exposes: - `open_workspace` - `read` @@ -173,20 +182,22 @@ The experimental Codex-style surface is enabled with - `exec_command` - `write_stdin` -In this mode, `write`, `edit`, `bash`, `grep`, `glob`, and `ls` are not +In this harness, `write`, `edit`, `bash`, `grep`, `glob`, and `ls` are not registered. `exec_command` returns a process session ID when a command is still running after its yield window. Use `write_stdin` to poll it, send input, resize a PTY, or send Ctrl-C. Set `tty: true` only for commands that need a terminal. -## Show Changes +`DEVSPACE_TOOL_MODE=minimal|full|codex` remains a compatibility override for +older deployments. -By default, `DEVSPACE_WIDGETS=full`. +## Show Changes -In that mode, DevSpace attaches widget UI to the exposed workspace, file, edit, -and shell tools. The aggregate `show_changes` tool is not exposed by default. +By default, `presentation.mode` is `"inline"`, which attaches widget UI to the +normal exposed tools without registering the aggregate review tool. -Use `DEVSPACE_WIDGETS=off` to disable widget UI, or `DEVSPACE_WIDGETS=changes` -to expose the aggregate show-changes flow. +Use `presentation.mode: "off"` to disable widget UI, or +`presentation.mode: "change-review"` to expose the aggregate `show_changes` +flow. `DEVSPACE_WIDGETS=off|changes|full` remains a compatibility override. When `show_changes` is exposed, call it exactly once after the final file modification in any turn that changes files. It shows the combined changes for diff --git a/docs/configuration.md b/docs/configuration.md index 93a3d4fa..d640c082 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,16 +1,19 @@ # Configuration Reference -DevSpace can be configured through `devspace init`, persisted config files, or -environment variables. - -The default files are: +DevSpace v1.1 uses a versioned JSONC configuration file as its primary product +configuration: ```text -~/.devspace/config.json +~/.devspace/config.jsonc ~/.devspace/auth.json ``` -Use another config directory with: +`config.jsonc` is human-editable, supports comments and trailing commas, and can +reference the checked-in JSON Schema for editor completion and validation. +`auth.json` remains separate so the OAuth owner password is not mixed into normal +product configuration. + +Use another configuration directory with: ```bash DEVSPACE_CONFIG_DIR=/path/to/config npx @waishnav/devspace serve @@ -26,234 +29,249 @@ npx @waishnav/devspace config get npx @waishnav/devspace config set publicBaseUrl https://devspace.example.com ``` -## Core Environment Variables +## JSONC configuration -| Variable | Purpose | -| --- | --- | -| `HOST` | Local bind host. Defaults to `127.0.0.1`. | -| `PORT` | Local port. Defaults to `7676`. | -| `DEVSPACE_ALLOWED_ROOTS` | Comma-separated local roots that workspaces may open. | -| `DEVSPACE_PUBLIC_BASE_URL` | Public origin for the server, without `/mcp`. | -| `DEVSPACE_ALLOWED_HOSTS` | Optional Host header allowlist override. | -| `DEVSPACE_OAUTH_OWNER_TOKEN` | Owner password for OAuth approval. Must be at least 16 characters. | -| `DEVSPACE_WORKTREE_ROOT` | Directory for managed Git worktrees. Defaults to `~/.devspace/worktrees`. | -| `DEVSPACE_STATE_DIR` | Directory for SQLite state. Defaults to `~/.local/share/devspace`. | +`devspace init` writes the canonical v1 shape. Keep only values you intentionally +want to configure; omitted values use DevSpace defaults. -## Native Artifact Download +```jsonc +{ + "$schema": "https://raw.githubusercontent.com/Waishnav/devspace/refs/tags/v1.1.0/schema/devspace-config.schema.json", + "version": 1, + + "server": { + "host": "127.0.0.1", + "port": 7676, + "allowedRoots": [ + "~/personal", + "~/work" + ], + "publicBaseUrl": "https://devspace.example.com" + }, + + "harness": { + "kind": "claude-code", + "inspection": "shell" + }, + + "presentation": { + "mode": "inline" + }, + + "skills": { + "enabled": true, + "paths": [] + }, -Native-file download is disabled by default. Enable it when ChatGPT needs to hand -an attached or generated file into an already-open workspace: + "artifacts": { + "enabled": false, + "maxFileBytes": 104857600 + }, -```bash -DEVSPACE_ARTIFACTS=1 npx @waishnav/devspace serve + "subagents": { + "enabled": true, + "providers": [ + { + "id": "codex", + "enabled": true, + "model": "gpt-5.4", + "effort": "high" + } + ] + }, + + "logging": { + "level": "info", + "format": "json" + } +} ``` -This feature currently supports Linux. It is not registered on macOS, Windows, -or BSD because the secure publication path depends on traversable, -descriptor-anchored directory paths provided by Linux procfs. +The published schema is generated from the same Zod codec used at runtime. A +test keeps the checked-in schema synchronized with that codec. -| Variable | Default | Purpose | -| --- | --- | --- | -| `DEVSPACE_ARTIFACTS` | `0` | Expose `download_artifact` for trusted native files. | -| `DEVSPACE_ARTIFACT_MAX_FILE_BYTES` | `104857600` | Maximum streamed size of one file (100 MiB). | +### Precedence + +Configuration resolves in this order: -The same settings may be persisted in `~/.devspace/config.json` as -`artifactsEnabled` and `artifactMaxFileBytes`. +1. Environment overrides, when supplied. +2. `~/.devspace/config.jsonc`. +3. Legacy `~/.devspace/config.json`, when no JSONC file exists. +4. DevSpace defaults. -`download_artifact` accepts the native file object supplied by the MCP connector, -a `workspaceId` returned by `open_workspace`, and a relative workspace `path`. -DevSpace safely creates missing parent directories, refuses to overwrite an -existing destination, and returns only the normalized workspace-relative path. -It does not accept conflict modes, expected hashes, arbitrary URL strings, local -paths, embedded credentials, or extra object fields. +If both persisted files exist, `config.jsonc` is authoritative. DevSpace does +not rewrite configuration during `serve`. An intentional write such as +`devspace init --force` or `devspace config set ...` writes the canonical JSONC +file. Existing JSONC comments, formatting, and unknown future keys are preserved +where the edited value does not require replacing them. -There is no artifact root, total quota, TTL, pinning, persistent database record, -or background artifact cleanup service. See [Native File Download](artifact-exchange.md) -for the supported connector shape and security boundaries. +Legacy `config.json` remains readable in v1.1. Its old flat fields are migrated +to the v1 model in memory. The legacy file is left untouched so merely starting +DevSpace never mutates user state. -## OAuth +## Coding harness -DevSpace uses a single-user OAuth approval flow. +The harness controls the model-facing coding tool contract. -| Variable | Default | +| Configuration | Exposed tools | | --- | --- | -| `DEVSPACE_OAUTH_ACCESS_TOKEN_TTL_SECONDS` | `3600` | -| `DEVSPACE_OAUTH_REFRESH_TOKEN_TTL_SECONDS` | `2592000` | -| `DEVSPACE_OAUTH_SCOPES` | `devspace` | -| `DEVSPACE_OAUTH_ALLOWED_REDIRECT_HOSTS` | `chatgpt.com,localhost,127.0.0.1` | +| `{ "kind": "claude-code", "inspection": "shell" }` | `open_workspace`, `read`, `write`, `edit`, `bash` | +| `{ "kind": "claude-code", "inspection": "dedicated" }` | Above plus `grep`, `glob`, `ls` | +| `{ "kind": "codex" }` | `open_workspace`, `read`, `apply_patch`, `exec_command`, `write_stdin` | -MCP clients discover metadata from: +The Claude Code harness uses the same mutation/shell contract as the previous +`minimal` and `full` tool modes. `inspection: "shell"` keeps inspection inside +`bash`; `inspection: "dedicated"` exposes dedicated search and directory tools. -```text -/.well-known/oauth-protected-resource/mcp -/.well-known/oauth-authorization-server -``` +The Codex harness uses process sessions. Commands run without a PTY by default; +set `tty: true` on `exec_command` for interactive terminal programs. + +For compatibility, `DEVSPACE_TOOL_MODE=minimal|full|codex` still overrides the +persisted harness. `DEVSPACE_MINIMAL_TOOLS` remains an older alias when +`DEVSPACE_TOOL_MODE` is unset. -## Tool Modes +## Presentation and change review -`DEVSPACE_TOOL_MODE` controls the tool surface. +`presentation.mode` controls host-rendered UI and the aggregate review workflow. | Value | Behavior | | --- | --- | -| `minimal` | Default. Exposes `open_workspace`, `read`, `write`, `edit`, and `bash`. Clients use `bash` with tools such as `rg`, `find`, and `ls` for inspection. | -| `full` | Exposes the minimal tools plus dedicated `grep`, `glob`, and `ls` tools. | -| `codex` | Experimental. Exposes `open_workspace`, `read`, `apply_patch`, `exec_command`, and `write_stdin`. Existing mutation and shell tools are hidden. | +| `inline` | Default. Attach widget UI to normal exposed tools. | +| `change-review` | Expose `show_changes`, attach UI to `open_workspace` and `show_changes`, and track review checkpoints. | +| `off` | Do not attach widget UI. | -`DEVSPACE_MINIMAL_TOOLS` remains a backward-compatible alias when -`DEVSPACE_TOOL_MODE` is unset: `1` selects `minimal` and `0` selects `full`. -The `codex` mode must be selected through `DEVSPACE_TOOL_MODE` and always uses -its fixed short tool names regardless of `DEVSPACE_TOOL_NAMING`. +`DEVSPACE_WIDGETS=full|changes|off` remains a compatibility override mapping to +`inline|change-review|off` respectively. -Codex-mode commands run without a PTY by default. Set `tty: true` on -`exec_command` for interactive terminal programs. PTY support uses the optional -`node-pty` dependency; `write_stdin` can send input, poll output, and resize PTY -sessions. +## Server -## Widgets +The `server` object supports: -`DEVSPACE_WIDGETS` controls ChatGPT Apps iframe usage. - -| Value | Behavior | -| --- | --- | -| `full` | Default. Widget UI is attached to exposed workspace, file, edit, and shell tools. | -| `changes` | Enables the aggregate `show_changes` tool and attaches widget UI to `open_workspace` and `show_changes`. | -| `off` | Disables widget UI. | +| Key | Default | Purpose | +| --- | --- | --- | +| `host` | `127.0.0.1` | Local bind host. | +| `port` | `7676` | Local MCP port. | +| `allowedRoots` | current directory | Local roots workspaces may open. | +| `publicBaseUrl` | local server URL | Public origin, without `/mcp`. Use `null` to fall back to the local URL. | +| `allowedHosts` | derived | Optional Host header allowlist. | +| `worktreeRoot` | `~/.devspace/worktrees` | Managed Git worktree directory. | +| `stateDir` | `~/.local/share/devspace` | SQLite state directory. | -## Skills +`HOST`, `PORT`, `DEVSPACE_ALLOWED_ROOTS`, `DEVSPACE_PUBLIC_BASE_URL`, +`DEVSPACE_ALLOWED_HOSTS`, `DEVSPACE_WORKTREE_ROOT`, and `DEVSPACE_STATE_DIR` +remain deployment overrides. -| Variable | Purpose | -| --- | --- | -| `DEVSPACE_SKILLS` | Set to `0` to hide skills. Enabled by default. | -| `DEVSPACE_SUBAGENTS` | Optional master override for the persisted Subagents configuration. | -| `DEVSPACE_AGENT_DIR` | Defaults to `~/.codex`; its `skills` child is loaded for compatibility. | -| `DEVSPACE_SKILL_PATHS` | Optional comma-separated additional skill directories. | +## Native artifact download -DevSpace discovers standard Agent Skills from: +Native-file download is disabled by default: -- `~/.agents/skills` -- project `.agents/skills` -- `~/.devspace/skills` - -It also keeps compatibility with: +```jsonc +{ + "version": 1, + "artifacts": { + "enabled": true, + "maxFileBytes": 104857600 + } +} +``` -- the bundled `subagents` skill when Subagents are enabled, unless `~/.devspace/skills/subagents/SKILL.md` exists -- `DEVSPACE_AGENT_DIR/skills`, defaulting to `~/.codex/skills` -- additional paths from `DEVSPACE_SKILL_PATHS` +This feature currently supports Linux. It is not registered on unsupported +platforms even when requested in configuration. Runtime compilation resolves +that availability once so tool registration, model instructions, and startup +status agree. -When Subagents are enabled, DevSpace discovers agent profiles -from: +`DEVSPACE_ARTIFACTS` and `DEVSPACE_ARTIFACT_MAX_FILE_BYTES` remain environment +overrides. See [Native File Download](artifact-exchange.md) for the connector and +security contract. -- `~/.devspace/agents/*.md` -- project `.devspace/agents/*.md` +## Skills and subagents -Enable providers and set their defaults in `~/.devspace/config.json`: +Skills are enabled by default. Additional paths and the compatibility agent +directory can be persisted under `skills`: -```json +```jsonc { - "subagents": { + "version": 1, + "skills": { "enabled": true, - "providers": [ - { - "id": "codex", - "enabled": true, - "model": "gpt-5.4", - "effort": "high" - }, - { - "id": "claude", - "enabled": true, - "model": "sonnet" - }, - { - "id": "grok", - "enabled": true, - "model": "grok-4.5", - "effort": "low" - } - ] + "paths": ["~/.claude/skills", "~/company/skills"], + "agentDir": "~/.codex" } } ``` -Each entry controls one provider. Providers omitted from the array are disabled. -`model` and `effort` are optional defaults; an invocation override wins over a -profile value, which wins over the provider default. The legacy boolean -`"subagents": true` remains readable and enables every provider, but new -configuration should use the explicit object form. - -`devspace agents targets` shows usable providers and profiles for the current -workspace. Add `--json` for a compact list of exact target names and their -selection metadata. Disabled, unavailable, and unconfigured providers are -omitted. Provider availability is runtime state and never rewrites the -configuration. - -Grok Build is discovered from the `grok` executable. Authenticate it with -`grok login` or `XAI_API_KEY`; DevSpace does not read or store Grok credentials. -Grok supports `grok-build` by default and validates explicit model and effort -values against the ACP session metadata when available. Set `GROK_COMMAND` when -the executable is not on the normal PATH. If your Grok installation selects a -custom agent profile, set `GROK_AGENT_PROFILE` to that profile's path; DevSpace -passes it to `grok agent stdio` without writing to Grok's configuration. - -`open_workspace` returns a compact catalog containing profile names, -descriptions, providers, and optional models/effort levels so the host model can choose an -agent without reading provider-specific launch details. Disabled or unavailable -providers and their profiles are omitted from this model-facing catalog. `devspace agents ls` -lists existing subagent sessions for the current workspace, scoped by the -workspace environment injected into shell commands. The `subagents` -skill teaches the model to use only the minimal `devspace agents ls`, -`devspace agents targets`, `devspace agents run`, `devspace agents continue`, -and `devspace agents show` workflow. - -For Codex, Claude Code, OpenCode, Pi, or another supported Coding Agent, use -the Skills CLI to install the same skill. DevSpace setup prints this command but -does not run it or write into agent skill directories: +DevSpace discovers standard Agent Skills from `~/.agents/skills`, project +`.agents/skills`, `~/.devspace/skills`, the compatibility agent directory, and +the configured additional paths. -```bash -npx skills add Waishnav/devspace --skill subagents --global -``` +When Subagents are enabled, agent profiles are discovered from +`~/.devspace/agents/*.md` and project `.devspace/agents/*.md`. Each provider entry +controls enablement plus optional `model` and `effort` defaults. Invocation +overrides win over profile values, which win over provider defaults. -Starter profile templates are available under `examples/agents/`. Copy or adapt -them into one of the active profile directories before use. +The legacy boolean `"subagents": true` in `config.json` remains readable and is +migrated in memory to the explicit provider configuration. New JSONC config uses +the object form. -Legacy project paths such as `.pi/skills` can be added through `DEVSPACE_SKILL_PATHS` when needed. +Provider availability is runtime state and never rewrites configuration. +Credentials remain owned by provider CLIs. For example, Grok Build uses +`grok login` or `XAI_API_KEY`; command-location variables such as `GROK_COMMAND` +and `CODEX_COMMAND` remain process/provider overrides rather than DevSpace +credentials. -Example: +`DEVSPACE_SKILLS`, `DEVSPACE_SKILL_PATHS`, `DEVSPACE_AGENT_DIR`, and +`DEVSPACE_SUBAGENTS` remain compatibility overrides. -```bash -DEVSPACE_SKILL_PATHS="$HOME/.claude/skills,$HOME/company/skills" \ -npx @waishnav/devspace serve -``` +## OAuth and secrets -## Logging +The OAuth owner password stays in `~/.devspace/auth.json` or +`DEVSPACE_OAUTH_OWNER_TOKEN`; it is intentionally not part of `config.jsonc`. -| Variable | Default | -| --- | --- | -| `DEVSPACE_LOG_LEVEL` | `info` | -| `DEVSPACE_LOG_FORMAT` | `json` | -| `DEVSPACE_LOG_REQUESTS` | `1` | -| `DEVSPACE_LOG_ASSETS` | `0` | -| `DEVSPACE_LOG_TOOL_CALLS` | `1` | -| `DEVSPACE_LOG_SHELL_COMMANDS` | `0` | -| `DEVSPACE_TRUST_PROXY` | `0` | +Non-secret OAuth policy can be persisted: -Set `DEVSPACE_LOG_FORMAT=pretty` for local debugging. +```jsonc +{ + "version": 1, + "oauth": { + "accessTokenTtlSeconds": 3600, + "refreshTokenTtlSeconds": 2592000, + "scopes": ["devspace"], + "allowedRedirectHosts": ["chatgpt.com", "localhost", "127.0.0.1"] + } +} +``` -Set `DEVSPACE_LOG_SHELL_COMMANDS=1` only when you intentionally want command -previews in logs. +The matching `DEVSPACE_OAUTH_*` variables remain environment overrides. -## Env-Only Example +MCP clients discover metadata from: -```bash -DEVSPACE_OAUTH_OWNER_TOKEN="$(openssl rand -base64 32)" \ -DEVSPACE_ALLOWED_ROOTS="$HOME/personal,$HOME/work" \ -DEVSPACE_PUBLIC_BASE_URL="https://devspace.example.com" \ -DEVSPACE_WORKTREE_ROOT="$HOME/.devspace/worktrees" \ -DEVSPACE_ARTIFACTS="1" \ -DEVSPACE_TOOL_MODE="minimal" \ -DEVSPACE_WIDGETS="full" \ -npx @waishnav/devspace serve +```text +/.well-known/oauth-protected-resource/mcp +/.well-known/oauth-authorization-server ``` -The environment assignments must be part of the same command invocation, or -exported first. +## Logging + +Logging can be persisted under `logging`; environment variables continue to +override individual values. + +| Key / override | Default | +| --- | --- | +| `level` / `DEVSPACE_LOG_LEVEL` | `info` | +| `format` / `DEVSPACE_LOG_FORMAT` | `json` | +| `requests` / `DEVSPACE_LOG_REQUESTS` | `true` | +| `assets` / `DEVSPACE_LOG_ASSETS` | `false` | +| `toolCalls` / `DEVSPACE_LOG_TOOL_CALLS` | `true` | +| `shellCommands` / `DEVSPACE_LOG_SHELL_COMMANDS` | `false` | +| `trustProxy` / `DEVSPACE_TRUST_PROXY` | `false` | + +Enable shell command logging only when command previews are intentionally safe +to retain. + +## Environment-only deployment + +JSONC is the normal persistent interface, but fully environment-driven +deployments remain supported. `DEVSPACE_CONFIG_DIR` is always environment-only +because it locates the configuration itself. Secrets, child-process workspace +context (`DEVSPACE_WORKSPACE_ID`, `DEVSPACE_WORKSPACE_ROOT`), and internal daemon +controls also remain outside the persisted product config. diff --git a/docs/gotchas.md b/docs/gotchas.md index 495243bb..18b40152 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -253,12 +253,16 @@ If a skill appears in `open_workspace`, the model must read that skill's ## Review Card Does Not Appear -Per-tool widget cards are enabled by default with: +Per-tool widget cards are enabled by default through the inline presentation: -```bash -DEVSPACE_WIDGETS=full +```jsonc +{ + "version": 1, + "presentation": { "mode": "inline" } +} ``` The aggregate `show_changes` tool is only exposed with -`DEVSPACE_WIDGETS=changes`. Plain MCP clients may ignore ChatGPT Apps widget -metadata and only show text results. +`presentation.mode: "change-review"`. `DEVSPACE_WIDGETS=changes` remains a +compatibility override. Plain MCP clients may ignore ChatGPT Apps widget metadata +and only show text results. diff --git a/docs/setup.md b/docs/setup.md index 934b0b8c..6feaebae 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -56,7 +56,7 @@ remain limited to the roots configured for ChatGPT. Setup detects supported Coding Agents and asks which ones DevSpace may use. These choices are stored as provider objects under `subagents` in -`~/.devspace/config.json`. +`~/.devspace/config.jsonc`. If you selected Coding Agents, setup prints: @@ -120,7 +120,7 @@ password approval page. Enter the Owner password printed during setup. The default config files are: ```text -~/.devspace/config.json +~/.devspace/config.jsonc ~/.devspace/auth.json ``` diff --git a/src/cli.ts b/src/cli.ts index 13b4e3ce..5d0f958e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -392,7 +392,7 @@ function printHelp(): void { "Usage:", " devspace Run first-time setup if needed, then start the server", " devspace serve Start the server", - " devspace init Create or update ~/.devspace/config.json and auth.json", + " devspace init Create or update ~/.devspace/config.jsonc and auth.json", " devspace doctor Show config, runtime, and native dependency status", " devspace config get Print persisted config", " devspace config set publicBaseUrl ", From eb76138277f7963ac4453f26313577b2674a9cdf Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:20:23 +0530 Subject: [PATCH 14/14] chore(release): prepare v1.1.0 metadata --- package-lock.json | 4 ++-- package.json | 2 +- src/local-agent-acp.ts | 10 +--------- src/local-agent-codex.ts | 3 ++- src/server.ts | 3 ++- src/version.ts | 10 ++++++++++ 6 files changed, 18 insertions(+), 14 deletions(-) create mode 100644 src/version.ts diff --git a/package-lock.json b/package-lock.json index e8d69ce2..5def9f34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@waishnav/devspace", - "version": "1.0.7", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@waishnav/devspace", - "version": "1.0.7", + "version": "1.1.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index c3209e95..598e3740 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@waishnav/devspace", - "version": "1.0.7", + "version": "1.1.0", "description": "Expose a secure local coding workspace through an MCP server.", "type": "module", "main": "dist/server.js", diff --git a/src/local-agent-acp.ts b/src/local-agent-acp.ts index a5d9c369..043939cf 100644 --- a/src/local-agent-acp.ts +++ b/src/local-agent-acp.ts @@ -10,6 +10,7 @@ import { isProgrammerDefect, } from "./local-agent-errors.js"; import { terminateProcessTree } from "./process-platform.js"; +import { DEVSPACE_VERSION } from "./version.js"; import { GrokPromptCompletionRegistry, GROK_DEFAULT_MODEL, @@ -36,7 +37,6 @@ const ACP_INITIALIZE_TIMEOUT_MS = 10_000; const ACP_GROK_PROMPT_COMPLETION_TIMEOUT_MS = 10 * 60_000; const require = createRequire(import.meta.url); const spawn = require("cross-spawn") as typeof import("node:child_process").spawn; -const DEVSPACE_VERSION = readDevspaceVersion(); const observeChildError = (): void => {}; @@ -851,14 +851,6 @@ async function withTimeout(promise: Promise, timeoutMs: number, message: s } } -function readDevspaceVersion(): string { - const packageJson = require("../package.json") as { version?: unknown }; - if (typeof packageJson.version !== "string" || !packageJson.version) { - throw new Error("Unable to read DevSpace package version."); - } - return packageJson.version; -} - function readArray(value: unknown, key: string): unknown[] | undefined { const result = asRecord(value)?.[key]; return Array.isArray(result) ? result : undefined; diff --git a/src/local-agent-codex.ts b/src/local-agent-codex.ts index dac27cc3..3d73c530 100644 --- a/src/local-agent-codex.ts +++ b/src/local-agent-codex.ts @@ -10,6 +10,7 @@ import { } from "./local-agent-errors.js"; import { removeDevspaceNodeModulesBinFromPath } from "./local-agent-path.js"; import { terminateProcessTree } from "./process-platform.js"; +import { DEVSPACE_VERSION } from "./version.js"; import type { LocalAgentDriver, LocalAgentRunCallbacks, @@ -109,7 +110,7 @@ export class CodexAppServerRuntime implements LocalAgentRuntime { async initialize(): Promise { await this.rpc.request("initialize", { - clientInfo: { name: "devspace", title: "DevSpace", version: "1.0.7" }, + clientInfo: { name: "devspace", title: "DevSpace", version: DEVSPACE_VERSION }, capabilities: {}, }); this.rpc.notify("initialized"); diff --git a/src/server.ts b/src/server.ts index aa78563e..9ad7ab85 100644 --- a/src/server.ts +++ b/src/server.ts @@ -62,6 +62,7 @@ import { shutdownHttpServer } from "./server-shutdown.js"; import { formatPathForPrompt } from "./skills.js"; import { createWorkspaceStore } from "./workspace-store.js"; import { formatAgentsPath, WorkspaceRegistry } from "./workspaces.js"; +import { DEVSPACE_VERSION } from "./version.js"; import { getLocalAgentProviderAvailabilitySnapshot, } from "./local-agent-availability.js"; @@ -683,7 +684,7 @@ export function createMcpServer( { name: "devspace", title: "DevSpace", - version: "0.1.0", + version: DEVSPACE_VERSION, description: "Coding tools for project workspaces. Open each project or worktree once, then reuse its workspaceId.", }, diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 00000000..d5dd91a3 --- /dev/null +++ b/src/version.ts @@ -0,0 +1,10 @@ +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const packageJson = require("../package.json") as { version?: unknown }; + +if (typeof packageJson.version !== "string" || !packageJson.version) { + throw new Error("DevSpace package version is missing."); +} + +export const DEVSPACE_VERSION = packageJson.version;