diff --git a/plugins/README.md b/plugins/README.md index 0a999c8c..594aa189 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 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/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/README.md b/plugins/dsh/README.md new file mode 100644 index 00000000..ea034814 --- /dev/null +++ b/plugins/dsh/README.md @@ -0,0 +1,119 @@ +# 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) + +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 +``` + +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 + +| Key | Default | Purpose | +|---|---|---| +| `pythonCmd` | `python` | Python interpreter for the engine | +| `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 | +| `maxTasks` / `maxSessions` | — | mine/harvest caps | +| `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 +`~/.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/README.zh.md b/plugins/dsh/README.zh.md new file mode 100644 index 00000000..f80dab04 --- /dev/null +++ b/plugins/dsh/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/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/package.json b/plugins/dsh/package.json new file mode 100644 index 00000000..ce6245da --- /dev/null +++ b/plugins/dsh/package.json @@ -0,0 +1,43 @@ +{ + "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", + "scripts", + "cordis.patch.yml", + "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" + } + }, + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "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 } + } +} diff --git a/plugins/dsh/scripts/audit-control-chars.mjs b/plugins/dsh/scripts/audit-control-chars.mjs new file mode 100644 index 00000000..233c8b8e --- /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 = process.env.BASH_PATH || '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/scripts/audit-injection.mjs b/plugins/dsh/scripts/audit-injection.mjs new file mode 100644 index 00000000..c53602d2 --- /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 = process.env.BASH_PATH || '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/canary.mjs b/plugins/dsh/scripts/canary.mjs new file mode 100644 index 00000000..66a87d96 --- /dev/null +++ b/plugins/dsh/scripts/canary.mjs @@ -0,0 +1,291 @@ +// 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), 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 (requires npm + the plugin's deps resolvable) + +import { execSync } from 'node:child_process' +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' + +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']) + +// --------------------------------------------------------------------------- +// 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 +// --------------------------------------------------------------------------- +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(packedRoot, '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') +// 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)) + +// --------------------------------------------------------------------------- +// 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(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 /" }) +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(packedRoot, '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') + +// --------------------------------------------------------------------------- +// 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) + +// --------------------------------------------------------------------------- +// 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') + +// --------------------------------------------------------------------------- +// 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/scripts/sleep.py b/plugins/dsh/scripts/sleep.py new file mode 100644 index 00000000..02585f30 --- /dev/null +++ b/plugins/dsh/scripts/sleep.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""dsh-skillopt — engine bootstrap helper (mirrors the official run-sleep.sh). + +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 + python scripts/sleep.py run --backend mock --project . + 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 + + +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: + 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() + + # 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__": + raise SystemExit(main()) 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) diff --git a/plugins/dsh/skills/skillopt-sleep/SKILL.md b/plugins/dsh/skills/skillopt-sleep/SKILL.md new file mode 100644 index 00000000..c12ccdd6 --- /dev/null +++ b/plugins/dsh/skills/skillopt-sleep/SKILL.md @@ -0,0 +1,121 @@ +--- +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: usage-driven self-evolution for the dsh agent + +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). + +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 + +- "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** — 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` | 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. check state (default mock backend, zero cost) +skillopt_status + +# 2. preview the cycle +skillopt_dry_run project= source= + +# 3. real run (consumes the selected backend's API budget) +skillopt_run project= backend= preferences="Prefer pytest; keep commits imperative." + +# 4. review the report, then adopt +skillopt_adopt project= + +# 5. schedule nightly at 03:17 +skillopt_schedule project= hour=3 minute=17 backend= +``` + +## Parameters + +| Parameter | Default | Meaning | +|---|---|---| +| `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 | +| `maxTasks` | 40 | mined-task cap | +| `preferences` | empty | house rules for the reflection prior (e.g. "always use async/await") | + +## Configuration (cordis.yml / bundle patch) + +```yaml +- insert: + - id: skillopt + name: './src/index.js' + config: + backend: codex + project: /path/to/project + preferences: 'Always use async/await' + # auto-adopt is OPERATOR-ONLY — the model cannot set it + autoAdopt: false +``` + +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 + +- **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. + +## 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. + +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 new file mode 100644 index 00000000..7e07a6e9 --- /dev/null +++ b/plugins/dsh/src/index.js @@ -0,0 +1,394 @@ +// 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' +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'] + +// --------------------------------------------------------------------------- +// Config (Schemastery) +// --------------------------------------------------------------------------- + +export const Config = Schema.object({ + pythonCmd: Schema.string() + .default('python') + .description('Python interpreter used to run the engine bootstrap (scripts/sleep.py)'), + module: Schema.string() + .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'), + 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'), + 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)'), + unscheduleAll: Schema.boolean() + .default(false) + .description('OPERATOR-ONLY: allow skillopt_unschedule to remove every managed entry (--all). The model cannot set this.'), +}) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// 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. +// +// 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).replace(/[\r\n\u0000-\u001f\u007f]/g, ' ') + 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 = [], allowed = null) { + 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)) + } + const has = (v) => v !== undefined && v !== null && v !== '' + // `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 +} + +/** Join argv with safe quoting for the platform shell. */ +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 +} + +// 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").`, + ) + } +} + +/** + * 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 }] +} + +// --------------------------------------------------------------------------- +// 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) => buildArgv(config, 'status', pick(a, ['project', 'json']), [], ['project', 'json']), + }, + { + 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) => buildArgv(config, 'dry-run', pick(a, ['project', 'source', 'backend', 'model', 'maxTasks']), a.progress ? ['--progress'] : [], ['project', 'source', 'backend', 'model', 'maxTasks']), + }, + { + 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' }, + progress: { type: 'boolean', description: 'Print phase progress to stderr' }, + }, + build: (a) => { + const extra = [] + // 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', pick(a, ['project', 'backend', 'source', 'preferences']), extra, ['project', 'backend', 'source', 'preferences']) + }, + }, + { + 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) => buildArgv(config, 'adopt', pick(a, ['project']), [], ['project']), + }, + { + 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 buildArgv(config, 'harvest', pick(a, ['project', 'source', 'maxTasks']), extra, ['project', 'source', 'maxTasks']) + }, + }, + { + 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 buildArgv(config, 'schedule', pick(a, ['project', 'backend']), extra, ['project', 'backend']) + }, + }, + { + name: 'skillopt_unschedule', + description: + 'Remove the nightly cron entry for this project.', + parameters: { + project: { type: 'string', description: 'Project directory' }, + }, + build: (a) => buildArgv(config, 'unschedule', pick(a, ['project']), config.unscheduleAll ? ['--all'] : [], ['project']), + }, + ] + + 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 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) + // 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}` + } + 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 = { + 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(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 : '(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)') +} 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"), }