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/harness.ts b/src/harness.ts index ab7e5eb7..fff3320c 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": @@ -20,6 +33,36 @@ 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.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, +}); 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,