diff --git a/.gitignore b/.gitignore index 397a63f..db0ed7d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ dist/ releases/ .env *.log +WORKING/ +skills/installed/ diff --git a/README.md b/README.md index 001b084..f145069 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,11 @@ License

-[![DevSpace connected to ChatGPT](https://raw.githubusercontent.com/Waishnav/devspace/main/docs/assets/devspace-screenshot.png)](https://raw.githubusercontent.com/Waishnav/devspace/main/docs/assets/devspace-screenshot.png) +

+ English | 简体中文 +

+ +[![DevSpace connected to ChatGPT](docs/assets/devspace-screenshot.png)](docs/assets/devspace-screenshot.png) **Give ChatGPT a secure connection to your own machine and Turn ChatGPT into Codex** @@ -117,6 +121,116 @@ Most users should connect through a public HTTPS tunnel: https://your-tunnel-host.example.com/mcp ``` +## Configuration Management + +Update the local server config with short commands: + +```bash +# Show the effective runtime configuration +devspace config show + +# Change the local listening port +devspace config port 7676 + +# Change the local bind host +devspace config host 127.0.0.1 + +# Set the public domain or URL +devspace config domain devspace.example.com + +# Rotate the Owner password +devspace config key +``` + +Configuration changes are saved immediately. If a managed DevSpace background +service is currently running, DevSpace automatically restarts it so the new +settings take effect right away. + +`devspace config show` displays the effective bind host, port, MCP path, public +URL, workspace list, service state, and a masked access key. If the current +Owner password comes from `DEVSPACE_OAUTH_OWNER_TOKEN`, DevSpace masks and shows +that effective value instead of reporting it as missing. + +`devspace config key` rotates the existing DevSpace Owner password, clears saved +OAuth approvals and tokens, and forces connected clients to reauthorize. + +## Workspace Management + +Persist the workspace roots DevSpace is allowed to open: + +```bash +# Add a workspace and mark it as the default one +devspace workspace add ~/workspace/project-a --default + +# Add another workspace without changing the default +devspace workspace add ~/workspace/project-b + +# Show configured workspaces +devspace workspace list + +# Switch the default workspace +devspace workspace default ~/workspace/project-b + +# Remove a workspace from the allowlist +devspace workspace remove ~/workspace/project-a +``` + +You can also allow extra paths for one run only: + +```bash +devspace serve --add-dir ~/scratch/project-c --workspace ~/workspace/project-b +``` + +Workspace paths are the authorization boundary for DevSpace and MCP file tools. +Adding a workspace authorizes only that path and its children. + +If you start DevSpace without any configured workspaces or `DEVSPACE_ALLOWED_ROOTS`, +DevSpace now fails closed: the server can start, but workspace access is denied +until you explicitly add an allowed path. + +## Service Management + +DevSpace service management only manages DevSpace itself. `devspace service start` +acts as the single entrypoint: if the background service is missing, DevSpace +creates it for the current platform and starts it; if it already exists, it +just starts it. It does not manage arbitrary system services. + +```bash +# Start the managed DevSpace background service +devspace service start + +# Show whether the service is installed and running +devspace service status + +# Read the service log output +devspace service logs + +# Restart the running service +devspace service restart + +# Stop the running service +devspace service stop + +# Disable automatic service startup +devspace service disable + +# Remove the installed DevSpace background service +devspace service remove + +# Check service-manager support and current health +devspace service doctor +``` + +Platform behavior: + +- macOS uses a per-user LaunchAgent. +- Linux and Ubuntu use a per-user systemd service when available. +- Windows uses Task Scheduler. +- WSL prefers user systemd and otherwise reports a Windows Task Scheduler fallback. + +DevSpace does not automatically configure DNS, reverse proxies, TLS +certificates, or firewall rules. + ## What ChatGPT Can Do Once connected, ChatGPT can open one of your approved project folders as a @@ -133,6 +247,113 @@ 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 bundles durable workflow Skills rather than short prompt examples. Core Skills cover project Plan recovery, Goal definition and status, workflow resumption, architecture review, and Skill authoring. + +Project Skill directories are split by purpose: + +- `skills/.system`: exactly five DevSpace-owned system Skills: `plan`, `goal`, `workflow`, `architecture-review`, and `skill-authoring` +- `skills/local`: project-defined Skills you want to keep in version control +- `skills/installed`: user-installed external Skills, ignored by git by default + +ChatGPT Plus on the web cannot natively install or register Codex Skills. DevSpace provides MCP-side discovery, resolution, and controlled `skill://` resource access instead. + +`@devspace /plan` and `@devspace /goal` are stable alias-style workflow conventions. `/plan` always resolves to system `plan`; `/goal` always resolves to system `goal`; local, installed, and global Skills cannot silently override them. `skills/.system/README.md` records the system Skill policy and change log. + +## Using `/plan` and `/goal` + +Use these aliases in a normal ChatGPT message after DevSpace is connected. They are not native ChatGPT slash commands. Open the workspace first, then state the requested outcome clearly. + +### `/plan`: inspect first, then save an implementation plan + +Use `/plan` when you want repository analysis and a durable implementation plan before any file changes. DevSpace loads the current Plan when one exists, enters Plan Mode, inspects the repository read-only, then persists a Plan with ordered steps, validation, risks, and a revision number. + +```text +@devspace Open /path/to/project. + +/plan Add a hello CLI command that prints "Hello DevSpace". +First inspect the project and create a persistent Plan. +Do not modify project files or run commands that write to the repository. +``` + +A good `/plan` request states the outcome, relevant constraints, and whether implementation should wait for approval. To review a saved Plan later, ask DevSpace to open the same workspace and read the current Plan before taking action. + +```text +@devspace Open /path/to/project. + +Read the current Plan and summarize its title, revision, pending steps, validation, and blockers. Do not modify files. +``` + +### `/goal`: keep a durable outcome across sessions + +Use `/goal` when an objective should remain available across future DevSpace sessions. A Goal records the objective, scope, success criteria, verification, stop conditions, current status, and exact metrics where evidence exists. DevSpace reads the active Goal first and will not silently replace it with a competing one. + +```text +@devspace Open /path/to/project. + +/goal Create a durable Goal to add a hello CLI command. +Success criteria: the command runs and prints "Hello DevSpace". +Verification: run the command and its automated test. +Stop condition: the project requirements change to a non-CLI interface. +Do not modify files yet. +``` + +You can explicitly start or pause the Goal work timer, or update its status when work is blocked, completed, or archived. + +```text +@devspace Start the current Goal work timer. + +@devspace Pause the current Goal work timer and show the measured work duration. +``` + +### Using them together + +Create a Goal for the long-lived outcome, then create a Plan that breaks the Goal into concrete steps and explicitly links the Plan to that Goal. Goal progress is calculated only from completed steps in that linked Plan; DevSpace does not guess a percentage. Provider token metrics are recorded only when an API/provider returns real token usage and a stable request ID, so ChatGPT web usage is not filled in automatically. + +```text +@devspace Create a Plan for the current Goal, link the Plan to the Goal, and save it without modifying files. +``` + +Manage installed skills with: + +```bash +# Install a skill for the current context +devspace skills install --repo openai/skills --path skills/.curated/research + +# Install a skill for one specific workspace +devspace skills install --workspace /path/to/project --repo openai/skills --path skills/.curated/research + +# List skills for the current context +devspace skills list + +# List skills for one specific workspace +devspace skills list --workspace /path/to/project + +# Remove a skill from the current context +devspace skills remove research + +# Remove a skill from one specific workspace +devspace skills remove --workspace /path/to/project research + +# Install a global skill +devspace skills install -g --repo openai/skills --path skills/.curated/research + +# List global skills +devspace skills list -g + +# Remove a global skill +devspace skills remove -g research +``` + +`--repo/--path` and `--local-path` must point directly at one standard skill directory that contains `SKILL.md`. Repository roots, plugin roots, command folders, and agent-rules directories are rejected. + +## Project Workflow Store + +DevSpace stores compact project-scoped workflow state: the current Plan, Goal, Plan Mode, structured step state, and at most 100 concise workflow events. It does not store chat transcripts, raw tool output, shell logs, or file snapshots. Goal metrics are limited to exact provider-reported token records, an explicit server work timer, and progress derived from a Plan explicitly linked to that Goal. + +The same canonical project directory shares Plan and Goal state across ChatGPT sessions and DevSpace restarts. Different projects and different Git worktree roots remain isolated. `open_workspace` returns a small `workflowDigest`; call `get_plan`, `get_goal`, and paginated `get_workflow_history` only when full state is needed. + +Plan and Goal writes use optimistic concurrency. Read the current state first, then send `expectedRevision`; stale sessions receive a revision conflict instead of silently overwriting newer work. + ## Mental Model DevSpace is remote access to selected local folders. @@ -152,7 +373,8 @@ For a normal ChatGPT coding session: ## Platform Support DevSpace supports Linux, macOS, and Windows environments with a Bash-compatible -shell. +shell for the main CLI, and supports native per-user service control on macOS, +Linux, Windows, and WSL. | Platform | Status | Notes | | ------------------------------------------------- | ----------------- | ---------------------------------------------- | diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..fd2a339 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,363 @@ +

+ + DevSpace logo + +

+ +

DevSpace

+ +

把类似 Codex 的编程工作流带到 ChatGPT。

+ +

+ npm + CI + License +

+ +

+ English | 简体中文 +

+ +[![DevSpace connected to ChatGPT](docs/assets/devspace-screenshot.png)](docs/assets/devspace-screenshot.png) + +**为 ChatGPT 提供一条安全连接到你自己机器的通道,把 ChatGPT 变成 Codex** + +DevSpace 是一个自托管的 MCP 服务器,让 ChatGPT 可以直接在你真实的本地项目里读取、编辑、搜索和运行代码,也就是使用你的文件、你的工具、你的终端,而无需把内容上传到第三方。它运行在你的机器上,通过你自己控制的隧道暴露出去,并使用只有你知道的密码来批准连接。 + +## 安装 + +DevSpace 需要 Node `>=20.12 <27`,推荐使用 Node 22 LTS。 + +安装 DevSpace CLI: + +```bash +npm install -g @waishnav/devspace +``` + +然后初始化并启动服务: + +```bash +devspace init +devspace serve +``` + +如果你不想全局安装,也可以直接运行: + +```bash +npx @waishnav/devspace init +npx @waishnav/devspace serve +``` + +在安装过程中,DevSpace 会询问你: + +- ChatGPT 被允许通过 DevSpace 打开的本地项目目录 +- 本地端口,通常为 `7676` +- 你的公网 HTTPS 基础地址,可以来自 Cloudflare Tunnel、ngrok、Pinggy、Tailscale Funnel 或其他反向代理 + +在初始化时,请填写不带 `/mcp` 的公网基础地址: + +```text +https://your-tunnel-host.example.com +``` + +完成设置后,再把带 `/mcp` 的公网地址配置到你的 MCP 客户端中。 + +当客户端连接时,DevSpace 会打开一个 Owner 密码确认页面。输入 `devspace init` 打印出来的 Owner 密码即可。这个密码也会保存到: + +```text +~/.devspace/auth.json +``` + +请妥善保管,不要泄露。 + +## 连接你的 MCP 客户端 + +默认的本地端点是: + +```text +http://127.0.0.1:7676/mcp +``` + +大多数用户应该通过公网 HTTPS 隧道连接: + +```text +https://your-tunnel-host.example.com/mcp +``` + +## 配置管理 + +你可以用这些简短命令更新本地服务配置: + +```bash +# 查看当前生效配置 +devspace config show + +# 修改本地监听端口 +devspace config port 7676 + +# 修改本地绑定地址 +devspace config host 127.0.0.1 + +# 设置公网域名或 URL +devspace config domain devspace.example.com + +# 轮换 Owner 密码 +devspace config key +``` + +配置修改后会立即保存。如果当前有由 DevSpace 管理的后台服务正在运行,DevSpace 会自动重启它,让新设置立刻生效。 + +`devspace config show` 会显示生效中的绑定地址、端口、MCP 路径、公网 URL、工作区列表、服务状态,以及打码后的访问密钥。如果当前 Owner 密码来自 `DEVSPACE_OAUTH_OWNER_TOKEN`,DevSpace 会显示打码后的实际值,而不是把它报告为缺失。 + +`devspace config key` 会轮换现有的 DevSpace Owner 密码、清除已保存的 OAuth 批准和令牌,并强制已连接客户端重新授权。 + +## 工作区管理 + +持久化保存 DevSpace 被允许打开的工作区根目录: + +```bash +# 添加工作区,并设为默认工作区 +devspace workspace add ~/workspace/project-a --default + +# 再添加一个工作区,但不修改默认项 +devspace workspace add ~/workspace/project-b + +# 查看当前工作区列表 +devspace workspace list + +# 把默认工作区切换到 project-b +devspace workspace default ~/workspace/project-b + +# 删除一个工作区配置 +devspace workspace remove ~/workspace/project-a +``` + +你也可以只在当前这次运行中临时允许额外路径: + +```bash +devspace serve --add-dir ~/scratch/project-c --workspace ~/workspace/project-b +``` + +工作区路径就是 DevSpace 与 MCP 文件工具的授权边界。添加某个工作区,只会授权这个路径及其子路径。 + +如果你启动 DevSpace 时既没有已配置工作区,也没有设置 `DEVSPACE_ALLOWED_ROOTS`,DevSpace 现在会默认拒绝访问:服务可以启动,但在你显式添加允许路径之前,工作区访问会被拒绝。 + +## 服务管理 + +DevSpace 的服务管理只负责管理 DevSpace 本身。`devspace service start` 是统一入口:如果后台服务不存在,DevSpace 会按当前平台创建并启动;如果已经存在,则只执行启动。它不会管理任意系统服务。 + +```bash +# 启动 DevSpace 后台服务 +devspace service start + +# 查看服务当前状态 +devspace service status + +# 查看服务日志 +devspace service logs + +# 重启服务 +devspace service restart + +# 停止服务 +devspace service stop + +# 禁用服务自启动 +devspace service disable + +# 删除已安装的 DevSpace 后台服务 +devspace service remove + +# 检查服务管理环境和健康状态 +devspace service doctor +``` + +平台行为如下: + +- macOS 使用按用户安装的 LaunchAgent +- Linux 和 Ubuntu 在可用时使用按用户安装的 systemd 服务 +- Windows 使用任务计划程序 +- WSL 优先使用用户级 systemd,否则会提示回退到 Windows 任务计划程序 + +DevSpace 不会自动帮你配置 DNS、反向代理、TLS 证书或防火墙规则。 + +## ChatGPT 能做什么 + +连接建立后,ChatGPT 可以把你已批准的某个项目目录作为工作区打开。之后它就可以检查仓库、做有限范围的修改、运行命令,并向你展示变更内容。 + +DevSpace 为 ChatGPT 提供了这些能力: + +- 读取、写入和编辑已打开工作区内的文件 +- 搜索代码并查看目录结构 +- 运行测试、构建、Git 和包管理脚本相关命令 +- 使用隔离的 Git worktree 并行处理多个编码会话 +- 遵循项目里的 `AGENTS.md` 和 `CLAUDE.md` 指令 +- 从你的技能目录中发现本地 agent skills +- 在兼容 ChatGPT Apps 的宿主中显示工具卡片和可选的变更摘要 + +DevSpace 内置一组稳定的工作流与工程技能,用于 Plan、Goal、跨会话恢复、架构审查和 Skill 编写。 + +项目技能目录按用途拆分为: + +- `skills/.system`:DevSpace 自己维护的 5 个系统技能:`plan`、`goal`、`workflow`、`architecture-review`、`skill-authoring` +- `skills/local`:你希望随项目版本控制保存的项目自定义技能 +- `skills/installed`:按需安装的外部技能,默认被 git 忽略 + +网页版 ChatGPT Plus 不能原生安装或注册 Codex Skills。DevSpace 改为在 MCP 这一侧提供技能安装、发现和解析这一层能力。 + +`@devspace /plan` 和 `@devspace /goal` 只是别名风格的工作流约定,不是 ChatGPT 原生斜杠命令。`/plan` 固定对应系统 `plan`,`/goal` 固定对应系统 `goal`;本地、已安装和全局技能都不能覆盖它们。 + +## 如何使用 `/plan` 和 `/goal` + +DevSpace 已连接后,在普通 ChatGPT 消息中使用这两个别名即可。它们不是 ChatGPT 界面原生注册的斜杠命令。先打开工作区,再清晰说明目标。 + +### `/plan`:先分析,再保存实施计划 + +需要在改代码前先梳理现状、设计实施步骤时使用 `/plan`。DevSpace 会优先读取已有 Plan,进入 Plan Mode,以只读方式检查项目,然后保存包含步骤、验证方式、风险和 revision 的持久 Plan。 + +```text +@devspace 打开 /path/to/project。 + +/plan 为项目增加一个 hello CLI 命令,输出 "Hello DevSpace"。 +先检查项目,再创建并保存 Plan。 +不要修改项目文件,也不要执行会写入仓库的命令。 +``` + +一个好的 `/plan` 请求应说明目标、关键约束,以及是否需要等待你确认后再实施。之后想查看 Plan 时,重新打开同一个工作区,并要求 DevSpace 先读取当前 Plan。 + +```text +@devspace 打开 /path/to/project。 + +读取当前 Plan,告诉我 title、revision、未完成步骤、validation 和 blocker;不要修改文件。 +``` + +### `/goal`:跨会话保存长期目标 + +需要跨会话持续推进一个结果时使用 `/goal`。Goal 会保存目标、范围、成功标准、验证方式、停止条件、当前状态,以及有可靠依据时的精确指标。DevSpace 会先读取当前活跃 Goal,不会静默用新 Goal 覆盖旧 Goal。 + +```text +@devspace 打开 /path/to/project。 + +/goal 创建一个持久 Goal:为项目增加 hello CLI 命令。 +成功标准:命令可运行并输出 "Hello DevSpace"。 +验证方式:运行命令和对应自动化测试。 +停止条件:项目需求改为非 CLI 交互。 +暂时不要修改文件。 +``` + +你可以显式开始或暂停 Goal 工作计时,也可以在任务受阻、完成或不再继续时更新 Goal 状态。 + +```text +@devspace 开始当前 Goal 的工作计时。 + +@devspace 暂停当前 Goal 的工作计时,并返回已测量的工作时长。 +``` + +### 配合使用 + +先用 Goal 保存长期目标,再创建 Plan 把目标拆成可执行步骤,并明确把该 Plan 绑定到 Goal。Goal 进度只根据已绑定 Plan 中已完成的步骤计算,DevSpace 不会猜测百分比。Provider Token 指标也只在 API/Provider 返回真实 token usage 和稳定 request ID 时记录,ChatGPT 网页版用量不会自动填充。 + +```text +@devspace 为当前 Goal 创建 Plan,显式绑定到这个 Goal,并保存 Plan;不要修改文件。 +``` + +用这些命令管理已安装技能: + +```bash +# 为当前上下文安装 skill +devspace skills install --repo openai/skills --path skills/.curated/research + +# 只为某个工作区安装 skill +devspace skills install --workspace /path/to/project --repo openai/skills --path skills/.curated/research + +# 查看当前上下文可用的 skill +devspace skills list + +# 查看指定工作区的 skill +devspace skills list --workspace /path/to/project + +# 从当前上下文移除 skill +devspace skills remove research + +# 从指定工作区移除 skill +devspace skills remove --workspace /path/to/project research + +# 安装全局 skill +devspace skills install -g --repo openai/skills --path skills/.curated/research + +# 查看全局 skill 列表 +devspace skills list -g + +# 删除全局 skill +devspace skills remove -g research +``` + +`--repo/--path` 和 `--local-path` 必须直接指向一个标准技能目录,并且其中包含 `SKILL.md`。仓库根目录、插件根目录、命令目录和 agent-rules 目录都会被拒绝。 + +## 心智模型 + +DevSpace 本质上是对选定本地目录的远程访问。 + +你来决定哪些根目录被允许访问。MCP 客户端在已打开工作区内仍然具备很强的本地能力,包括执行 shell 命令。因此,你应该把一个已连接的客户端视为一位受信任的编程协作者,它能够访问你的机器。 + +对于一次普通的 ChatGPT 编程会话: + +1. 启动你的隧道。 +2. 运行 `devspace serve`。 +3. 把 MCP 客户端连接到你的公网 `/mcp` URL。 +4. 使用 Owner 密码批准连接。 +5. 让 ChatGPT 在你的某个允许根目录内打开项目。 + +## 平台支持 + +DevSpace 支持 Linux、macOS、Windows 环境下带 Bash 兼容 shell 的主 CLI,并支持在 macOS、Linux、Windows 和 WSL 上进行原生的按用户服务控制。 + +| 平台 | 状态 | 说明 | +| --- | --- | --- | +| Linux | 支持 | 需要 Node、npm、Git 和 Bash。 | +| macOS | 支持 | 需要 Node、npm、Git 和 Bash。 | +| Windows with Git Bash, WSL, MSYS2, or Cygwin Bash | 支持 | 原生 Windows 环境下最简单的是 Git Bash。 | +| Windows PowerShell or `cmd.exe` only | 暂不支持 | 请安装 Git Bash 或使用 WSL。 | + +你可以运行下面的命令检查本地环境: + +```bash +devspace doctor +``` + +## 文档 + +- [安装指南](docs/setup.md) +- [ChatGPT 编码工作流](docs/chatgpt-coding-workflow.md) +- [配置参考](docs/configuration.md) +- [安全模型](docs/security.md) +- [常见问题与排障](docs/gotchas.md) + +## 理念 + +每一类软件都正在变得可对话。自然语言正在重新定义我们与工具、工作流和系统交互的方式。 + +我的判断是,ChatGPT 会成为一切的操作系统。一旦抵达 AGI,我们大概只需要和 ChatGPT 对话,它就会替我们提示、协调、编排子代理,并搭建合适的执行闭环。 + +但现在还没到那一步。 + +DevSpace 是一次试图把那个未来往前拉近的尝试:让像 ChatGPT、Claude 这样支持 MCP 的宿主,可以通过显式、可检查的工具,直接操作本地项目文件。 + +## Built by Waishnav + +我是 Waishnav,[GitCMS](https://gitcms.dev/) 的创建者。GitCMS 是一个面向 Markdown 网站、基于 Git 的 CMS。 + +我喜欢做带有明确产品判断的工具,而 DevSpace 也是这样的产品之一。我正在尝试建立一家由单人运营、能做到数百万营收的公司。如果你想围观其中的失败、胜利、经验和过程,欢迎在 [X](https://x.com/wshxnv) 上关注我。 + +## 本地开发 + +如果你要开发 DevSpace 自身,可以使用: + +```bash +npm install --include=dev +npm run dev +npm run typecheck +npm test +npm run build +npm run start +``` diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index c5efc66..41caf4c 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -14,8 +14,7 @@ ChatGPT should call `open_workspace` once for a project folder: } ``` -The result includes a `workspaceId`. All later file, search, edit, show-changes, -and shell calls should reuse that same `workspaceId`. +The result includes a `workspaceId` and a compact `workflowDigest`. All later file, search, edit, show-changes, shell, Skill, Plan, and Goal calls should reuse that same `workspaceId`. Do not reopen the same folder unless: @@ -79,22 +78,69 @@ new context during later tool calls. Skills are enabled by default for coding-agent workflows. -DevSpace discovers skills from: +DevSpace discovers Skills from: +- five DevSpace system Skills in `skills/.system`: `plan`, `goal`, `workflow`, `architecture-review`, and `skill-authoring` +- 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 -advertised `SKILL.md` before following that skill. +ChatGPT Plus on the web cannot natively install or register Codex Skills. In this setup, DevSpace provides MCP-based skill installation, discovery, and resolution. -Skill paths may be outside the workspace. DevSpace only permits reading: +`@devspace /plan` and `@devspace /goal` are workflow aliases, not native ChatGPT slash commands. `/plan` always resolves to system `plan`; `/goal` always resolves to system `goal`. Project-local, installed, and global Skills cannot silently override either alias. -- advertised `SKILL.md` files -- files under a skill directory after that skill's `SKILL.md` has been read +User-installed project skills can be managed through DevSpace itself: + +```text +请使用 DevSpace 打开当前项目,然后调用 install_skill,把 GitHub 仓库 openai/skills 里的 skills/.curated/research 安装到当前 workspace。 +``` + +```text +请注意 install_skill 只接受标准 skill 包目录。像仓库根目录、plugin 目录、commands 目录或 agent rules 目录都不应该安装,只有直接包含 SKILL.md 的 skill 目录才可以。 +``` + +```text +请调用 list_installed_skills,列出当前 workspace 的 installed skills。 +``` + +```text +请调用 remove_skill,删除当前 workspace 里名为 research 的 installed skill。 +``` + +```text +@devspace /plan 为跨平台服务管理增加 restart、status 和 logs 支持 +``` + +```text +@devspace /goal 将 DevSpace 的第三方 Skill 安装流程收敛为可测试、可回滚、跨平台兼容的实现 +``` + +`open_workspace` returns system and project Skill metadata only, capped at 24 entries, plus a source-count summary. Use `resolve_skill` to load the full `SKILL.md` once a Skill is selected. Use `search_skills` to discover additional local, installed, or global Skills without loading every Skill instruction into context. + +Skill resources use `skill://` locators. DevSpace only permits reading: + +- a resolved `SKILL.md` +- files under an activated Skill directory Set `DEVSPACE_SKILLS=0` to hide skills from workspace output. +DevSpace system Skills define the stable `/plan`, `/goal`, workflow recovery, and MCP Tool contracts. External Skills are installed only when needed and never control the core aliases. + +## Project Workflow Store + +DevSpace keeps only a small project-scoped workflow state. It is shared by every DevSpace session opened on the same canonical directory, while different project roots and different Git worktree roots stay isolated. + +`open_workspace` returns only `workflowDigest`, not Plan history, Goal history, chat transcripts, tool output, or shell logs. Load full state on demand: + +- `get_plan`: current Plan, step states, validation, risks, and revision +- `get_goal`: current Goal, criteria, verification, stop conditions, summary, and revision +- `get_workflow_history`: concise paginated status events; default 20, maximum 50 + +Create a Plan with `update_plan(expectedRevision=0, ...)`. For an existing Plan or Goal, first call `get_plan` or `get_goal`, then pass the returned `expectedRevision`. A conflict means another session updated state first; reload and merge rather than overwriting it. + +`plan` mode is a planning preference, not a permission boundary. It permits `update_plan` but should not perform project file changes until the user approves execution. + ## Tool Names Short names are the default: diff --git a/docs/configuration.md b/docs/configuration.md index 7107338..341a560 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -22,10 +22,92 @@ DEVSPACE_CONFIG_DIR=/path/to/config npx @waishnav/devspace serve npx @waishnav/devspace init npx @waishnav/devspace serve npx @waishnav/devspace doctor +npx @waishnav/devspace config show +npx @waishnav/devspace config port 7676 +npx @waishnav/devspace config host 127.0.0.1 +npx @waishnav/devspace config domain devspace.example.com +npx @waishnav/devspace config key +npx @waishnav/devspace workspace add ~/workspace/project-a --default +npx @waishnav/devspace workspace list +npx @waishnav/devspace service start +npx @waishnav/devspace service status +npx @waishnav/devspace service logs npx @waishnav/devspace config get -npx @waishnav/devspace config set publicBaseUrl https://devspace.example.com ``` +## Configuration Management + +The primary config commands are: + +```bash +devspace config show +devspace config port 7676 +devspace config host 127.0.0.1 +devspace config domain devspace.example.com +devspace config key +``` + +`config port`, `config host`, `config domain`, and `config key` save the new +value immediately. If a managed DevSpace background service is currently +running, DevSpace automatically restarts it. + +`config key` rotates the existing Owner password stored in `auth.json`, +invalidates saved OAuth approvals and tokens, and requires clients to +reauthorize. + +`config show` reports the effective runtime values. Access keys are always +masked. If the active Owner password comes from `DEVSPACE_OAUTH_OWNER_TOKEN`, +DevSpace masks and shows that effective value. + +## Workspace Management + +Persisted workspace roots replace the old one-shot “roots only at init” flow: + +```bash +devspace workspace add ~/workspace/project-a --default +devspace workspace add ~/workspace/project-b +devspace workspace list +devspace workspace remove ~/workspace/project-a +devspace workspace clear-default +``` + +Use temporary workspace overrides for one run: + +```bash +devspace serve --add-dir ~/scratch/project-c --workspace ~/workspace/project-b +``` + +These workspace paths define the authorization boundary for DevSpace file tools. +If no workspace is configured and `DEVSPACE_ALLOWED_ROOTS` is unset, DevSpace +starts in a safe blocked state with no authorized workspace roots. + +## Service Management + +DevSpace only manages its own background service. `devspace service start` +installs the service on first use for the current platform, and starts it on +later runs: + +```bash +devspace service start +devspace service status +devspace service restart +devspace service stop +devspace service disable +devspace service remove +devspace service logs +devspace service doctor +``` + +Platform behavior: + +- Linux and Ubuntu use `systemctl --user` when user systemd is available. +- macOS uses a per-user LaunchAgent. +- Windows uses Task Scheduler. +- WSL uses user systemd when available and otherwise reports a Task Scheduler fallback. + +DevSpace never auto-configures DNS, reverse proxies, TLS certificates, or +firewall rules. + ## Core Environment Variables | Variable | Purpose | @@ -34,10 +116,17 @@ 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_MCP_PATH` | Optional MCP path override. Defaults to `/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`. | | `DEVSPACE_STATE_DIR` | Directory for SQLite state. Defaults to `~/.local/share/devspace`. | +| `DEVSPACE_SESSION_WORKSPACE` | Temporary default workspace for the current `serve` run. | + +When `DEVSPACE_ALLOWED_ROOTS` is omitted, DevSpace does not fall back to the +current working directory anymore. You must explicitly configure allowed roots +through `devspace workspace add ...` or this environment variable. ## OAuth @@ -49,6 +138,13 @@ 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, token hashes, authorization code hashes, and approved +consents are persisted in SQLite at `$DEVSPACE_STATE_DIR/devspace.sqlite` by +default. `DEVSPACE_OAUTH_STATE_PATH` is kept as the legacy JSON state import +path; when an existing JSON file is present, DevSpace imports compatible clients, +token hashes, and consents into SQLite without storing raw tokens. MCP clients discover metadata from: @@ -73,6 +169,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. @@ -91,6 +211,31 @@ MCP clients discover metadata from: | `DEVSPACE_AGENT_DIR` | Defaults to `~/.codex`. | | `DEVSPACE_SKILL_PATHS` | Optional comma-separated skill directories. | +Project skill layout: + +- system built-in DevSpace skills +- `skills/local`: project skills meant to be committed +- `skills/installed`: user-installed project skills, typically git-ignored + +ChatGPT Plus on the web cannot natively install or register Codex Skills. DevSpace provides the MCP-side skill installation, discovery, and resolution layer instead. + +Manage installed skills with: + +```bash +devspace skills install --repo openai/skills --path skills/.curated/research +devspace skills install --workspace /path/to/project --repo openai/skills --path skills/.curated/research +devspace skills list +devspace skills list --workspace /path/to/project +devspace skills remove research +devspace skills remove --workspace /path/to/project research + +devspace skills install -g --repo openai/skills --path skills/.curated/research +devspace skills list -g +devspace skills remove -g research +``` + +Both `--repo/--path` and `--local-path` must point directly at one standard skill directory with `SKILL.md`. Plugin roots, command folders, and agent-rules directories are not valid install targets. + Example: ```bash diff --git a/docs/gotchas.md b/docs/gotchas.md index c5099dc..6b7fd91 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -196,9 +196,17 @@ DEVSPACE_SKILLS=1 npx @waishnav/devspace serve DevSpace looks in: - `DEVSPACE_AGENT_DIR`, defaulting to `~/.codex` -- project `.pi/skills` +- `skills/local` +- `skills/installed` +- system built-in skills - `DEVSPACE_SKILL_PATHS` +Recommended meaning: + +- system built-in DevSpace skills +- `skills/local`: project-defined skills you want to commit +- `skills/installed`: user-installed project skills that should stay git-ignored + If a skill appears in `open_workspace`, the model must read that skill's `SKILL.md` before reading other files inside the skill directory. 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 acb8f63..c026ae1 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", @@ -571,19 +572,19 @@ "typebox": "1.1.38" }, "bin": { - "pi-ai": "dist/cli.js" + "pi-ai": "./dist/cli.js" }, "engines": { "node": ">=22.19.0" } }, "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 cdac9c1..c4e8adf 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "dist", "docs", "scripts", + "skills", "README.md" ], "publishConfig": { @@ -25,7 +26,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 && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", + "test": "node scripts/dev-server.test.mjs && node --import tsx src/config.test.ts && node --import tsx src/config-operations.test.ts && node --import tsx src/oauth-provider.test.ts && node --import tsx src/oauth-store.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/skill-manager.test.ts && node --import tsx src/cli-skills.test.ts && node --import tsx src/cli.test.ts && node --import tsx src/goal-definition.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/workflow-store.test.ts && node --import tsx src/workflow-migration.test.ts && node --import tsx src/package-smoke.test.ts && node --import tsx src/review-checkpoints.test.ts && node --import tsx src/service.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], @@ -33,7 +34,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 +60,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/.system/README.md b/skills/.system/README.md new file mode 100644 index 0000000..40c98f3 --- /dev/null +++ b/skills/.system/README.md @@ -0,0 +1,42 @@ +# DevSpace System Skills + +This directory contains DevSpace-owned system Skills only. + +## Entries + +| Directory | Skill | Purpose | +|---|---|---| +| `plan/` | `plan` | `/plan`, durable Plans, steps, validation, and conflicts | +| `goal/` | `goal` | `/goal`, Goal lifecycle, metrics, and conflicts | +| `workflow/` | `workflow` | recovery, modes, isolation, routing, and history | +| `architecture-review/` | `architecture-review` | evidence-based architecture review | +| `skill-authoring/` | `skill-authoring` | Skill structure and quality rules | + +## Policy + +- `/plan` always resolves to `plan`. +- `/goal` always resolves to `goal`. +- The five Skill names above are reserved system names. +- Project-local, installed, and global Skills cannot override reserved names or aliases. +- External Skills belong in `skills/installed/`, not in `.system`. +- Old system Skill identifiers are not supported; use the names listed in the table above. +- System Skill frontmatter and the change-log Version column track the root `package.json` version. Update them in the same release commit. + +## OpenAI Skills upstream record + +| Field | Value | +|---|---| +| Upstream repository | `https://github.com/openai/skills.git` | +| Upstream Git commit | `972cb867affac58fda9afa76bb1a19b399a278cf` | +| Last sync check (UTC) | `2026-06-21T23:57:02Z` | +| Sync policy | DevSpace does not mirror the full upstream repository into `.system`; external Skills are installed individually into `skills/installed/`. | + +## Change log + +| Date | Version | Change | +|---|---:|---| +| 2026-06-22 | 1.0.1 | `create-plan` and `devspace-plan` merged into `plan` | +| 2026-06-22 | 1.0.1 | `define-goal` and `devspace-goal` merged into `goal` | +| 2026-06-22 | 1.0.1 | `devspace-workflow` renamed to `workflow` | +| 2026-06-22 | 1.0.1 | architecture and authoring Skills consolidated; `-lite` copies removed | +| 2026-06-22 | 1.0.1 | full OpenAI Skill mirror removed from the package | diff --git a/skills/.system/architecture-review/SKILL.md b/skills/.system/architecture-review/SKILL.md new file mode 100644 index 0000000..74215f2 --- /dev/null +++ b/skills/.system/architecture-review/SKILL.md @@ -0,0 +1,34 @@ +--- +name: architecture-review +description: Perform evidence-driven architecture review for a DevSpace workspace without bypassing project instructions, tests, workflow state, or authorization boundaries. +license: MIT +metadata: + version: 1.0.2 + author: DevSpace + category: system-engineering + updated: 2026-06-22 +--- + +# Architecture Review + +Use this Skill for decisions spanning modules, persistent state, compatibility, security boundaries, migrations, rollout risk, or operational recovery. + +## Method + +1. Read `AGENTS.md`, relevant entry points, schema, public interfaces, tests, configuration, and deployment paths before making claims. +2. Separate observed facts from assumptions and unresolved questions. +3. Prefer the smallest compatible change that preserves migration safety and authorization boundaries. +4. Evaluate ownership, lifecycle, concurrency, failure recovery, backwards compatibility, observability, rollout, and rollback. +5. When a durable implementation plan is needed, resolve `/plan` and persist a verified Plan. + +## Output + +State: + +- constraints and evidence; +- recommended boundary and approach; +- rejected alternatives and why; +- migration, security, and rollback effects; +- tests that prove the decision. + +Do not produce generic architecture slogans or introduce a subsystem without identifying its code boundary and operational cost. diff --git a/skills/.system/architecture-review/references/decision-guide.md b/skills/.system/architecture-review/references/decision-guide.md new file mode 100644 index 0000000..5caf613 --- /dev/null +++ b/skills/.system/architecture-review/references/decision-guide.md @@ -0,0 +1,19 @@ +# Architecture Decision Guide + +Before recommending a change, establish the minimum evidence: + +- current public API, command, or tool contract; +- data schema, migration behavior, and persistence ownership; +- relevant tests and failure behavior; +- deployment, operator, and authorization boundaries; +- compatibility expectations for existing clients and stored data. + +Ask: + +1. What concrete user or operator failure does the change solve? +2. Which module owns the behavior and lifecycle? +3. What happens during partial failure, restart, retry, or concurrent access? +4. Which callers, stored records, or deployment paths can break? +5. How is the change verified and rolled back? + +Prefer a narrow adapter or migration over a new framework when an existing boundary already fits the requirement. \ No newline at end of file diff --git a/skills/.system/goal/SKILL.md b/skills/.system/goal/SKILL.md new file mode 100644 index 0000000..441705d --- /dev/null +++ b/skills/.system/goal/SKILL.md @@ -0,0 +1,36 @@ +--- +name: goal +description: Define and maintain a durable, verifiable project Goal in DevSpace. Use for /goal when the user explicitly wants an outcome to persist across sessions. +license: MIT +metadata: + version: 1.0.2 + author: DevSpace + category: system-workflow + updated: 2026-06-22 +--- + +# DevSpace Goal + +Use this Skill for `/goal` or when the user explicitly requests a durable cross-session objective. Do not create a Goal for every ordinary coding request. + +## Required lifecycle + +1. Call `get_goal` first. +2. With no active Goal, create one with a concrete objective, scope, success criteria, verification, stop conditions, and concise current summary. +3. With a matching active Goal, continue it and update only fields that changed. +4. With a conflicting active Goal, ask the user whether to archive it, complete it, block it, or keep it. Never silently replace an active Goal. +5. Use the revision returned by `get_goal` as `expectedRevision` on every update. Reload before merging a revision conflict. +6. Start and pause measured work with `start_goal_work` and `pause_goal_work`. +7. Record tokens only with `record_goal_token_usage` when an upstream API or provider returned exact usage plus a stable request ID. +8. Link the current Plan through `update_plan(goalId=...)` only when the Plan is the authoritative breakdown for Goal progress. + +Read [references/state.md](references/state.md), [references/metrics.md](references/metrics.md), and [references/conflicts.md](references/conflicts.md) before acting on Goal state. + +## Status + +- `active`: the outcome can proceed. +- `blocked`: a specific decision, dependency, or permission is missing. +- `completed`: success criteria have been verified. +- `archived`: no longer current; history remains available. + +`currentSummary` contains only completed work, current work, and real blockers. Never put chat transcripts, raw tool output, file snapshots, or secrets into Goal state. diff --git a/skills/.system/goal/references/conflicts.md b/skills/.system/goal/references/conflicts.md new file mode 100644 index 0000000..d7f593b --- /dev/null +++ b/skills/.system/goal/references/conflicts.md @@ -0,0 +1,18 @@ +# Goal Conflicts + +A Goal conflicts when the requested objective, scope, or acceptance criteria would direct the project toward a different outcome than the active Goal. + +## Required user choice + +Show the current Goal and requested Goal briefly, then ask whether to: + +1. archive the current Goal and create the requested Goal; +2. complete the current Goal after verification; +3. mark the current Goal blocked with a concrete reason; or +4. keep the current Goal and treat the request as ordinary work. + +Do not create competing active Goals and do not silently replace one. + +## Revision conflict + +A revision conflict means another session changed the Goal after it was read. Call `get_goal`, preserve valid changes, and update once with the refreshed revision. \ No newline at end of file diff --git a/skills/.system/goal/references/metrics.md b/skills/.system/goal/references/metrics.md new file mode 100644 index 0000000..378c989 --- /dev/null +++ b/skills/.system/goal/references/metrics.md @@ -0,0 +1,25 @@ +# Exact Goal Metrics + +Goal metrics are recorded only under explicit evidence rules. + +## Provider tokens + +Use `record_goal_token_usage` only with exact counts returned by a model provider or API and a stable provider request ID. Usage is append-only and deduplicated by `provider + providerRequestId`. + +Never estimate tokens from text length, message bytes, context limits, model names, elapsed time, or intuition. + +## Work duration + +Call `start_goal_work` when measured work begins. Call `pause_goal_work` before waiting for approval, changing tasks, or stopping. DevSpace persists exact server wall-clock milliseconds only while this timer is running. A Goal transition out of `active` pauses a running timer automatically. + +This is an explicit timer interval, not a claim about hidden model reasoning or user attention. + +## Percentage progress + +Set the current Plan `goalId` to this Goal ID only when that Plan is the authoritative work breakdown. Progress then uses completed Plan steps: + +- canonical fraction: `completedSteps/totalSteps`; +- exact rational percentage: `percentageNumerator/percentageDenominator`; +- `displayPercent`: rounded human display only. + +Without a linked current Plan, percentage progress is unavailable rather than guessed. \ No newline at end of file diff --git a/skills/.system/goal/references/state.md b/skills/.system/goal/references/state.md new file mode 100644 index 0000000..e0688f8 --- /dev/null +++ b/skills/.system/goal/references/state.md @@ -0,0 +1,17 @@ +# Goal State + +A Goal is durable project-scoped state. It is shared across sessions for the same canonical project root and isolated from other projects and Git worktrees. + +## Fields + +- `objective`: concrete user-visible outcome. +- `scope.in` / `scope.out`: boundaries. +- `successCriteria`: observable completion requirements. +- `verification`: tests, builds, review steps, or manual checks. +- `stopConditions`: conditions that justify pausing, escalating, or stopping. +- `currentSummary`: compact completed/current/blocked record. +- `status`: `active`, `blocked`, `completed`, `archived`. +- `revision`: optimistic-concurrency version. +- `metrics`: exact token, duration, and Plan-progress data only where evidence exists. + +`create_goal` refuses to create a competing active Goal. After a Goal becomes blocked, completed, or archived, a new active Goal can be created deliberately. \ No newline at end of file diff --git a/skills/.system/plan/SKILL.md b/skills/.system/plan/SKILL.md new file mode 100644 index 0000000..90006a5 --- /dev/null +++ b/skills/.system/plan/SKILL.md @@ -0,0 +1,55 @@ +--- +name: plan +description: Create, resume, and maintain a durable DevSpace implementation Plan for the current project. Use for /plan and for requests that require read-only analysis before code changes. +license: MIT +metadata: + version: 1.0.2 + author: DevSpace + category: system-workflow + updated: 2026-06-22 +--- + +# DevSpace Plan + +Use this Skill for `/plan`, explicit implementation planning, or a task that should be analyzed before files are modified. + +## Required lifecycle + +1. Call `get_plan` first. Reuse a matching current Plan instead of silently replacing it. +2. Set collaboration mode to `plan` for the planning pass. +3. Read project instructions, source, tests, configuration, public interfaces, and migration paths. Planning is read-only: do not edit project files or claim implementation is complete. +4. Ask with `request_user_input` only when an unresolved decision changes scope, compatibility, architecture, safety, or rollout. +5. Produce one finite Plan with scope, ordered actions, validation, and risks. +6. Persist it with `update_plan`: + - use `expectedRevision=0` only when no current Plan exists; + - otherwise use the revision returned by `get_plan`; + - on a revision conflict, reload and merge instead of retrying stale content. + +Read [references/state.md](references/state.md) for Plan state and [references/conflicts.md](references/conflicts.md) before resolving concurrent changes. + +## Output contract + +```markdown +# Plan + +## Goal + + +## Existing state + + +## Scope +- In: ... +- Out: ... + +## Action items +- [ ] + +## Validation +- + +## Risks / rollback +- +``` + +The user-facing Plan must match the persisted Plan. Do not include token budgets, guessed time estimates, or invented percentage progress. diff --git a/skills/.system/plan/references/conflicts.md b/skills/.system/plan/references/conflicts.md new file mode 100644 index 0000000..75e30eb --- /dev/null +++ b/skills/.system/plan/references/conflicts.md @@ -0,0 +1,14 @@ +# Plan Revision Conflicts + +Multiple sessions can open the same project. DevSpace uses the Plan `revision` to prevent one session from silently erasing another session's work. + +## Resolution procedure + +1. A Plan update returns a revision conflict. +2. Stop; do not resend the stale Plan payload. +3. Call `get_plan` again. +4. Compare changed steps, notes, validation, risks, and status. +5. Preserve completed work and real blockers from the current Plan. +6. Submit one merged complete Plan with the refreshed `expectedRevision`. + +Do not use a conflict to justify overwriting a Plan just because a previous model response was longer or newer in chat history. \ No newline at end of file diff --git a/skills/.system/plan/references/output.md b/skills/.system/plan/references/output.md new file mode 100644 index 0000000..4f125a2 --- /dev/null +++ b/skills/.system/plan/references/output.md @@ -0,0 +1,11 @@ +# Plan Quality Checklist + +Before persisting a Plan, verify: + +- Existing-state statements are backed by inspected files, tests, or configuration. +- Scope identifies likely exclusions. +- Every action item is observable and names a real behavior, module, interface, or test target. +- Validation is executable or manually verifiable. +- Risks are concrete and include rollback or mitigation when appropriate. +- The Plan does not promise edits while Plan Mode is active. +- The Plan can be resumed by a future session without reconstructing the whole conversation. diff --git a/skills/.system/plan/references/state.md b/skills/.system/plan/references/state.md new file mode 100644 index 0000000..6f8792a --- /dev/null +++ b/skills/.system/plan/references/state.md @@ -0,0 +1,28 @@ +# Plan State + +A Plan is durable project-scoped state. It is shared by new DevSpace sessions opened on the same canonical project root and isolated from different projects and Git worktree roots. + +## Fields + +- `title`: concise work name. +- `summary`: current evidence and decision record. +- `scope.in` / `scope.out`: explicit boundaries. +- `steps`: ordered executable work. +- `validation`: proof required before completion. +- `risks`: real rollback, security, migration, or compatibility concerns. +- `status`: `draft`, `active`, `completed`, `archived`. +- `revision`: optimistic-concurrency version. + +## Step states + +- `pending`: not started. +- `in_progress`: the one active step. +- `blocked`: cannot proceed; include a short note explaining the decision or dependency needed. +- `completed`: verified done. +- `skipped`: intentionally not performed; include a reason in the note. + +A full step list is sent on every `update_plan`. At most one step can be `in_progress`. + +## Linking a Goal + +Set `goalId` on the current Plan only when that Plan is the authoritative work breakdown for a Goal. Goal percentage progress is then derived from completed Plan steps; it is unavailable when no current Plan is linked. diff --git a/skills/.system/skill-authoring/SKILL.md b/skills/.system/skill-authoring/SKILL.md new file mode 100644 index 0000000..711eb7d --- /dev/null +++ b/skills/.system/skill-authoring/SKILL.md @@ -0,0 +1,34 @@ +--- +name: skill-authoring +description: Create or revise DevSpace Skills with clear workflows, stable system routing, controlled resource access, and test coverage. +license: MIT +metadata: + version: 1.0.2 + author: DevSpace + category: system-engineering + updated: 2026-06-22 +--- + +# Skill Authoring + +Use this Skill to create or revise a Skill bundled with DevSpace or stored in a project. + +## Requirements + +- Every Skill has valid frontmatter with an accurate `name` and `description`. +- Instructions describe a concrete lifecycle: when the Skill applies, what evidence to inspect, which tools to use, safety boundaries, validation, and recovery behavior. +- Put detailed contracts and examples in `references/`; do not make `SKILL.md` a one-line persona prompt or an unbounded manual. +- A Skill never grants automatic execution of scripts, shell commands, Git, service operations, file writes, network access, or credentials. +- Use `resolve_skill` to activate a Skill before reading its `skill://` resources. + +## Directory policy + +```text +skills/.system/ DevSpace system Skills only +skills/local/ repository-maintained project Skills +skills/installed/ externally installed project Skills +``` + +System Skill names are reserved: `plan`, `goal`, `workflow`, `architecture-review`, and `skill-authoring`. `/plan` and `/goal` are fixed system aliases and cannot be overridden. + +Read [references/structure-checklist.md](references/structure-checklist.md) before accepting a Skill change. diff --git a/skills/.system/skill-authoring/references/structure-checklist.md b/skills/.system/skill-authoring/references/structure-checklist.md new file mode 100644 index 0000000..be21ce4 --- /dev/null +++ b/skills/.system/skill-authoring/references/structure-checklist.md @@ -0,0 +1,14 @@ +# Skill Structure Checklist + +Before accepting a Skill change, verify: + +- frontmatter has a stable name and accurate description; +- the Skill says when it applies and when it does not; +- required tool calls and their order are explicit; +- file, shell, Git, network, and credential boundaries are explicit; +- supporting procedures live in `references/` rather than bloating the main Skill; +- reserved system names and `/plan` / `/goal` aliases are not overridden; +- the Skill has a recovery path for missing state or revision conflict when relevant; +- discovery, resolution, resource access, packaging, and any described tool contracts have test coverage. + +A Skill must guide a dependable workflow, not merely ask the model to adopt a tone or role. \ No newline at end of file diff --git a/skills/.system/workflow/SKILL.md b/skills/.system/workflow/SKILL.md new file mode 100644 index 0000000..e0b9b3f --- /dev/null +++ b/skills/.system/workflow/SKILL.md @@ -0,0 +1,41 @@ +--- +name: workflow +description: Recover and coordinate DevSpace project workflow state across sessions, including Plan, Goal, mode, routing, and concise history. +license: MIT +metadata: + version: 1.0.2 + author: DevSpace + category: system-workflow + updated: 2026-06-22 +--- + +# DevSpace Workflow + +Use this Skill when a request depends on cross-session recovery, Plan Mode, workspace isolation, workflow history, or Skill routing. + +## Resume sequence + +After `open_workspace`, inspect `workflowDigest` first. + +- No active state: work normally; create a Plan or Goal only when the task calls for durable state. +- Existing Plan or Goal: call `get_plan` or `get_goal` only when the full definition matters. +- Relevant earlier decision: call paginated `get_workflow_history`; do not load history by default. +- A matching request can resume state. An incompatible Goal follows the Goal conflict procedure. + +## Isolation + +- Same canonical project root: shared across sessions and DevSpace restarts. +- Different project root: isolated. +- Different Git worktree root: isolated by default. +- `workspaceId` is only a session handle, never durable Plan or Goal identity. + +## Modes + +`plan` is a workflow preference, not a security boundary. + +- `plan`: inspect, ask material questions, write or revise the Plan, then wait for implementation approval. +- `default`: perform approved changes, test them, and maintain relevant state. + +Plan Mode does not grant filesystem, shell, Git, network, credential, or service-management permission. + +Read [references/routing.md](references/routing.md), [references/recovery.md](references/recovery.md), and [references/mode.md](references/mode.md) for details. diff --git a/skills/.system/workflow/references/mode.md b/skills/.system/workflow/references/mode.md new file mode 100644 index 0000000..8790aa1 --- /dev/null +++ b/skills/.system/workflow/references/mode.md @@ -0,0 +1,13 @@ +# Collaboration Mode + +## Plan mode + +Plan Mode is for repository inspection, material clarification, and durable Plan updates. `update_plan` is allowed. Project source edits, shell mutations, Git mutations, and implementation claims should wait for user approval or a return to default mode. + +## Default mode + +Default mode permits approved work through the existing DevSpace authorization boundaries. Keep a relevant Plan or Goal accurate when work changes its steps, verification evidence, blocker, or status. + +## History + +Workflow events are concise structured records such as `plan.updated`, `goal.blocked`, and `mode.changed`. They are not chat history and must not contain raw tool output, full diffs, logs, credentials, or source snapshots. \ No newline at end of file diff --git a/skills/.system/workflow/references/recovery.md b/skills/.system/workflow/references/recovery.md new file mode 100644 index 0000000..429da2d --- /dev/null +++ b/skills/.system/workflow/references/recovery.md @@ -0,0 +1,10 @@ +# Workflow Recovery + +1. Call `open_workspace` once for a project root or worktree. +2. Read `workflowDigest`. +3. Load `get_plan` only when the requested work needs Plan steps, risks, validation, or revision. +4. Load `get_goal` only when the requested work needs Goal scope, criteria, verification, status, metrics, or revision. +5. Use `get_workflow_history` only for a specific past decision; use its cursor rather than requesting unbounded history. +6. Before updating a Plan or Goal, use the revision that was read. On conflict, reload and merge. + +Do not treat a digest as enough context to execute a historical Plan without reading it. Do not create a Goal merely because no Goal exists. \ No newline at end of file diff --git a/skills/.system/workflow/references/routing.md b/skills/.system/workflow/references/routing.md new file mode 100644 index 0000000..6760fa9 --- /dev/null +++ b/skills/.system/workflow/references/routing.md @@ -0,0 +1,14 @@ +# Workflow Routing + +`/plan` and `/goal` are DevSpace routing aliases, not native ChatGPT slash commands. + +```text +/plan -> skills/.system/plan +/goal -> skills/.system/goal +``` + +The aliases are fixed system routes. Local, installed, and global Skills cannot override them. + +Use `resolve_skill` to load a selected Skill. Use `search_skills` to discover optional project-local, installed, or global Skills without loading every instruction into context. + +Skill resources are accessed through `skill://` locators only after a Skill has been resolved. Do not expose server absolute paths in model-facing output. \ No newline at end of file diff --git a/src/cli-skills.test.ts b/src/cli-skills.test.ts new file mode 100644 index 0000000..ce403d0 --- /dev/null +++ b/src/cli-skills.test.ts @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = mkdtempSync(join(tmpdir(), "devspace-cli-skills-test-")); +const projectRoot = fileURLToPath(new URL("..", import.meta.url)); +const require = createRequire(import.meta.url); +const packageVersion = (require("../package.json") as { version: string }).version; + +try { + const help = execFileSync(process.execPath, ["--import", "tsx", "src/cli.ts", "help"], { + cwd: projectRoot, + encoding: "utf8", + }); + assert.match(help, /devspace skills install/); + assert.match(help, /devspace skills list -g/); + assert.match(help, /devspace skills remove -g/); + assert.match(help, /install expects the target path to point at one standard skill directory with a SKILL\.md file/); + + for (const flag of ["-v", "--version"]) { + const version = execFileSync(process.execPath, ["--import", "tsx", "src/cli.ts", flag], { + cwd: projectRoot, + encoding: "utf8", + env: { ...process.env, HOME: root }, + }).trim(); + assert.equal(version, packageVersion); + } +} finally { + rmSync(root, { recursive: true, force: true }); +} diff --git a/src/cli.test.ts b/src/cli.test.ts index 96dfd55..c3fb787 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1,6 +1,8 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; import { readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string; @@ -9,7 +11,7 @@ const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.me for (const flag of ["-v", "--version"]) { const output = execFileSync("node", ["--import", "tsx", "src/cli.ts", flag], { encoding: "utf8", - env: { ...process.env, DEVSPACE_CONFIG_DIR: "/tmp/devspace-cli-version-test" }, + env: { ...process.env, DEVSPACE_CONFIG_DIR: join(tmpdir(), "devspace-cli-version-test") }, }).trim(); assert.equal(output, packageJson.version); diff --git a/src/cli.ts b/src/cli.ts index 87ba662..156fa98 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,11 +1,25 @@ #!/usr/bin/env node import { createRequire } from "node:module"; import { stdin as input, stdout as output } from "node:process"; +import { fileURLToPath } from "node:url"; 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 { + addWorkspace, + buildConfigShowResult, + clearDefaultWorkspace, + listWorkspaces, + removeWorkspace, + resetConfigKey, + setConfigDomain, + setConfigHost, + setConfigPort, + setDefaultWorkspace, +} from "./config-operations.js"; import { generateOwnerToken, loadDevspaceFiles, @@ -14,10 +28,21 @@ import { type DevspaceUserConfig, } from "./user-config.js"; import { expandHomePath } from "./roots.js"; - -type Command = "serve" | "init" | "doctor" | "config" | "help" | "version"; +import { createServiceManager } from "./service/manager.js"; +import { + installSkill, + listInstalledSkills, + removeInstalledSkill, + resolveWorkspaceRoot, + type SkillInstallSource, + type SkillScope, +} from "./skill-manager.js"; + +type Command = "serve" | "service-run" | "init" | "doctor" | "config" | "workspace" | "service" | "skills" | "version" | "help"; const require = createRequire(import.meta.url); +const PACKAGE_VERSION = (require("../package.json") as { version: string }).version; const SUPPORTED_NODE_RANGE = ">=20.12 <27"; +const CLI_ENTRYPOINT = fileURLToPath(import.meta.url); async function main(argv: string[]): Promise { assertSupportedNode(); @@ -28,7 +53,10 @@ async function main(argv: string[]): Promise { switch (command) { case "serve": await ensureConfigured(); - await serve(); + await serve(args); + return; + case "service-run": + await serve([]); return; case "init": await runInit({ force: args.includes("--force") }); @@ -37,22 +65,31 @@ async function main(argv: string[]): Promise { await runDoctor(); return; case "config": - runConfigCommand(args); + await runConfigCommand(args); return; - case "help": - printHelp(); + case "workspace": + await runWorkspaceCommand(args); + return; + case "service": + await runServiceCommand(args); + return; + case "skills": + await runSkillsCommand(args); return; case "version": - printVersion(); + console.log(PACKAGE_VERSION); + return; + case "help": + printHelp(); return; } } function normalizeCommand(command: string | undefined): Command { if (!command || command === "serve" || command === "start") return "serve"; - if (command === "init" || command === "doctor" || command === "config") return command; + if (command === "service-run" || command === "init" || command === "doctor" || command === "config" || command === "workspace" || command === "service" || command === "skills") return command; + if (command === "--version" || command === "-v") return "version"; if (command === "help" || command === "--help" || command === "-h") return "help"; - if (command === "version" || command === "--version" || command === "-v") return "version"; throw new Error(`Unknown command: ${command}`); } @@ -144,8 +181,8 @@ async function runInit({ force }: { force: boolean }): Promise { const lines = [ `Config: ${configPath}`, `Auth: ${authPath}`, - `Local MCP URL: http://${config.host}:${config.port}/mcp`, - ...(publicBaseUrl ? [`Public MCP URL: ${publicBaseUrl}/mcp`] : []), + `Local MCP URL: http://${config.host}:${config.port}${config.server?.mcpPath ?? "/mcp"}`, + ...(publicBaseUrl ? [`Public MCP URL: ${new URL(config.server?.mcpPath ?? "/mcp", publicBaseUrl).toString()}`] : []), ]; prompts.note(lines.join("\n"), "DevSpace configured"); prompts.note( @@ -166,7 +203,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( @@ -180,22 +217,51 @@ async function serve(): Promise { ); } + const sessionArgs = extractServeArgs(args); + if (sessionArgs.additionalRoots.length > 0) { + process.env.DEVSPACE_ALLOWED_ROOTS = mergeAllowedRoots(sessionArgs.additionalRoots); + } + if (sessionArgs.workspace) { + process.env.DEVSPACE_SESSION_WORKSPACE = sessionArgs.workspace; + } + + 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.server?.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, close } = createServer(config); const httpServer = app.listen(config.port, config.host, () => { - console.log(`devspace listening on http://${config.host}:${config.port}/mcp`); + console.log(`devspace listening on http://${config.host}:${config.port}${config.mcpPath}`); console.log(`public base url: ${config.publicBaseUrl}`); - console.log(`allowed roots: ${config.allowedRoots.join(", ")}`); + console.log(`allowed roots: ${config.allowedRoots.join(", ") || "(none configured - workspace access will be denied until you add one)"}`); console.log(`allowed hosts: ${config.allowedHosts.join(", ")}`); 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(() => { close(); process.exit(0); @@ -219,16 +285,19 @@ async function runDoctor(): Promise { try { const config = loadConfig(); - console.log(`Local MCP URL: http://${config.host}:${config.port}/mcp`); - console.log(`Public MCP URL: ${new URL("/mcp", config.publicBaseUrl).toString()}`); - console.log(`Allowed roots: ${config.allowedRoots.join(", ")}`); + console.log(`Local MCP URL: http://${config.host}:${config.port}${config.mcpPath}`); + console.log(`Public MCP URL: ${new URL(config.mcpPath, config.publicBaseUrl).toString()}`); + console.log(`Allowed roots: ${config.allowedRoots.join(", ") || "(none configured)"}`); + if (config.allowedRoots.length === 0) { + console.log("Workspace access: blocked until you add a workspace with `devspace workspace add ` or set DEVSPACE_ALLOWED_ROOTS."); + } console.log(`Allowed hosts: ${config.allowedHosts.join(", ")}`); } catch (error) { console.log(`Config status: ${error instanceof Error ? error.message : String(error)}`); } } -function runConfigCommand(args: string[]): void { +async function runConfigCommand(args: string[]): Promise { const [subcommand, key, ...rest] = args; const files = loadDevspaceFiles(); @@ -237,6 +306,53 @@ function runConfigCommand(args: string[]): void { return; } + if (subcommand === "show") { + const show = await buildConfigShowResult(CLI_ENTRYPOINT); + if (args.includes("--json")) { + console.log(JSON.stringify(show, null, 2)); + return; + } + + console.log([ + `bind host: ${show.host}`, + `port: ${show.port}`, + `MCP path: ${show.mcpPath}`, + `public URL: ${show.publicUrl}`, + `workspaces: ${show.workspaces.join(", ") || "(none)"}`, + `default workspace: ${show.defaultWorkspace ?? "(none)"}`, + `service installed: ${show.serviceInstalled ? "yes" : "no"}`, + `service running: ${show.serviceRunning ? "yes" : "no"}`, + `platform: ${show.platform}`, + `service manager: ${show.serviceManager}`, + `access key: ${show.accessKey}`, + ].join("\n")); + return; + } + + if (subcommand === "port") { + const port = Number(key); + console.log(await setConfigPort(port, CLI_ENTRYPOINT)); + return; + } + + if (subcommand === "host") { + if (!key) throw new Error("Missing host value."); + console.log(await setConfigHost(key, CLI_ENTRYPOINT)); + return; + } + + if (subcommand === "domain") { + const value = [key, ...rest].join(" ").trim(); + if (!value) throw new Error("Missing domain or URL."); + console.log(await setConfigDomain(value, CLI_ENTRYPOINT)); + return; + } + + if (subcommand === "key") { + console.log(await resetConfigKey(CLI_ENTRYPOINT)); + return; + } + if (subcommand !== "set") { throw new Error(`Unknown config command: ${subcommand}`); } @@ -252,10 +368,180 @@ function runConfigCommand(args: string[]): void { writeDevspaceConfig({ ...files.config, publicBaseUrl: normalizeOptionalPublicBaseUrl(value), + server: { + ...(files.config.server ?? {}), + publicBaseUrl: normalizeOptionalPublicBaseUrl(value), + }, }); console.log(`Updated ${files.configPath}`); } +async function runWorkspaceCommand(args: string[]): Promise { + const [subcommand, ...restArgs] = args; + const flags = restArgs.filter((arg) => arg.startsWith("--")); + const positional = restArgs.filter((arg) => !arg.startsWith("--")); + const value = positional[0]; + switch (subcommand) { + case "add": + if (!value) throw new Error("Missing workspace path."); + console.log(await addWorkspace(value, { + create: flags.includes("--create"), + makeDefault: flags.includes("--default"), + })); + return; + case "list": { + const result = listWorkspaces(); + if (flags.includes("--json")) { + console.log(JSON.stringify(result, null, 2)); + return; + } + console.log([ + "Workspaces:", + ...result.workspaces.map((workspace) => `${workspace}${workspace === result.defaultWorkspace ? " default" : ""}`), + ].join("\n")); + return; + } + case "remove": + if (!value) throw new Error("Missing workspace path."); + console.log(await removeWorkspace(value)); + return; + case "default": + if (!value) throw new Error("Missing workspace path."); + console.log(await setDefaultWorkspace(value)); + return; + case "clear-default": + console.log(await clearDefaultWorkspace()); + return; + default: + throw new Error(`Unknown workspace command: ${subcommand ?? ""}`); + } +} + +async function runServiceCommand(args: string[]): Promise { + const config = loadConfig(); + const manager = createServiceManager({ config, cliEntrypoint: CLI_ENTRYPOINT }); + const [subcommand, ...rest] = args; + + switch (subcommand) { + case "remove": + console.log((await manager.remove()).message); + return; + case "disable": + console.log((await manager.disable()).message); + return; + case "start": + console.log((await manager.start()).message); + return; + case "stop": + console.log((await manager.stop()).message); + return; + case "restart": + console.log((await manager.restart()).message); + return; + case "status": { + const status = await manager.status(); + console.log([ + `manager: ${status.manager}`, + `service: ${status.serviceName}`, + `installed: ${status.installed ? "yes" : "no"}`, + `enabled: ${status.enabled ? "yes" : "no"}`, + `running: ${status.running ? "yes" : "no"}`, + `endpoint: ${status.endpoint ?? "(unknown)"}`, + `public base URL: ${status.publicBaseUrl ?? "(unknown)"}`, + `log path: ${status.logPath ?? "(unknown)"}`, + ].join("\n")); + return; + } + case "logs": { + const tail = parseTailArgument(rest); + console.log(await manager.logs(tail === undefined ? undefined : { tail })); + return; + } + case "doctor": { + const doctor = await manager.doctor(); + console.log([ + `manager: ${doctor.manager}`, + ...doctor.checks.map((check) => `[${check.level.toUpperCase()}] ${check.message}`), + ].join("\n")); + return; + } + default: + throw new Error(`Unknown service command: ${subcommand ?? ""}`); + } +} + +async function runSkillsCommand(args: string[]): Promise { + const [subcommand, ...rest] = args; + const config = loadConfig(); + + switch (subcommand) { + case "install": { + const { scope, workspace, source } = parseSkillsInstallArgs(rest); + const workspaceRoot = scope === "workspace" + ? resolveWorkspaceRoot(config, workspace ?? process.cwd()) + : undefined; + const installed = await installSkill({ + config, + workspaceRoot, + scope, + source, + }); + console.log([ + `Installed ${installed.name}`, + `Scope: ${installed.scope}`, + `Path: ${installed.path}`, + `Source: ${installed.sourceSummary}`, + ].join("\n")); + return; + } + case "list": { + const { scope, workspace } = parseSkillsScopeArgs(rest); + const workspaceRoot = scope === "workspace" + ? resolveWorkspaceRoot(config, workspace ?? process.cwd()) + : undefined; + const skills = await listInstalledSkills({ + config, + workspaceRoot, + scope, + }); + if (skills.length === 0) { + console.log("No installed skills."); + return; + } + console.log( + skills + .map((skill) => [ + `${skill.name} (${skill.scope})`, + ` path: ${skill.path}`, + ` description: ${skill.description}`, + ].join("\n")) + .join("\n"), + ); + return; + } + case "remove": { + const { scope, workspace, name } = parseSkillsRemoveArgs(rest); + const workspaceRoot = scope === "workspace" + ? resolveWorkspaceRoot(config, workspace ?? process.cwd()) + : undefined; + const removed = await removeInstalledSkill({ + config, + workspaceRoot, + scope, + name, + }); + console.log([ + `Removed ${removed.name}`, + `Scope: ${removed.scope}`, + `Path: ${removed.removedPath}`, + ].join("\n")); + return; + } + default: + throw new Error(`Unknown skills command: ${subcommand ?? ""}`); + } +} + function printHelp(): void { console.log( [ @@ -263,12 +549,47 @@ function printHelp(): void { "", "Usage:", " devspace Run first-time setup if needed, then start the server", + " devspace -v, --version Print the installed DevSpace version", " devspace serve Start the server", + " devspace serve --add-dir Temporarily allow an extra workspace root", + " devspace serve --workspace Temporarily set the default workspace for this run", + " devspace service-run Internal service entrypoint", + " 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 show Print effective config and service state", + " devspace config port ", + " devspace config host ", + " devspace config domain ", + " devspace config key", " devspace config set publicBaseUrl ", - " devspace -v, --version Print the installed version", + " devspace workspace add [--default] [--create]", + " devspace workspace list [--json]", + " devspace workspace remove ", + " devspace workspace default ", + " devspace workspace clear-default", + " devspace skills install [--workspace ] [--repo --path [--ref ] | --github-url | --local-path ]", + " devspace skills install -g [--repo --path [--ref ] | --github-url | --local-path ]", + " devspace skills list [--workspace ]", + " devspace skills list -g", + " devspace skills remove [--workspace ] ", + " devspace skills remove -g ", + " install expects the target path to point at one standard skill directory with a SKILL.md file", + " plugin roots, command folders, and agent-rules directories are rejected", + " devspace service start", + " devspace service remove", + " devspace service disable", + " devspace service stop", + " devspace service restart", + " devspace service status", + " devspace service logs [--tail N]", + " devspace service doctor", + "", + "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", @@ -276,13 +597,44 @@ function printHelp(): void { ); } -function printVersion(): void { - const packageJson = require("../package.json") as { version?: unknown }; - if (typeof packageJson.version !== "string") { - throw new Error("Unable to read DevSpace package version."); +function parseTailArgument(args: string[]): number | undefined { + const index = args.indexOf("--tail"); + if (index === -1) return undefined; + const value = Number(args[index + 1]); + return Number.isInteger(value) && value > 0 ? value : undefined; +} + +function extractServeArgs(args: string[]): { additionalRoots: string[]; workspace?: string } { + const additionalRoots: string[] = []; + let workspace: string | undefined; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--add-dir") { + const value = args[index + 1]; + if (!value) throw new Error("Missing path after --add-dir."); + additionalRoots.push(resolve(expandHomePath(value))); + index += 1; + continue; + } + if (arg === "--workspace") { + const value = args[index + 1]; + if (!value) throw new Error("Missing path after --workspace."); + workspace = resolve(expandHomePath(value)); + index += 1; + } } - console.log(packageJson.version); + return { additionalRoots, workspace }; +} + +function mergeAllowedRoots(additionalRoots: string[]): string { + const files = loadDevspaceFiles(); + const merged = new Set([ + ...(files.config.workspaces?.allowed ?? files.config.allowedRoots ?? []), + ...additionalRoots, + ]); + return Array.from(merged).join(","); } function normalizeOptionalPublicBaseUrl(value: string): string | null { @@ -301,6 +653,104 @@ function normalizePublicBaseUrl(value: string): string { return parsed.toString().replace(/\/$/, ""); } +function parseSkillsInstallArgs(args: string[]): { + scope: SkillScope; + workspace?: string; + source: SkillInstallSource; +} { + const { scope, workspace } = parseSkillsScopeArgs(args); + const repo = valueAfterFlag(args, "--repo"); + const path = valueAfterFlag(args, "--path"); + const ref = valueAfterFlag(args, "--ref"); + const githubUrl = valueAfterFlag(args, "--github-url"); + const localPath = valueAfterFlag(args, "--local-path"); + const selectedSources = [Boolean(repo || path), Boolean(githubUrl), Boolean(localPath)].filter(Boolean).length; + + if (selectedSources !== 1) { + throw new Error("Choose exactly one skill source: --repo/--path, --github-url, or --local-path."); + } + + if (githubUrl) { + return { + scope, + workspace, + source: { kind: "github_url", url: githubUrl }, + }; + } + + if (localPath) { + return { + scope, + workspace, + source: { kind: "local", path: resolve(expandHomePath(localPath)) }, + }; + } + + if (!repo || !path) { + throw new Error("GitHub install requires both --repo and --path."); + } + + return { + scope, + workspace, + source: { kind: "github", repo, path, ref: ref || undefined }, + }; +} + +function parseSkillsRemoveArgs(args: string[]): { + scope: SkillScope; + workspace?: string; + name: string; +} { + const { scope, workspace } = parseSkillsScopeArgs(args); + const positional = positionalSkillArgs(args); + const [name] = positional; + if (!name) throw new Error("Missing skill name."); + if (positional.length > 1) throw new Error("Remove accepts exactly one skill name."); + return { scope, workspace, name }; +} + +function parseSkillsScopeArgs(args: string[]): { + scope: SkillScope; + workspace?: string; +} { + const global = args.includes("-g") || args.includes("--global"); + const workspace = valueAfterFlag(args, "--workspace"); + if (global && workspace) { + throw new Error("Use either -g/--global or --workspace, not both."); + } + + return { + scope: global ? "global" : "workspace", + workspace: workspace ? resolve(expandHomePath(workspace)) : undefined, + }; +} + +function positionalSkillArgs(args: string[]): string[] { + return args.filter((arg, index) => !isSkillFlagArgument(args, index)); +} + +function isSkillFlagArgument(args: string[], index: number): boolean { + const arg = args[index]; + if (arg === "-g" || arg === "--global") return true; + + const valueFlags = new Set(["--workspace", "--repo", "--path", "--ref", "--github-url", "--local-path"]); + if (valueFlags.has(arg)) return true; + + const previous = args[index - 1]; + return valueFlags.has(previous); +} + +function valueAfterFlag(args: string[], flag: string): string | undefined { + const index = args.indexOf(flag); + if (index === -1) return undefined; + const value = args[index + 1]; + if (!value || value.startsWith("-")) { + throw new Error(`Missing value after ${flag}.`); + } + return value; +} + type TextPromptOptions = Omit[0], "validate"> & { defaultValue: string; validate?: (value: string | undefined) => string | Error | undefined; 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-operations.test.ts b/src/config-operations.test.ts new file mode 100644 index 0000000..fddbf40 --- /dev/null +++ b/src/config-operations.test.ts @@ -0,0 +1,108 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, realpathSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + addWorkspace, + buildConfigShowResult, + clearDefaultWorkspace, + listWorkspaces, + removeWorkspace, + resetConfigKey, + setConfigDomain, + setConfigHost, + setConfigPort, + setDefaultWorkspace, +} from "./config-operations.js"; +import { loadDevspaceFiles, writeDevspaceAuth } from "./user-config.js"; +import type { ServiceManager } from "./service/types.js"; +import { removeTempDirSync } from "./test-utils.js"; + +const root = mkdtempSync(join(tmpdir(), "devspace-config-ops-test-")); +process.env.DEVSPACE_CONFIG_DIR = root; +process.env.DEVSPACE_STATE_DIR = join(root, "state"); + +const testManager: ServiceManager = { + kind: "unsupported", + serviceName: "devspace-test", + async isSupported() { + return false; + }, + async remove() { + return { ok: false, manager: "unsupported", message: "unsupported" }; + }, + async disable() { + return { ok: false, manager: "unsupported", message: "unsupported" }; + }, + async start() { + return { ok: false, manager: "unsupported", message: "unsupported" }; + }, + async stop() { + return { ok: false, manager: "unsupported", message: "unsupported" }; + }, + async restart() { + return { ok: true, manager: "unsupported", message: "Restarted service" }; + }, + async status() { + return { + installed: true, + enabled: true, + running: true, + manager: "unsupported", + serviceName: "devspace-test", + }; + }, + async logs() { + return ""; + }, + async doctor() { + return { manager: "unsupported", checks: [] }; + }, +}; + +try { + writeDevspaceAuth({ ownerToken: "test-owner-token-that-is-long-enough" }); + + const initialWorkspace = join(root, "workspace-a"); + await addWorkspace(initialWorkspace, { create: true, makeDefault: true }); + const resolvedInitialWorkspace = realpathSync(initialWorkspace); + let listed = listWorkspaces(); + assert.deepEqual(listed.workspaces, [resolvedInitialWorkspace]); + assert.equal(listed.defaultWorkspace, resolvedInitialWorkspace); + + const secondWorkspace = join(root, "workspace-b"); + await addWorkspace(secondWorkspace, { create: true }); + const resolvedSecondWorkspace = realpathSync(secondWorkspace); + await setDefaultWorkspace(secondWorkspace); + listed = listWorkspaces(); + assert.equal(listed.defaultWorkspace, resolvedSecondWorkspace); + + await clearDefaultWorkspace(); + assert.equal(listWorkspaces().defaultWorkspace, undefined); + + await removeWorkspace(initialWorkspace); + assert.deepEqual(listWorkspaces().workspaces, [resolvedSecondWorkspace]); + + await setConfigHost("127.0.0.1", import.meta.url, { manager: testManager }); + await setConfigDomain("https://devspace.example.com/custom-mcp", import.meta.url, { manager: testManager }); + const filesAfterDomain = loadDevspaceFiles(); + assert.equal(filesAfterDomain.config.server?.publicBaseUrl, "https://devspace.example.com"); + assert.equal(filesAfterDomain.config.server?.mcpPath, "/custom-mcp"); + + const oldToken = loadDevspaceFiles().auth.ownerToken; + await resetConfigKey(import.meta.url, { manager: testManager }); + const newToken = loadDevspaceFiles().auth.ownerToken; + assert.notEqual(newToken, oldToken); + + process.env.DEVSPACE_OAUTH_OWNER_TOKEN = "env-owner-token-that-is-long-enough"; + const shown = await buildConfigShowResult(import.meta.url, { manager: testManager }); + assert.match(shown.accessKey, /^env\*+/); + delete process.env.DEVSPACE_OAUTH_OWNER_TOKEN; + + await assert.rejects(() => setConfigPort(0, import.meta.url, { manager: testManager }), /between 1 and 65535/); +} finally { + removeTempDirSync(root); + delete process.env.DEVSPACE_CONFIG_DIR; + delete process.env.DEVSPACE_STATE_DIR; + delete process.env.DEVSPACE_OAUTH_OWNER_TOKEN; +} diff --git a/src/config-operations.ts b/src/config-operations.ts new file mode 100644 index 0000000..6e203a3 --- /dev/null +++ b/src/config-operations.ts @@ -0,0 +1,305 @@ +import { createServer } from "./server.js"; +import { existsSync, mkdirSync, realpathSync } from "node:fs"; +import { mkdir } from "node:fs/promises"; +import { createServer as createNetServer } from "node:net"; +import { dirname, resolve } from "node:path"; +import { devspaceAuthPath, loadDevspaceFiles, type DevspaceAuthConfig, type DevspaceUserConfig, writeDevspaceAuth, writeDevspaceConfig } from "./user-config.js"; +import { createServiceManager, restartServiceIfRunning } from "./service/manager.js"; +import { expandHomePath } from "./roots.js"; +import { generateOwnerToken } from "./user-config.js"; +import { loadConfig } from "./config.js"; +import { SingleUserOAuthProvider } from "./oauth-provider.js"; +import type { ServiceManager } from "./service/types.js"; + +interface ConfigOperationOptions { + manager?: ServiceManager; +} + +export interface ConfigShowResult { + host: string; + port: number; + mcpPath: string; + publicUrl: string; + workspaces: string[]; + defaultWorkspace?: string; + platform: string; + serviceManager: string; + serviceInstalled: boolean; + serviceRunning: boolean; + accessKey: string; +} + +export async function buildConfigShowResult( + cliEntrypoint: string, + options: ConfigOperationOptions = {}, +): Promise { + const config = loadConfig(); + const manager = options.manager ?? createServiceManager({ config, cliEntrypoint }); + const status = await manager.status(); + return { + host: config.host, + port: config.port, + mcpPath: config.mcpPath, + publicUrl: new URL(config.mcpPath, config.publicBaseUrl).toString(), + workspaces: config.configuredWorkspaces, + defaultWorkspace: config.defaultWorkspace, + platform: process.platform, + serviceManager: manager.kind, + serviceInstalled: status.installed, + serviceRunning: status.running, + accessKey: maskSecret(effectiveOwnerToken()), + }; +} + +export async function setConfigPort( + port: number, + cliEntrypoint: string, + options: ConfigOperationOptions = {}, +): Promise { + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error("Port must be an integer between 1 and 65535."); + } + + const occupied = await inspectPort(port); + if (occupied) { + throw new Error(`Port ${port} is already in use${occupied}.`); + } + + const files = loadDevspaceFiles(); + writeDevspaceConfig({ + ...files.config, + host: files.config.server?.host ?? files.config.host ?? "127.0.0.1", + port, + server: { + ...(files.config.server ?? {}), + host: files.config.server?.host ?? files.config.host ?? "127.0.0.1", + port, + }, + }); + + return applyConfigUpdate(cliEntrypoint, undefined, options); +} + +export async function setConfigHost( + host: string, + cliEntrypoint: string, + options: ConfigOperationOptions = {}, +): Promise { + validateHost(host); + const files = loadDevspaceFiles(); + writeDevspaceConfig({ + ...files.config, + host, + server: { + ...(files.config.server ?? {}), + host, + port: files.config.server?.port ?? files.config.port, + }, + }); + return applyConfigUpdate( + cliEntrypoint, + isPublicHost(host) + ? "Warning: this host may expose DevSpace beyond localhost. Ensure auth, TLS, and firewall rules are correctly configured." + : undefined, + options, + ); +} + +export async function setConfigDomain( + input: string, + cliEntrypoint: string, + options: ConfigOperationOptions = {}, +): Promise { + const normalized = normalizeDomainLikeInput(input); + const files = loadDevspaceFiles(); + writeDevspaceConfig({ + ...files.config, + publicBaseUrl: normalized.publicBaseUrl, + server: { + ...(files.config.server ?? {}), + publicBaseUrl: normalized.publicBaseUrl, + mcpPath: normalized.mcpPath, + }, + }); + const warning = normalized.publicBaseUrl.startsWith("http://") + ? "Warning: public URL uses HTTP. Prefer HTTPS for any remote MCP access." + : undefined; + return applyConfigUpdate(cliEntrypoint, warning, options); +} + +export async function resetConfigKey( + cliEntrypoint: string, + options: ConfigOperationOptions = {}, +): Promise { + const files = loadDevspaceFiles(); + const newToken = generateOwnerToken(); + writeDevspaceAuth({ ownerToken: newToken }); + + const config = loadConfig(); + const oauthProvider = new SingleUserOAuthProvider(config.oauth, new URL(config.mcpPath, config.publicBaseUrl)); + try { + oauthProvider.resetState(); + + const restartMessage = await applyConfigUpdate(cliEntrypoint, undefined, options); + return [ + "Access key has been reset successfully.", + "Existing clients must be reconfigured.", + restartMessage, + ].filter(Boolean).join("\n"); + } finally { + oauthProvider.close(); + } +} + +export async function addWorkspace(path: string, options: { + create?: boolean; + makeDefault?: boolean; +}): Promise { + const resolved = await resolveWorkspacePath(path, options.create ?? false); + const files = loadDevspaceFiles(); + const current = new Set(files.config.workspaces?.allowed ?? files.config.allowedRoots ?? []); + if (current.has(resolved)) { + return `Workspace already added: ${resolved}`; + } + current.add(resolved); + writeDevspaceConfig({ + ...files.config, + allowedRoots: Array.from(current), + workspaces: { + allowed: Array.from(current), + default: options.makeDefault ? resolved : files.config.workspaces?.default ?? null, + }, + }); + return `Added workspace: ${resolved}`; +} + +export function listWorkspaces(): { workspaces: string[]; defaultWorkspace?: string } { + const files = loadDevspaceFiles(); + return { + workspaces: files.config.workspaces?.allowed ?? files.config.allowedRoots ?? [], + defaultWorkspace: files.config.workspaces?.default ?? undefined, + }; +} + +export async function removeWorkspace(path: string): Promise { + const resolved = await resolveWorkspacePath(path, false); + const files = loadDevspaceFiles(); + const remaining = (files.config.workspaces?.allowed ?? files.config.allowedRoots ?? []).filter((entry) => entry !== resolved); + writeDevspaceConfig({ + ...files.config, + allowedRoots: remaining, + workspaces: { + allowed: remaining, + default: files.config.workspaces?.default === resolved ? null : files.config.workspaces?.default ?? null, + }, + }); + return `Removed workspace: ${resolved}`; +} + +export async function setDefaultWorkspace(path: string): Promise { + const resolved = await resolveWorkspacePath(path, false); + const files = loadDevspaceFiles(); + const workspaces = files.config.workspaces?.allowed ?? files.config.allowedRoots ?? []; + if (!workspaces.includes(resolved)) { + throw new Error(`Workspace is not configured: ${resolved}`); + } + writeDevspaceConfig({ + ...files.config, + workspaces: { + allowed: workspaces, + default: resolved, + }, + }); + return `Default workspace set to ${resolved}`; +} + +export async function clearDefaultWorkspace(): Promise { + const files = loadDevspaceFiles(); + writeDevspaceConfig({ + ...files.config, + workspaces: { + allowed: files.config.workspaces?.allowed ?? files.config.allowedRoots ?? [], + default: null, + }, + }); + return "Cleared default workspace"; +} + +function validateHost(host: string): void { + const trimmed = host.trim(); + if (!trimmed) throw new Error("Host is required."); + if (!/^(localhost|0\.0\.0\.0|127\.0\.0\.1|::1|::|[A-Za-z0-9._:-]+)$/.test(trimmed)) { + throw new Error(`Invalid host: ${host}`); + } +} + +function isPublicHost(host: string): boolean { + return !["127.0.0.1", "localhost", "::1"].includes(host); +} + +function normalizeDomainLikeInput(input: string): { publicBaseUrl: string; mcpPath: string } { + const trimmed = input.trim(); + if (!trimmed) throw new Error("Domain or URL is required."); + const withScheme = /^[A-Za-z][A-Za-z0-9+.-]*:/.test(trimmed) ? trimmed : `https://${trimmed}`; + const parsed = new URL(withScheme); + if (parsed.username || parsed.password) { + throw new Error("Public URL must not include a username or password."); + } + parsed.hash = ""; + parsed.search = ""; + const mcpPath = parsed.pathname.replace(/\/+$/, "") || "/mcp"; + parsed.pathname = ""; + return { + publicBaseUrl: parsed.toString().replace(/\/$/, ""), + mcpPath, + }; +} + +async function applyConfigUpdate( + cliEntrypoint: string, + extraMessage?: string, + options: ConfigOperationOptions = {}, +): Promise { + const config = loadConfig(); + const manager = options.manager ?? createServiceManager({ config, cliEntrypoint }); + const outcome = await restartServiceIfRunning(manager); + return [extraMessage, outcome.message].filter(Boolean).join("\n"); +} + +async function inspectPort(port: number): Promise { + const available = await canBindPort(port); + return available ? undefined : ` by another process on this machine`; +} + +function canBindPort(port: number): Promise { + return new Promise((resolve) => { + const server = createNetServer(); + server.once("error", () => resolve(false)); + server.listen(port, "127.0.0.1", () => { + server.close(() => resolve(true)); + }); + }); +} + +async function resolveWorkspacePath(path: string, create: boolean): Promise { + const target = resolve(expandHomePath(path)); + if (create) { + await mkdir(target, { recursive: true }); + } + if (!existsSync(target)) { + throw new Error(`Workspace path does not exist: ${target}`); + } + return realpathSync(target); +} + +function maskSecret(secret: string | undefined): string { + if (!secret) return "(not configured)"; + if (secret.length <= 6) return "*".repeat(secret.length); + return `${secret.slice(0, 3)}${"*".repeat(Math.max(8, secret.length - 5))}${secret.slice(-2)}`; +} + +function effectiveOwnerToken(): string | undefined { + const envToken = process.env.DEVSPACE_OAUTH_OWNER_TOKEN?.trim(); + if (envToken) return envToken; + return loadDevspaceFiles().auth.ownerToken; +} diff --git a/src/config.test.ts b/src/config.test.ts index 4f29c4e..434c517 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-")); @@ -12,6 +13,13 @@ const baseEnv = { }; assert.equal(loadConfig(baseEnv).widgets, "full"); +assert.deepEqual( + loadConfig({ + DEVSPACE_CONFIG_DIR: emptyConfigDir, + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + }).allowedRoots, + [], +); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "changes" }).widgets, "changes"); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "full" }).widgets, "full"); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "off" }).widgets, "off"); @@ -23,6 +31,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 +54,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 +99,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 +128,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 +181,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..92cdb89 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,21 +3,27 @@ import { join, resolve } from "node:path"; import { expandHomePath } from "./roots.js"; import type { LoggingConfig, LogFormat, LogLevel } from "./logger.js"; import type { OAuthConfig } from "./oauth-provider.js"; -import { loadDevspaceFiles } from "./user-config.js"; +import { loadDevspaceFiles, normalizeMcpPath } 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; export interface ServerConfig { host: string; port: number; + mcpPath: string; oauth: OAuthConfig; allowedRoots: string[]; + configuredWorkspaces: string[]; + defaultWorkspace?: string; + sessionWorkspace?: string; allowedHosts: string[]; publicBaseUrl: string; minimalTools: boolean; + shellMode: ShellMode; toolNaming: ToolNamingMode; widgets: WidgetMode; stateDir: string; @@ -42,7 +48,7 @@ function parsePort(value: string | number | undefined): number { function parseAllowedRoots(value: string | string[] | undefined): string[] { if (Array.isArray(value)) { const roots = value.map((entry) => entry.trim()).filter(Boolean); - return (roots.length > 0 ? roots : [process.cwd()]).map((root) => resolve(expandHomePath(root))); + return roots.map((root) => resolve(expandHomePath(root))); } const rawRoots = @@ -51,8 +57,7 @@ function parseAllowedRoots(value: string | string[] | undefined): string[] { .map((entry) => entry.trim()) .filter(Boolean) ?? []; - const roots = rawRoots.length > 0 ? rawRoots : [process.cwd()]; - return roots.map((root) => resolve(expandHomePath(root))); + return rawRoots.map((root) => resolve(expandHomePath(root))); } function parseAllowedHosts(value: string | string[] | undefined, derivedHosts: string[]): string[] { @@ -89,6 +94,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 +182,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 +209,7 @@ function parseOAuthConfig(env: NodeJS.ProcessEnv, ownerToken: string | undefined "localhost", "127.0.0.1", ]), + statePath: resolve(expandHomePath(env.DEVSPACE_OAUTH_STATE_PATH ?? defaultOAuthStatePath(stateDir))), }; } @@ -206,10 +227,21 @@ function defaultAgentDir(): string { export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { const files = loadDevspaceFiles(env); - const host = env.HOST ?? files.config.host ?? "127.0.0.1"; - const port = parsePort(env.PORT ?? files.config.port); + const host = env.HOST ?? files.config.server?.host ?? files.config.host ?? "127.0.0.1"; + const port = parsePort(env.PORT ?? files.config.server?.port ?? files.config.port); + const stateDir = resolve(expandHomePath(env.DEVSPACE_STATE_DIR ?? files.config.stateDir ?? defaultStateDir())); + const mcpPath = normalizeMcpPath(env.DEVSPACE_MCP_PATH ?? files.config.server?.mcpPath); + const configuredWorkspaces = parseAllowedRoots( + env.DEVSPACE_ALLOWED_ROOTS ?? files.config.workspaces?.allowed ?? files.config.allowedRoots, + ); + const defaultWorkspace = files.config.workspaces?.default + ? resolve(expandHomePath(files.config.workspaces.default)) + : undefined; + const sessionWorkspace = env.DEVSPACE_SESSION_WORKSPACE + ? resolve(expandHomePath(env.DEVSPACE_SESSION_WORKSPACE)) + : undefined; const publicBaseUrl = parsePublicBaseUrl( - env.DEVSPACE_PUBLIC_BASE_URL ?? files.config.publicBaseUrl ?? localPublicBaseUrl(host, port), + env.DEVSPACE_PUBLIC_BASE_URL ?? files.config.server?.publicBaseUrl ?? files.config.publicBaseUrl ?? localPublicBaseUrl(host, port), ); const derivedAllowedHosts = [ "localhost", @@ -223,14 +255,19 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { return { host, port, - oauth: parseOAuthConfig(env, files.auth.ownerToken), - allowedRoots: parseAllowedRoots(env.DEVSPACE_ALLOWED_ROOTS ?? files.config.allowedRoots), + mcpPath, + oauth: parseOAuthConfig(env, files.auth.ownerToken, stateDir), + allowedRoots: configuredWorkspaces, + configuredWorkspaces, + defaultWorkspace, + sessionWorkspace, 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/client.ts b/src/db/client.ts index fd129fb..d33650c 100644 --- a/src/db/client.ts +++ b/src/db/client.ts @@ -1,4 +1,4 @@ -import { chmodSync, mkdirSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync } from "node:fs"; import { join } from "node:path"; import Database from "better-sqlite3"; import { drizzle } from "drizzle-orm/better-sqlite3"; @@ -28,6 +28,8 @@ export function openDatabase(stateDir: string): DatabaseHandle { sqlite.pragma("synchronous = NORMAL"); sqlite.pragma("busy_timeout = 5000"); sqlite.pragma("foreign_keys = ON"); + chmodIfExists(`${path}-wal`, 0o600); + chmodIfExists(`${path}-shm`, 0o600); migrateDatabase(sqlite); return { @@ -40,3 +42,7 @@ export function openDatabase(stateDir: string): DatabaseHandle { function createDrizzleDatabase(sqlite: SqliteDatabase) { return drizzle(sqlite, { schema }); } + +function chmodIfExists(path: string, mode: number): void { + if (existsSync(path)) chmodSync(path, mode); +} diff --git a/src/db/migrations.ts b/src/db/migrations.ts index 1ce1e1c..acda3a5 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -100,11 +100,22 @@ function migrateOAuthState(sqlite: Database.Database): void { create table if not exists oauth_clients ( client_id text primary key, client_json text not null, - issued_at integer not null + created_at integer not null ); - create index if not exists oauth_clients_issued_at_idx - on oauth_clients(issued_at desc); + create index if not exists oauth_clients_created_at_idx + on oauth_clients(created_at desc); + + create table if not exists oauth_authorization_codes ( + code_hash text primary key, + client_id text not null, + params_json text not null, + expires_at_ms integer not null, + foreign key (client_id) references oauth_clients(client_id) on delete cascade + ); + + create index if not exists oauth_authorization_codes_expiry_idx + on oauth_authorization_codes(expires_at_ms); create table if not exists oauth_access_tokens ( token_hash text primary key, @@ -135,7 +146,27 @@ function migrateOAuthState(sqlite: Database.Database): void { create index if not exists oauth_refresh_tokens_expires_at_idx on oauth_refresh_tokens(expires_at); + + create table if not exists oauth_consents ( + consent_key text primary key, + client_id text not null, + redirect_uri text not null, + resource text not null, + scopes_json text not null, + approved_at integer not null, + foreign key (client_id) references oauth_clients(client_id) on delete cascade + ); + + create index if not exists oauth_consents_client_idx + on oauth_consents(client_id); + + create table if not exists oauth_metadata ( + key text primary key, + value text not null + ); `); + + migrateLegacyCombinedOauthTokens(sqlite); } function addColumnIfMissing( @@ -149,3 +180,24 @@ function addColumnIfMissing( sqlite.exec(`alter table ${table} add column ${column} ${definition}`); } + +function migrateLegacyCombinedOauthTokens(sqlite: Database.Database): void { + const hasLegacyTokensTable = sqlite + .prepare( + "select 1 from sqlite_master where type = 'table' and name = 'oauth_tokens' limit 1", + ) + .get(); + if (!hasLegacyTokensTable) return; + + sqlite.exec(` + insert or ignore into oauth_access_tokens (token_hash, client_id, scopes_json, expires_at, resource) + select token_hash, client_id, scopes_json, expires_at, resource + from oauth_tokens + where token_kind = 'access'; + + insert or ignore into oauth_refresh_tokens (token_hash, client_id, scopes_json, expires_at, resource) + select token_hash, client_id, scopes_json, expires_at, resource + from oauth_tokens + where token_kind = 'refresh'; + `); +} diff --git a/src/db/schema.ts b/src/db/schema.ts index 94b3862..ebac5d7 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,4 +1,4 @@ -import { index, integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { index, integer, primaryKey, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; export const workspaceSessions = sqliteTable( "workspace_sessions", @@ -38,42 +38,284 @@ export const loadedAgentFiles = sqliteTable( ], ); -export const oauthClients = sqliteTable( - "oauth_clients", +export const workspacePlans = sqliteTable( + "workspace_plans", { - clientId: text("client_id").primaryKey(), - clientJson: text("client_json").notNull(), - issuedAt: integer("issued_at").notNull(), + 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 oauthAccessTokens = sqliteTable( - "oauth_access_tokens", +export const workspaceModes = sqliteTable( + "workspace_modes", { - tokenHash: text("token_hash").primaryKey(), + workspaceSessionId: text("workspace_session_id") + .primaryKey() + .references(() => workspaceSessions.id, { onDelete: "cascade" }), + mode: text("mode").notNull().default("default"), + updatedAt: text("updated_at").notNull(), + }, +); + +export const projectWorkflows = sqliteTable( + "project_workflows", + { + projectWorkflowKey: text("project_workflow_key").primaryKey(), + canonicalRoot: text("canonical_root").notNull(), + workspaceKind: text("workspace_kind").notNull(), + gitCommonDir: text("git_common_dir"), + gitRemoteOrigin: text("git_remote_origin"), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull(), + }, + (table) => [uniqueIndex("project_workflows_root_idx").on(table.canonicalRoot)], +); + +export const workflowPlans = sqliteTable( + "workflow_plans", + { + id: text("id").primaryKey(), + projectWorkflowKey: text("project_workflow_key") + .notNull() + .references(() => projectWorkflows.projectWorkflowKey, { onDelete: "cascade" }), + goalId: text("goal_id"), + title: text("title").notNull(), + summary: text("summary"), + scopeInJson: text("scope_in_json").notNull(), + scopeOutJson: text("scope_out_json").notNull(), + validationJson: text("validation_json").notNull(), + risksJson: text("risks_json").notNull(), + status: text("status").notNull(), + revision: integer("revision").notNull(), + isCurrent: integer("is_current").notNull(), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull(), + archivedAt: text("archived_at"), + }, + (table) => [index("workflow_plans_history_idx").on(table.projectWorkflowKey, table.updatedAt)], +); + +export const workflowPlanSteps = sqliteTable( + "workflow_plan_steps", + { + id: text("id").primaryKey(), + planId: text("plan_id") + .notNull() + .references(() => workflowPlans.id, { onDelete: "cascade" }), + position: integer("position").notNull(), + content: text("content").notNull(), + status: text("status").notNull(), + note: text("note"), + updatedAt: text("updated_at").notNull(), + }, + (table) => [index("workflow_plan_steps_plan_idx").on(table.planId, table.position)], +); + +export const workflowGoals = sqliteTable( + "workflow_goals", + { + id: text("id").primaryKey(), + projectWorkflowKey: text("project_workflow_key") + .notNull() + .references(() => projectWorkflows.projectWorkflowKey, { onDelete: "cascade" }), + objective: text("objective").notNull(), + scopeInJson: text("scope_in_json").notNull(), + scopeOutJson: text("scope_out_json").notNull(), + successCriteriaJson: text("success_criteria_json").notNull(), + verificationJson: text("verification_json").notNull(), + stopConditionsJson: text("stop_conditions_json").notNull(), + currentSummary: text("current_summary"), + status: text("status").notNull(), + revision: integer("revision").notNull(), + isCurrent: integer("is_current").notNull(), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull(), + archivedAt: text("archived_at"), + }, + (table) => [index("workflow_goals_history_idx").on(table.projectWorkflowKey, table.updatedAt)], +); + +export const workflowGoalMetrics = sqliteTable( + "workflow_goal_metrics", + { + goalId: text("goal_id") + .primaryKey() + .references(() => workflowGoals.id, { onDelete: "cascade" }), + activeWorkStartedAt: text("active_work_started_at"), + accumulatedWorkMs: integer("accumulated_work_ms").notNull(), + updatedAt: text("updated_at").notNull(), + }, +); + +export const workflowGoalTokenUsage = sqliteTable( + "workflow_goal_token_usage", + { + id: text("id").primaryKey(), + goalId: text("goal_id") + .notNull() + .references(() => workflowGoals.id, { onDelete: "cascade" }), + provider: text("provider").notNull(), + providerRequestId: text("provider_request_id").notNull(), + model: text("model"), + inputTokens: integer("input_tokens").notNull(), + outputTokens: integer("output_tokens").notNull(), + reasoningTokens: integer("reasoning_tokens").notNull(), + totalTokens: integer("total_tokens").notNull(), + providerReportedAt: text("provider_reported_at"), + recordedAt: text("recorded_at").notNull(), + }, + (table) => [ + uniqueIndex("workflow_goal_token_usage_dedupe_idx").on( + table.goalId, + table.provider, + table.providerRequestId, + ), + index("workflow_goal_token_usage_history_idx").on(table.goalId, table.recordedAt), + ], +); + +export const workflowModes = sqliteTable( + "workflow_modes", + { + projectWorkflowKey: text("project_workflow_key") + .primaryKey() + .references(() => projectWorkflows.projectWorkflowKey, { onDelete: "cascade" }), + mode: text("mode").notNull(), + updatedAt: text("updated_at").notNull(), + }, +); + +export const workflowEvents = sqliteTable( + "workflow_events", + { + id: text("id").primaryKey(), + projectWorkflowKey: text("project_workflow_key") + .notNull() + .references(() => projectWorkflows.projectWorkflowKey, { onDelete: "cascade" }), + entityType: text("entity_type").notNull(), + entityId: text("entity_id").notNull(), + eventType: text("event_type").notNull(), + summary: text("summary").notNull(), + revision: integer("revision"), + createdAt: text("created_at").notNull(), + }, + (table) => [index("workflow_events_history_idx").on(table.projectWorkflowKey, table.createdAt)], +); + +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 const oauthClients = sqliteTable("oauth_clients", { + clientId: text("client_id").primaryKey(), + clientJson: text("client_json").notNull(), + createdAt: integer("created_at").notNull(), +}); + +export const oauthAuthorizationCodes = sqliteTable( + "oauth_authorization_codes", + { + codeHash: text("code_hash").primaryKey(), clientId: text("client_id") .notNull() .references(() => oauthClients.clientId, { onDelete: "cascade" }), - scopesJson: text("scopes_json").notNull(), - expiresAt: integer("expires_at").notNull(), - resource: text("resource"), + paramsJson: text("params_json").notNull(), + expiresAtMs: integer("expires_at_ms").notNull(), }, + (table) => [index("oauth_authorization_codes_expiry_idx").on(table.expiresAtMs)], ); -export const oauthRefreshTokens = sqliteTable( - "oauth_refresh_tokens", +export const oauthAccessTokens = sqliteTable("oauth_access_tokens", { + tokenHash: text("token_hash").primaryKey(), + clientId: text("client_id") + .notNull() + .references(() => oauthClients.clientId, { onDelete: "cascade" }), + scopesJson: text("scopes_json").notNull(), + expiresAt: integer("expires_at").notNull(), + resource: text("resource"), +}); + +export const oauthRefreshTokens = sqliteTable("oauth_refresh_tokens", { + tokenHash: text("token_hash").primaryKey(), + clientId: text("client_id") + .notNull() + .references(() => oauthClients.clientId, { onDelete: "cascade" }), + scopesJson: text("scopes_json").notNull(), + expiresAt: integer("expires_at").notNull(), + resource: text("resource"), +}); + +export const oauthConsents = sqliteTable( + "oauth_consents", { - tokenHash: text("token_hash").primaryKey(), + consentKey: text("consent_key").primaryKey(), clientId: text("client_id") .notNull() .references(() => oauthClients.clientId, { onDelete: "cascade" }), + redirectUri: text("redirect_uri").notNull(), + resource: text("resource").notNull(), scopesJson: text("scopes_json").notNull(), - expiresAt: integer("expires_at").notNull(), - resource: text("resource"), + approvedAt: integer("approved_at").notNull(), }, + (table) => [index("oauth_consents_client_idx").on(table.clientId)], ); +export const oauthMetadata = sqliteTable("oauth_metadata", { + key: text("key").primaryKey(), + value: text("value").notNull(), +}); + 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; +export type ProjectWorkflowRow = typeof projectWorkflows.$inferSelect; +export type WorkflowPlanRow = typeof workflowPlans.$inferSelect; +export type WorkflowPlanStepRow = typeof workflowPlanSteps.$inferSelect; +export type WorkflowGoalRow = typeof workflowGoals.$inferSelect; +export type WorkflowGoalMetricsRow = typeof workflowGoalMetrics.$inferSelect; +export type WorkflowGoalTokenUsageRow = typeof workflowGoalTokenUsage.$inferSelect; +export type WorkflowModeRow = typeof workflowModes.$inferSelect; +export type WorkflowEventRow = typeof workflowEvents.$inferSelect; diff --git a/src/goal-definition.test.ts b/src/goal-definition.test.ts new file mode 100644 index 0000000..59da6e5 --- /dev/null +++ b/src/goal-definition.test.ts @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import { + normalizeGoalDefinition, + parseGoalDefinition, + serializeGoalDefinition, +} from "./goal-definition.js"; + +const normalized = normalizeGoalDefinition({ + objective: " Ship lightweight goal flow ", + scope: { + in: [" goal tools ", " resolve_skill "], + out: [" dashboards ", ""], + }, + verification: [" npm test ", " npm run typecheck "], + stopConditions: [" Need product clarification "], +}); + +assert.deepEqual(normalized, { + objective: "Ship lightweight goal flow", + scope: { + in: ["goal tools", "resolve_skill"], + out: ["dashboards"], + }, + verification: ["npm test", "npm run typecheck"], + stopConditions: ["Need product clarification"], +}); + +const serialized = serializeGoalDefinition(normalized); +const parsed = parseGoalDefinition(serialized); +assert.equal(parsed.legacy, false); +assert.deepEqual(parsed.definition, normalized); + +const legacy = parseGoalDefinition("Ship the feature"); +assert.equal(legacy.legacy, true); +assert.deepEqual(legacy.definition, { + objective: "Ship the feature", +}); diff --git a/src/goal-definition.ts b/src/goal-definition.ts new file mode 100644 index 0000000..84b7cbb --- /dev/null +++ b/src/goal-definition.ts @@ -0,0 +1,58 @@ +export interface GoalScope { + in: string[]; + out: string[]; +} + +export interface GoalDefinition { + objective: string; + scope?: GoalScope; + verification?: string[]; + stopConditions?: string[]; +} + +export interface ParsedGoalDefinition { + definition: GoalDefinition; + legacy: boolean; +} + +const GOAL_PREFIX = "devspace-goal-v1:"; + +export function serializeGoalDefinition(definition: GoalDefinition): string { + return `${GOAL_PREFIX}${JSON.stringify(normalizeGoalDefinition(definition))}`; +} + +export function parseGoalDefinition(raw: string): ParsedGoalDefinition { + if (!raw.startsWith(GOAL_PREFIX)) { + return { + definition: { objective: raw }, + legacy: true, + }; + } + + try { + const parsed = JSON.parse(raw.slice(GOAL_PREFIX.length)) as GoalDefinition; + return { + definition: normalizeGoalDefinition(parsed), + legacy: false, + }; + } catch { + return { + definition: { objective: raw }, + legacy: true, + }; + } +} + +export function normalizeGoalDefinition(definition: GoalDefinition): GoalDefinition { + return { + objective: definition.objective.trim(), + scope: definition.scope + ? { + in: definition.scope.in.map((item) => item.trim()).filter(Boolean), + out: definition.scope.out.map((item) => item.trim()).filter(Boolean), + } + : undefined, + verification: definition.verification?.map((item) => item.trim()).filter(Boolean), + stopConditions: definition.stopConditions?.map((item) => item.trim()).filter(Boolean), + }; +} 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..b9d8d9e --- /dev/null +++ b/src/oauth-provider.test.ts @@ -0,0 +1,436 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { stat, chmod } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import Database from "better-sqlite3"; +import { InvalidGrantError, InvalidTokenError } from "@modelcontextprotocol/sdk/server/auth/errors.js"; +import { SingleUserOAuthProvider, type OAuthConfig } from "./oauth-provider.js"; +import { databasePath } from "./db/client.js"; +import type { OAuthClientInformationFull, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; +import type { AuthorizationParams } from "@modelcontextprotocol/sdk/server/auth/provider.js"; +import { removeTempDirSync } from "./test-utils.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, +}; +const providers: SingleUserOAuthProvider[] = []; + +try { + const firstProvider = new SingleUserOAuthProvider(config, resourceServerUrl); + providers.push(firstProvider); + 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 = readPersistedState(statePath); + 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("token" in savedState.accessTokens[0], false); + assert.equal(savedState.refreshTokens.length, 1); + assert.equal(savedState.refreshTokens[0].tokenHash.length > 0, true); + assert.equal("token" in savedState.refreshTokens[0], false); + assert.equal(JSON.stringify(savedState).includes(assertString(firstTokens.access_token)), false); + assert.equal(JSON.stringify(savedState).includes(assertString(firstTokens.refresh_token)), false); + + if (process.platform !== "win32") { + const stateStats = await stat(databasePath(dirname(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); + providers.push(secondProvider); + 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 = readPersistedState(statePath); + 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); + providers.push(expiredProvider); + await assert.rejects( + () => expiredProvider.exchangeRefreshToken(client, assertString(firstTokens.refresh_token), undefined, resourceServerUrl), + InvalidGrantError, + ); + const cleanedExpiredState = readPersistedState(expiredStatePath); + 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); + providers.push(corruptProvider); + assert.equal(corruptProvider.clientsStore.getClient(client.client_id), undefined); + const repairedState = readPersistedState(corruptStatePath); + assert.deepEqual(repairedState, { 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); + providers.push(emptyProvider); + assert.equal(emptyProvider.clientsStore.getClient(client.client_id), undefined); + const rewrittenEmptyState = readPersistedState(emptyStatePath); + assert.deepEqual(rewrittenEmptyState, { clients: [], accessTokens: [], refreshTokens: [], approvedConsents: [] }); + + const customProvider = new SingleUserOAuthProvider({ ...config, statePath: customStatePath }, resourceServerUrl); + providers.push(customProvider); + customProvider.clientsStore.registerClient({ + client_name: "custom state client", + redirect_uris: ["http://localhost/custom"], + scope: "devspace", + }); + assert.equal(readPersistedState(customStatePath).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, + ); + providers.push(expiredAccessProvider); + await assert.rejects( + () => expiredAccessProvider.verifyAccessToken(assertString(expiredAccessTokens.access_token)), + InvalidTokenError, + ); + const cleanedExpiredAccessState = readPersistedState(expiredAccessStatePath); + assert.equal(cleanedExpiredAccessState.accessTokens.length, 0); + + const consentStatePath = join(root, "consent", "oauth.json"); + const consentProvider = new SingleUserOAuthProvider({ ...config, statePath: consentStatePath }, resourceServerUrl); + providers.push(consentProvider); + 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 = readPersistedState(consentStatePath); + 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, + ); + providers.push(expandedScopeProvider); + 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); + providers.push(restartedConsentProvider); + 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 = readPersistedState(consentStatePath); + 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 { + for (const provider of providers.splice(0).reverse()) { + provider.close(); + } + removeTempDirSync(root); +} + +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 readPersistedState(statePath: string) { + const db = new Database(databasePath(dirname(statePath)), { readonly: true }); + try { + const clients = (db.prepare("select client_json from oauth_clients order by created_at asc").all() as { client_json: string }[]) + .map((row) => JSON.parse(row.client_json)); + const accessTokens = db.prepare("select token_hash, client_id, scopes_json, expires_at, resource from oauth_access_tokens order by token_hash asc").all() as { + token_hash: string; + client_id: string; + scopes_json: string; + expires_at: number; + resource: string | null; + }[]; + const refreshTokens = db.prepare("select token_hash, client_id, scopes_json, expires_at, resource from oauth_refresh_tokens order by token_hash asc").all() as { + token_hash: string; + client_id: string; + scopes_json: string; + expires_at: number; + resource: string | null; + }[]; + const consents = db.prepare("select client_id, redirect_uri, resource, scopes_json, approved_at from oauth_consents order by approved_at asc").all() as { + client_id: string; + redirect_uri: string; + resource: string; + scopes_json: string; + approved_at: number; + }[]; + + return { + clients, + accessTokens: accessTokens.map(rowToStoredToken), + refreshTokens: refreshTokens.map(rowToStoredToken), + approvedConsents: consents.map((row) => ({ + clientId: row.client_id, + redirectUri: row.redirect_uri, + resource: row.resource, + scopes: JSON.parse(row.scopes_json) as string[], + approvedAt: row.approved_at, + })), + }; + } finally { + db.close(); + } +} + +function rowToStoredToken(row: { + token_hash: string; + client_id: string; + scopes_json: string; + expires_at: number; + resource: string | null; +}) { + return { + tokenHash: row.token_hash, + clientId: row.client_id, + scopes: JSON.parse(row.scopes_json) as string[], + expiresAt: row.expires_at, + resource: row.resource ?? undefined, + }; +} + +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 e650378..c2bb0bd 100644 --- a/src/oauth-provider.ts +++ b/src/oauth-provider.ts @@ -1,6 +1,5 @@ import { timingSafeEqual, randomBytes, randomUUID, createHash } from "node:crypto"; 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"; import { AccessDeniedError, InvalidGrantError, InvalidRequestError, InvalidTokenError } from "@modelcontextprotocol/sdk/server/auth/errors.js"; import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; @@ -10,7 +9,13 @@ import type { OAuthTokens, } from "@modelcontextprotocol/sdk/shared/auth.js"; import { checkResourceAllowed, resourceUrlFromServerUrl } from "@modelcontextprotocol/sdk/shared/auth-utils.js"; -import { SqliteOAuthClientsStore, SqliteOAuthStore } from "./oauth-store.js"; +import { dirname } from "node:path"; +import { + consentKey, + SqliteOAuthClientsStore, + SqliteOAuthStore, + type AuthorizationCodeRecord, +} from "./oauth-store.js"; export interface OAuthConfig { ownerToken: string; @@ -18,12 +23,7 @@ export interface OAuthConfig { refreshTokenTtlSeconds: number; scopes: string[]; allowedRedirectHosts: string[]; -} - -interface AuthorizationCodeRecord { - clientId: string; - params: AuthorizationParams; - expiresAtMs: number; + statePath?: string; } const CODE_TTL_MS = 5 * 60 * 1000; @@ -112,19 +112,21 @@ function requestedScopesAllowed(requested: string[], supported: string[]): boole } export class SingleUserOAuthProvider implements OAuthServerProvider { - readonly clientsStore: OAuthRegisteredClientsStore; - private readonly codes = new Map(); - private readonly oauthStore: SqliteOAuthStore; + readonly clientsStore: SqliteOAuthClientsStore; + private readonly store: SqliteOAuthStore; private readonly resourceServerUrl: URL; constructor( private readonly config: OAuthConfig, resourceServerUrl: URL, - stateDir: string, + stateDir?: string, ) { this.resourceServerUrl = resourceUrlFromServerUrl(resourceServerUrl); - this.oauthStore = new SqliteOAuthStore(stateDir); - this.clientsStore = new SqliteOAuthClientsStore(this.oauthStore, config.allowedRedirectHosts); + const resolvedStateDir = config.statePath + ? dirname(config.statePath) + : stateDir ?? process.cwd(); + this.store = new SqliteOAuthStore(resolvedStateDir, config.statePath); + this.clientsStore = new SqliteOAuthClientsStore(this.store, config.allowedRedirectHosts); } async authorize( @@ -132,19 +134,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.store.getConsent(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), }), @@ -159,7 +176,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), }), @@ -167,8 +184,31 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { return; } + this.store.saveConsent(currentConsentKey, { + clientId: client.client_id, + redirectUri: params.redirectUri, + resource: params.resource.href, + scopes, + approvedAt: Math.floor(Date.now() / 1000), + }); + this.redirectWithAuthorizationCode(client, params, res); + } + + revokeClientConsent(clientId: string): void { + this.store.deleteClientConsents(clientId); + } + + resetState(): void { + this.store.resetState(); + } + + private redirectWithAuthorizationCode( + client: OAuthClientInformationFull, + params: AuthorizationParams, + res: Response, + ): void { const code = `code-${randomUUID()}`; - this.codes.set(code, { + this.store.saveAuthorizationCode(hashToken(code), { clientId: client.client_id, params, expiresAtMs: Date.now() + CODE_TTL_MS, @@ -203,7 +243,7 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { throw new InvalidGrantError("Invalid resource"); } - this.codes.delete(authorizationCode); + this.store.deleteAuthorizationCode(hashToken(authorizationCode)); return this.issueTokens(client.client_id, record.params.scopes ?? this.config.scopes, record.params.resource); } @@ -214,8 +254,11 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { resource?: URL, ): Promise { const refreshTokenHash = hashToken(refreshToken); - const record = this.oauthStore.getRefreshToken(refreshTokenHash); + const record = this.store.getRefreshToken(refreshTokenHash); if (!record || record.clientId !== client.client_id || record.expiresAt < Math.floor(Date.now() / 1000)) { + if (record) { + this.store.deleteRefreshToken(refreshTokenHash); + } throw new InvalidGrantError("Invalid refresh token"); } if (resource && !checkResourceAllowed({ requestedResource: resource, configuredResource: this.resourceServerUrl })) { @@ -236,8 +279,8 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { } async verifyAccessToken(token: string): Promise { - const record = this.oauthStore.getAccessToken(hashToken(token)); - if (!record || record.expiresAt < Math.floor(Date.now() / 1000)) { + const record = this.store.getAccessToken(hashToken(token)); + if (!record) { throw new InvalidTokenError("Invalid or expired access token"); } @@ -252,19 +295,18 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { async revokeToken(_client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest): Promise { const hashed = hashToken(request.token); - this.oauthStore.deleteAccessToken(hashed); - this.oauthStore.deleteRefreshToken(hashed); + this.store.revokeToken(hashed); } close(): void { - this.oauthStore.close(); + this.store.close(); } private validCodeRecord( client: OAuthClientInformationFull, authorizationCode: string, ): AuthorizationCodeRecord { - const record = this.codes.get(authorizationCode); + const record = this.store.getAuthorizationCode(hashToken(authorizationCode)); if (!record || record.clientId !== client.client_id || record.expiresAtMs < Date.now()) { throw new InvalidGrantError("Invalid authorization code"); } @@ -283,7 +325,7 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { const accessExpiresAt = now + this.config.accessTokenTtlSeconds; const refreshExpiresAt = now + this.config.refreshTokenTtlSeconds; - const saved = this.oauthStore.saveTokenPair( + const saved = this.store.saveTokenPair( { accessTokenHash: hashToken(accessToken), accessToken: { @@ -335,3 +377,7 @@ function authorizationFormFields( function hashToken(token: string): string { return createHash("sha256").update(token).digest("base64url"); } + +function normalizeScopes(scopes: string[]): string[] { + return [...scopes].sort(); +} diff --git a/src/oauth-store.test.ts b/src/oauth-store.test.ts index 2f2a873..4dce405 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -1,12 +1,13 @@ import assert from "node:assert/strict"; import { createHash } from "node:crypto"; -import { mkdtemp, rm, stat } from "node:fs/promises"; +import { mkdtemp, stat } 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 { databasePath, openDatabase } from "./db/client.js"; import { SingleUserOAuthProvider } from "./oauth-provider.js"; import { SqliteOAuthClientsStore, SqliteOAuthStore } from "./oauth-store.js"; +import { removeTempDir } from "./test-utils.js"; const root = await mkdtemp(join(tmpdir(), "devspace-oauth-test-")); const oauthConfig = { @@ -26,7 +27,7 @@ try { testTransactionalTokenRotation(join(root, "rotation")); await testProviderRestartRotationAndRevocation(join(root, "provider")); } finally { - await rm(root, { recursive: true, force: true }); + await removeTempDir(root); } async function testDatabaseConfiguration(stateDir: string): Promise { @@ -191,7 +192,7 @@ async function testProviderRestartRotationAndRevocation(stateDir: string): Promi assert.ok(client); const code = "code-test-123"; - firstProvider["codes"].set(code, { + (firstProvider as any).store.saveAuthorizationCode(hashToken(code), { clientId: client.client_id, params: { redirectUri, diff --git a/src/oauth-store.ts b/src/oauth-store.ts index 2567a40..a9b5e9d 100644 --- a/src/oauth-store.ts +++ b/src/oauth-store.ts @@ -1,84 +1,163 @@ import { randomUUID } from "node:crypto"; import type { OAuthRegisteredClientsStore } from "@modelcontextprotocol/sdk/server/auth/clients.js"; +import type { AuthorizationParams } from "@modelcontextprotocol/sdk/server/auth/provider.js"; import { InvalidRequestError } from "@modelcontextprotocol/sdk/server/auth/errors.js"; import type { OAuthClientInformationFull } from "@modelcontextprotocol/sdk/shared/auth.js"; +import { existsSync, readFileSync, statSync } from "node:fs"; +import { dirname } from "node:path"; import { openDatabase, type DatabaseHandle } from "./db/client.js"; -export interface PersistedAccessTokenRecord { +export interface AuthorizationCodeRecord { + clientId: string; + params: AuthorizationParams; + expiresAtMs: number; +} + +export interface TokenRecord { clientId: string; scopes: string[]; expiresAt: number; resource?: string; } -export interface PersistedRefreshTokenRecord { +export interface ConsentRecord { clientId: string; + redirectUri: string; + resource: string; scopes: string[]; - expiresAt: number; - resource?: string; + approvedAt: number; } export interface PersistedTokenPair { accessTokenHash: string; - accessToken: PersistedAccessTokenRecord; + accessToken: TokenRecord; refreshTokenHash: string; - refreshToken: PersistedRefreshTokenRecord; + refreshToken: TokenRecord; } -function redirectHostAllowed(redirectUri: string, allowedHosts: string[]): boolean { - let parsed: URL; - try { - parsed = new URL(redirectUri); - } catch { - return false; - } +interface SerializedAuthorizationParams extends Omit { + resource?: string; +} - if (["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname)) return true; - return allowedHosts.includes(parsed.hostname); +interface StoredTokenRecord { + tokenHash?: string; + clientId?: string; + scopes?: string[]; + expiresAt?: number; + resource?: string; +} + +interface StoredConsentRecord { + clientId?: string; + redirectUri?: string; + resource?: string; + scopes?: string[]; + approvedAt?: number; +} + +interface StoredOAuthState { + clients?: OAuthClientInformationFull[]; + accessTokens?: StoredTokenRecord[]; + refreshTokens?: StoredTokenRecord[]; + approvedConsents?: StoredConsentRecord[]; } export class SqliteOAuthStore { private readonly database: DatabaseHandle; - constructor(stateDir: string) { + constructor(stateDirOrPath: string, legacyStatePath?: string) { + const statePath = legacyStatePath ?? inferLegacyStatePath(stateDirOrPath); + const stateDir = legacyStatePath + ? stateDirOrPath + : statePath + ? dirname(statePath) + : stateDirOrPath; this.database = openDatabase(stateDir); - this.deleteExpiredTokens(Math.floor(Date.now() / 1000)); + this.importLegacyState(statePath); + this.deleteExpired(); } getClient(clientId: string): OAuthClientInformationFull | undefined { const row = this.database.sqlite .prepare("select client_json from oauth_clients where client_id = ?") .get(clientId) as { client_json: string } | undefined; - return row ? (JSON.parse(row.client_json) as OAuthClientInformationFull) : undefined; } - registerClient( - client: Omit, - allowedRedirectHosts: string[], - ): OAuthClientInformationFull { - if (!client.redirect_uris.every((uri) => redirectHostAllowed(String(uri), allowedRedirectHosts))) { - throw new InvalidRequestError("Client redirect_uri is not allowed for this DevSpace server"); - } + listClients(): OAuthClientInformationFull[] { + const rows = this.database.sqlite + .prepare("select client_json from oauth_clients order by created_at asc") + .all() as { client_json: string }[]; + return rows.map((row) => JSON.parse(row.client_json) as OAuthClientInformationFull); + } - const now = Math.floor(Date.now() / 1000); - const registered: OAuthClientInformationFull = { - ...client, - client_id: `devspace-${randomUUID()}`, - client_id_issued_at: now, - token_endpoint_auth_method: client.token_endpoint_auth_method ?? "none", - grant_types: client.grant_types ?? ["authorization_code", "refresh_token"], - response_types: client.response_types ?? ["code"], + saveClient(client: OAuthClientInformationFull): void { + this.database.sqlite + .prepare("insert or replace into oauth_clients (client_id, client_json, created_at) values (?, ?, ?)") + .run(client.client_id, JSON.stringify(client), client.client_id_issued_at ?? Math.floor(Date.now() / 1000)); + } + + getAuthorizationCode(codeHash: string): AuthorizationCodeRecord | undefined { + const row = this.database.sqlite + .prepare("select client_id, params_json, expires_at_ms from oauth_authorization_codes where code_hash = ?") + .get(codeHash) as { + client_id: string; + params_json: string; + expires_at_ms: number; + } | undefined; + if (!row) return undefined; + if (row.expires_at_ms < Date.now()) { + this.deleteAuthorizationCode(codeHash); + return undefined; + } + return { + clientId: row.client_id, + params: deserializeAuthorizationParams(row.params_json), + expiresAtMs: row.expires_at_ms, }; + } + saveAuthorizationCode(codeHash: string, record: AuthorizationCodeRecord): void { this.database.sqlite - .prepare("insert into oauth_clients (client_id, client_json, issued_at) values (?, ?, ?)") - .run(registered.client_id, JSON.stringify(registered), now); + .prepare( + "insert or replace into oauth_authorization_codes (code_hash, client_id, params_json, expires_at_ms) values (?, ?, ?, ?)", + ) + .run(codeHash, record.clientId, serializeAuthorizationParams(record.params), record.expiresAtMs); + } - return registered; + deleteAuthorizationCode(codeHash: string): void { + this.database.sqlite + .prepare("delete from oauth_authorization_codes where code_hash = ?") + .run(codeHash); } - saveAccessToken(tokenHash: string, record: PersistedAccessTokenRecord): void { + getAccessToken(tokenHash: string): TokenRecord | undefined { + const row = this.database.sqlite + .prepare( + "select client_id, scopes_json, expires_at, resource from oauth_access_tokens where token_hash = ?", + ) + .get(tokenHash) as + | { + client_id: string; + scopes_json: string; + expires_at: number; + resource: string | null; + } + | undefined; + if (!row) return undefined; + if (row.expires_at < Math.floor(Date.now() / 1000)) { + this.deleteAccessToken(tokenHash); + return undefined; + } + return { + clientId: row.client_id, + scopes: JSON.parse(row.scopes_json) as string[], + expiresAt: row.expires_at, + resource: row.resource ?? undefined, + }; + } + + saveAccessToken(tokenHash: string, record: TokenRecord): void { this.database.sqlite .prepare( `insert into oauth_access_tokens (token_hash, client_id, scopes_json, expires_at, resource) @@ -98,10 +177,14 @@ export class SqliteOAuthStore { ); } - getAccessToken(tokenHash: string): PersistedAccessTokenRecord | undefined { + deleteAccessToken(tokenHash: string): void { + this.database.sqlite.prepare("delete from oauth_access_tokens where token_hash = ?").run(tokenHash); + } + + getRefreshToken(tokenHash: string): TokenRecord | undefined { const row = this.database.sqlite .prepare( - "select client_id, scopes_json, expires_at, resource from oauth_access_tokens where token_hash = ?", + "select client_id, scopes_json, expires_at, resource from oauth_refresh_tokens where token_hash = ?", ) .get(tokenHash) as | { @@ -111,15 +194,20 @@ export class SqliteOAuthStore { resource: string | null; } | undefined; - - return row ? rowToAccessTokenRecord(row) : undefined; - } - - deleteAccessToken(tokenHash: string): void { - this.database.sqlite.prepare("delete from oauth_access_tokens where token_hash = ?").run(tokenHash); + if (!row) return undefined; + if (row.expires_at < Math.floor(Date.now() / 1000)) { + this.deleteRefreshToken(tokenHash); + return undefined; + } + return { + clientId: row.client_id, + scopes: JSON.parse(row.scopes_json) as string[], + expiresAt: row.expires_at, + resource: row.resource ?? undefined, + }; } - saveRefreshToken(tokenHash: string, record: PersistedRefreshTokenRecord): void { + saveRefreshToken(tokenHash: string, record: TokenRecord): void { this.database.sqlite .prepare( `insert into oauth_refresh_tokens (token_hash, client_id, scopes_json, expires_at, resource) @@ -156,34 +244,136 @@ export class SqliteOAuthStore { return save.immediate(); } - getRefreshToken(tokenHash: string): PersistedRefreshTokenRecord | undefined { + deleteRefreshToken(tokenHash: string): void { + this.database.sqlite.prepare("delete from oauth_refresh_tokens where token_hash = ?").run(tokenHash); + } + + revokeToken(tokenHash: string): void { + this.deleteAccessToken(tokenHash); + this.deleteRefreshToken(tokenHash); + } + + getConsent(key: string): ConsentRecord | undefined { const row = this.database.sqlite - .prepare( - "select client_id, scopes_json, expires_at, resource from oauth_refresh_tokens where token_hash = ?", - ) - .get(tokenHash) as - | { - client_id: string; - scopes_json: string; - expires_at: number; - resource: string | null; + .prepare("select client_id, redirect_uri, resource, scopes_json, approved_at from oauth_consents where consent_key = ?") + .get(key) as { + client_id: string; + redirect_uri: string; + resource: string; + scopes_json: string; + approved_at: number; + } | undefined; + return row + ? { + clientId: row.client_id, + redirectUri: row.redirect_uri, + resource: row.resource, + scopes: JSON.parse(row.scopes_json) as string[], + approvedAt: row.approved_at, } - | undefined; + : undefined; + } - return row ? rowToRefreshTokenRecord(row) : undefined; + saveConsent(key: string, record: ConsentRecord): void { + this.database.sqlite + .prepare("insert or replace into oauth_consents (consent_key, client_id, redirect_uri, resource, scopes_json, approved_at) values (?, ?, ?, ?, ?, ?)") + .run( + key, + record.clientId, + record.redirectUri, + record.resource, + JSON.stringify(record.scopes), + record.approvedAt, + ); } - deleteRefreshToken(tokenHash: string): void { - this.database.sqlite.prepare("delete from oauth_refresh_tokens where token_hash = ?").run(tokenHash); + deleteClientConsents(clientId: string): void { + this.database.sqlite.prepare("delete from oauth_consents where client_id = ?").run(clientId); + } + + resetState(): void { + this.database.sqlite.exec(` + delete from oauth_authorization_codes; + delete from oauth_access_tokens; + delete from oauth_refresh_tokens; + delete from oauth_consents; + `); } close(): void { this.database.close(); } - private deleteExpiredTokens(nowSeconds: number): void { - this.database.sqlite.prepare("delete from oauth_access_tokens where expires_at < ?").run(nowSeconds); - this.database.sqlite.prepare("delete from oauth_refresh_tokens where expires_at < ?").run(nowSeconds); + private deleteExpired(): void { + this.database.sqlite + .prepare("delete from oauth_authorization_codes where expires_at_ms < ?") + .run(Date.now()); + this.database.sqlite + .prepare("delete from oauth_access_tokens where expires_at < ?") + .run(Math.floor(Date.now() / 1000)); + this.database.sqlite + .prepare("delete from oauth_refresh_tokens where expires_at < ?") + .run(Math.floor(Date.now() / 1000)); + } + + private importLegacyState(statePath: string | undefined): void { + if (!statePath || !existsSync(statePath)) return; + + let state: StoredOAuthState; + try { + const raw = readFileSync(statePath, "utf8"); + if (!raw.trim()) return; + state = JSON.parse(raw) as StoredOAuthState; + } catch { + return; + } + + const mtime = statSync(statePath).mtimeMs; + const imported = this.database.sqlite + .prepare("select value from oauth_metadata where key = ?") + .get("legacy_json_import_mtime") as { value: string } | undefined; + if (imported?.value === String(mtime)) return; + + const now = Math.floor(Date.now() / 1000); + const transaction = this.database.sqlite.transaction(() => { + for (const client of state.clients ?? []) { + if (typeof client?.client_id !== "string") continue; + this.saveClient(client); + } + for (const record of state.accessTokens ?? []) { + if (!isStoredTokenRecord(record) || record.expiresAt < now) continue; + this.saveAccessToken(record.tokenHash, { + clientId: record.clientId, + scopes: record.scopes, + expiresAt: record.expiresAt, + resource: record.resource, + }); + } + for (const record of state.refreshTokens ?? []) { + if (!isStoredTokenRecord(record) || record.expiresAt < now) continue; + this.saveRefreshToken(record.tokenHash, { + clientId: record.clientId, + scopes: record.scopes, + expiresAt: record.expiresAt, + resource: record.resource, + }); + } + for (const record of state.approvedConsents ?? []) { + if (!isStoredConsentRecord(record)) continue; + const scopes = normalizeScopes(record.scopes); + this.saveConsent(consentKey(record.clientId, record.redirectUri, record.resource, scopes), { + clientId: record.clientId, + redirectUri: record.redirectUri, + resource: record.resource, + scopes, + approvedAt: record.approvedAt, + }); + } + this.database.sqlite + .prepare("insert or replace into oauth_metadata (key, value) values (?, ?)") + .run("legacy_json_import_mtime", String(mtime)); + }); + transaction(); } } @@ -200,34 +390,75 @@ export class SqliteOAuthClientsStore implements OAuthRegisteredClientsStore { registerClient( client: Omit, ): OAuthClientInformationFull { - return this.store.registerClient(client, this.allowedRedirectHosts); + if (!client.redirect_uris.every((uri) => redirectHostAllowed(String(uri), this.allowedRedirectHosts))) { + throw new InvalidRequestError("Client redirect_uri is not allowed for this DevSpace server"); + } + + const now = Math.floor(Date.now() / 1000); + const registered: OAuthClientInformationFull = { + ...client, + client_id: `devspace-${randomUUID()}`, + client_id_issued_at: now, + token_endpoint_auth_method: client.token_endpoint_auth_method ?? "none", + grant_types: client.grant_types ?? ["authorization_code", "refresh_token"], + response_types: client.response_types ?? ["code"], + }; + this.store.saveClient(registered); + return registered; } } -function rowToAccessTokenRecord(row: { - client_id: string; - scopes_json: string; - expires_at: number; - resource: string | null; -}): PersistedAccessTokenRecord { - return { - clientId: row.client_id, - scopes: JSON.parse(row.scopes_json) as string[], - expiresAt: row.expires_at, - resource: row.resource ?? undefined, - }; +export function consentKey(clientId: string, redirectUri: string, resource: string, scopes: string[]): string { + return [clientId, redirectUri, resource, normalizeScopes(scopes).join(" ")].join("\n"); +} + +function normalizeScopes(scopes: string[]): string[] { + return Array.from(new Set(scopes)).sort(); +} + +function serializeAuthorizationParams(params: AuthorizationParams): string { + return JSON.stringify({ ...params, resource: params.resource?.href }); } -function rowToRefreshTokenRecord(row: { - client_id: string; - scopes_json: string; - expires_at: number; - resource: string | null; -}): PersistedRefreshTokenRecord { +function deserializeAuthorizationParams(value: string): AuthorizationParams { + const parsed = JSON.parse(value) as SerializedAuthorizationParams; return { - clientId: row.client_id, - scopes: JSON.parse(row.scopes_json) as string[], - expiresAt: row.expires_at, - resource: row.resource ?? undefined, + ...parsed, + resource: parsed.resource ? new URL(parsed.resource) : undefined, }; } + +function redirectHostAllowed(redirectUri: string, allowedHosts: string[]): boolean { + let parsed: URL; + try { + parsed = new URL(redirectUri); + } catch { + return false; + } + + if (["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname)) return true; + return allowedHosts.includes(parsed.hostname); +} + +function isStoredTokenRecord(record: StoredTokenRecord): record is Required> & { resource?: string } { + return ( + typeof record?.tokenHash === "string" && + typeof record?.clientId === "string" && + Array.isArray(record?.scopes) && + typeof record?.expiresAt === "number" + ); +} + +function isStoredConsentRecord(record: StoredConsentRecord): record is Required { + return ( + typeof record?.clientId === "string" && + typeof record?.redirectUri === "string" && + typeof record?.resource === "string" && + Array.isArray(record?.scopes) && + typeof record?.approvedAt === "number" + ); +} + +function inferLegacyStatePath(path: string): string | undefined { + return path.endsWith(".json") ? path : undefined; +} diff --git a/src/package-smoke.test.ts b/src/package-smoke.test.ts new file mode 100644 index 0000000..fb367f3 --- /dev/null +++ b/src/package-smoke.test.ts @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const packageJson = JSON.parse(readFileSync(resolve(projectRoot, "package.json"), "utf8")) as { + files?: unknown; + version?: unknown; +}; + +assert.equal(Array.isArray(packageJson.files), true); +assert.equal((packageJson.files as string[]).includes("skills"), true); +assert.equal(typeof packageJson.version, "string"); +const packageVersion = packageJson.version as string; + +const requiredAssets = [ + "skills/.system/README.md", + "skills/.system/plan/SKILL.md", + "skills/.system/plan/references/state.md", + "skills/.system/goal/SKILL.md", + "skills/.system/goal/references/metrics.md", + "skills/.system/workflow/SKILL.md", + "skills/.system/workflow/references/routing.md", + "skills/.system/architecture-review/SKILL.md", + "skills/.system/skill-authoring/SKILL.md", +]; + +for (const path of requiredAssets) { + assert.equal(existsSync(resolve(projectRoot, path)), true, `Missing bundled Skill asset: ${path}`); +} + +for (const path of requiredAssets.filter((asset) => asset.endsWith("/SKILL.md"))) { + const contents = normalizeNewlines(readFileSync(resolve(projectRoot, path), "utf8")); + assert.match( + contents, + new RegExp(`\\n version: ${escapeRegExp(packageVersion)}\\n`), + `${path} must track package.json version`, + ); +} + +for (const removedPath of [ + "skills/openai", + "skills/.system/devspace-plan", + "skills/.system/devspace-goal", + "skills/.system/devspace-workflow", + "skills/.system/senior-architect-lite", + "skills/.system/skill-authoring-lite", +]) { + assert.equal(existsSync(resolve(projectRoot, removedPath)), false, `Unexpected legacy Skill path: ${removedPath}`); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function normalizeNewlines(value: string): string { + return value.replace(/\r\n/g, "\n"); +} 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..07c85ec --- /dev/null +++ b/src/prompting.test.ts @@ -0,0 +1,44 @@ +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 the user mentions a Skill name, \/plan, or \/goal, use resolve_skill to load the relevant SKILL\.md instructions\./); +assert.match(instructions, /Plan and Goal as project-scoped shared workflow state/); +assert.match(instructions, /open_workspace returns only workflowDigest/); +assert.match(instructions, /update_plan is allowed in plan mode/); +assert.match(instructions, /\/plan always resolves to DevSpace's system plan Skill/); +assert.match(instructions, /Treat \/plan and \/goal as aliases, not native ChatGPT slash commands\./); +assert.match(instructions, /Use handle_workspace_command only for compact pending-input replies or legacy workflow compatibility\./); + +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, /use update_plan with its expectedRevision to persist the revised Plan/); +assert.match(planInstruction, /Do not modify project files while plan mode is active\./); + +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..5fdb453 --- /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 a task matches a Skill, use resolve_skill to load its SKILL.md instructions. Use search_skills to discover optional project-local, installed, and global Skills without loading all of them. Skill resources use skill:// locators; ${toolNames.read} only permits the resolved SKILL.md and resources under an activated Skill directory. ` + : ""; + + 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 = + " Treat Plan and Goal as project-scoped shared workflow state, not chat memory or a project-management system. open_workspace returns only workflowDigest; call get_plan or get_goal only when their full state is needed. Before changing a Plan or Goal, read its revision and pass expectedRevision to update_plan or update_goal. In plan mode, inspect and ask material questions first, then persist the approved Plan with update_plan; update_plan is allowed in plan mode. Use get_workflow_history only when a concise historical event is relevant."; + + 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 the user mentions a Skill name, /plan, or /goal, use resolve_skill to load the relevant SKILL.md instructions. /plan always resolves to DevSpace's system plan Skill and /goal always resolves to its system goal Skill; local, installed, and global Skills do not override these aliases. Treat /plan and /goal as aliases, not native ChatGPT slash commands. Use handle_workspace_command only for compact pending-input replies or legacy workflow compatibility. For concise pending-input replies, prefer 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, skill, 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. Use resolve_skill for task-matched Skills and search_skills for optional Skill discovery." + : "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. Read get_plan when a prior Plan exists, then use update_plan with its expectedRevision to persist the revised Plan. Do not modify project files while plan mode is active.`; + } + + return `${base} This workspace is currently in default mode: execute work directly, keep status updates brief, and keep the current Plan and Goal accurate when they are relevant. Do not add unnecessary explanation for straightforward actions or results.`; +} diff --git a/src/server.ts b/src/server.ts index bfcd7af..6c20e82 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,13 +40,43 @@ import { } from "./pi-tools.js"; import { SingleUserOAuthProvider } from "./oauth-provider.js"; import { createReviewCheckpointManager } from "./review-checkpoints.js"; -import { formatPathForPrompt } from "./skills.js"; +import { validateShellCommand } from "./shell-policy.js"; +import { + resolveSkillDefinition, + skillSourceLabel, + type DevSpaceSkill, + type SkillResolveMode, + type SkillSource, +} from "./skills.js"; +import { + installSkill, + listInstalledSkills, + removeInstalledSkill, + type InstalledSkillRecord, + type SkillInstallSource, +} from "./skill-manager.js"; +import { normalizeGoalDefinition } from "./goal-definition.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 { + WorkflowDigest, + WorkspaceGoal, + WorkspacePlan, + WorkspacePlanStep, + WorkspaceQuestion, + WorkspaceStore, + WorkspaceUserInputAnswer, + WorkspaceUserInputRecord, +} from "./workspace-store.js"; type Transport = StreamableHTTPServerTransport; const WORKSPACE_APP_URI = "ui://devspace/workspace-app.html"; const WORKSPACE_APP_MANIFEST_ENTRY = "workspace-app.html"; +const MAX_OPEN_WORKSPACE_SKILLS = 24; const WRITE_TOOL_ANNOTATIONS = { readOnlyHint: false, destructiveHint: true, @@ -68,10 +102,6 @@ interface RunningServer { close(): void; } -type ToolContent = - | { type: "text"; text: string } - | { type: "image"; data: string; mimeType: string }; - interface WorkspaceAppManifestEntry { file: string; css?: string[]; @@ -87,12 +117,15 @@ interface DiffStats { type ToolWidgetKind = | "workspace" + | "plan" + | "goal" | "read" | "write" | "edit" | "search" | "directory" | "shell" + | "safe_operation" | "show_changes"; interface ToolDefinitionMeta extends Record { @@ -100,6 +133,8 @@ interface ToolDefinitionMeta extends Record { resourceUri: string; visibility: ["model"]; }; + "ui/resourceUri": string; + "openai/outputTemplate": string; } type EmptyToolDefinitionMeta = Record & { @@ -133,11 +168,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"; @@ -184,24 +221,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 @@ -213,10 +232,138 @@ function resultOutputSchema(extra: z.ZodRawShape = {}): z.ZodRawShape { }; } +const skillSourceOutputSchema = z.enum([ + "devspace_system", + "local", + "installed", + "global", +]); + const workspaceSkillOutputSchema = z.object({ name: z.string(), description: z.string(), path: z.string(), + source: skillSourceOutputSchema, +}); + +const installedSkillOutputSchema = z.object({ + name: z.string(), + description: z.string(), + scope: z.enum(["workspace", "global"]), + path: z.string(), + removable: z.boolean(), + sourceType: z.enum(["workspace-installed", "global-installed"]), +}); + +const resolvedSkillOutputSchema = z.object({ + name: z.string(), + qualifiedId: z.string(), + source: skillSourceOutputSchema, + path: z.string(), + alias: z.string().optional(), + mode: z.enum(["read_only", "normal"]), + instructions: z.string(), +}); + +const workflowScopeOutputSchema = z.object({ + in: z.array(z.string()), + out: z.array(z.string()), +}); + +const workflowPlanStepOutputSchema = z.object({ + id: z.string().optional(), + step: z.string(), + status: z.enum(["pending", "in_progress", "blocked", "completed", "skipped"]), + note: z.string().optional(), + updatedAt: z.string().optional(), +}); + +const workflowPlanOutputSchema = z.object({ + id: z.string(), + projectWorkflowKey: z.string(), + goalId: z.string().optional(), + title: z.string(), + summary: z.string().optional(), + scope: workflowScopeOutputSchema, + validation: z.array(z.string()), + risks: z.array(z.string()), + status: z.enum(["draft", "active", "completed", "archived"]), + revision: z.number().int().positive(), + steps: z.array(workflowPlanStepOutputSchema), + createdAt: z.string(), + updatedAt: z.string(), + archivedAt: z.string().optional(), +}); + +const goalTokenUsageOutputSchema = z.object({ + inputTokens: z.number().int().nonnegative(), + outputTokens: z.number().int().nonnegative(), + reasoningTokens: z.number().int().nonnegative(), + totalTokens: z.number().int().nonnegative(), + reportCount: z.number().int().nonnegative(), + lastReportedAt: z.string().optional(), +}); + +const goalWorkDurationOutputSchema = z.object({ + running: z.boolean(), + startedAt: z.string().optional(), + accumulatedMilliseconds: z.number().int().nonnegative(), + liveMilliseconds: z.number().int().nonnegative(), + totalMilliseconds: z.number().int().nonnegative(), + measuredAt: z.string(), +}); + +const goalProgressOutputSchema = z.object({ + source: z.enum(["linked_plan_steps", "unlinked"]), + completedSteps: z.number().int().nonnegative(), + totalSteps: z.number().int().nonnegative(), + exactFraction: z.string().optional(), + percentageNumerator: z.number().int().nonnegative().optional(), + percentageDenominator: z.number().int().positive().optional(), + displayPercent: z.string().optional(), +}); + +const goalMetricsOutputSchema = z.object({ + tokenUsage: goalTokenUsageOutputSchema, + workDuration: goalWorkDurationOutputSchema, + progress: goalProgressOutputSchema, + updatedAt: z.string().optional(), +}); + +const workflowGoalOutputSchema = z.object({ + id: z.string(), + projectWorkflowKey: z.string(), + objective: z.string(), + scope: workflowScopeOutputSchema, + successCriteria: z.array(z.string()), + verification: z.array(z.string()), + stopConditions: z.array(z.string()), + currentSummary: z.string().optional(), + status: z.enum(["active", "blocked", "completed", "archived"]), + revision: z.number().int().positive(), + metrics: goalMetricsOutputSchema, + createdAt: z.string(), + updatedAt: z.string(), + archivedAt: z.string().optional(), +}); + +const workflowDigestOutputSchema = z.object({ + projectWorkflowKey: z.string(), + hasActiveGoal: z.boolean(), + goalStatus: z.enum(["active", "blocked", "completed", "archived"]).optional(), + goalTitle: z.string().optional(), + hasActivePlan: z.boolean(), + planStatus: z.enum(["draft", "active", "completed", "archived"]).optional(), + planRevision: z.number().int().positive().optional(), + steps: z + .object({ + total: z.number().int().nonnegative(), + completed: z.number().int().nonnegative(), + inProgress: z.number().int().nonnegative(), + blocked: z.number().int().nonnegative(), + }) + .optional(), + lastUpdatedAt: z.string().optional(), }); const workspaceAgentsFileOutputSchema = z.object({ @@ -228,6 +375,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(), @@ -276,15 +458,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; @@ -309,17 +482,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") @@ -430,6 +592,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)); } @@ -456,6 +629,7 @@ function createMcpServer( config: ServerConfig, workspaces: WorkspaceRegistry, reviewCheckpoints: ReturnType, + workspaceStore: WorkspaceStore, ): McpServer { const toolNames = toolNamesFor(config); const server = new McpServer( @@ -467,7 +641,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, + ), }, ); @@ -481,6 +662,9 @@ function createMcpServer( ui: { csp: appCsp(config), }, + "openai/widgetDescription": "Interactive DevSpace workspace and file-change view.", + "openai/widgetPrefersBorder": true, + "openai/widgetCSP": openAiWidgetCsp(config), }, }, async () => { @@ -495,6 +679,9 @@ function createMcpServer( ui: { csp: appCsp(config), }, + "openai/widgetDescription": "Interactive DevSpace workspace and file-change view.", + "openai/widgetPrefersBorder": true, + "openai/widgetCSP": openAiWidgetCsp(config), }, }, ], @@ -512,8 +699,9 @@ function createMcpServer( inputSchema: { path: z .string() + .optional() .describe( - "Absolute path, or a leading-tilde home path such as ~/project, to a local project directory inside an allowed root.", + "Absolute path, or a leading-tilde home path such as ~/project, to a local project directory inside an allowed root. Omit this only when the server session has a configured default workspace.", ), mode: z .enum(["checkout", "worktree"]) @@ -544,8 +732,15 @@ function createMcpServer( agentsFiles: z.array(workspaceAgentsFileOutputSchema), availableAgentsFiles: z.array(workspaceAvailableAgentsFileOutputSchema), skills: z.array(workspaceSkillOutputSchema), + skillsTruncated: z.boolean(), skillDiagnostics: z.array(z.unknown()), instruction: z.string(), + collaborationMode: z.enum(["default", "plan"]), + workflowDigest: workflowDigestOutputSchema, + skillSummary: z.object({ + total: z.number().int().nonnegative(), + bySource: z.record(skillSourceOutputSchema, z.number().int().nonnegative()), + }), }, ...toolWidgetDescriptorMeta(config, "workspace"), annotations: { readOnlyHint: true }, @@ -559,13 +754,24 @@ function createMcpServer( root: workspace.root, }); } - const visibleSkills = workspace.skills + const discoverableSkills = workspace.skills .filter((skill) => !skill.disableModelInvocation) + .filter((skill) => ( + skill.source === "devspace_system" || + skill.source === "local" || + skill.source === "installed" + )) + .sort((left, right) => left.name.localeCompare(right.name)); + const skillsTruncated = discoverableSkills.length > MAX_OPEN_WORKSPACE_SKILLS; + const visibleSkills = discoverableSkills + .slice(0, MAX_OPEN_WORKSPACE_SKILLS) .map((skill) => ({ name: skill.name, description: skill.description, - path: formatPathForPrompt(skill.filePath), + path: skill.locator, + source: skill.source, })); + const skillSummary = summarizeSkills(workspace.skills); const loadedAgentsFiles = agentsFiles.map((file) => ({ path: formatAgentsPath(file.path, workspace.root), content: file.content, @@ -573,9 +779,9 @@ 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 workflowDigest = workspaceStore.getWorkflowDigest(workspace.id); + const instruction = workspaceInstruction(collaboration.mode, config.skillsEnabled); const resultContent: ToolContent[] = [ { type: "text" as const, @@ -590,8 +796,9 @@ function createMcpServer( ? `Available nested instructions: ${availableAgentsFileOutputs.map((file) => file.path).join(", ")}` : undefined, visibleSkills.length > 0 - ? `Available skills: ${visibleSkills.map((skill) => skill.name).join(", ")}` + ? `Available core and project skills: ${visibleSkills.map((skill) => skill.name).join(", ")}${skillsTruncated ? ` (showing first ${MAX_OPEN_WORKSPACE_SKILLS}; use search_skills for more)` : ""}` : undefined, + `Workflow: ${formatWorkflowDigest(workflowDigest)}`, instruction, ].filter(Boolean).join("\n"), }, @@ -604,35 +811,1477 @@ function createMcpServer( 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, - }, + 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: skillSummary.total, + visibleSkills: visibleSkills.length, + skillsTruncated, + skillDiagnostics: workspace.skillDiagnostics.length, + workflow: workflowDigest, + }, + }, + }, + structuredContent: { + workspaceId: workspace.id, + root: workspace.root, + mode: workspace.mode, + sourceRoot: workspace.sourceRoot, + worktree: workspace.worktree, + agentsFiles: loadedAgentsFiles, + availableAgentsFiles: availableAgentsFileOutputs, + skills: visibleSkills, + skillsTruncated, + skillDiagnostics: workspace.skillDiagnostics, + instruction, + collaborationMode: collaboration.mode, + workflowDigest, + skillSummary, + }, + }; + }, + ); + + registerAppTool( + server, + "resolve_skill", + { + title: "Resolve skill", + description: + "Resolve a skill name or alias such as /plan or /goal for the current workspace. This tool only reads and returns skill instructions; it does not execute installation, file changes, or commands.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + nameOrAlias: z.string().describe("Skill name or stable alias such as plan, goal, /plan, or /goal."), + }, + outputSchema: { + result: z.string(), + skill: resolvedSkillOutputSchema, + }, + ...toolWidgetDescriptorMeta(config, "workspace"), + annotations: { readOnlyHint: true }, + }, + async ({ workspaceId, nameOrAlias }) => { + const startedAt = performance.now(); + const workspace = workspaces.getWorkspace(workspaceId); + try { + const resolved = await resolveSkillDefinition(workspace.skills, nameOrAlias); + const content = [textBlock(resolved.instructions)]; + + logToolCall(config, { + tool: "resolve_skill", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return { + content, + _meta: { + tool: "resolve_skill", + card: { + workspaceId, + path: resolved.path, + summary: { + source: resolved.source, + mode: resolved.mode, + alias: resolved.alias, + }, + payload: { content }, + }, + }, + structuredContent: { + result: contentText(content), + skill: { + name: resolved.name, + qualifiedId: resolved.qualifiedId, + source: resolved.source, + path: resolved.path, + alias: resolved.alias, + mode: resolved.mode, + instructions: resolved.instructions, + }, + }, + }; + } catch (error) { + const response = toolError(error instanceof Error ? error.message : String(error)); + logFailedToolResponse(config, { + tool: "resolve_skill", + workspaceId, + }, response.content, startedAt); + return response; + } + }, + ); + + registerAppTool( + server, + "search_skills", + { + title: "Search skills", + description: + "Search available DevSpace, project, installed, global, and vendored OpenAI Skills without loading their full instructions. Resolve a returned qualifiedId only when the task needs that Skill.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + query: z.string().optional().describe("Case-insensitive name or description search."), + source: skillSourceOutputSchema.optional(), + limit: z.number().int().positive().max(50).optional(), + cursor: z.string().optional(), + }, + outputSchema: { + result: z.string(), + skills: z.array(z.object({ + qualifiedId: z.string(), + name: z.string(), + description: z.string(), + source: skillSourceOutputSchema, + locator: z.string(), + })), + nextCursor: z.string().optional(), + }, + ...toolWidgetDescriptorMeta(config, "workspace"), + annotations: { readOnlyHint: true }, + }, + async ({ workspaceId, query, source, limit, cursor }) => { + const startedAt = performance.now(); + const workspace = workspaces.getWorkspace(workspaceId); + try { + const page = searchWorkspaceSkills(workspace.skills, { query, source, limit, cursor }); + const content = [textBlock( + page.skills.length === 0 + ? "No matching skills." + : page.skills.map((skill) => `${skill.qualifiedId} — ${skill.description}`).join("\n"), + )]; + logToolCall(config, { + tool: "search_skills", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + return { + content, + structuredContent: { + result: contentText(content), + skills: page.skills, + nextCursor: page.nextCursor, + }, + }; + } catch (error) { + const response = toolError(error instanceof Error ? error.message : String(error)); + logFailedToolResponse(config, { tool: "search_skills", workspaceId }, response.content, startedAt); + return response; + } + }, + ); + + 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, + "install_skill", + { + title: "Install skill", + description: + "Install a third-party skill into the current workspace or the global agent skill directory.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + scope: z.enum(["workspace", "global"]).optional(), + source: z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("local"), + path: z.string(), + }), + z.object({ + kind: z.literal("github"), + repo: z.string(), + path: z.string(), + ref: z.string().optional(), + }), + z.object({ + kind: z.literal("github_url"), + url: z.string(), + }), + ]), + }, + outputSchema: { + result: z.string(), + status: z.literal("installed"), + scope: z.enum(["workspace", "global"]), + skill: installedSkillOutputSchema, + sourceSummary: z.string(), + visibleInCurrentWorkspace: z.boolean(), + }, + ...toolWidgetDescriptorMeta(config, "workspace"), + annotations: WRITE_TOOL_ANNOTATIONS, + }, + async ({ workspaceId, scope, source }) => { + const startedAt = performance.now(); + const workspace = workspaces.getWorkspace(workspaceId); + try { + const installed = await installSkill({ + config, + workspaceRoot: workspace.root, + scope: scope ?? "workspace", + source: source as SkillInstallSource, + }); + const refreshed = workspaces.refreshWorkspaceSkills(workspaceId); + const visible = refreshed.skills.some((skill) => skill.name === installed.name); + const content = [textBlock(`Installed skill ${installed.name} (${installed.scope}).`)]; + + logToolCall(config, { + tool: "install_skill", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return { + content, + _meta: { + tool: "install_skill", + card: { + workspaceId, + status: "installed", + path: installed.path, + summary: { + scope: installed.scope, + visibleInCurrentWorkspace: visible, + }, + payload: { content }, + }, + }, + structuredContent: { + result: contentText(content), + status: "installed" as const, + scope: installed.scope, + skill: toInstalledSkillOutput(installed), + sourceSummary: installed.sourceSummary, + visibleInCurrentWorkspace: visible, + }, + }; + } catch (error) { + const response = toolError(error instanceof Error ? error.message : String(error)); + logFailedToolResponse(config, { + tool: "install_skill", + workspaceId, + }, response.content, startedAt); + return response; + } + }, + ); + + registerAppTool( + server, + "list_installed_skills", + { + title: "List installed skills", + description: + "List installed skills for the current workspace and optionally the global agent skill directory.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + scope: z.enum(["workspace", "global", "all"]).optional(), + }, + outputSchema: { + result: z.string(), + skills: z.array(installedSkillOutputSchema), + }, + ...toolWidgetDescriptorMeta(config, "workspace"), + annotations: { readOnlyHint: true }, + }, + async ({ workspaceId, scope }) => { + const startedAt = performance.now(); + const workspace = workspaces.getWorkspace(workspaceId); + try { + const skills = await listInstalledSkills({ + config, + workspaceRoot: workspace.root, + scope: scope ?? "workspace", + }); + const content = [textBlock(formatInstalledSkillsList(skills))]; + + logToolCall(config, { + tool: "list_installed_skills", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return { + content, + _meta: { + tool: "list_installed_skills", + card: { + workspaceId, + summary: { + skills: skills.length, + }, + payload: { content }, + }, + }, + structuredContent: { + result: contentText(content), + skills: skills.map(toInstalledSkillOutput), + }, + }; + } catch (error) { + const response = toolError(error instanceof Error ? error.message : String(error)); + logFailedToolResponse(config, { + tool: "list_installed_skills", + workspaceId, + }, response.content, startedAt); + return response; + } + }, + ); + + registerAppTool( + server, + "remove_skill", + { + title: "Remove skill", + description: + "Remove an installed skill from the current workspace or the global agent skill directory.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + scope: z.enum(["workspace", "global"]).optional(), + name: z.string(), + }, + outputSchema: { + result: z.string(), + status: z.literal("removed"), + scope: z.enum(["workspace", "global"]), + name: z.string(), + removedPath: z.string(), + visibleInCurrentWorkspace: z.boolean(), + }, + ...toolWidgetDescriptorMeta(config, "workspace"), + annotations: WRITE_TOOL_ANNOTATIONS, + }, + async ({ workspaceId, scope, name }) => { + const startedAt = performance.now(); + const workspace = workspaces.getWorkspace(workspaceId); + try { + const removed = await removeInstalledSkill({ + config, + workspaceRoot: workspace.root, + scope: scope ?? "workspace", + name, + }); + const refreshed = workspaces.refreshWorkspaceSkills(workspaceId); + const visible = refreshed.skills.some((skill) => skill.name === removed.name); + const content = [textBlock(`Removed skill ${removed.name} (${removed.scope}).`)]; + + logToolCall(config, { + tool: "remove_skill", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return { + content, + _meta: { + tool: "remove_skill", + card: { + workspaceId, + status: "removed", + path: removed.removedPath, + summary: { + scope: removed.scope, + visibleInCurrentWorkspace: visible, + }, + payload: { content }, + }, + }, + structuredContent: { + result: contentText(content), + status: "removed" as const, + scope: removed.scope, + name: removed.name, + removedPath: removed.removedPath, + visibleInCurrentWorkspace: visible, + }, + }; + } catch (error) { + const response = toolError(error instanceof Error ? error.message : String(error)); + logFailedToolResponse(config, { + tool: "remove_skill", + workspaceId, + }, response.content, startedAt); + return response; + } + }, + ); + + 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"]), + skill: resolvedSkillOutputSchema.optional(), + prompt: userInputPromptOutputSchema.optional(), + }, + ...toolWidgetDescriptorMeta(config, "plan"), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + }, + async ({ workspaceId, message }) => { + const startedAt = performance.now(); + const workspace = 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 resolved = await resolveSkillDefinition(workspace.skills, "/plan"); + workspaceStore.setCollaborationMode({ workspaceSessionId: workspaceId, mode: "plan" }); + const content = [textBlock(`Resolved /plan to ${resolved.name} (${skillSourceLabel(resolved.source)}) and enabled plan mode.`)]; + 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, + skill: { + name: resolved.name, + qualifiedId: resolved.qualifiedId, + source: resolved.source, + path: resolved.path, + alias: resolved.alias, + mode: resolved.mode, + instructions: resolved.instructions, + }, + }, + }; + } + + if (parsed.kind === "goal") { + const resolved = await resolveSkillDefinition(workspace.skills, "/goal"); + const content = [textBlock(`Resolved /goal to ${resolved.name} (${skillSourceLabel(resolved.source)}).`)]; + 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, + skill: { + name: resolved.name, + qualifiedId: resolved.qualifiedId, + source: resolved.source, + path: resolved.path, + alias: resolved.alias, + mode: resolved.mode, + instructions: resolved.instructions, + }, + }, + }; + } + + 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, + "get_plan", + { + title: "Get plan", + description: + "Get the current project-scoped Plan. Use this after opening a workspace or before changing a persisted plan so you have the latest revision.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + }, + outputSchema: { + result: z.string(), + plan: workflowPlanOutputSchema.nullable(), + }, + ...toolWidgetDescriptorMeta(config, "plan"), + annotations: { readOnlyHint: true }, + }, + async ({ workspaceId }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + const plan = workspaceStore.getPlan(workspaceId); + const content = [textBlock(plan ? formatPlanResult(plan) : "No current Plan for this project.")]; + logToolCall(config, { + tool: "get_plan", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + return { + content, + structuredContent: { + result: contentText(content), + plan: plan ? toStructuredPlan(plan) : null, + }, + }; + }, + ); + + registerAppTool( + server, + "update_plan", + { + title: "Update plan", + description: + "Create or update the current project-scoped Plan. Pass expectedRevision=0 to create a Plan; otherwise pass the revision returned by get_plan. This works in both default and plan mode.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + expectedRevision: z.number().int().nonnegative().describe("0 creates a new Plan; otherwise the current Plan revision."), + title: z.string().optional(), + summary: z.string().optional(), + scope: workflowScopeOutputSchema.optional(), + validation: z.array(z.string()).optional(), + risks: z.array(z.string()).optional(), + status: z.enum(["draft", "active", "completed", "archived"]).optional(), + goalId: z.string().optional(), + plan: z + .array( + z.object({ + id: z.string().optional(), + step: z.string().describe("Concrete plan step."), + status: z.enum(["pending", "in_progress", "blocked", "completed", "skipped"]), + note: z.string().optional(), + }), + ) + .min(1) + .max(100) + .describe("The complete current Plan step list. At most one step may be in_progress."), + }, + outputSchema: { + result: z.string(), + plan: workflowPlanOutputSchema, + }, + ...toolWidgetDescriptorMeta(config, "plan"), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + }, + async ({ workspaceId, expectedRevision, title, summary, scope, validation, risks, status, goalId, plan }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + try { + validatePlanSteps(plan); + const saved = workspaceStore.savePlan({ + workspaceSessionId: workspaceId, + expectedRevision, + title, + summary, + scopeIn: scope?.in, + scopeOut: scope?.out, + validation, + risks, + status, + goalId, + steps: plan, + }); + const content = [textBlock(formatPlanResult(saved))]; + logToolCall(config, { + tool: "update_plan", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + return { + content, + structuredContent: { + result: contentText(content), + plan: toStructuredPlan(saved), + }, + }; + } catch (error) { + const response = toolError(error instanceof Error ? error.message : String(error)); + logFailedToolResponse(config, { tool: "update_plan", workspaceId }, response.content, startedAt); + return response; + } + }, + ); + + registerAppTool( + server, + "get_goal", + { + title: "Get goal", + description: + "Get the current project-scoped Goal, including its scope, acceptance criteria, verification, summary, status, and revision.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + }, + outputSchema: { + result: z.string(), + goal: workflowGoalOutputSchema.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 current Goal for this project.")]; + logToolCall(config, { + tool: "get_goal", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + return { + content, + structuredContent: { + result: contentText(content), + goal: goal ? toStructuredGoal(goal) : null, + }, + }; + }, + ); + + registerAppTool( + server, + "create_goal", + { + title: "Create goal", + description: + "Create a new current Goal for this project. It fails when an active Goal already exists; inspect and explicitly update or archive that Goal first.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + objective: z.string().describe("Concrete objective to pursue."), + scope: workflowScopeOutputSchema.optional(), + successCriteria: z.array(z.string()).optional(), + verification: z.array(z.string()).optional(), + stopConditions: z.array(z.string()).optional(), + currentSummary: z.string().optional(), + }, + outputSchema: { + result: z.string(), + goal: workflowGoalOutputSchema, + }, + ...toolWidgetDescriptorMeta(config, "goal"), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + }, + async ({ workspaceId, objective, scope, successCriteria, verification, stopConditions, currentSummary }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + try { + const definition = normalizeGoalDefinition({ + objective, + scope, + verification, + stopConditions, + }); + const goal = workspaceStore.saveGoal({ + workspaceSessionId: workspaceId, + objective: definition.objective, + scopeIn: definition.scope?.in, + scopeOut: definition.scope?.out, + successCriteria, + verification: definition.verification, + stopConditions: definition.stopConditions, + currentSummary, + }); + const content = [textBlock(formatGoalResult(goal))]; + logToolCall(config, { + tool: "create_goal", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + return { + content, + structuredContent: { + result: contentText(content), + goal: toStructuredGoal(goal), + }, + }; + } catch (error) { + const response = toolError(error instanceof Error ? error.message : String(error)); + logFailedToolResponse(config, { tool: "create_goal", workspaceId }, response.content, startedAt); + return response; + } + }, + ); + + registerAppTool( + server, + "update_goal", + { + title: "Update goal", + description: + "Update the current project-scoped Goal. Pass the revision returned by get_goal to prevent another session from silently overwriting this Goal.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + expectedRevision: z.number().int().positive(), + objective: z.string().optional(), + scope: workflowScopeOutputSchema.optional(), + successCriteria: z.array(z.string()).optional(), + verification: z.array(z.string()).optional(), + stopConditions: z.array(z.string()).optional(), + currentSummary: z.string().optional(), + status: z.enum(["active", "blocked", "completed", "archived"]).optional(), + }, + outputSchema: { + result: z.string(), + goal: workflowGoalOutputSchema, + }, + ...toolWidgetDescriptorMeta(config, "goal"), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + }, + async ({ workspaceId, expectedRevision, objective, scope, successCriteria, verification, stopConditions, currentSummary, status }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + try { + const goal = workspaceStore.updateGoal({ + workspaceSessionId: workspaceId, + expectedRevision, + objective, + scopeIn: scope?.in, + scopeOut: scope?.out, + successCriteria, + verification, + stopConditions, + currentSummary, + status, + }); + const content = [textBlock(formatGoalResult(goal))]; + logToolCall(config, { + tool: "update_goal", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + return { + content, + structuredContent: { + result: contentText(content), + goal: toStructuredGoal(goal), + }, + }; + } catch (error) { + const response = toolError(error instanceof Error ? error.message : String(error)); + logFailedToolResponse(config, { tool: "update_goal", workspaceId }, response.content, startedAt); + return response; + } + }, + ); + + registerAppTool( + server, + "start_goal_work", + { + title: "Start goal work timer", + description: + "Start the server-authoritative work timer for the active Goal. The duration measures only wall-clock time while this explicit timer is running; it is not inferred from chat activity.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + }, + outputSchema: { + result: z.string(), + started: z.boolean(), + metrics: goalMetricsOutputSchema, + }, + ...toolWidgetDescriptorMeta(config, "goal"), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, + async ({ workspaceId }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + try { + const result = workspaceStore.startGoalWork({ workspaceSessionId: workspaceId }); + const content = [textBlock( + result.started + ? `Started Goal work timer. Exact tracked duration is now ${result.metrics.workDuration.totalMilliseconds} ms.` + : `Goal work timer is already running since ${result.metrics.workDuration.startedAt ?? "an unknown time"}.`, + )]; + logToolCall(config, { + tool: "start_goal_work", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + return { + content, + structuredContent: { + result: contentText(content), + started: result.started, + metrics: result.metrics, + }, + }; + } catch (error) { + const response = toolError(error instanceof Error ? error.message : String(error)); + logFailedToolResponse(config, { tool: "start_goal_work", workspaceId }, response.content, startedAt); + return response; + } + }, + ); + + registerAppTool( + server, + "pause_goal_work", + { + title: "Pause goal work timer", + description: + "Pause the server-authoritative work timer for the current Goal and persist the exact elapsed milliseconds. This is safe to call when the timer is already paused.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + }, + outputSchema: { + result: z.string(), + paused: z.boolean(), + metrics: goalMetricsOutputSchema, + }, + ...toolWidgetDescriptorMeta(config, "goal"), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, + async ({ workspaceId }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + try { + const result = workspaceStore.pauseGoalWork({ workspaceSessionId: workspaceId }); + const content = [textBlock( + result.paused + ? `Paused Goal work timer at ${result.metrics.workDuration.totalMilliseconds} ms.` + : `Goal work timer was already paused at ${result.metrics.workDuration.totalMilliseconds} ms.`, + )]; + logToolCall(config, { + tool: "pause_goal_work", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + return { + content, + structuredContent: { + result: contentText(content), + paused: result.paused, + metrics: result.metrics, + }, + }; + } catch (error) { + const response = toolError(error instanceof Error ? error.message : String(error)); + logFailedToolResponse(config, { tool: "pause_goal_work", workspaceId }, response.content, startedAt); + return response; + } + }, + ); + + registerAppTool( + server, + "record_goal_token_usage", + { + title: "Record provider token usage", + description: + "Append exact provider-reported token usage to the current Goal. Call only with counts and request IDs returned by the model provider or API; never estimate tokens from text, timing, or context length. Duplicate provider request IDs are ignored.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + provider: z.string().min(1).max(512).describe("Provider that returned the usage record."), + providerRequestId: z.string().min(1).max(2048).describe("Stable provider request or response ID used for deduplication."), + model: z.string().max(512).optional(), + inputTokens: z.number().int().nonnegative(), + outputTokens: z.number().int().nonnegative(), + reasoningTokens: z.number().int().nonnegative().optional(), + totalTokens: z.number().int().nonnegative().describe("Exact total reported by the provider."), + providerReportedAt: z.string().datetime().optional(), + }, + outputSchema: { + result: z.string(), + recorded: z.boolean(), + metrics: goalMetricsOutputSchema, + }, + ...toolWidgetDescriptorMeta(config, "goal"), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, + async ({ workspaceId, provider, providerRequestId, model, inputTokens, outputTokens, reasoningTokens, totalTokens, providerReportedAt }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + try { + const result = workspaceStore.recordGoalTokenUsage({ + workspaceSessionId: workspaceId, + provider, + providerRequestId, + model, + inputTokens, + outputTokens, + reasoningTokens, + totalTokens, + providerReportedAt, + }); + const content = [textBlock( + result.recorded + ? `Recorded exact provider-reported usage. Goal total is ${result.metrics.tokenUsage.totalTokens} tokens across ${result.metrics.tokenUsage.reportCount} reports.` + : "This provider request ID was already recorded; Goal token totals were not changed.", + )]; + logToolCall(config, { + tool: "record_goal_token_usage", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + return { + content, + structuredContent: { + result: contentText(content), + recorded: result.recorded, + metrics: result.metrics, + }, + }; + } catch (error) { + const response = toolError(error instanceof Error ? error.message : String(error)); + logFailedToolResponse(config, { tool: "record_goal_token_usage", workspaceId }, response.content, startedAt); + return response; + } + }, + ); + + registerAppTool( + server, + "get_workflow_history", + { + title: "Get workflow history", + description: + "Read concise project workflow events without loading Plan, Goal, chat, or tool-output history. Results are paginated and capped at 50 events.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + limit: z.number().int().positive().max(50).optional(), + cursor: z.string().optional(), + }, + outputSchema: { + result: z.string(), + events: z.array(z.object({ + id: z.string(), + projectWorkflowKey: z.string(), + entityType: z.enum(["plan", "goal", "mode"]), + entityId: z.string(), + eventType: z.string(), + summary: z.string(), + revision: z.number().int().positive().optional(), + createdAt: z.string(), + })), + nextCursor: z.string().optional(), + }, + ...toolWidgetDescriptorMeta(config, "workspace"), + annotations: { readOnlyHint: true }, + }, + async ({ workspaceId, limit, cursor }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + try { + const history = workspaceStore.getWorkflowHistory({ workspaceSessionId: workspaceId, limit, cursor }); + const content = [textBlock(formatWorkflowHistory(history.events))]; + logToolCall(config, { + tool: "get_workflow_history", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + return { + content, + structuredContent: { + result: contentText(content), + events: history.events, + nextCursor: history.nextCursor, }, - }, - 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, - }, - }; + }; + } catch (error) { + const response = toolError(error instanceof Error ? error.message : String(error)); + logFailedToolResponse(config, { tool: "get_workflow_history", workspaceId }, response.content, startedAt); + return response; + } }, ); @@ -703,7 +2352,7 @@ function createMcpServer( workspaces.markReadPathLoaded(workspace, readPath); const summary = { - ...textSummary(response.content), + ...contentStats(response.content), offset: input.offset ?? 1, limited: input.limit !== undefined, }; @@ -897,6 +2546,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, @@ -1007,7 +2809,7 @@ function createMcpServer( const summary = { pattern: input.pattern, scope: input.path ?? ".", - ...textSummary(response.content), + ...contentStats(response.content), }; logToolCall(config, { tool: toolNames.grep, @@ -1077,7 +2879,7 @@ function createMcpServer( const summary = { pattern: input.pattern, scope: input.path ?? ".", - ...textSummary(response.content), + ...contentStats(response.content), }; logToolCall(config, { tool: toolNames.glob, @@ -1144,7 +2946,7 @@ function createMcpServer( return response; } - const summary = textSummary(response.content); + const summary = contentStats(response.content); logToolCall(config, { tool: toolNames.ls, workspaceId, @@ -1213,6 +3015,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, @@ -1232,7 +3046,7 @@ function createMcpServer( const summary = { command: input.command, workingDirectory: workingDirectory ?? ".", - ...textSummary(response.content), + ...contentStats(response.content), }; logToolCall(config, { tool: toolNames.shell, @@ -1274,7 +3088,7 @@ export function createServer(config = loadConfig()): RunningServer { ...(allowedHosts ? { allowedHosts } : {}), }); const transports = new Map(); - const mcpUrl = new URL("/mcp", config.publicBaseUrl); + const mcpUrl = new URL(config.mcpPath, config.publicBaseUrl); const resourceServerUrl = resourceUrlFromServerUrl(mcpUrl); const oauthProvider = new SingleUserOAuthProvider(config.oauth, mcpUrl, config.stateDir); const bearerAuth = requireBearerAuth({ @@ -1287,7 +3101,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) => { @@ -1313,6 +3129,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, @@ -1343,7 +3169,7 @@ export function createServer(config = loadConfig()): RunningServer { res.json({ ok: true, name: "devspace" }); }); - app.all("/mcp", async (req, res) => { + app.all(config.mcpPath, async (req, res) => { const requestId = res.locals.requestId as string | undefined; const sessionId = req.header("mcp-session-id"); const initializeRequest = req.method === "POST" && isInitializeRequest(req.body); @@ -1408,7 +3234,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"); @@ -1440,6 +3266,384 @@ export function createServer(config = loadConfig()): RunningServer { }; } +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(plan: WorkspacePlan): string { + const lines = [ + `Plan: ${plan.title}`, + plan.summary, + `Status: ${plan.status} (revision ${plan.revision})`, + plan.scopeIn.length || plan.scopeOut.length + ? `Scope: In(${plan.scopeIn.join("; ") || "none"}) / Out(${plan.scopeOut.join("; ") || "none"})` + : undefined, + plan.validation.length ? `Validation: ${plan.validation.join("; ")}` : undefined, + plan.risks.length ? `Risks: ${plan.risks.join("; ")}` : undefined, + ...plan.steps.map((step) => `${planStepMarker(step.status)} ${step.step}${step.note ? ` — ${step.note}` : ""}`), + ]; + return lines.filter(Boolean).join("\n"); +} + +function planStepMarker(status: WorkspacePlanStep["status"]): string { + switch (status) { + case "completed": + return "[done]"; + case "in_progress": + return "[doing]"; + case "blocked": + return "[blocked]"; + case "skipped": + return "[skipped]"; + default: + return "[todo]"; + } +} + +function toStructuredPlan(plan: WorkspacePlan): { + id: string; + projectWorkflowKey: string; + goalId?: string; + title: string; + summary?: string; + scope: { in: string[]; out: string[] }; + validation: string[]; + risks: string[]; + status: WorkspacePlan["status"]; + revision: number; + steps: WorkspacePlanStep[]; + createdAt: string; + updatedAt: string; + archivedAt?: string; +} { + return { + id: plan.id, + projectWorkflowKey: plan.projectWorkflowKey, + goalId: plan.goalId, + title: plan.title, + summary: plan.summary, + scope: { in: plan.scopeIn, out: plan.scopeOut }, + validation: plan.validation, + risks: plan.risks, + status: plan.status, + revision: plan.revision, + steps: plan.steps, + createdAt: plan.createdAt, + updatedAt: plan.updatedAt, + archivedAt: plan.archivedAt, + }; +} + +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: WorkspaceGoal): string { + const lines = [ + `Goal: ${goal.objective}`, + goal.scopeIn.length || goal.scopeOut.length + ? `Scope: In(${goal.scopeIn.join("; ") || "none"}) / Out(${goal.scopeOut.join("; ") || "none"})` + : undefined, + goal.successCriteria.length ? `Success criteria: ${goal.successCriteria.join("; ")}` : undefined, + goal.verification.length ? `Verification: ${goal.verification.join("; ")}` : undefined, + goal.stopConditions.length ? `Stop conditions: ${goal.stopConditions.join("; ")}` : undefined, + goal.currentSummary ? `Current summary: ${goal.currentSummary}` : undefined, + `Status: ${goal.status} (revision ${goal.revision})`, + `Provider-reported tokens: ${goal.metrics.tokenUsage.totalTokens} across ${goal.metrics.tokenUsage.reportCount} reports`, + `Exact work timer: ${goal.metrics.workDuration.totalMilliseconds} ms${goal.metrics.workDuration.running ? " (running)" : ""}`, + goal.metrics.progress.source === "linked_plan_steps" + ? `Plan progress: ${goal.metrics.progress.displayPercent} (${goal.metrics.progress.exactFraction}; exact % = ${goal.metrics.progress.percentageNumerator}/${goal.metrics.progress.percentageDenominator})` + : "Plan progress: unavailable until a current Plan is explicitly linked to this Goal", + ]; + return lines.filter(Boolean).join("\n"); +} + +function toStructuredGoal(goal: WorkspaceGoal): { + id: string; + projectWorkflowKey: string; + objective: string; + scope: { in: string[]; out: string[] }; + successCriteria: string[]; + verification: string[]; + stopConditions: string[]; + currentSummary?: string; + status: WorkspaceGoal["status"]; + revision: number; + metrics: WorkspaceGoal["metrics"]; + createdAt: string; + updatedAt: string; + archivedAt?: string; +} { + return { + id: goal.id, + projectWorkflowKey: goal.projectWorkflowKey, + objective: goal.objective, + scope: { in: goal.scopeIn, out: goal.scopeOut }, + successCriteria: goal.successCriteria, + verification: goal.verification, + stopConditions: goal.stopConditions, + currentSummary: goal.currentSummary, + status: goal.status, + revision: goal.revision, + metrics: goal.metrics, + createdAt: goal.createdAt, + updatedAt: goal.updatedAt, + archivedAt: goal.archivedAt, + }; +} + +function formatWorkflowDigest(digest: WorkflowDigest): string { + const goal = digest.goalStatus + ? `Goal ${digest.goalStatus}${digest.goalTitle ? `: ${digest.goalTitle}` : ""}` + : "No current Goal"; + const plan = digest.planStatus + ? `Plan ${digest.planStatus} r${digest.planRevision ?? 0}${digest.steps ? ` (${digest.steps.completed}/${digest.steps.total} complete)` : ""}` + : "No current Plan"; + return `${goal}; ${plan}.`; +} + +function formatWorkflowHistory(events: Array<{ + createdAt: string; + eventType: string; + summary: string; +}>): string { + if (events.length === 0) return "No workflow history for this project."; + return events.map((event) => `${event.createdAt} ${event.eventType}: ${event.summary}`).join("\n"); +} + +function summarizeSkills(skills: Array<{ source: SkillSource }>): { + total: number; + bySource: Record; +} { + const bySource: Record = { + devspace_system: 0, + local: 0, + installed: 0, + global: 0, + }; + for (const skill of skills) bySource[skill.source]++; + return { total: skills.length, bySource }; +} + +function searchWorkspaceSkills( + skills: DevSpaceSkill[], + input: { + query?: string; + source?: SkillSource; + limit?: number; + cursor?: string; + }, +): { + skills: Array<{ + qualifiedId: string; + name: string; + description: string; + source: SkillSource; + locator: string; + }>; + nextCursor?: string; +} { + const query = input.query?.trim().toLocaleLowerCase(); + const matching = skills + .filter((skill) => !input.source || skill.source === input.source) + .filter((skill) => { + if (!query) return true; + return [skill.qualifiedId, skill.name, skill.description] + .join("\n") + .toLocaleLowerCase() + .includes(query); + }) + .sort((left, right) => left.qualifiedId.localeCompare(right.qualifiedId)); + + const start = input.cursor === undefined ? 0 : Number.parseInt(input.cursor, 10); + if (!Number.isSafeInteger(start) || start < 0 || start > matching.length) { + throw new Error("Invalid skills search cursor."); + } + const limit = Math.max(1, Math.min(input.limit ?? 20, 50)); + const page = matching.slice(start, start + limit); + const nextOffset = start + page.length; + + return { + skills: page.map((skill) => ({ + qualifiedId: skill.qualifiedId, + name: skill.name, + description: skill.description, + source: skill.source, + locator: skill.locator, + })), + nextCursor: nextOffset < matching.length ? String(nextOffset) : undefined, + }; +} + +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, + }; +} + +function toInstalledSkillOutput(skill: InstalledSkillRecord) { + return { + name: skill.name, + description: skill.description, + scope: skill.scope, + path: skill.path, + removable: skill.removable, + sourceType: skill.sourceType, + }; +} + +function formatInstalledSkillsList(skills: InstalledSkillRecord[]): string { + if (skills.length === 0) return "No installed skills."; + return skills + .map((skill) => `${skill.name} (${skill.scope})\nPath: ${skill.path}\nDescription: ${skill.description}`) + .join("\n\n"); +} + async function isMainModule(): Promise { if (!process.argv[1]) return false; @@ -1452,7 +3656,7 @@ if (await isMainModule()) { const { app, config, close } = createServer(); const httpServer = app.listen(config.port, config.host, () => { console.log( - `devspace listening on http://${config.host}:${config.port}/mcp`, + `devspace listening on http://${config.host}:${config.port}${config.mcpPath}`, ); console.log(`allowed roots: ${config.allowedRoots.join(", ")}`); console.log("auth: oauth owner-token flow required"); diff --git a/src/service.test.ts b/src/service.test.ts new file mode 100644 index 0000000..af1e624 --- /dev/null +++ b/src/service.test.ts @@ -0,0 +1,204 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadConfig } from "./config.js"; +import { createServiceManager, detectServiceManagerKind } from "./service/manager.js"; +import { buildLaunchAgentPlist, buildServiceEnvironment, buildSystemdUnit, devspaceLogDir } from "./service/templates.js"; +import type { CommandRunner } from "./service/runner.js"; +import { writeDevspaceAuth, writeDevspaceConfig } from "./user-config.js"; + +const root = mkdtempSync(join(tmpdir(), "devspace-service-test-")); +const originalHome = process.env.HOME; +const originalConfigDir = process.env.DEVSPACE_CONFIG_DIR; +const originalAllowedRoots = process.env.DEVSPACE_ALLOWED_ROOTS; +const originalSessionWorkspace = process.env.DEVSPACE_SESSION_WORKSPACE; +const originalPath = process.env.PATH; + +try { + process.env.HOME = root; + process.env.DEVSPACE_CONFIG_DIR = root; + process.env.PATH = "/usr/bin:/bin"; + process.env.DEVSPACE_ALLOWED_ROOTS = "/tmp/should-not-persist"; + process.env.DEVSPACE_SESSION_WORKSPACE = "/tmp/session-only"; + writeDevspaceConfig({ + allowedRoots: [root], + workspaces: { + allowed: [root], + default: null, + }, + publicBaseUrl: "https://devspace.example.com", + server: { + publicBaseUrl: "https://devspace.example.com", + mcpPath: "/mcp", + host: "127.0.0.1", + port: 7676, + }, + }); + writeDevspaceAuth({ ownerToken: "test-owner-token-that-is-long-enough" }); + const config = loadConfig({ + DEVSPACE_CONFIG_DIR: root, + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + }); + const serviceEnvironment = buildServiceEnvironment(); + assert.equal(serviceEnvironment.DEVSPACE_ALLOWED_ROOTS, undefined); + assert.equal(serviceEnvironment.DEVSPACE_SESSION_WORKSPACE, undefined); + assert.equal(serviceEnvironment.DEVSPACE_CONFIG_DIR, root); + assert.equal(serviceEnvironment.PATH, "/usr/bin:/bin"); + + const builtCliPath = join(root, "dist", "cli.js"); + mkdirSync(join(root, "dist"), { recursive: true }); + writeFileSync(builtCliPath, "console.log('devspace');\n", "utf8"); + const systemdUnit = buildSystemdUnit({ + cliEntrypoint: builtCliPath, + config, + }); + assert.match(systemdUnit, /ExecStart=/); + assert.match(systemdUnit, /Restart=on-failure/); + assert.match(systemdUnit, /devspace\.out\.log/); + assert.equal(devspaceLogDir(), join(root, "logs")); + assert.match(systemdUnit, new RegExp(`${escapeRegExp(join(root, "logs", "devspace.out.log"))}`)); + assert.doesNotMatch(systemdUnit, /DEVSPACE_ALLOWED_ROOTS/); + assert.doesNotMatch(systemdUnit, /DEVSPACE_SESSION_WORKSPACE/); + + const launchdPlist = buildLaunchAgentPlist({ + cliEntrypoint: builtCliPath, + config, + }); + assert.match(launchdPlist, /ProgramArguments/); + assert.match(launchdPlist, /service-run/); + assert.match(launchdPlist, /devspace\.err\.log/); + assert.match(launchdPlist, new RegExp(`${escapeRegExp(join(root, "logs", "devspace.err.log"))}`)); + assert.doesNotMatch(launchdPlist, /DEVSPACE_ALLOWED_ROOTS/); + assert.doesNotMatch(launchdPlist, /DEVSPACE_SESSION_WORKSPACE/); + + const runner = createMockRunner(); + const systemdPaths = createSystemdPaths(root, "systemd"); + const manager = createServiceManager({ + config, + cliEntrypoint: join(root, "src", "cli.ts"), + runner, + managerKindOverride: "systemd-user", + pathsOverride: systemdPaths, + }); + const startResult = await manager.start(); + assert.equal(startResult.ok, true); + assert.match(startResult.message, /Started service|Installed and started service/); + assert.match( + readFileSync(systemdPaths.userSystemdUnitPath, "utf8"), + new RegExp(escapeRegExp(escapeSystemdUnitArg(builtCliPath))), + ); + const status = await manager.status(); + assert.equal(status.installed, true); + assert.equal(status.endpoint, "https://devspace.example.com/mcp"); + const doctor = await manager.doctor(); + assert.equal(doctor.checks.some((check) => check.level === "warn" && /running from source/.test(check.message)), true); + + const brokenManager = createServiceManager({ + config, + cliEntrypoint: join(root, "missing-project", "src", "cli.ts"), + runner: createMockRunner(), + managerKindOverride: "systemd-user", + pathsOverride: createSystemdPaths(root, "broken"), + }); + const brokenStart = await brokenManager.start(); + assert.equal(brokenStart.ok, false); + assert.match(brokenStart.message, /Expected built service entrypoint/); + + const logPath = join(root, "logs", "devspace.out.log"); + mkdirSync(join(root, "logs"), { recursive: true }); + writeFileSync(logPath, "line-1\nline-2\nline-3\n", "utf8"); + assert.equal(await manager.logs(), "line-1\nline-2\nline-3\n"); + assert.equal(await manager.logs({ tail: 2 }), "line-3\n"); + + assert.equal( + detectServiceManagerKind({ + platform: "linux", + env: { XDG_RUNTIME_DIR: "/run/user/0" }, + }), + "systemd-user", + ); + assert.equal( + detectServiceManagerKind({ + platform: "linux", + env: {}, + hasSystemdRuntimeMarkers: false, + }), + "unsupported", + ); + assert.equal( + detectServiceManagerKind({ + platform: "linux", + env: {}, + hasSystemdRuntimeMarkers: true, + }), + "systemd-user", + ); + assert.equal( + detectServiceManagerKind({ + platform: "linux", + env: { DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/0/bus" }, + }), + "systemd-user", + ); +} finally { + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + if (originalConfigDir === undefined) delete process.env.DEVSPACE_CONFIG_DIR; + else process.env.DEVSPACE_CONFIG_DIR = originalConfigDir; + if (originalAllowedRoots === undefined) delete process.env.DEVSPACE_ALLOWED_ROOTS; + else process.env.DEVSPACE_ALLOWED_ROOTS = originalAllowedRoots; + if (originalSessionWorkspace === undefined) delete process.env.DEVSPACE_SESSION_WORKSPACE; + else process.env.DEVSPACE_SESSION_WORKSPACE = originalSessionWorkspace; + if (originalPath === undefined) delete process.env.PATH; + else process.env.PATH = originalPath; + rmSync(root, { recursive: true, force: true }); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function escapeSystemdUnitArg(value: string): string { + if (/^[A-Za-z0-9_./:@-]+$/.test(value)) return value; + return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; +} + +function createSystemdPaths(baseRoot: string, label: string) { + return { + userSystemdUnitPath: join(baseRoot, label, ".config", "systemd", "user", "devspace.service"), + launchdPlistPath: join(baseRoot, label, "Library", "LaunchAgents", "com.devspace.server.plist"), + }; +} + +function createMockRunner(): CommandRunner { + return { + async exec(command, args) { + if (command === "systemctl" && args[0] === "--user" && args.includes("show")) { + return { stdout: "PATH=/usr/bin\n123\n", stderr: "", exitCode: 0 }; + } + if (command === "systemctl" && args.includes("is-enabled")) { + return { stdout: "enabled\n", stderr: "", exitCode: 0 }; + } + if (command === "systemctl" && args.includes("is-active")) { + return { stdout: "active\n", stderr: "", exitCode: 0 }; + } + if (command === "systemctl") { + return { stdout: "", stderr: "", exitCode: 0 }; + } + if (command === "launchctl") { + return { stdout: "", stderr: "", exitCode: 0 }; + } + if (command === "schtasks.exe") { + if (args.includes("/FO")) { + return { stdout: "", stderr: "", exitCode: 1 }; + } + return { stdout: "", stderr: "", exitCode: 1 }; + } + return { stdout: "", stderr: "", exitCode: 0 }; + }, + }; +} diff --git a/src/service/manager.ts b/src/service/manager.ts new file mode 100644 index 0000000..9be9ce1 --- /dev/null +++ b/src/service/manager.ts @@ -0,0 +1,785 @@ +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { homedir, platform } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import type { ServerConfig } from "../config.js"; +import { defaultCommandRunner, type CommandRunner } from "./runner.js"; +import { buildLaunchAgentPlist, buildServiceCommand, buildSystemdUnit, devspaceLogDir } from "./templates.js"; +import type { + ServiceDoctorResult, + ServiceManager, + ServiceManagerKind, + ServiceResult, + ServiceStatus, +} from "./types.js"; + +const SYSTEMD_SERVICE_NAME = "devspace.service"; +const LAUNCHD_LABEL = "com.devspace.server"; +const WINDOWS_TASK_NAME = "DevSpace MCP Server"; + +interface ManagerContext { + config: ServerConfig; + cliEntrypoint: string; + runner?: CommandRunner; + managerKindOverride?: ServiceManagerKind; + pathsOverride?: Partial; +} + +interface DetectServiceManagerOptions { + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + hasSystemdRuntimeMarkers?: boolean; +} + +interface ServiceManagerPaths { + userSystemdUnitPath: string; + launchdPlistPath: string; +} + +interface CliEntrypointHealth { + currentEntrypoint: string; + managedEntrypoint?: string; + sourceBound: boolean; + managedExists: boolean; + message?: string; +} + +interface NormalizedManagerContext { + config: ServerConfig; + cliEntrypoint: string; + managedCliEntrypoint: string; + cliHealth: CliEntrypointHealth; + runner: CommandRunner; + paths: ServiceManagerPaths; +} + +interface ExistingSystemdUnitState { + installed: boolean; + content?: string; + execEntrypoint?: string; +} + +interface ExistingLaunchdState { + installed: boolean; + content?: string; + execEntrypoint?: string; +} + +interface SystemdRuntimeDetails { + environment: string; + mainPid?: number; +} + +export function createServiceManager(context: ManagerContext): ServiceManager { + const kind = context.managerKindOverride ?? detectServiceManagerKind(); + const runner = context.runner ?? defaultCommandRunner; + const paths = resolveServiceManagerPaths(context.pathsOverride); + const cliHealth = inspectCliEntrypoint(context.cliEntrypoint); + const managedCliEntrypoint = cliHealth.managedEntrypoint ?? context.cliEntrypoint; + const base: NormalizedManagerContext = { + config: context.config, + cliEntrypoint: context.cliEntrypoint, + managedCliEntrypoint, + cliHealth, + runner, + paths, + }; + + switch (kind) { + case "systemd-user": + return createSystemdUserManager(base); + case "launchd": + return createLaunchdManager(base); + case "windows-task-scheduler": + case "wsl-task-scheduler-fallback": + return createWindowsTaskManager(base, kind); + default: + return createUnsupportedManager(base.config); + } +} + +export async function restartServiceIfRunning( + manager: ServiceManager, +): Promise<{ restarted: boolean; message?: string }> { + const status = await manager.status(); + if (!status.installed || !status.running) { + return { + restarted: false, + message: status.installed + ? "Config saved. DevSpace service is installed but not running; changes will apply on next start." + : "Config saved. Changes will apply the next time DevSpace starts.", + }; + } + + const result = await manager.restart(); + if (!result.ok) { + throw new Error(`Config saved, but automatic service restart failed: ${result.message}`); + } + + return { restarted: true }; +} + +export function detectServiceManagerKind(options: DetectServiceManagerOptions = {}): ServiceManagerKind { + const currentPlatform = options.platform ?? platform(); + const env = options.env ?? process.env; + const systemdRuntimeMarkers = options.hasSystemdRuntimeMarkers ?? hasSystemdRuntimeMarkers(); + + if (currentPlatform === "darwin") return "launchd"; + if (currentPlatform === "win32") return "windows-task-scheduler"; + if (env.WSL_DISTRO_NAME) { + return hasSystemdUserSession(env) ? "systemd-user" : "wsl-task-scheduler-fallback"; + } + if (currentPlatform === "linux") { + return hasSystemdUserSession(env) || systemdRuntimeMarkers + ? "systemd-user" + : "unsupported"; + } + return "unsupported"; +} + +function hasSystemdUserSession(env: NodeJS.ProcessEnv): boolean { + return Boolean(env.SYSTEMD_EXEC_PID || env.XDG_RUNTIME_DIR || env.DBUS_SESSION_BUS_ADDRESS); +} + +function hasSystemdRuntimeMarkers(): boolean { + return existsSync("/run/systemd/system") || existsSync(`/run/user/${process.getuid?.() ?? 0}`); +} + +function createUnsupportedManager(config: ServerConfig): ServiceManager { + return { + kind: "unsupported", + serviceName: "devspace", + async isSupported() { + return false; + }, + async remove() { + return { ok: false, manager: "unsupported", message: unsupportedMessage() }; + }, + async disable() { + return { ok: false, manager: "unsupported", message: unsupportedMessage() }; + }, + async start() { + return { ok: false, manager: "unsupported", message: unsupportedMessage() }; + }, + async stop() { + return { ok: false, manager: "unsupported", message: unsupportedMessage() }; + }, + async restart() { + return { ok: false, manager: "unsupported", message: unsupportedMessage() }; + }, + async status() { + return baseStatus("unsupported", "devspace", config); + }, + async logs() { + throw new Error(unsupportedMessage()); + }, + async doctor() { + return { + manager: "unsupported", + checks: [{ level: "warn", message: unsupportedMessage() }], + }; + }, + }; +} + +function createSystemdUserManager(context: NormalizedManagerContext): ServiceManager { + const unitPath = context.paths.userSystemdUnitPath; + return { + kind: "systemd-user", + serviceName: SYSTEMD_SERVICE_NAME, + async isSupported() { + const result = await context.runner.exec("systemctl", ["--user", "--version"]); + return result.exitCode === 0; + }, + async remove() { + await context.runner.exec("systemctl", ["--user", "disable", SYSTEMD_SERVICE_NAME]); + await context.runner.exec("systemctl", ["--user", "stop", SYSTEMD_SERVICE_NAME]); + if (existsSync(unitPath)) { + rmSync(unitPath, { force: true }); + } + await context.runner.exec("systemctl", ["--user", "daemon-reload"]); + return { + ok: true, + manager: "systemd-user", + message: `Uninstalled ${SYSTEMD_SERVICE_NAME}`, + }; + }, + async disable() { + return execServiceResult(context.runner, "systemd-user", "systemctl", ["--user", "disable", SYSTEMD_SERVICE_NAME], "Disabled service"); + }, + async start() { + return installOrStartSystemdUserService(context, "start"); + }, + async stop() { + return execServiceResult(context.runner, "systemd-user", "systemctl", ["--user", "stop", SYSTEMD_SERVICE_NAME], "Stopped service"); + }, + async restart() { + return installOrStartSystemdUserService(context, "restart"); + }, + async status() { + const installed = existsSync(unitPath); + const enabled = installed && (await context.runner.exec("systemctl", ["--user", "is-enabled", SYSTEMD_SERVICE_NAME])).exitCode === 0; + const running = installed && (await context.runner.exec("systemctl", ["--user", "is-active", SYSTEMD_SERVICE_NAME])).exitCode === 0; + const existing = readSystemdUnitState(unitPath); + const runtime = installed ? await readSystemdRuntimeDetails(context.runner) : emptySystemdRuntimeDetails(); + return { + ...baseStatus("systemd-user", SYSTEMD_SERVICE_NAME, context.config), + installed, + enabled, + running, + logPath: join(devspaceLogDir(), "devspace.out.log"), + pid: runtime.mainPid, + details: { + installedEntrypoint: existing.execEntrypoint ?? "(unknown)", + runtimeEnvironmentOverride: + runtime.environment.includes("DEVSPACE_ALLOWED_ROOTS=") + || runtime.environment.includes("DEVSPACE_SESSION_WORKSPACE="), + }, + }; + }, + async logs(options) { + const logPath = join(devspaceLogDir(), "devspace.out.log"); + return readLog(logPath, options?.tail); + }, + async doctor() { + const entrypointCheck = cliEntrypointDoctorCheck(context.cliHealth); + const status = await this.status(); + const existing = readSystemdUnitState(unitPath); + const checks: ServiceDoctorResult["checks"] = [ + { + level: (await this.isSupported()) ? "pass" : "warn", + message: (await this.isSupported()) ? "systemd user service is available" : "systemd user service is unavailable", + }, + ]; + if (entrypointCheck) checks.push(entrypointCheck); + checks.push( + { + level: status.installed ? "pass" : "info", + message: status.installed ? "DevSpace unit is installed" : "DevSpace unit is not installed", + }, + { + level: status.running ? "pass" : "warn", + message: status.running ? "DevSpace service is running" : "DevSpace service is not running", + }, + ); + if ( + existing.installed + && existing.execEntrypoint + && existing.execEntrypoint !== context.managedCliEntrypoint + ) { + checks.push({ + level: "warn", + message: `Installed unit points to ${existing.execEntrypoint} instead of ${context.managedCliEntrypoint}. Run \`devspace service start\` to repair it.`, + }); + } + if (existing.installed && existing.execEntrypoint && !existsSync(existing.execEntrypoint)) { + checks.push({ + level: "error", + message: `Installed unit points to a missing CLI entrypoint: ${existing.execEntrypoint}.`, + }); + } + if (status.details?.runtimeEnvironmentOverride) { + checks.push({ + level: "error", + message: "Running service environment still contains temporary workspace override variables. Re-run `devspace service start` to rewrite the service definition.", + }); + } + return { + manager: "systemd-user", + checks, + }; + }, + }; +} + +function createLaunchdManager(context: NormalizedManagerContext): ServiceManager { + const plistPath = context.paths.launchdPlistPath; + return { + kind: "launchd", + serviceName: LAUNCHD_LABEL, + async isSupported() { + return true; + }, + async remove() { + await context.runner.exec("launchctl", ["bootout", `gui/${process.getuid?.() ?? 0}/${LAUNCHD_LABEL}`]); + if (existsSync(plistPath)) { + rmSync(plistPath, { force: true }); + } + return { ok: true, manager: "launchd", message: "Uninstalled service" }; + }, + async disable() { + return execServiceResult(context.runner, "launchd", "launchctl", ["bootout", `gui/${process.getuid?.() ?? 0}/${LAUNCHD_LABEL}`], "Disabled service"); + }, + async start() { + const validation = validateManagedCliEntrypoint(context.cliHealth, "launchd"); + if (validation) return validation; + + const expected = buildLaunchAgentPlist({ + cliEntrypoint: context.managedCliEntrypoint, + config: context.config, + }); + const existing = readLaunchdState(plistPath); + const needsRewrite = !existing.installed || existing.content !== expected; + + if (needsRewrite) { + mkdirSync(join(homedir(), "Library", "LaunchAgents"), { recursive: true }); + mkdirSync(devspaceLogDir(), { recursive: true }); + writeFileSync(plistPath, expected, "utf8"); + if (existing.installed) { + await context.runner.exec("launchctl", ["bootout", `gui/${process.getuid?.() ?? 0}/${LAUNCHD_LABEL}`]); + } + const bootstrap = await context.runner.exec("launchctl", ["bootstrap", `gui/${process.getuid?.() ?? 0}`, plistPath]); + if ( + bootstrap.exitCode !== 0 + && !bootstrap.stderr.includes("already bootstrapped") + && !bootstrap.stderr.includes("service already loaded") + ) { + return { + ok: false, + manager: "launchd", + message: [ + "LaunchAgent file was written, but launchctl could not start it.", + bootstrap.stderr.trim() || bootstrap.stdout.trim() || "Failed to bootstrap LaunchAgent.", + ].filter(Boolean).join(" "), + }; + } + } + const kickstart = await context.runner.exec("launchctl", ["kickstart", "-k", `gui/${process.getuid?.() ?? 0}/${LAUNCHD_LABEL}`]); + if (kickstart.exitCode === 0) { + return { + ok: true, + manager: "launchd", + message: needsRewrite ? "Installed and started service" : "Started service", + }; + } + return execServiceResult(context.runner, "launchd", "launchctl", ["bootstrap", `gui/${process.getuid?.() ?? 0}`, plistPath], "Started service"); + }, + async stop() { + return execServiceResult(context.runner, "launchd", "launchctl", ["bootout", `gui/${process.getuid?.() ?? 0}/${LAUNCHD_LABEL}`], "Stopped service"); + }, + async restart() { + if (!existsSync(plistPath)) { + return { ok: false, manager: "launchd", message: "LaunchAgent is not installed" }; + } + const result = await context.runner.exec("launchctl", ["kickstart", "-k", `gui/${process.getuid?.() ?? 0}/${LAUNCHD_LABEL}`]); + if (result.exitCode === 0) { + return { ok: true, manager: "launchd", message: "Restarted service" }; + } + await this.stop(); + return this.start(); + }, + async status() { + const existing = readLaunchdState(plistPath); + const result = existing.installed + ? await context.runner.exec("launchctl", ["print", `gui/${process.getuid?.() ?? 0}/${LAUNCHD_LABEL}`]) + : { stdout: "", stderr: "", exitCode: 1 }; + return { + ...baseStatus("launchd", LAUNCHD_LABEL, context.config), + installed: existing.installed, + enabled: existing.installed, + running: result.exitCode === 0, + logPath: join(devspaceLogDir(), "devspace.out.log"), + details: { + installedEntrypoint: existing.execEntrypoint ?? "(unknown)", + }, + }; + }, + async logs(options) { + return readLog(join(devspaceLogDir(), "devspace.out.log"), options?.tail); + }, + async doctor() { + const entrypointCheck = cliEntrypointDoctorCheck(context.cliHealth); + const status = await this.status(); + const existing = readLaunchdState(plistPath); + const checks: ServiceDoctorResult["checks"] = [ + { level: "pass", message: "launchd is available" }, + ]; + if (entrypointCheck) checks.push(entrypointCheck); + checks.push( + { + level: status.installed ? "pass" : "info", + message: status.installed ? "LaunchAgent is installed" : "LaunchAgent is not installed", + }, + { + level: status.running ? "pass" : "warn", + message: status.running ? "DevSpace service is running" : "DevSpace service is not running", + }, + { + level: existsSync(devspaceLogDir()) ? "pass" : "warn", + message: existsSync(devspaceLogDir()) ? "Log directory is available" : "Log directory is missing", + }, + ); + if ( + existing.installed + && existing.execEntrypoint + && existing.execEntrypoint !== context.managedCliEntrypoint + ) { + checks.push({ + level: "warn", + message: `Installed LaunchAgent points to ${existing.execEntrypoint} instead of ${context.managedCliEntrypoint}. Run \`devspace service start\` to repair it.`, + }); + } + if (existing.installed && existing.execEntrypoint && !existsSync(existing.execEntrypoint)) { + checks.push({ + level: "error", + message: `Installed LaunchAgent points to a missing CLI entrypoint: ${existing.execEntrypoint}.`, + }); + } + return { + manager: "launchd", + checks, + }; + }, + }; +} + +function createWindowsTaskManager( + context: NormalizedManagerContext, + kind: "windows-task-scheduler" | "wsl-task-scheduler-fallback", +): ServiceManager { + return { + kind, + serviceName: WINDOWS_TASK_NAME, + async isSupported() { + const result = await context.runner.exec("schtasks.exe", ["/Query", "/TN", WINDOWS_TASK_NAME]); + return result.exitCode === 0 || result.exitCode === 1; + }, + async remove() { + return execServiceResult(context.runner, kind, "schtasks.exe", ["/Delete", "/F", "/TN", WINDOWS_TASK_NAME], `Deleted task ${WINDOWS_TASK_NAME}`); + }, + async disable() { + return execServiceResult(context.runner, kind, "schtasks.exe", ["/Change", "/TN", WINDOWS_TASK_NAME, "/DISABLE"], "Disabled task"); + }, + async start() { + const validation = validateManagedCliEntrypoint(context.cliHealth, kind); + if (validation) return validation; + const installed = (await context.runner.exec("schtasks.exe", ["/Query", "/TN", WINDOWS_TASK_NAME])).exitCode === 0; + if (!installed) { + const taskCommand = buildWindowsTaskCommand(context.managedCliEntrypoint); + const created = await execServiceResult( + context.runner, + kind, + "schtasks.exe", + ["/Create", "/F", "/SC", "ONLOGON", "/TN", WINDOWS_TASK_NAME, "/TR", taskCommand], + `Installed task ${WINDOWS_TASK_NAME}`, + ); + if (!created.ok) return created; + return execServiceResult(context.runner, kind, "schtasks.exe", ["/Run", "/TN", WINDOWS_TASK_NAME], "Installed and started task"); + } + return execServiceResult(context.runner, kind, "schtasks.exe", ["/Run", "/TN", WINDOWS_TASK_NAME], "Started task"); + }, + async stop() { + return execServiceResult(context.runner, kind, "schtasks.exe", ["/End", "/TN", WINDOWS_TASK_NAME], "Stopped task"); + }, + async restart() { + await this.stop(); + return this.start(); + }, + async status() { + const result = await context.runner.exec("schtasks.exe", ["/Query", "/TN", WINDOWS_TASK_NAME, "/FO", "LIST", "/V"]); + const installed = result.exitCode === 0; + const running = /Status:\s+Running/i.test(result.stdout); + const enabled = !/Scheduled Task State:\s+Disabled/i.test(result.stdout); + return { + ...baseStatus(kind, WINDOWS_TASK_NAME, context.config), + installed, + enabled: installed && enabled, + running: installed && running, + logPath: join(devspaceLogDir(), "devspace.out.log"), + }; + }, + async logs(options) { + return readLog(join(devspaceLogDir(), "devspace.out.log"), options?.tail); + }, + async doctor() { + const status = await this.status(); + return { + manager: kind, + checks: [ + { + level: status.installed ? "pass" : "info", + message: status.installed ? "Scheduled task is installed" : "Scheduled task is not installed", + }, + { + level: status.running ? "pass" : "warn", + message: status.running ? "DevSpace task is running" : "DevSpace task is not running", + }, + ], + }; + }, + }; +} + +function baseStatus(kind: ServiceManagerKind, serviceName: string, config: ServerConfig): ServiceStatus { + return { + installed: false, + enabled: false, + running: false, + manager: kind, + serviceName, + endpoint: new URL(config.mcpPath, config.publicBaseUrl).toString(), + publicBaseUrl: config.publicBaseUrl, + }; +} + +async function execServiceResult( + runner: CommandRunner, + manager: ServiceManagerKind, + command: string, + args: string[], + successMessage: string, +): Promise { + const result = await runner.exec(command, args); + return result.exitCode === 0 + ? { ok: true, manager, message: successMessage } + : { ok: false, manager, message: result.stderr.trim() || result.stdout.trim() || successMessage }; +} + +async function readLog(path: string, tail?: number): Promise { + if (!existsSync(path)) return ""; + const content = readFileSync(path, "utf8"); + if (tail === undefined) return content; + const lines = content.split(/\r?\n/); + return lines.slice(Math.max(0, lines.length - tail)).join("\n"); +} + +function unsupportedMessage(): string { + return "DevSpace service management is not supported on this platform."; +} + +function buildWindowsTaskCommand(cliEntrypoint: string): string { + const spec = buildServiceCommand(cliEntrypoint); + return `"${spec.command}" ${spec.args.map(windowsQuote).join(" ")}`; +} + +function windowsQuote(value: string): string { + if (!/[\s"]/u.test(value)) return value; + return `"${value.replace(/"/g, '""')}"`; +} + +function installOrStartSystemdUserService( + context: NormalizedManagerContext, + action: "start" | "restart", +): Promise { + return installOrStartSystemdUserServiceImpl(context, action); +} + +async function installOrStartSystemdUserServiceImpl( + context: NormalizedManagerContext, + action: "start" | "restart", +): Promise { + const validation = validateManagedCliEntrypoint(context.cliHealth, "systemd-user"); + if (validation) return validation; + + const unitPath = context.paths.userSystemdUnitPath; + const expected = buildSystemdUnit({ + cliEntrypoint: context.managedCliEntrypoint, + config: context.config, + }); + const existing = readSystemdUnitState(unitPath); + const needsRewrite = !existing.installed || existing.content !== expected; + + if (needsRewrite) { + mkdirSync(dirname(unitPath), { recursive: true }); + mkdirSync(devspaceLogDir(), { recursive: true }); + writeFileSync(unitPath, expected, "utf8"); + await context.runner.exec("systemctl", ["--user", "daemon-reload"]); + await context.runner.exec("systemctl", ["--user", "enable", SYSTEMD_SERVICE_NAME]); + } + + const command = needsRewrite || action === "restart" ? "restart" : "start"; + return execServiceResult( + context.runner, + "systemd-user", + "systemctl", + ["--user", command, SYSTEMD_SERVICE_NAME], + needsRewrite + ? "Installed and started service" + : command === "restart" + ? "Restarted service" + : "Started service", + ); +} + +function resolveServiceManagerPaths(overrides: Partial = {}): ServiceManagerPaths { + return { + userSystemdUnitPath: + overrides.userSystemdUnitPath ?? join(homedir(), ".config", "systemd", "user", SYSTEMD_SERVICE_NAME), + launchdPlistPath: + overrides.launchdPlistPath ?? join(homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`), + }; +} + +function inspectCliEntrypoint(cliEntrypoint: string): CliEntrypointHealth { + const currentEntrypoint = resolve(cliEntrypoint); + const sourceBound = + /[/\\]src[/\\]cli\.[cm]?[jt]s$/u.test(currentEntrypoint) + || currentEntrypoint.includes("/worktrees/") + || currentEntrypoint.includes("\\worktrees\\"); + const managedEntrypoint = resolveManagedCliEntrypoint(currentEntrypoint); + const managedExists = managedEntrypoint ? existsSync(managedEntrypoint) : false; + + if (!managedEntrypoint) { + return { + currentEntrypoint, + sourceBound, + managedExists: false, + message: `DevSpace could not determine a stable CLI entrypoint from ${currentEntrypoint}.`, + }; + } + + if (!managedExists) { + return { + currentEntrypoint, + managedEntrypoint, + sourceBound, + managedExists: false, + message: sourceBound + ? [ + `Current DevSpace CLI is running from source at ${currentEntrypoint}.`, + `Expected built service entrypoint is ${managedEntrypoint}, but it does not exist.`, + "Run `npm run build` before starting the background service.", + ].join(" ") + : `DevSpace CLI entrypoint does not exist: ${managedEntrypoint}.`, + }; + } + + return { + currentEntrypoint, + managedEntrypoint, + sourceBound, + managedExists: true, + }; +} + +function resolveManagedCliEntrypoint(cliEntrypoint: string): string | undefined { + if (/[/\\]dist[/\\]cli\.js$/u.test(cliEntrypoint)) return cliEntrypoint; + if (/[/\\]src[/\\]cli\.[cm]?[jt]s$/u.test(cliEntrypoint)) { + return resolve(dirname(dirname(cliEntrypoint)), "dist", "cli.js"); + } + return cliEntrypoint; +} + +function validateManagedCliEntrypoint( + cliHealth: CliEntrypointHealth, + manager: ServiceManagerKind, +): ServiceResult | undefined { + if (cliHealth.managedEntrypoint && cliHealth.managedExists) return undefined; + return { + ok: false, + manager, + message: cliHealth.message ?? `DevSpace CLI entrypoint is unavailable: ${cliHealth.currentEntrypoint}`, + }; +} + +function cliEntrypointDoctorCheck( + cliHealth: CliEntrypointHealth, +): ServiceDoctorResult["checks"][number] | undefined { + if (cliHealth.sourceBound) { + return { + level: cliHealth.managedExists ? "warn" : "error", + message: cliHealth.managedExists + ? `Current DevSpace CLI is running from source. The managed service will bind to ${cliHealth.managedEntrypoint}.` + : (cliHealth.message ?? "Current DevSpace CLI is running from source, but the built service entrypoint is missing."), + }; + } + + if (!cliHealth.managedExists) { + return { + level: "error", + message: cliHealth.message ?? `DevSpace CLI entrypoint is missing: ${cliHealth.currentEntrypoint}`, + }; + } + + return undefined; +} + +function readSystemdUnitState(unitPath: string): ExistingSystemdUnitState { + if (!existsSync(unitPath)) return { installed: false }; + const content = readFileSync(unitPath, "utf8"); + const execStart = content.match(/^ExecStart=(.+)$/m)?.[1]; + const tokens = execStart ? splitCommandLine(execStart) : []; + return { + installed: true, + content, + execEntrypoint: tokens[1], + }; +} + +function readLaunchdState(plistPath: string): ExistingLaunchdState { + if (!existsSync(plistPath)) return { installed: false }; + const content = readFileSync(plistPath, "utf8"); + const matches = Array.from(content.matchAll(/([^<]+)<\/string>/g)).map((match) => xmlUnescape(match[1] ?? "")); + const serviceRunIndex = matches.indexOf("service-run"); + return { + installed: true, + content, + execEntrypoint: serviceRunIndex >= 1 ? matches[serviceRunIndex - 1] : undefined, + }; +} + +function splitCommandLine(value: string): string[] { + const parts: string[] = []; + let current = ""; + let quote: '"' | "'" | undefined; + + for (let index = 0; index < value.length; index += 1) { + const char = value[index]; + if (quote) { + if (char === quote) { + quote = undefined; + } else if (char === "\\" && quote === '"' && index + 1 < value.length) { + index += 1; + current += value[index]; + } else { + current += char; + } + continue; + } + if (char === '"' || char === "'") { + quote = char; + continue; + } + if (/\s/u.test(char)) { + if (current) { + parts.push(current); + current = ""; + } + continue; + } + current += char; + } + + if (current) parts.push(current); + return parts; +} + +function xmlUnescape(value: string): string { + return value + .replaceAll("'", "'") + .replaceAll(""", '"') + .replaceAll(">", ">") + .replaceAll("<", "<") + .replaceAll("&", "&"); +} + +async function readSystemdRuntimeDetails(runner: CommandRunner): Promise { + const result = await runner.exec("systemctl", [ + "--user", + "show", + SYSTEMD_SERVICE_NAME, + "--property=Environment", + "--property=MainPID", + "--value", + ]); + if (result.exitCode !== 0) return emptySystemdRuntimeDetails(); + const [environment = "", mainPidRaw = ""] = result.stdout.split(/\r?\n/); + const mainPid = Number(mainPidRaw.trim()); + return { + environment: environment.trim(), + mainPid: Number.isFinite(mainPid) && mainPid > 0 ? mainPid : undefined, + }; +} + +function emptySystemdRuntimeDetails(): SystemdRuntimeDetails { + return { environment: "" }; +} diff --git a/src/service/runner.ts b/src/service/runner.ts new file mode 100644 index 0000000..8ba7816 --- /dev/null +++ b/src/service/runner.ts @@ -0,0 +1,40 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +export interface CommandRunner { + exec(command: string, args: string[], options?: { cwd?: string }): Promise<{ + stdout: string; + stderr: string; + exitCode: number; + }>; +} + +export const defaultCommandRunner: CommandRunner = { + async exec(command, args, options) { + try { + const result = await execFileAsync(command, args, { + cwd: options?.cwd, + encoding: "utf8", + windowsHide: true, + }); + return { + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + exitCode: 0, + }; + } catch (error) { + const execError = error as NodeJS.ErrnoException & { + stdout?: string; + stderr?: string; + code?: string | number; + }; + return { + stdout: execError.stdout ?? "", + stderr: execError.stderr ?? execError.message, + exitCode: typeof execError.code === "number" ? execError.code : 1, + }; + } + }, +}; diff --git a/src/service/templates.ts b/src/service/templates.ts new file mode 100644 index 0000000..f6d2b09 --- /dev/null +++ b/src/service/templates.ts @@ -0,0 +1,119 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { ServerConfig } from "../config.js"; +import { devspaceConfigDir } from "../user-config.js"; + +export interface ServiceCommandSpec { + command: string; + args: string[]; +} + +export function devspaceLogDir(): string { + return join(devspaceConfigDir(), "logs"); +} + +export function buildServiceCommand(cliEntrypoint: string): ServiceCommandSpec { + return { + command: process.execPath, + args: [cliEntrypoint, "service-run"], + }; +} + +export function buildServiceEnvironment(): Record { + const environment: Record = {}; + const allowed = ["DEVSPACE_CONFIG_DIR", "PATH"]; + + for (const key of allowed) { + const value = process.env[key]; + if (value) environment[key] = value; + } + + if (!environment.DEVSPACE_CONFIG_DIR) { + environment.DEVSPACE_CONFIG_DIR = join(homedir(), ".devspace"); + } + + return environment; +} + +export function buildSystemdUnit(options: { + cliEntrypoint: string; + config: ServerConfig; +}): string { + const spec = buildServiceCommand(options.cliEntrypoint); + const logDir = devspaceLogDir(); + const execStart = [spec.command, ...spec.args].map(escapeSystemdArg).join(" "); + const environment = buildServiceEnvironment(); + + return [ + "[Unit]", + "Description=DevSpace MCP Server", + "After=network.target", + "", + "[Service]", + "Type=simple", + `ExecStart=${execStart}`, + "Restart=on-failure", + ...Object.entries(environment).map(([key, value]) => `Environment=${key}=${escapeEnvValue(value)}`), + `StandardOutput=append:${join(logDir, "devspace.out.log")}`, + `StandardError=append:${join(logDir, "devspace.err.log")}`, + "", + "[Install]", + "WantedBy=default.target", + "", + ].join("\n"); +} + +export function buildLaunchAgentPlist(options: { + cliEntrypoint: string; + config: ServerConfig; + label?: string; +}): string { + const spec = buildServiceCommand(options.cliEntrypoint); + const logDir = devspaceLogDir(); + const label = options.label ?? "com.devspace.server"; + const environment = buildServiceEnvironment(); + + return ` + + + + Label + ${xmlEscape(label)} + ProgramArguments + + ${[spec.command, ...spec.args].map((arg) => `${xmlEscape(arg)}`).join("\n ")} + + RunAtLoad + + KeepAlive + + EnvironmentVariables + + ${Object.entries(environment).map(([key, value]) => `${xmlEscape(key)}\n ${xmlEscape(value)}`).join("\n ")} + + StandardOutPath + ${xmlEscape(join(logDir, "devspace.out.log"))} + StandardErrorPath + ${xmlEscape(join(logDir, "devspace.err.log"))} + + +`; +} + +function xmlEscape(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function escapeEnvValue(value: string): string { + return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; +} + +function escapeSystemdArg(value: string): string { + if (/^[A-Za-z0-9_./:@-]+$/.test(value)) return value; + return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; +} diff --git a/src/service/types.ts b/src/service/types.ts new file mode 100644 index 0000000..206fdf0 --- /dev/null +++ b/src/service/types.ts @@ -0,0 +1,48 @@ +export type ServiceManagerKind = + | "systemd-user" + | "launchd" + | "windows-task-scheduler" + | "wsl-task-scheduler-fallback" + | "unsupported"; + +export interface ServiceResult { + ok: boolean; + manager: ServiceManagerKind; + message: string; +} + +export interface ServiceStatus { + installed: boolean; + enabled: boolean; + running: boolean; + manager: ServiceManagerKind; + serviceName: string; + logPath?: string; + endpoint?: string; + publicBaseUrl?: string; + pid?: number; + details?: Record; +} + +export interface ServiceDoctorResult { + manager: ServiceManagerKind; + checks: Array<{ + level: "pass" | "warn" | "info" | "error"; + message: string; + }>; +} + +export interface ServiceManager { + readonly kind: ServiceManagerKind; + readonly serviceName: string; + + isSupported(): Promise; + remove(): Promise; + disable(): Promise; + start(): Promise; + stop(): Promise; + restart(): Promise; + status(): Promise; + logs(options?: { tail?: number }): Promise; + doctor(): Promise; +} 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/skill-manager.test.ts b/src/skill-manager.test.ts new file mode 100644 index 0000000..0a2bba4 --- /dev/null +++ b/src/skill-manager.test.ts @@ -0,0 +1,307 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import assert from "node:assert/strict"; +import { loadConfig } from "./config.js"; +import { + installRootForScope, + installSkill, + listInstalledSkills, + parseGithubTreeUrl, + removeInstalledSkill, +} from "./skill-manager.js"; + +const execFileAsync = promisify(execFile); +const root = await mkdtemp(join(tmpdir(), "devspace-skill-manager-test-")); + +try { + const projectRoot = join(root, "project"); + const agentDir = join(root, "agent"); + const localSkill = join(root, "local-installed-skill"); + const remoteRepo = join(root, "remote-skill-repo"); + const conflictingLocal = join(root, "plan"); + const invalidDirSkill = join(root, "mismatched-dir"); + const symlinkSkill = join(root, "symlink-skill"); + const pluginLikeRoot = join(root, "plugin-like-root"); + const commandsOnlyDir = join(root, "commands-only"); + + await mkdir(projectRoot, { recursive: true }); + await mkdir(join(agentDir, "skills"), { recursive: true }); + await mkdir(localSkill, { recursive: true }); + await writeFile( + join(localSkill, "SKILL.md"), + [ + "---", + "name: local-installed-skill", + "description: Installed from a local path.", + "---", + "", + "# Local Installed Skill", + ].join("\n"), + ); + await mkdir(join(localSkill, "references"), { recursive: true }); + await writeFile(join(localSkill, "references", "guide.md"), "hello\n"); + + await mkdir(remoteRepo, { recursive: true }); + await mkdir(join(remoteRepo, "skills", ".curated", "remote-installed-skill"), { recursive: true }); + await writeFile( + join(remoteRepo, "skills", ".curated", "remote-installed-skill", "SKILL.md"), + [ + "---", + "name: remote-installed-skill", + "description: Installed from a git repo.", + "---", + "", + "# Remote Installed Skill", + ].join("\n"), + ); + await execFileAsync("git", ["init"], { cwd: remoteRepo }); + await execFileAsync("git", ["config", "user.email", "devspace@example.com"], { cwd: remoteRepo }); + await execFileAsync("git", ["config", "user.name", "DevSpace Test"], { cwd: remoteRepo }); + await execFileAsync("git", ["add", "."], { cwd: remoteRepo }); + await execFileAsync("git", ["commit", "-m", "Initial commit"], { cwd: remoteRepo }); + + await mkdir(conflictingLocal, { recursive: true }); + await writeFile( + join(conflictingLocal, "SKILL.md"), + [ + "---", + "name: plan", + "description: Should conflict with system skill.", + "---", + "", + "# Conflicting Local Skill", + ].join("\n"), + ); + + await mkdir(invalidDirSkill, { recursive: true }); + await writeFile( + join(invalidDirSkill, "SKILL.md"), + [ + "---", + "name: different-name", + "description: Directory name mismatch.", + "---", + "", + "# Invalid Skill", + ].join("\n"), + ); + + await mkdir(symlinkSkill, { recursive: true }); + await writeFile( + join(symlinkSkill, "SKILL.md"), + [ + "---", + "name: symlink-skill", + "description: Should be rejected because of symlink contents.", + "---", + "", + "# Symlink Skill", + ].join("\n"), + ); + await symlink(join(root, "project"), join(symlinkSkill, "linked-project")); + + await mkdir(pluginLikeRoot, { recursive: true }); + await writeFile( + join(pluginLikeRoot, "plugin.json"), + JSON.stringify({ name: "plugin-like-root" }, null, 2), + ); + + await mkdir(commandsOnlyDir, { recursive: true }); + await writeFile(join(commandsOnlyDir, "README.md"), "# Commands Only\n"); + + const config = loadConfig({ + DEVSPACE_ALLOWED_ROOTS: `${projectRoot},${root}`, + DEVSPACE_AGENT_DIR: agentDir, + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + PORT: "1", + }); + + const installedLocal = await installSkill({ + config, + workspaceRoot: projectRoot, + scope: "workspace", + source: { kind: "local", path: localSkill }, + localPathResolver: (path) => path, + }); + assert.equal(installedLocal.name, "local-installed-skill"); + assert.equal(installedLocal.scope, "workspace"); + assert.equal(installedLocal.path, join(projectRoot, "skills", "installed", "local-installed-skill")); + assert.equal( + await readFile(join(installedLocal.path, "references", "guide.md"), "utf8"), + "hello\n", + ); + + const listedWorkspace = await listInstalledSkills({ + config, + workspaceRoot: projectRoot, + scope: "workspace", + }); + assert.deepEqual(listedWorkspace.map((skill) => skill.name), ["local-installed-skill"]); + + const installedGlobal = await installSkill({ + config, + workspaceRoot: projectRoot, + scope: "global", + source: { + kind: "github", + repo: "example/skills", + repoUrl: `file://${remoteRepo}`, + path: "skills/.curated/remote-installed-skill", + ref: "master", + }, + runGit: async (args) => { + await execFileAsync("git", args); + }, + }); + assert.equal(installedGlobal.scope, "global"); + assert.equal(installedGlobal.name, "remote-installed-skill"); + assert.equal(installedGlobal.path, join(agentDir, "skills", "remote-installed-skill")); + + const listedAll = await listInstalledSkills({ + config, + workspaceRoot: projectRoot, + scope: "all", + }); + assert.deepEqual( + listedAll.map((skill) => `${skill.scope}:${skill.name}`), + ["global:remote-installed-skill", "workspace:local-installed-skill"], + ); + + await assert.rejects( + () => + installSkill({ + config, + workspaceRoot: projectRoot, + scope: "workspace", + source: { kind: "local", path: localSkill }, + localPathResolver: (path) => path, + }), + /already exists/, + ); + + await assert.rejects( + () => + installSkill({ + config, + workspaceRoot: projectRoot, + scope: "workspace", + source: { kind: "local", path: conflictingLocal }, + localPathResolver: (path) => path, + }), + /DevSpace 系统/, + ); + + await assert.rejects( + () => + installSkill({ + config, + workspaceRoot: projectRoot, + scope: "workspace", + source: { kind: "local", path: invalidDirSkill }, + localPathResolver: (path) => path, + }), + /directory name must match/, + ); + + await assert.rejects( + () => + installSkill({ + config, + workspaceRoot: projectRoot, + scope: "workspace", + source: { kind: "local", path: symlinkSkill }, + localPathResolver: (path) => path, + }), + /symlink/, + ); + + await assert.rejects( + () => + installSkill({ + config, + workspaceRoot: projectRoot, + scope: "workspace", + source: { kind: "local", path: pluginLikeRoot }, + localPathResolver: (path) => path, + }), + /missing SKILL\.md/, + ); + + await assert.rejects( + () => + installSkill({ + config, + workspaceRoot: projectRoot, + scope: "workspace", + source: { kind: "local", path: commandsOnlyDir }, + localPathResolver: (path) => path, + }), + /missing SKILL\.md/, + ); + + await assert.rejects( + () => + installSkill({ + config, + workspaceRoot: projectRoot, + scope: "global", + source: { + kind: "github", + repo: "example/skills", + repoUrl: `file://${remoteRepo}`, + path: "../escape", + ref: "master", + }, + runGit: async (args) => { + await execFileAsync("git", args); + }, + }), + /Invalid skill path/, + ); + + const removedWorkspace = await removeInstalledSkill({ + config, + workspaceRoot: projectRoot, + scope: "workspace", + name: "local-installed-skill", + }); + assert.equal(removedWorkspace.name, "local-installed-skill"); + + const listedAfterRemove = await listInstalledSkills({ + config, + workspaceRoot: projectRoot, + scope: "workspace", + }); + assert.deepEqual(listedAfterRemove, []); + + await assert.rejects( + () => + removeInstalledSkill({ + config, + workspaceRoot: projectRoot, + scope: "workspace", + name: "missing-skill", + }), + /not found/, + ); + + assert.deepEqual(parseGithubTreeUrl("https://github.com/openai/skills/tree/main/skills/.curated/research"), { + repo: "openai/skills", + ref: "main", + path: "skills/.curated/research", + }); + + assert.equal( + installRootForScope(config, projectRoot, "workspace"), + join(projectRoot, "skills", "installed"), + ); + assert.equal( + installRootForScope(config, projectRoot, "global"), + join(agentDir, "skills"), + ); +} finally { + await rm(root, { recursive: true, force: true }); +} diff --git a/src/skill-manager.ts b/src/skill-manager.ts new file mode 100644 index 0000000..3772478 --- /dev/null +++ b/src/skill-manager.ts @@ -0,0 +1,383 @@ +import { mkdtemp, mkdir, opendir, readFile, readdir, rename, rm, stat, lstat, cp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, join, resolve } from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { parseFrontmatter } from "@earendil-works/pi-coding-agent"; +import { assertAllowedPath, isPathInsideRoot } from "./roots.js"; +import type { ServerConfig } from "./config.js"; +import { loadWorkspaceSkills, skillSourceLabel } from "./skills.js"; + +const execFileAsync = promisify(execFile); + +export type SkillScope = "workspace" | "global"; +export type SkillSourceType = "local" | "github" | "github_url"; +export type InstalledSkillSourceType = "workspace-installed" | "global-installed"; + +export type SkillInstallSource = + | { + kind: "local"; + path: string; + } + | { + kind: "github"; + repo: string; + path: string; + ref?: string; + repoUrl?: string; + } + | { + kind: "github_url"; + url: string; + }; + +export interface InstalledSkillRecord { + name: string; + description: string; + scope: SkillScope; + path: string; + removable: boolean; + sourceType: InstalledSkillSourceType; +} + +export interface InstalledSkillResult extends InstalledSkillRecord { + sourceSummary: string; +} + +export interface RemovedSkillResult { + name: string; + scope: SkillScope; + removedPath: string; +} + +interface GitRunner { + (args: string[]): Promise; +} + +interface ParsedSkillMetadata { + name: string; + description: string; + baseDir: string; +} + +export async function installSkill(options: { + config: ServerConfig; + workspaceRoot?: string; + scope: SkillScope; + source: SkillInstallSource; + githubBaseUrl?: string; + localPathResolver?: (path: string) => string; + runGit?: GitRunner; +}): Promise { + const sourceDir = await materializeSource(options.source, { + githubBaseUrl: options.githubBaseUrl, + localPathResolver: options.localPathResolver + ?? ((path: string) => assertAllowedPath(path, options.config.allowedRoots)), + runGit: options.runGit, + }); + try { + const metadata = await readSkillMetadata(sourceDir.path); + const targetRoot = installRootForScope(options.config, options.workspaceRoot, options.scope); + const targetPath = join(targetRoot, metadata.name); + await mkdir(targetRoot, { recursive: true }); + await validateSkillTree(metadata.baseDir); + await assertInstallConflicts(options.config, options.workspaceRoot, metadata.name, options.scope); + + await ensurePathMissing(targetPath, metadata.name, options.scope); + const stagingPath = join( + targetRoot, + `.${metadata.name}.tmp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`, + ); + try { + await cp(metadata.baseDir, stagingPath, { recursive: true, errorOnExist: true, force: false }); + await rename(stagingPath, targetPath); + } catch (error) { + await rm(stagingPath, { recursive: true, force: true }); + throw error; + } + + return { + name: metadata.name, + description: metadata.description, + scope: options.scope, + path: targetPath, + removable: true, + sourceType: options.scope === "workspace" ? "workspace-installed" : "global-installed", + sourceSummary: sourceDir.summary, + }; + } finally { + await sourceDir.dispose(); + } +} + +export async function removeInstalledSkill(options: { + config: ServerConfig; + workspaceRoot?: string; + scope: SkillScope; + name: string; +}): Promise { + validateSkillName(options.name); + const targetRoot = installRootForScope(options.config, options.workspaceRoot, options.scope); + const targetPath = resolve(targetRoot, options.name); + if (!isPathInsideRoot(targetPath, targetRoot)) { + throw new Error(`Refusing to remove skill outside installed root: ${options.name}`); + } + + const targetStats = await safeStat(targetPath); + if (!targetStats?.isDirectory()) { + throw new Error(`Installed skill not found: ${options.name}`); + } + + const metadata = await readSkillMetadata(targetPath); + if (metadata.name !== options.name) { + throw new Error(`Installed skill name mismatch for ${options.name}.`); + } + + await rm(targetPath, { recursive: true, force: false }); + return { + name: options.name, + scope: options.scope, + removedPath: targetPath, + }; +} + +export async function listInstalledSkills(options: { + config: ServerConfig; + workspaceRoot?: string; + scope: "workspace" | "global" | "all"; +}): Promise { + const scopes: SkillScope[] = + options.scope === "all" ? ["workspace", "global"] : [options.scope]; + const collected = await Promise.all( + scopes.map(async (scope) => listInstalledSkillsForScope(options.config, options.workspaceRoot, scope)), + ); + + return collected.flat().sort((a, b) => { + if (a.scope !== b.scope) return a.scope.localeCompare(b.scope); + return a.name.localeCompare(b.name); + }); +} + +export function resolveWorkspaceRoot(config: ServerConfig, workspacePath: string): string { + return assertAllowedPath(workspacePath, config.allowedRoots); +} + +export function installRootForScope( + config: ServerConfig, + workspaceRoot: string | undefined, + scope: SkillScope, +): string { + if (scope === "global") { + return resolve(config.agentDir, "skills"); + } + + if (!workspaceRoot) { + throw new Error("workspaceRoot is required for workspace-scoped skill operations."); + } + + return resolve(workspaceRoot, "skills", "installed"); +} + +async function listInstalledSkillsForScope( + config: ServerConfig, + workspaceRoot: string | undefined, + scope: SkillScope, +): Promise { + const root = installRootForScope(config, workspaceRoot, scope); + const rootStats = await safeStat(root); + if (!rootStats?.isDirectory()) return []; + + const entries = await readdir(root, { withFileTypes: true }); + const records: InstalledSkillRecord[] = []; + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const skillDir = join(root, entry.name); + const metadata = await safeReadSkillMetadata(skillDir); + if (!metadata) continue; + records.push({ + name: metadata.name, + description: metadata.description, + scope, + path: skillDir, + removable: true, + sourceType: scope === "workspace" ? "workspace-installed" : "global-installed", + }); + } + + return records; +} + +async function readSkillMetadata(skillDir: string): Promise { + const skillFile = join(skillDir, "SKILL.md"); + const content = await readFile(skillFile, "utf8").catch(() => { + throw new Error(`Skill directory is missing SKILL.md: ${skillDir}`); + }); + const { frontmatter } = parseFrontmatter>(content); + const name = String(frontmatter.name ?? "").trim(); + const description = String(frontmatter.description ?? "").trim(); + + if (!name) { + throw new Error(`Skill frontmatter is missing name: ${skillFile}`); + } + if (!description) { + throw new Error(`Skill frontmatter is missing description: ${skillFile}`); + } + + validateSkillName(name); + if (basename(skillDir) !== name) { + throw new Error(`Skill directory name must match frontmatter name: ${skillDir}`); + } + + return { + name, + description, + baseDir: skillDir, + }; +} + +async function safeReadSkillMetadata(skillDir: string): Promise { + try { + return await readSkillMetadata(skillDir); + } catch { + return null; + } +} + +async function ensurePathMissing(targetPath: string, skillName: string, scope: SkillScope): Promise { + const existing = await safeStat(targetPath); + if (existing) { + throw new Error(`Installed skill already exists in ${scope} scope: ${skillName}`); + } +} + +function validateSkillName(name: string): void { + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) { + throw new Error(`Invalid skill name: ${name}`); + } +} + +async function validateSkillTree(root: string): Promise { + const entries = await opendir(root); + for await (const entry of entries) { + const path = join(root, entry.name); + const stats = await lstat(path); + if (stats.isSymbolicLink()) { + throw new Error(`Skill directory contains unsupported symlink: ${path}`); + } + if (stats.isDirectory()) { + await validateSkillTree(path); + } + } +} + +async function assertInstallConflicts( + config: ServerConfig, + workspaceRoot: string | undefined, + skillName: string, + scope: SkillScope, +): Promise { + if (!workspaceRoot) return; + + const loaded = loadWorkspaceSkills(config, workspaceRoot); + const existing = loaded.skills.find((skill) => skill.name === skillName); + if (!existing) return; + + if (existing.source === "devspace_system" || existing.source === "local") { + throw new Error( + `Skill ${skillName} conflicts with an existing ${skillSourceLabel(existing.source)} skill.`, + ); + } + + if ( + (scope === "workspace" && existing.source === "installed") || + (scope === "global" && existing.source === "global") + ) { + throw new Error( + `Skill ${skillName} already exists in ${scope === "workspace" ? "项目已安装" : "全局已安装"} source.`, + ); + } +} + +async function safeStat(path: string) { + try { + return await stat(path); + } catch { + return null; + } +} + +async function materializeSource( + source: SkillInstallSource, + options: { + githubBaseUrl?: string; + localPathResolver?: (path: string) => string; + runGit?: GitRunner; + }, +): Promise<{ + path: string; + summary: string; + dispose: () => Promise; +}> { + if (source.kind === "local") { + const resolvedPath = options.localPathResolver ? options.localPathResolver(source.path) : resolve(source.path); + return { + path: resolvedPath, + summary: `local:${resolvedPath}`, + dispose: async () => {}, + }; + } + + const tempRoot = await mkdtemp(join(tmpdir(), "devspace-skill-")); + const checkoutRoot = join(tempRoot, "repo"); + const parsed = source.kind === "github_url" ? parseGithubTreeUrl(source.url) : source; + validateRelativeSkillPath(parsed.path); + const repoBaseUrl = options.githubBaseUrl ?? "https://github.com/"; + const repoUrl = parsed.repoUrl ?? new URL(`${parsed.repo}.git`, repoBaseUrl).toString(); + const ref = parsed.ref ?? "main"; + const runGit = options.runGit ?? ((args: string[]) => execFileAsync("git", args).then(() => undefined)); + + try { + await runGit(["clone", "--depth", "1", "--filter=blob:none", "--sparse", "--branch", ref, repoUrl, checkoutRoot]); + await runGit(["-C", checkoutRoot, "sparse-checkout", "set", "--no-cone", parsed.path]); + } catch (error) { + await rm(tempRoot, { recursive: true, force: true }); + throw new Error( + `Failed to fetch GitHub skill from ${parsed.repo}:${parsed.path}${parsed.ref ? `@${parsed.ref}` : ""}.`, + ); + } + + const skillDir = join(checkoutRoot, parsed.path); + return { + path: skillDir, + summary: `github:${parsed.repo}/${parsed.path}${parsed.ref ? `@${parsed.ref}` : ""}`, + dispose: async () => { + await rm(tempRoot, { recursive: true, force: true }); + }, + }; +} + +export function parseGithubTreeUrl(url: string): { repo: string; path: string; ref?: string; repoUrl?: string } { + const parsed = new URL(url); + if (parsed.hostname !== "github.com") { + throw new Error(`Unsupported GitHub URL host: ${parsed.hostname}`); + } + + const parts = parsed.pathname.split("/").filter(Boolean); + if (parts.length < 5 || parts[2] !== "tree") { + throw new Error(`Unsupported GitHub tree URL: ${url}`); + } + + return { + repo: `${parts[0]}/${parts[1]}`, + ref: parts[3], + path: parts.slice(4).join("/"), + }; +} + +function validateRelativeSkillPath(path: string): void { + const normalized = path.trim(); + if (!normalized || normalized.startsWith("/") || normalized.split("/").includes("..")) { + throw new Error(`Invalid skill path: ${path}`); + } +} diff --git a/src/skills.test.ts b/src/skills.test.ts index 1b0ebae..9bfa245 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -1,11 +1,12 @@ +import assert from "node:assert/strict"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import assert from "node:assert/strict"; import { loadConfig } from "./config.js"; import { - formatPathForPrompt, loadWorkspaceSkills, + markSkillActivated, + resolveSkillDefinition, resolveSkillReadPath, } from "./skills.js"; @@ -15,56 +16,42 @@ 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(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"), - [ - "---", - "name: project-skill", - "description: Project skill description.", - "---", - "", - "# Project Skill", - ].join("\n"), - ); - await writeFile( - join(agentDir, "skills", "global-skill", "SKILL.md"), - [ - "---", - "name: duplicate-skill", - "description: First duplicate wins.", - "---", - "", - "# Global Skill", - ].join("\n"), - ); - await writeFile( - join(explicitSkills, "duplicate", "SKILL.md"), - [ - "---", - "name: duplicate-skill", - "description: Duplicate loser.", - "---", - "", - "# Duplicate Skill", - ].join("\n"), - ); - await writeFile( - join(explicitSkills, "disabled", "SKILL.md"), - [ - "---", - "name: hidden-skill", - "description: Hidden skill.", - "disable-model-invocation: true", - "---", - "", - "# Hidden Skill", - ].join("\n"), - ); + await writeSkill(join(projectRoot, "skills", "local", "project-skill"), { + name: "project-skill", + description: "Project-local Skill.", + body: "# Project Skill", + }); + await writeSkill(join(projectRoot, "skills", "installed", "installed-skill"), { + name: "installed-skill", + description: "Installed Skill.", + body: "# Installed Skill", + }); + await writeSkill(join(projectRoot, "skills", "local", "duplicate-priority-skill"), { + name: "duplicate-priority-skill", + description: "Local wins over installed and global.", + body: "# Local Duplicate", + }); + await writeSkill(join(projectRoot, "skills", "installed", "duplicate-priority-skill"), { + name: "duplicate-priority-skill", + description: "Installed loses to local.", + body: "# Installed Duplicate", + }); + await writeSkill(join(projectRoot, "skills", "local", "plan"), { + name: "plan", + description: "Attempted local system override that must lose.", + body: "# Local Plan Override", + }); + await writeSkill(join(agentDir, "skills", "global-only-skill"), { + name: "global-only-skill", + description: "Global Skill.", + body: "# Global Skill", + }); + await writeSkill(join(explicitSkills, "external-global-skill"), { + name: "external-global-skill", + description: "Explicit global Skill path.", + body: "# Explicit Global Skill", + }); const disabledConfig = loadConfig({ DEVSPACE_ALLOWED_ROOTS: projectRoot, @@ -84,27 +71,82 @@ try { PORT: "1", }); const loaded = loadWorkspaceSkills(config, projectRoot); - assert.equal(loaded.skills.some((skill) => skill.name === "project-skill"), true); - assert.equal(loaded.skills.filter((skill) => skill.name === "duplicate-skill").length, 1); - assert.equal(loaded.skills.some((skill) => skill.name === "hidden-skill"), true); - assert.equal(loaded.diagnostics.some((diagnostic) => diagnostic.type === "collision"), true); - const projectSkill = loaded.skills.find((skill) => skill.name === "project-skill"); - assert.ok(projectSkill); - assert.match(formatPathForPrompt(projectSkill.filePath), /SKILL\.md$/); + assert.equal(loaded.skills.some((skill) => skill.name === "project-skill" && skill.source === "local"), true); + assert.equal(loaded.skills.some((skill) => skill.name === "installed-skill" && skill.source === "installed"), true); + assert.equal(loaded.skills.some((skill) => skill.name === "global-only-skill" && skill.source === "global"), true); + assert.equal(loaded.skills.some((skill) => skill.name === "external-global-skill" && skill.source === "global"), true); + assert.equal(loaded.skills.some((skill) => skill.baseDir.includes(join("skills", "core"))), false); + assert.deepEqual( + loaded.skills.filter((skill) => skill.source === "devspace_system").map((skill) => skill.name).sort(), + ["architecture-review", "goal", "plan", "skill-authoring", "workflow"], + ); - const skillFileRead = resolveSkillReadPath(loaded.skills, new Set(), projectSkill.filePath); - assert.equal(skillFileRead?.isSkillFile, true); - assert.equal(skillFileRead?.absolutePath, projectSkill.filePath); + const duplicate = loaded.skills.find((skill) => skill.name === "duplicate-priority-skill"); + assert.ok(duplicate); + assert.equal(duplicate.source, "local"); + assert.equal(duplicate.filePath.endsWith(join("skills", "local", "duplicate-priority-skill", "SKILL.md")), true); - const resourcePath = join(projectSkill.baseDir, "references.md"); - await writeFile(resourcePath, "reference\n"); - assert.equal(resolveSkillReadPath(loaded.skills, new Set(), resourcePath), undefined); - assert.equal( - resolveSkillReadPath(loaded.skills, new Set([projectSkill.baseDir]), resourcePath) - ?.isSkillFile, - false, + const plan = loaded.skills.find((skill) => skill.name === "plan"); + assert.ok(plan); + assert.equal(plan.source, "devspace_system"); + assert.equal(plan.filePath.endsWith(join("skills", "local", "plan", "SKILL.md")), false); + assert.match(plan.locator, /^skill:\/\/devspace-system\/plan\/SKILL\.md$/); + + const resolvedPlan = await resolveSkillDefinition(loaded.skills, "/plan"); + assert.equal(resolvedPlan.name, "plan"); + assert.equal(resolvedPlan.qualifiedId, "plan"); + assert.equal(resolvedPlan.source, "devspace_system"); + assert.equal(resolvedPlan.alias, "/plan"); + assert.equal(resolvedPlan.mode, "read_only"); + assert.match(resolvedPlan.instructions, /# DevSpace Plan/); + + await assert.rejects( + () => resolveSkillDefinition(loaded.skills, "create-plan"), + /Skill not found: create-plan/, ); + + const resolvedGoal = await resolveSkillDefinition(loaded.skills, "/goal"); + assert.equal(resolvedGoal.name, "goal"); + assert.equal(resolvedGoal.source, "devspace_system"); + assert.equal(resolvedGoal.alias, "/goal"); + assert.equal(resolvedGoal.mode, "normal"); + assert.match(resolvedGoal.instructions, /# DevSpace Goal/); + await assert.rejects(() => resolveSkillDefinition(loaded.skills, "define-goal"), /Skill not found: define-goal/); + await assert.rejects(() => resolveSkillDefinition(loaded.skills, "devspace-workflow"), /Skill not found: devspace-workflow/); + await assert.rejects(() => resolveSkillDefinition(loaded.skills, "senior-architect-lite"), /Skill not found: senior-architect-lite/); + + const skillFileRead = resolveSkillReadPath(loaded.skills, new Set(), resolvedPlan.path); + assert.equal(skillFileRead?.isSkillFile, true); + assert.equal(skillFileRead?.skill.name, "plan"); + + const resourceLocator = resolvedPlan.path.replace("SKILL.md", "references/state.md"); + assert.equal(resolveSkillReadPath(loaded.skills, new Set(), resourceLocator), undefined); + const activated = new Set(); + markSkillActivated(activated, resolvedPlan.skill); + assert.equal(resolveSkillReadPath(loaded.skills, activated, resourceLocator)?.isSkillFile, false); + + const resolvedExplicit = await resolveSkillDefinition(loaded.skills, "external-global-skill"); + assert.equal(resolvedExplicit.source, "global"); } finally { await rm(root, { recursive: true, force: true }); } + +async function writeSkill( + directory: string, + input: { name: string; description: string; body: string }, +): Promise { + await mkdir(directory, { recursive: true }); + await writeFile( + join(directory, "SKILL.md"), + [ + "---", + `name: ${input.name}`, + `description: ${input.description}`, + "---", + "", + input.body, + "", + ].join("\n"), + ); +} diff --git a/src/skills.ts b/src/skills.ts index 20a3520..e0f68d5 100644 --- a/src/skills.ts +++ b/src/skills.ts @@ -1,75 +1,286 @@ +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; import { homedir } from "node:os"; import { resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; import { loadSkills, - type Skill, + loadSkillsFromDir, type LoadSkillsResult, + type Skill, } from "@earendil-works/pi-coding-agent"; import type { ServerConfig } from "./config.js"; import { expandHomePath, isPathInsideRoot } from "./roots.js"; +export type SkillSource = "devspace_system" | "local" | "installed" | "global"; +export type SkillResolveMode = "read_only" | "normal"; + +export interface DevSpaceSkill extends Skill { + source: SkillSource; + qualifiedId: string; + locator: string; + aliases?: string[]; + resolveMode: SkillResolveMode; +} + export interface LoadedSkills { - skills: Skill[]; + skills: DevSpaceSkill[]; diagnostics: LoadSkillsResult["diagnostics"]; } export interface SkillReadResolution { absolutePath: string; - skill: Skill; + skill: DevSpaceSkill; isSkillFile: boolean; } +export interface ResolvedSkillDefinition { + name: string; + qualifiedId: string; + source: SkillSource; + path: string; + alias?: string; + mode: SkillResolveMode; + instructions: string; + skill: DevSpaceSkill; +} + +interface SkillBatch { + skills: DevSpaceSkill[]; + diagnostics: LoadSkillsResult["diagnostics"]; +} + +const PLAN_ALIAS = "/plan"; +const GOAL_ALIAS = "/goal"; +const SYSTEM_SKILL_NAMES = [ + "plan", + "goal", + "workflow", + "architecture-review", + "skill-authoring", +] as const; + 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, - }); + return mergeLoadedSkills([ + ...loadDevSpaceSystemSkillBatches(), + loadSkillsFromSourceDir(workspaceLocalSkillPath(cwd), "local"), + loadSkillsFromSourceDir(workspaceInstalledSkillPath(cwd), "installed"), + loadSkillsFromSourceDir(globalSkillPath(config.agentDir), "global"), + loadExplicitSkillPaths(config, cwd), + ]); +} + +export async function resolveSkillDefinition( + skills: DevSpaceSkill[], + nameOrAlias: string, +): Promise { + const lookup = normalizeSkillLookup(nameOrAlias); + const alias = lookup === PLAN_ALIAS || lookup === GOAL_ALIAS ? lookup : undefined; + const fixedName = alias === PLAN_ALIAS + ? "plan" + : alias === GOAL_ALIAS + ? "goal" + : lookup; + + const skill = alias + ? skills.find((candidate) => candidate.name === fixedName && candidate.source === "devspace_system") + : skills.find((candidate) => candidate.qualifiedId === fixedName) + ?? skills.find((candidate) => candidate.name === fixedName); + + if (!skill) throw new Error(`Skill not found: ${nameOrAlias}`); + + return { + name: skill.name, + qualifiedId: skill.qualifiedId, + source: skill.source, + path: skill.locator, + alias, + mode: fixedName === "plan" && skill.source === "devspace_system" ? "read_only" : skill.resolveMode, + instructions: await readFile(skill.filePath, "utf8"), + skill, + }; } export function resolveSkillReadPath( - skills: Skill[], + skills: DevSpaceSkill[], activatedSkillDirs: Set, inputPath: string, ): SkillReadResolution | undefined { - const absolutePath = resolve(expandHomePath(inputPath)); + const locatorMatch = resolveLocatorReadPath(skills, activatedSkillDirs, inputPath); + if (locatorMatch) return locatorMatch; + const absolutePath = resolve(expandHomePath(inputPath)); for (const skill of skills) { - const skillFilePath = resolve(skill.filePath); - if (absolutePath === skillFilePath) { + if (absolutePath === resolve(skill.filePath)) { return { absolutePath, skill, isSkillFile: true }; } } for (const skill of skills) { const baseDir = resolve(skill.baseDir); - if (!activatedSkillDirs.has(baseDir)) continue; - if (!isPathInsideRoot(absolutePath, baseDir)) continue; - + if (!activatedSkillDirs.has(baseDir) || !isPathInsideRoot(absolutePath, baseDir)) continue; return { absolutePath, skill, isSkillFile: false }; } return undefined; } -export function markSkillActivated( - activatedSkillDirs: Set, - skill: Skill, -): void { +export function markSkillActivated(activatedSkillDirs: Set, skill: DevSpaceSkill): void { activatedSkillDirs.add(resolve(skill.baseDir)); } export function formatPathForPrompt(path: string): string { const home = resolve(homedir()); const resolvedPath = resolve(path); - if (resolvedPath === home) return "~"; if (resolvedPath.startsWith(`${home}${sep}`)) { return `~/${resolvedPath.slice(home.length + 1).split(sep).join("/")}`; } - return resolvedPath.split(sep).join("/"); } + +export function skillSourceLabel(source: SkillSource): string { + switch (source) { + case "devspace_system": + return "DevSpace 系统"; + case "local": + return "项目自定义"; + case "installed": + return "项目已安装"; + case "global": + return "全局已安装"; + } +} + +function loadDevSpaceSystemSkillBatches(): SkillBatch[] { + const root = bundledSystemSkillPath(); + return SYSTEM_SKILL_NAMES.map((name) => loadSkillsFromSourceDir(resolve(root, name), "devspace_system")); +} + +function bundledSystemSkillPath(): string { + return resolve(fileURLToPath(new URL("../skills/.system", import.meta.url))); +} + +function workspaceLocalSkillPath(cwd: string): string { + return resolve(cwd, "skills", "local"); +} + +function workspaceInstalledSkillPath(cwd: string): string { + return resolve(cwd, "skills", "installed"); +} + +function globalSkillPath(agentDir: string): string { + return resolve(agentDir, "skills"); +} + +function loadSkillsFromSourceDir(dir: string, source: SkillSource): SkillBatch { + if (!existsSync(dir)) return { skills: [], diagnostics: [] }; + const loaded = loadSkillsFromDir({ + dir, + source: source === "global" ? "user" : "system", + }); + return { + diagnostics: [...loaded.diagnostics], + skills: loaded.skills.map((skill) => decorateSkill(skill, source)), + }; +} + +function loadExplicitSkillPaths(config: ServerConfig, cwd: string): SkillBatch { + if (config.skillPaths.length === 0) return { skills: [], diagnostics: [] }; + const loaded = loadSkills({ + cwd, + agentDir: config.agentDir, + skillPaths: config.skillPaths, + includeDefaults: false, + }); + return { + diagnostics: loaded.diagnostics, + skills: loaded.skills.map((skill) => decorateSkill(skill, "global")), + }; +} + +function decorateSkill(skill: Skill, source: SkillSource): DevSpaceSkill { + return { + ...skill, + source, + qualifiedId: skill.name, + locator: skillLocator(source, skill.name), + aliases: aliasesForSkill(skill.name, source), + resolveMode: skill.name === "plan" && source === "devspace_system" ? "read_only" : "normal", + }; +} + +function aliasesForSkill(name: string, source: SkillSource): string[] | undefined { + if (source !== "devspace_system") return undefined; + if (name === "plan") return [PLAN_ALIAS]; + if (name === "goal") return [GOAL_ALIAS]; + return undefined; +} + +function mergeLoadedSkills(batches: SkillBatch[]): 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 (${skillSourceLabel(existing.source)} wins over ${skillSourceLabel(skill.source)})`, + path: skill.filePath, + collision: { + resourceType: "skill", + name: skill.name, + winnerPath: existing.filePath, + loserPath: skill.filePath, + }, + }); + } + } + + return { skills: Array.from(winners.values()), diagnostics }; +} + +function resolveLocatorReadPath( + skills: DevSpaceSkill[], + activatedSkillDirs: Set, + inputPath: string, +): SkillReadResolution | undefined { + if (!inputPath.startsWith("skill://")) return undefined; + + for (const skill of skills) { + if (inputPath === skill.locator) { + return { absolutePath: resolve(skill.filePath), skill, isSkillFile: true }; + } + + const prefix = skill.locator.slice(0, -"SKILL.md".length); + if (!inputPath.startsWith(prefix) || !activatedSkillDirs.has(resolve(skill.baseDir))) continue; + + const relativePath = inputPath.slice(prefix.length); + if (!relativePath || relativePath === "SKILL.md") { + return { absolutePath: resolve(skill.filePath), skill, isSkillFile: true }; + } + const absolutePath = resolve(skill.baseDir, relativePath); + if (!isPathInsideRoot(absolutePath, resolve(skill.baseDir))) return undefined; + return { absolutePath, skill, isSkillFile: false }; + } + + return undefined; +} + +function skillLocator(source: SkillSource, name: string): string { + const namespace = source === "devspace_system" ? "devspace-system" : source; + return `skill://${namespace}/${name}/SKILL.md`; +} + +function normalizeSkillLookup(nameOrAlias: string): string { + const trimmed = nameOrAlias.trim().replace(/^@\S+\s+/, ""); + return trimmed.startsWith("/") ? (trimmed.split(/\s+/)[0] ?? trimmed) : trimmed; +} diff --git a/src/test-utils.ts b/src/test-utils.ts new file mode 100644 index 0000000..e6eaddc --- /dev/null +++ b/src/test-utils.ts @@ -0,0 +1,48 @@ +import { rm } from "node:fs/promises"; +import { rmSync } from "node:fs"; +import { setTimeout as delay } from "node:timers/promises"; + +const RETRYABLE_REMOVE_ERRORS = new Set(["EBUSY", "ENOTEMPTY", "EPERM"]); + +export async function removeTempDir(path: string): Promise { + let lastError: unknown; + + for (let attempt = 0; attempt < 8; attempt += 1) { + try { + await rm(path, { recursive: true, force: true }); + return; + } catch (error) { + if (!isRetryableRemoveError(error)) throw error; + lastError = error; + await delay(25 * (attempt + 1)); + } + } + + throw lastError; +} + +export function removeTempDirSync(path: string): void { + let lastError: unknown; + + for (let attempt = 0; attempt < 8; attempt += 1) { + try { + rmSync(path, { recursive: true, force: true }); + return; + } catch (error) { + if (!isRetryableRemoveError(error)) throw error; + lastError = error; + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25 * (attempt + 1)); + } + } + + throw lastError; +} + +function isRetryableRemoveError(error: unknown): boolean { + return ( + typeof error === "object" + && error !== null + && "code" in error + && RETRYABLE_REMOVE_ERRORS.has(String(error.code)) + ); +} 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..d6cd239 100644 --- a/src/ui/card-types.ts +++ b/src/ui/card-types.ts @@ -2,6 +2,14 @@ import type { App } from "@modelcontextprotocol/ext-apps"; export type ToolName = | "open_workspace" + | "resolve_skill" + | "install_skill" + | "list_installed_skills" + | "remove_skill" + | "request_user_input" + | "get_pending_user_input" + | "answer_user_input" + | "list_user_input_history" | "read_file" | "write_file" | "edit_file" @@ -49,6 +57,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 +101,14 @@ export interface ToolPayload { export function isToolName(value: unknown): value is ToolName { return ( value === "open_workspace" || + value === "resolve_skill" || + value === "install_skill" || + value === "list_installed_skills" || + value === "remove_skill" || + 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 0f5003f..fea7dcc 100644 --- a/src/ui/workspace-app.css +++ b/src/ui/workspace-app.css @@ -372,6 +372,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 373b3ac..65de4b8 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 }); @@ -298,6 +328,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; @@ -376,6 +416,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` }); } @@ -492,6 +536,22 @@ function getToolDisplay(card: ToolResultCard): ToolDisplay { switch (card.tool) { case "open_workspace": return { icon: folderIcon(), title: "Workspace", label, tone: "workspace" }; + case "resolve_skill": + return { icon: fileIcon(), title: "Resolve Skill", label, tone: "read" }; + case "install_skill": + return { icon: filePlusIcon(), title: "Install Skill", label, tone: "write" }; + case "list_installed_skills": + return { icon: filesIcon(), title: "Installed Skills", label, tone: "directory" }; + case "remove_skill": + return { icon: editIcon(), title: "Remove Skill", label, tone: "edit" }; + 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" }; @@ -531,6 +591,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; } @@ -608,6 +671,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..f25361f 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -3,12 +3,33 @@ import { existsSync, mkdirSync, readFileSync, + renameSync, + rmSync, writeFileSync, } from "node:fs"; import { homedir } from "node:os"; -import { join, resolve } from "node:path"; +import { dirname, isAbsolute, join, resolve } from "node:path"; import { expandHomePath } from "./roots.js"; +export type TunnelMode = "cloudflare"; + +export interface DevspaceServerUserConfig { + host?: string; + port?: number; + mcpPath?: string; + publicBaseUrl?: string | null; +} + +export interface DevspaceWorkspacesUserConfig { + allowed?: string[]; + default?: string | null; +} + +export interface DevspaceServiceUserConfig { + manager?: string; + autostart?: boolean; +} + export interface DevspaceUserConfig { host?: string; port?: number; @@ -18,6 +39,13 @@ export interface DevspaceUserConfig { stateDir?: string; worktreeRoot?: string; agentDir?: string; + tunnel?: TunnelMode; + server?: DevspaceServerUserConfig; + workspaces?: DevspaceWorkspacesUserConfig; + service?: DevspaceServiceUserConfig; + allowedDirectories?: string[]; + publicUrl?: string | null; + baseUrl?: string | null; } export interface DevspaceAuthConfig { @@ -59,7 +87,7 @@ export function loadDevspaceFiles(env: NodeJS.ProcessEnv = process.env): Devspac authPath, configExists, authExists, - config: configExists ? readJsonFile(configPath) : {}, + config: configExists ? normalizeDevspaceUserConfig(readJsonFile(configPath)) : {}, auth: authExists ? readJsonFile(authPath) : {}, }; } @@ -70,7 +98,7 @@ export function writeDevspaceConfig( ): string { const filePath = devspaceConfigPath(env); mkdirSync(devspaceConfigDir(env), { recursive: true }); - writeJsonFile(filePath, config, 0o600); + writeJsonFile(filePath, serializeDevspaceUserConfig(config), 0o600); return filePath; } @@ -98,5 +126,126 @@ function readJsonFile(filePath: string): T { } function writeJsonFile(filePath: string, value: unknown, mode: number): void { - writeFileSync(filePath, JSON.stringify(value, null, 2) + "\n", { mode }); + const directory = dirname(filePath); + mkdirSync(directory, { recursive: true, mode: 0o700 }); + const tempPath = `${filePath}.${process.pid}.tmp`; + + try { + writeFileSync(tempPath, JSON.stringify(value, null, 2) + "\n", { mode }); + renameSync(tempPath, filePath); + } finally { + rmSync(tempPath, { force: true }); + } +} + +export function normalizeDevspaceUserConfig(raw: DevspaceUserConfig): DevspaceUserConfig { + const normalizedRoots = normalizePathList( + raw.workspaces?.allowed ?? raw.allowedRoots ?? raw.allowedDirectories, + ); + const defaultWorkspace = normalizeOptionalPath(raw.workspaces?.default); + const splitUrl = splitConfiguredPublicUrl( + raw.server?.publicBaseUrl ?? raw.publicBaseUrl ?? raw.publicUrl ?? raw.baseUrl ?? null, + raw.server?.mcpPath, + ); + const server: DevspaceServerUserConfig = { + host: raw.server?.host ?? raw.host, + port: raw.server?.port ?? raw.port, + mcpPath: splitUrl.mcpPath, + publicBaseUrl: splitUrl.publicBaseUrl, + }; + + return { + ...raw, + host: server.host, + port: server.port, + allowedRoots: normalizedRoots, + publicBaseUrl: server.publicBaseUrl, + server, + workspaces: { + allowed: normalizedRoots, + default: defaultWorkspace, + }, + service: { + manager: raw.service?.manager, + autostart: raw.service?.autostart, + }, + }; +} + +export function normalizeMcpPath(path: string | undefined): string { + const trimmed = path?.trim(); + if (!trimmed) return "/mcp"; + const normalized = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; + return normalized.replace(/\/+$/, "") || "/mcp"; +} + +export function splitConfiguredPublicUrl( + value: string | null | undefined, + explicitMcpPath?: string, +): { + publicBaseUrl: string | null; + mcpPath: string; +} { + const fallbackPath = normalizeMcpPath(explicitMcpPath); + const trimmed = value?.trim(); + if (!trimmed) { + return { + publicBaseUrl: null, + mcpPath: fallbackPath, + }; + } + + const parsed = new URL(trimmed); + const pathname = parsed.pathname.replace(/\/+$/, ""); + parsed.hash = ""; + parsed.search = ""; + parsed.pathname = ""; + + return { + publicBaseUrl: parsed.toString().replace(/\/$/, ""), + mcpPath: normalizeMcpPath(pathname || fallbackPath), + }; +} + +export function serializeDevspaceUserConfig(config: DevspaceUserConfig): DevspaceUserConfig { + const normalized = normalizeDevspaceUserConfig(config); + return { + host: normalized.server?.host, + port: normalized.server?.port, + allowedRoots: normalized.workspaces?.allowed, + publicBaseUrl: normalized.server?.publicBaseUrl ?? null, + allowedHosts: normalized.allowedHosts, + stateDir: normalized.stateDir, + worktreeRoot: normalized.worktreeRoot, + agentDir: normalized.agentDir, + tunnel: normalized.tunnel, + server: { + host: normalized.server?.host, + port: normalized.server?.port, + mcpPath: normalized.server?.mcpPath, + publicBaseUrl: normalized.server?.publicBaseUrl ?? null, + }, + workspaces: { + allowed: normalized.workspaces?.allowed, + default: normalized.workspaces?.default ?? null, + }, + service: { + manager: normalized.service?.manager, + autostart: normalized.service?.autostart, + }, + }; +} + +function normalizePathList(paths: string[] | undefined): string[] | undefined { + const normalized = paths + ?.map((path) => normalizeOptionalPath(path)) + .filter((path): path is string => Boolean(path)); + if (!normalized || normalized.length === 0) return undefined; + return Array.from(new Set(normalized)); +} + +function normalizeOptionalPath(path: string | null | undefined): string | undefined { + const trimmed = path?.trim(); + if (!trimmed) return undefined; + return resolve(expandHomePath(trimmed)); } diff --git a/src/workflow-migration.test.ts b/src/workflow-migration.test.ts new file mode 100644 index 0000000..6955df9 --- /dev/null +++ b/src/workflow-migration.test.ts @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import Database from "better-sqlite3"; +import { databasePath } from "./db/client.js"; +import { SqliteWorkspaceStore } from "./workspace-store.js"; +import { removeTempDir } from "./test-utils.js"; + +const root = await mkdtemp(join(tmpdir(), "devspace-workflow-migration-test-")); + +try { + const stateDir = join(root, "state"); + const projectRoot = join(root, "project"); + await mkdir(projectRoot, { recursive: true }); + await mkdir(stateDir, { recursive: true }); + + const sqlite = new Database(databasePath(stateDir)); + sqlite.exec(` + create table workspace_sessions ( + id text primary key, root text not null, status text not null, mode text not null, + source_root text, base_ref text, base_sha text, managed text not null, + created_at text not null, last_used_at text not null + ); + create table workspace_plans ( + workspace_session_id text primary key, explanation text, steps_json text not null, updated_at text not null + ); + create table workspace_goals ( + workspace_session_id text primary key, objective text not null, status text not null, + token_budget text, created_at text not null, updated_at text not null, + active_seconds text not null, completed_at text, blocked_at text + ); + create table workspace_modes ( + workspace_session_id text primary key, mode text not null, updated_at text not null + ); + `); + + const older = "2026-06-20T00:00:00.000Z"; + const newer = "2026-06-21T00:00:00.000Z"; + const insertSession = sqlite.prepare( + "insert into workspace_sessions values (?, ?, 'active', 'checkout', null, null, null, 'false', ?, ?)", + ); + insertSession.run("old", projectRoot, older, older); + insertSession.run("new", projectRoot, newer, newer); + sqlite.prepare("insert into workspace_plans values (?, ?, ?, ?)").run( + "old", "Older plan", JSON.stringify([{ step: "Old work", status: "completed" }]), older, + ); + sqlite.prepare("insert into workspace_plans values (?, ?, ?, ?)").run( + "new", "Newer plan", JSON.stringify([{ step: "New work", status: "in_progress" }]), newer, + ); + sqlite.prepare("insert into workspace_goals values (?, ?, ?, null, ?, ?, '0', null, null)").run( + "old", "Older goal", "completed", older, older, + ); + sqlite.prepare("insert into workspace_goals values (?, ?, ?, null, ?, ?, '0', null, null)").run( + "new", "Newer goal", "active", newer, newer, + ); + sqlite.prepare("insert into workspace_modes values (?, ?, ?)").run("old", "default", older); + sqlite.prepare("insert into workspace_modes values (?, ?, ?)").run("new", "plan", newer); + sqlite.close(); + + const store = new SqliteWorkspaceStore(stateDir); + assert.equal(store.getPlan("old")?.summary, "Newer plan"); + assert.equal(store.getGoal("old")?.objective, "Newer goal"); + assert.equal(store.getCollaborationMode("old").mode, "plan"); + + const history = store.getWorkflowHistory({ workspaceSessionId: "new", limit: 50 }); + assert.equal(history.events.some((event) => event.eventType === "plan.migrated"), true); + assert.equal(history.events.some((event) => event.eventType === "plan.archived_migrated"), true); + assert.equal(history.events.some((event) => event.eventType === "goal.migrated"), true); + assert.equal(history.events.some((event) => event.eventType === "goal.archived_migrated"), true); + const eventCount = history.events.length; + store.close(); + + const reopened = new SqliteWorkspaceStore(stateDir); + assert.equal(reopened.getWorkflowHistory({ workspaceSessionId: "new", limit: 50 }).events.length, eventCount); + reopened.close(); +} finally { + await removeTempDir(root); +} diff --git a/src/workflow-store.test.ts b/src/workflow-store.test.ts new file mode 100644 index 0000000..248ea61 --- /dev/null +++ b/src/workflow-store.test.ts @@ -0,0 +1,207 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { SqliteWorkspaceStore, WorkflowRevisionConflictError } from "./workspace-store.js"; +import { removeTempDir } from "./test-utils.js"; + +const root = await mkdtemp(join(tmpdir(), "devspace-workflow-store-test-")); + +try { + const stateDir = join(root, "state"); + const projectRoot = join(root, "project"); + const worktreeRoot = join(root, "project-worktree"); + await mkdir(projectRoot, { recursive: true }); + await mkdir(worktreeRoot, { recursive: true }); + + const store = new SqliteWorkspaceStore(stateDir); + store.createSession({ id: "ws_a", root: projectRoot, mode: "checkout" }); + store.createSession({ id: "ws_b", root: projectRoot, mode: "checkout" }); + store.createSession({ id: "ws_worktree", root: worktreeRoot, mode: "worktree" }); + + const projectKey = store.getProjectWorkflowKey("ws_a"); + assert.equal(store.getProjectWorkflowKey("ws_b"), projectKey); + assert.notEqual(store.getProjectWorkflowKey("ws_worktree"), projectKey); + + const plan = store.savePlan({ + workspaceSessionId: "ws_a", + expectedRevision: 0, + title: "Shared workflow state", + summary: "Persist Plan across sessions.", + scopeIn: ["workflow store"], + scopeOut: ["chat transcript storage"], + validation: ["npm test"], + risks: ["stale session writes"], + steps: [ + { step: "Create durable tables", status: "completed" }, + { step: "Expose MCP tools", status: "in_progress" }, + ], + }); + assert.equal(plan.revision, 1); + assert.equal(store.getPlan("ws_b")?.title, "Shared workflow state"); + assert.equal(store.getPlan("ws_worktree"), undefined); + + const updatedPlan = store.savePlan({ + workspaceSessionId: "ws_b", + expectedRevision: 1, + title: plan.title, + summary: plan.summary, + scopeIn: plan.scopeIn, + scopeOut: plan.scopeOut, + validation: plan.validation, + risks: plan.risks, + steps: [ + { id: plan.steps[0]?.id, step: "Create durable tables", status: "completed" }, + { id: plan.steps[1]?.id, step: "Expose MCP tools", status: "completed" }, + ], + }); + assert.equal(updatedPlan.revision, 2); + assert.throws( + () => store.savePlan({ workspaceSessionId: "ws_a", expectedRevision: 1, title: "Stale plan", steps: [{ step: "No overwrite", status: "pending" }] }), + (error: unknown) => error instanceof WorkflowRevisionConflictError && error.entity === "plan" && error.currentRevision === 2, + ); + + const goal = store.saveGoal({ + workspaceSessionId: "ws_a", + objective: "Make workflow state recoverable across sessions", + scopeIn: ["Plan", "Goal", "mode"], + scopeOut: ["chat history"], + successCriteria: ["New sessions read the same Plan and Goal"], + verification: ["workflow store test"], + stopConditions: ["database migration fails"], + currentSummary: "Current: exercise revision conflicts.", + }); + assert.equal(goal.status, "active"); + assert.equal(store.getGoal("ws_b")?.objective, goal.objective); + assert.equal("tokenBudget" in goal, false); + assert.equal("timeUsedSeconds" in goal, false); + + const updatedGoal = store.updateGoal({ + workspaceSessionId: "ws_b", + expectedRevision: goal.revision, + currentSummary: "Completed: shared state. Current: validate conflict detection.", + }); + assert.equal(updatedGoal.revision, 2); + assert.throws( + () => store.updateGoal({ workspaceSessionId: "ws_a", expectedRevision: 1, objective: "Stale goal write" }), + (error: unknown) => error instanceof WorkflowRevisionConflictError && error.entity === "goal" && error.currentRevision === 2, + ); + const blockedGoal = store.updateGoal({ + workspaceSessionId: "ws_a", + expectedRevision: updatedGoal.revision, + status: "blocked", + }); + assert.equal(blockedGoal.status, "blocked"); + assert.throws( + () => store.updateGoal({ workspaceSessionId: "ws_b", expectedRevision: blockedGoal.revision, currentSummary: "Should fail" }), + /Only an active Goal can be updated/, + ); + const replacementGoal = store.saveGoal({ + workspaceSessionId: "ws_a", + objective: "Continue after resolving the blocker", + }); + assert.equal(replacementGoal.status, "active"); + + const linkedPlan = store.savePlan({ + workspaceSessionId: "ws_a", + expectedRevision: updatedPlan.revision, + goalId: replacementGoal.id, + title: updatedPlan.title, + summary: updatedPlan.summary, + scopeIn: updatedPlan.scopeIn, + scopeOut: updatedPlan.scopeOut, + validation: updatedPlan.validation, + risks: updatedPlan.risks, + steps: [ + { id: updatedPlan.steps[0]?.id, step: "Create durable tables", status: "completed" }, + { id: updatedPlan.steps[1]?.id, step: "Expose MCP tools", status: "in_progress" }, + ], + }); + assert.equal(linkedPlan.revision, 3); + const linkedGoal = store.getGoal("ws_b"); + assert.equal(linkedGoal?.metrics.progress.source, "linked_plan_steps"); + assert.equal(linkedGoal?.metrics.progress.exactFraction, "1/2"); + assert.equal(linkedGoal?.metrics.progress.percentageNumerator, 100); + assert.equal(linkedGoal?.metrics.progress.percentageDenominator, 2); + assert.equal(linkedGoal?.metrics.progress.displayPercent, "50.00%"); + + const workStart = store.startGoalWork({ workspaceSessionId: "ws_a" }); + assert.equal(workStart.started, true); + assert.equal(store.startGoalWork({ workspaceSessionId: "ws_b" }).started, false); + await delay(15); + const workPause = store.pauseGoalWork({ workspaceSessionId: "ws_b" }); + assert.equal(workPause.paused, true); + assert.equal(workPause.metrics.workDuration.running, false); + assert.equal(workPause.metrics.workDuration.totalMilliseconds >= 10, true); + assert.equal(store.pauseGoalWork({ workspaceSessionId: "ws_a" }).paused, false); + + const tokenUsage = store.recordGoalTokenUsage({ + workspaceSessionId: "ws_a", + provider: "openai-api", + providerRequestId: "req_001", + model: "gpt-test", + inputTokens: 120, + outputTokens: 80, + reasoningTokens: 20, + totalTokens: 200, + providerReportedAt: "2026-06-22T00:00:00.000Z", + }); + assert.equal(tokenUsage.recorded, true); + assert.deepEqual(tokenUsage.metrics.tokenUsage, { + inputTokens: 120, + outputTokens: 80, + reasoningTokens: 20, + totalTokens: 200, + reportCount: 1, + lastReportedAt: tokenUsage.metrics.tokenUsage.lastReportedAt, + }); + assert.equal( + store.recordGoalTokenUsage({ + workspaceSessionId: "ws_b", + provider: "openai-api", + providerRequestId: "req_001", + inputTokens: 120, + outputTokens: 80, + totalTokens: 200, + }).recorded, + false, + ); + assert.equal(store.getGoal("ws_a")?.metrics.tokenUsage.totalTokens, 200); + + store.setCollaborationMode({ workspaceSessionId: "ws_a", mode: "plan" }); + assert.equal(store.getCollaborationMode("ws_b").mode, "plan"); + const digest = store.getWorkflowDigest("ws_b"); + assert.equal(digest.projectWorkflowKey, projectKey); + assert.equal(digest.hasActiveGoal, true); + assert.equal(digest.hasActivePlan, true); + assert.equal(digest.planRevision, 3); + assert.deepEqual(digest.steps, { total: 2, completed: 1, inProgress: 1, blocked: 0 }); + assert.equal(Buffer.byteLength(JSON.stringify(digest), "utf8") <= 2 * 1024, true); + + for (let index = 0; index < 120; index++) { + store.setCollaborationMode({ workspaceSessionId: "ws_a", mode: index % 2 === 0 ? "default" : "plan" }); + } + + const firstPage = store.getWorkflowHistory({ workspaceSessionId: "ws_a", limit: 50 }); + const secondPage = store.getWorkflowHistory({ workspaceSessionId: "ws_a", limit: 50, cursor: firstPage.nextCursor }); + assert.equal(firstPage.events.length, 50); + assert.equal(secondPage.events.length, 50); + assert.equal(secondPage.nextCursor, undefined); + assert.equal(firstPage.events.every((event) => event.summary.length <= 2048), true); + + assert.equal(store.startGoalWork({ workspaceSessionId: "ws_a" }).started, true); + await delay(5); + const completedReplacementGoal = store.updateGoal({ + workspaceSessionId: "ws_a", + expectedRevision: replacementGoal.revision, + status: "completed", + }); + assert.equal(completedReplacementGoal.status, "completed"); + assert.equal(completedReplacementGoal.metrics.workDuration.running, false); + assert.equal(completedReplacementGoal.metrics.workDuration.totalMilliseconds >= workPause.metrics.workDuration.totalMilliseconds, true); + + store.close(); +} finally { + await removeTempDir(root); +} 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..b55715e --- /dev/null +++ b/src/workspace-operations.test.ts @@ -0,0 +1,62 @@ +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(normalizeNewlines(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 }); +} + +function normalizeNewlines(value: string): string { + return value.replace(/\r\n/g, "\n"); +} 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 39c2ed0..d96c112 100644 --- a/src/workspace-store.ts +++ b/src/workspace-store.ts @@ -1,11 +1,30 @@ +import { execFileSync } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; +import { realpathSync } from "node:fs"; +import { resolve } from "node:path"; import { eq } from "drizzle-orm"; import { openDatabase, type DatabaseHandle } from "./db/client.js"; import { workspaceSessions, + workspaceUserInputs, type WorkspaceSessionRow, + type WorkspaceUserInputRow, } from "./db/schema.js"; +import { parseGoalDefinition } from "./goal-definition.js"; export type WorkspaceMode = "checkout" | "worktree"; +export type CollaborationMode = "default" | "plan"; +export type PlanStatus = "draft" | "active" | "completed" | "archived"; +export type PlanStepStatus = "pending" | "in_progress" | "blocked" | "completed" | "skipped"; +export type GoalStatus = "active" | "blocked" | "completed" | "archived"; +export type WorkflowEntityType = "plan" | "goal" | "mode"; +export type UserInputStatus = "pending" | "completed" | "declined" | "cancelled"; +export type UserInputDeliveryMode = "elicitation" | "tool" | "ui"; + +const MAX_WORKFLOW_TEXT_BYTES = 32 * 1024; +const MAX_SUMMARY_BYTES = 4 * 1024; +const MAX_EVENT_SUMMARY_BYTES = 2 * 1024; +const MAX_WORKFLOW_EVENTS = 100; export interface WorkspaceSession { id: string; @@ -20,6 +39,172 @@ export interface WorkspaceSession { lastUsedAt: string; } +export interface WorkspacePlanStep { + id?: string; + step: string; + status: PlanStepStatus; + note?: string; + updatedAt?: string; +} + +export interface WorkspacePlan { + id: string; + projectWorkflowKey: string; + goalId?: string; + title: string; + summary?: string; + scopeIn: string[]; + scopeOut: string[]; + validation: string[]; + risks: string[]; + status: PlanStatus; + revision: number; + steps: WorkspacePlanStep[]; + createdAt: string; + updatedAt: string; + archivedAt?: string; +} + +export interface GoalTokenUsage { + /** Exact values reported by an upstream provider, never inferred from text length. */ + inputTokens: number; + outputTokens: number; + reasoningTokens: number; + totalTokens: number; + reportCount: number; + lastReportedAt?: string; +} + +export interface GoalWorkDuration { + /** True only while an explicit goal work timer is running on this server. */ + running: boolean; + startedAt?: string; + accumulatedMilliseconds: number; + liveMilliseconds: number; + totalMilliseconds: number; + measuredAt: string; +} + +export interface GoalProgress { + /** Progress is exact only when the current Plan is explicitly linked to this Goal. */ + source: "linked_plan_steps" | "unlinked"; + completedSteps: number; + totalSteps: number; + /** Exact canonical completion ratio, for example `2/3`. */ + exactFraction?: string; + /** Exact rational percentage: percentageNumerator / percentageDenominator. */ + percentageNumerator?: number; + percentageDenominator?: number; + /** Rounded display only; use the numerator and denominator for machine accuracy. */ + displayPercent?: string; +} + +export interface GoalMetrics { + tokenUsage: GoalTokenUsage; + workDuration: GoalWorkDuration; + progress: GoalProgress; + updatedAt?: string; +} + +export interface WorkspaceGoal { + id: string; + projectWorkflowKey: string; + objective: string; + scopeIn: string[]; + scopeOut: string[]; + successCriteria: string[]; + verification: string[]; + stopConditions: string[]; + currentSummary?: string; + status: GoalStatus; + revision: number; + metrics: GoalMetrics; + createdAt: string; + updatedAt: string; + archivedAt?: string; +} + +export interface WorkflowDigest { + projectWorkflowKey: string; + hasActiveGoal: boolean; + goalStatus?: GoalStatus; + goalTitle?: string; + hasActivePlan: boolean; + planStatus?: PlanStatus; + planRevision?: number; + steps?: { + total: number; + completed: number; + inProgress: number; + blocked: number; + }; + lastUpdatedAt?: string; +} + +export interface WorkflowEvent { + id: string; + projectWorkflowKey: string; + entityType: WorkflowEntityType; + entityId: string; + eventType: string; + summary: string; + revision?: number; + createdAt: string; +} + +export interface WorkflowHistoryPage { + events: WorkflowEvent[]; + nextCursor?: 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 class WorkflowRevisionConflictError extends Error { + readonly entity: "plan" | "goal"; + readonly currentRevision: number; + + constructor(entity: "plan" | "goal", currentRevision: number) { + super(`${entity} revision conflict: current revision is ${currentRevision}. Reload the ${entity} before updating it.`); + this.name = "WorkflowRevisionConflictError"; + this.entity = entity; + this.currentRevision = currentRevision; + } +} + export interface WorkspaceStore { createSession(input: { id: string; @@ -32,6 +217,116 @@ export interface WorkspaceStore { }): WorkspaceSession; getSession(id: string): WorkspaceSession | undefined; touchSession(id: string): void; + getProjectWorkflowKey(workspaceSessionId: string): string; + getWorkflowDigest(workspaceSessionId: string): WorkflowDigest; + getWorkflowHistory(input: { + workspaceSessionId: string; + limit?: number; + cursor?: string; + }): WorkflowHistoryPage; + savePlan(input: { + workspaceSessionId: string; + expectedRevision: number; + title?: string; + summary?: string; + scopeIn?: string[]; + scopeOut?: string[]; + validation?: string[]; + risks?: string[]; + status?: Exclude | "archived"; + goalId?: string; + steps: WorkspacePlanStep[]; + }): WorkspacePlan; + getPlan(workspaceSessionId: string): WorkspacePlan | undefined; + saveGoal(input: { + workspaceSessionId: string; + objective: string; + scopeIn?: string[]; + scopeOut?: string[]; + successCriteria?: string[]; + verification?: string[]; + stopConditions?: string[]; + currentSummary?: string; + }): WorkspaceGoal; + getGoal(workspaceSessionId: string): WorkspaceGoal | undefined; + getGoalMetrics(workspaceSessionId: string): GoalMetrics | undefined; + startGoalWork(input: { + workspaceSessionId: string; + }): { + metrics: GoalMetrics; + started: boolean; + }; + pauseGoalWork(input: { + workspaceSessionId: string; + }): { + metrics: GoalMetrics; + paused: boolean; + }; + recordGoalTokenUsage(input: { + workspaceSessionId: string; + provider: string; + providerRequestId: string; + model?: string; + inputTokens: number; + outputTokens: number; + reasoningTokens?: number; + totalTokens: number; + providerReportedAt?: string; + }): { + metrics: GoalMetrics; + recorded: boolean; + }; + updateGoal(input: { + workspaceSessionId: string; + expectedRevision: number; + objective?: string; + scopeIn?: string[]; + scopeOut?: string[]; + successCriteria?: string[]; + verification?: string[]; + stopConditions?: string[]; + currentSummary?: string; + status?: GoalStatus; + }): WorkspaceGoal; + updateGoalStatus(input: { + workspaceSessionId: string; + status: "completed" | "complete" | "blocked" | "archived"; + expectedRevision?: number; + }): WorkspaceGoal; + setCollaborationMode(input: { + workspaceSessionId: string; + mode: CollaborationMode; + }): { + workspaceSessionId: string; + projectWorkflowKey: string; + mode: CollaborationMode; + updatedAt: string; + }; + getCollaborationMode(workspaceSessionId: string): { + workspaceSessionId: string; + projectWorkflowKey: 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; } @@ -40,6 +335,7 @@ export class SqliteWorkspaceStore implements WorkspaceStore { constructor(stateDir: string) { this.database = openDatabase(stateDir); + this.migrate(); } createSession(input: { @@ -81,6 +377,7 @@ export class SqliteWorkspaceStore implements WorkspaceStore { }) .run(); + this.ensureProjectWorkflow(session.root, session.mode); return session; } @@ -102,16 +399,1473 @@ export class SqliteWorkspaceStore implements WorkspaceStore { .run(); } + getProjectWorkflowKey(workspaceSessionId: string): string { + return this.workflowForSession(workspaceSessionId).key; + } + + getWorkflowDigest(workspaceSessionId: string): WorkflowDigest { + const workflow = this.workflowForSession(workspaceSessionId); + const plan = this.getPlan(workspaceSessionId); + const goal = this.getGoal(workspaceSessionId); + const updatedAt = [plan?.updatedAt, goal?.updatedAt, goal?.metrics.updatedAt] + .filter((value): value is string => Boolean(value)) + .sort() + .at(-1); + + return { + projectWorkflowKey: workflow.key, + hasActiveGoal: goal?.status === "active", + goalStatus: goal?.status, + goalTitle: goal ? truncateText(goal.objective, 160) : undefined, + hasActivePlan: Boolean(plan && (plan.status === "draft" || plan.status === "active")), + planStatus: plan?.status, + planRevision: plan?.revision, + steps: plan + ? { + total: plan.steps.length, + completed: plan.steps.filter((step) => step.status === "completed").length, + inProgress: plan.steps.filter((step) => step.status === "in_progress").length, + blocked: plan.steps.filter((step) => step.status === "blocked").length, + } + : undefined, + lastUpdatedAt: updatedAt, + }; + } + + getWorkflowHistory(input: { + workspaceSessionId: string; + limit?: number; + cursor?: string; + }): WorkflowHistoryPage { + const workflow = this.workflowForSession(input.workspaceSessionId); + const limit = Math.max(1, Math.min(input.limit ?? 20, 50)); + const cursor = decodeHistoryCursor(input.cursor); + const rows = cursor + ? this.database.sqlite + .prepare( + `select id, project_workflow_key, entity_type, entity_id, event_type, summary, revision, created_at + from workflow_events + where project_workflow_key = ? + and (created_at < ? or (created_at = ? and id < ?)) + order by created_at desc, id desc + limit ?`, + ) + .all(workflow.key, cursor.createdAt, cursor.createdAt, cursor.id, limit + 1) + : this.database.sqlite + .prepare( + `select id, project_workflow_key, entity_type, entity_id, event_type, summary, revision, created_at + from workflow_events + where project_workflow_key = ? + order by created_at desc, id desc + limit ?`, + ) + .all(workflow.key, limit + 1); + + const pageRows = (rows as WorkflowEventRow[]).slice(0, limit); + const events = pageRows.map(rowToWorkflowEvent); + const lastReturned = pageRows.at(-1); + const hasMore = (rows as WorkflowEventRow[]).length > pageRows.length; + + return { + events, + nextCursor: hasMore && lastReturned + ? encodeHistoryCursor({ createdAt: lastReturned.created_at, id: lastReturned.id }) + : undefined, + }; + } + + savePlan(input: { + workspaceSessionId: string; + expectedRevision: number; + title?: string; + summary?: string; + scopeIn?: string[]; + scopeOut?: string[]; + validation?: string[]; + risks?: string[]; + status?: PlanStatus; + goalId?: string; + steps: WorkspacePlanStep[]; + }): WorkspacePlan { + validatePlanSteps(input.steps); + const workflow = this.workflowForSession(input.workspaceSessionId); + const existing = this.getPlan(input.workspaceSessionId); + const now = new Date().toISOString(); + + if (!existing && input.expectedRevision !== 0) { + throw new WorkflowRevisionConflictError("plan", 0); + } + if (existing && input.expectedRevision !== existing.revision) { + throw new WorkflowRevisionConflictError("plan", existing.revision); + } + + return this.database.sqlite.transaction(() => { + const isCreate = !existing; + const status = input.status ?? existing?.status ?? "active"; + const plan: WorkspacePlan = { + id: existing?.id ?? randomUUID(), + projectWorkflowKey: workflow.key, + goalId: input.goalId ?? existing?.goalId, + title: normalizeRequiredText(input.title ?? existing?.title ?? "Project plan", "Plan title"), + summary: normalizeOptionalText(input.summary ?? existing?.summary, MAX_WORKFLOW_TEXT_BYTES), + scopeIn: normalizeStringList(input.scopeIn ?? existing?.scopeIn ?? []), + scopeOut: normalizeStringList(input.scopeOut ?? existing?.scopeOut ?? []), + validation: normalizeStringList(input.validation ?? existing?.validation ?? []), + risks: normalizeStringList(input.risks ?? existing?.risks ?? []), + status, + revision: (existing?.revision ?? 0) + 1, + steps: normalizePlanSteps(input.steps, now), + createdAt: existing?.createdAt ?? now, + updatedAt: now, + archivedAt: status === "archived" ? now : undefined, + }; + + if (isCreate) { + const inserted = this.database.sqlite + .prepare( + `insert into workflow_plans ( + id, project_workflow_key, goal_id, title, summary, + scope_in_json, scope_out_json, validation_json, risks_json, + status, revision, is_current, created_at, updated_at, archived_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + on conflict do nothing`, + ) + .run( + plan.id, + plan.projectWorkflowKey, + plan.goalId ?? null, + plan.title, + plan.summary ?? null, + JSON.stringify(plan.scopeIn), + JSON.stringify(plan.scopeOut), + JSON.stringify(plan.validation), + JSON.stringify(plan.risks), + plan.status, + plan.revision, + plan.status === "archived" ? 0 : 1, + plan.createdAt, + plan.updatedAt, + plan.archivedAt ?? null, + ); + if (inserted.changes !== 1) { + throw new WorkflowRevisionConflictError("plan", this.currentPlanRevision(workflow.key)); + } + } else { + const updated = this.database.sqlite + .prepare( + `update workflow_plans + set goal_id = ?, title = ?, summary = ?, scope_in_json = ?, scope_out_json = ?, + validation_json = ?, risks_json = ?, status = ?, revision = ?, + is_current = ?, updated_at = ?, archived_at = ? + where id = ? and revision = ? and is_current = 1`, + ) + .run( + plan.goalId ?? null, + plan.title, + plan.summary ?? null, + JSON.stringify(plan.scopeIn), + JSON.stringify(plan.scopeOut), + JSON.stringify(plan.validation), + JSON.stringify(plan.risks), + plan.status, + plan.revision, + plan.status === "archived" ? 0 : 1, + plan.updatedAt, + plan.archivedAt ?? null, + plan.id, + input.expectedRevision, + ); + if (updated.changes !== 1) { + throw new WorkflowRevisionConflictError("plan", this.currentPlanRevision(workflow.key)); + } + this.database.sqlite.prepare("delete from workflow_plan_steps where plan_id = ?").run(plan.id); + } + + this.insertPlanSteps(plan); + this.recordWorkflowEvent({ + projectWorkflowKey: workflow.key, + entityType: "plan", + entityId: plan.id, + eventType: isCreate ? "plan.created" : plan.status === "archived" ? "plan.archived" : "plan.updated", + summary: truncateText(`${isCreate ? "Created" : "Updated"} plan: ${plan.title}`, MAX_EVENT_SUMMARY_BYTES), + revision: plan.revision, + createdAt: now, + }); + return plan; + })(); + } + + getPlan(workspaceSessionId: string): WorkspacePlan | undefined { + const workflow = this.workflowForSession(workspaceSessionId); + return this.getCurrentPlanForWorkflow(workflow.key); + } + + saveGoal(input: { + workspaceSessionId: string; + objective: string; + scopeIn?: string[]; + scopeOut?: string[]; + successCriteria?: string[]; + verification?: string[]; + stopConditions?: string[]; + currentSummary?: string; + }): WorkspaceGoal { + const workflow = this.workflowForSession(input.workspaceSessionId); + const now = new Date().toISOString(); + const goal: WorkspaceGoal = { + id: randomUUID(), + projectWorkflowKey: workflow.key, + objective: normalizeRequiredText(input.objective, "Goal objective"), + scopeIn: normalizeStringList(input.scopeIn ?? []), + scopeOut: normalizeStringList(input.scopeOut ?? []), + successCriteria: normalizeStringList(input.successCriteria ?? []), + verification: normalizeStringList(input.verification ?? []), + stopConditions: normalizeStringList(input.stopConditions ?? []), + currentSummary: normalizeOptionalText(input.currentSummary, MAX_SUMMARY_BYTES), + status: "active", + revision: 1, + metrics: emptyGoalMetrics(now), + createdAt: now, + updatedAt: now, + }; + + return this.database.sqlite.transaction(() => { + const current = this.database.sqlite + .prepare("select id, status from workflow_goals where project_workflow_key = ? and is_current = 1 limit 1") + .get(workflow.key) as { id: string; status: string } | undefined; + if (current?.status === "active") { + throw new Error("An active goal already exists for this project workflow."); + } + if (current) { + this.database.sqlite + .prepare("update workflow_goals set is_current = 0 where id = ? and is_current = 1") + .run(current.id); + } + + this.database.sqlite + .prepare( + `insert into workflow_goals ( + id, project_workflow_key, objective, scope_in_json, scope_out_json, + success_criteria_json, verification_json, stop_conditions_json, current_summary, + status, revision, is_current, created_at, updated_at, archived_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, null)`, + ) + .run( + goal.id, + goal.projectWorkflowKey, + goal.objective, + JSON.stringify(goal.scopeIn), + JSON.stringify(goal.scopeOut), + JSON.stringify(goal.successCriteria), + JSON.stringify(goal.verification), + JSON.stringify(goal.stopConditions), + goal.currentSummary ?? null, + goal.status, + goal.revision, + goal.createdAt, + goal.updatedAt, + ); + this.ensureGoalMetricsRecord(goal.id, now); + + this.recordWorkflowEvent({ + projectWorkflowKey: workflow.key, + entityType: "goal", + entityId: goal.id, + eventType: "goal.created", + summary: truncateText(`Created goal: ${goal.objective}`, MAX_EVENT_SUMMARY_BYTES), + revision: goal.revision, + createdAt: now, + }); + return this.hydrateGoalMetrics(goal, workflow.key, now); + })(); + } + + getGoal(workspaceSessionId: string): WorkspaceGoal | undefined { + const workflow = this.workflowForSession(workspaceSessionId); + const row = this.database.sqlite + .prepare( + `select id, project_workflow_key, objective, scope_in_json, scope_out_json, + success_criteria_json, verification_json, stop_conditions_json, current_summary, + status, revision, created_at, updated_at, archived_at + from workflow_goals + where project_workflow_key = ? and is_current = 1 + order by updated_at desc, id desc + limit 1`, + ) + .get(workflow.key) as WorkflowGoalRow | undefined; + + return row ? this.hydrateGoalMetrics(rowToWorkspaceGoal(row), workflow.key) : undefined; + } + + getGoalMetrics(workspaceSessionId: string): GoalMetrics | undefined { + return this.getGoal(workspaceSessionId)?.metrics; + } + + startGoalWork(input: { workspaceSessionId: string }): { + metrics: GoalMetrics; + started: boolean; + } { + const workflow = this.workflowForSession(input.workspaceSessionId); + const goal = this.getGoal(input.workspaceSessionId); + if (!goal) throw new Error("No current Goal exists for this project workflow."); + if (goal.status !== "active") throw new Error("Only an active Goal can start work tracking."); + const now = new Date().toISOString(); + + return this.database.sqlite.transaction(() => { + this.ensureGoalMetricsRecord(goal.id, now); + const started = this.database.sqlite + .prepare( + `update workflow_goal_metrics + set active_work_started_at = ?, updated_at = ? + where goal_id = ? and active_work_started_at is null`, + ) + .run(now, now, goal.id).changes === 1; + if (started) { + this.recordWorkflowEvent({ + projectWorkflowKey: workflow.key, + entityType: "goal", + entityId: goal.id, + eventType: "goal.work_started", + summary: "Started exact goal work timer.", + revision: goal.revision, + createdAt: now, + }); + } + return { + metrics: this.hydrateGoalMetrics(goal, workflow.key, now).metrics, + started, + }; + })(); + } + + pauseGoalWork(input: { workspaceSessionId: string }): { + metrics: GoalMetrics; + paused: boolean; + } { + const workflow = this.workflowForSession(input.workspaceSessionId); + const goal = this.getGoal(input.workspaceSessionId); + if (!goal) throw new Error("No current Goal exists for this project workflow."); + const now = new Date().toISOString(); + + return this.database.sqlite.transaction(() => { + const paused = this.pauseGoalWorkForGoal(goal.id, now); + if (paused) { + this.recordWorkflowEvent({ + projectWorkflowKey: workflow.key, + entityType: "goal", + entityId: goal.id, + eventType: "goal.work_paused", + summary: "Paused exact goal work timer.", + revision: goal.revision, + createdAt: now, + }); + } + return { + metrics: this.hydrateGoalMetrics(goal, workflow.key, now).metrics, + paused, + }; + })(); + } + + recordGoalTokenUsage(input: { + workspaceSessionId: string; + provider: string; + providerRequestId: string; + model?: string; + inputTokens: number; + outputTokens: number; + reasoningTokens?: number; + totalTokens: number; + providerReportedAt?: string; + }): { + metrics: GoalMetrics; + recorded: boolean; + } { + const workflow = this.workflowForSession(input.workspaceSessionId); + const goal = this.getGoal(input.workspaceSessionId); + if (!goal) throw new Error("No current Goal exists for this project workflow."); + validateTokenUsage(input); + const now = new Date().toISOString(); + + return this.database.sqlite.transaction(() => { + this.ensureGoalMetricsRecord(goal.id, now); + const result = this.database.sqlite + .prepare( + `insert into workflow_goal_token_usage ( + id, goal_id, provider, provider_request_id, model, + input_tokens, output_tokens, reasoning_tokens, total_tokens, + provider_reported_at, recorded_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + on conflict(goal_id, provider, provider_request_id) do nothing`, + ) + .run( + randomUUID(), + goal.id, + normalizeRequiredText(input.provider, "Token usage provider"), + normalizeRequiredText(input.providerRequestId, "Provider request ID"), + normalizeOptionalText(input.model, 512) ?? null, + input.inputTokens, + input.outputTokens, + input.reasoningTokens ?? 0, + input.totalTokens, + input.providerReportedAt ?? null, + now, + ); + const recorded = result.changes === 1; + if (recorded) { + this.database.sqlite + .prepare("update workflow_goal_metrics set updated_at = ? where goal_id = ?") + .run(now, goal.id); + this.recordWorkflowEvent({ + projectWorkflowKey: workflow.key, + entityType: "goal", + entityId: goal.id, + eventType: "goal.token_usage_recorded", + summary: `Recorded provider-reported token usage from ${input.provider}.`, + revision: goal.revision, + createdAt: now, + }); + } + return { + metrics: this.hydrateGoalMetrics(goal, workflow.key, now).metrics, + recorded, + }; + })(); + } + + updateGoal(input: { + workspaceSessionId: string; + expectedRevision: number; + objective?: string; + scopeIn?: string[]; + scopeOut?: string[]; + successCriteria?: string[]; + verification?: string[]; + stopConditions?: string[]; + currentSummary?: string; + status?: GoalStatus; + }): WorkspaceGoal { + const workflow = this.workflowForSession(input.workspaceSessionId); + const existing = this.getGoal(input.workspaceSessionId); + if (!existing) { + throw new Error("No current goal exists for this project workflow."); + } + if (existing.status !== "active") { + throw new Error("Only an active Goal can be updated. Create a new Goal after completing, blocking, or archiving the previous Goal."); + } + if (input.expectedRevision !== existing.revision) { + throw new WorkflowRevisionConflictError("goal", existing.revision); + } + + const now = new Date().toISOString(); + const status = input.status ?? existing.status; + const goal: WorkspaceGoal = { + ...existing, + objective: input.objective === undefined ? existing.objective : normalizeRequiredText(input.objective, "Goal objective"), + scopeIn: input.scopeIn === undefined ? existing.scopeIn : normalizeStringList(input.scopeIn), + scopeOut: input.scopeOut === undefined ? existing.scopeOut : normalizeStringList(input.scopeOut), + successCriteria: input.successCriteria === undefined + ? existing.successCriteria + : normalizeStringList(input.successCriteria), + verification: input.verification === undefined ? existing.verification : normalizeStringList(input.verification), + stopConditions: input.stopConditions === undefined + ? existing.stopConditions + : normalizeStringList(input.stopConditions), + currentSummary: input.currentSummary === undefined + ? existing.currentSummary + : normalizeOptionalText(input.currentSummary, MAX_SUMMARY_BYTES), + status, + revision: existing.revision + 1, + updatedAt: now, + archivedAt: status === "archived" ? now : undefined, + }; + + return this.database.sqlite.transaction(() => { + const updated = this.database.sqlite + .prepare( + `update workflow_goals + set objective = ?, scope_in_json = ?, scope_out_json = ?, success_criteria_json = ?, + verification_json = ?, stop_conditions_json = ?, current_summary = ?, status = ?, + revision = ?, is_current = ?, updated_at = ?, archived_at = ? + where id = ? and revision = ? and is_current = 1 and status = 'active'`, + ) + .run( + goal.objective, + JSON.stringify(goal.scopeIn), + JSON.stringify(goal.scopeOut), + JSON.stringify(goal.successCriteria), + JSON.stringify(goal.verification), + JSON.stringify(goal.stopConditions), + goal.currentSummary ?? null, + goal.status, + goal.revision, + goal.status === "archived" ? 0 : 1, + goal.updatedAt, + goal.archivedAt ?? null, + goal.id, + input.expectedRevision, + ); + if (updated.changes !== 1) { + throw new WorkflowRevisionConflictError("goal", this.currentGoalRevision(workflow.key)); + } + if (goal.status !== "active") { + this.pauseGoalWorkForGoal(goal.id, now); + } + + const eventType = goal.status === "archived" + ? "goal.archived" + : goal.status === "completed" + ? "goal.completed" + : goal.status === "blocked" + ? "goal.blocked" + : "goal.updated"; + this.recordWorkflowEvent({ + projectWorkflowKey: workflow.key, + entityType: "goal", + entityId: goal.id, + eventType, + summary: truncateText(`Updated goal: ${goal.objective}`, MAX_EVENT_SUMMARY_BYTES), + revision: goal.revision, + createdAt: now, + }); + return this.hydrateGoalMetrics(goal, workflow.key, now); + })(); + } + + updateGoalStatus(input: { + workspaceSessionId: string; + status: "completed" | "complete" | "blocked" | "archived"; + expectedRevision?: number; + }): WorkspaceGoal { + const existing = this.getGoal(input.workspaceSessionId); + if (!existing) { + throw new Error("No current goal exists for this project workflow."); + } + return this.updateGoal({ + workspaceSessionId: input.workspaceSessionId, + expectedRevision: input.expectedRevision ?? existing.revision, + status: input.status === "complete" ? "completed" : input.status, + }); + } + + setCollaborationMode(input: { + workspaceSessionId: string; + mode: CollaborationMode; + }): { + workspaceSessionId: string; + projectWorkflowKey: string; + mode: CollaborationMode; + updatedAt: string; + } { + const workflow = this.workflowForSession(input.workspaceSessionId); + const updatedAt = new Date().toISOString(); + + this.database.sqlite + .prepare( + `insert into workflow_modes (project_workflow_key, mode, updated_at) + values (?, ?, ?) + on conflict(project_workflow_key) do update set mode = excluded.mode, updated_at = excluded.updated_at`, + ) + .run(workflow.key, input.mode, updatedAt); + + this.recordWorkflowEvent({ + projectWorkflowKey: workflow.key, + entityType: "mode", + entityId: workflow.key, + eventType: "mode.changed", + summary: `Collaboration mode changed to ${input.mode}.`, + createdAt: updatedAt, + }); + + return { + workspaceSessionId: input.workspaceSessionId, + projectWorkflowKey: workflow.key, + mode: input.mode, + updatedAt, + }; + } + + getCollaborationMode(workspaceSessionId: string): { + workspaceSessionId: string; + projectWorkflowKey: string; + mode: CollaborationMode; + updatedAt: string; + } { + const workflow = this.workflowForSession(workspaceSessionId); + const row = this.database.sqlite + .prepare("select mode, updated_at from workflow_modes where project_workflow_key = ?") + .get(workflow.key) as { mode: string; updated_at: string } | undefined; + + return { + workspaceSessionId, + projectWorkflowKey: workflow.key, + mode: row?.mode === "plan" ? "plan" : "default", + updatedAt: row?.updated_at ?? "", + }; + } + + 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 currentPlanRevision(projectWorkflowKey: string): number { + const row = this.database.sqlite + .prepare("select revision from workflow_plans where project_workflow_key = ? and is_current = 1 limit 1") + .get(projectWorkflowKey) as { revision: number } | undefined; + return row?.revision ?? 0; + } + + private currentGoalRevision(projectWorkflowKey: string): number { + const row = this.database.sqlite + .prepare("select revision from workflow_goals where project_workflow_key = ? and is_current = 1 limit 1") + .get(projectWorkflowKey) as { revision: number } | undefined; + return row?.revision ?? 0; + } + + private workflowForSession(workspaceSessionId: string): { key: string; canonicalRoot: string; mode: WorkspaceMode } { + const session = this.getSession(workspaceSessionId); + if (!session) { + throw new Error(`Unknown workspace session: ${workspaceSessionId}`); + } + return this.ensureProjectWorkflow(session.root, session.mode); + } + + private ensureProjectWorkflow(root: string, mode: WorkspaceMode): { + key: string; + canonicalRoot: string; + mode: WorkspaceMode; + } { + const canonicalRoot = canonicalizeRoot(root); + const key = projectWorkflowKeyForRoot(canonicalRoot); + const now = new Date().toISOString(); + const existing = this.database.sqlite + .prepare("select project_workflow_key from project_workflows where project_workflow_key = ?") + .get(key); + + if (existing) { + this.database.sqlite + .prepare("update project_workflows set workspace_kind = ?, updated_at = ? where project_workflow_key = ?") + .run(mode, now, key); + return { key, canonicalRoot, mode }; + } + + const git = readGitIdentity(canonicalRoot); + this.database.sqlite + .prepare( + `insert into project_workflows ( + project_workflow_key, canonical_root, workspace_kind, git_common_dir, git_remote_origin, created_at, updated_at + ) values (?, ?, ?, ?, ?, ?, ?)`, + ) + .run(key, canonicalRoot, mode, git.commonDir ?? null, git.remoteOrigin ?? null, now, now); + + return { key, canonicalRoot, mode }; + } + + private getCurrentPlanForWorkflow(projectWorkflowKey: string): WorkspacePlan | undefined { + const row = this.database.sqlite + .prepare( + `select id, project_workflow_key, goal_id, title, summary, + scope_in_json, scope_out_json, validation_json, risks_json, + status, revision, created_at, updated_at, archived_at + from workflow_plans + where project_workflow_key = ? and is_current = 1 + order by updated_at desc, id desc + limit 1`, + ) + .get(projectWorkflowKey) as WorkflowPlanRow | undefined; + return row ? this.rowToWorkspacePlan(row) : undefined; + } + + private ensureGoalMetricsRecord(goalId: string, now: string): void { + this.database.sqlite + .prepare( + `insert into workflow_goal_metrics ( + goal_id, active_work_started_at, accumulated_work_ms, updated_at + ) values (?, null, 0, ?) + on conflict(goal_id) do nothing`, + ) + .run(goalId, now); + } + + private pauseGoalWorkForGoal(goalId: string, now: string): boolean { + const row = this.database.sqlite + .prepare( + `select active_work_started_at, accumulated_work_ms + from workflow_goal_metrics where goal_id = ?`, + ) + .get(goalId) as GoalMetricsRow | undefined; + if (!row?.active_work_started_at) return false; + + const elapsed = elapsedMilliseconds(row.active_work_started_at, now); + const result = this.database.sqlite + .prepare( + `update workflow_goal_metrics + set active_work_started_at = null, accumulated_work_ms = ?, updated_at = ? + where goal_id = ? and active_work_started_at = ?`, + ) + .run(Number(row.accumulated_work_ms) + elapsed, now, goalId, row.active_work_started_at); + return result.changes === 1; + } + + private hydrateGoalMetrics( + goal: WorkspaceGoal, + projectWorkflowKey: string, + measuredAt = new Date().toISOString(), + ): WorkspaceGoal { + const row = this.database.sqlite + .prepare( + `select active_work_started_at, accumulated_work_ms, updated_at + from workflow_goal_metrics where goal_id = ?`, + ) + .get(goal.id) as GoalMetricsRow | undefined; + const usage = this.database.sqlite + .prepare( + `select + coalesce(sum(input_tokens), 0) as input_tokens, + coalesce(sum(output_tokens), 0) as output_tokens, + coalesce(sum(reasoning_tokens), 0) as reasoning_tokens, + coalesce(sum(total_tokens), 0) as total_tokens, + count(*) as report_count, + max(recorded_at) as last_reported_at + from workflow_goal_token_usage where goal_id = ?`, + ) + .get(goal.id) as GoalTokenUsageRow; + const accumulatedMilliseconds = Number(row?.accumulated_work_ms ?? 0); + const liveMilliseconds = row?.active_work_started_at + ? elapsedMilliseconds(row.active_work_started_at, measuredAt) + : 0; + const linkedPlan = this.getCurrentPlanForWorkflow(projectWorkflowKey); + const progress = linkedPlan?.goalId === goal.id + ? goalProgressFromPlan(linkedPlan) + : unlinkedGoalProgress(); + + return { + ...goal, + metrics: { + tokenUsage: { + inputTokens: Number(usage.input_tokens ?? 0), + outputTokens: Number(usage.output_tokens ?? 0), + reasoningTokens: Number(usage.reasoning_tokens ?? 0), + totalTokens: Number(usage.total_tokens ?? 0), + reportCount: Number(usage.report_count ?? 0), + lastReportedAt: usage.last_reported_at ?? undefined, + }, + workDuration: { + running: Boolean(row?.active_work_started_at), + startedAt: row?.active_work_started_at ?? undefined, + accumulatedMilliseconds, + liveMilliseconds, + totalMilliseconds: accumulatedMilliseconds + liveMilliseconds, + measuredAt, + }, + progress, + updatedAt: maxIsoTimestamp(row?.updated_at, usage.last_reported_at), + }, + }; + } + + private insertPlanSteps(plan: WorkspacePlan): void { + const statement = this.database.sqlite.prepare( + `insert into workflow_plan_steps (id, plan_id, position, content, status, note, updated_at) + values (?, ?, ?, ?, ?, ?, ?)`, + ); + for (const [position, step] of plan.steps.entries()) { + statement.run( + step.id ?? randomUUID(), + plan.id, + position, + step.step, + step.status, + step.note ?? null, + step.updatedAt ?? plan.updatedAt, + ); + } + } + + private rowToWorkspacePlan(row: WorkflowPlanRow): WorkspacePlan { + const stepRows = this.database.sqlite + .prepare( + `select id, position, content, status, note, updated_at + from workflow_plan_steps where plan_id = ? order by position asc`, + ) + .all(row.id) as WorkflowPlanStepRow[]; + + return { + id: row.id, + projectWorkflowKey: row.project_workflow_key, + goalId: row.goal_id ?? undefined, + title: row.title, + summary: row.summary ?? undefined, + scopeIn: parseStringList(row.scope_in_json), + scopeOut: parseStringList(row.scope_out_json), + validation: parseStringList(row.validation_json), + risks: parseStringList(row.risks_json), + status: normalizePlanStatus(row.status), + revision: Number(row.revision), + steps: stepRows.map((step) => ({ + id: step.id, + step: step.content, + status: normalizePlanStepStatus(step.status), + note: step.note ?? undefined, + updatedAt: step.updated_at, + })), + createdAt: row.created_at, + updatedAt: row.updated_at, + archivedAt: row.archived_at ?? undefined, + }; + } + + private recordWorkflowEvent(input: Omit): void { + this.database.sqlite + .prepare( + `insert into workflow_events ( + id, project_workflow_key, entity_type, entity_id, event_type, summary, revision, created_at + ) values (?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + randomUUID(), + input.projectWorkflowKey, + input.entityType, + input.entityId, + input.eventType, + truncateText(input.summary, MAX_EVENT_SUMMARY_BYTES), + input.revision ?? null, + input.createdAt, + ); + + this.database.sqlite + .prepare( + `delete from workflow_events + where id in ( + select id from workflow_events + where project_workflow_key = ? + order by created_at desc, id desc + limit -1 offset ? + )`, + ) + .run(input.projectWorkflowKey, MAX_WORKFLOW_EVENTS); + } + + 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 ( + id text primary key, + root text not null, + status text not null default 'active', + mode text not null default 'checkout', + source_root text, + base_ref text, + base_sha text, + managed text not null default 'false', + created_at text not null, + last_used_at text not null + ); + + create index if not exists workspace_sessions_root_idx + on workspace_sessions(root, last_used_at desc); + + create index if not exists workspace_sessions_status_idx + on workspace_sessions(status, last_used_at desc); + + create table if not exists loaded_agent_files ( + workspace_session_id text not null, + path text not null, + content_hash text not null, + content text not null, + loaded_at text not null, + last_seen_at text not null, + primary key (workspace_session_id, path), + foreign key (workspace_session_id) + references workspace_sessions(id) + on delete cascade + ); + + 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 + ); + + create table if not exists project_workflows ( + project_workflow_key text primary key, + canonical_root text not null, + workspace_kind text not null, + git_common_dir text, + git_remote_origin text, + created_at text not null, + updated_at text not null + ); + + create unique index if not exists project_workflows_root_idx + on project_workflows(canonical_root); + + create table if not exists workflow_plans ( + id text primary key, + project_workflow_key text not null, + goal_id text, + title text not null, + summary text, + scope_in_json text not null default '[]', + scope_out_json text not null default '[]', + validation_json text not null default '[]', + risks_json text not null default '[]', + status text not null, + revision integer not null, + is_current integer not null default 1, + created_at text not null, + updated_at text not null, + archived_at text, + foreign key (project_workflow_key) + references project_workflows(project_workflow_key) + on delete cascade + ); + + create unique index if not exists workflow_plans_current_idx + on workflow_plans(project_workflow_key) + where is_current = 1; + + create index if not exists workflow_plans_history_idx + on workflow_plans(project_workflow_key, updated_at desc); + + create table if not exists workflow_plan_steps ( + id text primary key, + plan_id text not null, + position integer not null, + content text not null, + status text not null, + note text, + updated_at text not null, + foreign key (plan_id) + references workflow_plans(id) + on delete cascade + ); + + create unique index if not exists workflow_plan_steps_position_idx + on workflow_plan_steps(plan_id, position); + + create table if not exists workflow_goals ( + id text primary key, + project_workflow_key text not null, + objective text not null, + scope_in_json text not null default '[]', + scope_out_json text not null default '[]', + success_criteria_json text not null default '[]', + verification_json text not null default '[]', + stop_conditions_json text not null default '[]', + current_summary text, + status text not null, + revision integer not null, + is_current integer not null default 1, + created_at text not null, + updated_at text not null, + archived_at text, + foreign key (project_workflow_key) + references project_workflows(project_workflow_key) + on delete cascade + ); + + create unique index if not exists workflow_goals_current_idx + on workflow_goals(project_workflow_key) + where is_current = 1; + + create index if not exists workflow_goals_history_idx + on workflow_goals(project_workflow_key, updated_at desc); + + create table if not exists workflow_goal_metrics ( + goal_id text primary key, + active_work_started_at text, + accumulated_work_ms integer not null default 0, + updated_at text not null, + foreign key (goal_id) + references workflow_goals(id) + on delete cascade + ); + + create table if not exists workflow_goal_token_usage ( + id text primary key, + goal_id text not null, + provider text not null, + provider_request_id text not null, + model text, + input_tokens integer not null, + output_tokens integer not null, + reasoning_tokens integer not null default 0, + total_tokens integer not null, + provider_reported_at text, + recorded_at text not null, + foreign key (goal_id) + references workflow_goals(id) + on delete cascade + ); + + create unique index if not exists workflow_goal_token_usage_dedupe_idx + on workflow_goal_token_usage(goal_id, provider, provider_request_id); + + create index if not exists workflow_goal_token_usage_history_idx + on workflow_goal_token_usage(goal_id, recorded_at desc); + + create table if not exists workflow_modes ( + project_workflow_key text primary key, + mode text not null default 'default', + updated_at text not null, + foreign key (project_workflow_key) + references project_workflows(project_workflow_key) + on delete cascade + ); + + create table if not exists workflow_events ( + id text primary key, + project_workflow_key text not null, + entity_type text not null, + entity_id text not null, + event_type text not null, + summary text not null, + revision integer, + created_at text not null, + foreign key (project_workflow_key) + references project_workflows(project_workflow_key) + on delete cascade + ); + + create index if not exists workflow_events_history_idx + on workflow_events(project_workflow_key, created_at desc, id desc); + + create table if not exists workflow_migrations ( + migration_key text primary key, + completed_at text not null + ); + `); + + this.addColumnIfMissing("workspace_sessions", "mode", "text not null default 'checkout'"); + this.addColumnIfMissing("workspace_sessions", "source_root", "text"); + 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"); + + this.database.sqlite + .prepare( + `insert or ignore into workflow_goal_metrics (goal_id, active_work_started_at, accumulated_work_ms, updated_at) + select id, null, 0, updated_at from workflow_goals`, + ) + .run(); + + this.migrateLegacyWorkflowState(); + } + + private migrateLegacyWorkflowState(): void { + const migrationKey = "project-workflow-store-v2"; + const alreadyMigrated = this.database.sqlite + .prepare("select migration_key from workflow_migrations where migration_key = ?") + .get(migrationKey); + if (alreadyMigrated) return; + + this.database.sqlite.transaction(() => { + const sessions = this.database.sqlite + .prepare("select id, root, mode from workspace_sessions") + .all() as Array<{ id: string; root: string; mode: string }>; + const workflows = new Map(); + + for (const session of sessions) { + const mode: WorkspaceMode = session.mode === "worktree" ? "worktree" : "checkout"; + const workflow = this.ensureProjectWorkflow(session.root, mode); + workflows.set(session.id, { key: workflow.key, root: workflow.canonicalRoot, mode }); + } + + const existingCurrentPlans = new Set( + (this.database.sqlite + .prepare("select project_workflow_key from workflow_plans where is_current = 1") + .all() as Array<{ project_workflow_key: string }>) + .map((row) => row.project_workflow_key), + ); + const importedPlans = new Set(); + const legacyPlans = this.database.sqlite + .prepare( + `select workspace_session_id, explanation, steps_json, updated_at + from workspace_plans + order by updated_at desc`, + ) + .all() as LegacyPlanRow[]; + + for (const legacy of legacyPlans) { + const workflow = workflows.get(legacy.workspace_session_id); + if (!workflow || existingCurrentPlans.has(workflow.key)) continue; + const now = legacy.updated_at; + const isCurrent = !importedPlans.has(workflow.key); + const status: PlanStatus = isCurrent ? "active" : "archived"; + const archivedAt = isCurrent ? null : now; + const planId = randomUUID(); + const steps = normalizePlanSteps(parseLegacyPlanSteps(legacy.steps_json), now); + this.database.sqlite + .prepare( + `insert into workflow_plans ( + id, project_workflow_key, goal_id, title, summary, + scope_in_json, scope_out_json, validation_json, risks_json, + status, revision, is_current, created_at, updated_at, archived_at + ) values (?, ?, null, ?, ?, '[]', '[]', '[]', '[]', ?, 1, ?, ?, ?, ?)`, + ) + .run( + planId, + workflow.key, + "Migrated workspace plan", + legacy.explanation ?? null, + status, + isCurrent ? 1 : 0, + now, + now, + archivedAt, + ); + this.insertPlanSteps({ + id: planId, + projectWorkflowKey: workflow.key, + title: "Migrated workspace plan", + summary: legacy.explanation ?? undefined, + scopeIn: [], + scopeOut: [], + validation: [], + risks: [], + status, + revision: 1, + steps, + createdAt: now, + updatedAt: now, + archivedAt: archivedAt ?? undefined, + }); + this.recordWorkflowEvent({ + projectWorkflowKey: workflow.key, + entityType: "plan", + entityId: planId, + eventType: isCurrent ? "plan.migrated" : "plan.archived_migrated", + summary: isCurrent ? "Migrated legacy workspace plan." : "Archived older legacy workspace plan during migration.", + revision: 1, + createdAt: now, + }); + if (isCurrent) importedPlans.add(workflow.key); + } + const existingCurrentGoals = new Set( + (this.database.sqlite + .prepare("select project_workflow_key from workflow_goals where is_current = 1") + .all() as Array<{ project_workflow_key: string }>) + .map((row) => row.project_workflow_key), + ); + const importedGoals = new Set(); + const legacyGoals = this.database.sqlite + .prepare( + `select workspace_session_id, objective, status, created_at, updated_at + from workspace_goals + order by updated_at desc`, + ) + .all() as LegacyGoalRow[]; + + for (const legacy of legacyGoals) { + const workflow = workflows.get(legacy.workspace_session_id); + if (!workflow || existingCurrentGoals.has(workflow.key)) continue; + const parsed = parseGoalDefinition(legacy.objective).definition; + const goalId = randomUUID(); + const isCurrent = !importedGoals.has(workflow.key); + const status: GoalStatus = isCurrent ? normalizeGoalStatus(legacy.status) : "archived"; + const archivedAt = isCurrent ? null : legacy.updated_at; + this.database.sqlite + .prepare( + `insert into workflow_goals ( + id, project_workflow_key, objective, scope_in_json, scope_out_json, + success_criteria_json, verification_json, stop_conditions_json, current_summary, + status, revision, is_current, created_at, updated_at, archived_at + ) values (?, ?, ?, ?, ?, '[]', ?, ?, null, ?, 1, ?, ?, ?, ?)`, + ) + .run( + goalId, + workflow.key, + normalizeRequiredText(parsed.objective, "Goal objective"), + JSON.stringify(parsed.scope?.in ?? []), + JSON.stringify(parsed.scope?.out ?? []), + JSON.stringify(parsed.verification ?? []), + JSON.stringify(parsed.stopConditions ?? []), + status, + isCurrent ? 1 : 0, + legacy.created_at, + legacy.updated_at, + archivedAt, + ); + this.ensureGoalMetricsRecord(goalId, legacy.updated_at); + this.recordWorkflowEvent({ + projectWorkflowKey: workflow.key, + entityType: "goal", + entityId: goalId, + eventType: isCurrent ? "goal.migrated" : "goal.archived_migrated", + summary: isCurrent ? "Migrated legacy workspace goal." : "Archived older legacy workspace goal during migration.", + revision: 1, + createdAt: legacy.updated_at, + }); + if (isCurrent) importedGoals.add(workflow.key); + } + + const existingModes = new Set( + (this.database.sqlite + .prepare("select project_workflow_key from workflow_modes") + .all() as Array<{ project_workflow_key: string }>) + .map((row) => row.project_workflow_key), + ); + const importedModes = new Set(); + const legacyModes = this.database.sqlite + .prepare( + `select workspace_session_id, mode, updated_at + from workspace_modes + order by updated_at desc`, + ) + .all() as LegacyModeRow[]; + + for (const legacy of legacyModes) { + const workflow = workflows.get(legacy.workspace_session_id); + if (!workflow || existingModes.has(workflow.key) || importedModes.has(workflow.key)) continue; + this.database.sqlite + .prepare( + "insert into workflow_modes (project_workflow_key, mode, updated_at) values (?, ?, ?)", + ) + .run(workflow.key, legacy.mode === "plan" ? "plan" : "default", legacy.updated_at); + importedModes.add(workflow.key); + } + + this.database.sqlite + .prepare("insert into workflow_migrations (migration_key, completed_at) values (?, ?)") + .run(migrationKey, new Date().toISOString()); + })(); + } + + private addColumnIfMissing(table: string, column: string, definition: string): void { + const columns = this.database.sqlite.prepare(`pragma table_info(${table})`).all() as Array<{ + name: string; + }>; + if (columns.some((existingColumn) => existingColumn.name === column)) return; + + this.database.sqlite.exec(`alter table ${table} add column ${column} ${definition}`); + } } export function createWorkspaceStore(stateDir: string): WorkspaceStore { return new SqliteWorkspaceStore(stateDir); } +export function projectWorkflowKeyForRoot(root: string): string { + return `pw_${createHash("sha256").update(`v1:${canonicalizeRoot(root)}`).digest("hex")}`; +} + +function canonicalizeRoot(root: string): string { + try { + return realpathSync.native(root); + } catch { + return resolve(root); + } +} + +function readGitIdentity(root: string): { commonDir?: string; remoteOrigin?: string } { + const commonDir = runGitForMetadata(root, ["rev-parse", "--git-common-dir"]); + if (!commonDir) return {}; + const remoteOrigin = runGitForMetadata(root, ["remote", "get-url", "origin"]); + return { + commonDir: canonicalizeRoot(resolve(root, commonDir)), + remoteOrigin: remoteOrigin || undefined, + }; +} + +function runGitForMetadata(root: string, args: string[]): string | undefined { + try { + const value = execFileSync("git", ["-C", root, ...args], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 1_500, + }).trim(); + return value || undefined; + } catch { + return undefined; + } +} + function rowToWorkspaceSession(row: WorkspaceSessionRow): WorkspaceSession { return { id: row.id, @@ -126,3 +1880,397 @@ function rowToWorkspaceSession(row: WorkspaceSessionRow): WorkspaceSession { lastUsedAt: row.lastUsedAt, }; } + +function rowToWorkspaceGoal(row: WorkflowGoalRow): WorkspaceGoal { + return { + id: row.id, + projectWorkflowKey: row.project_workflow_key, + objective: row.objective, + scopeIn: parseStringList(row.scope_in_json), + scopeOut: parseStringList(row.scope_out_json), + successCriteria: parseStringList(row.success_criteria_json), + verification: parseStringList(row.verification_json), + stopConditions: parseStringList(row.stop_conditions_json), + currentSummary: row.current_summary ?? undefined, + status: normalizeGoalStatus(row.status), + revision: Number(row.revision), + metrics: emptyGoalMetrics(row.updated_at), + createdAt: row.created_at, + updatedAt: row.updated_at, + archivedAt: row.archived_at ?? undefined, + }; +} + +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 emptyGoalMetrics(measuredAt: string): GoalMetrics { + return { + tokenUsage: { + inputTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + totalTokens: 0, + reportCount: 0, + }, + workDuration: { + running: false, + accumulatedMilliseconds: 0, + liveMilliseconds: 0, + totalMilliseconds: 0, + measuredAt, + }, + progress: unlinkedGoalProgress(), + }; +} + +function unlinkedGoalProgress(): GoalProgress { + return { + source: "unlinked", + completedSteps: 0, + totalSteps: 0, + }; +} + +function goalProgressFromPlan(plan: WorkspacePlan): GoalProgress { + const completedSteps = plan.steps.filter((step) => step.status === "completed").length; + const totalSteps = plan.steps.length; + if (totalSteps === 0) return unlinkedGoalProgress(); + + return { + source: "linked_plan_steps", + completedSteps, + totalSteps, + exactFraction: `${completedSteps}/${totalSteps}`, + percentageNumerator: completedSteps * 100, + percentageDenominator: totalSteps, + displayPercent: formatExactPercent(completedSteps, totalSteps), + }; +} + +function formatExactPercent(completed: number, total: number): string { + const scale = 100n; + const numerator = BigInt(completed) * 100n * scale; + const denominator = BigInt(total); + const rounded = (numerator + denominator / 2n) / denominator; + const integer = rounded / scale; + const decimal = (rounded % scale).toString().padStart(2, "0"); + return `${integer}.${decimal}%`; +} + +function elapsedMilliseconds(startedAt: string, measuredAt: string): number { + const start = Date.parse(startedAt); + const end = Date.parse(measuredAt); + if (!Number.isFinite(start) || !Number.isFinite(end)) return 0; + return Math.max(0, end - start); +} + +function maxIsoTimestamp(...values: Array): string | undefined { + return values + .filter((value): value is string => Boolean(value)) + .sort() + .at(-1); +} + +function validateTokenUsage(input: { + inputTokens: number; + outputTokens: number; + reasoningTokens?: number; + totalTokens: number; + providerReportedAt?: string; +}): void { + for (const [label, value] of [ + ["inputTokens", input.inputTokens], + ["outputTokens", input.outputTokens], + ["reasoningTokens", input.reasoningTokens ?? 0], + ["totalTokens", input.totalTokens], + ] as const) { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`${label} must be a non-negative safe integer reported by the provider.`); + } + } + if (input.providerReportedAt && !Number.isFinite(Date.parse(input.providerReportedAt))) { + throw new Error("providerReportedAt must be a valid ISO timestamp."); + } +} + +function normalizePlanSteps(steps: WorkspacePlanStep[], updatedAt: string): WorkspacePlanStep[] { + return steps.map((step) => ({ + id: step.id ?? randomUUID(), + step: normalizeRequiredText(step.step, "Plan step"), + status: normalizePlanStepStatus(step.status), + note: normalizeOptionalText(step.note, MAX_WORKFLOW_TEXT_BYTES), + updatedAt, + })); +} + +function parseLegacyPlanSteps(value: string): WorkspacePlanStep[] { + try { + const parsed = JSON.parse(value) as unknown; + if (!Array.isArray(parsed)) return []; + return parsed.flatMap((item) => { + if (!item || typeof item !== "object") return []; + const candidate = item as { step?: unknown; content?: unknown; status?: unknown; note?: unknown }; + const step = typeof candidate.step === "string" + ? candidate.step + : typeof candidate.content === "string" + ? candidate.content + : undefined; + if (!step) return []; + return [{ + step, + status: normalizePlanStepStatus(typeof candidate.status === "string" ? candidate.status : "pending"), + note: typeof candidate.note === "string" ? candidate.note : undefined, + }]; + }); + } catch { + return []; + } +} + +function validatePlanSteps(steps: WorkspacePlanStep[]): void { + if (steps.length === 0) { + throw new Error("A plan must include at least one step."); + } + if (steps.length > 100) { + throw new Error("A plan may not contain more than 100 steps."); + } + 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 normalizePlanStatus(value: string): PlanStatus { + if (value === "draft" || value === "completed" || value === "archived") return value; + return "active"; +} + +function normalizePlanStepStatus(value: string): PlanStepStatus { + if ( + value === "pending" || + value === "in_progress" || + value === "blocked" || + value === "completed" || + value === "skipped" + ) { + return value; + } + return "pending"; +} + +function normalizeGoalStatus(value: string): GoalStatus { + if (value === "blocked" || value === "completed" || value === "archived") return value; + if (value === "complete") return "completed"; + return "active"; +} + +function normalizeStringList(values: string[]): string[] { + const normalized = values + .map((value) => normalizeOptionalText(value, MAX_WORKFLOW_TEXT_BYTES)) + .filter((value): value is string => Boolean(value)); + const serialized = JSON.stringify(normalized); + assertTextLimit(serialized, MAX_WORKFLOW_TEXT_BYTES, "Workflow list"); + return normalized; +} + +function parseStringList(value: string | null | undefined): string[] { + if (!value) return []; + try { + const parsed = JSON.parse(value) as unknown; + return Array.isArray(parsed) + ? parsed.filter((item): item is string => typeof item === "string") + : []; + } catch { + return []; + } +} + +function normalizeRequiredText(value: string, label: string): string { + const normalized = value.trim(); + if (!normalized) throw new Error(`${label} is required.`); + assertTextLimit(normalized, MAX_WORKFLOW_TEXT_BYTES, label); + return normalized; +} + +function normalizeOptionalText(value: string | undefined, maxBytes: number): string | undefined { + if (value === undefined) return undefined; + const normalized = value.trim(); + if (!normalized) return undefined; + assertTextLimit(normalized, maxBytes, "Workflow text"); + return normalized; +} + +function assertTextLimit(value: string, maxBytes: number, label: string): void { + if (Buffer.byteLength(value, "utf8") > maxBytes) { + throw new Error(`${label} exceeds the ${maxBytes}-byte limit.`); + } +} + +function truncateText(value: string, maxBytes: number): string { + if (Buffer.byteLength(value, "utf8") <= maxBytes) return value; + let end = Math.max(0, Math.floor(maxBytes / 2)); + while (Buffer.byteLength(value.slice(0, end), "utf8") > maxBytes - 3 && end > 0) end--; + return `${value.slice(0, end)}...`; +} + +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; +} + +function encodeHistoryCursor(cursor: { createdAt: string; id: string }): string { + return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url"); +} + +function decodeHistoryCursor(cursor: string | undefined): { createdAt: string; id: string } | undefined { + if (!cursor) return undefined; + try { + const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as { + createdAt?: unknown; + id?: unknown; + }; + if (typeof parsed.createdAt !== "string" || typeof parsed.id !== "string") { + throw new Error("invalid cursor"); + } + return { createdAt: parsed.createdAt, id: parsed.id }; + } catch { + throw new Error("Invalid workflow history cursor."); + } +} + +function rowToWorkflowEvent(row: WorkflowEventRow): WorkflowEvent { + return { + id: row.id, + projectWorkflowKey: row.project_workflow_key, + entityType: row.entity_type === "goal" || row.entity_type === "mode" ? row.entity_type : "plan", + entityId: row.entity_id, + eventType: row.event_type, + summary: row.summary, + revision: row.revision === null ? undefined : Number(row.revision), + createdAt: row.created_at, + }; +} + +interface GoalMetricsRow { + active_work_started_at: string | null; + accumulated_work_ms: number; + updated_at: string; +} + +interface GoalTokenUsageRow { + input_tokens: number | null; + output_tokens: number | null; + reasoning_tokens: number | null; + total_tokens: number | null; + report_count: number | null; + last_reported_at: string | null; +} + +interface WorkflowPlanRow { + id: string; + project_workflow_key: string; + goal_id: string | null; + title: string; + summary: string | null; + scope_in_json: string; + scope_out_json: string; + validation_json: string; + risks_json: string; + status: string; + revision: number; + created_at: string; + updated_at: string; + archived_at: string | null; +} + +interface WorkflowPlanStepRow { + id: string; + position: number; + content: string; + status: string; + note: string | null; + updated_at: string; +} + +interface WorkflowGoalRow { + id: string; + project_workflow_key: string; + objective: string; + scope_in_json: string; + scope_out_json: string; + success_criteria_json: string; + verification_json: string; + stop_conditions_json: string; + current_summary: string | null; + status: string; + revision: number; + created_at: string; + updated_at: string; + archived_at: string | null; +} + +interface WorkflowEventRow { + id: string; + project_workflow_key: string; + entity_type: string; + entity_id: string; + event_type: string; + summary: string; + revision: number | null; + created_at: string; +} + +interface LegacyPlanRow { + workspace_session_id: string; + explanation: string | null; + steps_json: string; + updated_at: string; +} + +interface LegacyGoalRow { + workspace_session_id: string; + objective: string; + status: string; + created_at: string; + updated_at: string; +} + +interface LegacyModeRow { + workspace_session_id: string; + mode: string; + updated_at: string; +} diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index 554a3da..eccdc58 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { mkdtemp, mkdir, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, stat, symlink, writeFile } from "node:fs/promises"; import { platform, tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; @@ -8,6 +8,7 @@ import { loadConfig } from "./config.js"; import { GitWorktreeError } from "./git-worktrees.js"; import { SqliteWorkspaceStore } from "./workspace-store.js"; import { WorkspaceRegistry } from "./workspaces.js"; +import { removeTempDir } from "./test-utils.js"; const execFileAsync = promisify(execFile); const root = await mkdtemp(join(tmpdir(), "devspace-workspace-test-")); @@ -41,6 +42,26 @@ try { [join(root, "nested", "AGENTS.md")], ); + const planSkill = workspace.skills.find((skill) => skill.name === "plan" && skill.source === "devspace_system"); + assert.ok(planSkill, "expected the bundled plan Skill to be available"); + + const planSkillFile = registry.resolveReadPath(workspace, planSkill.locator); + assert.equal(planSkillFile.absolutePath, planSkill.filePath); + assert.equal(planSkillFile.skillRead?.isSkillFile, true); + registry.markReadPathLoaded(workspace, planSkillFile); + + const planReference = registry.resolveReadPath( + workspace, + "skill://devspace-system/plan/references/state.md", + ); + assert.equal(planReference.absolutePath, join(planSkill.baseDir, "references", "state.md")); + assert.equal(planReference.skillRead?.isSkillFile, false); + + await assert.rejects( + async () => registry.resolveReadPath(workspace, "skill://devspace-system/unknown/SKILL.md"), + /Unknown or unauthorized Skill resource/, + ); + const missingWorkspaceRoot = join(root, "missing", "workspace"); const missingWorkspace = await registry.openWorkspace(missingWorkspaceRoot); assert.equal(missingWorkspace.workspace.root, missingWorkspaceRoot); @@ -82,6 +103,35 @@ try { const worktreeReadmePath = registry.resolvePath(worktreeWorkspace.workspace, "README.md"); assert.equal(worktreeReadmePath.startsWith(worktreeWorkspace.workspace.root), true); + await mkdir(join(root, "skills", "installed", "refresh-skill"), { recursive: true }); + await writeFile( + join(root, "skills", "installed", "refresh-skill", "SKILL.md"), + [ + "---", + "name: refresh-skill", + "description: Refresh test skill.", + "---", + "", + "# Refresh Skill", + ].join("\n"), + ); + assert.equal(workspace.skills.some((skill) => skill.name === "refresh-skill"), false); + const refreshedWorkspace = registry.refreshWorkspaceSkills(workspace.id); + assert.equal(refreshedWorkspace.skills.some((skill) => skill.name === "refresh-skill"), true); + + const defaultOnlyConfig = loadConfig({ + DEVSPACE_ALLOWED_ROOTS: `${root},${gitRoot}`, + DEVSPACE_WORKTREE_ROOT: join(root, ".devspace", "default-worktrees"), + DEVSPACE_AGENT_DIR: agentDir, + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + DEVSPACE_SESSION_WORKSPACE: root, + PORT: "1", + }); + const defaultOnlyRegistry = new WorkspaceRegistry(defaultOnlyConfig); + const defaultWorkspace = await defaultOnlyRegistry.openWorkspace({ mode: "checkout" }); + assert.equal(defaultWorkspace.workspace.root, root); + assert.equal(defaultWorkspace.workspace.mode, "checkout"); + const stateDir = join(root, ".state"); const firstStore = new SqliteWorkspaceStore(stateDir); const persistentRegistry = new WorkspaceRegistry(config, firstStore); @@ -90,6 +140,69 @@ try { path: gitRoot, mode: "worktree", }); + const savedPlan = firstStore.savePlan({ + workspaceSessionId: persistentWorkspace.workspace.id, + expectedRevision: 0, + title: "Workflow state migration", + summary: "Track work in small steps", + scopeIn: ["project workflow state"], + validation: ["npm test"], + 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 project-scoped workflow support", + successCriteria: ["A new session resumes the current Plan and Goal"], + verification: ["npm test"], + currentSummary: "Current: migrate workflow state.", + }); + assert.equal(savedGoal.status, "active"); + const sameProjectSession = await persistentRegistry.openWorkspace(root); + assert.notEqual(sameProjectSession.workspace.id, persistentWorkspace.workspace.id); + assert.equal( + firstStore.getPlan(sameProjectSession.workspace.id)?.projectWorkflowKey, + savedPlan.projectWorkflowKey, + ); + assert.equal(firstStore.getGoal(sameProjectSession.workspace.id)?.objective, savedGoal.objective); + assert.equal(firstStore.getCollaborationMode(sameProjectSession.workspace.id).mode, "plan"); + assert.equal(firstStore.getWorkflowDigest(sameProjectSession.workspace.id).hasActivePlan, true); + assert.equal(firstStore.getWorkflowDigest(persistentWorktree.workspace.id).hasActivePlan, false); + 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 +210,35 @@ 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?.title, "Workflow state migration"); + assert.equal(restoredPlan?.summary, "Track work in small steps"); + assert.equal(restoredPlan?.steps[1]?.status, "in_progress"); + assert.equal(restoredPlan?.revision, 1); + 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?.revision, 1); + assert.equal("tokenBudget" in (restoredGoal ?? {}), false); + assert.equal("timeUsedSeconds" in (restoredGoal ?? {}), false); + 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, "completed"); const restoredWorktree = restoredRegistry.getWorkspace(persistentWorktree.workspace.id); assert.equal(restoredWorktree.mode, "worktree"); @@ -122,7 +264,7 @@ try { assert.equal(aliasWorkspace.workspace.sourceRoot, join(aliasRoot, "git-project")); } } finally { - await rm(root, { recursive: true, force: true }); + await removeTempDir(root); } async function git(cwd: string, args: string[]): Promise { diff --git a/src/workspaces.ts b/src/workspaces.ts index 3b7b51e..2ae2d0f 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -56,7 +56,7 @@ export interface WorkspaceReadPath { } export interface OpenWorkspaceInput { - path: string; + path?: string; mode?: WorkspaceMode; baseRef?: string; } @@ -71,13 +71,17 @@ export class WorkspaceRegistry { async openWorkspace(input: string | OpenWorkspaceInput): Promise { const options = typeof input === "string" ? { path: input } : input; + const path = options.path ?? this.defaultWorkspaceRoot(); + if (!path) { + throw new Error("Workspace path is required unless a default workspace has been configured for this session."); + } const mode = options.mode ?? "checkout"; if (mode === "worktree") { - return this.openWorktreeWorkspace(options.path, options.baseRef); + return this.openWorktreeWorkspace(path, options.baseRef); } - return this.openCheckoutWorkspace(options.path); + return this.openCheckoutWorkspace(path); } getWorkspace(workspaceId: string): Workspace { @@ -118,6 +122,15 @@ export class WorkspaceRegistry { return restoredWorkspace; } + refreshWorkspaceSkills(workspaceId: string): Workspace { + const workspace = this.getWorkspace(workspaceId); + const refreshed = this.loadSkillsForWorkspace(workspace.root); + workspace.skills = refreshed.skills; + workspace.skillDiagnostics = refreshed.skillDiagnostics; + workspace.activatedSkillDirs.clear(); + return workspace; + } + resolvePath(workspace: Workspace, inputPath: string): string { const absolutePath = resolveAllowedPath(inputPath, workspace.root, [workspace.root]); if (!isPathInsideRoot(absolutePath, workspace.root)) { @@ -128,18 +141,15 @@ export class WorkspaceRegistry { } resolveReadPath(workspace: Workspace, inputPath: string): WorkspaceReadPath { - try { - return { - absolutePath: this.resolvePath(workspace, inputPath), - readRoots: [workspace.root], - }; - } catch (workspaceError) { + if (inputPath.startsWith("skill://")) { const skillRead = resolveSkillReadPath( workspace.skills, workspace.activatedSkillDirs, inputPath, ); - if (!skillRead) throw workspaceError; + if (!skillRead) { + throw new Error(`Unknown or unauthorized Skill resource: ${inputPath}`); + } return { absolutePath: skillRead.absolutePath, @@ -147,6 +157,11 @@ export class WorkspaceRegistry { skillRead, }; } + + return { + absolutePath: this.resolvePath(workspace, inputPath), + readRoots: [workspace.root], + }; } markReadPathLoaded(workspace: Workspace, readPath: WorkspaceReadPath): void { @@ -160,6 +175,12 @@ export class WorkspaceRegistry { return assertAllowedPath(directory, [workspace.root]); } + defaultWorkspaceRoot(): string | undefined { + const preferred = this.config.sessionWorkspace ?? this.config.defaultWorkspace; + if (!preferred) return undefined; + return assertAllowedPath(preferred, this.config.allowedRoots); + } + private async openCheckoutWorkspace(path: string): Promise { const root = assertAllowedPath(path, this.config.allowedRoots); await mkdir(root, { recursive: true });