6fc20f33c6
* 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.
* Fix dsh integration per review: safe argv, operator-only auto-adopt, parity tests, English skill
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.
* Add LICENSE, portable test scripts; align README.zh.md and pack files with the established plugin pattern
* Security: strip control chars in argv quoting (defense in depth)
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).
* 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).
* Security: enforce per-tool parameter whitelist (block undeclared arg injection)
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.
* 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).
* 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).
* 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.
* 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).
* 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.
* 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).
---------
Co-authored-by: WODE25500 <WODE25500@users.noreply.github.com>
243 lines
10 KiB
Python
243 lines
10 KiB
Python
"""Cross-plugin parity tests — ensure all plugins document the same features.
|
|
|
|
Run: python3 -m pytest tests/test_plugin_sync.py -v
|
|
"""
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import unittest
|
|
|
|
REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
|
|
PLUGIN_SKILL_MDS = {
|
|
"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"),
|
|
}
|
|
|
|
MCP_SERVER = os.path.join(REPO, "plugins/copilot/mcp_server.py")
|
|
COPILOT_README = os.path.join(REPO, "plugins/copilot/README.md")
|
|
COPILOT_INSTRUCTIONS = os.path.join(REPO, "plugins/copilot/copilot-instructions.snippet.md")
|
|
DEVIN_README = os.path.join(REPO, "plugins/devin/README.md")
|
|
DEVIN_RULES = os.path.join(REPO, "plugins/devin/devin-rules.snippet.md")
|
|
|
|
CANONICAL_BACKENDS = {"mock", "claude", "codex", "copilot"}
|
|
CURSOR_MANIFEST = os.path.join(REPO, "plugins/cursor/.cursor-plugin/plugin.json")
|
|
CURSOR_MARKETPLACE = os.path.join(REPO, ".cursor-plugin/marketplace.json")
|
|
CURSOR_COMMAND = os.path.join(REPO, "plugins/cursor/commands/skillopt-sleep.md")
|
|
CURSOR_README = os.path.join(REPO, "plugins/cursor/README.md")
|
|
CURSOR_INSTALL_SH = os.path.join(REPO, "plugins/cursor/install.sh")
|
|
CURSOR_INSTALL_PS1 = os.path.join(REPO, "plugins/cursor/install.ps1")
|
|
CURSOR_LICENSE = os.path.join(REPO, "plugins/cursor/LICENSE")
|
|
OPENCLAW_RUNNER = os.path.join(REPO, "plugins/openclaw/run_sleep.py")
|
|
|
|
|
|
def _read(path):
|
|
if not os.path.exists(path):
|
|
return ""
|
|
with open(path, encoding="utf-8") as f:
|
|
return f.read()
|
|
|
|
|
|
class TestPluginParity(unittest.TestCase):
|
|
def test_cursor_plugin_manifest_and_marketplace_registration(self):
|
|
with open(CURSOR_MANIFEST, encoding="utf-8") as f:
|
|
manifest = json.load(f)
|
|
with open(CURSOR_MARKETPLACE, encoding="utf-8") as f:
|
|
marketplace = json.load(f)
|
|
|
|
self.assertEqual(manifest["name"], "skillopt-sleep")
|
|
self.assertEqual(manifest["skills"], "./skills/")
|
|
self.assertEqual(manifest["commands"], "./commands/")
|
|
self.assertNotIn("hooks", manifest)
|
|
self.assertNotIn("mcpServers", manifest)
|
|
allowed_manifest_keys = {
|
|
"name", "displayName", "description", "version", "author",
|
|
"publisher", "homepage", "repository", "license", "logo",
|
|
"keywords", "category", "tags", "commands", "agents", "skills",
|
|
"rules", "hooks", "mcpServers",
|
|
}
|
|
self.assertEqual(set(manifest) - allowed_manifest_keys, set())
|
|
registered = next(
|
|
(plugin for plugin in marketplace["plugins"] if plugin.get("name") == "skillopt-sleep"),
|
|
None,
|
|
)
|
|
self.assertIsNotNone(registered)
|
|
self.assertEqual(registered["source"], "plugins/cursor")
|
|
self.assertEqual(registered["name"], manifest["name"])
|
|
self.assertTrue(os.path.isdir(os.path.join(REPO, registered["source"])))
|
|
|
|
def test_cursor_skill_has_frontmatter_target_and_cursor_guidance(self):
|
|
text = _read(PLUGIN_SKILL_MDS["cursor"])
|
|
self.assertTrue(text.startswith("---\n"))
|
|
self.assertIn("name: skillopt-sleep", text)
|
|
self.assertIn(".cursor/skills/skillopt-sleep-learned/SKILL.md", text)
|
|
self.assertIn("--source cursor", text)
|
|
self.assertIn("--backend cursor", text)
|
|
|
|
def test_cursor_command_is_thin_and_preserves_safety_defaults(self):
|
|
text = _read(CURSOR_COMMAND)
|
|
self.assertIn("$ARGUMENTS", text)
|
|
self.assertIn("use `status`", text)
|
|
self.assertIn("--source cursor", text)
|
|
self.assertIn("--scope invoked", text)
|
|
self.assertIn(".cursor/skills/skillopt-sleep-learned/SKILL.md", text)
|
|
self.assertIn("`mock` backend", text)
|
|
self.assertNotIn("--auto-adopt", text)
|
|
|
|
def test_cursor_installers_package_command_skill_readme_and_license(self):
|
|
for installer in (CURSOR_INSTALL_SH, CURSOR_INSTALL_PS1):
|
|
text = _read(installer)
|
|
for filename in (
|
|
"plugin.json",
|
|
"commands/skillopt-sleep.md" if installer.endswith(".sh") else "commands\\skillopt-sleep.md",
|
|
"skills/skillopt-sleep/SKILL.md" if installer.endswith(".sh") else "skills\\skillopt-sleep\\SKILL.md",
|
|
"README.md",
|
|
"LICENSE",
|
|
):
|
|
self.assertIn(filename, text, f"{installer} does not package {filename}")
|
|
|
|
self.assertEqual(_read(CURSOR_LICENSE), _read(os.path.join(REPO, "LICENSE")))
|
|
|
|
def test_cursor_docs_keep_scheduling_explicit_and_target_relative(self):
|
|
for path in (CURSOR_README, PLUGIN_SKILL_MDS["cursor"]):
|
|
text = _read(path)
|
|
self.assertIn('"target_skill_path": ".cursor/skills/', text)
|
|
self.assertIn("no session-end hook", text.lower())
|
|
self.assertIn("`tool_called`", text)
|
|
self.assertIn("temporarily disabled", text.lower())
|
|
self.assertIn("before agent mode", text.lower())
|
|
|
|
def test_openclaw_wrapper_matches_shared_backend_signature(self):
|
|
text = _read(OPENCLAW_RUNNER)
|
|
self.assertIn('claude_path="claude"', text)
|
|
self.assertIn('pi_path=""', text)
|
|
self.assertIn('cursor_path=""', text)
|
|
self.assertIn('opencode_path=""', text)
|
|
self.assertIn('azure_endpoint=""', text)
|
|
self.assertIn('project_dir=""', text)
|
|
self.assertIn("claude_path=claude_path", text)
|
|
self.assertIn("pi_path=pi_path", text)
|
|
self.assertIn("cursor_path=cursor_path", text)
|
|
self.assertIn("opencode_path=opencode_path", text)
|
|
self.assertIn("azure_endpoint=azure_endpoint", text)
|
|
self.assertIn("project_dir=project_dir", text)
|
|
self.assertNotIn("**kwargs", text)
|
|
|
|
script = f"""
|
|
import inspect
|
|
import runpy
|
|
import sys
|
|
sys.path.insert(0, {os.path.dirname(OPENCLAW_RUNNER)!r})
|
|
runner = runpy.run_path({OPENCLAW_RUNNER!r}, run_name="openclaw_runner_test")
|
|
wrapped_get_backend = runner["get_backend"]
|
|
assert inspect.signature(wrapped_get_backend).parameters["opencode_tool_replay"].default is False
|
|
|
|
enabled = wrapped_get_backend(
|
|
"opencode",
|
|
opencode_path="unused-opencode",
|
|
opencode_tool_replay=True,
|
|
)
|
|
assert enabled.tool_replay is True, enabled.tool_replay
|
|
|
|
stringly_enabled = wrapped_get_backend(
|
|
"opencode",
|
|
opencode_path="unused-opencode",
|
|
opencode_tool_replay="true",
|
|
)
|
|
assert stringly_enabled.tool_replay is False, stringly_enabled.tool_replay
|
|
|
|
from skillopt_sleep.backend import build_backend
|
|
backend = build_backend(
|
|
backend="mock",
|
|
pi_path="unused-pi",
|
|
opencode_path="unused-opencode",
|
|
azure_endpoint="https://unused.invalid",
|
|
)
|
|
assert backend.name == "mock", backend.name
|
|
"""
|
|
result = subprocess.run(
|
|
[sys.executable, "-c", script],
|
|
cwd=REPO,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
|
|
def test_all_skill_mds_mention_all_backends(self):
|
|
for name, path in PLUGIN_SKILL_MDS.items():
|
|
text = _read(path)
|
|
if not text:
|
|
self.skipTest(f"{name} SKILL.md not found")
|
|
for backend in CANONICAL_BACKENDS:
|
|
self.assertIn(backend, text,
|
|
f"{name}/SKILL.md missing backend '{backend}'")
|
|
|
|
def test_all_skill_mds_mention_schedule(self):
|
|
for name, path in PLUGIN_SKILL_MDS.items():
|
|
text = _read(path)
|
|
if not text:
|
|
continue
|
|
self.assertIn("schedule", text.lower(),
|
|
f"{name}/SKILL.md missing 'schedule'")
|
|
self.assertIn("unschedule", text.lower(),
|
|
f"{name}/SKILL.md missing 'unschedule'")
|
|
|
|
def test_copilot_instructions_mention_schedule(self):
|
|
text = _read(COPILOT_INSTRUCTIONS)
|
|
self.assertIn("sleep_schedule", text)
|
|
self.assertIn("sleep_unschedule", text)
|
|
|
|
def test_copilot_instructions_mention_all_backends(self):
|
|
text = _read(COPILOT_INSTRUCTIONS)
|
|
for backend in CANONICAL_BACKENDS:
|
|
self.assertIn(backend, text,
|
|
f"copilot-instructions missing backend '{backend}'")
|
|
|
|
def test_mcp_server_has_schedule_tools(self):
|
|
text = _read(MCP_SERVER)
|
|
self.assertIn("sleep_schedule", text)
|
|
self.assertIn("sleep_unschedule", text)
|
|
|
|
def test_mcp_schema_has_key_params(self):
|
|
text = _read(MCP_SERVER)
|
|
for param in ["source", "tasks_file", "target_skill_path",
|
|
"staging", "skills", "all_skills", "legacy",
|
|
"max_sessions", "max_tasks", "auto_adopt", "json"]:
|
|
self.assertIn(f'"{param}"', text,
|
|
f"MCP schema missing param '{param}'")
|
|
|
|
def test_mcp_adoption_docs_cover_fanout_selection(self):
|
|
for path in (COPILOT_README, COPILOT_INSTRUCTIONS, DEVIN_README, DEVIN_RULES):
|
|
text = _read(path)
|
|
for param in ("staging", "skills", "all_skills", "legacy"):
|
|
self.assertIn(f"`{param}`", text, f"{path} missing `{param}`")
|
|
|
|
def test_devin_docs_disclaim_post_adoption_copy(self):
|
|
for path in (DEVIN_README, DEVIN_RULES):
|
|
text = _read(path).lower()
|
|
self.assertIn("no post-adoption copy", text)
|
|
self.assertIn("target_skill_path", text)
|
|
self.assertIn("core engine", text)
|
|
|
|
def test_all_skill_mds_mention_memory_consolidation(self):
|
|
for name, path in PLUGIN_SKILL_MDS.items():
|
|
text = _read(path).lower()
|
|
if not text:
|
|
continue
|
|
has_mention = (
|
|
"memory consolidation" in text
|
|
or "evolve_memory" in text
|
|
or ("consolidate" in text and "memory" in text)
|
|
)
|
|
self.assertTrue(has_mention,
|
|
f"{name}/SKILL.md missing memory consolidation docs")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|