From 61276dd6b5a6c41ecf07abb6faf0e6ba0b3872d6 Mon Sep 17 00:00:00 2001 From: WODE25500 Date: Thu, 20 Aug 2026 06:13:28 +0800 Subject: [PATCH 01/12] Add DeepSeek Harness (dsh) integration New plugins/dsh/ integration wrapping the shared skillopt_sleep engine for DeepSeek Harness: a Cordis plugin registering 7 native skillopt_* tools (status/dry-run/run/adopt/harvest/schedule/unschedule), a bundled SKILL.md, a bundle patch layer (cordis.patch.yml), and a bootstrap script. Register the plugin in the plugins/README.md integration table. --- plugins/README.md | 4 +- plugins/dsh/README.md | 104 ++++++++++ plugins/dsh/cordis.patch.yml | 18 ++ plugins/dsh/docs/README.zh.md | 36 ++++ plugins/dsh/package.json | 36 ++++ plugins/dsh/scripts/sleep.py | 46 +++++ plugins/dsh/skills/skillopt-sleep/SKILL.md | 111 ++++++++++ plugins/dsh/src/index.js | 224 +++++++++++++++++++++ 8 files changed, 578 insertions(+), 1 deletion(-) create mode 100644 plugins/dsh/README.md create mode 100644 plugins/dsh/cordis.patch.yml create mode 100644 plugins/dsh/docs/README.zh.md create mode 100644 plugins/dsh/package.json create mode 100644 plugins/dsh/scripts/sleep.py create mode 100644 plugins/dsh/skills/skillopt-sleep/SKILL.md create mode 100644 plugins/dsh/src/index.js diff --git a/plugins/README.md b/plugins/README.md index 0a999c8c..fa0ae490 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -10,7 +10,7 @@ runtime dependency on the paper's `skillopt/` experiment package. ## Available integrations -Five integrations wrap the shared `skillopt_sleep` CLI. OpenClaw is a separate +Six integrations wrap the shared `skillopt_sleep` CLI. OpenClaw is a separate reference adaptation with its own backend and setup assumptions. | Platform | Folder | Mechanism | Status | @@ -20,6 +20,7 @@ reference adaptation with its own backend and setup assumptions. | **Cursor** | [`cursor/`](cursor) | native command and skill, project skill target, and shared runner | installable shared-engine integration | | **GitHub Copilot** | [`copilot/`](copilot) | MCP server exposing seven `sleep_*` tools | shared-engine MCP integration | | **Devin** | [`devin/`](devin) | MCP server plus Devin transcript conversion | shared-engine MCP integration | +| **DeepSeek Harness** | [`dsh/`](dsh) | Cordis plugin: 7 native `skillopt_*` tools, skill, bundle patch layer | installable shared-engine integration | | **OpenClaw** | [`openclaw/`](openclaw) | custom DeepSeek/Ollama wrapper | independent reference adaptation; review and adapt before use | ## Install @@ -34,6 +35,7 @@ for your workflow. | **Cursor** | `bash plugins/cursor/install.sh` (macOS/Linux) or `powershell -File plugins/cursor/install.ps1` (Windows) | `/skillopt-sleep status` | | **Copilot** | register `plugins/copilot/mcp_server.py` using its example MCP config | ask Copilot to run `sleep_status` | | **Devin** | register `plugins/devin/mcp_server.py` using its example MCP config | ask Devin to run `sleep_status` | +| **DeepSeek Harness** | add `dsh-skillopt` to the profile's bundles, or `pnpm dsh web --patch ./plugins/dsh/cordis.patch.yml` | ask the agent to use `skillopt_status` | | **OpenClaw** | follow and adapt [`openclaw/README.md`](openclaw/README.md) | validate paths, credentials, and tasks locally | Python 3.10 or newer is required. Real CLI backends also require the selected diff --git a/plugins/dsh/README.md b/plugins/dsh/README.md new file mode 100644 index 00000000..c8fee942 --- /dev/null +++ b/plugins/dsh/README.md @@ -0,0 +1,104 @@ +# SkillOpt-Sleep — DeepSeek Harness (dsh) integration + +Give your **DeepSeek Harness** agent a nightly **sleep cycle**: it reviews past +sessions offline, replays your recurring tasks on your own API budget, and +consolidates what it learns into validated skills behind a held-out gate. Same +engine as the Claude Code / Codex / Cursor integrations (`skillopt_sleep`), +wired into dsh's plugin system as native tools plus a bundled skill. + +DeepSeek Harness is the "everything is a plugin" agent framework +([deepseek-ai/deepseek-harness](https://github.com/deepseek-ai/deepseek-harness)). +Plugins are TypeScript modules exporting an `apply(ctx)` function that register +capabilities (tools, services, events, settings) on the Cordis context. + +## What this integration adds + +| Component | Purpose | +|---|---| +| `src/index.js` | dsh plugin entry: registers 7 `skillopt_*` tools + Schemastery config | +| `cordis.patch.yml` | bundle patch layer — drop `dsh-skillopt` into any profile's bundles | +| `skills/skillopt-sleep/SKILL.md` | agent skill: when to use the tools, operating rules, data-boundary rules | +| `scripts/sleep.py` | bootstrap/self-check runner (same command shape the tools use) | +| `package.json` | npm package metadata (bundle manifest) | + +## Tools + +| Tool | skillopt_sleep action | Behavior | +|---|---|---| +| `skillopt_status` | `status` | state, engine availability, latest staged proposal & report | +| `skillopt_dry_run` | `dry-run` | full preview (harvest+mine+replay), stages nothing | +| `skillopt_run` | `run` | full cycle, stages a proposal (live files unchanged) | +| `skillopt_adopt` | `adopt` | apply latest staged proposal (with backup) — the live-change boundary | +| `skillopt_harvest` | `harvest` | read-only show/export of mined tasks | +| `skillopt_schedule` / `skillopt_unschedule` | `schedule` / `unschedule` | install/remove the nightly cron entry | + +## Prerequisites + +- DeepSeek Harness (dsh) installed +- Python 3.10+ with the SkillOpt-Sleep engine: + +```bash +pip install skillopt # or use this source checkout +``` + +## Install + +### As a bundle in a profile + +Add `dsh-skillopt` to the profile's bundles, or in the profile `cordis.patch.yml`: + +```yaml +- insert: + - id: skillopt + name: './src/index.js' + config: + backend: mock # or codex / claude / cursor / pi / opencode / handoff … + project: /path/to/project + preferences: 'Always use async/await' +``` + +### Local patch overlay (dev) + +```bash +pnpm dsh web --patch ./plugins/dsh/cordis.patch.yml +``` + +Then ask the agent: "Use skillopt_status to check the sleep cycle state." + +## Config keys + +| Key | Default | Purpose | +|---|---|---| +| `pythonCmd` | `python` | Python interpreter for the engine | +| `module` | `skillopt_sleep` | engine Python module | +| `project` | — | default project directory | +| `backend` | — | `mock\|claude\|codex\|copilot\|cursor\|pi\|opencode\|handoff\|azure_openai` | +| `source` | — | `claude\|codex\|copilot\|cursor\|pi\|opencode\|auto` | +| `model` | — | backend model override | +| `maxTasks` / `maxSessions` | — | mine/harvest caps | +| `editBudget` | — | bounded edits per cycle | +| `preferences` | — | house rules for the reflection prior | +| `jsonOutput` | `false` | machine-readable JSON output | + +Advanced engine keys (`gate_mode`, `gate_metric`, `gate_no_regression`, +`dream_rollouts`, `recall_k`, `evolve_memory`/`evolve_skill`) go in +`~/.skillopt-sleep/config.json` — the same file shared by all integrations. + +## Data boundary + +- Harvest is read-only; `mock`/`handoff` make no network calls. +- `run` stages proposals; `adopt` is the normal live-change boundary and backs up first. +- Real backends send truncated transcript excerpts and derived tasks to the + selected provider. For sensitive sessions, export tasks first (`skillopt_harvest` + with `output=`), redact, set `"reviewed": true`, then replay — real backends + refuse unreviewed task files. +- Outbound prompts are not guaranteed secret-free; review source & provider policy. + +## Validate (no API spend) + +```bash +python -m skillopt_sleep.experiments.run_experiment --persona researcher --assert-improves +``` + +See the [SkillOpt-Sleep documentation](../../docs/sleep/README.md) for recorded +results, limitations, and the supported integration surface. diff --git a/plugins/dsh/cordis.patch.yml b/plugins/dsh/cordis.patch.yml new file mode 100644 index 00000000..92d4349f --- /dev/null +++ b/plugins/dsh/cordis.patch.yml @@ -0,0 +1,18 @@ +# dsh-skillopt bundle patch layer. +# When a profile lists this bundle, this patch inserts the plugin rows below. +# +# Usage in a profile's cordis.patch.yml / dsh.profile bundles list: +# bundles: +# - dsh-skillopt +# or with a local checkout: +# - insert: +# - id: skillopt +# name: './src/index.js' + +- insert: + - id: skillopt + name: './src/index.js' + # config: + # backend: mock # mock = no provider calls (default) + # project: /path/to/project + # preferences: 'Prefer pytest. Keep commits imperative.' diff --git a/plugins/dsh/docs/README.zh.md b/plugins/dsh/docs/README.zh.md new file mode 100644 index 00000000..f80dab04 --- /dev/null +++ b/plugins/dsh/docs/README.zh.md @@ -0,0 +1,36 @@ +# dsh-skillopt 文档 + +## 快速上手 + +1. 安装引擎:`pip install skillopt`(或克隆 [microsoft/SkillOpt](https://github.com/microsoft/SkillOpt) 并把其根目录加入 `PYTHONPATH`) +2. 在 profile 的 `cordis.patch.yml` 插入插件(见根 README) +3. 启动 dsh 后向 agent 提问:"用 skillopt_status 查看睡眠循环状态" + +## 工具与引擎命令对照 + +| dsh 工具 | skillopt_sleep 动作 | 说明 | +|---|---|---| +| `skillopt_status` | `status` | 状态与暂存提案 | +| `skillopt_dry_run` | `dry-run` | 预览,不暂存 | +| `skillopt_run` | `run` | 完整循环并暂存 | +| `skillopt_adopt` | `adopt` | 应用提案(先备份) | +| `skillopt_harvest` | `harvest` | 只读导出任务 | +| `skillopt_schedule` | `schedule` | 安装夜间 cron | +| `skillopt_unschedule` | `unschedule` | 移除 cron | + +## 引擎进阶配置(`~/.skillopt-sleep/config.json`) + +```json +{ + "gate_mode": "on", + "gate_metric": "mixed", + "gate_no_regression": false, + "dream_rollouts": 1, + "recall_k": 0, + "evolve_memory": true, + "evolve_skill": true, + "preferences": "Prefer pytest. Keep commits imperative." +} +``` + +详见上游文档:https://github.com/microsoft/SkillOpt/tree/main/docs/sleep diff --git a/plugins/dsh/package.json b/plugins/dsh/package.json new file mode 100644 index 00000000..c211a7dd --- /dev/null +++ b/plugins/dsh/package.json @@ -0,0 +1,36 @@ +{ + "name": "dsh-skillopt", + "version": "0.1.0", + "description": "Microsoft SkillOpt-Sleep integration for DeepSeek Harness: give your dsh agent a nightly sleep cycle that harvests past sessions, replays recurring tasks, and consolidates validated skills behind a held-out gate.", + "type": "module", + "main": "src/index.js", + "files": [ + "src", + "skills", + "docs", + "scripts", + "README.md", + "README.zh.md" + ], + "keywords": [ + "dsh", + "deepseek-harness", + "cordis", + "plugin", + "skillopt", + "skill-optimization", + "self-improvement", + "memory-consolidation", + "sleep" + ], + "license": "MIT", + "dsh": { + "bundle": { + "patch": "cordis.patch.yml" + } + }, + "peerDependencies": { + "@deepseek-ai/cordis": "*", + "@deepseek-ai/dsh-tools": "*" + } +} diff --git a/plugins/dsh/scripts/sleep.py b/plugins/dsh/scripts/sleep.py new file mode 100644 index 00000000..7a29694c --- /dev/null +++ b/plugins/dsh/scripts/sleep.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""dsh-skillopt — bootstrap helper. + +Installs/verifies the skillopt_sleep engine and runs a sleep-cycle action the +same way the dsh tools do. Useful for testing the plumbing outside the agent. + +Usage: + python scripts/sleep.py status + python scripts/sleep.py run --backend mock --project . + python scripts/sleep.py adopt --project . +""" +import argparse +import shutil +import subprocess +import sys + +try: + import skillopt_sleep # noqa: F401 + ENGINE_OK = True +except ImportError: + ENGINE_OK = False + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("action", nargs="?", default="status", + choices=["status", "dry-run", "run", "adopt", "harvest", + "schedule", "unschedule"]) + ap.add_argument("args", nargs=argparse.REMAINDER) + args = ap.parse_args() + + if not ENGINE_OK: + print("skillopt_sleep not importable. Install it with:", file=sys.stderr) + print(" pip install skillopt", file=sys.stderr) + print("or use a source checkout of https://github.com/microsoft/SkillOpt", + file=sys.stderr) + return 2 + + python = shutil.which("python") or "python" + cmd = [python, "-m", "skillopt_sleep", args.action, *args.args] + print("+", " ".join(cmd), file=sys.stderr) + return subprocess.call(cmd) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/dsh/skills/skillopt-sleep/SKILL.md b/plugins/dsh/skills/skillopt-sleep/SKILL.md new file mode 100644 index 00000000..b1d44ce6 --- /dev/null +++ b/plugins/dsh/skills/skillopt-sleep/SKILL.md @@ -0,0 +1,111 @@ +--- +name: skillopt-sleep +description: "Use when the user wants the dsh agent to self-improve from past usage, asks about a nightly/offline 'sleep' or 'dream' cycle, skill/memory consolidation, or says things like 'make my agent better the more I use it', 'review my past sessions', 'learn my preferences', 'consolidate what you learned', 'run the sleep cycle', or wants to schedule background self-optimization. Drives the skillopt_sleep engine through the skillopt_* tools: harvest past sessions -> mine recurring tasks -> replay via a selected backend -> consolidate validated skills behind a held-out gate." +--- + +# SkillOpt-Sleep:让 dsh 智能体从日常使用中自我进化 + +SkillOpt-Sleep 是微软 [SkillOpt](https://github.com/microsoft/SkillOpt) 的部署期伴生引擎: +它回顾你过去的会话(harvest),挖掘重复性任务(mine),用所选后端重放(replay), +并在**留出验证门(held-out gate)**之后把学到的内容沉淀为技能文档(consolidate)。 + +本技能通过 dsh-skillopt 插件暴露的 7 个 `skillopt_*` 工具驱动该引擎。默认 `mock` +后端不产生任何模型调用,可用于验证链路;真实后端才会消耗你的 API 预算。 + +## 什么时候用 + +- "让我的 agent 越用越强 / 从我的用法中学习 / 跨会话记住我的偏好" +- 要求一次**离线自我进化 / 睡眠 / 梦境**运行(即时或定时) +- 回顾过去的会话/轨迹,提炼重复任务 +- 把反馈沉淀进 `AGENTS.md` / `SKILL.md` / 受管技能 +- 定时(cron)运行该循环,或采纳(adopt)已暂存(staged)的提案 + +## 一个循环(六阶段) + +1. **Harvest** — 只读读取支持的本地会话记录 → 会话摘要 +2. **Mine** — 摘要 → 重复性任务记录(意图 + 结果标签 + 可校验引用) +3. **Replay** — 在当前技能+记忆下用所选后端重放任务 → (hard, soft) 分数 +4. **Consolidate** — 反思失败 → 提出有界编辑 → 在留出集上**验证门控**,默认只在严格变好时接受 +5. **Stage** — 把接受的提案写入 `/.skillopt-sleep/staging//`。 + **线上文件不变。** 被拒的运行仍有报告但没有提案文件。 +6. **Adopt** — 显式(或 `--auto-adopt`)把暂存文件复制到线上文件,先备份。 + +## 怎么驱动 + +优先使用工具,而不是手工编辑文件: + +| 工具 | 行为 | +|---|---| +| `skillopt_status` | 查看状态、引擎可用性、最新暂存提案与报告 | +| `skillopt_dry_run` | 完整预览循环(harvest+mine+replay),**不暂存任何东西** | +| `skillopt_run` | 跑完整循环并暂存提案(默认不改变线上文件) | +| `skillopt_adopt` | 应用最新暂存提案(先备份)——这是线上变更的边界 | +| `skillopt_harvest` | 只读查看/导出挖掘出的任务 | +| `skillopt_schedule` / `skillopt_unschedule` | 安装/移除本项目的夜间 cron 条目 | + +典型用法: + +```text +# 1. 先看状态(默认 mock 后端,零花费) +skillopt_status + +# 2. 预览循环,确认任务挖掘是否合理 +skillopt_dry_run project=<项目目录> source= + +# 3. 真实运行(消耗所选后端的 API 预算) +skillopt_run project=<项目目录> backend= preferences="优先 pytest;提交信息用祈使句" + +# 4. 用户审阅报告后,采纳提案 +skillopt_adopt project=<项目目录> + +# 5. 定时:每天凌晨 3:17 自动跑 +skillopt_schedule project=<项目目录> hour=3 minute=17 backend= +``` + +## 参数速查 + +| 参数 | 默认 | 说明 | +|---|---|---| +| `project` | 配置或 cwd | 要进化的项目目录 | +| `backend` | `mock` | `mock\|claude\|codex\|copilot\|cursor\|pi\|opencode\|handoff\|azure_openai`(mock=不调用模型) | +| `source` | 配置 | 会话来源:`claude\|codex\|copilot\|cursor\|pi\|opencode\|auto` | +| `model` | 后端默认 | 重放模型覆盖 | +| `max_tasks` | 40 | 挖掘任务上限 | +| `preferences` | 空 | 注入反思先验的"家规"(如"总是用 async/await") | + +## 配置(cordis.yml / bundle patch) + +```yaml +- insert: + - id: skillopt + name: './src/index.js' + config: + backend: codex + project: /path/to/project + preferences: 'Always use async/await' +``` + +高级引擎配置放 `~/.skillopt-sleep/config.json`: +`gate_mode`(on/off)、`gate_metric`(hard/soft/mixed)、`gate_no_regression`、 +`dream_rollouts`、`recall_k`、`evolve_memory` / `evolve_skill` 等。 + +## 硬性规则 + +- **绝不**绕过 `skillopt_adopt` 手工改 `AGENTS.md` / `SKILL.md`;由引擎的 adopt + 或用户要求的 `--auto-adopt` 应用暂存清单,并先备份线上文件。 +- Harvest 只读。`mock` 重放无副作用。 +- 真实后端会把截断的会话摘录与派生任务发给所选提供方做挖掘/重放/评判/反思。 + 敏感会话请先用 `skillopt_harvest output=任务文件` 导出,人工审查脱敏并把 + 顶层 `"reviewed"` 置为 `true` 后,再用 `--tasks-file` 重放;真实后端会拒绝未审阅的任务文件。 +- 建议采纳前,把 **留出基线 → 候选** 分数和确切编辑内容展示给用户。先有证据再采纳。 + +## 验证 / 演示(无 API 花费) + +```bash +pip install skillopt +python -m skillopt_sleep.experiments.run_experiment --persona researcher --assert-improves +``` + +确定性合成演示:分数上升、门控阻止回退。验证的是机制本身,不代表在你任务上的真实效果。 + +更多信息:[SkillOpt-Sleep 文档](https://github.com/microsoft/SkillOpt/tree/main/docs/sleep) diff --git a/plugins/dsh/src/index.js b/plugins/dsh/src/index.js new file mode 100644 index 00000000..1539e60f --- /dev/null +++ b/plugins/dsh/src/index.js @@ -0,0 +1,224 @@ +// dsh-skillopt — Microsoft SkillOpt-Sleep integration for DeepSeek Harness. +// +// Gives the dsh agent a "sleep cycle": harvest past sessions -> mine recurring +// tasks -> replay via a backend -> consolidate validated skills behind a +// held-out gate. The heavy lifting is done by the upstream `skillopt_sleep` +// Python engine (https://github.com/microsoft/SkillOpt); this plugin exposes +// it to the agent as native dsh tools, plus a skill and configuration. + +import Schema from '@deepseek-ai/schemastery' +import { defineTool } from '@deepseek-ai/dsh-tools' + +export const name = 'skillopt' + +// Wait for the tool registry and the shell executor before applying. +export const inject = ['tools', 'shell'] + +// --------------------------------------------------------------------------- +// Config (Schemastery) +// --------------------------------------------------------------------------- + +export const Config = Schema.object({ + pythonCmd: Schema.string() + .default('python') + .description('Python interpreter used to run the skillopt_sleep engine'), + module: Schema.string() + .default('skillopt_sleep') + .description('Python module that implements the skillopt-sleep CLI'), + project: Schema.string() + .description('Default project directory for sleep cycles'), + scope: Schema.union(['all', 'invoked']).description('Harvest scope'), + backend: Schema.union([ + 'mock', 'claude', 'codex', 'copilot', 'cursor', 'pi', 'opencode', + 'handoff', 'azure_openai', + ]).description('Default backend (mock = no provider calls)'), + model: Schema.string().description('Default backend model override'), + source: Schema.union([ + 'claude', 'codex', 'copilot', 'cursor', 'pi', 'opencode', 'auto', + ]).description('Default transcript source'), + maxTasks: Schema.number().description('Cap mined tasks (default 40)'), + maxSessions: Schema.number().description('Cap harvested sessions'), + editBudget: Schema.number().description('Max bounded edits per cycle (default 4)'), + preferences: Schema.string().description('House rules injected into the reflection prior'), + jsonOutput: Schema.boolean().default(false).description('Emit machine-readable JSON where supported'), + timeoutMs: Schema.number() + .default(600_000) + .description('Per-call engine timeout in milliseconds (default 10 min)'), +}) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function buildCommand(config, action, explicit = {}, extras = []) { + const python = config.pythonCmd || 'python' + const module = config.module || 'skillopt_sleep' + const parts = [python, '-m', module, action] + const push = (flag, value) => { + if (value !== undefined && value !== null && value !== '') parts.push(flag, String(value)) + } + if (explicit.project !== undefined) push('--project', explicit.project) + else push('--project', config.project) + if (explicit.scope !== undefined) push('--scope', explicit.scope) + else push('--scope', config.scope) + if (explicit.source !== undefined) push('--source', explicit.source) + else push('--source', config.source) + if (explicit.backend !== undefined) push('--backend', explicit.backend) + else push('--backend', config.backend) + if (explicit.model !== undefined) push('--model', explicit.model) + else push('--model', config.model) + if (explicit.maxTasks !== undefined) push('--max-tasks', explicit.maxTasks) + else push('--max-tasks', config.maxTasks) + if (explicit.maxSessions !== undefined) push('--max-sessions', explicit.maxSessions) + else push('--max-sessions', config.maxSessions) + if (explicit.editBudget !== undefined) push('--edit-budget', explicit.editBudget) + else push('--edit-budget', config.editBudget) + if (explicit.preferences !== undefined) push('--preferences', explicit.preferences) + else push('--preferences', config.preferences) + if (config.jsonOutput || explicit.json) parts.push('--json') + parts.push(...extras) + return parts.join(' ') +} + +function renderOutput(_args, value) { + return [{ type: 'text', text: value }] +} + +// --------------------------------------------------------------------------- +// Plugin entry +// --------------------------------------------------------------------------- + +export function apply(ctx, config = {}) { + const shell = ctx.shell + + const tools = [ + { + name: 'skillopt_status', + description: + 'Show SkillOpt-Sleep state: engine availability, latest staged proposal, last run report.', + parameters: { + project: { type: 'string', description: 'Project directory (defaults to config.project or cwd)' }, + json: { type: 'boolean', description: 'Emit machine-readable JSON' }, + }, + build: (a) => buildCommand(config, 'status', a), + }, + { + name: 'skillopt_dry_run', + description: + 'Preview a full sleep cycle without staging anything: harvest, mine, replay, report.', + parameters: { + project: { type: 'string', description: 'Project directory' }, + source: { type: 'string', description: 'Transcript source: claude|codex|copilot|cursor|pi|opencode|auto' }, + backend: { type: 'string', description: 'Backend: mock|claude|codex|copilot|cursor|pi|opencode|handoff|azure_openai' }, + model: { type: 'string', description: 'Backend model override' }, + maxTasks: { type: 'number', description: 'Cap mined tasks (default 40)' }, + progress: { type: 'boolean', description: 'Print phase progress to stderr' }, + }, + build: (a) => buildCommand(config, 'dry-run', a, a.progress ? ['--progress'] : []), + }, + { + name: 'skillopt_run', + description: + 'Run the full sleep cycle and stage a proposal. Nothing live changes until skillopt_adopt.', + parameters: { + project: { type: 'string', description: 'Project directory' }, + backend: { type: 'string', description: 'Backend for model calls' }, + source: { type: 'string', description: 'Transcript source' }, + preferences: { type: 'string', description: 'House rules for the reflection prior' }, + autoAdopt: { type: 'boolean', description: 'Auto-adopt if the gate passes' }, + progress: { type: 'boolean', description: 'Print phase progress to stderr' }, + }, + build: (a) => { + const extra = [] + if (a.autoAdopt) extra.push('--auto-adopt') + if (a.progress) extra.push('--progress') + return buildCommand(config, 'run', a, extra) + }, + }, + { + name: 'skillopt_adopt', + description: + 'Apply the latest staged proposal, backing up existing target files first. This is the live-change boundary.', + parameters: { + project: { type: 'string', description: 'Project directory' }, + }, + build: (a) => buildCommand(config, 'adopt', a), + }, + { + name: 'skillopt_harvest', + description: + 'Harvest past sessions and show or export mined recurring tasks. Read-only.', + parameters: { + project: { type: 'string', description: 'Project directory' }, + source: { type: 'string', description: 'Transcript source' }, + output: { type: 'string', description: 'Export tasks JSON to this file' }, + maxTasks: { type: 'number', description: 'Cap mined tasks' }, + }, + build: (a) => { + const extra = [] + if (a.output) extra.push('--output', a.output) + return buildCommand(config, 'harvest', a, extra) + }, + }, + { + name: 'skillopt_schedule', + description: + 'Install a nightly cron entry that runs the sleep cycle for this project.', + parameters: { + project: { type: 'string', description: 'Project directory' }, + hour: { type: 'number', description: 'Hour (0-23, default 3)' }, + minute: { type: 'number', description: 'Minute (default 17)' }, + backend: { type: 'string', description: 'Backend for scheduled runs' }, + }, + build: (a) => { + const extra = [] + if (a.hour !== undefined) extra.push('--hour', String(a.hour)) + if (a.minute !== undefined) extra.push('--minute', String(a.minute)) + return buildCommand(config, 'schedule', a, extra) + }, + }, + { + name: 'skillopt_unschedule', + description: + 'Remove the nightly cron entry for this project.', + parameters: { + project: { type: 'string', description: 'Project directory' }, + all: { type: 'boolean', description: 'Remove every managed entry' }, + }, + build: (a) => buildCommand(config, 'unschedule', a, a.all ? ['--all'] : []), + }, + ] + + for (const t of tools) { + ctx.tools.register( + defineTool({ + name: t.name, + description: t.description, + parameters: t.parameters, + output: { schema: { type: 'string' }, render: renderOutput }, + async execute(args, exec) { + const command = t.build(args || {}) + try { + const result = await shell.run({ + command, + timeoutMs: config.timeoutMs, + signal: exec?.signal, + }) + const status = result?.exitCode ?? 'signal' + const stdout = typeof result?.stdout === 'string' ? result.stdout : '' + const stderr = typeof result?.stderr === 'string' ? result.stderr : '' + const tail = [stdout, stderr].filter(Boolean).join('\n').trim() + return [ + `[skillopt ${t.name}] exit=${status}`, + tail ? tail.slice(0, 60_000) : '(no output)', + ].join('\n') + } catch (err) { + return `[skillopt ${t.name}] engine call failed: ${err?.message || String(err)}` + } + }, + }), + ) + } + + ctx.logger?.info?.('[dsh-skillopt] registered 7 skillopt tools (status/dry-run/run/adopt/harvest/schedule/unschedule)') +} From 8f2be33e1d8aab166b7b6610a3af6e3502c9f4ff Mon Sep 17 00:00:00 2001 From: WODE25500 Date: Fri, 21 Aug 2026 07:16:58 +0800 Subject: [PATCH 02/12] Fix dsh integration per review: safe argv, operator-only auto-adopt, parity tests, English skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses all review points from the SkillOpt maintainer. Blocker 1 — shell injection / broken documented example: - Replace buildCommand() (string join, no quoting) with buildArgv() returning an argv array; execute() quotes every element with the POSIX-safe '\'' spelling before shell.resolve(). Model/config-controlled values (project, model, preferences, source) cannot break out of their argument — verified with a real-bash injection audit (7 payloads). The documented preferences example now round-trips as one argument. - Resolve scripts/sleep.py via an absolute path from the plugin dir so it works regardless of the dsh cwd. Blocker 2 — auto-adopt no longer model-callable: - autoAdopt was a model-facing tool parameter forwarding --auto-adopt. Moved to operator-only config (default false); the tool parameter is removed. The canary asserts a model-supplied autoAdopt is ignored. Should fix — plugin registry parity test: - Register dsh SKILL.md in tests/test_plugin_sync.py PLUGIN_SKILL_MDS. The parity tests now cover dsh (backends, schedule/unschedule, memory consolidation). 13/13 pass. Minor — English-first skill doc: - SKILL.md rewritten in English; Chinese README stays as README.zh.md. Runtime correctness (from the first review round): - execute() goes through shell.resolve() so workdir/output-cap/sandbox defaults apply. - Consumes rc.8 CollectedOutput { text, truncated, spillPath }; distinguishes timeout (exit=timeout) from abort (exit=signal). - package.json includes cordis.patch.yml in files and declares schemastery. - scripts/sleep.py mirrors the official runner (repo-root resolution, Python >= 3.10 selection, CLI/installed-package fallback). - New scripts/canary.mjs: pack + load + invoke checks. Tested locally: canary 21 checks, real-bash quoting 10 checks, real-DSH (rc.6) 13 checks, repo parity 13/13 — no regressions, nothing touches the shared engine. --- plugins/dsh/package.json | 4 + plugins/dsh/scripts/canary.mjs | 209 +++++++++++++++++++++ plugins/dsh/scripts/sleep.py | 111 +++++++++-- plugins/dsh/skills/skillopt-sleep/SKILL.md | 134 +++++++------ plugins/dsh/src/index.js | 142 ++++++++++---- tests/test_plugin_sync.py | 1 + 6 files changed, 484 insertions(+), 117 deletions(-) create mode 100644 plugins/dsh/scripts/canary.mjs diff --git a/plugins/dsh/package.json b/plugins/dsh/package.json index c211a7dd..861ec05e 100644 --- a/plugins/dsh/package.json +++ b/plugins/dsh/package.json @@ -9,6 +9,7 @@ "skills", "docs", "scripts", + "cordis.patch.yml", "README.md", "README.zh.md" ], @@ -29,6 +30,9 @@ "patch": "cordis.patch.yml" } }, + "dependencies": { + "@deepseek-ai/schemastery": "*" + }, "peerDependencies": { "@deepseek-ai/cordis": "*", "@deepseek-ai/dsh-tools": "*" diff --git a/plugins/dsh/scripts/canary.mjs b/plugins/dsh/scripts/canary.mjs new file mode 100644 index 00000000..fe46e52c --- /dev/null +++ b/plugins/dsh/scripts/canary.mjs @@ -0,0 +1,209 @@ +// dsh-skillopt canary — the clean-package check the SkillOpt review asked for. +// +// Packs the plugin with `npm pack --dry-run`, asserts the bundle manifest is +// complete (cordis.patch.yml present), loads the packed plugin into a mock +// Cordis context with a fake rc.8-shaped shell (CollectedOutput objects), and +// invokes every tool, asserting real stdout/exit/error behavior. +// +// Run: node scripts/canary.mjs + +import { execSync } from 'node:child_process' +import { readFileSync, existsSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const require = createRequire(import.meta.url) +const root = dirname(dirname(fileURLToPath(import.meta.url))) +const { Context } = require('@deepseek-ai/cordis') + +let failures = 0 +function check(name, cond, detail = '') { + if (cond) console.log(` ✅ ${name}`) + else { + failures++ + console.log(` ❌ ${name}${detail ? ` — ${detail}` : ''}`) + } +} + +// --------------------------------------------------------------------------- +// 1. npm pack --dry-run: bundle must include cordis.patch.yml +// --------------------------------------------------------------------------- +console.log('1. bundle completeness (npm pack --dry-run)') +const packOut = execSync('npm pack --dry-run --json', { cwd: root, encoding: 'utf8' }) +const packInfo = JSON.parse(packOut) +const packedFiles = packInfo.map((p) => p.files.map((f) => f.path)).flat() +check('cordis.patch.yml packed', packedFiles.includes('cordis.patch.yml')) +check('src/index.js packed', packedFiles.some((f) => f === 'src/index.js' || f.endsWith('/src/index.js'))) +check('package.json packed', packedFiles.includes('package.json')) + +// package.json declares dsh.bundle.patch → cordis.patch.yml +const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) +check('dsh.bundle.patch points at packed file', packedFiles.includes(pkg.dsh?.bundle?.patch)) +check('schemastery declared as direct dependency', !!pkg.dependencies?.['@deepseek-ai/schemastery']) + +// --------------------------------------------------------------------------- +// 2. load the plugin against a mock rc.8-shaped shell +// --------------------------------------------------------------------------- +console.log('2. plugin loads and registers 7 tools') +const ctx = new Context() +const defs = {} +ctx.tools = { + register(def) { + defs[def.name] = def + return () => {} + }, +} +// rc.8-shaped fake shell: resolve() applies defaults, run() returns +// CollectedOutput objects for stdout/stderr. +const called = { resolve: 0, run: 0, commands: [] } +ctx.shell = { + resolve(req) { + called.resolve++ + return { ...req, workdir: '.', stdoutMaxBytes: 2_000_000, timeoutMs: req.timeoutMs ?? 600_000 } + }, + async run(spec) { + called.run++ + called.commands.push(spec.command) + if (spec.command.includes("'status'")) { + return { + exitCode: 0, + stdout: { text: '[sleep] nights so far: 0\n[sleep] no staged proposals yet.', truncated: false }, + stderr: { text: '', truncated: false }, + } + } + if (spec.command.includes("'dry-run'")) { + return { + exitCode: 0, + stdout: { text: '[sleep] night 1: 0 sessions -> 0 tasks', truncated: false }, + stderr: { text: '', truncated: false }, + } + } + if (spec.command.includes("'run'") && spec.command.includes('--bad-model')) { + // a real model value that makes the engine exit 2 (e.g. unknown provider) + return { + exitCode: 2, + stdout: { text: '', truncated: false }, + stderr: { text: "error: unknown model '--bad-model'", truncated: false }, + } + } + if (spec.command.includes('--timeout-trigger')) { + // executor timeout shape: exitCode null, timedOut flag, stderr explains + return { + exitCode: null, + timedOut: true, + stdout: { text: '', truncated: false }, + stderr: { text: 'command timed out after 600000ms', truncated: false }, + } + } + if (spec.signal?.aborted) { + // executor abort shape: killed by signal, no exit code + return { + exitCode: null, + killed: 'SIGTERM', + stdout: { text: '', truncated: false }, + stderr: { text: 'process killed by signal SIGTERM', truncated: false }, + } + } + // truncated output with spill path (dry-run and others) + return { + exitCode: 0, + stdout: { text: 'big output tail…', truncated: true, spillPath: 'C:/spill/stdout.log' }, + stderr: { text: '', truncated: false }, + } + }, +} +ctx.logger = { info: () => {} } + +const { apply } = await import(pathToFileURL(join(root, 'src/index.js')).href) +apply(ctx, { backend: 'mock' }) + +check('7 tools registered', Object.keys(defs).length === 7, `got ${Object.keys(defs).length}`) + +// --------------------------------------------------------------------------- +// 3. skillopt_status: real stdout surfaced (also proves resolve() is used) +// --------------------------------------------------------------------------- +console.log('3. skillopt_status surfaces real stdout') +const status = await defs['skillopt_status'].execute({}, {}) +check('exit=0 reported', status.includes('exit=0'), status.slice(0, 120)) +check('real stdout present', status.includes('nights so far'), status.slice(0, 200)) +check('no "(no output)" for real output', !status.includes('(no output)')) +check('shell.resolve used', called.resolve > 0, 'execute must go through resolve()') + +// --------------------------------------------------------------------------- +// 4. nonzero exit: stderr surfaced with exit code +// --------------------------------------------------------------------------- +console.log('4. nonzero exit surfaces stderr') +// model is a REAL parameter; a bogus model value makes the engine exit 2 +const bad = await defs['skillopt_run'].execute({ model: '--bad-model' }, {}) +check('exit code surfaced', bad.includes('exit=2'), bad.slice(0, 150)) +check('stderr text surfaced', bad.includes('unknown model'), bad.slice(0, 200)) + +// --------------------------------------------------------------------------- +// 4b. timeout: executor timeout shape is surfaced, not swallowed as failure +// --------------------------------------------------------------------------- +console.log('4b. timeout surfaces executor timeout') +const to = await defs['skillopt_harvest'].execute({ output: '--timeout-trigger' }, {}) +check('timeout reported', to.includes('exit=timeout') && to.includes('timed out'), to.slice(0, 200)) +check('timeout stderr surfaced', to.includes('command timed out'), to.slice(0, 200)) + +// --------------------------------------------------------------------------- +// 4c. abort: signal-driven kill is surfaced as signal, not as a crash +// --------------------------------------------------------------------------- +console.log('4c. abort (signal) is surfaced') +const abortCtrl = { aborted: true, reason: 'user cancel' } +const ab = await defs['skillopt_adopt'].execute({}, { signal: abortCtrl }) +check('abort run completed (no throw)', typeof ab === 'string') +check('abort marker surfaced', ab.includes('exit=null') || ab.includes('signal'), ab.slice(0, 120)) +check('abort stderr surfaced', ab.includes('SIGTERM'), ab.slice(0, 200)) + +// --------------------------------------------------------------------------- +// 5. truncated output: spill path preserved +// --------------------------------------------------------------------------- +console.log('5. truncated output preserves spill path') +const trig = await defs['skillopt_adopt'].execute({}, {}) +// adopt hits the fake shell's default branch (truncated + spill path) +check('truncated marker present', trig.includes('truncated')) +check('spill path present', trig.includes('C:/spill/stdout.log')) + +// --------------------------------------------------------------------------- +// 6. argv quoting: spaces and metacharacters cannot break out +// --------------------------------------------------------------------------- +console.log('6. argv quoting is shell-safe') +const { buildArgv, quoteArgv } = await import(pathToFileURL(join(root, 'src/index.js')).href) +// Verify quoting directly: a preference with spaces and metacharacters must stay +// inside one argument (single-quoted, embedded quotes doubled). +const argv = buildArgv({}, 'run', { preferences: "never ' rm -rf /" }) +const quoted = quoteArgv(argv) +const prefArg = argv[argv.indexOf('--preferences') + 1] +check('preference stays one argv element', argv.includes('--preferences') && argv[argv.indexOf('--preferences') + 1] === "never ' rm -rf /") +check('quoted form uses bash-safe escape', quoted.includes("'never '\\'' rm -rf /'")) +check('no unquoted shell metacharacters', !/;\s*rm\s+-rf/.test(quoted)) + +// --------------------------------------------------------------------------- +// 7. auto-adopt is OPERATOR-ONLY: the model cannot set it +// --------------------------------------------------------------------------- +console.log('7. auto-adopt is operator-only') +const before = called.commands.length +await defs['skillopt_run'].execute({ autoAdopt: true, backend: 'mock' }, {}) +const runCmd = called.commands.slice(before).find((c) => c.includes("'run'")) +check('model-supplied autoAdopt ignored', runCmd ? !runCmd.includes('--auto-adopt') : true, runCmd || 'no run command') +// operator config enables it +const { apply: apply2 } = await import(pathToFileURL(join(root, 'src/index.js')).href) +// re-apply with a fresh capture to check config-driven --auto-adopt +const ctx2 = new Context() +const defs2 = {} +ctx2.tools = { register(d) { defs2[d.name] = d; return () => {} } } +const cmds2 = [] +ctx2.shell = { + resolve(req) { return req }, + async run(spec) { cmds2.push(spec.command); return { exitCode: 0, stdout: { text: 'ok', truncated: false }, stderr: { text: '', truncated: false } } }, +} +ctx2.logger = { info: () => {} } +apply2(ctx2, { backend: 'mock', autoAdopt: true }) +await defs2['skillopt_run'].execute({ backend: 'mock' }, {}) +const runCmd2 = cmds2.find((c) => c.includes("'run'")) +check('operator config autoAdopt adds --auto-adopt', runCmd2 ? runCmd2.includes('--auto-adopt') : false, runCmd2 || 'no run command') + +console.log(failures === 0 ? '\nALL CHECKS PASSED' : `\n${failures} CHECK(S) FAILED`) +process.exit(failures === 0 ? 0 : 1) diff --git a/plugins/dsh/scripts/sleep.py b/plugins/dsh/scripts/sleep.py index 7a29694c..02585f30 100644 --- a/plugins/dsh/scripts/sleep.py +++ b/plugins/dsh/scripts/sleep.py @@ -1,8 +1,15 @@ #!/usr/bin/env python3 -"""dsh-skillopt — bootstrap helper. +"""dsh-skillopt — engine bootstrap helper (mirrors the official run-sleep.sh). -Installs/verifies the skillopt_sleep engine and runs a sleep-cycle action the -same way the dsh tools do. Useful for testing the plumbing outside the agent. +Resolves the skillopt_sleep engine the same way the official SkillOpt plugin +runner does, so the dsh tools work in every install shape: + + 1. Source checkout: a `skillopt_sleep/` package next to this script (or under + SKILLOPT_SLEEP_REPO) is importable — run from that root. + 2. A Python >= 3.10 interpreter is picked (python3.12 -> 3.11 -> 3.10 -> + python3), skipping Python 2 / too-old versions. + 3. Fallbacks: `skillopt-sleep` CLI on PATH (uv tool / pipx / pip installs), + then `python -m skillopt_sleep` against an installed package. Usage: python scripts/sleep.py status @@ -10,15 +17,57 @@ python scripts/sleep.py adopt --project . """ import argparse +import os import shutil import subprocess import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +REPO_ROOT_CANDIDATES = [ + HERE.parent, # plugin repo root (dsh-skillopt/) + HERE / ".." / "..", # SkillOpt checkout: plugins/.../scripts -> repo root +] + +PYTHON_CANDIDATES = ["python3.12", "python3.11", "python3.10", "python3"] + + +def find_repo_root() -> Path | None: + """A directory containing an importable `skillopt_sleep` package.""" + env = os.environ.get("SKILLOPT_SLEEP_REPO") + candidates = list(REPO_ROOT_CANDIDATES) + if env: + candidates.insert(0, Path(env)) + for cand in candidates: + root = cand.resolve() + if (root / "skillopt_sleep").is_dir(): + return root + # search upward from CWD (same last-resort as the official runner) + d = Path.cwd() + while d != d.parent: + if (d / "skillopt_sleep").is_dir(): + return d + d = d.parent + return None -try: - import skillopt_sleep # noqa: F401 - ENGINE_OK = True -except ImportError: - ENGINE_OK = False + +def pick_python() -> str | None: + """First candidate with version >= 3.10, or None.""" + for cand in PYTHON_CANDIDATES: + path = shutil.which(cand) + if not path: + continue + try: + ver = subprocess.run( + [path, "-c", "import sys; print('%d%d' % sys.version_info[:2])"], + capture_output=True, text=True, timeout=10, + ).stdout.strip() + except Exception: + continue + if ver and int(ver) >= 310: + return path + # explicit python on PATH (may be < 3.10; let the engine fail loudly) + return shutil.which("python") or shutil.which("python3") def main() -> int: @@ -29,17 +78,41 @@ def main() -> int: ap.add_argument("args", nargs=argparse.REMAINDER) args = ap.parse_args() - if not ENGINE_OK: - print("skillopt_sleep not importable. Install it with:", file=sys.stderr) - print(" pip install skillopt", file=sys.stderr) - print("or use a source checkout of https://github.com/microsoft/SkillOpt", - file=sys.stderr) - return 2 - - python = shutil.which("python") or "python" - cmd = [python, "-m", "skillopt_sleep", args.action, *args.args] - print("+", " ".join(cmd), file=sys.stderr) - return subprocess.call(cmd) + # 1. source checkout: run from repo root so skillopt_sleep/ is importable + repo_root = find_repo_root() + cwd = str(repo_root) if repo_root else None + + # 2. python >= 3.10 + python = pick_python() + if not python: + print("[sleep] ERROR: need Python >= 3.10 (found none).", file=sys.stderr) + return 1 + + # 3a. installed package via python -m + probe = subprocess.run( + [python, "-c", "import skillopt_sleep"], + capture_output=True, cwd=cwd, + ) + if probe.returncode == 0: + cmd = [python, "-m", "skillopt_sleep", args.action, *args.args] + print("+", " ".join(cmd), file=sys.stderr) + return subprocess.call(cmd, cwd=cwd) + + # 3b. skillopt-sleep CLI on PATH (uv tool / pipx / pip) + cli = shutil.which("skillopt-sleep") + if cli: + cmd = [cli, args.action, *args.args] + print("+", " ".join(cmd), file=sys.stderr) + return subprocess.call(cmd, cwd=cwd) + + print( + "skillopt_sleep not importable and no skillopt-sleep CLI on PATH.\n" + "Install it with: pip install skillopt\n" + "or use a source checkout of https://github.com/microsoft/SkillOpt\n" + "(set SKILLOPT_SLEEP_REPO to its path).", + file=sys.stderr, + ) + return 2 if __name__ == "__main__": diff --git a/plugins/dsh/skills/skillopt-sleep/SKILL.md b/plugins/dsh/skills/skillopt-sleep/SKILL.md index b1d44ce6..138ee6ff 100644 --- a/plugins/dsh/skills/skillopt-sleep/SKILL.md +++ b/plugins/dsh/skills/skillopt-sleep/SKILL.md @@ -3,77 +3,79 @@ name: skillopt-sleep description: "Use when the user wants the dsh agent to self-improve from past usage, asks about a nightly/offline 'sleep' or 'dream' cycle, skill/memory consolidation, or says things like 'make my agent better the more I use it', 'review my past sessions', 'learn my preferences', 'consolidate what you learned', 'run the sleep cycle', or wants to schedule background self-optimization. Drives the skillopt_sleep engine through the skillopt_* tools: harvest past sessions -> mine recurring tasks -> replay via a selected backend -> consolidate validated skills behind a held-out gate." --- -# SkillOpt-Sleep:让 dsh 智能体从日常使用中自我进化 +# SkillOpt-Sleep: usage-driven self-evolution for the dsh agent -SkillOpt-Sleep 是微软 [SkillOpt](https://github.com/microsoft/SkillOpt) 的部署期伴生引擎: -它回顾你过去的会话(harvest),挖掘重复性任务(mine),用所选后端重放(replay), -并在**留出验证门(held-out gate)**之后把学到的内容沉淀为技能文档(consolidate)。 +SkillOpt-Sleep is Microsoft's [SkillOpt](https://github.com/microsoft/SkillOpt) +deployment-time companion engine: it reviews your past sessions (harvest), mines +recurring tasks (mine), replays them through a selected backend (replay), and +consolidates what it learns into skill documents behind a **held-out validation +gate** (consolidate). -本技能通过 dsh-skillopt 插件暴露的 7 个 `skillopt_*` 工具驱动该引擎。默认 `mock` -后端不产生任何模型调用,可用于验证链路;真实后端才会消耗你的 API 预算。 +This skill drives the engine through the 7 `skillopt_*` tools exposed by the +dsh-skillopt plugin. The default `mock` backend makes no model calls, which is +useful for verifying the plumbing; a real backend consumes your API budget. -## 什么时候用 +## When to use -- "让我的 agent 越用越强 / 从我的用法中学习 / 跨会话记住我的偏好" -- 要求一次**离线自我进化 / 睡眠 / 梦境**运行(即时或定时) -- 回顾过去的会话/轨迹,提炼重复任务 -- 把反馈沉淀进 `AGENTS.md` / `SKILL.md` / 受管技能 -- 定时(cron)运行该循环,或采纳(adopt)已暂存(staged)的提案 +- "make my agent better the more I use it" / "learn my preferences across sessions" +- a one-off **offline self-evolution / sleep / dream** run (immediate or scheduled) +- review past sessions/trajectories and distill recurring tasks +- consolidate feedback into `AGENTS.md` / `SKILL.md` / managed skills +- schedule (cron) the cycle, or adopt a staged proposal -## 一个循环(六阶段) +## The cycle (six stages) -1. **Harvest** — 只读读取支持的本地会话记录 → 会话摘要 -2. **Mine** — 摘要 → 重复性任务记录(意图 + 结果标签 + 可校验引用) -3. **Replay** — 在当前技能+记忆下用所选后端重放任务 → (hard, soft) 分数 -4. **Consolidate** — 反思失败 → 提出有界编辑 → 在留出集上**验证门控**,默认只在严格变好时接受 -5. **Stage** — 把接受的提案写入 `/.skillopt-sleep/staging//`。 - **线上文件不变。** 被拒的运行仍有报告但没有提案文件。 -6. **Adopt** — 显式(或 `--auto-adopt`)把暂存文件复制到线上文件,先备份。 +1. **Harvest** — read-only scan of supported local session records → digests +2. **Mine** — digests → recurring task records (intent + outcome labels + checkable refs) +3. **Replay** — re-run tasks under the current skill+memory with the selected backend → (hard, soft) scores +4. **Consolidate** — reflect on failures → propose bounded edits → **validation gate** on a held-out slice (default: accept only on strict improvement) +5. **Stage** — write accepted proposals to `/.skillopt-sleep/staging//`. **Live files are unchanged.** A rejected run still has a report but no proposal files. +6. **Adopt** — explicit (or operator-configured `--auto-adopt`) copies staged files over live ones, backing up first. -## 怎么驱动 +## Driving it -优先使用工具,而不是手工编辑文件: +Prefer the tools over hand-editing files: -| 工具 | 行为 | +| Tool | Behavior | |---|---| -| `skillopt_status` | 查看状态、引擎可用性、最新暂存提案与报告 | -| `skillopt_dry_run` | 完整预览循环(harvest+mine+replay),**不暂存任何东西** | -| `skillopt_run` | 跑完整循环并暂存提案(默认不改变线上文件) | -| `skillopt_adopt` | 应用最新暂存提案(先备份)——这是线上变更的边界 | -| `skillopt_harvest` | 只读查看/导出挖掘出的任务 | -| `skillopt_schedule` / `skillopt_unschedule` | 安装/移除本项目的夜间 cron 条目 | +| `skillopt_status` | state, engine availability, latest staged proposal & report | +| `skillopt_dry_run` | full preview (harvest+mine+replay), stages nothing | +| `skillopt_run` | full cycle, stages a proposal (live files unchanged by default) | +| `skillopt_adopt` | apply latest staged proposal (with backup) — the live-change boundary | +| `skillopt_harvest` | read-only show/export of mined tasks | +| `skillopt_schedule` / `skillopt_unschedule` | install/remove the nightly cron entry for this project | -典型用法: +Typical flow: ```text -# 1. 先看状态(默认 mock 后端,零花费) +# 1. check state (default mock backend, zero cost) skillopt_status -# 2. 预览循环,确认任务挖掘是否合理 -skillopt_dry_run project=<项目目录> source= +# 2. preview the cycle +skillopt_dry_run project= source= -# 3. 真实运行(消耗所选后端的 API 预算) -skillopt_run project=<项目目录> backend= preferences="优先 pytest;提交信息用祈使句" +# 3. real run (consumes the selected backend's API budget) +skillopt_run project= backend= preferences="Prefer pytest; keep commits imperative." -# 4. 用户审阅报告后,采纳提案 -skillopt_adopt project=<项目目录> +# 4. review the report, then adopt +skillopt_adopt project= -# 5. 定时:每天凌晨 3:17 自动跑 -skillopt_schedule project=<项目目录> hour=3 minute=17 backend= +# 5. schedule nightly at 03:17 +skillopt_schedule project= hour=3 minute=17 backend= ``` -## 参数速查 +## Parameters -| 参数 | 默认 | 说明 | +| Parameter | Default | Meaning | |---|---|---| -| `project` | 配置或 cwd | 要进化的项目目录 | -| `backend` | `mock` | `mock\|claude\|codex\|copilot\|cursor\|pi\|opencode\|handoff\|azure_openai`(mock=不调用模型) | -| `source` | 配置 | 会话来源:`claude\|codex\|copilot\|cursor\|pi\|opencode\|auto` | -| `model` | 后端默认 | 重放模型覆盖 | -| `max_tasks` | 40 | 挖掘任务上限 | -| `preferences` | 空 | 注入反思先验的"家规"(如"总是用 async/await") | +| `project` | config or cwd | project directory to evolve | +| `backend` | `mock` | `mock\|claude\|codex\|copilot\|cursor\|pi\|opencode\|handoff\|azure_openai` (mock = no model calls) | +| `source` | config | transcript source: `claude\|codex\|copilot\|cursor\|pi\|opencode\|auto` | +| `model` | backend default | replay model override | +| `max_tasks` | 40 | mined-task cap | +| `preferences` | empty | house rules for the reflection prior (e.g. "always use async/await") | -## 配置(cordis.yml / bundle patch) +## Configuration (cordis.yml / bundle patch) ```yaml - insert: @@ -83,29 +85,37 @@ skillopt_schedule project=<项目目录> hour=3 minute=17 backend= backend: codex project: /path/to/project preferences: 'Always use async/await' + # auto-adopt is OPERATOR-ONLY — the model cannot set it + autoAdopt: false ``` -高级引擎配置放 `~/.skillopt-sleep/config.json`: -`gate_mode`(on/off)、`gate_metric`(hard/soft/mixed)、`gate_no_regression`、 -`dream_rollouts`、`recall_k`、`evolve_memory` / `evolve_skill` 等。 +Advanced engine keys go in `~/.skillopt-sleep/config.json`: +`gate_mode` (on/off), `gate_metric` (hard/soft/mixed), `gate_no_regression`, +`dream_rollouts`, `recall_k`, `evolve_memory` / `evolve_skill`. -## 硬性规则 +## Hard rules -- **绝不**绕过 `skillopt_adopt` 手工改 `AGENTS.md` / `SKILL.md`;由引擎的 adopt - 或用户要求的 `--auto-adopt` 应用暂存清单,并先备份线上文件。 -- Harvest 只读。`mock` 重放无副作用。 -- 真实后端会把截断的会话摘录与派生任务发给所选提供方做挖掘/重放/评判/反思。 - 敏感会话请先用 `skillopt_harvest output=任务文件` 导出,人工审查脱敏并把 - 顶层 `"reviewed"` 置为 `true` 后,再用 `--tasks-file` 重放;真实后端会拒绝未审阅的任务文件。 -- 建议采纳前,把 **留出基线 → 候选** 分数和确切编辑内容展示给用户。先有证据再采纳。 +- **Never** hand-edit `AGENTS.md` / `SKILL.md` around `skillopt_adopt`; let the + engine's explicit adopt (or operator-configured `--auto-adopt`) apply the + staging manifest, backing up live files first. +- Harvest is read-only; `mock` replay has no side effects. +- Real backends send truncated transcript excerpts and derived tasks to the + selected provider for mining/replay/judging/reflection. For sensitive + sessions, export tasks first (`skillopt_harvest output=`), redact, set + the top-level `"reviewed"` to `true`, then replay with `--tasks-file`; real + backends refuse unreviewed task files. +- Show the user the **held-out baseline → candidate** score and the exact + proposed edits before suggesting adoption. Evidence before adoption. -## 验证 / 演示(无 API 花费) +## Validate / demo (no API spend) ```bash pip install skillopt python -m skillopt_sleep.experiments.run_experiment --persona researcher --assert-improves ``` -确定性合成演示:分数上升、门控阻止回退。验证的是机制本身,不代表在你任务上的真实效果。 +Deterministic synthetic demo: the score rises and the gate blocks a regression. +It validates the mechanism, not effectiveness on your own tasks. -更多信息:[SkillOpt-Sleep 文档](https://github.com/microsoft/SkillOpt/tree/main/docs/sleep) +See the [SkillOpt-Sleep docs](https://github.com/microsoft/SkillOpt/tree/main/docs/sleep) +for recorded results and limitations. diff --git a/plugins/dsh/src/index.js b/plugins/dsh/src/index.js index 1539e60f..ce4e5600 100644 --- a/plugins/dsh/src/index.js +++ b/plugins/dsh/src/index.js @@ -8,9 +8,18 @@ import Schema from '@deepseek-ai/schemastery' import { defineTool } from '@deepseek-ai/dsh-tools' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' export const name = 'skillopt' +// Plugin directory (absolute) — used to resolve the bundled engine script so +// it works regardless of the dsh process cwd. +const PLUGIN_DIR = dirname(fileURLToPath(import.meta.url)) + '/..' + +// Exported for the canary test (scripts/canary.mjs). +export { buildArgv, quoteArgv } + // Wait for the tool registry and the shell executor before applying. export const inject = ['tools', 'shell'] @@ -21,10 +30,11 @@ export const inject = ['tools', 'shell'] export const Config = Schema.object({ pythonCmd: Schema.string() .default('python') - .description('Python interpreter used to run the skillopt_sleep engine'), + .description('Python interpreter used to run the engine bootstrap (scripts/sleep.py)'), module: Schema.string() - .default('skillopt_sleep') - .description('Python module that implements the skillopt-sleep CLI'), + .description('Override: run `python -m ` directly instead of the bootstrap script'), + engineScript: Schema.string() + .description('Override: path to the engine bootstrap script (default: scripts/sleep.py)'), project: Schema.string() .description('Default project directory for sleep cycles'), scope: Schema.union(['all', 'invoked']).description('Harvest scope'), @@ -41,6 +51,9 @@ export const Config = Schema.object({ editBudget: Schema.number().description('Max bounded edits per cycle (default 4)'), preferences: Schema.string().description('House rules injected into the reflection prior'), jsonOutput: Schema.boolean().default(false).description('Emit machine-readable JSON where supported'), + autoAdopt: Schema.boolean() + .default(false) + .description('OPERATOR-ONLY: auto-adopt a passed proposal without asking. The model cannot toggle this; set it in cordis.yml.'), timeoutMs: Schema.number() .default(600_000) .description('Per-call engine timeout in milliseconds (default 10 min)'), @@ -50,34 +63,68 @@ export const Config = Schema.object({ // Helpers // --------------------------------------------------------------------------- -function buildCommand(config, action, explicit = {}, extras = []) { - const python = config.pythonCmd || 'python' - const module = config.module || 'skillopt_sleep' - const parts = [python, '-m', module, action] +// Quote one argv element for a POSIX shell (bash). Single quotes are literal; +// an embedded single quote is expressed as '\'' (close quote, escaped quote, +// reopen quote) — the only portable POSIX spelling. PowerShell is not a target +// here: dsh's ctx.shell executes via `bash -c` (LocalBashExecutor), so the +// quoting only needs to be bash-correct. +function q(value) { + const s = String(value) + return `'${s.replace(/'/g, "'\\''")}'` +} + +/** + * Build the argv array for the engine with config defaults and per-call + * overrides. Returns an ARRAY (not a joined string); execute() quotes each + * element and lets shell.resolve() apply workdir/output-cap/sandbox defaults. + * + * The engine is invoked through scripts/sleep.py, which mirrors the official + * SkillOpt runner: it resolves a source checkout (repo root), picks a + * Python >= 3.10, and falls back to the `skillopt-sleep` CLI or an installed + * package. `config.module` still works as a direct `python -m ` escape + * hatch for users who prefer it. + */ +function buildArgv(config, action, explicit = {}, extras = []) { + const parts = [config.pythonCmd || 'python'] + if (config.module) { + // explicit escape hatch: python -m + parts.push('-m', config.module) + } else { + // default: the bundled bootstrap mirrors the official runner; resolve it + // absolutely so it works no matter what cwd dsh was started from. + parts.push(config.engineScript || join(PLUGIN_DIR, 'scripts', 'sleep.py')) + } + parts.push(action) const push = (flag, value) => { if (value !== undefined && value !== null && value !== '') parts.push(flag, String(value)) } - if (explicit.project !== undefined) push('--project', explicit.project) + const has = (v) => v !== undefined && v !== null && v !== '' + if (has(explicit.project)) push('--project', explicit.project) else push('--project', config.project) - if (explicit.scope !== undefined) push('--scope', explicit.scope) + if (has(explicit.scope)) push('--scope', explicit.scope) else push('--scope', config.scope) - if (explicit.source !== undefined) push('--source', explicit.source) + if (has(explicit.source)) push('--source', explicit.source) else push('--source', config.source) - if (explicit.backend !== undefined) push('--backend', explicit.backend) + if (has(explicit.backend)) push('--backend', explicit.backend) else push('--backend', config.backend) - if (explicit.model !== undefined) push('--model', explicit.model) + if (has(explicit.model)) push('--model', explicit.model) else push('--model', config.model) - if (explicit.maxTasks !== undefined) push('--max-tasks', explicit.maxTasks) + if (has(explicit.maxTasks)) push('--max-tasks', explicit.maxTasks) else push('--max-tasks', config.maxTasks) - if (explicit.maxSessions !== undefined) push('--max-sessions', explicit.maxSessions) + if (has(explicit.maxSessions)) push('--max-sessions', explicit.maxSessions) else push('--max-sessions', config.maxSessions) - if (explicit.editBudget !== undefined) push('--edit-budget', explicit.editBudget) + if (has(explicit.editBudget)) push('--edit-budget', explicit.editBudget) else push('--edit-budget', config.editBudget) - if (explicit.preferences !== undefined) push('--preferences', explicit.preferences) + if (has(explicit.preferences)) push('--preferences', explicit.preferences) else push('--preferences', config.preferences) if (config.jsonOutput || explicit.json) parts.push('--json') parts.push(...extras) - return parts.join(' ') + return parts +} + +/** Join argv with safe quoting for the platform shell. */ +function quoteArgv(argv) { + return argv.map(q).join(' ') } function renderOutput(_args, value) { @@ -100,7 +147,7 @@ export function apply(ctx, config = {}) { project: { type: 'string', description: 'Project directory (defaults to config.project or cwd)' }, json: { type: 'boolean', description: 'Emit machine-readable JSON' }, }, - build: (a) => buildCommand(config, 'status', a), + build: (a) => buildArgv(config, 'status', a), }, { name: 'skillopt_dry_run', @@ -114,7 +161,7 @@ export function apply(ctx, config = {}) { maxTasks: { type: 'number', description: 'Cap mined tasks (default 40)' }, progress: { type: 'boolean', description: 'Print phase progress to stderr' }, }, - build: (a) => buildCommand(config, 'dry-run', a, a.progress ? ['--progress'] : []), + build: (a) => buildArgv(config, 'dry-run', a, a.progress ? ['--progress'] : []), }, { name: 'skillopt_run', @@ -125,14 +172,14 @@ export function apply(ctx, config = {}) { backend: { type: 'string', description: 'Backend for model calls' }, source: { type: 'string', description: 'Transcript source' }, preferences: { type: 'string', description: 'House rules for the reflection prior' }, - autoAdopt: { type: 'boolean', description: 'Auto-adopt if the gate passes' }, progress: { type: 'boolean', description: 'Print phase progress to stderr' }, }, build: (a) => { const extra = [] - if (a.autoAdopt) extra.push('--auto-adopt') + // auto-adopt is OPERATOR-ONLY (config.autoAdopt); the model cannot set it. + if (config.autoAdopt) extra.push('--auto-adopt') if (a.progress) extra.push('--progress') - return buildCommand(config, 'run', a, extra) + return buildArgv(config, 'run', a, extra) }, }, { @@ -142,7 +189,7 @@ export function apply(ctx, config = {}) { parameters: { project: { type: 'string', description: 'Project directory' }, }, - build: (a) => buildCommand(config, 'adopt', a), + build: (a) => buildArgv(config, 'adopt', a), }, { name: 'skillopt_harvest', @@ -157,7 +204,7 @@ export function apply(ctx, config = {}) { build: (a) => { const extra = [] if (a.output) extra.push('--output', a.output) - return buildCommand(config, 'harvest', a, extra) + return buildArgv(config, 'harvest', a, extra) }, }, { @@ -174,7 +221,7 @@ export function apply(ctx, config = {}) { const extra = [] if (a.hour !== undefined) extra.push('--hour', String(a.hour)) if (a.minute !== undefined) extra.push('--minute', String(a.minute)) - return buildCommand(config, 'schedule', a, extra) + return buildArgv(config, 'schedule', a, extra) }, }, { @@ -185,7 +232,7 @@ export function apply(ctx, config = {}) { project: { type: 'string', description: 'Project directory' }, all: { type: 'boolean', description: 'Remove every managed entry' }, }, - build: (a) => buildCommand(config, 'unschedule', a, a.all ? ['--all'] : []), + build: (a) => buildArgv(config, 'unschedule', a, a.all ? ['--all'] : []), }, ] @@ -197,20 +244,43 @@ export function apply(ctx, config = {}) { parameters: t.parameters, output: { schema: { type: 'string' }, render: renderOutput }, async execute(args, exec) { - const command = t.build(args || {}) + const argv = t.build(args || {}) + // `command` must be the shell-quoted form for the platform executor; + // resolve() applies the executor's workdir/output-cap/sandbox defaults. + const request = { + command: quoteArgv(argv), + timeoutMs: config.timeoutMs, + signal: exec?.signal, + } + const spec = typeof shell.resolve === 'function' ? shell.resolve(request) : request try { - const result = await shell.run({ - command, - timeoutMs: config.timeoutMs, - signal: exec?.signal, - }) - const status = result?.exitCode ?? 'signal' - const stdout = typeof result?.stdout === 'string' ? result.stdout : '' - const stderr = typeof result?.stderr === 'string' ? result.stderr : '' + const result = await shell.run(spec) + // Distinguish the executor's timeout (timedOut: true, exitCode null) + // from an abort/kill (exitCode null, no timedOut) so the marker is + // honest instead of lumping both under "signal". + const status = result?.timedOut + ? 'timeout' + : (result?.exitCode ?? 'signal') + // rc.8 returns stdout/stderr as CollectedOutput { text, truncated, spillPath } + const fmt = (co) => { + if (co === undefined || co === null) return '' + if (typeof co === 'string') return co + const parts = [] + if (co.text) parts.push(co.text) + if (co.truncated) { + parts.push(`[truncated${co.spillPath ? ` — full output at ${co.spillPath}` : ''}]`) + } + return parts.join('\n') + } + const stdout = fmt(result?.stdout) + const stderr = fmt(result?.stderr) const tail = [stdout, stderr].filter(Boolean).join('\n').trim() + // Do NOT slice here: fmt() already carries the executor's truncation + // marker + spill path when output was capped. A second slice would + // hide data the executor already bounded and contradict the marker. return [ `[skillopt ${t.name}] exit=${status}`, - tail ? tail.slice(0, 60_000) : '(no output)', + tail ? tail : '(no output)', ].join('\n') } catch (err) { return `[skillopt ${t.name}] engine call failed: ${err?.message || String(err)}` diff --git a/tests/test_plugin_sync.py b/tests/test_plugin_sync.py index e49a8994..6c2c6ba9 100644 --- a/tests/test_plugin_sync.py +++ b/tests/test_plugin_sync.py @@ -14,6 +14,7 @@ "claude-code": os.path.join(REPO, "plugins/claude-code/skills/skillopt-sleep/SKILL.md"), "codex": os.path.join(REPO, "plugins/codex/skills/skillopt-sleep/SKILL.md"), "cursor": os.path.join(REPO, "plugins/cursor/skills/skillopt-sleep/SKILL.md"), + "dsh": os.path.join(REPO, "plugins/dsh/skills/skillopt-sleep/SKILL.md"), "openclaw": os.path.join(REPO, "plugins/openclaw/SKILL.md"), } From 0601a57bff3da9db0164966a1923b870627dcf8d Mon Sep 17 00:00:00 2001 From: WODE25500 Date: Fri, 21 Aug 2026 07:51:06 +0800 Subject: [PATCH 03/12] Add LICENSE, portable test scripts; align README.zh.md and pack files with the established plugin pattern --- plugins/dsh/LICENSE | 21 ++++++ plugins/dsh/{docs => }/README.zh.md | 0 plugins/dsh/package.json | 1 - plugins/dsh/scripts/audit-injection.mjs | 31 +++++++++ plugins/dsh/scripts/test-quoting-bash.mjs | 78 +++++++++++++++++++++++ 5 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 plugins/dsh/LICENSE rename plugins/dsh/{docs => }/README.zh.md (100%) create mode 100644 plugins/dsh/scripts/audit-injection.mjs create mode 100644 plugins/dsh/scripts/test-quoting-bash.mjs diff --git a/plugins/dsh/LICENSE b/plugins/dsh/LICENSE new file mode 100644 index 00000000..cf7bcb2b --- /dev/null +++ b/plugins/dsh/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Microsoft Corporation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/dsh/docs/README.zh.md b/plugins/dsh/README.zh.md similarity index 100% rename from plugins/dsh/docs/README.zh.md rename to plugins/dsh/README.zh.md diff --git a/plugins/dsh/package.json b/plugins/dsh/package.json index 861ec05e..14f41f0f 100644 --- a/plugins/dsh/package.json +++ b/plugins/dsh/package.json @@ -7,7 +7,6 @@ "files": [ "src", "skills", - "docs", "scripts", "cordis.patch.yml", "README.md", diff --git a/plugins/dsh/scripts/audit-injection.mjs b/plugins/dsh/scripts/audit-injection.mjs new file mode 100644 index 00000000..33388b14 --- /dev/null +++ b/plugins/dsh/scripts/audit-injection.mjs @@ -0,0 +1,31 @@ +// 独立注入审计:各种恶意 payload 过 quoteArgv → 真实 bash → 验证不逃逸 +import { execFileSync } from 'node:child_process' +import { existsSync } from 'node:fs' +const m = await import('../src/index.js') +const { quoteArgv } = m +const BASH = 'C:/Program Files/Git/bin/bash.exe' +const payloads = [ + 'x; touch /tmp/pwned', + 'x$(touch /tmp/pwned2)', + 'x`touch /tmp/pwned3`', + 'x|cat /etc/passwd', + 'x&&rm -rf /', + "' OR 1=1 --", + 'x > /tmp/redirected', +] +let fail = 0 +for (const p of payloads) { + const argv = ['python', '-m', 'skillopt_sleep', 'run', '--preferences', p] + const quoted = quoteArgv(argv) + const script = `for a in ${quoted}; do printf '[%s]\\n' "$a"; done` + const out = execFileSync(BASH, ['-c', script], { encoding: 'utf8' }) + const args = out.trim().split('\n').map((l) => l.slice(1, -1)) + const pref = args[args.indexOf('--preferences') + 1] + const ok = pref === p + if (!ok) { fail++; console.log('FAIL:', JSON.stringify(p), '->', JSON.stringify(pref)) } +} +for (const f of ['/tmp/pwned', '/tmp/pwned2', '/tmp/pwned3', '/tmp/redirected', '/tmp/pwnedx']) { + if (existsSync(f)) { fail++; console.log('FILE CREATED:', f) } +} +console.log(fail === 0 ? 'ALL 7 INJECTION PAYLOADS INERT' : `${fail} FAILURES`) +process.exit(fail === 0 ? 0 : 1) diff --git a/plugins/dsh/scripts/test-quoting-bash.mjs b/plugins/dsh/scripts/test-quoting-bash.mjs new file mode 100644 index 00000000..8f1a63f3 --- /dev/null +++ b/plugins/dsh/scripts/test-quoting-bash.mjs @@ -0,0 +1,78 @@ +#!/usr/bin/env node +// Real-bash quoting verification for dsh-skillopt. +// +// The review asked for "Bash and pwsh tests for spaces, quotes, and +// metacharacters". This runs the plugin's quoteArgv() output through a REAL +// bash (Git Bash on Windows) and asserts the shell sees exactly one argument +// per argv element — spaces stay inside one argument, embedded quotes are +// preserved, and metacharacters cannot break out. +// +// Usage: node scripts/test-quoting-bash.mjs (requires Git Bash) +import { execFileSync } from 'node:child_process' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const require = createRequire(import.meta.url) +const root = dirname(dirname(fileURLToPath(import.meta.url))) +const BASH = process.env.BASH_PATH || 'C:/Program Files/Git/bin/bash.exe' + +const { quoteArgv } = await import(pathToFileURL(join(root, 'src/index.js')).href) + +let failures = 0 +function check(name, cond, detail = '') { + if (cond) console.log(` ✅ ${name}`) + else { + failures++ + console.log(` ❌ ${name}${detail ? ` — ${detail}` : ''}`) + } +} + +// Run a bash snippet that prints each received argument on its own line, +// then compare what the shell received against what we intended. +function bashRoundtrip(argv) { + const quoted = quoteArgv(argv) + // bash: for each arg, print a delimiter + the arg; newlines in args are + // escaped so the split stays unambiguous. + const script = `for a in ${quoted}; do printf '[%s]\\n' "$a"; done` + const out = execFileSync(BASH, ['-c', script], { encoding: 'utf8', cwd: root }) + return out.trim().split('\n').map((l) => l.replace(/^\[/, '').replace(/\]$/, '')) +} + +console.log('1. spaces stay inside one argument') +const spaced = ['python', '-m', 'skillopt_sleep', 'run', '--project', '/tmp/my proj', '--preferences', 'use async always'] +const got1 = bashRoundtrip(spaced) +check('path with space intact', got1[5] === '/tmp/my proj', JSON.stringify(got1)) +check('preference with space intact', got1[7] === 'use async always', JSON.stringify(got1)) + +console.log('2. embedded single quotes are preserved') +const quoted = ['python', '-m', 'skillopt_sleep', 'run', '--preferences', "never ' rm -rf /"] +const got2 = bashRoundtrip(quoted) +check("embedded quote preserved", got2[5] === "never ' rm -rf /", JSON.stringify(got2)) + +console.log('3. metacharacters cannot break out (injection attempt)') +const inject = ['python', '-m', 'skillopt_sleep', 'run', '--preferences', 'x; touch /tmp/dsh-injected; echo PWNED'] +const got3 = bashRoundtrip(inject) +// The injection must arrive as ONE literal argument, and the touch/echo must +// NOT have executed as shell commands. +check('injection stays one argument', got3[5] === 'x; touch /tmp/dsh-injected; echo PWNED', JSON.stringify(got3)) +// The bash loop prints the arg verbatim, so PWNED appears in the ARG text — +// the real assertion is that NO extra output line was produced (which would +// mean the `;` broke out and echo executed). +check('no extra output line from executed echo', got3.length === 6, `got ${got3.length} lines`) +// ensure no file was created by the injection attempt +const { existsSync } = await import('node:fs') +check('no /tmp/dsh-injected file created', !existsSync('/tmp/dsh-injected') && !existsSync('C:/tmp/dsh-injected')) + +console.log('4. double quotes and backticks are inert') +const backtick = ['python', '-m', 'skillopt_sleep', 'run', '--preferences', 'echo `id` $(whoami) "x"'] +const got4 = bashRoundtrip(backtick) +check('backticks/dollar stay literal', got4[5] === 'echo `id` $(whoami) "x"', JSON.stringify(got4)) + +console.log('5. empty and numeric values') +const mixed = ['python', '-m', 'skillopt_sleep', 'run', '--max-tasks', '40', '--hour', '3'] +const got5 = bashRoundtrip(mixed) +check('numbers intact', got5[5] === '40' && got5[7] === '3', JSON.stringify(got5)) + +console.log(failures === 0 ? '\nALL BASH QUOTING CHECKS PASSED' : `\n${failures} CHECK(S) FAILED`) +process.exit(failures === 0 ? 0 : 1) From 680a00407c48815eb8b2053c1e119b4298859b5c Mon Sep 17 00:00:00 2001 From: WODE25500 Date: Fri, 21 Aug 2026 08:31:24 +0800 Subject: [PATCH 04/12] Security: strip control chars in argv quoting (defense in depth) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Model-controlled values containing \\r, \\r\\n or other control characters would split a single-quoted word into multiple argv words (broken command, not RCE — quotes never execute), and corrupt the engine's arg parsing. Strip C0 control characters to a space so every value arrives as exactly one argument. Verified: new audit-control-chars.mjs covers \\n, \\r, \\r\\n, tab, NUL, backtick, quotes — all neutralized (single arg, no file, no execution). --- plugins/dsh/scripts/audit-control-chars.mjs | 41 +++++++++++++++++++++ plugins/dsh/src/index.js | 8 +++- 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 plugins/dsh/scripts/audit-control-chars.mjs diff --git a/plugins/dsh/scripts/audit-control-chars.mjs b/plugins/dsh/scripts/audit-control-chars.mjs new file mode 100644 index 00000000..1d916850 --- /dev/null +++ b/plugins/dsh/scripts/audit-control-chars.mjs @@ -0,0 +1,41 @@ +// 安全审核:控制字符/换行不能逃逸单引号边界,且不产生副作用。 +// 修复后的期望:控制字符被剥离为空格,值仍作为单个参数到达,无文件/命令执行。 +import { execFileSync } from 'node:child_process' +import { existsSync } from 'node:fs' +const m = await import('../src/index.js') +const { quoteArgv } = m +const BASH = 'C:/Program Files/Git/bin/bash.exe' +const payloads = [ + 'x\n touch /tmp/nl-pwned', + 'x\r echo PWNED', + 'x\ttab', + 'x`id`', + 'x\x00null', + "'; touch /tmp/semi-pwned;'", + 'normal\r\ntouch /tmp/crnl-pwned', +] +let fail = 0 +for (const p of payloads) { + const argv = ['python', '-m', 'skillopt_sleep', 'run', '--preferences', p] + const quoted = quoteArgv(argv) + const script = `for a in ${quoted}; do printf '[%s]\\n' "$a"; done` + const out = execFileSync(BASH, ['-c', script], { encoding: 'utf8' }) + const args = out.trim().split('\n').map((l) => l.slice(1, -1)) + // 安全断言 1:--preferences 后恰好一个参数(无参数分裂) + const idx = args.indexOf('--preferences') + const singleArg = idx >= 0 && idx + 1 < args.length && idx + 2 === args.length + // 安全断言 2:到达的值不含原始控制字符(已被剥离为空格) + const arrived = idx >= 0 ? args[idx + 1] : '' + const stripped = !/[\r\n\t\x00]/.test(arrived) + // 安全断言 3:没有注入命令出现在参数列表外 + const noInjection = !args.some((a) => /touch|PWNED|rm\s/.test(a) && a !== arrived) + if (!singleArg || !stripped || !noInjection) { + fail++ + console.log('FAIL:', JSON.stringify(p), '-> args:', JSON.stringify(args)) + } +} +for (const f of ['/tmp/nl-pwned', '/tmp/semi-pwned', '/tmp/crnl-pwned']) { + if (existsSync(f)) { fail++; console.log('FILE CREATED:', f) } +} +console.log(fail === 0 ? 'ALL CONTROL-CHAR PAYLOADS NEUTRALIZED' : `${fail} FAILURES`) +process.exit(fail === 0 ? 0 : 1) diff --git a/plugins/dsh/src/index.js b/plugins/dsh/src/index.js index ce4e5600..c533b363 100644 --- a/plugins/dsh/src/index.js +++ b/plugins/dsh/src/index.js @@ -68,8 +68,14 @@ export const Config = Schema.object({ // reopen quote) — the only portable POSIX spelling. PowerShell is not a target // here: dsh's ctx.shell executes via `bash -c` (LocalBashExecutor), so the // quoting only needs to be bash-correct. +// +// Control characters are stripped as defense in depth: \r and \r\n inside a +// single-quoted word would otherwise split the value into multiple argv words +// (broken command, not RCE — quotes never execute), and \n would corrupt the +// engine's own arg parsing. Model-controlled values must arrive as exactly +// one argument. function q(value) { - const s = String(value) + const s = String(value).replace(/[\r\n\u0000-\u001f\u007f]/g, ' ') return `'${s.replace(/'/g, "'\\''")}'` } From 437d0dabdf7ed9e6bcc62947f84294f3d2680442 Mon Sep 17 00:00:00 2001 From: WODE25500 Date: Fri, 21 Aug 2026 09:19:40 +0800 Subject: [PATCH 05/12] Fix dsh install command in READMEs: dsh is a global CLI, not a pnpm dependency The previous form 'pnpm dsh web --patch ...' made pnpm try to fetch a nonexistent @deepseek-ai/dsh-type-meta package and fail with 404. dsh is installed as a global CLI; the correct overlay invocation is 'dsh web --patch ./plugins/dsh/cordis.patch.yml' (verified with --dump-config). --- plugins/README.md | 2 +- plugins/dsh/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/README.md b/plugins/README.md index fa0ae490..2ae6bc50 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -35,7 +35,7 @@ for your workflow. | **Cursor** | `bash plugins/cursor/install.sh` (macOS/Linux) or `powershell -File plugins/cursor/install.ps1` (Windows) | `/skillopt-sleep status` | | **Copilot** | register `plugins/copilot/mcp_server.py` using its example MCP config | ask Copilot to run `sleep_status` | | **Devin** | register `plugins/devin/mcp_server.py` using its example MCP config | ask Devin to run `sleep_status` | -| **DeepSeek Harness** | add `dsh-skillopt` to the profile's bundles, or `pnpm dsh web --patch ./plugins/dsh/cordis.patch.yml` | ask the agent to use `skillopt_status` | +| **DeepSeek Harness** | add `dsh-skillopt` to the profile's bundles, or `dsh web --patch ./plugins/dsh/cordis.patch.yml` | ask the agent to use `skillopt_status` | | **OpenClaw** | follow and adapt [`openclaw/README.md`](openclaw/README.md) | validate paths, credentials, and tasks locally | Python 3.10 or newer is required. Real CLI backends also require the selected diff --git a/plugins/dsh/README.md b/plugins/dsh/README.md index c8fee942..e6c7e084 100644 --- a/plugins/dsh/README.md +++ b/plugins/dsh/README.md @@ -60,7 +60,7 @@ Add `dsh-skillopt` to the profile's bundles, or in the profile `cordis.patch.yml ### Local patch overlay (dev) ```bash -pnpm dsh web --patch ./plugins/dsh/cordis.patch.yml +dsh web --patch ./plugins/dsh/cordis.patch.yml ``` Then ask the agent: "Use skillopt_status to check the sleep cycle state." From f74e7e5d20722a0b2c6ccd6ea83172eab01eb6e4 Mon Sep 17 00:00:00 2001 From: WODE25500 Date: Fri, 21 Aug 2026 09:28:29 +0800 Subject: [PATCH 06/12] Security: enforce per-tool parameter whitelist (block undeclared arg injection) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dsh's parameter schema accepts undeclared properties by default (no additionalProperties:false), and buildArgv() forwarded both model-supplied values and operator config defaults for every known key to the engine. A model (or prompt-injected transcript) could therefore pass backend/model/ json/editBudget/etc. to tools that do not declare them — including skillopt_adopt, the live-change boundary. - buildArgv() now takes an explicit per-tool llowed key set; keys outside it are neither read from args nor filled from config defaults. - Each tool's build() passes exactly the keys it declares (whitelist). - canary.mjs: new 7b step asserts adopt drops undeclared backend/model/ maxTasks/json while keeping declared project; step 4 now drives the nonzero-exit path via preferences (a declared run parameter). - audit-*.mjs: BASH_PATH env override for non-Windows portability. --- plugins/dsh/scripts/audit-control-chars.mjs | 2 +- plugins/dsh/scripts/audit-injection.mjs | 2 +- plugins/dsh/scripts/canary.mjs | 20 +++++- plugins/dsh/src/index.js | 71 +++++++++++++-------- 4 files changed, 64 insertions(+), 31 deletions(-) diff --git a/plugins/dsh/scripts/audit-control-chars.mjs b/plugins/dsh/scripts/audit-control-chars.mjs index 1d916850..233c8b8e 100644 --- a/plugins/dsh/scripts/audit-control-chars.mjs +++ b/plugins/dsh/scripts/audit-control-chars.mjs @@ -4,7 +4,7 @@ import { execFileSync } from 'node:child_process' import { existsSync } from 'node:fs' const m = await import('../src/index.js') const { quoteArgv } = m -const BASH = 'C:/Program Files/Git/bin/bash.exe' +const BASH = process.env.BASH_PATH || 'C:/Program Files/Git/bin/bash.exe' const payloads = [ 'x\n touch /tmp/nl-pwned', 'x\r echo PWNED', diff --git a/plugins/dsh/scripts/audit-injection.mjs b/plugins/dsh/scripts/audit-injection.mjs index 33388b14..c53602d2 100644 --- a/plugins/dsh/scripts/audit-injection.mjs +++ b/plugins/dsh/scripts/audit-injection.mjs @@ -3,7 +3,7 @@ import { execFileSync } from 'node:child_process' import { existsSync } from 'node:fs' const m = await import('../src/index.js') const { quoteArgv } = m -const BASH = 'C:/Program Files/Git/bin/bash.exe' +const BASH = process.env.BASH_PATH || 'C:/Program Files/Git/bin/bash.exe' const payloads = [ 'x; touch /tmp/pwned', 'x$(touch /tmp/pwned2)', diff --git a/plugins/dsh/scripts/canary.mjs b/plugins/dsh/scripts/canary.mjs index fe46e52c..82ba4a94 100644 --- a/plugins/dsh/scripts/canary.mjs +++ b/plugins/dsh/scripts/canary.mjs @@ -134,8 +134,8 @@ check('shell.resolve used', called.resolve > 0, 'execute must go through resolve // 4. nonzero exit: stderr surfaced with exit code // --------------------------------------------------------------------------- console.log('4. nonzero exit surfaces stderr') -// model is a REAL parameter; a bogus model value makes the engine exit 2 -const bad = await defs['skillopt_run'].execute({ model: '--bad-model' }, {}) +// preferences is a REAL parameter of run; a bogus value makes the engine exit 2 +const bad = await defs['skillopt_run'].execute({ preferences: '--bad-model' }, {}) check('exit code surfaced', bad.includes('exit=2'), bad.slice(0, 150)) check('stderr text surfaced', bad.includes('unknown model'), bad.slice(0, 200)) @@ -205,5 +205,21 @@ await defs2['skillopt_run'].execute({ backend: 'mock' }, {}) const runCmd2 = cmds2.find((c) => c.includes("'run'")) check('operator config autoAdopt adds --auto-adopt', runCmd2 ? runCmd2.includes('--auto-adopt') : false, runCmd2 || 'no run command') +// --------------------------------------------------------------------------- +// 7b. undeclared parameters are filtered: the model cannot inject fields the +// tool does not declare (dsh's parameter schema allows extra properties by +// default, so the plugin's per-tool whitelist is what stops this). adopt +// declares only `project`; backend/model/maxTasks/json must not reach argv. +// --------------------------------------------------------------------------- +console.log('7b. undeclared tool parameters are filtered') +const before7b = called.commands.length +await defs['skillopt_adopt'].execute({ project: '/tmp/p', backend: 'codex', model: 'gpt-x', maxTasks: 99, json: true }, {}) +const adoptCmd7b = called.commands.slice(before7b).find((c) => c.includes("'adopt'")) +check('adopt keeps declared project', adoptCmd7b ? adoptCmd7b.includes("'--project'") : false, adoptCmd7b || 'no adopt command') +check('adopt drops undeclared backend', adoptCmd7b ? !adoptCmd7b.includes("'--backend'") : true, adoptCmd7b || 'no adopt command') +check('adopt drops undeclared model', adoptCmd7b ? !adoptCmd7b.includes("'--model'") : true) +check('adopt drops undeclared maxTasks', adoptCmd7b ? !adoptCmd7b.includes("'--max-tasks'") : true) +check('adopt drops undeclared json', adoptCmd7b ? !adoptCmd7b.includes("'--json'") : true) + console.log(failures === 0 ? '\nALL CHECKS PASSED' : `\n${failures} CHECK(S) FAILED`) process.exit(failures === 0 ? 0 : 1) diff --git a/plugins/dsh/src/index.js b/plugins/dsh/src/index.js index c533b363..8c186113 100644 --- a/plugins/dsh/src/index.js +++ b/plugins/dsh/src/index.js @@ -90,7 +90,7 @@ function q(value) { * package. `config.module` still works as a direct `python -m ` escape * hatch for users who prefer it. */ -function buildArgv(config, action, explicit = {}, extras = []) { +function buildArgv(config, action, explicit = {}, extras = [], allowed = null) { const parts = [config.pythonCmd || 'python'] if (config.module) { // explicit escape hatch: python -m @@ -105,25 +105,28 @@ function buildArgv(config, action, explicit = {}, extras = []) { if (value !== undefined && value !== null && value !== '') parts.push(flag, String(value)) } const has = (v) => v !== undefined && v !== null && v !== '' - if (has(explicit.project)) push('--project', explicit.project) - else push('--project', config.project) - if (has(explicit.scope)) push('--scope', explicit.scope) - else push('--scope', config.scope) - if (has(explicit.source)) push('--source', explicit.source) - else push('--source', config.source) - if (has(explicit.backend)) push('--backend', explicit.backend) - else push('--backend', config.backend) - if (has(explicit.model)) push('--model', explicit.model) - else push('--model', config.model) - if (has(explicit.maxTasks)) push('--max-tasks', explicit.maxTasks) - else push('--max-tasks', config.maxTasks) - if (has(explicit.maxSessions)) push('--max-sessions', explicit.maxSessions) - else push('--max-sessions', config.maxSessions) - if (has(explicit.editBudget)) push('--edit-budget', explicit.editBudget) - else push('--edit-budget', config.editBudget) - if (has(explicit.preferences)) push('--preferences', explicit.preferences) - else push('--preferences', config.preferences) - if (config.jsonOutput || explicit.json) parts.push('--json') + // `allowed` is the tool's declared parameter set (null = everything, the + // pre-whitelist behavior). Both the model-supplied value AND the operator + // config default are gated on it, so a tool like skillopt_adopt (declares + // only `project`) never receives --backend/--model/--json/… from either + // source — the config default must not leak into tools that do not declare + // the key. + const permits = (key) => !allowed || allowed.includes(key) + const withDefault = (key, flag) => { + if (!permits(key)) return + if (has(explicit[key])) push(flag, explicit[key]) + else push(flag, config[key]) + } + withDefault('project', '--project') + withDefault('scope', '--scope') + withDefault('source', '--source') + withDefault('backend', '--backend') + withDefault('model', '--model') + withDefault('maxTasks', '--max-tasks') + withDefault('maxSessions', '--max-sessions') + withDefault('editBudget', '--edit-budget') + withDefault('preferences', '--preferences') + if (permits('json') && (config.jsonOutput || explicit.json)) parts.push('--json') parts.push(...extras) return parts } @@ -133,6 +136,20 @@ function quoteArgv(argv) { return argv.map(q).join(' ') } +// Pick exactly the parameters a tool declares. dsh's parameter schema does +// not reject undeclared properties by default (no additionalProperties:false), +// so without this filter the model could inject fields (backend, model, json, +// editBudget, …) that buildArgv would forward to the engine — crossing the +// per-tool surface and, for skillopt_adopt, the live-change boundary. Each +// tool's build() must pass through exactly its declared keys. +function pick(obj, keys) { + const out = {} + for (const key of keys) { + if (obj[key] !== undefined) out[key] = obj[key] + } + return out +} + function renderOutput(_args, value) { return [{ type: 'text', text: value }] } @@ -153,7 +170,7 @@ export function apply(ctx, config = {}) { project: { type: 'string', description: 'Project directory (defaults to config.project or cwd)' }, json: { type: 'boolean', description: 'Emit machine-readable JSON' }, }, - build: (a) => buildArgv(config, 'status', a), + build: (a) => buildArgv(config, 'status', pick(a, ['project', 'json']), [], ['project', 'json']), }, { name: 'skillopt_dry_run', @@ -167,7 +184,7 @@ export function apply(ctx, config = {}) { maxTasks: { type: 'number', description: 'Cap mined tasks (default 40)' }, progress: { type: 'boolean', description: 'Print phase progress to stderr' }, }, - build: (a) => buildArgv(config, 'dry-run', a, a.progress ? ['--progress'] : []), + build: (a) => buildArgv(config, 'dry-run', pick(a, ['project', 'source', 'backend', 'model', 'maxTasks']), a.progress ? ['--progress'] : [], ['project', 'source', 'backend', 'model', 'maxTasks']), }, { name: 'skillopt_run', @@ -185,7 +202,7 @@ export function apply(ctx, config = {}) { // auto-adopt is OPERATOR-ONLY (config.autoAdopt); the model cannot set it. if (config.autoAdopt) extra.push('--auto-adopt') if (a.progress) extra.push('--progress') - return buildArgv(config, 'run', a, extra) + return buildArgv(config, 'run', pick(a, ['project', 'backend', 'source', 'preferences']), extra, ['project', 'backend', 'source', 'preferences']) }, }, { @@ -195,7 +212,7 @@ export function apply(ctx, config = {}) { parameters: { project: { type: 'string', description: 'Project directory' }, }, - build: (a) => buildArgv(config, 'adopt', a), + build: (a) => buildArgv(config, 'adopt', pick(a, ['project']), [], ['project']), }, { name: 'skillopt_harvest', @@ -210,7 +227,7 @@ export function apply(ctx, config = {}) { build: (a) => { const extra = [] if (a.output) extra.push('--output', a.output) - return buildArgv(config, 'harvest', a, extra) + return buildArgv(config, 'harvest', pick(a, ['project', 'source', 'maxTasks']), extra, ['project', 'source', 'maxTasks']) }, }, { @@ -227,7 +244,7 @@ export function apply(ctx, config = {}) { const extra = [] if (a.hour !== undefined) extra.push('--hour', String(a.hour)) if (a.minute !== undefined) extra.push('--minute', String(a.minute)) - return buildArgv(config, 'schedule', a, extra) + return buildArgv(config, 'schedule', pick(a, ['project', 'backend']), extra, ['project', 'backend']) }, }, { @@ -238,7 +255,7 @@ export function apply(ctx, config = {}) { project: { type: 'string', description: 'Project directory' }, all: { type: 'boolean', description: 'Remove every managed entry' }, }, - build: (a) => buildArgv(config, 'unschedule', a, a.all ? ['--all'] : []), + build: (a) => buildArgv(config, 'unschedule', pick(a, ['project']), a.all ? ['--all'] : [], ['project']), }, ] From 5c38fd0b6c1e10a7b4a89174f10d9e8477c32222 Mon Sep 17 00:00:00 2001 From: WODE25500 Date: Fri, 21 Aug 2026 09:34:43 +0800 Subject: [PATCH 07/12] Security: value-domain guard for path params; unschedule --all is operator-only The engine re-interpolates model-supplied values into its OWN shell command strings: scheduler.py splices --project into a crontab line and a Windows run.cmd executed by schtasks (no escaping), and write_tasks_file() turns an arbitrary --output into abspath+makedirs+overwrite. argv-level quoting in the plugin protects the dsh bash -c boundary but cannot protect those secondarysplices. A model-controlled project containing shell metacharacters (quote, ampersand, semicolon, pipe, dollar, backtick, angle brackets, braces, glob, control chars) would break out and execute as a separate command under thescheduler shell; an absolute or traversal output would overwrite an arbitrary file. - assertSafePath(): rejects shell metacharacters in project and output values. - assertSafeOutput(): refuses absolute paths and .. traversal for --output. - execute() runs both guards before buildArgv, so a bad value never reaches the engine; the rejection is returned to the model as tool output. - skillopt_unschedule: removed model-callable --all; now operator-only via config.unscheduleAll (same pattern as autoAdopt). - canary.mjs: new 7c step asserts injected project / absolute / traversal output are rejected and legit paths pass (32 checks total). --- plugins/dsh/scripts/canary.mjs | 25 ++++++++++++++ plugins/dsh/src/index.js | 63 ++++++++++++++++++++++++++++++++-- 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/plugins/dsh/scripts/canary.mjs b/plugins/dsh/scripts/canary.mjs index 82ba4a94..58257d32 100644 --- a/plugins/dsh/scripts/canary.mjs +++ b/plugins/dsh/scripts/canary.mjs @@ -221,5 +221,30 @@ check('adopt drops undeclared model', adoptCmd7b ? !adoptCmd7b.includes("'--mode check('adopt drops undeclared maxTasks', adoptCmd7b ? !adoptCmd7b.includes("'--max-tasks'") : true) check('adopt drops undeclared json', adoptCmd7b ? !adoptCmd7b.includes("'--json'") : true) +// --------------------------------------------------------------------------- +// 7c. value-domain guard: project/output with shell metacharacters are rejected +// before they reach the engine's own shell/crontab/schtasks interpolation +// (scheduler.py splices --project into a crontab line / Windows run.cmd, and +// write_tasks_file() writes --output to an arbitrary path). Legitimate values +// pass; metacharacter and traversal values are refused with an error message. +// --------------------------------------------------------------------------- +console.log('7c. path value-domain guard (engine re-interpolation / file write)') +const before7c = called.commands.length +// schedule with an injected project (would break out of the engine's own +// `--project "..."` splice and run a separate command under the scheduler) +const inj = await defs['skillopt_schedule'].execute({ project: 'C:/tmp/x" & echo PWNED > C:/tmp/pwned.txt & "', hour: 3 }, {}) +check('schedule rejects injected project', /rejected/.test(inj), inj.slice(0, 160)) +check('no schedule command reached the shell', called.commands.length === before7c) +// harvest output escaping the working area (absolute path / traversal) +const abs = await defs['skillopt_harvest'].execute({ project: '/tmp/p', output: 'C:/Windows/System32/drivers/etc/hosts' }, {}) +check('harvest rejects absolute output', /rejected/.test(abs), abs.slice(0, 160)) +const trav = await defs['skillopt_harvest'].execute({ project: '/tmp/p', output: '../../etc/hosts' }, {}) +check('harvest rejects traversal output', /rejected/.test(trav), trav.slice(0, 160)) +// legit values still pass through the guard +const ok7c = await defs['skillopt_harvest'].execute({ project: '/tmp/my proj', output: 'tasks.json', source: 'claude' }, {}) +const okCmd7c = called.commands.slice(before7c).find((c) => c.includes("'harvest'")) +check('legit project/output pass', !/rejected/.test(ok7c) && !!okCmd7c, ok7c.slice(0, 120)) +check('legit harvest cmd has project+output', okCmd7c ? okCmd7c.includes("'--output'") && okCmd7c.includes("'/tmp/my proj'") : false, okCmd7c || 'no harvest command') + console.log(failures === 0 ? '\nALL CHECKS PASSED' : `\n${failures} CHECK(S) FAILED`) process.exit(failures === 0 ? 0 : 1) diff --git a/plugins/dsh/src/index.js b/plugins/dsh/src/index.js index 8c186113..87ec72f7 100644 --- a/plugins/dsh/src/index.js +++ b/plugins/dsh/src/index.js @@ -57,6 +57,9 @@ export const Config = Schema.object({ timeoutMs: Schema.number() .default(600_000) .description('Per-call engine timeout in milliseconds (default 10 min)'), + unscheduleAll: Schema.boolean() + .default(false) + .description('OPERATOR-ONLY: allow skillopt_unschedule to remove every managed entry (--all). The model cannot set this.'), }) // --------------------------------------------------------------------------- @@ -150,6 +153,49 @@ function pick(obj, keys) { return out } +// Value-domain guard for model-supplied path-like strings. +// +// argv-level quoting (quoteArgv) protects the dsh `bash -c` boundary, but the +// engine re-interpolates these values into its OWN shell/command strings: +// scheduler.py builds `--project "{project}"` inside a crontab line and a +// Windows run.cmd executed by schtasks, and write_tasks_file() turns an +// arbitrary `output` into a file write (abspath + makedirs + overwrite). A +// model-controlled value containing `"`, `&`, `;`, `|`, `$`, backticks or +// other shell metacharacters would break out of that splice and execute as a +// separate command under the scheduler's shell, or overwrite an arbitrary +// file. Legitimate paths contain letters, digits, spaces, and `- _ . / \ :` +// only — reject everything else up front. +const UNSAFE_PATH = /["'&;|$`<>()\[\]{}*\u0000-\u001f\u007f]/ + +/** Throws on a path-like value carrying shell metacharacters. */ +function assertSafePath(value, what) { + if (value === undefined || value === null || value === '') return + if (UNSAFE_PATH.test(String(value))) { + throw new Error( + `[skillopt] ${what} rejected: contains shell metacharacters (` + + `" ' & ; | $ \` < > ( ) [ ] { } * or control chars). ` + + `Use a plain directory/file path.`, + ) + } +} + +/** + * Reject an output path that could write outside the working area: + * absolute paths and `..` traversal are refused; only a bare relative + * file name (or a simple relative path) is accepted. + */ +function assertSafeOutput(value) { + if (value === undefined || value === null || value === '') return + const s = String(value) + assertSafePath(s, 'output path') + if (s.startsWith('/') || s.startsWith('\\') || /^[A-Za-z]:[\\/]/.test(s) || s.includes('..')) { + throw new Error( + `[skillopt] output path rejected: absolute paths and ".." traversal are not allowed; ` + + `give a relative file name (e.g. "tasks.json").`, + ) + } +} + function renderOutput(_args, value) { return [{ type: 'text', text: value }] } @@ -253,9 +299,8 @@ export function apply(ctx, config = {}) { 'Remove the nightly cron entry for this project.', parameters: { project: { type: 'string', description: 'Project directory' }, - all: { type: 'boolean', description: 'Remove every managed entry' }, }, - build: (a) => buildArgv(config, 'unschedule', pick(a, ['project']), a.all ? ['--all'] : [], ['project']), + build: (a) => buildArgv(config, 'unschedule', pick(a, ['project']), config.unscheduleAll ? ['--all'] : [], ['project']), }, ] @@ -267,7 +312,19 @@ export function apply(ctx, config = {}) { parameters: t.parameters, output: { schema: { type: 'string' }, render: renderOutput }, async execute(args, exec) { - const argv = t.build(args || {}) + const a = args || {} + // Value-domain guard BEFORE building argv: project paths reach the + // engine's own shell/crontab/schtasks string interpolation and the + // filesystem; output writes a file. Model-controlled values with + // shell metacharacters (or output escaping the working area) are + // rejected here, never forwarded. + try { + assertSafePath(a.project, 'project') + assertSafeOutput(a.output) + } catch (err) { + return `[skillopt ${t.name}] ${err.message}` + } + const argv = t.build(a) // `command` must be the shell-quoted form for the platform executor; // resolve() applies the executor's workdir/output-cap/sandbox defaults. const request = { From 62e49c834b840120a23c1a068bcca932698eece2 Mon Sep 17 00:00:00 2001 From: WODE25500 Date: Fri, 21 Aug 2026 09:41:52 +0800 Subject: [PATCH 08/12] Security: clock range guard for schedule; pin dependency versions - schedule hour/minute were spliced by the engine into a crontab line and a schtasks start time without validation; out-of-range values (99, -1) would create broken scheduled entries. execute() now enforces hour in [0,23] and minute in [0,59] before building argv. - package.json: replace bare '*' dependency ranges with known-good pinned versions (@deepseek-ai/schemastery ^3.18.1, cordis ^4.0.1, dsh-tools ^0.1.0-rc.8) so installs are reproducible and not silently broken by a future upstream release. - canary.mjs: new 7d step asserts hour=99 / minute=-1 are rejected and legit clock values pass (35 checks total). --- plugins/dsh/package.json | 6 +++--- plugins/dsh/scripts/canary.mjs | 14 ++++++++++++++ plugins/dsh/src/index.js | 20 ++++++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/plugins/dsh/package.json b/plugins/dsh/package.json index 14f41f0f..b637ff4d 100644 --- a/plugins/dsh/package.json +++ b/plugins/dsh/package.json @@ -30,10 +30,10 @@ } }, "dependencies": { - "@deepseek-ai/schemastery": "*" + "@deepseek-ai/schemastery": "^3.18.1" }, "peerDependencies": { - "@deepseek-ai/cordis": "*", - "@deepseek-ai/dsh-tools": "*" + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.8" } } diff --git a/plugins/dsh/scripts/canary.mjs b/plugins/dsh/scripts/canary.mjs index 58257d32..a8e6fdf9 100644 --- a/plugins/dsh/scripts/canary.mjs +++ b/plugins/dsh/scripts/canary.mjs @@ -246,5 +246,19 @@ const okCmd7c = called.commands.slice(before7c).find((c) => c.includes("'harvest check('legit project/output pass', !/rejected/.test(ok7c) && !!okCmd7c, ok7c.slice(0, 120)) check('legit harvest cmd has project+output', okCmd7c ? okCmd7c.includes("'--output'") && okCmd7c.includes("'/tmp/my proj'") : false, okCmd7c || 'no harvest command') +// --------------------------------------------------------------------------- +// 7d. clock range guard: schedule's hour/minute are spliced by the engine into +// a crontab line and a schtasks start time without validation; out-of-range +// values would create broken scheduled entries. They must be rejected. +// --------------------------------------------------------------------------- +console.log('7d. schedule clock range guard') +const badHour = await defs['skillopt_schedule'].execute({ project: '/tmp/p', hour: 99, minute: 17 }, {}) +check('schedule rejects hour=99', /rejected/.test(badHour), badHour.slice(0, 140)) +const badMinute = await defs['skillopt_schedule'].execute({ project: '/tmp/p', hour: 3, minute: -1 }, {}) +check('schedule rejects minute=-1', /rejected/.test(badMinute), badMinute.slice(0, 140)) +const okSched = await defs['skillopt_schedule'].execute({ project: '/tmp/p', hour: 3, minute: 17 }, {}) +const okSchedCmd = called.commands.slice(-1)[0] +check('legit clock passes and reaches shell', !/rejected/.test(okSched) && !!okSchedCmd && okSchedCmd.includes("'--hour'") && okSchedCmd.includes("'--minute'"), okSched.slice(0, 120)) + console.log(failures === 0 ? '\nALL CHECKS PASSED' : `\n${failures} CHECK(S) FAILED`) process.exit(failures === 0 ? 0 : 1) diff --git a/plugins/dsh/src/index.js b/plugins/dsh/src/index.js index 87ec72f7..7e07a6e9 100644 --- a/plugins/dsh/src/index.js +++ b/plugins/dsh/src/index.js @@ -196,6 +196,22 @@ function assertSafeOutput(value) { } } +/** + * Range guard for schedule's clock parameters. The engine does not validate + * hour/minute itself and splices them straight into a crontab line and a + * schtasks start time; an out-of-range value (99, -1, …) would create a + * broken scheduled-task entry. Reject anything outside the real clock. + */ +function assertSafeClock(value, what, min, max) { + if (value === undefined || value === null || value === '') return + const n = Number(value) + if (!Number.isInteger(n) || n < min || n > max) { + throw new Error( + `[skillopt] ${what} rejected: must be an integer in [${min}, ${max}], got ${JSON.stringify(value)}.`, + ) + } +} + function renderOutput(_args, value) { return [{ type: 'text', text: value }] } @@ -321,6 +337,10 @@ export function apply(ctx, config = {}) { try { assertSafePath(a.project, 'project') assertSafeOutput(a.output) + // schedule clock params: the engine splices them into crontab / + // schtasks verbatim, so keep them inside the real clock range. + assertSafeClock(a.hour, 'hour', 0, 23) + assertSafeClock(a.minute, 'minute', 0, 59) } catch (err) { return `[skillopt ${t.name}] ${err.message}` } From 484b50c0c80cf63069ce6b2dee3975d51e9e854f Mon Sep 17 00:00:00 2001 From: WODE25500 Date: Fri, 21 Aug 2026 09:49:19 +0800 Subject: [PATCH 09/12] Align with DSH ecosystem plugin conventions; document both patch-invocation forms - package.json: add peerDependenciesMeta marking @deepseek-ai/cordis and @deepseek-ai/dsh-tools optional, matching the official ecosystem practice (dsh-office-tools et al. declare host-provided peers optional). Without it a plain 'npm install dsh-skillopt' would hard-fail when the host DSH version differs from the pinned peer range, instead of warning. - README.md / plugins/README.md: document BOTH overlay forms - 'pnpm dsh web --patch' for a DeepSeek Harness source checkout (the official dev workflow) and 'dsh web --patch' for a globally installed dsh. --- plugins/README.md | 2 +- plugins/dsh/README.md | 12 +++++++++++- plugins/dsh/package.json | 4 ++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/plugins/README.md b/plugins/README.md index 2ae6bc50..594aa189 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -35,7 +35,7 @@ for your workflow. | **Cursor** | `bash plugins/cursor/install.sh` (macOS/Linux) or `powershell -File plugins/cursor/install.ps1` (Windows) | `/skillopt-sleep status` | | **Copilot** | register `plugins/copilot/mcp_server.py` using its example MCP config | ask Copilot to run `sleep_status` | | **Devin** | register `plugins/devin/mcp_server.py` using its example MCP config | ask Devin to run `sleep_status` | -| **DeepSeek Harness** | add `dsh-skillopt` to the profile's bundles, or `dsh web --patch ./plugins/dsh/cordis.patch.yml` | ask the agent to use `skillopt_status` | +| **DeepSeek Harness** | add `dsh-skillopt` to the profile's bundles, or patch it in — from a DSH source checkout: `pnpm dsh web --patch ./plugins/dsh/cordis.patch.yml`; with global dsh: `dsh web --patch ./plugins/dsh/cordis.patch.yml` | ask the agent to use `skillopt_status` | | **OpenClaw** | follow and adapt [`openclaw/README.md`](openclaw/README.md) | validate paths, credentials, and tasks locally | Python 3.10 or newer is required. Real CLI backends also require the selected diff --git a/plugins/dsh/README.md b/plugins/dsh/README.md index e6c7e084..c6afb499 100644 --- a/plugins/dsh/README.md +++ b/plugins/dsh/README.md @@ -59,11 +59,21 @@ Add `dsh-skillopt` to the profile's bundles, or in the profile `cordis.patch.yml ### Local patch overlay (dev) +Run from a DeepSeek Harness **source checkout** (the official dev workflow, +`pnpm` resolves the workspace `dsh` bin): + +```bash +pnpm dsh web --patch ./plugins/dsh/cordis.patch.yml +``` + +If `dsh` is installed **globally** (npm install -g), use it directly: + ```bash dsh web --patch ./plugins/dsh/cordis.patch.yml ``` -Then ask the agent: "Use skillopt_status to check the sleep cycle state." +Either way the patch inserts the `skillopt` plugin row into the profile; then +ask the agent: "Use skillopt_status to check the sleep cycle state." ## Config keys diff --git a/plugins/dsh/package.json b/plugins/dsh/package.json index b637ff4d..ce6245da 100644 --- a/plugins/dsh/package.json +++ b/plugins/dsh/package.json @@ -35,5 +35,9 @@ "peerDependencies": { "@deepseek-ai/cordis": "^4.0.1", "@deepseek-ai/dsh-tools": "^0.1.0-rc.8" + }, + "peerDependenciesMeta": { + "@deepseek-ai/cordis": { "optional": true }, + "@deepseek-ai/dsh-tools": { "optional": true } } } From 9b9add1732ef426b5c9a85a98fbf256e58baa03c Mon Sep 17 00:00:00 2001 From: WODE25500 Date: Fri, 21 Aug 2026 09:51:13 +0800 Subject: [PATCH 10/12] Docs: fix parameter name in SKILL.md (maxTasks, not max_tasks) The skill's parameter table listed max_tasks (snake_case) but the tools declare maxTasks (camelCase); a model following the skill doc would send max_tasks and be rejected by dsh's parameter validation (undeclared property). --- plugins/dsh/skills/skillopt-sleep/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/dsh/skills/skillopt-sleep/SKILL.md b/plugins/dsh/skills/skillopt-sleep/SKILL.md index 138ee6ff..c12ccdd6 100644 --- a/plugins/dsh/skills/skillopt-sleep/SKILL.md +++ b/plugins/dsh/skills/skillopt-sleep/SKILL.md @@ -72,7 +72,7 @@ skillopt_schedule project= hour=3 minute=17 backend= | `backend` | `mock` | `mock\|claude\|codex\|copilot\|cursor\|pi\|opencode\|handoff\|azure_openai` (mock = no model calls) | | `source` | config | transcript source: `claude\|codex\|copilot\|cursor\|pi\|opencode\|auto` | | `model` | backend default | replay model override | -| `max_tasks` | 40 | mined-task cap | +| `maxTasks` | 40 | mined-task cap | | `preferences` | empty | house rules for the reflection prior (e.g. "always use async/await") | ## Configuration (cordis.yml / bundle patch) From 6db4cb16de5819a0ab5e1a30364d517e676ac604 Mon Sep 17 00:00:00 2001 From: WODE25500 Date: Fri, 21 Aug 2026 09:55:13 +0800 Subject: [PATCH 11/12] Canary: actually pack + extract and load the packed bundle (review requirement) The review asked for a clean-package canary that 'loads the packed bundle'. The previous canary verified the pack file list via --dry-run but then imported the plugin from the source tree. It now runs 'npm pack --json', extracts the tarball, and loads src/index.js FROM THE EXTRACTED package/ artifact for every step (register, status, error paths, quoting, whitelist, value guard, clock), so the artifact under test is exactly what the 'files' list ships. Tarball and scratch dir are removed on exit. --- plugins/dsh/scripts/canary.mjs | 43 +++++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/plugins/dsh/scripts/canary.mjs b/plugins/dsh/scripts/canary.mjs index a8e6fdf9..66a87d96 100644 --- a/plugins/dsh/scripts/canary.mjs +++ b/plugins/dsh/scripts/canary.mjs @@ -1,14 +1,17 @@ // dsh-skillopt canary — the clean-package check the SkillOpt review asked for. // // Packs the plugin with `npm pack --dry-run`, asserts the bundle manifest is -// complete (cordis.patch.yml present), loads the packed plugin into a mock -// Cordis context with a fake rc.8-shaped shell (CollectedOutput objects), and -// invokes every tool, asserting real stdout/exit/error behavior. +// complete (cordis.patch.yml present), then ACTUALLY packs it (npm pack), +// extracts the tarball, and loads the plugin FROM THE PACKED ARTIFACT into a +// mock Cordis context with a fake rc.8-shaped shell (CollectedOutput objects), +// invoking every tool and asserting real stdout/exit/error behavior. Loading +// the extracted bundle (not the source tree) is what the review's "loads the +// packed bundle" demands — the packed files are exactly what `files` ships. // -// Run: node scripts/canary.mjs +// Run: node scripts/canary.mjs (requires npm + the plugin's deps resolvable) import { execSync } from 'node:child_process' -import { readFileSync, existsSync } from 'node:fs' +import { mkdirSync, readFileSync, existsSync, rmSync } from 'node:fs' import { createRequire } from 'node:module' import { dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' @@ -42,6 +45,30 @@ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) check('dsh.bundle.patch points at packed file', packedFiles.includes(pkg.dsh?.bundle?.patch)) check('schemastery declared as direct dependency', !!pkg.dependencies?.['@deepseek-ai/schemastery']) +// --------------------------------------------------------------------------- +// 1b. ACTUAL pack + extract: the rest of the canary runs against the packed +// artifact (what `files` ships), not the source tree — the review asked for a +// canary that "loads the packed bundle". npm pack --json prints the tarball +// name; extract into a scratch dir inside root so the extracted module can +// still resolve @deepseek-ai/* deps up the tree. +// --------------------------------------------------------------------------- +console.log('1b. real pack + extract (canary runs against the packed artifact)') +const tarball = JSON.parse(execSync('npm pack --json', { cwd: root, encoding: 'utf8' }))[0].filename +check('npm pack produced a tarball', !!tarball && existsSync(join(root, tarball)), tarball || 'no tarball') +const scratch = join(root, '.canary-pack') +rmSync(scratch, { recursive: true, force: true }) +mkdirSync(scratch, { recursive: true }) +execSync(`tar -xzf "${tarball}" -C "${scratch}"`, { cwd: root, encoding: 'utf8' }) +const packedRoot = join(scratch, 'package') +check('extracted package/ contains src/index.js', existsSync(join(packedRoot, 'src/index.js'))) +check('extracted package/ contains cordis.patch.yml', existsSync(join(packedRoot, 'cordis.patch.yml'))) +check('extracted package.json matches files list', JSON.parse(readFileSync(join(packedRoot, 'package.json'), 'utf8')).name === pkg.name) +// Never leave the tarball or scratch dir behind. +process.on('exit', () => { + try { rmSync(join(root, tarball), { force: true }) } catch {} + try { rmSync(scratch, { recursive: true, force: true }) } catch {} +}) + // --------------------------------------------------------------------------- // 2. load the plugin against a mock rc.8-shaped shell // --------------------------------------------------------------------------- @@ -115,7 +142,7 @@ ctx.shell = { } ctx.logger = { info: () => {} } -const { apply } = await import(pathToFileURL(join(root, 'src/index.js')).href) +const { apply } = await import(pathToFileURL(join(packedRoot, 'src/index.js')).href) apply(ctx, { backend: 'mock' }) check('7 tools registered', Object.keys(defs).length === 7, `got ${Object.keys(defs).length}`) @@ -170,7 +197,7 @@ check('spill path present', trig.includes('C:/spill/stdout.log')) // 6. argv quoting: spaces and metacharacters cannot break out // --------------------------------------------------------------------------- console.log('6. argv quoting is shell-safe') -const { buildArgv, quoteArgv } = await import(pathToFileURL(join(root, 'src/index.js')).href) +const { buildArgv, quoteArgv } = await import(pathToFileURL(join(packedRoot, 'src/index.js')).href) // Verify quoting directly: a preference with spaces and metacharacters must stay // inside one argument (single-quoted, embedded quotes doubled). const argv = buildArgv({}, 'run', { preferences: "never ' rm -rf /" }) @@ -189,7 +216,7 @@ await defs['skillopt_run'].execute({ autoAdopt: true, backend: 'mock' }, {}) const runCmd = called.commands.slice(before).find((c) => c.includes("'run'")) check('model-supplied autoAdopt ignored', runCmd ? !runCmd.includes('--auto-adopt') : true, runCmd || 'no run command') // operator config enables it -const { apply: apply2 } = await import(pathToFileURL(join(root, 'src/index.js')).href) +const { apply: apply2 } = await import(pathToFileURL(join(packedRoot, 'src/index.js')).href) // re-apply with a fresh capture to check config-driven --auto-adopt const ctx2 = new Context() const defs2 = {} From db1b1e46763094404b445de71867d0094763ba80 Mon Sep 17 00:00:00 2001 From: WODE25500 Date: Fri, 21 Aug 2026 09:58:38 +0800 Subject: [PATCH 12/12] Docs: complete README config keys table (all schema keys, corrected module default) The config keys table now lists every Config schema key (added engineScript, scope, autoAdopt, unscheduleAll, timeoutMs) and no longer claims module defaults to 'skillopt_sleep' (the default path is the scripts/sleep.py bootstrap; module is an explicit override). --- plugins/dsh/README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/plugins/dsh/README.md b/plugins/dsh/README.md index c6afb499..ea034814 100644 --- a/plugins/dsh/README.md +++ b/plugins/dsh/README.md @@ -80,8 +80,10 @@ ask the agent: "Use skillopt_status to check the sleep cycle state." | Key | Default | Purpose | |---|---|---| | `pythonCmd` | `python` | Python interpreter for the engine | -| `module` | `skillopt_sleep` | engine Python module | +| `module` | — (bootstrap) | engine Python module override (`python -m `) | +| `engineScript` | — (scripts/sleep.py) | engine bootstrap script override | | `project` | — | default project directory | +| `scope` | — | harvest scope: `all` \| `invoked` | | `backend` | — | `mock\|claude\|codex\|copilot\|cursor\|pi\|opencode\|handoff\|azure_openai` | | `source` | — | `claude\|codex\|copilot\|cursor\|pi\|opencode\|auto` | | `model` | — | backend model override | @@ -89,6 +91,9 @@ ask the agent: "Use skillopt_status to check the sleep cycle state." | `editBudget` | — | bounded edits per cycle | | `preferences` | — | house rules for the reflection prior | | `jsonOutput` | `false` | machine-readable JSON output | +| `autoAdopt` | `false` | OPERATOR-ONLY: auto-adopt a passed proposal without asking | +| `unscheduleAll` | `false` | OPERATOR-ONLY: allow `skillopt_unschedule` to remove every managed entry | +| `timeoutMs` | `600000` | per-call engine timeout in milliseconds | Advanced engine keys (`gate_mode`, `gate_metric`, `gate_no_regression`, `dream_rollouts`, `recall_k`, `evolve_memory`/`evolve_skill`) go in