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/package-lock.json b/package-lock.json index 5b5c247a..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": { @@ -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 388f99e2..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", @@ -15,6 +15,7 @@ "dist", "docs", "examples", + "schema", "scripts", "skills", "README.md" @@ -29,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/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": [], @@ -51,6 +52,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/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/cli.ts b/src/cli.ts index 7cf723f8..5d0f958e 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); + 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), - }); + version: 1, + server: { + ...files.config.server, + publicBaseUrl: normalizeOptionalPublicBaseUrl(value), + }, + }, process.env, files); console.log(`Updated ${files.configPath}`); } @@ -384,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 ", 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()); diff --git a/src/config.test.ts b/src/config.test.ts index 7b3eeeb6..81196677 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -11,16 +11,43 @@ 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.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).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", +}); +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/, +); assert.equal(loadConfig(baseEnv).skillsEnabled, true); assert.equal(loadConfig(baseEnv).devspaceSkillsDir, join(emptyConfigDir, "skills")); assert.equal(loadConfig(baseEnv).devspaceAgentsDir, join(emptyConfigDir, "agents")); @@ -34,10 +61,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/, @@ -186,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/config.ts b/src/config.ts index 54a131c9..65520342 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,9 +5,17 @@ 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"; +import { + presentationFromLegacyWidgetMode, + type LegacyWidgetMode, + type PresentationConfig, +} from "./presentation.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; const DEFAULT_ARTIFACT_MAX_FILE_BYTES = 100 * 1024 * 1024; @@ -19,8 +27,8 @@ export interface ServerConfig { allowedRoots: string[]; allowedHosts: string[]; publicBaseUrl: string; - toolMode: ToolMode; - widgets: WidgetMode; + harness: HarnessConfig; + presentation: PresentationConfig; stateDir: string; worktreeRoot: string; artifactsEnabled: boolean; @@ -81,36 +89,44 @@ 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 { +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}`); 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"; + 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(",") @@ -144,20 +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), - 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), + level: parseLogLevel(env.DEVSPACE_LOG_LEVEL, stored.level), + format: parseLogFormat(env.DEVSPACE_LOG_FORMAT, stored.format), + requests: env.DEVSPACE_LOG_REQUESTS === undefined + ? stored.requests ?? true + : parseBoolean(env.DEVSPACE_LOG_REQUESTS, "DEVSPACE_LOG_REQUESTS"), + assets: env.DEVSPACE_LOG_ASSETS === undefined + ? stored.assets ?? false + : parseBoolean(env.DEVSPACE_LOG_ASSETS, "DEVSPACE_LOG_ASSETS"), + toolCalls: env.DEVSPACE_LOG_TOOL_CALLS === undefined + ? stored.toolCalls ?? true + : parseBoolean(env.DEVSPACE_LOG_TOOL_CALLS, "DEVSPACE_LOG_TOOL_CALLS"), + 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 parseWidgetMode(value: string | undefined): WidgetMode { - 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}`); @@ -174,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"], + ), }; } @@ -210,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", @@ -221,36 +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, - toolMode: parseToolMode(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())), + 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 - : parseBoolean(env.DEVSPACE_ARTIFACTS), + ? 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 : parseBoolean(env.DEVSPACE_SKILLS), - skillPaths: parsePathList(env.DEVSPACE_SKILL_PATHS), + skillsEnabled: env.DEVSPACE_SKILLS === undefined + ? storedSkills.enabled ?? true + : parseBoolean(env.DEVSPACE_SKILLS, "DEVSPACE_SKILLS"), + 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 new file mode 100644 index 00000000..c9db89dd --- /dev/null +++ b/src/harness.ts @@ -0,0 +1,82 @@ +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"; + inspection: "shell" | "dedicated"; + } + | { + kind: "codex"; + }; + +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": + return { kind: "claude-code", inspection: "shell" }; + case "full": + return { kind: "claude-code", inspection: "dedicated" }; + case "codex": + return { kind: "codex" }; + } +} + +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/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/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/presentation.ts b/src/presentation.ts new file mode 100644 index 00000000..a971b844 --- /dev/null +++ b/src/presentation.ts @@ -0,0 +1,83 @@ +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" } + | { 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.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..09788550 --- /dev/null +++ b/src/runtime-config.ts @@ -0,0 +1,35 @@ +import type { ServerConfig } from "./config.js"; +import { compileHarness, type CompiledHarness } from "./harness.js"; +import { compilePresentation, type CompiledPresentation } from "./presentation.js"; + +export type ArtifactCapability = + | { + status: "available"; + maxFileBytes: number; + } + | { + status: "unavailable"; + reason: "disabled" | "unsupported-platform"; + }; + +export interface RuntimeConfig extends ServerConfig { + runtimeHarness: CompiledHarness; + runtimePresentation: CompiledPresentation; + artifactCapability: ArtifactCapability; +} + +export function compileRuntime( + config: ServerConfig, + environment: { artifactDownloadSupported: boolean }, +): RuntimeConfig { + return { + ...config, + runtimeHarness: compileHarness(config.harness, { skillsEnabled: config.skillsEnabled }), + runtimePresentation: compilePresentation(config.presentation), + 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 cb29d11c..c98f2ccb 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"; @@ -80,6 +81,53 @@ 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("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, { @@ -200,7 +248,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(), @@ -247,6 +295,8 @@ async function fixture( git?: boolean; localAgentProviders?: LocalAgentProviderAvailability[] | (() => LocalAgentProviderAvailability[]); subagents?: SubagentsConfig; + toolMode?: "minimal" | "full" | "codex"; + widgetMode?: "off" | "changes" | "full"; } = {}, ): Promise { const root = await mkdtemp(join(tmpdir(), "devspace-server-test-")); @@ -284,8 +334,8 @@ async function fixture( DEVSPACE_ALLOWED_ROOTS: root, DEVSPACE_WORKTREE_ROOT: join(root, ".worktrees"), DEVSPACE_AGENT_DIR: agentDir, - DEVSPACE_WIDGETS: "full", - DEVSPACE_TOOL_MODE: "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", PORT: "1", @@ -313,7 +363,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(), @@ -344,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 16c2010d..9ad7ab85 100644 --- a/src/server.ts +++ b/src/server.ts @@ -22,7 +22,14 @@ 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, type IncomingArtifactAdapter, @@ -55,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"; @@ -115,16 +123,6 @@ interface DiffStats { removals: number; } -type ToolWidgetKind = - | "workspace" - | "read" - | "write" - | "edit" - | "search" - | "directory" - | "shell" - | "show_changes"; - interface ToolDefinitionMeta extends Record { ui: { resourceUri: string; @@ -140,22 +138,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: { @@ -193,30 +180,11 @@ 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 = - 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." - : ""; - - if (config.toolMode === "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" - ? `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}${config.runtimePresentation.instructions}`; } function formatVisibleAgent(agent: { @@ -562,7 +530,7 @@ function processToolResponse( function registerCodexProcessTools( server: McpServer, - config: ServerConfig, + config: RuntimeConfig, workspaces: WorkspaceRegistry, processSessions: ProcessSessionManager, ): void { @@ -705,7 +673,7 @@ function registerCodexProcessTools( } export function createMcpServer( - config: ServerConfig, + config: RuntimeConfig, workspaces: WorkspaceRegistry, reviewCheckpoints: ReturnType, processSessions: ProcessSessionManager, @@ -716,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.", }, @@ -818,11 +786,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) @@ -1055,7 +1026,7 @@ export function createMcpServer( }, ); - if (config.toolMode !== "codex") { + const registerWriteEditTools = () => { registerAppTool( server, toolNames.write, @@ -1219,9 +1190,9 @@ export function createMcpServer( }; }, ); - } + }; - if (config.toolMode === "codex") { + const registerApplyPatchTool = () => { registerAppTool( server, "apply_patch", @@ -1294,9 +1265,9 @@ export function createMcpServer( }; }, ); - } + }; - if (config.widgets === "changes") { + const registerChangeReviewTool = () => { registerAppTool( server, "show_changes", @@ -1349,9 +1320,9 @@ export function createMcpServer( }; }, ); - } + }; - if (config.toolMode === "full") { + const registerDedicatedInspectionTools = () => { registerAppTool( server, toolNames.grep, @@ -1560,17 +1531,15 @@ export function createMcpServer( }; }, ); - } + }; - if (config.toolMode !== "codex") { + const registerBashTool = () => { registerAppTool( server, toolNames.shell, { title: "Bash", - description: config.toolMode !== "full" - ? `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() @@ -1652,13 +1621,27 @@ export function createMcpServer( }; }, ); + }; + + 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.toolMode === "codex") { - registerCodexProcessTools(server, config, workspaces, processSessions); + const presentationRegistrations: Record void> = { + "change-review": registerChangeReviewTool, + }; + for (const group of config.runtimePresentation.toolGroups) { + presentationRegistrations[group](); } - if (config.artifactsEnabled && isArtifactDownloadSupportedPlatform()) { + if (config.artifactCapability.status === "available") { registerArtifactTools(server, { config, workspaces, @@ -1677,6 +1660,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("*") @@ -1862,7 +1848,7 @@ export function createServer( }; const server = createMcpServer( - config, + runtime, workspaces, reviewCheckpoints, processSessions, 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/, +); diff --git a/src/user-config.ts b/src/user-config.ts index 98d05ac6..15db5407 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -7,35 +7,122 @@ 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 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; -} +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."), +}); -export interface DevspaceAuthConfig { - ownerToken?: string; +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(), + 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; 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 { @@ -43,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"); } @@ -60,29 +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 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: configExists ? readJsonFile(configPath) : {}, - auth: authExists ? readJsonFile(authPath) : {}, + config, + auth: parseDocument(devspaceAuthConfigSchema, authDocument, authPath), + configDocument, + configSourcePath, + configSourceText, }; } export function writeDevspaceConfig( config: DevspaceUserConfig, env: NodeJS.ProcessEnv = process.env, + source: Pick | undefined = undefined, ): string { const filePath = devspaceConfigPath(env); mkdirSync(devspaceConfigDir(env), { recursive: true }); - writeJsonFile(filePath, 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; } @@ -100,15 +225,129 @@ export function generateOwnerToken(): string { return randomBytes(32).toString("base64url"); } -function readJsonFile(filePath: string): T { +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 { - return JSON.parse(readFileSync(filePath, "utf8")) as T; + 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, + filePath: string, +): T { + const result = schema.safeParse(document); + if (result.success) return result.data; + + 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 }); } 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;