diff --git a/.gitignore b/.gitignore index 397a63f..5e4384b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ dist/ releases/ .env *.log +WORKING/ diff --git a/README.md b/README.md index 6b45147..9ffd296 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,9 @@ DevSpace gives ChatGPT tools to: - discover local agent skills from your skill folders - show tool cards and optional change summaries in ChatGPT Apps-compatible hosts +DevSpace also bundles a small set of built-in workflow and engineering skills in `skills/core/`. +Their structure is inspired by [alirezarezvani/claude-skills](https://github.com/alirezarezvani/claude-skills), which is released under the MIT license. + ## Mental Model DevSpace is remote access to selected local folders. diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index c5efc66..47af611 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -81,8 +81,10 @@ Skills are enabled by default for coding-agent workflows. DevSpace discovers skills from: +- built-in DevSpace skills in `skills/core` +- workspace-local skills in `skills/local` +- workspace-installed skills in `skills/installed` - `DEVSPACE_AGENT_DIR`, which defaults to `~/.codex` -- project `.pi/skills` - optional paths from `DEVSPACE_SKILL_PATHS` When `open_workspace` returns matching skills, the model should read the @@ -95,6 +97,8 @@ Skill paths may be outside the workspace. DevSpace only permits reading: Set `DEVSPACE_SKILLS=0` to hide skills from workspace output. +The built-in skill layout is inspired by [alirezarezvani/claude-skills](https://github.com/alirezarezvani/claude-skills) under the MIT license, with DevSpace-specific adaptations for MCP workflow commands and local coding tasks. + ## Tool Names Short names are the default: diff --git a/docs/configuration.md b/docs/configuration.md index 7107338..0101a3d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -34,6 +34,7 @@ npx @waishnav/devspace config set publicBaseUrl https://devspace.example.com | `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_TUNNEL` | Optional automatic tunnel mode. Currently supports `cloudflare` when explicitly enabled. | | `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`. | @@ -49,6 +50,11 @@ DevSpace uses a single-user OAuth approval flow. | `DEVSPACE_OAUTH_REFRESH_TOKEN_TTL_SECONDS` | `2592000` | | `DEVSPACE_OAUTH_SCOPES` | `devspace` | | `DEVSPACE_OAUTH_ALLOWED_REDIRECT_HOSTS` | `chatgpt.com,localhost,127.0.0.1` | +| `DEVSPACE_OAUTH_STATE_PATH` | `$DEVSPACE_STATE_DIR/oauth.json` | + +Registered OAuth clients and refresh token hashes are persisted in +`$DEVSPACE_STATE_DIR/oauth.json` by default. Access tokens and authorization +codes remain in memory only. MCP clients discover metadata from: @@ -73,6 +79,30 @@ MCP clients discover metadata from: | `minimal` | Default. Disables dedicated search and list tools. Clients use the shell tool with `rg`, `grep`, `find`, `ls`, or `tree` for inspection. | | `full` | Enables dedicated `grep`, `glob`, and `ls` tools. | +`DEVSPACE_SHELL_MODE` controls shell execution policy. + +| Value | Behavior | +| --- | --- | +| `full` | Default. Preserves the current shell behavior. | +| `read-only` | Allows only single-command inspection workflows such as `rg`, `git status`, `find`, or `ls`. Blocks shell control operators and mutating commands. | +| `off` | Disables shell execution entirely. | + +## Tunnel Modes + +DevSpace keeps the existing manual `publicBaseUrl` flow by default. Automatic +Cloudflare quick tunnel mode is opt-in only. + +Enable it explicitly with one of: + +```bash +npx @waishnav/devspace serve --tunnel +DEVSPACE_TUNNEL=cloudflare npx @waishnav/devspace serve +``` + +Or set `"tunnel": "cloudflare"` in `~/.devspace/config.json`. + +Use `--no-tunnel` to override configured tunnel mode for one run. + ## Widgets `DEVSPACE_WIDGETS` controls ChatGPT Apps iframe usage. diff --git a/docs/gotchas.md b/docs/gotchas.md index c5099dc..8261c2d 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -196,7 +196,9 @@ DEVSPACE_SKILLS=1 npx @waishnav/devspace serve DevSpace looks in: - `DEVSPACE_AGENT_DIR`, defaulting to `~/.codex` -- project `.pi/skills` +- `skills/local` +- `skills/installed` +- `skills/core` - `DEVSPACE_SKILL_PATHS` If a skill appears in `open_workspace`, the model must read that skill's diff --git a/docs/setup.md b/docs/setup.md index 8efbcdc..4ad2c3e 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -11,8 +11,8 @@ projects through DevSpace. - Bash, including Git Bash or WSL on Windows - a public HTTPS URL that forwards to the local DevSpace server -DevSpace does not create the public tunnel for you. Use Cloudflare Tunnel, -ngrok, Pinggy, Tailscale Funnel, or your own HTTPS reverse proxy. +DevSpace does not create the public tunnel for you by default. Use Cloudflare +Tunnel, ngrok, Pinggy, Tailscale Funnel, or your own HTTPS reverse proxy. ## Install And Configure @@ -95,6 +95,22 @@ npx @waishnav/devspace config set publicBaseUrl https://devspace.example.com npx @waishnav/devspace serve ``` +If you explicitly want DevSpace to open a Cloudflare quick tunnel for a single +run, opt in with: + +```bash +npx @waishnav/devspace serve --tunnel +``` + +Or: + +```bash +DEVSPACE_TUNNEL=cloudflare npx @waishnav/devspace serve +``` + +This mode requires an existing `cloudflared` binary and does not replace the +default manual public URL workflow. + ## Approve The Client When ChatGPT, Claude, or another MCP client connects, DevSpace shows an Owner diff --git a/package-lock.json b/package-lock.json index c112ebc..0b240d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@clack/prompts": "^1.5.1", - "@earendil-works/pi-coding-agent": "^0.79.4", + "@earendil-works/pi-coding-agent": "^0.79.8", "@modelcontextprotocol/ext-apps": "^1.7.2", "@modelcontextprotocol/sdk": "^1.29.0", "@pierre/diffs": "^1.2.5", @@ -70,15 +70,15 @@ } }, "node_modules/@earendil-works/pi-coding-agent": { - "version": "0.79.4", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.79.4.tgz", - "integrity": "sha512-PthzVzM5m4XH/hrU+2fVjuwuH5M4eMFWbd0NCRScH14XKpwlPc8/Fh6JDz0jQb5kTBT9oQT183YLTHVVulFL9A==", + "version": "0.79.8", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.79.8.tgz", + "integrity": "sha512-wr9oTS/yrwURDXnYrONQgFgV7QDlwslXL/rvKU5X7TRtrGxIhippsRApXqYlRwSeMjb2YzgHMfZ/kAhOqrzoFQ==", "hasShrinkwrap": true, "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.79.4", - "@earendil-works/pi-ai": "^0.79.4", - "@earendil-works/pi-tui": "^0.79.4", + "@earendil-works/pi-agent-core": "^0.79.8", + "@earendil-works/pi-ai": "^0.79.8", + "@earendil-works/pi-tui": "^0.79.8", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -92,7 +92,7 @@ "proper-lockfile": "4.1.2", "semver": "7.8.0", "typebox": "1.1.38", - "undici": "8.3.0", + "undici": "8.5.0", "yaml": "2.9.0" }, "bin": { @@ -541,11 +541,11 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { - "version": "0.79.4", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.79.4.tgz", + "version": "0.79.8", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.79.8.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.79.4", + "@earendil-works/pi-ai": "^0.79.8", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -555,14 +555,15 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { - "version": "0.79.4", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.79.4.tgz", + "version": "0.79.8", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.79.8.tgz", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", - "@mistralai/mistralai": "2.2.1", + "@mistralai/mistralai": "2.2.6", + "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", @@ -578,12 +579,12 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { - "version": "0.79.4", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.79.4.tgz", + "version": "0.79.8", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.79.8.tgz", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", - "marked": "15.0.12" + "marked": "18.0.5" }, "engines": { "node": ">=22.19.0" @@ -687,6 +688,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -703,6 +707,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -719,6 +726,9 @@ "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -735,6 +745,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -751,6 +764,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -793,14 +809,23 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@mistralai/mistralai": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.1.tgz", - "integrity": "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==", + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.6.tgz", + "integrity": "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==", "license": "Apache-2.0", "dependencies": { + "@opentelemetry/semantic-conventions": "^1.40.0", "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.25.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@nodable/entities": { @@ -815,6 +840,24 @@ ], "license": "MIT" }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", + "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -834,9 +877,9 @@ "license": "BSD-3-Clause" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", "license": "BSD-3-Clause" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/fetch": { @@ -854,12 +897,6 @@ "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", "license": "BSD-3-Clause" }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/inquire": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", - "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", - "license": "BSD-3-Clause" - }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/path": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", @@ -1451,15 +1488,15 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/marked": { - "version": "15.0.12", - "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", - "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", "license": "MIT", "bin": { "marked": "bin/marked.js" }, "engines": { - "node": ">= 18" + "node": ">= 20" } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/minimatch": { @@ -1637,24 +1674,23 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/protobufjs": { - "version": "7.5.9", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.9.tgz", - "integrity": "sha512-Od4muIm3HW1AouyHF5lONOf1FWo3hY1NbFDoy191X9GzhpgW1clCoaFjfVs2rKJNFYpTNJbje4cbAIDBZJ63ZA==", + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", + "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", - "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", - "long": "^5.0.0" + "long": "^5.3.2" }, "engines": { "node": ">=12.0.0" @@ -1759,9 +1795,9 @@ "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/undici": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.3.0.tgz", - "integrity": "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==", + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz", + "integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", "license": "MIT", "engines": { "node": ">=22.19.0" @@ -1798,9 +1834,9 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index f8d3903..d7465e3 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "build:app": "vite build", "dev": "node scripts/dev-server.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts", + "test": "node scripts/dev-server.test.mjs && node --import tsx src/config.test.ts && node --import tsx src/oauth-provider.test.ts && node --import tsx src/cloudflare-tunnel.test.ts && node --import tsx src/shell-policy.test.ts && node --import tsx src/tool-result.test.ts && node --import tsx src/roots.test.ts && node --import tsx src/skills.test.ts && node --import tsx src/prompting.test.ts && node --import tsx src/workspace-commands.test.ts && node --import tsx src/workspace-operations.test.ts && node --import tsx src/workspaces.test.ts && node --import tsx src/review-checkpoints.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], @@ -33,7 +33,7 @@ "license": "MIT", "dependencies": { "@clack/prompts": "^1.5.1", - "@earendil-works/pi-coding-agent": "^0.79.4", + "@earendil-works/pi-coding-agent": "^0.79.8", "@modelcontextprotocol/ext-apps": "^1.7.2", "@modelcontextprotocol/sdk": "^1.29.0", "@pierre/diffs": "^1.2.5", @@ -59,6 +59,7 @@ }, "overrides": { "protobufjs": "7.6.4", - "ws": "8.21.0" + "ws": "8.21.0", + "undici": "8.5.0" } } diff --git a/scripts/dev-server.mjs b/scripts/dev-server.mjs index 5585bda..2575214 100644 --- a/scripts/dev-server.mjs +++ b/scripts/dev-server.mjs @@ -1,9 +1,13 @@ import { spawn } from "node:child_process"; +import { createRequire } from "node:module"; import { readdirSync, statSync, watch } from "node:fs"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -const repoRoot = resolve(fileURLToPath(new URL("..", import.meta.url))); +const scriptPath = fileURLToPath(import.meta.url); +export const repoRoot = resolve(fileURLToPath(new URL("..", import.meta.url))); +const require = createRequire(import.meta.url); +const tsxCliPath = require.resolve("tsx/cli"); const watchRoots = ["src"].map((entry) => join(repoRoot, entry)); const restartDelayMs = 750; const crashDelayMs = 1500; @@ -17,9 +21,17 @@ function log(message) { console.error(`[devspace:dev] ${message}`); } +export function createServerCommand() { + return { + command: process.execPath, + args: [tsxCliPath, "src/cli.ts", "serve"], + }; +} + function start() { stoppingForRestart = false; - child = spawn("npx", ["tsx", "src/cli.ts", "serve"], { + const { command, args } = createServerCommand(); + child = spawn(command, args, { cwd: repoRoot, env: process.env, stdio: "inherit", @@ -108,13 +120,19 @@ function shutdown() { setTimeout(() => process.exit(1), 3000).unref(); } -for (const signal of ["SIGINT", "SIGTERM"]) { - process.on(signal, shutdown); -} +function main() { + for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, shutdown); + } -for (const root of watchRoots) { - watchDirectory(root); + for (const root of watchRoots) { + watchDirectory(root); + } + + log("watching src; server restarts on changes and after crashes"); + start(); } -log("watching src; server restarts on changes and after crashes"); -start(); +if (resolve(process.argv[1] ?? "") === scriptPath) { + main(); +} diff --git a/scripts/dev-server.test.mjs b/scripts/dev-server.test.mjs new file mode 100644 index 0000000..8e7ecf5 --- /dev/null +++ b/scripts/dev-server.test.mjs @@ -0,0 +1,10 @@ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { createServerCommand } from "./dev-server.mjs"; + +const require = createRequire(import.meta.url); + +assert.deepEqual(createServerCommand(), { + command: process.execPath, + args: [require.resolve("tsx/cli"), "src/cli.ts", "serve"], +}); diff --git a/skills/core/devspace-workflow/SKILL.md b/skills/core/devspace-workflow/SKILL.md new file mode 100644 index 0000000..e1f583c --- /dev/null +++ b/skills/core/devspace-workflow/SKILL.md @@ -0,0 +1,75 @@ +--- +name: devspace-workflow +description: Run concise DevSpace planning, goal, and answer workflows with minimal narration. +license: MIT +metadata: + version: 1.0.0 + author: DevSpace + category: workflow + updated: 2026-06-20 +--- + +# DevSpace Workflow + +## What This Skill Does + +Use this skill when the user drives DevSpace with concise workflow messages such as `/plan`, `/goal`, or compact answers to pending questions. + +## Before Starting + +1. Confirm you already have a `workspaceId` for the active project. +2. If there is no open workspace, call `open_workspace` first. +3. Reuse the same `workspaceId`; do not reopen the same folder unless it stops working or the user explicitly asks. +4. Keep replies short and operational unless the user asks for explanation. + +## Workflow Modes + +### Plan Workflow + +Trigger on messages like: + +- `@dev /plan ...` +- `/plan ...` + +Use `handle_workspace_command` first, then continue the planning workflow in `plan` mode. + +### Goal Workflow + +Trigger on messages like: + +- `@dev /goal ...` +- `/goal ...` + +Use `handle_workspace_command` first, then continue with `create_goal`, `get_goal`, and `update_goal` as needed. + +### Compact Answers + +Trigger when there is pending `request_user_input` state and the user replies with compact text such as `1B,2A`, `1B, 2A`, or `1b 2a`. + +Prefer passing the raw reply through `handle_workspace_command` or `answer_user_input(text)` instead of paraphrasing it. + +### Batch File Changes + +When the user asks for broad or multi-file modifications, prefer `apply_workspace_patch` with a unified diff patch instead of shell redirection, heredocs, generated scripts, or ad-hoc write commands. + +### Git Push + +When the user explicitly asks to push commits, prefer `git_push` with structured `remote`, `branch`, and `setUpstream` arguments instead of `bash` with a raw `git push` command. + +## Response Standard + +- Bottom line first. +- Prefer action over explanation. +- For simple workflow steps, return a short status. +- Do not explain slash semantics or MCP mechanics unless the user asks. + +## References + +- [Command Mapping](references/commands.md) +- [Response Style](references/style.md) +- [Examples](references/examples.md) + +## Related Skills + +- `senior-architect-lite` for architecture decisions before or during `/plan` +- `skill-authoring-lite` for creating or refactoring DevSpace skills with the same structure diff --git a/skills/core/devspace-workflow/references/commands.md b/skills/core/devspace-workflow/references/commands.md new file mode 100644 index 0000000..61ba4f4 --- /dev/null +++ b/skills/core/devspace-workflow/references/commands.md @@ -0,0 +1,77 @@ +# Command Mapping + +## `/plan` + +Inputs: + +- `@dev /plan ...` +- `/plan ...` + +Expected behavior: + +1. Call `handle_workspace_command`. +2. Ensure the workspace moves to `plan` mode. +3. Continue with planning tools and repository exploration. +4. Keep replies short unless the user explicitly asks for detail. + +## `/goal` + +Inputs: + +- `@dev /goal ...` +- `/goal ...` + +Expected behavior: + +1. Call `handle_workspace_command`. +2. Create or continue the workspace goal. +3. Use `get_goal` and `update_goal` for lifecycle management. + +## Compact Answers + +Inputs: + +- `1B,2A` +- `1B, 2A` +- `1b 2a` + +Expected behavior: + +1. Check that a pending `request_user_input` exists. +2. Pass the raw text to `handle_workspace_command` or `answer_user_input(text)`. +3. Do not rewrite the user's answer into prose before submitting it. + +## Failure Handling + +- If there is no open workspace, open one first. +- If there is no pending user input, do not pretend the answer was accepted. +- If the compact answer is incomplete or invalid, return the specific validation error. + +## Batch File Changes + +Inputs: + +- "Modify these files ..." +- "Apply this patch ..." +- "Do the same change across the project ..." + +Expected behavior: + +1. Inspect the files first. +2. Use `apply_workspace_patch` for coordinated multi-file changes. +3. Avoid `bash` redirection, heredocs, `sed -i`, `perl -i`, or generated scripts for project writes. +4. Call `show_changes` after the related change set when available. + +## Git Push + +Inputs: + +- "Push this branch" +- "git push" +- "Push origin main" + +Expected behavior: + +1. Use `git status` or the git inspection tools to verify what will be pushed. +2. Use `git_push` with structured arguments. +3. Do not use generic `bash` for raw `git push` unless `git_push` is unavailable. diff --git a/skills/core/devspace-workflow/references/examples.md b/skills/core/devspace-workflow/references/examples.md new file mode 100644 index 0000000..60e21ea --- /dev/null +++ b/skills/core/devspace-workflow/references/examples.md @@ -0,0 +1,42 @@ +# Examples + +## Planning + +User: + +```text +@dev /plan 修复国家列表节点数量显示 +``` + +Expected behavior: + +- enter plan mode +- continue planning +- keep the immediate status brief + +## Goal + +User: + +```text +@dev /goal 修复国家列表节点数量显示 +``` + +Expected behavior: + +- create or continue the goal +- report a short status + +## Compact Answer + +User: + +```text +1B,2A +``` + +Expected behavior: + +- treat the reply as answer payload +- complete pending user input if valid +- return a short status diff --git a/skills/core/devspace-workflow/references/style.md b/skills/core/devspace-workflow/references/style.md new file mode 100644 index 0000000..e61dfcd --- /dev/null +++ b/skills/core/devspace-workflow/references/style.md @@ -0,0 +1,22 @@ +# Response Style + +## Core Rules + +- Bottom line first. +- Prefer action over explanation. +- Use short status messages for straightforward workflow steps. +- Avoid long tutorials, architecture essays, or repeated background. + +## Good Status Examples + +- `Plan mode on` +- `Goal created` +- `Answer recorded` +- `No workflow command recognized` + +## Avoid + +- Re-explaining what `/plan` means after already acting on it +- Explaining MCP mechanics unless the user asks +- Repeating already-confirmed choices +- Turning one-line results into long summaries diff --git a/skills/core/senior-architect-lite/SKILL.md b/skills/core/senior-architect-lite/SKILL.md new file mode 100644 index 0000000..c33102e --- /dev/null +++ b/skills/core/senior-architect-lite/SKILL.md @@ -0,0 +1,44 @@ +--- +name: senior-architect-lite +description: Evaluate architecture options, tradeoffs, and implementation direction for coding tasks inside DevSpace. +license: MIT +metadata: + version: 1.0.0 + author: DevSpace + category: engineering + updated: 2026-06-20 +--- + +# Senior Architect Lite + +## What This Skill Does + +Use this skill when the task needs architecture guidance, solution framing, design tradeoff analysis, or implementation direction before code changes. + +## Before Starting + +1. Read the relevant code, types, configs, and entrypoints first. +2. Ground recommendations in the current repository, not generic best practices. +3. Keep conclusions concise and decision-oriented. + +## Workflow + +1. Identify the current architecture and constraints. +2. Compare only the viable options. +3. Recommend one approach with clear reasoning. +4. Surface the main risks, compatibility concerns, and validation needs. +5. If the task is still ambiguous, use `request_user_input` for the missing product or tradeoff decision. + +## Deliverable + +Return: + +- recommended approach +- why it fits this codebase +- key implementation implications +- tests or checks needed to validate it + +## References + +- [Decision Guide](references/decision-guide.md) +- [Response Style](references/style.md) diff --git a/skills/core/senior-architect-lite/references/decision-guide.md b/skills/core/senior-architect-lite/references/decision-guide.md new file mode 100644 index 0000000..8039c32 --- /dev/null +++ b/skills/core/senior-architect-lite/references/decision-guide.md @@ -0,0 +1,14 @@ +# Decision Guide + +When comparing options, prefer: + +- the smallest change that still fully satisfies the requirement +- compatibility with existing project patterns +- explicit interfaces and testability +- approaches that reduce follow-up ambiguity for the implementer + +Always name: + +- the recommended option +- the main rejected option +- the key risk or migration concern diff --git a/skills/core/senior-architect-lite/references/style.md b/skills/core/senior-architect-lite/references/style.md new file mode 100644 index 0000000..70b9dec --- /dev/null +++ b/skills/core/senior-architect-lite/references/style.md @@ -0,0 +1,6 @@ +# Response Style + +- Lead with the recommendation. +- Keep tradeoff discussion focused on the actual repo. +- Avoid long framework lectures. +- Do not list speculative future architecture unless asked. diff --git a/skills/core/skill-authoring-lite/SKILL.md b/skills/core/skill-authoring-lite/SKILL.md new file mode 100644 index 0000000..9117015 --- /dev/null +++ b/skills/core/skill-authoring-lite/SKILL.md @@ -0,0 +1,27 @@ +--- +name: skill-authoring-lite +description: Create or refactor DevSpace skills using a structured SKILL.md plus references layout. +license: MIT +metadata: + version: 1.0.0 + author: DevSpace + category: meta + updated: 2026-06-20 +--- + +# Skill Authoring Lite + +## What This Skill Does + +Use this skill when creating or refactoring skills for DevSpace so they stay structured, concise, and reference-driven. + +## Workflow + +1. Keep `SKILL.md` focused on workflow and decisions. +2. Move detail-heavy material into `references/`. +3. Prefer examples and checklists over long prose. +4. Keep the response standard explicit. + +## References + +- [Structure Checklist](references/structure-checklist.md) diff --git a/skills/core/skill-authoring-lite/references/structure-checklist.md b/skills/core/skill-authoring-lite/references/structure-checklist.md new file mode 100644 index 0000000..b2e3977 --- /dev/null +++ b/skills/core/skill-authoring-lite/references/structure-checklist.md @@ -0,0 +1,10 @@ +# Structure Checklist + +A DevSpace skill should usually include: + +- frontmatter with `name`, `description`, `license`, and `metadata` +- a short purpose section +- `Before Starting` +- a compact workflow +- response style guidance +- `references/` for detailed rules and examples diff --git a/src/cli.ts b/src/cli.ts index 0e0147b..be0242a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,6 +5,7 @@ import { resolve } from "node:path"; import * as prompts from "@clack/prompts"; import { getShellConfig } from "@earendil-works/pi-coding-agent"; import { satisfies } from "semver"; +import { resolveTunnelMode, startQuickTunnel, type QuickTunnel } from "./cloudflare-tunnel.js"; import { loadConfig } from "./config.js"; import { generateOwnerToken, @@ -28,7 +29,7 @@ async function main(argv: string[]): Promise { switch (command) { case "serve": await ensureConfigured(); - await serve(); + await serve(args); return; case "init": await runInit({ force: args.includes("--force") }); @@ -162,7 +163,7 @@ async function runInit({ force }: { force: boolean }): Promise { } } -async function serve(): Promise { +async function serve(args: string[] = []): Promise { const sqliteStatus = checkSqliteNative(); if (sqliteStatus !== "ok") { throw new Error( @@ -176,6 +177,23 @@ async function serve(): Promise { ); } + let tunnel: QuickTunnel | undefined; + const configuredTunnel = resolveTunnelMode({ + args, + env: process.env, + configuredTunnel: loadDevspaceFiles().config.tunnel, + }); + if (configuredTunnel === "cloudflare") { + const files = loadDevspaceFiles(); + const host = process.env.HOST ?? files.config.host ?? "127.0.0.1"; + const port = Number(process.env.PORT ?? files.config.port ?? 7676); + const tunnelHost = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host; + const localBaseUrl = `http://${tunnelHost}:${port}`; + + tunnel = await startQuickTunnel(localBaseUrl, { quiet: true }); + process.env.DEVSPACE_PUBLIC_BASE_URL = tunnel.publicBaseUrl; + } + const { createServer } = await import("./server.js"); const config = loadConfig(); const { app } = createServer(config); @@ -187,11 +205,15 @@ async function serve(): Promise { if (config.allowedHosts.includes("*")) { console.warn("warning: Host header allowlist is disabled because DEVSPACE_ALLOWED_HOSTS=*"); } + if (tunnel) { + console.log(`cloudflare tunnel: ${tunnel.publicBaseUrl}`); + } console.log("auth: Owner password approval required"); console.log(`logging: ${config.logging.level} ${config.logging.format}`); }); const shutdown = () => { + tunnel?.stop(); httpServer.close(() => process.exit(0)); }; process.once("SIGINT", shutdown); @@ -257,11 +279,17 @@ function printHelp(): void { "Usage:", " devspace Run first-time setup if needed, then start the server", " devspace serve Start the server", + " devspace serve --tunnel Start the server with an explicit Cloudflare quick tunnel", + " devspace serve --no-tunnel Disable a configured Cloudflare quick tunnel for this run", " devspace init Create or update ~/.devspace/config.json and auth.json", " devspace doctor Show config, runtime, and native dependency status", " devspace config get Print persisted config", " devspace config set publicBaseUrl ", "", + "Optional Cloudflare quick tunnel:", + " DEVSPACE_TUNNEL=cloudflare devspace serve", + " or set { \"tunnel\": \"cloudflare\" } in ~/.devspace/config.json", + "", "For temporary tunnels:", " DEVSPACE_PUBLIC_BASE_URL=https://example.trycloudflare.com devspace serve", ].join("\n"), diff --git a/src/cloudflare-tunnel.test.ts b/src/cloudflare-tunnel.test.ts new file mode 100644 index 0000000..048914f --- /dev/null +++ b/src/cloudflare-tunnel.test.ts @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import { + buildCloudflareTunnelCommand, + extractTryCloudflareUrl, + resolveTunnelMode, +} from "./cloudflare-tunnel.js"; + +assert.equal(resolveTunnelMode(), undefined); +assert.equal(resolveTunnelMode({ args: ["--tunnel"] }), "cloudflare"); +assert.equal(resolveTunnelMode({ args: ["--tunnel=cloudflare"] }), "cloudflare"); +assert.equal(resolveTunnelMode({ args: ["--tunnel", "--no-tunnel"] }), undefined); +assert.equal(resolveTunnelMode({ env: { DEVSPACE_TUNNEL: "cloudflare" } as NodeJS.ProcessEnv }), "cloudflare"); +assert.equal(resolveTunnelMode({ env: { DEVSPACE_TUNNEL: "off" } as NodeJS.ProcessEnv }), undefined); +assert.equal(resolveTunnelMode({ configuredTunnel: "cloudflare" }), "cloudflare"); +assert.equal( + resolveTunnelMode({ + args: ["--no-tunnel"], + env: { DEVSPACE_TUNNEL: "cloudflare" } as NodeJS.ProcessEnv, + configuredTunnel: "cloudflare", + }), + undefined, +); + +assert.equal( + extractTryCloudflareUrl("INF Requesting new quick Tunnel on trycloudflare.com...\nhttps://abc-123.trycloudflare.com"), + "https://abc-123.trycloudflare.com", +); +assert.equal( + extractTryCloudflareUrl("https://abc.trycloudflare.com and then https://def.trycloudflare.com"), + "https://abc.trycloudflare.com", +); +assert.equal(extractTryCloudflareUrl("https://example.com"), undefined); +assert.equal(extractTryCloudflareUrl("https://nottrycloudflare.example.com"), undefined); + +assert.deepEqual( + buildCloudflareTunnelCommand("cloudflared", "http://127.0.0.1:7676"), + { + command: "cloudflared", + args: ["tunnel", "--url", "http://127.0.0.1:7676", "--no-autoupdate"], + }, +); diff --git a/src/cloudflare-tunnel.ts b/src/cloudflare-tunnel.ts new file mode 100644 index 0000000..83746aa --- /dev/null +++ b/src/cloudflare-tunnel.ts @@ -0,0 +1,164 @@ +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; +import { existsSync } from "node:fs"; +import type { TunnelMode } from "./user-config.js"; + +const TRYCLOUDFLARE_URL_RE = /https:\/\/([a-zA-Z0-9-]+)\.trycloudflare\.com\b/g; + +export interface QuickTunnel { + publicBaseUrl: string; + child: ChildProcess; + stop: () => void; +} + +export interface StartQuickTunnelOptions { + quiet?: boolean; + timeoutMs?: number; +} + +export interface CloudflareSpawnCommand { + command: string; + args: string[]; +} + +export interface TunnelModeOptions { + args?: string[]; + env?: NodeJS.ProcessEnv; + configuredTunnel?: TunnelMode; +} + +export function resolveTunnelMode(options: TunnelModeOptions = {}): TunnelMode | undefined { + const args = options.args ?? []; + if (args.includes("--no-tunnel")) return undefined; + if (args.includes("--tunnel") || args.includes("--tunnel=cloudflare")) return "cloudflare"; + + const envTunnel = options.env?.DEVSPACE_TUNNEL?.trim().toLowerCase(); + if (envTunnel === "cloudflare") return "cloudflare"; + if (envTunnel === "none" || envTunnel === "off") return undefined; + + return options.configuredTunnel; +} + +export function extractTryCloudflareUrl(output: string): string | undefined { + const match = TRYCLOUDFLARE_URL_RE.exec(output); + TRYCLOUDFLARE_URL_RE.lastIndex = 0; + return match ? `https://${match[1]}.trycloudflare.com` : undefined; +} + +export function buildCloudflareTunnelCommand( + cloudflaredPath: string, + localBaseUrl: string, +): CloudflareSpawnCommand { + return { + command: cloudflaredPath, + args: ["tunnel", "--url", localBaseUrl, "--no-autoupdate"], + }; +} + +export function resolveCloudflaredBinary(env: NodeJS.ProcessEnv = process.env): string { + const explicit = env.CLOUDFLARED_BIN?.trim(); + if (explicit) { + if (verifyCloudflared(explicit)) return explicit; + throw new Error(`CLOUDFLARED_BIN is set to ${explicit}, but it failed --version.`); + } + + if (verifyCloudflared("cloudflared")) return "cloudflared"; + throw new Error( + "Cloudflare tunnel mode requires an installed cloudflared binary. " + + "Install cloudflared or set CLOUDFLARED_BIN to an existing executable.", + ); +} + +export async function startQuickTunnel( + localBaseUrl: string, + options: StartQuickTunnelOptions = {}, +): Promise { + const cloudflaredPath = resolveCloudflaredBinary(); + const command = buildCloudflareTunnelCommand(cloudflaredPath, localBaseUrl); + const child = spawn(command.command, command.args, { + stdio: ["ignore", "pipe", "pipe"], + shell: false, + }); + + try { + const publicBaseUrl = await waitForCloudflareUrl(child, options.timeoutMs ?? 45_000); + if (!options.quiet) { + console.log(`devspace: Cloudflare quick tunnel ready at ${publicBaseUrl}`); + } + + return { + publicBaseUrl, + child, + stop: () => stopChildProcess(child), + }; + } catch (error) { + stopChildProcess(child); + throw error; + } +} + +function verifyCloudflared(binaryPath: string): boolean { + if (binaryPath !== "cloudflared" && !existsSync(binaryPath)) return false; + + const result = spawnSync(binaryPath, ["--version"], { + stdio: "ignore", + shell: false, + timeout: 15_000, + }); + return result.status === 0; +} + +function waitForCloudflareUrl(child: ChildProcess, timeoutMs: number): Promise { + let output = ""; + + return new Promise((resolve, reject) => { + const cleanup = () => { + child.stdout?.off("data", onData); + child.stderr?.off("data", onData); + child.off("exit", onExit); + clearTimeout(timer); + }; + + const onData = (chunk: Buffer | string) => { + output += String(chunk); + const publicBaseUrl = extractTryCloudflareUrl(output); + if (!publicBaseUrl) return; + + cleanup(); + resolve(publicBaseUrl); + }; + + const onExit = (code: number | null, signal: NodeJS.Signals | null) => { + cleanup(); + reject(new Error(`cloudflared exited before publishing a tunnel URL (code=${code}, signal=${signal ?? "none"}).`)); + }; + + const timer = setTimeout(() => { + cleanup(); + reject(new Error("Timed out waiting for cloudflared to publish a trycloudflare URL.")); + }, timeoutMs); + timer.unref?.(); + + child.stdout?.on("data", onData); + child.stderr?.on("data", onData); + child.on("exit", onExit); + }); +} + +function stopChildProcess(child: ChildProcess): void { + if (child.killed) return; + + try { + child.kill("SIGTERM"); + } catch { + return; + } + + setTimeout(() => { + if (child.killed) return; + try { + child.kill("SIGKILL"); + } catch { + // ignore cleanup failures + } + }, 1_500).unref?.(); +} diff --git a/src/config.test.ts b/src/config.test.ts index 4f29c4e..492fb66 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -1,7 +1,8 @@ import assert from "node:assert/strict"; import { mkdtempSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { loadConfig } from "./config.js"; const emptyConfigDir = mkdtempSync(join(tmpdir(), "devspace-empty-config-test-")); @@ -23,6 +24,9 @@ assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "minimal" }).minimalTo assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "full" }).minimalTools, false); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_MINIMAL_TOOLS: "0" }).minimalTools, false); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_MINIMAL_TOOLS: "1" }).minimalTools, true); +assert.equal(loadConfig(baseEnv).shellMode, "full"); +assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SHELL_MODE: "read-only" }).shellMode, "read-only"); +assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SHELL_MODE: "off" }).shellMode, "off"); assert.equal(loadConfig(baseEnv).skillsEnabled, true); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "0" }).skillsEnabled, false); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "1" }).skillsEnabled, true); @@ -43,6 +47,10 @@ assert.throws( () => loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "invalid" }), /Invalid DEVSPACE_TOOL_MODE: invalid/, ); +assert.throws( + () => loadConfig({ ...baseEnv, DEVSPACE_SHELL_MODE: "invalid" }), + /Invalid DEVSPACE_SHELL_MODE: invalid/, +); assert.throws( () => loadConfig({ ...baseEnv, DEVSPACE_TOOL_NAMING: "invalid" }), /Invalid DEVSPACE_TOOL_NAMING: invalid/, @@ -84,6 +92,7 @@ assert.throws( ); assert.equal(loadConfig(baseEnv).oauth.ownerToken, "test-owner-token-that-is-long-enough"); +assert.match(loadConfig(baseEnv).oauth.statePath ?? "", /oauth\.json$/); assert.deepEqual(loadConfig(baseEnv).oauth.scopes, ["devspace"]); assert.deepEqual(loadConfig(baseEnv).oauth.allowedRedirectHosts, [ "chatgpt.com", @@ -112,6 +121,10 @@ assert.equal( .refreshTokenTtlSeconds, 240, ); +assert.equal( + loadConfig({ ...baseEnv, DEVSPACE_OAUTH_STATE_PATH: "~/custom-devspace-oauth.json" }).oauth.statePath, + resolve(homedir(), "custom-devspace-oauth.json"), +); assert.throws( () => loadConfig({ DEVSPACE_CONFIG_DIR: emptyConfigDir, DEVSPACE_ALLOWED_ROOTS: process.cwd() }), @@ -161,6 +174,7 @@ writeFileSync( const fileConfig = loadConfig({ DEVSPACE_CONFIG_DIR: configDir }); assert.equal(fileConfig.port, 8787); assert.equal(fileConfig.oauth.ownerToken, "persisted-owner-token-long-enough"); +assert.match(fileConfig.oauth.statePath ?? "", /oauth\.json$/); assert.equal(fileConfig.publicBaseUrl, "https://devspace.example.com"); assert.deepEqual(fileConfig.allowedHosts, [ "localhost", diff --git a/src/config.ts b/src/config.ts index bb0526c..136c32c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -7,6 +7,7 @@ import { loadDevspaceFiles } from "./user-config.js"; export type ToolNamingMode = "legacy" | "short"; export type WidgetMode = "off" | "changes" | "full"; +export type ShellMode = "full" | "read-only" | "off"; const DEFAULT_OAUTH_ACCESS_TOKEN_TTL_SECONDS = 60 * 60; const DEFAULT_OAUTH_REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60; @@ -18,6 +19,7 @@ export interface ServerConfig { allowedHosts: string[]; publicBaseUrl: string; minimalTools: boolean; + shellMode: ShellMode; toolNaming: ToolNamingMode; widgets: WidgetMode; stateDir: string; @@ -89,6 +91,13 @@ function parseMinimalTools(env: NodeJS.ProcessEnv): boolean { return true; } +function parseShellMode(value: string | undefined): ShellMode { + if (!value || value === "full") return "full"; + if (value === "read-only" || value === "off") return value; + + throw new Error(`Invalid DEVSPACE_SHELL_MODE: ${value}`); +} + function parseLogLevel(value: string | undefined): LogLevel { if (!value || value === "info") return "info"; if (["silent", "error", "warn", "debug"].includes(value)) return value as LogLevel; @@ -170,7 +179,15 @@ function parseRequiredSecret(value: string | undefined, name: string): string { return secret; } -function parseOAuthConfig(env: NodeJS.ProcessEnv, ownerToken: string | undefined): OAuthConfig { +function defaultOAuthStatePath(stateDir: string): string { + return join(stateDir, "oauth.json"); +} + +function parseOAuthConfig( + env: NodeJS.ProcessEnv, + ownerToken: string | undefined, + stateDir: string, +): OAuthConfig { return { ownerToken: parseRequiredSecret(env.DEVSPACE_OAUTH_OWNER_TOKEN ?? ownerToken, "DEVSPACE_OAUTH_OWNER_TOKEN"), accessTokenTtlSeconds: parsePositiveInteger( @@ -189,6 +206,7 @@ function parseOAuthConfig(env: NodeJS.ProcessEnv, ownerToken: string | undefined "localhost", "127.0.0.1", ]), + statePath: resolve(expandHomePath(env.DEVSPACE_OAUTH_STATE_PATH ?? defaultOAuthStatePath(stateDir))), }; } @@ -208,6 +226,7 @@ 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 stateDir = resolve(expandHomePath(env.DEVSPACE_STATE_DIR ?? files.config.stateDir ?? defaultStateDir())); const publicBaseUrl = parsePublicBaseUrl( env.DEVSPACE_PUBLIC_BASE_URL ?? files.config.publicBaseUrl ?? localPublicBaseUrl(host, port), ); @@ -223,14 +242,15 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { return { host, port, - oauth: parseOAuthConfig(env, files.auth.ownerToken), + oauth: parseOAuthConfig(env, files.auth.ownerToken, stateDir), allowedRoots: parseAllowedRoots(env.DEVSPACE_ALLOWED_ROOTS ?? files.config.allowedRoots), allowedHosts: parseAllowedHosts(env.DEVSPACE_ALLOWED_HOSTS, derivedAllowedHosts), publicBaseUrl, minimalTools: parseMinimalTools(env), + shellMode: parseShellMode(env.DEVSPACE_SHELL_MODE), toolNaming: parseToolNaming(env.DEVSPACE_TOOL_NAMING), widgets: parseWidgetMode(env.DEVSPACE_WIDGETS), - stateDir: resolve(expandHomePath(env.DEVSPACE_STATE_DIR ?? files.config.stateDir ?? defaultStateDir())), + stateDir, worktreeRoot: resolve(expandHomePath(env.DEVSPACE_WORKTREE_ROOT ?? files.config.worktreeRoot ?? defaultWorktreeRoot())), skillsEnabled: env.DEVSPACE_SKILLS === undefined ? true : parseBoolean(env.DEVSPACE_SKILLS), skillPaths: parsePathList(env.DEVSPACE_SKILL_PATHS), diff --git a/src/db/schema.ts b/src/db/schema.ts index ed5d292..5db2c86 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -38,7 +38,74 @@ export const loadedAgentFiles = sqliteTable( ], ); +export const workspacePlans = sqliteTable( + "workspace_plans", + { + workspaceSessionId: text("workspace_session_id") + .primaryKey() + .references(() => workspaceSessions.id, { onDelete: "cascade" }), + explanation: text("explanation"), + stepsJson: text("steps_json").notNull(), + updatedAt: text("updated_at").notNull(), + }, +); + +export const workspaceGoals = sqliteTable( + "workspace_goals", + { + workspaceSessionId: text("workspace_session_id") + .primaryKey() + .references(() => workspaceSessions.id, { onDelete: "cascade" }), + objective: text("objective").notNull(), + status: text("status").notNull().default("active"), + tokenBudget: text("token_budget"), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull(), + activeSeconds: text("active_seconds").notNull().default("0"), + completedAt: text("completed_at"), + blockedAt: text("blocked_at"), + }, + (table) => [ + index("workspace_goals_status_idx").on(table.status, table.updatedAt), + ], +); + +export const workspaceModes = sqliteTable( + "workspace_modes", + { + workspaceSessionId: text("workspace_session_id") + .primaryKey() + .references(() => workspaceSessions.id, { onDelete: "cascade" }), + mode: text("mode").notNull().default("default"), + updatedAt: text("updated_at").notNull(), + }, +); + +export const workspaceUserInputs = sqliteTable( + "workspace_user_inputs", + { + workspaceSessionId: text("workspace_session_id") + .primaryKey() + .references(() => workspaceSessions.id, { onDelete: "cascade" }), + promptJson: text("prompt_json").notNull(), + status: text("status").notNull().default("pending"), + deliveryMode: text("delivery_mode"), + responseJson: text("response_json"), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull(), + answeredAt: text("answered_at"), + }, +); + export type WorkspaceSessionRow = typeof workspaceSessions.$inferSelect; export type NewWorkspaceSessionRow = typeof workspaceSessions.$inferInsert; export type LoadedAgentFileRow = typeof loadedAgentFiles.$inferSelect; export type NewLoadedAgentFileRow = typeof loadedAgentFiles.$inferInsert; +export type WorkspacePlanRow = typeof workspacePlans.$inferSelect; +export type NewWorkspacePlanRow = typeof workspacePlans.$inferInsert; +export type WorkspaceGoalRow = typeof workspaceGoals.$inferSelect; +export type NewWorkspaceGoalRow = typeof workspaceGoals.$inferInsert; +export type WorkspaceModeRow = typeof workspaceModes.$inferSelect; +export type NewWorkspaceModeRow = typeof workspaceModes.$inferInsert; +export type WorkspaceUserInputRow = typeof workspaceUserInputs.$inferSelect; +export type NewWorkspaceUserInputRow = typeof workspaceUserInputs.$inferInsert; diff --git a/src/logger.ts b/src/logger.ts index c183ff6..85617ea 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -65,6 +65,8 @@ export function requestIp(req: Request, trustProxy: boolean): string | undefined } export function requestPath(req: Request): string { + const originalUrl = req.originalUrl?.split("?")[0]; + if (originalUrl) return originalUrl; return req.path || req.url.split("?")[0] || req.url; } diff --git a/src/oauth-provider.test.ts b/src/oauth-provider.test.ts new file mode 100644 index 0000000..dd75949 --- /dev/null +++ b/src/oauth-provider.test.ts @@ -0,0 +1,357 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { stat, chmod } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { InvalidGrantError, InvalidTokenError } from "@modelcontextprotocol/sdk/server/auth/errors.js"; +import { SingleUserOAuthProvider, type OAuthConfig } from "./oauth-provider.js"; +import type { OAuthClientInformationFull, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; +import type { AuthorizationParams } from "@modelcontextprotocol/sdk/server/auth/provider.js"; + +const root = mkdtempSync(join(tmpdir(), "devspace-oauth-provider-test-")); +const statePath = join(root, "state", "oauth.json"); +const customStatePath = join(root, "custom", "oauth-state.json"); +const resourceServerUrl = new URL("https://devspace.example.com/mcp"); +const config: OAuthConfig = { + ownerToken: "owner-token-that-is-long-enough", + accessTokenTtlSeconds: 3600, + refreshTokenTtlSeconds: 2592000, + scopes: ["devspace"], + allowedRedirectHosts: ["localhost"], + statePath, +}; + +try { + const firstProvider = new SingleUserOAuthProvider(config, resourceServerUrl); + const client = firstProvider.clientsStore.registerClient({ + client_name: "test client", + redirect_uris: ["http://localhost/callback"], + scope: "devspace", + }); + const firstTokens = issueTokens(firstProvider, client.client_id, ["devspace"], resourceServerUrl); + + const savedState = JSON.parse(readFileSync(statePath, "utf8")); + assert.equal(savedState.clients.length, 1); + assert.deepEqual(savedState.approvedConsents, []); + assert.equal(savedState.accessTokens.length, 1); + assert.equal(savedState.accessTokens[0].tokenHash.length > 0, true); + assert.equal(savedState.accessTokens[0].token, undefined); + assert.equal(savedState.refreshTokens.length, 1); + assert.equal(savedState.refreshTokens[0].tokenHash.length > 0, true); + assert.equal(savedState.refreshTokens[0].token, undefined); + assert.equal(JSON.stringify(savedState).includes(assertString(firstTokens.access_token)), false); + assert.equal(JSON.stringify(savedState).includes(assertString(firstTokens.refresh_token)), false); + + const stateStats = await stat(statePath); + const dirStats = await stat(join(root, "state")); + assert.equal(stateStats.mode & 0o777, 0o600); + assert.equal(dirStats.mode & 0o777, 0o700); + + const secondProvider = new SingleUserOAuthProvider(config, resourceServerUrl); + const persistedClient = secondProvider.clientsStore.getClient(client.client_id); + assert.equal(persistedClient?.client_id, client.client_id); + + const persistedAccess = await secondProvider.verifyAccessToken(assertString(firstTokens.access_token)); + assert.equal(persistedAccess.clientId, client.client_id); + assert.deepEqual(persistedAccess.scopes, ["devspace"]); + assert.equal(persistedAccess.resource?.href, resourceServerUrl.href); + + const secondTokens = await secondProvider.exchangeRefreshToken( + client, + assertString(firstTokens.refresh_token), + undefined, + resourceServerUrl, + ); + assert.equal(Boolean(secondTokens.refresh_token), true); + assert.notEqual(secondTokens.refresh_token, firstTokens.refresh_token); + + const rotatedState = JSON.parse(readFileSync(statePath, "utf8")); + assert.equal(rotatedState.refreshTokens.length, 1); + assert.equal(rotatedState.accessTokens.length, 2); + assert.equal(JSON.stringify(rotatedState).includes(assertString(firstTokens.access_token)), false); + assert.equal(JSON.stringify(rotatedState).includes(assertString(firstTokens.refresh_token)), false); + await assert.rejects( + () => secondProvider.exchangeRefreshToken(client, assertString(firstTokens.refresh_token), undefined, resourceServerUrl), + InvalidGrantError, + ); + + const expiredStatePath = join(root, "expired", "oauth.json"); + mkdirSync(join(root, "expired"), { recursive: true }); + writeFileSync( + expiredStatePath, + JSON.stringify({ + version: 1, + clients: [client], + accessTokens: [{ + tokenHash: "expired-access-token-hash", + clientId: client.client_id, + scopes: ["devspace"], + expiresAt: 1, + resource: resourceServerUrl.href, + }], + refreshTokens: [{ + tokenHash: "expired-token-hash", + clientId: client.client_id, + scopes: ["devspace"], + expiresAt: 1, + resource: resourceServerUrl.href, + }], + }), + ); + await chmod(expiredStatePath, 0o600); + const expiredProvider = new SingleUserOAuthProvider({ ...config, statePath: expiredStatePath }, resourceServerUrl); + await assert.rejects( + () => expiredProvider.exchangeRefreshToken(client, assertString(firstTokens.refresh_token), undefined, resourceServerUrl), + InvalidGrantError, + ); + const cleanedExpiredState = JSON.parse(readFileSync(expiredStatePath, "utf8")); + assert.equal(cleanedExpiredState.accessTokens.length, 0); + assert.equal(cleanedExpiredState.refreshTokens.length, 0); + + const corruptStatePath = join(root, "corrupt", "oauth.json"); + mkdirSync(join(root, "corrupt"), { recursive: true }); + writeFileSync(corruptStatePath, "{not valid json"); + await chmod(corruptStatePath, 0o600); + const corruptProvider = new SingleUserOAuthProvider({ ...config, statePath: corruptStatePath }, resourceServerUrl); + assert.equal(corruptProvider.clientsStore.getClient(client.client_id), undefined); + const repairedState = JSON.parse(readFileSync(corruptStatePath, "utf8")); + assert.deepEqual(repairedState, { version: 1, clients: [], accessTokens: [], refreshTokens: [], approvedConsents: [] }); + + const emptyStatePath = join(root, "empty", "oauth.json"); + mkdirSync(join(root, "empty"), { recursive: true }); + writeFileSync(emptyStatePath, ""); + await chmod(emptyStatePath, 0o600); + const emptyProvider = new SingleUserOAuthProvider({ ...config, statePath: emptyStatePath }, resourceServerUrl); + assert.equal(emptyProvider.clientsStore.getClient(client.client_id), undefined); + const rewrittenEmptyState = JSON.parse(readFileSync(emptyStatePath, "utf8")); + assert.deepEqual(rewrittenEmptyState, { version: 1, clients: [], accessTokens: [], refreshTokens: [], approvedConsents: [] }); + + const customProvider = new SingleUserOAuthProvider({ ...config, statePath: customStatePath }, resourceServerUrl); + customProvider.clientsStore.registerClient({ + client_name: "custom state client", + redirect_uris: ["http://localhost/custom"], + scope: "devspace", + }); + assert.equal(JSON.parse(readFileSync(customStatePath, "utf8")).clients.length, 1); + + const expiredAccessStatePath = join(root, "expired-access", "oauth.json"); + mkdirSync(join(root, "expired-access"), { recursive: true }); + const expiredAccessTokens = issueTokens(firstProvider, client.client_id, ["devspace"], resourceServerUrl); + writeFileSync( + expiredAccessStatePath, + JSON.stringify({ + version: 1, + clients: [client], + accessTokens: [{ + tokenHash: hashTestToken(assertString(expiredAccessTokens.access_token)), + clientId: client.client_id, + scopes: ["devspace"], + expiresAt: 1, + resource: resourceServerUrl.href, + }], + refreshTokens: [], + }), + ); + await chmod(expiredAccessStatePath, 0o600); + const expiredAccessProvider = new SingleUserOAuthProvider( + { ...config, statePath: expiredAccessStatePath }, + resourceServerUrl, + ); + await assert.rejects( + () => expiredAccessProvider.verifyAccessToken(assertString(expiredAccessTokens.access_token)), + InvalidTokenError, + ); + const cleanedExpiredAccessState = JSON.parse(readFileSync(expiredAccessStatePath, "utf8")); + assert.equal(cleanedExpiredAccessState.accessTokens.length, 0); + + const consentStatePath = join(root, "consent", "oauth.json"); + const consentProvider = new SingleUserOAuthProvider({ ...config, statePath: consentStatePath }, resourceServerUrl); + const consentClient = consentProvider.clientsStore.registerClient({ + client_name: "consent client", + redirect_uris: ["http://localhost/consent", "http://localhost/other"], + scope: "devspace", + }); + const consentParams = authorizationParams("http://localhost/consent", resourceServerUrl, ["devspace"], "state-1"); + + const firstConsentGet = mockResponse("GET"); + await consentProvider.authorize(consentClient, consentParams, firstConsentGet.res); + assert.equal(firstConsentGet.statusCode, 200); + assert.match(assertString(firstConsentGet.body), /Owner password/); + + const firstConsentPost = mockResponse("POST", { owner_token: config.ownerToken }); + await consentProvider.authorize(consentClient, consentParams, firstConsentPost.res); + assert.equal(firstConsentPost.redirectStatus, 302); + assert.equal(firstConsentPost.redirectUrl?.searchParams.get("state"), "state-1"); + assert.match(assertPresentString(firstConsentPost.redirectUrl?.searchParams.get("code")), /^code-/); + + const consentSavedState = JSON.parse(readFileSync(consentStatePath, "utf8")); + assert.equal(consentSavedState.approvedConsents.length, 1); + assert.equal(consentSavedState.approvedConsents[0].clientId, consentClient.client_id); + assert.equal(consentSavedState.approvedConsents[0].redirectUri, "http://localhost/consent"); + assert.equal(consentSavedState.approvedConsents[0].resource, resourceServerUrl.href); + assert.deepEqual(consentSavedState.approvedConsents[0].scopes, ["devspace"]); + assert.equal(JSON.stringify(consentSavedState).includes(config.ownerToken), false); + + const secondConsentGet = mockResponse("GET"); + await consentProvider.authorize(consentClient, consentParams, secondConsentGet.res); + assert.equal(secondConsentGet.redirectStatus, 302); + assert.equal(assertUrl(secondConsentGet.redirectUrl).origin + assertUrl(secondConsentGet.redirectUrl).pathname, "http://localhost/consent"); + assert.equal(secondConsentGet.redirectUrl?.searchParams.get("state"), "state-1"); + assert.match(assertPresentString(secondConsentGet.redirectUrl?.searchParams.get("code")), /^code-/); + assert.notEqual(secondConsentGet.redirectUrl?.searchParams.get("code"), firstConsentPost.redirectUrl?.searchParams.get("code")); + assert.equal(secondConsentGet.body, undefined); + + const changedRedirectGet = mockResponse("GET"); + await consentProvider.authorize( + consentClient, + authorizationParams("http://localhost/other", resourceServerUrl, ["devspace"], "state-redirect"), + changedRedirectGet.res, + ); + assert.equal(changedRedirectGet.statusCode, 200); + assert.match(assertString(changedRedirectGet.body), /Owner password/); + + const changedResourceGet = mockResponse("GET"); + await consentProvider.authorize( + consentClient, + authorizationParams("http://localhost/consent", new URL("https://devspace.example.com/mcp/"), ["devspace"], "state-resource"), + changedResourceGet.res, + ); + assert.equal(changedResourceGet.statusCode, 200); + assert.match(assertString(changedResourceGet.body), /Owner password/); + + const expandedScopeStatePath = join(root, "expanded-scope", "oauth.json"); + const expandedScopeProvider = new SingleUserOAuthProvider( + { ...config, scopes: ["devspace", "admin"], statePath: expandedScopeStatePath }, + resourceServerUrl, + ); + const expandedScopeClient = expandedScopeProvider.clientsStore.registerClient({ + client_name: "expanded scope client", + redirect_uris: ["http://localhost/expanded"], + scope: "devspace admin", + }); + await expandedScopeProvider.authorize( + expandedScopeClient, + authorizationParams("http://localhost/expanded", resourceServerUrl, ["devspace"], "state-scope-1"), + mockResponse("POST", { owner_token: config.ownerToken }).res, + ); + const expandedScopeGet = mockResponse("GET"); + await expandedScopeProvider.authorize( + expandedScopeClient, + authorizationParams("http://localhost/expanded", resourceServerUrl, ["devspace", "admin"], "state-scope-2"), + expandedScopeGet.res, + ); + assert.equal(expandedScopeGet.statusCode, 200); + assert.match(assertString(expandedScopeGet.body), /Owner password/); + + const restartedConsentProvider = new SingleUserOAuthProvider({ ...config, statePath: consentStatePath }, resourceServerUrl); + const restartedConsentClient = restartedConsentProvider.clientsStore.getClient(consentClient.client_id); + assert.equal(Boolean(restartedConsentClient), true); + const restartedConsentGet = mockResponse("GET"); + await restartedConsentProvider.authorize(assertClient(restartedConsentClient), consentParams, restartedConsentGet.res); + assert.equal(restartedConsentGet.redirectStatus, 302); + assert.equal(assertUrl(restartedConsentGet.redirectUrl).origin + assertUrl(restartedConsentGet.redirectUrl).pathname, "http://localhost/consent"); + + const finalConsentState = JSON.parse(readFileSync(consentStatePath, "utf8")); + assert.equal(JSON.stringify(finalConsentState).includes(config.ownerToken), false); + assert.equal(JSON.stringify(finalConsentState).includes(assertString(firstTokens.access_token)), false); + assert.equal(JSON.stringify(finalConsentState).includes(assertString(firstTokens.refresh_token)), false); +} finally { + rmSync(root, { recursive: true, force: true }); +} + +function issueTokens( + provider: SingleUserOAuthProvider, + clientId: string, + scopes: string[], + resource?: URL, +): OAuthTokens { + const rawIssueTokens = provider["issueTokens"] as ( + currentClientId: string, + currentScopes: string[], + currentResource?: URL, + ) => OAuthTokens; + return rawIssueTokens.call(provider, clientId, scopes, resource); +} + +function assertString(value: string | undefined): string { + if (typeof value !== "string") { + throw new Error("Expected string value"); + } + return value; +} + +function assertPresentString(value: string | null | undefined): string { + if (typeof value !== "string") { + throw new Error("Expected string value"); + } + return value; +} + +function hashTestToken(token: string): string { + return createHash("sha256").update(token).digest("base64url"); +} + +function authorizationParams( + redirectUri: string, + resource: URL, + scopes: string[], + state: string, +): AuthorizationParams { + return { + redirectUri, + codeChallenge: "challenge", + scopes, + state, + resource, + }; +} + +function mockResponse(method: "GET" | "POST", body: Record = {}) { + const result: { + statusCode?: number; + headers: Record; + body?: string; + redirectStatus?: number; + redirectUrl?: URL; + res: any; + } = { + headers: {}, + res: undefined, + }; + result.res = { + req: { method, body }, + status(code: number) { + result.statusCode = code; + return this; + }, + setHeader(name: string, value: string) { + result.headers[name] = value; + return this; + }, + send(bodyValue: string) { + result.body = bodyValue; + return this; + }, + redirect(code: number, url: string) { + result.redirectStatus = code; + result.redirectUrl = new URL(url); + return this; + }, + }; + return result; +} + +function assertClient(client: OAuthClientInformationFull | undefined): OAuthClientInformationFull { + if (!client) { + throw new Error("Expected OAuth client"); + } + return client; +} + +function assertUrl(url: URL | undefined): URL { + if (!url) { + throw new Error("Expected URL"); + } + return url; +} diff --git a/src/oauth-provider.ts b/src/oauth-provider.ts index 9bb442f..50f4b6c 100644 --- a/src/oauth-provider.ts +++ b/src/oauth-provider.ts @@ -1,4 +1,14 @@ import { timingSafeEqual, randomBytes, randomUUID, createHash } from "node:crypto"; +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { dirname } from "node:path"; import type { Response } from "express"; import type { OAuthRegisteredClientsStore } from "@modelcontextprotocol/sdk/server/auth/clients.js"; import type { OAuthServerProvider, AuthorizationParams } from "@modelcontextprotocol/sdk/server/auth/provider.js"; @@ -17,6 +27,7 @@ export interface OAuthConfig { refreshTokenTtlSeconds: number; scopes: string[]; allowedRedirectHosts: string[]; + statePath?: string; } interface AuthorizationCodeRecord { @@ -41,6 +52,38 @@ interface RefreshTokenRecord { resource?: URL; } +interface StoredRefreshTokenRecord { + tokenHash: string; + clientId: string; + scopes: string[]; + expiresAt: number; + resource?: string; +} + +interface StoredAccessTokenRecord { + tokenHash: string; + clientId: string; + scopes: string[]; + expiresAt: number; + resource?: string; +} + +interface StoredOAuthConsentRecord { + clientId: string; + redirectUri: string; + resource: string; + scopes: string[]; + approvedAt: number; +} + +interface StoredOAuthState { + version: number; + clients: OAuthClientInformationFull[]; + accessTokens: StoredAccessTokenRecord[]; + refreshTokens: StoredRefreshTokenRecord[]; + approvedConsents: StoredOAuthConsentRecord[]; +} + const CODE_TTL_MS = 5 * 60 * 1000; function randomToken(): string { @@ -138,10 +181,115 @@ function redirectHostAllowed(redirectUri: string, allowedHosts: string[]): boole return allowedHosts.includes(parsed.hostname); } +function emptyOAuthState(): StoredOAuthState { + return { + version: 1, + clients: [], + accessTokens: [], + refreshTokens: [], + approvedConsents: [], + }; +} + +function parseOAuthState(raw: string): StoredOAuthState { + const parsed = JSON.parse(raw) as Partial; + return { + version: 1, + clients: Array.isArray(parsed.clients) ? parsed.clients : [], + accessTokens: Array.isArray(parsed.accessTokens) ? parsed.accessTokens : [], + refreshTokens: Array.isArray(parsed.refreshTokens) ? parsed.refreshTokens : [], + approvedConsents: Array.isArray(parsed.approvedConsents) ? parsed.approvedConsents : [], + }; +} + +function readOAuthState(statePath: string | undefined): StoredOAuthState { + if (!statePath || !existsSync(statePath)) return emptyOAuthState(); + + try { + const raw = readFileSync(statePath, "utf8"); + if (!raw.trim()) return emptyOAuthState(); + return parseOAuthState(raw); + } catch { + return emptyOAuthState(); + } +} + +function ensurePrivateDirectory(directory: string): void { + mkdirSync(directory, { recursive: true, mode: 0o700 }); + chmodSync(directory, 0o700); +} + +function writeOAuthState( + statePath: string | undefined, + clients: OAuthClientInformationFull[], + accessTokens: Iterable<[string, AccessTokenRecord]>, + refreshTokens: Iterable<[string, RefreshTokenRecord]>, + approvedConsents: Iterable, +): void { + if (!statePath) return; + + const directory = dirname(statePath); + ensurePrivateDirectory(directory); + + const state: StoredOAuthState = { + version: 1, + clients, + accessTokens: Array.from(accessTokens, ([tokenHash, record]) => ({ + tokenHash, + clientId: record.clientId, + scopes: record.scopes, + expiresAt: record.expiresAt, + resource: record.resource?.href, + })), + refreshTokens: Array.from(refreshTokens, ([tokenHash, record]) => ({ + tokenHash, + clientId: record.clientId, + scopes: record.scopes, + expiresAt: record.expiresAt, + resource: record.resource?.href, + })), + approvedConsents: Array.from(approvedConsents, (record) => ({ + clientId: record.clientId, + redirectUri: record.redirectUri, + resource: record.resource, + scopes: record.scopes, + approvedAt: record.approvedAt, + })), + }; + const tempPath = `${statePath}.${process.pid}.${randomUUID()}.tmp`; + + try { + writeFileSync(tempPath, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 }); + chmodSync(tempPath, 0o600); + renameSync(tempPath, statePath); + chmodSync(statePath, 0o600); + } finally { + rmSync(tempPath, { force: true }); + } +} + +function parseStoredResource(resource: string | undefined): URL | undefined { + if (!resource) return undefined; + + try { + return new URL(resource); + } catch { + return undefined; + } +} + export class InMemoryOAuthClientsStore implements OAuthRegisteredClientsStore { private readonly clients = new Map(); - constructor(private readonly allowedRedirectHosts: string[]) {} + constructor( + private readonly allowedRedirectHosts: string[], + initialClients: OAuthClientInformationFull[] = [], + private readonly onChange: () => void = () => {}, + ) { + for (const client of initialClients) { + this.clients.set(client.client_id, client); + } + } getClient(clientId: string): OAuthClientInformationFull | undefined { return this.clients.get(clientId); @@ -164,15 +312,21 @@ export class InMemoryOAuthClientsStore implements OAuthRegisteredClientsStore { response_types: client.response_types ?? ["code"], }; this.clients.set(registered.client_id, registered); + this.onChange(); return registered; } + + dumpClients(): OAuthClientInformationFull[] { + return Array.from(this.clients.values()); + } } export class SingleUserOAuthProvider implements OAuthServerProvider { - readonly clientsStore: OAuthRegisteredClientsStore; + readonly clientsStore: InMemoryOAuthClientsStore; private readonly codes = new Map(); private readonly accessTokens = new Map(); private readonly refreshTokens = new Map(); + private readonly approvedConsents = new Map(); private readonly resourceServerUrl: URL; constructor( @@ -180,7 +334,80 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { resourceServerUrl: URL, ) { this.resourceServerUrl = resourceUrlFromServerUrl(resourceServerUrl); - this.clientsStore = new InMemoryOAuthClientsStore(config.allowedRedirectHosts); + const state = readOAuthState(config.statePath); + this.clientsStore = new InMemoryOAuthClientsStore( + config.allowedRedirectHosts, + state.clients, + () => this.saveOAuthState(), + ); + + const now = Math.floor(Date.now() / 1000); + for (const record of state.accessTokens) { + if ( + typeof record?.tokenHash !== "string" || + typeof record?.clientId !== "string" || + !Array.isArray(record?.scopes) || + typeof record?.expiresAt !== "number" || + record.expiresAt < now + ) { + continue; + } + + this.accessTokens.set(record.tokenHash, { + token: record.tokenHash, + clientId: record.clientId, + scopes: record.scopes, + expiresAt: record.expiresAt, + resource: parseStoredResource(record.resource), + }); + } + + for (const record of state.refreshTokens) { + if ( + typeof record?.tokenHash !== "string" || + typeof record?.clientId !== "string" || + !Array.isArray(record?.scopes) || + typeof record?.expiresAt !== "number" || + record.expiresAt < now + ) { + continue; + } + + this.refreshTokens.set(record.tokenHash, { + token: record.tokenHash, + clientId: record.clientId, + scopes: record.scopes, + expiresAt: record.expiresAt, + resource: parseStoredResource(record.resource), + }); + } + + for (const record of state.approvedConsents) { + if ( + typeof record?.clientId !== "string" || + typeof record?.redirectUri !== "string" || + typeof record?.resource !== "string" || + !Array.isArray(record?.scopes) || + typeof record?.approvedAt !== "number" + ) { + continue; + } + + const client = this.clientsStore.getClient(record.clientId); + if (!client || !client.redirect_uris.includes(record.redirectUri)) { + continue; + } + + this.approvedConsents.set(consentKey(record.clientId, record.redirectUri, record.resource, record.scopes), { + clientId: record.clientId, + redirectUri: record.redirectUri, + resource: record.resource, + scopes: normalizeScopes(record.scopes), + approvedAt: record.approvedAt, + }); + } + + this.saveOAuthState(); } async authorize( @@ -188,19 +415,34 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { params: AuthorizationParams, res: Response, ): Promise { + const registeredClient = this.clientsStore.getClient(client.client_id); + if (!registeredClient) { + throw new InvalidRequestError("OAuth client is not registered"); + } if (!params.resource || !checkResourceAllowed({ requestedResource: params.resource, configuredResource: this.resourceServerUrl })) { throw new InvalidRequestError("Invalid or missing OAuth resource"); } if (!requestedScopesAllowed(params.scopes ?? [], this.config.scopes)) { throw new InvalidRequestError("Requested scope is not supported"); } + if (!registeredClient.redirect_uris.includes(params.redirectUri)) { + throw new InvalidRequestError("redirect_uri is not registered for this client"); + } + + const scopes = normalizeScopes(params.scopes ?? this.config.scopes); + const currentConsentKey = consentKey(client.client_id, params.redirectUri, params.resource.href, scopes); if (res.req.method !== "POST") { + if (this.approvedConsents.has(currentConsentKey)) { + this.redirectWithAuthorizationCode(client, params, res); + return; + } + res.status(200).setHeader("Content-Type", "text/html; charset=utf-8"); res.send( formHtml({ clientName: client.client_name ?? client.client_id, - scopes: params.scopes ?? this.config.scopes, + scopes, resource: params.resource, fields: authorizationFormFields(client, params), }), @@ -215,7 +457,7 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { formHtml({ error: "The Owner password was not accepted.", clientName: client.client_name ?? client.client_id, - scopes: params.scopes ?? this.config.scopes, + scopes, resource: params.resource, fields: authorizationFormFields(client, params), }), @@ -223,6 +465,32 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { return; } + this.approvedConsents.set(currentConsentKey, { + clientId: client.client_id, + redirectUri: params.redirectUri, + resource: params.resource.href, + scopes, + approvedAt: Math.floor(Date.now() / 1000), + }); + this.saveOAuthState(); + this.redirectWithAuthorizationCode(client, params, res); + } + + revokeClientConsent(clientId: string): void { + let changed = false; + for (const [key, record] of this.approvedConsents.entries()) { + if (record.clientId !== clientId) continue; + this.approvedConsents.delete(key); + changed = true; + } + if (changed) this.saveOAuthState(); + } + + private redirectWithAuthorizationCode( + client: OAuthClientInformationFull, + params: AuthorizationParams, + res: Response, + ): void { const code = `code-${randomUUID()}`; this.codes.set(code, { clientId: client.client_id, @@ -271,6 +539,10 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { ): Promise { const record = this.refreshTokens.get(hashToken(refreshToken)); if (!record || record.clientId !== client.client_id || record.expiresAt < Math.floor(Date.now() / 1000)) { + if (record) { + this.refreshTokens.delete(hashToken(refreshToken)); + this.saveOAuthState(); + } throw new InvalidGrantError("Invalid refresh token"); } if (resource && !checkResourceAllowed({ requestedResource: resource, configuredResource: this.resourceServerUrl })) { @@ -287,8 +559,14 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { } async verifyAccessToken(token: string): Promise { - const record = this.accessTokens.get(hashToken(token)); - if (!record || record.expiresAt < Math.floor(Date.now() / 1000)) { + const hashed = hashToken(token); + const record = this.accessTokens.get(hashed); + if (!record) { + throw new InvalidTokenError("Invalid or expired access token"); + } + if (record.expiresAt < Math.floor(Date.now() / 1000)) { + this.accessTokens.delete(hashed); + this.saveOAuthState(); throw new InvalidTokenError("Invalid or expired access token"); } @@ -305,6 +583,7 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { const hashed = hashToken(request.token); this.accessTokens.delete(hashed); this.refreshTokens.delete(hashed); + this.saveOAuthState(); } private validCodeRecord( @@ -339,6 +618,7 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { expiresAt: refreshExpiresAt, resource, }); + this.saveOAuthState(); return { access_token: accessToken, @@ -348,6 +628,16 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { scope: scopes.join(" "), }; } + + private saveOAuthState(): void { + writeOAuthState( + this.config.statePath, + this.clientsStore.dumpClients(), + this.accessTokens.entries(), + this.refreshTokens.entries(), + this.approvedConsents.values(), + ); + } } function authorizationFormFields( @@ -369,3 +659,11 @@ function authorizationFormFields( function hashToken(token: string): string { return createHash("sha256").update(token).digest("base64url"); } + +function normalizeScopes(scopes: string[]): string[] { + return [...scopes].sort(); +} + +function consentKey(clientId: string, redirectUri: string, resource: string, scopes: string[]): string { + return `${clientId}\n${redirectUri}\n${resource}\n${normalizeScopes(scopes).join(" ")}`; +} diff --git a/src/pi-tools.ts b/src/pi-tools.ts index 238b9c5..21f9da7 100644 --- a/src/pi-tools.ts +++ b/src/pi-tools.ts @@ -17,13 +17,7 @@ import { type AgentToolResult, } from "@earendil-works/pi-coding-agent"; import { resolveAllowedPath } from "./roots.js"; - -type McpContent = { type: "text"; text: string } | { type: "image"; data: string; mimeType: string }; -export type ToolResponse = { - content: McpContent[]; - details?: TDetails; - isError?: boolean; -}; +import { toolError, type ToolContent, type ToolResponse } from "./tool-result.js"; interface ToolContext { cwd: string; @@ -31,7 +25,7 @@ interface ToolContext { readRoots?: string[]; } -function toMcpContent(result: AgentToolResult): McpContent[] { +function toMcpContent(result: AgentToolResult): ToolContent[] { return result.content.map((content) => { if (content.type === "text") { return { type: "text", text: content.text }; @@ -45,11 +39,6 @@ function toMcpContent(result: AgentToolResult): McpContent[] { }); } -function formatToolError(error: unknown): McpContent[] { - const message = error instanceof Error ? error.message : String(error); - return [{ type: "text", text: message }]; -} - async function runTool( execute: (input: TInput) => Promise>, input: TInput, @@ -62,7 +51,8 @@ async function runTool( details: result.details, }; } catch (error) { - return { content: formatToolError(error), isError: true }; + const message = error instanceof Error ? error.message : String(error); + return toolError(message); } } diff --git a/src/prompting.test.ts b/src/prompting.test.ts new file mode 100644 index 0000000..d65c41c --- /dev/null +++ b/src/prompting.test.ts @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import { serverInstructions, workspaceInstruction } from "./prompting.js"; +import type { ToolNames } from "./server.js"; + +const toolNames: ToolNames = { + openWorkspace: "open_workspace", + read: "read_file", + write: "write_file", + edit: "edit_file", + grep: "grep_files", + glob: "find_files", + ls: "list_directory", + shell: "run_shell", +}; + +const instructions = serverInstructions( + { + minimalTools: false, + skillsEnabled: false, + widgetsChangesOnly: false, + }, + toolNames, +); + +assert.match(instructions, /Prefer action over explanation\./); +assert.match(instructions, /Keep responses terse and operational\./); +assert.match(instructions, /Do not add long design discussion, repeated background, or speculative future improvements unless the user explicitly asks for them\./); +assert.match(instructions, /When the user sends a short reply such as '1B, 2A', treat it as workflow input and continue instead of explaining the mechanism back to them\./); +assert.match(instructions, /When available skills include a matching workflow skill, read that skill before handling slash-style workspace commands or compact user-input replies\./); +assert.match(instructions, /For concise workflow commands and compact pending-input replies, prefer handle_workspace_command or answer_user_input\(text\) over paraphrasing the user's message\./); + +const planInstruction = workspaceInstruction("plan", false); +assert.match(planInstruction, /ask clarifying questions with request_user_input only when they materially affect the plan/); +assert.match(planInstruction, /Keep the plan decision complete but compact\./); +assert.match(planInstruction, /Do not repeat already-confirmed choices, do not add long design essays/); + +const defaultInstruction = workspaceInstruction("default", false); +assert.match(defaultInstruction, /execute work directly, keep status updates brief/); +assert.match(defaultInstruction, /Do not add unnecessary explanation for straightforward actions or results\./); diff --git a/src/prompting.ts b/src/prompting.ts new file mode 100644 index 0000000..1484b57 --- /dev/null +++ b/src/prompting.ts @@ -0,0 +1,54 @@ +import type { ToolNames } from "./server.js"; + +export type CollaborationMode = "default" | "plan"; + +export interface PromptingContext { + minimalTools: boolean; + skillsEnabled: boolean; + widgetsChangesOnly: boolean; +} + +export function serverInstructions( + context: PromptingContext, + toolNames: ToolNames, +): string { + const inspection = context.minimalTools + ? `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 = context.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. `; + + const showChanges = context.widgetsChangesOnly + ? " After creating, editing, or overwriting files, call show_changes once after the related file changes are complete so the user can see the aggregate diff." + : ""; + + const planning = + " Use get_collaboration_mode to inspect the workspace collaboration mode. Use set_collaboration_mode to switch between default execution and plan mode. In default mode, use update_plan for a concise execution checklist when helpful. In plan mode, prefer request_user_input, repository exploration, and concrete specification work; do not use update_plan while plan mode is active. When the user asks to pursue a concrete objective across multiple turns, use create_goal to start one goal for that workspace, get_goal to inspect its status, and update_goal to mark it complete or blocked."; + + const style = + " Prefer action over explanation. Keep responses terse and operational. For mode switches, goal updates, confirmations, cancellations, pending answers, and other straightforward workflow steps, return only the necessary status or next action. Do not add long design discussion, repeated background, or speculative future improvements unless the user explicitly asks for them. When the user sends a short reply such as '1B, 2A', treat it as workflow input and continue instead of explaining the mechanism back to them."; + + const commands = + " When available skills include a matching workflow skill, read that skill before handling slash-style workspace commands or compact user-input replies. For concise workflow commands and compact pending-input replies, prefer handle_workspace_command or answer_user_input(text) over paraphrasing the user's message."; + + return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree to obtain a workspaceId. Reuse that same workspaceId for all later file, search, edit, write, show-changes, shell, plan, and goal tools in that folder; do not call ${toolNames.openWorkspace} again unless switching folders/worktrees, changing checkout/worktree mode, the workspaceId is rejected as unknown, or the user explicitly asks to reopen. ${agentsMd}${skills}${inspection}${planning}${style}${commands} Prefer ${toolNames.edit} for targeted modifications, ${toolNames.write} only for new files or complete rewrites, apply_workspace_patch for coordinated multi-file patches, and ${toolNames.shell} for tests, builds, git inspection, package scripts, and commands that are better executed by the shell. Use git_push for explicit push requests instead of raw git push through ${toolNames.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.${showChanges}`; +} + +export function workspaceInstruction( + mode: CollaborationMode, + skillsEnabled: boolean, +): string { + const base = skillsEnabled + ? "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file. When a task matches an available skill in skills, read its path before proceeding." + : "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file."; + + if (mode === "plan") { + return `${base} This workspace is currently in plan mode: explore first, ask clarifying questions with request_user_input only when they materially affect the plan, and produce a concrete implementation plan before execution. Keep the plan decision complete but compact. Do not repeat already-confirmed choices, do not add long design essays, and do not use update_plan while plan mode is active.`; + } + + return `${base} This workspace is currently in default mode: execute work directly, keep status updates brief, and use update_plan only when a concise execution checklist would help. Do not add unnecessary explanation for straightforward actions or results.`; +} diff --git a/src/server.ts b/src/server.ts index 9c554dc..b13b84c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -4,7 +4,11 @@ import { access, realpath } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; -import { mcpAuthRouter, getOAuthProtectedResourceMetadataUrl } from "@modelcontextprotocol/sdk/server/auth/router.js"; +import { + createOAuthMetadata, + mcpAuthRouter, + getOAuthProtectedResourceMetadataUrl, +} from "@modelcontextprotocol/sdk/server/auth/router.js"; import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; @@ -36,9 +40,21 @@ import { } from "./pi-tools.js"; import { SingleUserOAuthProvider } from "./oauth-provider.js"; import { createReviewCheckpointManager } from "./review-checkpoints.js"; +import { validateShellCommand } from "./shell-policy.js"; import { formatPathForPrompt } from "./skills.js"; +import { contentStats, contentText, toolError, type ToolContent } from "./tool-result.js"; import { createWorkspaceStore } from "./workspace-store.js"; import { formatAgentsPath, WorkspaceRegistry } from "./workspaces.js"; +import { serverInstructions as buildServerInstructions, workspaceInstruction } from "./prompting.js"; +import { parseAnswerTextOrThrow, parseWorkspaceCommand } from "./workspace-commands.js"; +import { applyWorkspacePatch, gitPush } from "./workspace-operations.js"; +import type { + WorkspaceStore, + WorkspacePlanStep, + WorkspaceQuestion, + WorkspaceUserInputAnswer, + WorkspaceUserInputRecord, +} from "./workspace-store.js"; type Transport = StreamableHTTPServerTransport; const WORKSPACE_APP_URI = "ui://devspace/workspace-app.html"; @@ -67,10 +83,6 @@ interface RunningServer { config: ServerConfig; } -type ToolContent = - | { type: "text"; text: string } - | { type: "image"; data: string; mimeType: string }; - interface WorkspaceAppManifestEntry { file: string; css?: string[]; @@ -86,12 +98,15 @@ interface DiffStats { type ToolWidgetKind = | "workspace" + | "plan" + | "goal" | "read" | "write" | "edit" | "search" | "directory" | "shell" + | "safe_operation" | "show_changes"; interface ToolDefinitionMeta extends Record { @@ -99,6 +114,8 @@ interface ToolDefinitionMeta extends Record { resourceUri: string; visibility: ["model"]; }; + "ui/resourceUri": string; + "openai/outputTemplate": string; } type EmptyToolDefinitionMeta = Record & { @@ -132,11 +149,13 @@ function toolWidgetDescriptorMeta( resourceUri: WORKSPACE_APP_URI, visibility: ["model"], }, + "ui/resourceUri": WORKSPACE_APP_URI, + "openai/outputTemplate": WORKSPACE_APP_URI, }, }; } -interface ToolNames { +export interface ToolNames { openWorkspace: "open_workspace"; read: "read_file" | "read"; write: "write_file" | "write"; @@ -183,24 +202,6 @@ function toolNamesFor(config: ServerConfig): ToolNames { }; } -function serverInstructions(config: ServerConfig, toolNames: ToolNames): string { - const inspection = config.minimalTools - ? `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. `; - - const showChanges = - config.widgets === "changes" - ? " After creating, editing, or overwriting files, call show_changes once after the related file changes are complete so the user can see the aggregate diff." - : ""; - - return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree to obtain a workspaceId. Reuse that same workspaceId for all later file, search, edit, write, show-changes, and shell tools in that folder; do not call ${toolNames.openWorkspace} again unless switching folders/worktrees, changing checkout/worktree mode, the workspaceId is rejected as unknown, or the user explicitly asks to reopen. ${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.${showChanges}`; -} function resultOutputSchema(extra: z.ZodRawShape = {}): z.ZodRawShape { return { result: z @@ -227,6 +228,41 @@ const workspaceAvailableAgentsFileOutputSchema = z.object({ path: z.string(), }); +const userInputAnswerOutputSchema = z.object({ + questionId: z.string(), + label: z.string(), +}); + +const userInputPromptOutputSchema = z.object({ + questions: z.array( + z.object({ + header: z.string(), + id: z.string(), + question: z.string(), + options: z.array( + z.object({ + label: z.string(), + description: z.string(), + }), + ), + }), + ), + autoResolutionMs: z.number().int().min(60000).max(240000).optional(), + status: z.enum(["pending", "completed", "declined", "cancelled"]), + deliveryMode: z.enum(["elicitation", "tool", "ui"]).optional(), + createdAt: z.string(), + updatedAt: z.string(), + answeredAt: z.string().optional(), + response: z + .object({ + answers: z.array(userInputAnswerOutputSchema), + summary: z.string(), + source: z.enum(["elicitation", "tool", "ui"]), + action: z.enum(["accept", "decline", "cancel"]), + }) + .optional(), +}); + const reviewFileOutputSchema = z.object({ path: z.string(), previousPath: z.string().optional(), @@ -275,15 +311,6 @@ function logToolCall(config: ServerConfig, fields: ToolLogFields): void { }); } -function contentText(content: ToolContent[]): string { - return content - .filter( - (item): item is { type: "text"; text: string } => item.type === "text", - ) - .map((item) => item.text) - .join("\n"); -} - function toolErrorPreview(content: ToolContent[]): string | undefined { const text = contentText(content).replace(/\s+/g, " ").trim(); if (!text) return undefined; @@ -308,17 +335,6 @@ function textBlock(text: string): ToolContent { return { type: "text", text }; } -function textSummary(content: ToolContent[]): { - lines: number; - characters: number; -} { - const text = contentText(content); - return { - lines: text.length === 0 ? 0 : text.split("\n").length, - characters: text.length, - }; -} - function contentLineCount(content: string): number { if (content.length === 0) return 0; return content.endsWith("\n") @@ -429,6 +445,17 @@ function appCsp(config: ServerConfig): { }; } +function openAiWidgetCsp(config: ServerConfig): { + resource_domains: string[]; + connect_domains: string[]; +} { + const csp = appCsp(config); + return { + resource_domains: csp.resourceDomains, + connect_domains: csp.connectDomains, + }; +} + function uiBuildDirectory(): string { return fileURLToPath(new URL("../dist/ui", import.meta.url)); } @@ -455,6 +482,7 @@ function createMcpServer( config: ServerConfig, workspaces: WorkspaceRegistry, reviewCheckpoints: ReturnType, + workspaceStore: WorkspaceStore, ): McpServer { const toolNames = toolNamesFor(config); const server = new McpServer( @@ -466,7 +494,14 @@ function createMcpServer( "Secure local coding workspace for MCP clients. Provides workspace-scoped file, search, edit, write, and shell tools.", }, { - instructions: serverInstructions(config, toolNames), + instructions: buildServerInstructions( + { + minimalTools: config.minimalTools, + skillsEnabled: config.skillsEnabled, + widgetsChangesOnly: config.widgets === "changes", + }, + toolNames, + ), }, ); @@ -480,6 +515,9 @@ function createMcpServer( ui: { csp: appCsp(config), }, + "openai/widgetDescription": "Interactive DevSpace workspace and file-change view.", + "openai/widgetPrefersBorder": true, + "openai/widgetCSP": openAiWidgetCsp(config), }, }, async () => { @@ -494,6 +532,9 @@ function createMcpServer( ui: { csp: appCsp(config), }, + "openai/widgetDescription": "Interactive DevSpace workspace and file-change view.", + "openai/widgetPrefersBorder": true, + "openai/widgetCSP": openAiWidgetCsp(config), }, }, ], @@ -545,6 +586,7 @@ function createMcpServer( skills: z.array(workspaceSkillOutputSchema), skillDiagnostics: z.array(z.unknown()), instruction: z.string(), + collaborationMode: z.enum(["default", "plan"]), }, ...toolWidgetDescriptorMeta(config, "workspace"), annotations: { readOnlyHint: true }, @@ -572,9 +614,8 @@ function createMcpServer( const availableAgentsFileOutputs = availableAgentsFiles.map((file) => ({ path: formatAgentsPath(file.path, workspace.root), })); - const instruction = config.skillsEnabled - ? "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file. When a task matches an available skill in skills, read its path before proceeding." - : "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file."; + const collaboration = workspaceStore.getCollaborationMode(workspace.id); + const instruction = workspaceInstruction(collaboration.mode, config.skillsEnabled); const resultContent: ToolContent[] = [ { type: "text" as const, @@ -594,42 +635,897 @@ function createMcpServer( instruction, ].filter(Boolean).join("\n"), }, - ]; + ]; + logToolCall(config, { + tool: "open_workspace", + workspaceId: workspace.id, + path: workspace.root, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return { + content: resultContent, + _meta: { + tool: "open_workspace", + card: { + workspaceId: workspace.id, + root: workspace.root, + path: workspace.root, + summary: { + agentsFiles: loadedAgentsFiles.length, + availableAgentsFiles: availableAgentsFileOutputs.length, + skills: visibleSkills.length, + skillDiagnostics: workspace.skillDiagnostics.length, + }, + }, + }, + structuredContent: { + workspaceId: workspace.id, + root: workspace.root, + mode: workspace.mode, + sourceRoot: workspace.sourceRoot, + worktree: workspace.worktree, + agentsFiles: loadedAgentsFiles, + availableAgentsFiles: availableAgentsFileOutputs, + skills: visibleSkills, + skillDiagnostics: workspace.skillDiagnostics, + instruction, + collaborationMode: collaboration.mode, + }, + }; + }, + ); + + registerAppTool( + server, + "get_collaboration_mode", + { + title: "Get collaboration mode", + description: + "Get the workspace collaboration mode. Use this to tell whether the workspace is in default execution mode or plan mode.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + }, + outputSchema: { + result: z.string(), + mode: z.enum(["default", "plan"]), + updatedAt: z.string().optional(), + }, + ...toolWidgetDescriptorMeta(config, "plan"), + annotations: { readOnlyHint: true }, + }, + async ({ workspaceId }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + const collaboration = workspaceStore.getCollaborationMode(workspaceId); + const content = [textBlock(`Workspace collaboration mode: ${collaboration.mode}`)]; + + logToolCall(config, { + tool: "get_collaboration_mode", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return { + content, + structuredContent: { + result: contentText(content), + mode: collaboration.mode, + updatedAt: collaboration.updatedAt || undefined, + }, + }; + }, + ); + + registerAppTool( + server, + "set_collaboration_mode", + { + title: "Set collaboration mode", + description: + "Set the workspace collaboration mode. Use plan mode when the task should stay in exploration and specification until the plan is complete.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + mode: z.enum(["default", "plan"]), + }, + outputSchema: { + result: z.string(), + mode: z.enum(["default", "plan"]), + updatedAt: z.string(), + }, + ...toolWidgetDescriptorMeta(config, "plan"), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + }, + async ({ workspaceId, mode }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + const collaboration = workspaceStore.setCollaborationMode({ + workspaceSessionId: workspaceId, + mode, + }); + const content = [textBlock(`Workspace collaboration mode set to ${collaboration.mode}.`)]; + + logToolCall(config, { + tool: "set_collaboration_mode", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return { + content, + structuredContent: { + result: contentText(content), + mode: collaboration.mode, + updatedAt: collaboration.updatedAt, + }, + }; + }, + ); + + registerAppTool( + server, + "handle_workspace_command", + { + title: "Handle workspace command", + description: + "Interpret concise workflow messages such as /plan, /goal, and compact answers for the current workspace.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + message: z.string().describe("Raw user message, such as /plan fix this, /goal ship this, or 1B, 2A."), + }, + outputSchema: { + result: z.string(), + recognized: z.boolean(), + command: z.enum(["plan", "goal", "answer", "none"]), + mode: z.enum(["default", "plan"]).optional(), + goal: z + .object({ + objective: z.string(), + status: z.enum(["active", "complete", "blocked"]), + tokenBudget: z.number().int().positive().optional(), + createdAt: z.string(), + updatedAt: z.string(), + timeUsedSeconds: z.number().int().nonnegative(), + completedAt: z.string().optional(), + blockedAt: z.string().optional(), + }) + .optional(), + prompt: userInputPromptOutputSchema.optional(), + }, + ...toolWidgetDescriptorMeta(config, "plan"), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + }, + async ({ workspaceId, message }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + const pending = workspaceStore.getPendingUserInput(workspaceId); + const parsed = parseWorkspaceCommand(message, pending); + + if (!parsed.recognized || parsed.kind === "none") { + const content = [textBlock("No workflow command recognized.")]; + logToolCall(config, { + tool: "handle_workspace_command", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + return { + content, + structuredContent: { + result: contentText(content), + recognized: false, + command: "none" as const, + }, + }; + } + + if (parsed.kind === "plan") { + const collaboration = workspaceStore.setCollaborationMode({ + workspaceSessionId: workspaceId, + mode: "plan", + }); + const content = [textBlock(parsed.argument ? `Plan mode on\n${parsed.argument}` : "Plan mode on")]; + logToolCall(config, { + tool: "handle_workspace_command", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + return { + content, + structuredContent: { + result: contentText(content), + recognized: true, + command: "plan" as const, + mode: collaboration.mode, + }, + }; + } + + if (parsed.kind === "goal") { + if (!parsed.argument) { + const response = toolError("Goal command is missing an objective."); + logFailedToolResponse(config, { + tool: "handle_workspace_command", + workspaceId, + }, response.content, startedAt); + return response; + } + + const goal = workspaceStore.saveGoal({ + workspaceSessionId: workspaceId, + objective: parsed.argument, + }); + const content = [textBlock("Goal created")]; + logToolCall(config, { + tool: "handle_workspace_command", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + return { + content, + structuredContent: { + result: contentText(content), + recognized: true, + command: "goal" as const, + goal: { + objective: goal.objective, + status: goal.status, + tokenBudget: goal.tokenBudget, + createdAt: goal.createdAt, + updatedAt: goal.updatedAt, + timeUsedSeconds: goal.timeUsedSeconds, + completedAt: goal.completedAt, + blockedAt: goal.blockedAt, + }, + }, + }; + } + + if (!pending) { + const response = toolError("No pending user-input request exists for this workspace."); + logFailedToolResponse(config, { + tool: "handle_workspace_command", + workspaceId, + }, response.content, startedAt); + return response; + } + + if (parsed.error) { + const response = toolError(parsed.error); + logFailedToolResponse(config, { + tool: "handle_workspace_command", + workspaceId, + }, response.content, startedAt); + return response; + } + + const answers = parsed.answers ?? []; + validateSubmittedAnswers(pending, answers); + const summary = summarizeSubmittedAnswers(pending, answers); + const completed = workspaceStore.completeUserInput({ + workspaceSessionId: workspaceId, + answers, + summary, + source: "tool", + }); + const content = [textBlock("Answer recorded")]; + + logToolCall(config, { + tool: "handle_workspace_command", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return { + content, + _meta: { + tool: "answer_user_input", + card: { + workspaceId, + status: completed.status, + summary: { + answered: completed.response?.answers.length ?? 0, + }, + payload: { content }, + userInput: toStructuredUserInputRecord(completed), + }, + }, + structuredContent: { + result: contentText(content), + recognized: true, + command: "answer" as const, + prompt: toStructuredUserInputRecord(completed), + }, + }; + }, + ); + + registerAppTool( + server, + "request_user_input", + { + title: "Request user input", + description: + "Store a structured user-input request for the current workspace. Use this primarily in plan mode when an implementation choice or product preference materially affects the plan.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + autoResolutionMs: z.number().int().min(60000).max(240000).optional(), + questions: z + .array( + z.object({ + header: z.string(), + id: z.string(), + question: z.string(), + options: z + .array( + z.object({ + label: z.string(), + description: z.string(), + }), + ) + .min(2) + .max(3), + }), + ) + .min(1) + .max(3), + }, + outputSchema: { + result: z.string(), + status: z.enum(["pending", "completed", "declined", "cancelled"]), + delivery: z.enum([ + "elicitation_completed", + "elicitation_declined", + "elicitation_cancelled", + "pending_fallback", + ]), + prompt: userInputPromptOutputSchema, + response: z + .object({ + answers: z.array(userInputAnswerOutputSchema), + summary: z.string(), + source: z.enum(["elicitation", "tool", "ui"]), + action: z.enum(["accept", "decline", "cancel"]), + }) + .optional(), + }, + ...toolWidgetDescriptorMeta(config, "plan"), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + }, + async ({ workspaceId, questions, autoResolutionMs }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + validateQuestions(questions); + const requested = workspaceStore.createUserInputRequest({ + workspaceSessionId: workspaceId, + questions, + autoResolutionMs, + }); + + const capabilities = server.server.getClientCapabilities(); + const supportsElicitation = Boolean(capabilities?.elicitation?.form); + + let record = requested; + let delivery: + | "elicitation_completed" + | "elicitation_declined" + | "elicitation_cancelled" + | "pending_fallback" = "pending_fallback"; + + if (supportsElicitation) { + try { + const elicitation = await server.server.elicitInput({ + mode: "form", + message: "Please answer the following questions to continue.", + requestedSchema: toElicitationSchema(questions), + }); + + if (elicitation.action === "accept" && elicitation.content) { + record = workspaceStore.completeUserInput({ + workspaceSessionId: workspaceId, + answers: answersFromElicitation(questions, elicitation.content), + summary: summarizeAnswers(questions, elicitation.content), + source: "elicitation", + }); + delivery = "elicitation_completed"; + } else if (elicitation.action === "decline") { + record = workspaceStore.cancelOrDeclineUserInput({ + workspaceSessionId: workspaceId, + action: "decline", + source: "elicitation", + }); + delivery = "elicitation_declined"; + } else { + record = workspaceStore.cancelOrDeclineUserInput({ + workspaceSessionId: workspaceId, + action: "cancel", + source: "elicitation", + }); + delivery = "elicitation_cancelled"; + } + } catch { + record = requested; + delivery = "pending_fallback"; + } + } + + const content = [ + textBlock( + delivery === "pending_fallback" + ? `${formatUserInputPrompt(record.questions, record.autoResolutionMs)}\nReply with answers or use the card.` + : formatUserInputRecordResult(record), + ), + ]; + + logToolCall(config, { + tool: "request_user_input", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return { + content, + structuredContent: { + result: contentText(content), + status: record.status, + delivery, + prompt: toStructuredUserInputRecord(record), + response: record.response, + }, + }; + }, + ); + + registerAppTool( + server, + "get_pending_user_input", + { + title: "Get pending user input", + description: + "Get the currently pending user-input request for a workspace, if one exists.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + }, + outputSchema: { + result: z.string(), + prompt: userInputPromptOutputSchema.nullable(), + }, + ...toolWidgetDescriptorMeta(config, "plan"), + annotations: { readOnlyHint: true }, + }, + async ({ workspaceId }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + const pending = workspaceStore.getPendingUserInput(workspaceId); + const content = [ + textBlock( + pending + ? formatUserInputPrompt(pending.questions, pending.autoResolutionMs) + : "No pending user-input request for this workspace.", + ), + ]; + + logToolCall(config, { + tool: "get_pending_user_input", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return { + content, + structuredContent: { + result: contentText(content), + prompt: pending ? toStructuredUserInputRecord(pending) : null, + }, + }; + }, + ); + + registerAppTool( + server, + "answer_user_input", + { + title: "Answer user input", + description: + "Answer the currently pending user-input request for a workspace and complete the request lifecycle.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + source: z.enum(["tool", "ui"]).optional(), + text: z.string().optional(), + answers: z.array( + z.object({ + questionId: z.string(), + label: z.string(), + }), + ).min(1), + }, + outputSchema: { + result: z.string(), + prompt: userInputPromptOutputSchema, + response: z.object({ + answers: z.array(userInputAnswerOutputSchema), + summary: z.string(), + source: z.enum(["elicitation", "tool", "ui"]), + action: z.enum(["accept", "decline", "cancel"]), + }), + }, + ...toolWidgetDescriptorMeta(config, "plan"), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + }, + async ({ workspaceId, answers, text, source }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + const pending = workspaceStore.getPendingUserInput(workspaceId); + if (!pending) { + const response = toolError("No pending user-input request exists for this workspace."); + logFailedToolResponse(config, { + tool: "answer_user_input", + workspaceId, + }, response.content, startedAt); + return response; + } + + const submittedAnswers = text ? parseAnswerTextOrThrow(pending, text) : answers; + validateSubmittedAnswers(pending, submittedAnswers); + const summary = summarizeSubmittedAnswers(pending, submittedAnswers); + const completed = workspaceStore.completeUserInput({ + workspaceSessionId: workspaceId, + answers: submittedAnswers, + summary, + source: source ?? "tool", + }); + const content = [textBlock("Answer recorded")]; + + logToolCall(config, { + tool: "answer_user_input", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return { + content, + _meta: { + tool: "answer_user_input", + card: { + workspaceId, + status: completed.status, + summary: { + answered: completed.response?.answers.length ?? 0, + }, + payload: { + content, + }, + userInput: toStructuredUserInputRecord(completed), + }, + }, + structuredContent: { + result: contentText(content), + prompt: toStructuredUserInputRecord(completed), + response: completed.response, + }, + }; + }, + ); + + registerAppTool( + server, + "list_user_input_history", + { + title: "List user input history", + description: + "List recent user-input requests and answers for a workspace.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + limit: z.number().int().positive().max(20).optional(), + }, + outputSchema: { + result: z.string(), + history: z.array(userInputPromptOutputSchema), + }, + ...toolWidgetDescriptorMeta(config, "plan"), + annotations: { readOnlyHint: true }, + }, + async ({ workspaceId, limit }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + const history = workspaceStore.listUserInputHistory(workspaceId, limit); + const content = [textBlock(history.length === 0 ? "No user-input history for this workspace." : history.map(formatUserInputRecordResult).join("\n\n"))]; + + logToolCall(config, { + tool: "list_user_input_history", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return { + content, + structuredContent: { + result: contentText(content), + history: history.map(toStructuredUserInputRecord), + }, + }; + }, + ); + + registerAppTool( + server, + "update_plan", + { + title: "Update plan", + description: + "Store or replace a workspace-scoped execution plan. Use this when the task benefits from a short checklist with pending, in-progress, and completed steps.", + inputSchema: { + workspaceId: z + .string() + .describe("Workspace identifier returned by open_workspace."), + explanation: z + .string() + .optional() + .describe("Optional short explanation for this plan update."), + plan: z + .array( + z.object({ + step: z.string().describe("Concrete plan step."), + status: z.enum(["pending", "in_progress", "completed"]), + }), + ) + .min(1) + .describe("Current workspace plan. At most one step may be in_progress."), + }, + outputSchema: { + result: z.string(), + explanation: z.string().optional(), + plan: z.array( + z.object({ + step: z.string(), + status: z.enum(["pending", "in_progress", "completed"]), + }), + ), + updatedAt: z.string(), + }, + ...toolWidgetDescriptorMeta(config, "plan"), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + }, + async ({ workspaceId, explanation, plan }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + const collaboration = workspaceStore.getCollaborationMode(workspaceId); + if (collaboration.mode === "plan") { + const response = toolError("update_plan is unavailable while the workspace is in plan mode. Use request_user_input, repository exploration, and concrete planning instead."); + logFailedToolResponse(config, { + tool: "update_plan", + workspaceId, + }, response.content, startedAt); + return response; + } + + validatePlanSteps(plan); + const saved = workspaceStore.savePlan({ + workspaceSessionId: workspaceId, + explanation, + steps: plan, + }); + const content = [textBlock(formatPlanResult(saved.steps, saved.explanation))]; + + logToolCall(config, { + tool: "update_plan", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return { + content, + structuredContent: { + result: contentText(content), + explanation: saved.explanation, + plan: saved.steps, + updatedAt: saved.updatedAt, + }, + }; + }, + ); + + registerAppTool( + server, + "get_goal", + { + title: "Get goal", + description: + "Get the current workspace-scoped goal if one exists, including objective, status, and timestamps.", + inputSchema: { + workspaceId: z + .string() + .describe("Workspace identifier returned by open_workspace."), + }, + outputSchema: { + result: z.string(), + goal: z + .object({ + objective: z.string(), + status: z.enum(["active", "complete", "blocked"]), + tokenBudget: z.number().int().positive().optional(), + createdAt: z.string(), + updatedAt: z.string(), + timeUsedSeconds: z.number().int().nonnegative(), + completedAt: z.string().optional(), + blockedAt: z.string().optional(), + }) + .nullable(), + }, + ...toolWidgetDescriptorMeta(config, "goal"), + annotations: { readOnlyHint: true }, + }, + async ({ workspaceId }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + const goal = workspaceStore.getGoal(workspaceId); + const content = [textBlock(goal ? formatGoalResult(goal) : "No active or historical goal for this workspace.")]; + + logToolCall(config, { + tool: "get_goal", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return { + content, + structuredContent: { + result: contentText(content), + goal: goal + ? { + objective: goal.objective, + status: goal.status, + tokenBudget: goal.tokenBudget, + createdAt: goal.createdAt, + updatedAt: goal.updatedAt, + timeUsedSeconds: goal.timeUsedSeconds, + completedAt: goal.completedAt, + blockedAt: goal.blockedAt, + } + : null, + }, + }; + }, + ); + + registerAppTool( + server, + "create_goal", + { + title: "Create goal", + description: + "Create a new workspace-scoped goal. Fails if an active goal already exists for that workspace.", + inputSchema: { + workspaceId: z + .string() + .describe("Workspace identifier returned by open_workspace."), + objective: z.string().describe("Concrete objective to pursue."), + tokenBudget: z + .number() + .int() + .positive() + .optional() + .describe("Optional positive token budget for the goal."), + }, + outputSchema: { + result: z.string(), + goal: z.object({ + objective: z.string(), + status: z.literal("active"), + tokenBudget: z.number().int().positive().optional(), + createdAt: z.string(), + updatedAt: z.string(), + timeUsedSeconds: z.number().int().nonnegative(), + }), + }, + ...toolWidgetDescriptorMeta(config, "goal"), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + }, + async ({ workspaceId, objective, tokenBudget }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + const goal = workspaceStore.saveGoal({ + workspaceSessionId: workspaceId, + objective, + tokenBudget, + }); + const content = [textBlock(formatGoalResult(goal))]; + logToolCall(config, { - tool: "open_workspace", - workspaceId: workspace.id, - path: workspace.root, + tool: "create_goal", + workspaceId, success: true, durationMs: Math.round(performance.now() - startedAt), }); return { - content: resultContent, - _meta: { - tool: "open_workspace", - card: { - workspaceId: workspace.id, - root: workspace.root, - path: workspace.root, - summary: { - agentsFiles: loadedAgentsFiles.length, - availableAgentsFiles: availableAgentsFileOutputs.length, - skills: visibleSkills.length, - skillDiagnostics: workspace.skillDiagnostics.length, - }, + content, + structuredContent: { + result: contentText(content), + goal: { + objective: goal.objective, + status: goal.status, + tokenBudget: goal.tokenBudget, + createdAt: goal.createdAt, + updatedAt: goal.updatedAt, + timeUsedSeconds: goal.timeUsedSeconds, }, }, + }; + }, + ); + + registerAppTool( + server, + "update_goal", + { + title: "Update goal", + description: + "Mark the current workspace-scoped goal complete or blocked.", + inputSchema: { + workspaceId: z + .string() + .describe("Workspace identifier returned by open_workspace."), + status: z.enum(["complete", "blocked"]), + }, + outputSchema: { + result: z.string(), + goal: z.object({ + objective: z.string(), + status: z.enum(["complete", "blocked"]), + tokenBudget: z.number().int().positive().optional(), + createdAt: z.string(), + updatedAt: z.string(), + timeUsedSeconds: z.number().int().nonnegative(), + completedAt: z.string().optional(), + blockedAt: z.string().optional(), + }), + }, + ...toolWidgetDescriptorMeta(config, "goal"), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + }, + async ({ workspaceId, status }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + const goal = workspaceStore.updateGoalStatus({ + workspaceSessionId: workspaceId, + status, + }); + const content = [textBlock(formatGoalResult(goal, status === "complete"))]; + + logToolCall(config, { + tool: "update_goal", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return { + content, structuredContent: { - workspaceId: workspace.id, - root: workspace.root, - mode: workspace.mode, - sourceRoot: workspace.sourceRoot, - worktree: workspace.worktree, - agentsFiles: loadedAgentsFiles, - availableAgentsFiles: availableAgentsFileOutputs, - skills: visibleSkills, - skillDiagnostics: workspace.skillDiagnostics, - instruction, + result: contentText(content), + goal: { + objective: goal.objective, + status: goal.status, + tokenBudget: goal.tokenBudget, + createdAt: goal.createdAt, + updatedAt: goal.updatedAt, + timeUsedSeconds: goal.timeUsedSeconds, + completedAt: goal.completedAt, + blockedAt: goal.blockedAt, + }, }, }; }, @@ -702,7 +1598,7 @@ function createMcpServer( workspaces.markReadPathLoaded(workspace, readPath); const summary = { - ...textSummary(response.content), + ...contentStats(response.content), offset: input.offset ?? 1, limited: input.limit !== undefined, }; @@ -896,6 +1792,159 @@ function createMcpServer( }, ); + registerAppTool( + server, + "apply_workspace_patch", + { + title: "Apply workspace patch", + description: + `Apply a unified diff patch inside an open workspace. Use this for multi-file or batch file modifications instead of ${toolNames.shell}, shell redirection, heredocs, generated scripts, or ad-hoc write commands. All changed paths must stay inside the workspace root. Call open_workspace first and pass workspaceId.`, + inputSchema: { + workspaceId: z + .string() + .describe("Workspace identifier returned by open_workspace."), + patch: z + .string() + .describe("Unified diff patch containing diff --git file headers."), + }, + outputSchema: resultOutputSchema({ + status: z.literal("applied"), + files: z.array(z.string()), + }), + ...toolWidgetDescriptorMeta(config, "safe_operation"), + annotations: WRITE_TOOL_ANNOTATIONS, + }, + async ({ workspaceId, patch }) => { + const startedAt = performance.now(); + const workspace = workspaces.getWorkspace(workspaceId); + + try { + const result = await applyWorkspacePatch({ patch }, { root: workspace.root }); + const stats = countDiffStats(patch); + const message = `Applied patch to ${result.files.length} file${result.files.length === 1 ? "" : "s"} (+${stats.additions} -${stats.removals}).`; + const content = [textBlock(message)]; + + logToolCall(config, { + tool: "apply_workspace_patch", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return { + content, + _meta: { + tool: "apply_workspace_patch", + card: { + workspaceId, + summary: { + files: result.files.length, + ...stats, + }, + payload: { + patch, + stdout: result.stdout, + stderr: result.stderr, + }, + }, + }, + structuredContent: { + status: "applied" as const, + files: result.files, + result: contentText(content), + }, + }; + } catch (error) { + const response = toolError(error instanceof Error ? error.message : String(error)); + logFailedToolResponse(config, { + tool: "apply_workspace_patch", + workspaceId, + }, response.content, startedAt); + return response; + } + }, + ); + + registerAppTool( + server, + "git_push", + { + title: "Git push", + description: + "Push the current workspace git branch using structured arguments. Use this instead of running git push through the generic shell tool when the user explicitly asks to push.", + inputSchema: { + workspaceId: z + .string() + .describe("Workspace identifier returned by open_workspace."), + remote: z + .string() + .optional() + .describe("Git remote name. Defaults to origin."), + branch: z + .string() + .optional() + .describe("Branch or refspec to push. Omit to use git's configured default push target."), + setUpstream: z + .boolean() + .optional() + .describe("When true, pass -u to set upstream for the branch."), + }, + outputSchema: resultOutputSchema({ + remote: z.string(), + branch: z.string().optional(), + }), + ...toolWidgetDescriptorMeta(config, "safe_operation"), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, + }, + async ({ workspaceId, remote, branch, setUpstream }) => { + const startedAt = performance.now(); + const workspace = workspaces.getWorkspace(workspaceId); + + try { + const result = await gitPush({ remote, branch, setUpstream }, { root: workspace.root }); + const text = [result.stdout, result.stderr].filter(Boolean).join("\n").trim(); + const content = [textBlock(text || `Pushed to ${result.remote}${result.branch ? ` ${result.branch}` : ""}.`)]; + + logToolCall(config, { + tool: "git_push", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return { + content, + _meta: { + tool: "git_push", + card: { + workspaceId, + summary: { + remote: result.remote, + branch: result.branch, + }, + payload: { + stdout: result.stdout, + stderr: result.stderr, + }, + }, + }, + structuredContent: { + remote: result.remote, + branch: result.branch, + result: contentText(content), + }, + }; + } catch (error) { + const response = toolError(error instanceof Error ? error.message : String(error)); + logFailedToolResponse(config, { + tool: "git_push", + workspaceId, + }, response.content, startedAt); + return response; + } + }, + ); + if (config.widgets === "changes") { registerAppTool( server, @@ -1006,7 +2055,7 @@ function createMcpServer( const summary = { pattern: input.pattern, scope: input.path ?? ".", - ...textSummary(response.content), + ...contentStats(response.content), }; logToolCall(config, { tool: toolNames.grep, @@ -1076,7 +2125,7 @@ function createMcpServer( const summary = { pattern: input.pattern, scope: input.path ?? ".", - ...textSummary(response.content), + ...contentStats(response.content), }; logToolCall(config, { tool: toolNames.glob, @@ -1143,7 +2192,7 @@ function createMcpServer( return response; } - const summary = textSummary(response.content); + const summary = contentStats(response.content); logToolCall(config, { tool: toolNames.ls, workspaceId, @@ -1212,6 +2261,18 @@ function createMcpServer( workspace, workingDirectory, ); + const shellPolicy = validateShellCommand(config.shellMode, input.command); + if (!shellPolicy.allowed) { + const response = toolError(shellPolicy.reason ?? "Shell command blocked."); + logFailedToolResponse(config, { + tool: toolNames.shell, + workspaceId, + workingDirectory: workingDirectory ?? ".", + command: input.command, + commandLength: input.command.length, + }, response.content, startedAt); + return response; + } const response = await runShellTool(input, { cwd, root: workspace.root, @@ -1231,7 +2292,7 @@ function createMcpServer( const summary = { command: input.command, workingDirectory: workingDirectory ?? ".", - ...textSummary(response.content), + ...contentStats(response.content), }; logToolCall(config, { tool: toolNames.shell, @@ -1286,7 +2347,9 @@ export function createServer(config = loadConfig()): RunningServer { const reviewCheckpoints = createReviewCheckpointManager(); if (config.logging.trustProxy) { - app.set("trust proxy", true); + // DevSpace sits behind exactly one local reverse proxy: Nginx. + // Do not trust arbitrary forwarded chains from public clients. + app.set("trust proxy", 1); } app.use((req, res, next) => { @@ -1312,6 +2375,16 @@ export function createServer(config = loadConfig()): RunningServer { next(); }); + app.get("/.well-known/openid-configuration", (_req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.json(createOAuthMetadata({ + provider: oauthProvider, + issuerUrl: new URL(config.publicBaseUrl), + baseUrl: new URL(config.publicBaseUrl), + scopesSupported: config.oauth.scopes, + })); + }); + app.use( mcpAuthRouter({ provider: oauthProvider, @@ -1407,7 +2480,7 @@ export function createServer(config = loadConfig()): RunningServer { } }; - const server = createMcpServer(config, workspaces, reviewCheckpoints); + const server = createMcpServer(config, workspaces, reviewCheckpoints, workspaceStore); await server.connect(transport); } else { sendJsonRpcError(res, 400, -32000, "No valid MCP session"); @@ -1429,6 +2502,195 @@ export function createServer(config = loadConfig()): RunningServer { return { app, config }; } +function validatePlanSteps(steps: WorkspacePlanStep[]): void { + const inProgressCount = steps.filter((step) => step.status === "in_progress").length; + if (inProgressCount > 1) { + throw new Error("A plan may have at most one in_progress step."); + } +} + +function validateQuestions(questions: WorkspaceQuestion[]): void { + for (const question of questions) { + if (question.options.length < 2 || question.options.length > 3) { + throw new Error("Each question must have 2 or 3 options."); + } + } +} + +function validateSubmittedAnswers( + pending: WorkspaceUserInputRecord, + answers: WorkspaceUserInputAnswer[], +): void { + const answerMap = new Map(answers.map((answer) => [answer.questionId, answer.label])); + if (answerMap.size !== pending.questions.length) { + throw new Error("Each pending question must have exactly one submitted answer."); + } + + for (const question of pending.questions) { + const selected = answerMap.get(question.id); + if (!selected) { + throw new Error(`Missing answer for question ${question.id}.`); + } + if (!question.options.some((option) => option.label === selected)) { + throw new Error(`Invalid answer label for question ${question.id}: ${selected}`); + } + } +} + +function formatPlanResult(steps: WorkspacePlanStep[], explanation: string | undefined): string { + const summary = steps + .map((step) => `${step.status === "completed" ? "[done]" : step.status === "in_progress" ? "[doing]" : "[todo]"} ${step.step}`) + .join("\n"); + return explanation ? `${explanation}\n${summary}` : summary; +} + +function toElicitationSchema(questions: WorkspaceQuestion[]): { + type: "object"; + properties: Record< + string, + { + type: "string"; + title: string; + description: string; + oneOf: Array<{ + const: string; + title: string; + description: string; + }>; + } + >; + required: string[]; +} { + return { + type: "object", + properties: Object.fromEntries( + questions.map((question) => [ + question.id, + { + type: "string", + title: question.header, + description: question.question, + oneOf: question.options.map((option) => ({ + const: option.label, + title: option.label, + description: option.description, + })), + }, + ]), + ), + required: questions.map((question) => question.id), + }; +} + +function answersFromElicitation( + questions: WorkspaceQuestion[], + content: Record, +): WorkspaceUserInputAnswer[] { + return questions.map((question) => ({ + questionId: question.id, + label: String(content[question.id] ?? ""), + })); +} + +function summarizeAnswers( + questions: WorkspaceQuestion[], + content: Record, +): string { + return questions + .map((question) => `${question.header}: ${String(content[question.id] ?? "")}`) + .join("\n"); +} + +function summarizeSubmittedAnswers( + pending: WorkspaceUserInputRecord, + answers: WorkspaceUserInputAnswer[], +): string { + const answerMap = new Map(answers.map((answer) => [answer.questionId, answer.label])); + return pending.questions + .map((question) => `${question.header}: ${answerMap.get(question.id) ?? ""}`) + .join("\n"); +} + +function formatGoalResult(goal: { + objective: string; + status: "active" | "complete" | "blocked"; + tokenBudget?: number; + createdAt: string; + updatedAt: string; + timeUsedSeconds: number; + completedAt?: string; + blockedAt?: string; +}, includeCompletionNote = false): string { + const lines = [ + `Goal: ${goal.objective}`, + `Status: ${goal.status}`, + goal.tokenBudget !== undefined ? `Token budget: ${goal.tokenBudget}` : undefined, + `Time used seconds: ${goal.timeUsedSeconds}`, + goal.completedAt ? `Completed: ${goal.completedAt}` : undefined, + goal.blockedAt ? `Blocked: ${goal.blockedAt}` : undefined, + includeCompletionNote ? "Report the final budget and usage summary back to the user if the host tracks it." : undefined, + ]; + + return lines.filter(Boolean).join("\n"); +} + +function formatUserInputRecordResult(record: WorkspaceUserInputRecord): string { + const lines = [ + `Status: ${record.status}`, + record.response?.summary, + record.deliveryMode ? `Delivery: ${record.deliveryMode}` : undefined, + record.answeredAt ? `Answered: ${record.answeredAt}` : undefined, + ]; + + if (record.status === "pending") { + lines.unshift(formatUserInputPrompt(record.questions, record.autoResolutionMs)); + } + + return lines.filter(Boolean).join("\n"); +} + +function formatUserInputPrompt( + questions: WorkspaceQuestion[], + autoResolutionMs: number | undefined, +): string { + const lines = questions.flatMap((question) => [ + `${question.header}: ${question.question}`, + ...question.options.map((option) => `- ${option.label}: ${option.description}`), + ]); + if (autoResolutionMs !== undefined) { + lines.push(`Auto resolution: ${autoResolutionMs}ms`); + } + + return lines.join("\n"); +} + +function toStructuredUserInputRecord(record: WorkspaceUserInputRecord): { + questions: WorkspaceQuestion[]; + autoResolutionMs?: number; + status: "pending" | "completed" | "declined" | "cancelled"; + deliveryMode?: "elicitation" | "tool" | "ui"; + createdAt: string; + updatedAt: string; + answeredAt?: string; + response?: { + answers: WorkspaceUserInputAnswer[]; + summary: string; + source: "elicitation" | "tool" | "ui"; + action: "accept" | "decline" | "cancel"; + }; +} { + return { + questions: record.questions, + autoResolutionMs: record.autoResolutionMs, + status: record.status, + deliveryMode: record.deliveryMode, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + answeredAt: record.answeredAt, + response: record.response, + }; +} + async function isMainModule(): Promise { if (!process.argv[1]) return false; diff --git a/src/shell-policy.test.ts b/src/shell-policy.test.ts new file mode 100644 index 0000000..e931e2d --- /dev/null +++ b/src/shell-policy.test.ts @@ -0,0 +1,12 @@ +import assert from "node:assert/strict"; +import { validateShellCommand } from "./shell-policy.js"; + +assert.equal(validateShellCommand("full", "npm test").allowed, true); +assert.equal(validateShellCommand("off", "pwd").allowed, false); +assert.equal(validateShellCommand("read-only", "rg devspace src").allowed, true); +assert.equal(validateShellCommand("read-only", "git status --short").allowed, true); +assert.equal(validateShellCommand("read-only", "find . -name '*.ts'").allowed, true); +assert.equal(validateShellCommand("read-only", "find . -delete").allowed, false); +assert.equal(validateShellCommand("read-only", "npm test").allowed, false); +assert.equal(validateShellCommand("read-only", "git commit -m nope").allowed, false); +assert.equal(validateShellCommand("read-only", "rg devspace src | head").allowed, false); diff --git a/src/shell-policy.ts b/src/shell-policy.ts new file mode 100644 index 0000000..d9c02dd --- /dev/null +++ b/src/shell-policy.ts @@ -0,0 +1,124 @@ +import type { ShellMode } from "./config.js"; + +export interface ShellPolicyDecision { + allowed: boolean; + mode: ShellMode; + reason?: string; +} + +const READ_ONLY_COMMANDS = new Set([ + "cat", + "df", + "du", + "file", + "find", + "git", + "grep", + "head", + "ls", + "pwd", + "rg", + "stat", + "tail", + "wc", +]); + +const READ_ONLY_GIT_SUBCOMMANDS = new Set([ + "branch", + "diff", + "grep", + "log", + "ls-files", + "remote", + "rev-parse", + "show", + "status", +]); + +const SHELL_CONTROL_PATTERNS = [/&&/, /\|\|/, /;/, /\|/, />/, / DESTRUCTIVE_FIND_FLAGS.has(word)); + if (destructiveFlag) { + return deny(mode, `DEVSPACE_SHELL_MODE=read-only blocked find flag '${destructiveFlag}'.`); + } + + return allow(mode); +} + +function hasShellControlOperator(command: string): boolean { + return SHELL_CONTROL_PATTERNS.some((pattern) => pattern.test(command)); +} + +function basename(command: string): string { + return (command.split(/[\\/]/).pop() ?? command).toLowerCase(); +} + +function allow(mode: ShellMode): ShellPolicyDecision { + return { allowed: true, mode }; +} + +function deny(mode: ShellMode, reason: string): ShellPolicyDecision { + return { allowed: false, mode, reason }; +} diff --git a/src/skills.test.ts b/src/skills.test.ts index 1b0ebae..c08e311 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -15,13 +15,14 @@ try { const projectRoot = join(root, "project"); const agentDir = join(root, "agent"); const explicitSkills = join(root, "explicit-skills"); - await mkdir(join(projectRoot, ".pi", "skills", "project-skill"), { recursive: true }); + await mkdir(join(projectRoot, "skills", "local", "project-skill"), { recursive: true }); + await mkdir(join(projectRoot, "skills", "installed", "installed-skill"), { recursive: true }); await mkdir(join(agentDir, "skills", "global-skill"), { recursive: true }); await mkdir(join(explicitSkills, "duplicate"), { recursive: true }); await mkdir(join(explicitSkills, "disabled"), { recursive: true }); await writeFile( - join(projectRoot, ".pi", "skills", "project-skill", "SKILL.md"), + join(projectRoot, "skills", "local", "project-skill", "SKILL.md"), [ "---", "name: project-skill", @@ -31,6 +32,17 @@ try { "# Project Skill", ].join("\n"), ); + await writeFile( + join(projectRoot, "skills", "installed", "installed-skill", "SKILL.md"), + [ + "---", + "name: installed-skill", + "description: Installed skill description.", + "---", + "", + "# Installed Skill", + ].join("\n"), + ); await writeFile( join(agentDir, "skills", "global-skill", "SKILL.md"), [ @@ -53,6 +65,30 @@ try { "# Duplicate Skill", ].join("\n"), ); + await mkdir(join(projectRoot, "skills", "local", "duplicate-local"), { recursive: true }); + await mkdir(join(projectRoot, "skills", "installed", "duplicate-installed"), { recursive: true }); + await writeFile( + join(projectRoot, "skills", "local", "duplicate-local", "SKILL.md"), + [ + "---", + "name: duplicate-priority-skill", + "description: Local wins.", + "---", + "", + "# Duplicate Local", + ].join("\n"), + ); + await writeFile( + join(projectRoot, "skills", "installed", "duplicate-installed", "SKILL.md"), + [ + "---", + "name: duplicate-priority-skill", + "description: Installed loses to local.", + "---", + "", + "# Duplicate Installed", + ].join("\n"), + ); await writeFile( join(explicitSkills, "disabled", "SKILL.md"), [ @@ -85,7 +121,15 @@ try { }); const loaded = loadWorkspaceSkills(config, projectRoot); assert.equal(loaded.skills.some((skill) => skill.name === "project-skill"), true); + assert.equal(loaded.skills.some((skill) => skill.name === "installed-skill"), true); + assert.equal(loaded.skills.some((skill) => skill.name === "devspace-workflow"), true); + assert.equal(loaded.skills.some((skill) => skill.name === "senior-architect-lite"), true); + assert.equal(loaded.skills.some((skill) => skill.name === "skill-authoring-lite"), true); assert.equal(loaded.skills.filter((skill) => skill.name === "duplicate-skill").length, 1); + assert.equal(loaded.skills.filter((skill) => skill.name === "duplicate-priority-skill").length, 1); + const duplicatePrioritySkill = loaded.skills.find((skill) => skill.name === "duplicate-priority-skill"); + assert.ok(duplicatePrioritySkill); + assert.match(duplicatePrioritySkill.filePath, /skills\/local\/duplicate-local\/SKILL\.md$/); assert.equal(loaded.skills.some((skill) => skill.name === "hidden-skill"), true); assert.equal(loaded.diagnostics.some((diagnostic) => diagnostic.type === "collision"), true); @@ -105,6 +149,16 @@ try { ?.isSkillFile, false, ); + + const bundledWorkflowSkill = loaded.skills.find((skill) => skill.name === "devspace-workflow"); + assert.ok(bundledWorkflowSkill); + const bundledReferencePath = join(bundledWorkflowSkill.baseDir, "references", "commands.md"); + assert.equal(resolveSkillReadPath(loaded.skills, new Set(), bundledReferencePath), undefined); + assert.equal( + resolveSkillReadPath(loaded.skills, new Set([bundledWorkflowSkill.baseDir]), bundledReferencePath) + ?.isSkillFile, + false, + ); } finally { await rm(root, { recursive: true, force: true }); } diff --git a/src/skills.ts b/src/skills.ts index 20a3520..df7f3b9 100644 --- a/src/skills.ts +++ b/src/skills.ts @@ -1,5 +1,6 @@ import { homedir } from "node:os"; -import { resolve, sep } from "node:path"; +import { dirname, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; import { loadSkills, type Skill, @@ -22,12 +23,40 @@ export interface SkillReadResolution { export function loadWorkspaceSkills(config: ServerConfig, cwd: string): LoadedSkills { if (!config.skillsEnabled) return { skills: [], diagnostics: [] }; - return loadSkills({ - cwd, - agentDir: config.agentDir, - skillPaths: config.skillPaths, - includeDefaults: true, - }); + const batches = [ + loadSkills({ + cwd, + agentDir: config.agentDir, + skillPaths: [workspaceLocalSkillPath(cwd)], + includeDefaults: false, + }), + loadSkills({ + cwd, + agentDir: config.agentDir, + skillPaths: [workspaceInstalledSkillPath(cwd)], + includeDefaults: false, + }), + loadSkills({ + cwd, + agentDir: config.agentDir, + skillPaths: [bundledSkillPath()], + includeDefaults: false, + }), + loadSkills({ + cwd, + agentDir: config.agentDir, + skillPaths: [], + includeDefaults: true, + }), + loadSkills({ + cwd, + agentDir: config.agentDir, + skillPaths: config.skillPaths, + includeDefaults: false, + }), + ]; + + return mergeLoadedSkills(batches); } export function resolveSkillReadPath( @@ -73,3 +102,48 @@ export function formatPathForPrompt(path: string): string { return resolvedPath.split(sep).join("/"); } + +function bundledSkillPath(): string { + return resolve(dirname(fileURLToPath(import.meta.url)), "..", "skills", "core"); +} + +function workspaceLocalSkillPath(cwd: string): string { + return resolve(cwd, "skills", "local"); +} + +function workspaceInstalledSkillPath(cwd: string): string { + return resolve(cwd, "skills", "installed"); +} + +function mergeLoadedSkills(batches: LoadedSkills[]): LoadedSkills { + const winners = new Map(); + const diagnostics: LoadSkillsResult["diagnostics"] = []; + + for (const batch of batches) { + diagnostics.push(...batch.diagnostics); + for (const skill of batch.skills) { + const existing = winners.get(skill.name); + if (!existing) { + winners.set(skill.name, skill); + continue; + } + + diagnostics.push({ + type: "collision", + message: `name "${skill.name}" collision`, + path: skill.filePath, + collision: { + resourceType: "skill", + name: skill.name, + winnerPath: existing.filePath, + loserPath: skill.filePath, + }, + }); + } + } + + return { + skills: Array.from(winners.values()), + diagnostics, + }; +} diff --git a/src/tool-result.test.ts b/src/tool-result.test.ts new file mode 100644 index 0000000..3250535 --- /dev/null +++ b/src/tool-result.test.ts @@ -0,0 +1,7 @@ +import assert from "node:assert/strict"; +import { contentStats, contentText, textContent, toolError } from "./tool-result.js"; + +assert.deepEqual(textContent("hello"), [{ type: "text", text: "hello" }]); +assert.equal(contentText([{ type: "text", text: "hello" }, { type: "text", text: "world" }]), "hello\nworld"); +assert.deepEqual(contentStats([{ type: "text", text: "hello\nworld" }]), { lines: 2, characters: 11 }); +assert.equal(toolError("nope").isError, true); diff --git a/src/tool-result.ts b/src/tool-result.ts new file mode 100644 index 0000000..e4c220c --- /dev/null +++ b/src/tool-result.ts @@ -0,0 +1,36 @@ +export type ToolContent = + | { type: "text"; text: string } + | { type: "image"; data: string; mimeType: string }; + +export interface ToolResponse { + [key: string]: unknown; + content: ToolContent[]; + details?: TDetails; + isError?: boolean; +} + +export function textContent(text: string): ToolContent[] { + return [{ type: "text", text }]; +} + +export function toolError(message: string): ToolResponse { + return { + content: textContent(message), + isError: true, + }; +} + +export function contentText(content: ToolContent[]): string { + return content + .filter((item): item is { type: "text"; text: string } => item.type === "text") + .map((item) => item.text) + .join("\n"); +} + +export function contentStats(content: ToolContent[]): { lines: number; characters: number } { + const text = contentText(content); + return { + lines: text.length === 0 ? 0 : text.split("\n").length, + characters: text.length, + }; +} diff --git a/src/ui/card-types.ts b/src/ui/card-types.ts index 89ec3fe..03585c9 100644 --- a/src/ui/card-types.ts +++ b/src/ui/card-types.ts @@ -2,6 +2,10 @@ import type { App } from "@modelcontextprotocol/ext-apps"; export type ToolName = | "open_workspace" + | "request_user_input" + | "get_pending_user_input" + | "answer_user_input" + | "list_user_input_history" | "read_file" | "write_file" | "edit_file" @@ -49,6 +53,32 @@ export interface ToolResultCard { }>; skillDiagnostics?: unknown[]; instruction?: string; + userInput?: { + questions?: Array<{ + header?: string; + id?: string; + question?: string; + options?: Array<{ + label?: string; + description?: string; + }>; + }>; + autoResolutionMs?: number; + status?: string; + deliveryMode?: string; + createdAt?: string; + updatedAt?: string; + answeredAt?: string; + response?: { + answers?: Array<{ + questionId?: string; + label?: string; + }>; + summary?: string; + source?: string; + action?: string; + }; + }; } export interface ToolContent { @@ -67,6 +97,10 @@ export interface ToolPayload { export function isToolName(value: unknown): value is ToolName { return ( value === "open_workspace" || + value === "request_user_input" || + value === "get_pending_user_input" || + value === "answer_user_input" || + value === "list_user_input_history" || value === "read_file" || value === "write_file" || value === "edit_file" || diff --git a/src/ui/user-input-payload.tsx b/src/ui/user-input-payload.tsx new file mode 100644 index 0000000..ece5a76 --- /dev/null +++ b/src/ui/user-input-payload.tsx @@ -0,0 +1,141 @@ +import { useState } from "react"; +import { createRoot } from "react-dom/client"; +import type { HostContext, ToolResultCard } from "./card-types.js"; + +interface PayloadRendererOptions { + card: ToolResultCard; + hostContext?: HostContext; + errorMessage?: string | null; + submitAnswers?: (input: { + workspaceId: string; + answers: Array<{ questionId: string; label: string }>; + }) => Promise; +} + +interface MountedPayload { + update(options: PayloadRendererOptions): void; + unmount(): void; +} + +export function mountUserInputPayload( + container: HTMLElement, + options: PayloadRendererOptions, +): MountedPayload { + const root = createRoot(container); + root.render(); + + return { + update(nextOptions) { + root.render(); + }, + unmount() { + root.unmount(); + }, + }; +} + +function UserInputPayload({ + card, + errorMessage = null, + submitAnswers, +}: PayloadRendererOptions) { + const userInput = card.userInput; + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + const [selected, setSelected] = useState>({}); + + if (errorMessage) return ; + if (!userInput) return ; + + const isPending = userInput.status === "pending"; + + return ( +
+ {(userInput.questions ?? []).map((question) => ( +
+
{question.header}
+
{question.question}
+
+ {(question.options ?? []).map((option) => { + const isSelected = selected[question.id ?? ""] === option.label; + return ( + + ); + })} +
+
+ ))} + + {userInput.response?.summary ? ( +
{userInput.response.summary}
+ ) : null} + {submitError ? : null} + + {isPending ? ( +
+ +
+ ) : null} +
+ ); +} + +function canSubmit( + questions: Array<{ id?: string }>, + selected: Record, +): boolean { + return questions.every((question) => { + if (!question.id) return false; + return typeof selected[question.id] === "string" && selected[question.id].length > 0; + }); +} + +function StatusLine({ + message, + tone = "muted", +}: { + message: string; + tone?: "muted" | "error"; +}) { + return
{message}
; +} diff --git a/src/ui/workspace-app.css b/src/ui/workspace-app.css index 7a3fe06..0d4121b 100644 --- a/src/ui/workspace-app.css +++ b/src/ui/workspace-app.css @@ -349,6 +349,92 @@ body { background: var(--color-background-primary, #101114); } +.user-input-card { + display: grid; + gap: 14px; + padding: 14px; +} + +.user-input-question { + display: grid; + gap: 8px; +} + +.user-input-header { + color: var(--color-text-tertiary, #a3a3aa); + font-size: var(--font-text-sm-size, 12px); + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.user-input-text { + color: var(--color-text-primary, #f5f5f6); + font-size: var(--font-text-sm-size, 14px); +} + +.user-input-options { + display: grid; + gap: 8px; +} + +.user-input-option { + display: grid; + gap: 4px; + width: 100%; + padding: 10px 12px; + border: 1px solid color-mix(in srgb, var(--color-border-primary, #3a3a40) 80%, transparent); + border-radius: 8px; + background: color-mix(in srgb, var(--color-background-primary, #17181c) 42%, transparent); + color: inherit; + cursor: pointer; + text-align: left; +} + +.user-input-option:hover:not(:disabled), +.user-input-option.selected { + border-color: color-mix(in srgb, var(--color-text-primary, #f5f5f6) 28%, var(--color-border-primary, #3a3a40)); + background: color-mix(in srgb, var(--color-background-tertiary, #333338) 72%, transparent); +} + +.user-input-option:disabled { + cursor: default; + opacity: 0.8; +} + +.user-input-option-label { + color: var(--color-text-primary, #f5f5f6); + font-size: var(--font-text-sm-size, 13px); + font-weight: 600; +} + +.user-input-option-description, +.user-input-summary { + color: var(--color-text-secondary, #d6d6dc); + font-size: var(--font-text-sm-size, 13px); +} + +.user-input-actions { + display: flex; + justify-content: flex-end; +} + +.user-input-submit { + min-height: 34px; + padding: 0 12px; + border: 1px solid color-mix(in srgb, var(--color-border-primary, #3a3a40) 80%, transparent); + border-radius: 8px; + background: color-mix(in srgb, var(--color-background-tertiary, #333338) 88%, transparent); + color: var(--color-text-primary, #f5f5f6); + cursor: pointer; + font: inherit; +} + +.user-input-submit:disabled { + cursor: default; + opacity: 0.6; +} + @media (max-width: 520px) { .tool-header { grid-template-columns: 32px minmax(0, 1fr) auto 18px; diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx index 8acb70a..ca4a14d 100644 --- a/src/ui/workspace-app.tsx +++ b/src/ui/workspace-app.tsx @@ -230,6 +230,36 @@ async function renderPayloadIfNeeded(): Promise { return; } + if (shouldUseUserInputPayload(card)) { + if (currentPayload) { + currentPayload.update({ card, hostContext, errorMessage }); + return; + } + + renderStatus(target, "Loading question flow..."); + + const { mountUserInputPayload } = await import("./user-input-payload.js"); + if (target !== currentPayloadContainer || !expanded || !card) return; + + currentPayload = mountUserInputPayload(target, { + card, + hostContext, + errorMessage, + submitAnswers: async ({ workspaceId, answers }) => { + if (!app) throw new Error("Host app is not connected."); + await app.callServerTool({ + name: "answer_user_input", + arguments: { + workspaceId, + source: "ui", + answers, + }, + }); + }, + }); + return; + } + if (shouldUseHeavyPayload(card)) { if (currentPayload) { currentPayload.update({ card, hostContext, errorMessage }); @@ -286,6 +316,16 @@ function shouldUseHeavyPayload(card: ToolResultCard): boolean { return isReadTool(card.tool) || isEditTool(card.tool) || isWriteTool(card.tool); } +function shouldUseUserInputPayload(card: ToolResultCard): boolean { + return ( + (card.tool === "request_user_input" || + card.tool === "get_pending_user_input" || + card.tool === "answer_user_input" || + card.tool === "list_user_input_history") && + Boolean(card.userInput) + ); +} + function unmountPayload(): void { unmountCurrentPayload(); currentPayload = null; @@ -364,6 +404,10 @@ function renderSummaryBadge(card: ToolResultCard): HTMLElement { return element("span", { className: "badge", text: `${String(summary.lines ?? 0)} lines` }); } + if (card.userInput?.status) { + return element("span", { className: "badge", text: card.userInput.status }); + } + return element("span", { className: "badge", text: `${String(summary.lines ?? 0)} lines` }); } @@ -466,6 +510,14 @@ function getToolDisplay(card: ToolResultCard): ToolDisplay { switch (card.tool) { case "open_workspace": return { icon: folderIcon(), title: "Workspace", label, tone: "workspace" }; + case "request_user_input": + return { icon: questionIcon(), title: "Request User Input", label, tone: "directory" }; + case "get_pending_user_input": + return { icon: questionIcon(), title: "Pending User Input", label, tone: "directory" }; + case "answer_user_input": + return { icon: answeredIcon(), title: "Answered User Input", label, tone: "directory" }; + case "list_user_input_history": + return { icon: filesIcon(), title: "User Input History", label, tone: "directory" }; case "read_file": case "read": return { icon: fileIcon(), title: "Read File", label, tone: "read" }; @@ -505,6 +557,9 @@ function getToolLabel(card: ToolResultCard): string { if (isSearchTool(card.tool)) { return String(card.summary?.pattern ?? card.tool); } + if (card.userInput?.status) { + return `status: ${card.userInput.status}`; + } return card.tool; } @@ -582,6 +637,14 @@ function checkCircleIcon(): string { return ''; } +function questionIcon(): string { + return iconSvg(''); +} + +function answeredIcon(): string { + return iconSvg(''); +} + function listIcon(): string { return iconSvg(''); } diff --git a/src/user-config.ts b/src/user-config.ts index 0b79c51..dad8405 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -9,6 +9,8 @@ import { homedir } from "node:os"; import { join, resolve } from "node:path"; import { expandHomePath } from "./roots.js"; +export type TunnelMode = "cloudflare"; + export interface DevspaceUserConfig { host?: string; port?: number; @@ -18,6 +20,7 @@ export interface DevspaceUserConfig { stateDir?: string; worktreeRoot?: string; agentDir?: string; + tunnel?: TunnelMode; } export interface DevspaceAuthConfig { diff --git a/src/workspace-commands.test.ts b/src/workspace-commands.test.ts new file mode 100644 index 0000000..858dfbc --- /dev/null +++ b/src/workspace-commands.test.ts @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import { + normalizeWorkspaceCommandMessage, + parseAnswerTextOrThrow, + parseWorkspaceCommand, +} from "./workspace-commands.js"; +import type { WorkspaceUserInputRecord } from "./workspace-store.js"; + +const pending: WorkspaceUserInputRecord = { + workspaceSessionId: "ws_test", + questions: [ + { + header: "Count", + id: "count_mode", + question: "How should count work?", + options: [ + { label: "Visible", description: "Visible only" }, + { label: "All", description: "All nodes" }, + ], + }, + { + header: "Placement", + id: "placement", + question: "Where should it show?", + options: [ + { label: "Inline", description: "After name" }, + { label: "Column", description: "Separate column" }, + ], + }, + ], + status: "pending", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", +}; + +assert.equal(normalizeWorkspaceCommandMessage("@dev /plan fix this"), "/plan fix this"); +assert.equal(parseWorkspaceCommand("/plan fix this").kind, "plan"); +assert.equal(parseWorkspaceCommand("@dev /goal ship this").kind, "goal"); + +const parsedAnswer = parseWorkspaceCommand("1B,2A", pending); +assert.equal(parsedAnswer.kind, "answer"); +assert.equal(parsedAnswer.answers?.[0]?.label, "All"); +assert.equal(parsedAnswer.answers?.[1]?.label, "Inline"); + +const directAnswer = parseAnswerTextOrThrow(pending, "1b 2b"); +assert.deepEqual(directAnswer, [ + { questionId: "count_mode", label: "All" }, + { questionId: "placement", label: "Column" }, +]); + +assert.throws(() => parseAnswerTextOrThrow(pending, "1B"), /Missing answers for question 2/); +assert.throws(() => parseAnswerTextOrThrow(pending, "1C 2A"), /Option C is invalid/); diff --git a/src/workspace-commands.ts b/src/workspace-commands.ts new file mode 100644 index 0000000..4c87886 --- /dev/null +++ b/src/workspace-commands.ts @@ -0,0 +1,136 @@ +import type { + WorkspaceUserInputAnswer, + WorkspaceUserInputRecord, +} from "./workspace-store.js"; + +export type WorkspaceCommandKind = "plan" | "goal" | "answer" | "none"; + +export interface ParsedWorkspaceCommand { + kind: WorkspaceCommandKind; + recognized: boolean; + argument?: string; + answers?: WorkspaceUserInputAnswer[]; + error?: string; +} + +export function normalizeWorkspaceCommandMessage(message: string): string { + return message.trim().replace(/^@\S+\s+/, "").trim(); +} + +export function parseWorkspaceCommand( + message: string, + pending?: WorkspaceUserInputRecord, +): ParsedWorkspaceCommand { + const normalized = normalizeWorkspaceCommandMessage(message); + + const planMatch = normalized.match(/^\/plan(?:\s+([\s\S]+))?$/i); + if (planMatch) { + return { + kind: "plan", + recognized: true, + argument: planMatch[1]?.trim() || undefined, + }; + } + + const goalMatch = normalized.match(/^\/goal(?:\s+([\s\S]+))?$/i); + if (goalMatch) { + return { + kind: "goal", + recognized: true, + argument: goalMatch[1]?.trim() || undefined, + }; + } + + if (pending) { + const parsedAnswers = parseCompactAnswerText(pending, normalized); + if (parsedAnswers.matched) { + return { + kind: "answer", + recognized: true, + answers: parsedAnswers.answers, + error: parsedAnswers.error, + }; + } + } + + return { kind: "none", recognized: false }; +} + +export function parseAnswerTextOrThrow( + pending: WorkspaceUserInputRecord, + text: string, +): WorkspaceUserInputAnswer[] { + const parsed = parseCompactAnswerText(pending, text); + if (parsed.error) { + throw new Error(parsed.error); + } + if (!parsed.matched || !parsed.answers) { + throw new Error("Could not parse the reply as answers for the pending questions."); + } + + return parsed.answers; +} + +export function parseCompactAnswerText( + pending: WorkspaceUserInputRecord, + text: string, +): { + matched: boolean; + answers?: WorkspaceUserInputAnswer[]; + error?: string; +} { + const normalized = normalizeWorkspaceCommandMessage(text).replace(/[,、;]/g, ","); + if (!/\d/.test(normalized)) return { matched: false }; + + const tokens = normalized.split(/[\s,]+/).filter(Boolean); + if (tokens.length === 0) return { matched: false }; + + const parsed = tokens.map((token) => token.match(/^(\d+)([A-Za-z])$/)); + if (parsed.some((match) => !match)) return { matched: false }; + + const seen = new Set(); + const answerMap = new Map(); + + for (const match of parsed) { + if (!match) continue; + const questionNumber = Number(match[1]); + const optionLetter = match[2]?.toUpperCase() ?? ""; + const question = pending.questions[questionNumber - 1]; + if (!question) { + return { matched: true, error: `Question ${questionNumber} does not exist.` }; + } + if (seen.has(questionNumber)) { + return { matched: true, error: `Question ${questionNumber} was answered more than once.` }; + } + + const optionIndex = optionLetter.charCodeAt(0) - 65; + const option = question.options[optionIndex]; + if (!option) { + return { + matched: true, + error: `Option ${optionLetter} is invalid for question ${questionNumber}.`, + }; + } + + seen.add(questionNumber); + answerMap.set(question.id, option.label); + } + + if (seen.size !== pending.questions.length) { + const missing = pending.questions + .map((_, index) => index + 1) + .filter((index) => !seen.has(index)); + return { + matched: true, + error: `Missing answers for question ${missing.join(", ")}.`, + }; + } + + return { + matched: true, + answers: pending.questions.map((question) => ({ + questionId: question.id, + label: answerMap.get(question.id) ?? "", + })), + }; +} diff --git a/src/workspace-operations.test.ts b/src/workspace-operations.test.ts new file mode 100644 index 0000000..c0d2443 --- /dev/null +++ b/src/workspace-operations.test.ts @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { applyWorkspacePatch, extractPatchPaths, gitPush } from "./workspace-operations.js"; + +const execFileAsync = promisify(execFile); +const root = await mkdtemp(join(tmpdir(), "devspace-workspace-ops-test-")); + +try { + await git(root, ["init"]); + await git(root, ["config", "user.email", "devspace@example.com"]); + await git(root, ["config", "user.name", "DevSpace Test"]); + await writeFile(join(root, "README.md"), "hello\n"); + await git(root, ["add", "."]); + await git(root, ["commit", "-m", "Initial commit"]); + + const patch = [ + "diff --git a/README.md b/README.md", + "index ce01362..94954ab 100644", + "--- a/README.md", + "+++ b/README.md", + "@@ -1 +1,2 @@", + " hello", + "+world", + "", + ].join("\n"); + assert.deepEqual(extractPatchPaths(patch), ["README.md"]); + const result = await applyWorkspacePatch({ patch }, { root }); + assert.deepEqual(result.files, ["README.md"]); + assert.equal(await readFile(join(root, "README.md"), "utf8"), "hello\nworld\n"); + + const escapingPatch = [ + "diff --git a/../escape.txt b/../escape.txt", + "--- a/../escape.txt", + "+++ b/../escape.txt", + "@@ -0,0 +1 @@", + "+bad", + "", + ].join("\n"); + await assert.rejects( + () => applyWorkspacePatch({ patch: escapingPatch }, { root }), + /Path is outside allowed roots/, + ); + + await assert.rejects( + () => gitPush({ remote: "--upload-pack=bad" }, { root }), + /Invalid git remote/, + ); +} finally { + await rm(root, { recursive: true, force: true }); +} + +async function git(cwd: string, args: string[]): Promise { + await execFileAsync("git", args, { cwd }); +} diff --git a/src/workspace-operations.ts b/src/workspace-operations.ts new file mode 100644 index 0000000..0f27252 --- /dev/null +++ b/src/workspace-operations.ts @@ -0,0 +1,145 @@ +import { execFile, spawn } from "node:child_process"; +import { promisify } from "node:util"; +import { resolveAllowedPath } from "./roots.js"; + +const execFileAsync = promisify(execFile); + +export interface ApplyWorkspacePatchInput { + patch: string; +} + +export interface ApplyWorkspacePatchResult { + stdout: string; + stderr: string; + files: string[]; +} + +export interface GitPushInput { + remote?: string; + branch?: string; + setUpstream?: boolean; +} + +export interface GitPushResult { + stdout: string; + stderr: string; + remote: string; + branch?: string; +} + +export async function applyWorkspacePatch( + input: ApplyWorkspacePatchInput, + context: { root: string }, +): Promise { + const files = extractPatchPaths(input.patch); + if (files.length === 0) { + throw new Error("Patch does not contain any file paths."); + } + + for (const file of files) { + resolveAllowedPath(file, context.root, [context.root]); + } + + const { stdout, stderr } = await spawnWithInput( + "git", + ["apply", "--whitespace=nowarn", "-"], + { + cwd: context.root, + maxBuffer: 10 * 1024 * 1024, + }, + input.patch, + ); + + return { stdout, stderr, files }; +} + +export async function gitPush( + input: GitPushInput, + context: { root: string }, +): Promise { + const remote = input.remote ?? "origin"; + assertGitRefPart(remote, "remote"); + if (input.branch !== undefined) assertGitRefPart(input.branch, "branch"); + + const args = ["push"]; + if (input.setUpstream) args.push("-u"); + args.push(remote); + if (input.branch) args.push(input.branch); + + const { stdout, stderr } = await execFileAsync("git", args, { + cwd: context.root, + maxBuffer: 10 * 1024 * 1024, + }); + + return { stdout, stderr, remote, branch: input.branch }; +} + +export function extractPatchPaths(patch: string): string[] { + const paths = new Set(); + + for (const line of patch.split(/\r?\n/)) { + const match = line.match(/^diff --git a\/(.+) b\/(.+)$/); + if (!match) continue; + + const oldPath = normalizePatchPath(match[1]); + const newPath = normalizePatchPath(match[2]); + if (oldPath) paths.add(oldPath); + if (newPath) paths.add(newPath); + } + + return Array.from(paths); +} + +function normalizePatchPath(path: string | undefined): string | undefined { + if (!path || path === "/dev/null") return undefined; + return path; +} + +function assertGitRefPart(value: string, name: string): void { + if (!/^[A-Za-z0-9._/-]+$/.test(value) || value.includes("..") || value.startsWith("-")) { + throw new Error(`Invalid git ${name}.`); + } +} + +function spawnWithInput( + command: string, + args: string[], + options: { cwd: string; maxBuffer: number }, + input: string, +): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: options.cwd, + stdio: ["pipe", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + stdout += chunk; + if (stdout.length + stderr.length > options.maxBuffer) { + child.kill(); + reject(new Error("Command output exceeded maxBuffer.")); + } + }); + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + if (stdout.length + stderr.length > options.maxBuffer) { + child.kill(); + reject(new Error("Command output exceeded maxBuffer.")); + } + }); + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) { + resolve({ stdout, stderr }); + } else { + reject(new Error(stderr.trim() || `${command} exited with status ${code}`)); + } + }); + + child.stdin.end(input); + }); +} diff --git a/src/workspace-store.ts b/src/workspace-store.ts index 5fcb99a..57e9c46 100644 --- a/src/workspace-store.ts +++ b/src/workspace-store.ts @@ -2,10 +2,21 @@ import { eq } from "drizzle-orm"; import { openDatabase, type DatabaseHandle } from "./db/client.js"; import { workspaceSessions, + workspacePlans, + workspaceGoals, + workspaceModes, + workspaceUserInputs, type WorkspaceSessionRow, + type WorkspacePlanRow, + type WorkspaceGoalRow, + type WorkspaceModeRow, + type WorkspaceUserInputRow, } from "./db/schema.js"; export type WorkspaceMode = "checkout" | "worktree"; +export type CollaborationMode = "default" | "plan"; +export type UserInputStatus = "pending" | "completed" | "declined" | "cancelled"; +export type UserInputDeliveryMode = "elicitation" | "tool" | "ui"; export interface WorkspaceSession { id: string; @@ -20,6 +31,66 @@ export interface WorkspaceSession { lastUsedAt: string; } +export interface WorkspacePlanStep { + step: string; + status: "pending" | "in_progress" | "completed"; +} + +export interface WorkspacePlan { + workspaceSessionId: string; + explanation?: string; + steps: WorkspacePlanStep[]; + updatedAt: string; +} + +export interface WorkspaceGoal { + workspaceSessionId: string; + objective: string; + status: "active" | "complete" | "blocked"; + tokenBudget?: number; + createdAt: string; + updatedAt: string; + timeUsedSeconds: number; + completedAt?: string; + blockedAt?: string; +} + +export interface WorkspaceQuestionOption { + label: string; + description: string; +} + +export interface WorkspaceQuestion { + header: string; + id: string; + question: string; + options: WorkspaceQuestionOption[]; +} + +export interface WorkspaceUserInputAnswer { + questionId: string; + label: string; +} + +export interface WorkspaceUserInputResponse { + answers: WorkspaceUserInputAnswer[]; + summary: string; + source: UserInputDeliveryMode; + action: "accept" | "decline" | "cancel"; +} + +export interface WorkspaceUserInputRecord { + workspaceSessionId: string; + questions: WorkspaceQuestion[]; + autoResolutionMs?: number; + status: UserInputStatus; + deliveryMode?: UserInputDeliveryMode; + response?: WorkspaceUserInputResponse; + createdAt: string; + updatedAt: string; + answeredAt?: string; +} + export interface WorkspaceStore { createSession(input: { id: string; @@ -32,6 +103,54 @@ export interface WorkspaceStore { }): WorkspaceSession; getSession(id: string): WorkspaceSession | undefined; touchSession(id: string): void; + savePlan(input: { + workspaceSessionId: string; + explanation?: string; + steps: WorkspacePlanStep[]; + }): WorkspacePlan; + getPlan(workspaceSessionId: string): WorkspacePlan | undefined; + saveGoal(input: { + workspaceSessionId: string; + objective: string; + tokenBudget?: number; + }): WorkspaceGoal; + getGoal(workspaceSessionId: string): WorkspaceGoal | undefined; + updateGoalStatus(input: { + workspaceSessionId: string; + status: "complete" | "blocked"; + }): WorkspaceGoal; + setCollaborationMode(input: { + workspaceSessionId: string; + mode: CollaborationMode; + }): { + workspaceSessionId: string; + mode: CollaborationMode; + updatedAt: string; + }; + getCollaborationMode(workspaceSessionId: string): { + workspaceSessionId: string; + mode: CollaborationMode; + updatedAt: string; + }; + createUserInputRequest(input: { + workspaceSessionId: string; + questions: WorkspaceQuestion[]; + autoResolutionMs?: number; + }): WorkspaceUserInputRecord; + completeUserInput(input: { + workspaceSessionId: string; + answers: WorkspaceUserInputAnswer[]; + summary: string; + source: UserInputDeliveryMode; + }): WorkspaceUserInputRecord; + cancelOrDeclineUserInput(input: { + workspaceSessionId: string; + action: "decline" | "cancel"; + source?: UserInputDeliveryMode; + }): WorkspaceUserInputRecord; + getPendingUserInput(workspaceSessionId: string): WorkspaceUserInputRecord | undefined; + getLatestUserInput(workspaceSessionId: string): WorkspaceUserInputRecord | undefined; + listUserInputHistory(workspaceSessionId: string, limit?: number): WorkspaceUserInputRecord[]; close?(): void; } @@ -103,10 +222,340 @@ export class SqliteWorkspaceStore implements WorkspaceStore { .run(); } + savePlan(input: { + workspaceSessionId: string; + explanation?: string; + steps: WorkspacePlanStep[]; + }): WorkspacePlan { + const updatedAt = new Date().toISOString(); + const plan: WorkspacePlan = { + workspaceSessionId: input.workspaceSessionId, + explanation: input.explanation, + steps: input.steps, + updatedAt, + }; + + this.database.db + .insert(workspacePlans) + .values({ + workspaceSessionId: plan.workspaceSessionId, + explanation: plan.explanation ?? null, + stepsJson: JSON.stringify(plan.steps), + updatedAt: plan.updatedAt, + }) + .onConflictDoUpdate({ + target: workspacePlans.workspaceSessionId, + set: { + explanation: plan.explanation ?? null, + stepsJson: JSON.stringify(plan.steps), + updatedAt: plan.updatedAt, + }, + }) + .run(); + + return plan; + } + + getPlan(workspaceSessionId: string): WorkspacePlan | undefined { + const row = this.database.db + .select() + .from(workspacePlans) + .where(eq(workspacePlans.workspaceSessionId, workspaceSessionId)) + .get(); + + return row ? rowToWorkspacePlan(row) : undefined; + } + + saveGoal(input: { + workspaceSessionId: string; + objective: string; + tokenBudget?: number; + }): WorkspaceGoal { + const existing = this.getGoal(input.workspaceSessionId); + if (existing && existing.status === "active") { + throw new Error("An active goal already exists for this workspace."); + } + + const now = new Date().toISOString(); + const goal: WorkspaceGoal = { + workspaceSessionId: input.workspaceSessionId, + objective: input.objective, + status: "active", + tokenBudget: input.tokenBudget, + createdAt: now, + updatedAt: now, + timeUsedSeconds: 0, + }; + + this.database.db + .insert(workspaceGoals) + .values({ + workspaceSessionId: goal.workspaceSessionId, + objective: goal.objective, + status: goal.status, + tokenBudget: goal.tokenBudget === undefined ? null : String(goal.tokenBudget), + createdAt: goal.createdAt, + updatedAt: goal.updatedAt, + activeSeconds: "0", + completedAt: null, + blockedAt: null, + }) + .onConflictDoUpdate({ + target: workspaceGoals.workspaceSessionId, + set: { + objective: goal.objective, + status: goal.status, + tokenBudget: goal.tokenBudget === undefined ? null : String(goal.tokenBudget), + createdAt: goal.createdAt, + updatedAt: goal.updatedAt, + activeSeconds: "0", + completedAt: null, + blockedAt: null, + }, + }) + .run(); + + return goal; + } + + getGoal(workspaceSessionId: string): WorkspaceGoal | undefined { + const row = this.database.db + .select() + .from(workspaceGoals) + .where(eq(workspaceGoals.workspaceSessionId, workspaceSessionId)) + .get(); + + return row ? rowToWorkspaceGoal(row) : undefined; + } + + updateGoalStatus(input: { + workspaceSessionId: string; + status: "complete" | "blocked"; + }): WorkspaceGoal { + const existing = this.getGoal(input.workspaceSessionId); + if (!existing) { + throw new Error("No goal exists for this workspace."); + } + if (existing.status !== "active") { + throw new Error(`Goal is already ${existing.status}. Create a new goal to continue.`); + } + + const updatedAt = new Date().toISOString(); + const completedAt = input.status === "complete" ? updatedAt : null; + const blockedAt = input.status === "blocked" ? updatedAt : null; + const activeSeconds = calculateGoalActiveSeconds(existing, updatedAt); + + this.database.db + .update(workspaceGoals) + .set({ + status: input.status, + updatedAt, + activeSeconds: String(activeSeconds), + completedAt, + blockedAt, + }) + .where(eq(workspaceGoals.workspaceSessionId, input.workspaceSessionId)) + .run(); + + const updated = this.getGoal(input.workspaceSessionId); + if (!updated) { + throw new Error("Failed to reload goal after update."); + } + + return updated; + } + + setCollaborationMode(input: { + workspaceSessionId: string; + mode: CollaborationMode; + }): { + workspaceSessionId: string; + mode: CollaborationMode; + updatedAt: string; + } { + const updatedAt = new Date().toISOString(); + + this.database.db + .insert(workspaceModes) + .values({ + workspaceSessionId: input.workspaceSessionId, + mode: input.mode, + updatedAt, + }) + .onConflictDoUpdate({ + target: workspaceModes.workspaceSessionId, + set: { + mode: input.mode, + updatedAt, + }, + }) + .run(); + + return { + workspaceSessionId: input.workspaceSessionId, + mode: input.mode, + updatedAt, + }; + } + + getCollaborationMode(workspaceSessionId: string): { + workspaceSessionId: string; + mode: CollaborationMode; + updatedAt: string; + } { + const row = this.database.db + .select() + .from(workspaceModes) + .where(eq(workspaceModes.workspaceSessionId, workspaceSessionId)) + .get(); + + return row + ? rowToWorkspaceMode(row) + : { + workspaceSessionId, + mode: "default", + updatedAt: "", + }; + } + + createUserInputRequest(input: { + workspaceSessionId: string; + questions: WorkspaceQuestion[]; + autoResolutionMs?: number; + }): WorkspaceUserInputRecord { + const existing = this.getPendingUserInput(input.workspaceSessionId); + if (existing) { + throw new Error("A pending user-input request already exists for this workspace."); + } + + const now = new Date().toISOString(); + const record: WorkspaceUserInputRecord = { + workspaceSessionId: input.workspaceSessionId, + questions: input.questions, + autoResolutionMs: input.autoResolutionMs, + status: "pending", + createdAt: now, + updatedAt: now, + }; + + return this.persistUserInputRecord(record); + } + + completeUserInput(input: { + workspaceSessionId: string; + answers: WorkspaceUserInputAnswer[]; + summary: string; + source: UserInputDeliveryMode; + }): WorkspaceUserInputRecord { + const existing = this.getPendingUserInput(input.workspaceSessionId); + if (!existing) { + throw new Error("No pending user-input request exists for this workspace."); + } + + const now = new Date().toISOString(); + return this.persistUserInputRecord({ + ...existing, + status: "completed", + deliveryMode: input.source, + response: { + answers: input.answers, + summary: input.summary, + source: input.source, + action: "accept", + }, + updatedAt: now, + answeredAt: now, + }); + } + + cancelOrDeclineUserInput(input: { + workspaceSessionId: string; + action: "decline" | "cancel"; + source?: UserInputDeliveryMode; + }): WorkspaceUserInputRecord { + const existing = this.getPendingUserInput(input.workspaceSessionId); + if (!existing) { + throw new Error("No pending user-input request exists for this workspace."); + } + + const now = new Date().toISOString(); + const status: UserInputStatus = input.action === "decline" ? "declined" : "cancelled"; + + return this.persistUserInputRecord({ + ...existing, + status, + deliveryMode: input.source, + response: { + answers: [], + summary: input.action === "decline" ? "User declined to answer." : "User cancelled the request.", + source: input.source ?? "elicitation", + action: input.action, + }, + updatedAt: now, + answeredAt: now, + }); + } + + getPendingUserInput(workspaceSessionId: string): WorkspaceUserInputRecord | undefined { + const record = this.getLatestUserInput(workspaceSessionId); + return record?.status === "pending" ? record : undefined; + } + + getLatestUserInput(workspaceSessionId: string): WorkspaceUserInputRecord | undefined { + const row = this.database.db + .select() + .from(workspaceUserInputs) + .where(eq(workspaceUserInputs.workspaceSessionId, workspaceSessionId)) + .get(); + + return row ? rowToWorkspaceUserInput(row) : undefined; + } + + listUserInputHistory(workspaceSessionId: string, limit = 5): WorkspaceUserInputRecord[] { + const record = this.getLatestUserInput(workspaceSessionId); + if (!record) return []; + return [record].slice(0, Math.max(1, limit)); + } + close(): void { this.database.close(); } + private persistUserInputRecord(record: WorkspaceUserInputRecord): WorkspaceUserInputRecord { + this.database.db + .insert(workspaceUserInputs) + .values({ + workspaceSessionId: record.workspaceSessionId, + promptJson: JSON.stringify({ + questions: record.questions, + autoResolutionMs: record.autoResolutionMs, + }), + status: record.status, + deliveryMode: record.deliveryMode ?? null, + responseJson: record.response ? JSON.stringify(record.response) : null, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + answeredAt: record.answeredAt ?? null, + }) + .onConflictDoUpdate({ + target: workspaceUserInputs.workspaceSessionId, + set: { + promptJson: JSON.stringify({ + questions: record.questions, + autoResolutionMs: record.autoResolutionMs, + }), + status: record.status, + deliveryMode: record.deliveryMode ?? null, + responseJson: record.response ? JSON.stringify(record.response) : null, + updatedAt: record.updatedAt, + answeredAt: record.answeredAt ?? null, + }, + }) + .run(); + + return record; + } + private migrate(): void { this.database.sqlite.exec(` create table if not exists workspace_sessions ( @@ -143,6 +592,57 @@ export class SqliteWorkspaceStore implements WorkspaceStore { create index if not exists loaded_agent_files_path_idx on loaded_agent_files(path); + + create table if not exists workspace_plans ( + workspace_session_id text primary key, + explanation text, + steps_json text not null, + updated_at text not null, + foreign key (workspace_session_id) + references workspace_sessions(id) + on delete cascade + ); + + create table if not exists workspace_goals ( + workspace_session_id text primary key, + objective text not null, + status text not null default 'active', + token_budget text, + created_at text not null, + updated_at text not null, + active_seconds text not null default '0', + completed_at text, + blocked_at text, + foreign key (workspace_session_id) + references workspace_sessions(id) + on delete cascade + ); + + create index if not exists workspace_goals_status_idx + on workspace_goals(status, updated_at desc); + + create table if not exists workspace_modes ( + workspace_session_id text primary key, + mode text not null default 'default', + updated_at text not null, + foreign key (workspace_session_id) + references workspace_sessions(id) + on delete cascade + ); + + create table if not exists workspace_user_inputs ( + workspace_session_id text primary key, + prompt_json text not null, + status text not null default 'pending', + delivery_mode text, + response_json text, + created_at text not null, + updated_at text not null, + answered_at text, + foreign key (workspace_session_id) + references workspace_sessions(id) + on delete cascade + ); `); this.addColumnIfMissing("workspace_sessions", "mode", "text not null default 'checkout'"); @@ -150,6 +650,10 @@ export class SqliteWorkspaceStore implements WorkspaceStore { this.addColumnIfMissing("workspace_sessions", "base_ref", "text"); this.addColumnIfMissing("workspace_sessions", "base_sha", "text"); this.addColumnIfMissing("workspace_sessions", "managed", "text not null default 'false'"); + this.addColumnIfMissing("workspace_goals", "active_seconds", "text not null default '0'"); + this.addColumnIfMissing("workspace_user_inputs", "delivery_mode", "text"); + this.addColumnIfMissing("workspace_user_inputs", "response_json", "text"); + this.addColumnIfMissing("workspace_user_inputs", "answered_at", "text"); } private addColumnIfMissing(table: string, column: string, definition: string): void { @@ -180,3 +684,138 @@ function rowToWorkspaceSession(row: WorkspaceSessionRow): WorkspaceSession { lastUsedAt: row.lastUsedAt, }; } + +function rowToWorkspacePlan(row: WorkspacePlanRow): WorkspacePlan { + return { + workspaceSessionId: row.workspaceSessionId, + explanation: row.explanation ?? undefined, + steps: parsePlanSteps(row.stepsJson), + updatedAt: row.updatedAt, + }; +} + +function rowToWorkspaceGoal(row: WorkspaceGoalRow): WorkspaceGoal { + return { + workspaceSessionId: row.workspaceSessionId, + objective: row.objective, + status: + row.status === "complete" ? "complete" : row.status === "blocked" ? "blocked" : "active", + tokenBudget: row.tokenBudget === null ? undefined : Number(row.tokenBudget), + createdAt: row.createdAt, + updatedAt: row.updatedAt, + timeUsedSeconds: computePersistedGoalTimeUsedSeconds( + row.createdAt, + row.updatedAt, + row.activeSeconds, + row.status, + ), + completedAt: row.completedAt ?? undefined, + blockedAt: row.blockedAt ?? undefined, + }; +} + +function rowToWorkspaceMode(row: WorkspaceModeRow): { + workspaceSessionId: string; + mode: CollaborationMode; + updatedAt: string; +} { + return { + workspaceSessionId: row.workspaceSessionId, + mode: row.mode === "plan" ? "plan" : "default", + updatedAt: row.updatedAt, + }; +} + +function rowToWorkspaceUserInput(row: WorkspaceUserInputRow): WorkspaceUserInputRecord { + const parsedPrompt = JSON.parse(row.promptJson) as { + questions?: WorkspaceQuestion[]; + autoResolutionMs?: number; + }; + const parsedResponse = row.responseJson + ? (JSON.parse(row.responseJson) as WorkspaceUserInputResponse) + : undefined; + + return { + workspaceSessionId: row.workspaceSessionId, + questions: Array.isArray(parsedPrompt.questions) ? parsedPrompt.questions : [], + autoResolutionMs: + typeof parsedPrompt.autoResolutionMs === "number" + ? parsedPrompt.autoResolutionMs + : undefined, + status: normalizeUserInputStatus(row.status), + deliveryMode: normalizeUserInputDeliveryMode(row.deliveryMode), + response: parsedResponse, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + answeredAt: row.answeredAt ?? undefined, + }; +} + +function parsePlanSteps(value: string): WorkspacePlanStep[] { + const parsed = JSON.parse(value) as unknown; + if (!Array.isArray(parsed)) return []; + + return parsed.flatMap((item) => { + if (!item || typeof item !== "object") return []; + const step = "step" in item && typeof item.step === "string" ? item.step : undefined; + const status = + "status" in item && typeof item.status === "string" ? item.status : undefined; + if (!step) return []; + if (status !== "pending" && status !== "in_progress" && status !== "completed") return []; + + return [{ step, status }]; + }); +} + +function calculateGoalActiveSeconds(existing: WorkspaceGoal, updatedAt: string): number { + const createdAtMs = Date.parse(existing.createdAt); + const updatedAtMs = Date.parse(updatedAt); + if (!Number.isFinite(createdAtMs) || !Number.isFinite(updatedAtMs)) { + return existing.timeUsedSeconds; + } + + return Math.max(existing.timeUsedSeconds, Math.floor((updatedAtMs - createdAtMs) / 1000)); +} + +function computePersistedGoalTimeUsedSeconds( + createdAt: string, + updatedAt: string, + activeSeconds: string | null, + status: string, +): number { + const persisted = activeSeconds === null ? NaN : Number(activeSeconds); + if (Number.isFinite(persisted)) { + if (status === "active") { + const createdAtMs = Date.parse(createdAt); + const updatedAtMs = Date.now(); + if (Number.isFinite(createdAtMs) && Number.isFinite(updatedAtMs)) { + return Math.max(persisted, Math.floor((updatedAtMs - createdAtMs) / 1000)); + } + } + + return persisted; + } + + const createdAtMs = Date.parse(createdAt); + const endMs = status === "active" ? Date.now() : Date.parse(updatedAt); + if (!Number.isFinite(createdAtMs) || !Number.isFinite(endMs)) return 0; + return Math.max(0, Math.floor((endMs - createdAtMs) / 1000)); +} + +function normalizeUserInputStatus(value: string): UserInputStatus { + if (value === "completed" || value === "declined" || value === "cancelled") { + return value; + } + + return "pending"; +} + +function normalizeUserInputDeliveryMode( + value: string | null, +): UserInputDeliveryMode | undefined { + if (value === "elicitation" || value === "tool" || value === "ui") { + return value; + } + + return undefined; +} diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index 554a3da..423a24a 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -90,6 +90,53 @@ try { path: gitRoot, mode: "worktree", }); + const savedPlan = firstStore.savePlan({ + workspaceSessionId: persistentWorkspace.workspace.id, + explanation: "Track work in small steps", + steps: [ + { step: "Inspect repo", status: "completed" }, + { step: "Implement plan tools", status: "in_progress" }, + { step: "Run tests", status: "pending" }, + ], + }); + assert.equal(savedPlan.steps.length, 3); + const savedMode = firstStore.setCollaborationMode({ + workspaceSessionId: persistentWorkspace.workspace.id, + mode: "plan", + }); + assert.equal(savedMode.mode, "plan"); + const savedPrompt = firstStore.createUserInputRequest({ + workspaceSessionId: persistentWorkspace.workspace.id, + questions: [ + { + header: "Mode", + id: "mode_choice", + question: "Which implementation mode should we use?", + options: [ + { label: "Strict", description: "Closer to Codex semantics" }, + { label: "Loose", description: "More permissive for compatibility" }, + ], + }, + ], + autoResolutionMs: 60000, + }); + assert.equal(savedPrompt.status, "pending"); + const savedGoal = firstStore.saveGoal({ + workspaceSessionId: persistentWorkspace.workspace.id, + objective: "Ship Codex-style planning support", + tokenBudget: 1200, + }); + assert.equal(savedGoal.status, "active"); + const blockedGoal = firstStore.updateGoalStatus({ + workspaceSessionId: persistentWorkspace.workspace.id, + status: "blocked", + }); + assert.equal(blockedGoal.status, "blocked"); + const restartedGoal = firstStore.saveGoal({ + workspaceSessionId: persistentWorkspace.workspace.id, + objective: "Retry Codex-style planning support", + }); + assert.equal(restartedGoal.status, "active"); firstStore.close(); const secondStore = new SqliteWorkspaceStore(stateDir); @@ -97,6 +144,32 @@ try { const restoredWorkspace = restoredRegistry.getWorkspace(persistentWorkspace.workspace.id); assert.equal(restoredWorkspace.root, root); assert.equal(restoredWorkspace.mode, "checkout"); + const restoredPlan = secondStore.getPlan(persistentWorkspace.workspace.id); + assert.equal(restoredPlan?.explanation, "Track work in small steps"); + assert.equal(restoredPlan?.steps[1]?.status, "in_progress"); + const restoredMode = secondStore.getCollaborationMode(persistentWorkspace.workspace.id); + assert.equal(restoredMode.mode, "plan"); + const restoredPrompt = secondStore.getPendingUserInput(persistentWorkspace.workspace.id); + assert.equal(restoredPrompt?.questions[0]?.id, "mode_choice"); + assert.equal(restoredPrompt?.autoResolutionMs, 60000); + const restoredGoal = secondStore.getGoal(persistentWorkspace.workspace.id); + assert.equal(restoredGoal?.objective, "Retry Codex-style planning support"); + assert.equal(restoredGoal?.status, "active"); + assert.equal(restoredGoal?.tokenBudget, undefined); + assert.equal(typeof restoredGoal?.timeUsedSeconds, "number"); + assert.throws( + () => + secondStore.saveGoal({ + workspaceSessionId: persistentWorkspace.workspace.id, + objective: "Should fail while active goal exists", + }), + /An active goal already exists/, + ); + const completedGoal = secondStore.updateGoalStatus({ + workspaceSessionId: persistentWorkspace.workspace.id, + status: "complete", + }); + assert.equal(completedGoal.status, "complete"); const restoredWorktree = restoredRegistry.getWorkspace(persistentWorktree.workspace.id); assert.equal(restoredWorktree.mode, "worktree");