diff --git a/.dsh-plugin/README.md b/.dsh-plugin/README.md index e4745b9..0782f0c 100644 --- a/.dsh-plugin/README.md +++ b/.dsh-plugin/README.md @@ -77,8 +77,19 @@ SCD 管理但不可恢复,就 `agent.steer(...)` 一条纠正消息,让 Agen 管理,CLI 拒绝对它做 config-dump,但 home 级层对它同样生效。 - 统一只读检查器:`node scripts/verify-install.mjs --platform dsh` 读取 `$DSH_HOME/cordis.patch.yml` 与 `$DSH_HOME/profiles/*/cordis.patch.yml`, - 挂载行指向当前源码的 `.dsh-plugin/continuity.mjs` 时 `hooks` 检查为 - `PASS`;未挂载时保持 `MANUAL`(skills-only 仍是受支持安装形态)。 + 仅当静态结构是顶层、无目标 `id` 的 `insert` 列表,直接条目的 + `id: thinloop-continuity` 和 `name` 精确指向当前源码 handler,且未禁用时, + `hooks` 检查为 `PASS`。profile patch 与 home patch 按该顺序一起检查; + 不把不同 profile 当作叠加层,也不猜测当前运行的是哪个 profile。 + `PASS` 仅说明所列 patch 的静态插入配置,**不证明最终组合、插件已经加载 + 或事件已经执行**;bundle、CLI overlay 和运行时须通过上述组合检查与下面 + 的行为检查确认。 +- 检查器不依赖外部 YAML 包,只读取安装示例使用的 block 列表/映射、普通 + 或单/双引号标量、布尔值、空 `[]` / `{}` 和注释。无挂载、裸更新行、 + 重复 id/key、禁用行、嵌套 group、条件字段、无法读取或无效配置,以及 + 未支持的 YAML(如 flow collection、anchor/alias、tag、block scalar)保持 + `MANUAL`,不会凭路径文本猜为成功。任何覆盖操作也保守地要求组合检查; + 不支持的有效 YAML 不等于安装错误。skills-only 仍是受支持安装形态。 - 运行时行为:临时目录写入一份 `managed_by` 为 `scd-quickdev` 但缺章节的 `.scd/tasks/current.md`,在该目录运行 `dsh --profile headless "简单任务"`,确认 Agent 停止前被纠正消息打断、 diff --git a/config/platform-capabilities.json b/config/platform-capabilities.json index ff24ca1..a02ff9a 100644 --- a/config/platform-capabilities.json +++ b/config/platform-capabilities.json @@ -157,7 +157,7 @@ }, "verification": { "mode": "skill-links", - "summary": "十二个 Skill 链接均指向当前源码;新会话的 skill 工具可发现 `scd-next`、`scd-execute`、`scd-project` 与 `scd-quickdev`;只读检查器核对 home 级与 profile 级 `cordis.patch.yml` 中的挂载行,已挂载为 `PASS`,未挂载为 `MANUAL`" + "summary": "十二个 Skill 链接均指向当前源码;新会话的 skill 工具可发现 `scd-next`、`scd-execute`、`scd-project` 与 `scd-quickdev`;只读检查器核对 home 级与 profile 级 `cordis.patch.yml` 中明确启用的根级 `insert`,静态配置通过为 `PASS`,未挂载或组合不确定为 `MANUAL`;不证明运行时加载或事件执行" } }, { diff --git a/docs/installation.md b/docs/installation.md index bd05721..34be21f 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -158,6 +158,13 @@ Electron desktop 宿主与全部 agent preset),无需复制 preset;挂载 [`.dsh-plugin/README.md`](../.dsh-plugin/README.md)。DSH 未暴露第三方可用的 压缩前否决点,压缩后仍由 `AGENTS.md` 基线机制重新注入指令。 +`node scripts/verify-install.mjs --platform dsh` 只验证上述 `insert` 的静态 +结构及精确的 handler 路径,不以注释、裸行或禁用行作为挂载证据。检查器 +保守读取文档示例中的 YAML 子集;复杂语法、覆盖操作或不确定组合返回 +`MANUAL`。`PASS` 不代表运行时已加载或事件已执行,仍应通过 +`dsh --profile web --dump-config` 和插件 README 中的行为步骤核验。 + + ## Evolve 权威源码 `scd-evolve` 诊断阶段不需要源码配置;用户按候选 ID 批准实施后,必须通过本次 diff --git a/docs/verification.md b/docs/verification.md index 3ac0ef4..90e89df 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -72,7 +72,7 @@ node evals/knowledge/runner/run.mjs --mode full | Pi | 十二个 Skill 链接均指向当前源码;Pi RPC `get_commands` 可发现十二个 `/skill:scd-*` 命令 | | CodeWhale | 十二个 Skill 链接均指向当前源码;`codewhale doctor --json` 确认全局 Skill 根、数量且跳过实时 API 探测 | | Reasonix | 十二个 Skill 链接均指向当前源码;新会话可通过 `/scd-next`、`/scd-execute`、`/scd-project` 与 `/scd-quickdev` 调用 | -| DeepSeek Harness | 十二个 Skill 链接均指向当前源码;新会话的 skill 工具可发现 `scd-next`、`scd-execute`、`scd-project` 与 `scd-quickdev`;只读检查器核对 home 级与 profile 级 `cordis.patch.yml` 中的挂载行,已挂载为 `PASS`,未挂载为 `MANUAL` | +| DeepSeek Harness | 十二个 Skill 链接均指向当前源码;新会话的 skill 工具可发现 `scd-next`、`scd-execute`、`scd-project` 与 `scd-quickdev`;只读检查器核对 home 级与 profile 级 `cordis.patch.yml` 中明确启用的根级 `insert`,静态配置通过为 `PASS`,未挂载或组合不确定为 `MANUAL`;不证明运行时加载或事件执行 | | Claude Code | `claude plugin list --json` 提供版本、enabled 与安装路径;检查器从该路径核对十二个 Skill 和两个 Hook,包括 `scd-next` 与 `scd-execute` | | WorkBuddy | 不验证:WorkBuddy 无可靠只读 CLI 探测;已取消插件页核验要求 | | ZCode | `zcode plugins list --json` 提供 enabled、version、rootPath、skillCount 与 hookDetails;检查完整 Skill/Hook 载荷和两个可运行 Hook | diff --git a/scripts/dsh-patch.mjs b/scripts/dsh-patch.mjs new file mode 100644 index 0000000..cb449e5 --- /dev/null +++ b/scripts/dsh-patch.mjs @@ -0,0 +1,111 @@ +// A deliberately bounded, dependency-free reader for the documented DSH patch +// format. It is NOT a general YAML parser. Unsupported syntax fails closed so +// comments, aliases, tags, folded strings or malformed YAML cannot prove a mount. +export function readDshPatch(text) { + const fail = () => { throw new Error("unsupported or malformed patch YAML; verify with dsh --dump-config"); }; + const lines = []; + let ended = false; + let started = false; + for (const raw of text.replace(/^\uFEFF/, "").split(/\r?\n/)) { + if (/[\t\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f\ufffe\uffff]/.test(raw)) fail(); + let quote = null; + let end = raw.length; + for (let i = 0; i < raw.length; i++) { + const char = raw[i]; + if (quote === '"' && char === "\\") { i++; continue; } + if (quote === "'" && char === "'" && raw[i + 1] === "'") { i++; continue; } + if (quote) { if (char === quote) quote = null; } + else if (char === '"' || char === "'") quote = char; + else if (char === "#" && (i === 0 || raw[i - 1] === " ")) { end = i; break; } + } + if (quote) fail(); + const line = raw.slice(0, end).replace(/ +$/, ""); + if (!line) continue; + if (ended) fail(); + if (line === "---" && lines.length === 0 && !started) { started = true; continue; } + if (line === "...") { ended = true; continue; } + const indent = /^ */.exec(line)[0].length; + lines.push({ indent, text: line.slice(indent) }); + } + if (lines.length === 0) fail(); + if (lines.length === 1 && lines[0].text === "[]") return []; + let index = 0; + const scalar = value => { + if (value === "[]") return []; + if (value === "{}") return {}; + if (["null", "Null", "NULL", "~"].includes(value)) return null; + if (/^(true|True|TRUE|false|False|FALSE)$/.test(value)) return value.toLowerCase() === "true"; + if (/^-?(0|[1-9]\d*)(\.\d+)?$/.test(value)) return Number(value); + if (value.startsWith('"')) { + try { return JSON.parse(value); } catch { fail(); } + } + if (value.startsWith("'")) { + if (!/^'(?:[^']|'')*'$/.test(value)) fail(); + return value.slice(1, -1).replaceAll("''", "'"); + } + if (!value || /^[?:,\-\[\]{}#&*!|>'"%@`]/.test(value) || + /[\[\]{}]|:(?:\s|$)|\s[&*!|>]/.test(value)) fail(); + return value; + }; + function pair(text, indent, target) { + const match = /^([A-Za-z_][\w-]*):(?: +(.*))?$/.exec(text); + if (!match || ["__proto__", "constructor", "prototype", "__jsExpr"].includes(match[1]) || Object.hasOwn(target, match[1])) fail(); + const [, key, value] = match; + target[key] = value !== undefined ? scalar(value) : + lines[index]?.indent > indent ? block(lines[index].indent) : null; + } + function mapping(indent, first) { + const result = {}; + if (first !== undefined) pair(first, indent, result); + while (index < lines.length && lines[index].indent === indent && + !/^-(?: |$)/.test(lines[index].text)) { + pair(lines[index++].text, indent, result); + } + return result; + } + function block(indent) { + if (!/^-(?: |$)/.test(lines[index].text)) return mapping(indent); + const result = []; + while (index < lines.length && lines[index].indent === indent && + /^-(?: |$)/.test(lines[index].text)) { + const text = lines[index++].text; + if (text !== "-" && !/^- [^ ]/.test(text)) fail(); + const value = text.slice(1).replace(/^ +/, ""); + if (!value) result.push(lines[index]?.indent > indent ? block(lines[index].indent) : null); + else if (/^[A-Za-z_][\w-]*:(?: |$)/.test(value)) result.push(mapping(indent + 2, value)); + else result.push(scalar(value)); + } + return result; + } + const result = block(lines[0].indent); + if (index !== lines.length || !Array.isArray(result)) fail(); + return result; +} + +const object = value => value !== null && typeof value === "object" && !Array.isArray(value); + +/** + * Recognize unconditional root insertions only. This does not implement Cordis + * composition. Overrides, nested groups, conditional fields and duplicate ids + * require a real composition dump, even if another line names the right handler. + * Passing all applicable profile/home layers makes overrides fail closed too. + */ +export function hasDshInsertion(layers, rowId, handlerNames) { + const ids = new Set(); + let found = false; + for (const patches of layers) { + for (const patch of patches) { + if (!object(patch) || Object.keys(patch).length !== 1 || + !Array.isArray(patch.insert)) return false; + for (const entry of patch.insert) { + if (!object(entry) || typeof entry.id !== "string" || !entry.id || + typeof entry.name !== "string" || !entry.name || ids.has(entry.id) || + entry.group || Object.keys(entry).some(key => + !["id", "name", "disabled", "group", "config"].includes(key))) return false; + ids.add(entry.id); + if (entry.id === rowId && handlerNames.includes(entry.name) && !entry.disabled) found = true; + } + } + } + return found; +} diff --git a/scripts/plugin-list.mjs b/scripts/plugin-list.mjs new file mode 100644 index 0000000..c6441e9 --- /dev/null +++ b/scripts/plugin-list.mjs @@ -0,0 +1,6 @@ +/** Normalize the two observed ZCode CLI shapes without accepting malformed lists. */ +export function pluginList(platformId, response) { + if (Array.isArray(response)) return response; + if (platformId === "zcode" && Array.isArray(response?.plugins)) return response.plugins; + return null; +} diff --git a/scripts/refresh-install.mjs b/scripts/refresh-install.mjs index a2c5462..aa4f3bd 100644 --- a/scripts/refresh-install.mjs +++ b/scripts/refresh-install.mjs @@ -6,6 +6,7 @@ import path from "node:path"; import { spawn, spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { inspectInstallations } from "./verify-install.mjs"; +import { pluginList } from "./plugin-list.mjs"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const pluginId = "thinloop@thinloop"; @@ -98,8 +99,8 @@ export async function refreshInstallation({ } } else { const response = JSON.parse(runCommand(platform.verification.command, context)); - const plugins = platformId === "zcode" ? response.plugins : response; - const matches = Array.isArray(plugins) ? plugins.filter(entry => entry.id === pluginId) : []; + const plugins = pluginList(platformId, response); + const matches = Array.isArray(plugins) ? plugins.filter(entry => entry?.id === pluginId) : []; if (matches.length !== 1 || matches[0].enabled !== true) { throw new Error("Thinloop must already be installed and enabled; no installation or enablement was attempted"); } diff --git a/scripts/verify-install.mjs b/scripts/verify-install.mjs index 4030bd5..a038df3 100644 --- a/scripts/verify-install.mjs +++ b/scripts/verify-install.mjs @@ -6,6 +6,8 @@ import path from "node:path"; import { spawnSync } from "node:child_process"; import { fileURLToPath, pathToFileURL } from "node:url"; import { isDeepStrictEqual } from "node:util"; +import { pluginList } from "./plugin-list.mjs"; +import { readDshPatch, hasDshInsertion } from "./dsh-patch.mjs"; const SCRIPT_ROOT = path.resolve( path.dirname(fileURLToPath(import.meta.url)), @@ -364,12 +366,9 @@ function inspectSkillLinks(platform, expected, homeDir, environment) { } /** - * Read-only inspection of a Cordis-plugin host mount: the DSH user patch layers - * (`$DSH_HOME/cordis.patch.yml` plus every profile's own `cordis.patch.yml`) - * are scanned for a row naming the source checkout's hook handler. A mounted - * row proves the composition inserts the plugin; no CLI probe is run. An - * absent row stays MANUAL because a skills-only install remains a supported - * state, not a confirmed failure. + * Read-only, static evidence of an unconditional root insert in DSH user patch + * layers. Unsupported YAML/composition stays MANUAL. This cannot establish the + * selected runtime profile, CLI overlays, successful boot or event execution. */ function inspectHookMount(platform, expected, context) { const mount = hookMountDescriptors(platform)[0]; @@ -408,35 +407,45 @@ function inspectHookMount(platform, expected, context) { } } - const mountedIn = []; - const unreadable = []; + const patches = new Map(); + const unknown = []; for (const candidate of candidates) { - let text; try { - text = fs.readFileSync(candidate, "utf8"); + patches.set(candidate, readDshPatch(fs.readFileSync(candidate, "utf8"))); } catch (error) { - if (error?.code === "ENOENT") continue; - unreadable.push(`${candidate}: ${error.message}`); - continue; - } - if (text.includes(handlerPath) || text.includes(handlerUrl)) { - mountedIn.push(candidate); + if (error?.code === "ENOENT") { patches.set(candidate, []); continue; } + unknown.push(`${candidate}: ${error.message}`); } } + // Profiles are alternatives, not sequential overlays. Each receives the home + // patch after its own patch. A bare update/unsupported operation in either + // layer prevents that combination from proving an unconditional insertion. + const homePatch = path.join(dshHome, "cordis.patch.yml"); + const home = patches.get(homePatch) || []; + const mountedIn = []; + if (unknown.length === 0) { + if (patches.size === 1 && hasDshInsertion([home], mount.row, [handlerPath, handlerUrl])) mountedIn.push(homePatch); + for (const [candidate, patch] of patches) { + if (candidate !== homePatch && + hasDshInsertion([patch, home], mount.row, [handlerPath, handlerUrl])) { + mountedIn.push(home.length ? `${candidate} + ${homePatch}` : candidate); + } + } + } if (mountedIn.length > 0) { return makeCheck( "hooks", "PASS", - `${platform.capabilities.hooks.length}/${platform.capabilities.hooks.length} Cordis plugin mounted via ${mountedIn.join(", ")}; loaded at profile boot (restart applies profile patches)`, + `${platform.capabilities.hooks.length}/${platform.capabilities.hooks.length} static Cordis root insert configured via ${mountedIn.join(", ")}; runtime composition, activation and events not verified`, ); } return makeCheck( "hooks", "MANUAL", - unreadable.length > 0 - ? `plugin mount unknown (${unreadable.join("; ")}); mount per .dsh-plugin/README.md` - : `plugin not mounted in any ${mount.patchFiles.join(" or ")} under ${dshHome}; skills-only install remains supported — mount per .dsh-plugin/README.md`, + unknown.length > 0 + ? `plugin mount unknown (${unknown.join("; ")}); mount per .dsh-plugin/README.md` + : `no unconditional root insert verified in ${mount.patchFiles.join(" or ")} under ${dshHome}; skills-only install remains supported; overrides or complex YAML require dsh --dump-config; see .dsh-plugin/README.md`, ); } @@ -727,7 +736,7 @@ function inspectPlugin(platform, expected, runCommand, context) { let response; try { response = JSON.parse(commandResult.stdout); - plugins = platform.id === "zcode" ? response?.plugins : response; + plugins = pluginList(platform.id, response); } catch { return singleCheckResult( platform, diff --git a/tests/dsh-patch.test.mjs b/tests/dsh-patch.test.mjs new file mode 100644 index 0000000..14d5575 --- /dev/null +++ b/tests/dsh-patch.test.mjs @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { readDshPatch, hasDshInsertion } from "../scripts/dsh-patch.mjs"; + +const handler = "file:///tmp/thinloop/.dsh-plugin/continuity.mjs"; +const mount = `- insert:\n - id: thinloop-continuity\n name: ${handler}\n`; +const recognizes = text => hasDshInsertion([readDshPatch(text)], "thinloop-continuity", [handler]); + +test("DSH bounded reader handles documented block structure and quoted scalars", () => { + assert.deepEqual(readDshPatch(`# comment\n---\n${mount} disabled: false\n config:\n note: 'it''s # a value' # comment\n items:\n - one\n - "two"\n empty: {}\n...\n`), [{ insert: [{ + id: "thinloop-continuity", name: handler, disabled: false, + config: { note: "it's # a value", items: ["one", "two"], empty: {} }, + }] }]); + for (const text of ["[]", "---\n[]\n..."]) assert.deepEqual(readDshPatch(text), []); +}); + +for (const [label, text] of [ + ["empty file", ""], + ["comment-only file", "# comment\n"], + ["empty document", "---\n...\n"], + ["flow-style maps", `- insert: [{ id: thinloop-continuity, name: ${handler} }]`], + ["block scalar", `${mount} config: |\n arbitrary text\n`], + ["alias", `${mount} config: *settings\n`], + ["anchor", `${mount} config: &settings false\n`], + ["tag", `${mount} disabled: !!js false\n`], + ["broken quote", `${mount} config: "broken\n`], + ["duplicate key", `${mount} name: other\n`], + ["wrong indentation", `${mount} disabled: true\n`], + ["plain scalar trailing colon", `${mount} config: value:\n`], + ["non-ASCII indentation", mount.replaceAll(" ", "\u00a0\u00a0\u00a0\u00a0")], + ["invalid control character", `${mount} config: \u007f\n`], + ["invalid Unicode noncharacter", `${mount} config: bad\ufffevalue\n`], + ["tabs", mount.replace(" -", "\t-")], + ["second document", `${mount}---\n[]\n`], + ["leading empty document", `---\n---\n${mount}`], + ["content after end", `${mount}...\n[]\n`], + ["multiple spaces after dash", mount.replace("- insert", "- insert")], + ["expression object", `${mount} config:\n __jsExpr: does.not.exist()\n`], + ["prototype key", `${mount} __proto__:\n disabled: false\n`], + ["non-list root", `insert:\n - id: thinloop-continuity\n name: ${handler}\n`], +]) { + test(`DSH bounded reader rejects ${label}`, () => { + assert.throws(() => readDshPatch(text), /unsupported or malformed patch YAML/); + }); +} + +for (const value of ["false", "False", "FALSE", "null", "~", "0"]) { + test(`DSH disabled ${value} permits a static insertion`, () => { + assert.equal(recognizes(`${mount} disabled: ${value}\n`), true); + }); +} +for (const value of ["true", '"false"', "FaLsE", "yes", "1", "false\u00a0"]) { + test(`DSH disabled ${value} cannot prove a mount`, () => { + assert.equal(recognizes(`${mount} disabled: ${value}\n`), false); + }); +} + +test("DSH config does not act as a host-level enabled flag", () => { + assert.equal(recognizes(`${mount} config: false\n`), true); + assert.equal(recognizes(`${mount} config:\n enabled: false\n`), true); +}); + +test("DSH unknown entry semantics, nested groups, duplicate ids and overrides fail closed", () => { + for (const suffix of [" group: true\n", " inject: {}\n", " isolate: {}\n", " intercept: {}\n", " unknown: false\n"]) { + assert.equal(recognizes(mount + suffix), false); + } + assert.equal(recognizes(mount + mount), false); + assert.equal(recognizes(`${mount}- id: thinloop-continuity\n disabled: true\n`), false); + assert.equal(recognizes(`- id: group\n insert:\n - id: thinloop-continuity\n name: ${handler}\n`), false); +}); diff --git a/tests/fixtures/zcode/README.md b/tests/fixtures/zcode/README.md new file mode 100644 index 0000000..3edccde --- /dev/null +++ b/tests/fixtures/zcode/README.md @@ -0,0 +1,9 @@ +# ZCode plugin-list compatibility fixtures + +These are representative, sanitized fixtures, not captured private CLI output. +`array.json` models the top-level list reported in Thinloop issue #104: +https://github.com/mindcarver/thinloop/issues/104 +`envelope.json` preserves the previously supported `{ plugins, diagnostics }` +shape. Both retain the installation fields checked by Thinloop; paths/version +are substituted at test time. An unrelated plugin is included to exercise +selection rather than treating the first list entry as Thinloop. diff --git a/tests/fixtures/zcode/array.json b/tests/fixtures/zcode/array.json new file mode 100644 index 0000000..c3b5d99 --- /dev/null +++ b/tests/fixtures/zcode/array.json @@ -0,0 +1,29 @@ +[ + { + "id": "other@market", + "enabled": false, + "version": "1.0.0", + "rootPath": "/unrelated/plugin" + }, + { + "id": "thinloop@thinloop", + "enabled": true, + "version": "$VERSION", + "rootPath": "$INSTALL_ROOT", + "manifestPath": "$INSTALL_ROOT/.zcode-plugin/plugin.json", + "skillCount": 12, + "hookDetails": [ + { + "event": "SessionStart", + "matcher": "compact", + "runnable": true, + "sourcePath": "$INSTALL_ROOT/hooks/hooks.zcode.json" + }, + { + "event": "Stop", + "runnable": true, + "sourcePath": "$INSTALL_ROOT/hooks/hooks.json" + } + ] + } +] diff --git a/tests/fixtures/zcode/envelope.json b/tests/fixtures/zcode/envelope.json new file mode 100644 index 0000000..ccfbd78 --- /dev/null +++ b/tests/fixtures/zcode/envelope.json @@ -0,0 +1,39 @@ +{ + "cwd": "$SOURCE_ROOT", + "plugins": [ + { + "id": "other@market", + "enabled": false, + "version": "1.0.0", + "rootPath": "/unrelated/plugin" + }, + { + "id": "thinloop@thinloop", + "enabled": true, + "version": "$VERSION", + "rootPath": "$INSTALL_ROOT", + "manifestPath": "$INSTALL_ROOT/.zcode-plugin/plugin.json", + "skillCount": 12, + "hookDetails": [ + { + "event": "SessionStart", + "matcher": "compact", + "runnable": true, + "sourcePath": "$INSTALL_ROOT/hooks/hooks.zcode.json" + }, + { + "event": "Stop", + "runnable": true, + "sourcePath": "$INSTALL_ROOT/hooks/hooks.json" + } + ] + } + ], + "diagnostics": [ + { + "pluginId": "other@market", + "severity": "error", + "message": "unrelated fixture diagnostic" + } + ] +} diff --git a/tests/helpers/zcode-fixture.mjs b/tests/helpers/zcode-fixture.mjs new file mode 100644 index 0000000..0ff346a --- /dev/null +++ b/tests/helpers/zcode-fixture.mjs @@ -0,0 +1,14 @@ +import fs from "node:fs"; +import path from "node:path"; + +export function zcodeFixture(shape, { installPath, version, sourceRoot }) { + return JSON.parse(fs.readFileSync(new URL(`../fixtures/zcode/${shape}.json`, import.meta.url), "utf8"), (key, value) => { + if (value === "$VERSION") return version; + if (value === "$SOURCE_ROOT") return sourceRoot; + if (value === "$INSTALL_ROOT") return installPath; + if (typeof value === "string" && value.startsWith("$INSTALL_ROOT/")) { + return path.join(installPath, ...value.slice("$INSTALL_ROOT/".length).split("/")); + } + return value; + }); +} diff --git a/tests/platform-capabilities.test.mjs b/tests/platform-capabilities.test.mjs index b6bbb01..b4f6dce 100644 --- a/tests/platform-capabilities.test.mjs +++ b/tests/platform-capabilities.test.mjs @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; import test from "node:test"; +import { zcodeFixture } from "./helpers/zcode-fixture.mjs"; import { fileURLToPath, pathToFileURL } from "node:url"; import { @@ -160,6 +161,11 @@ function codeWhaleReport(homeDir, environment = {}) { function pluginRunner(recordsByExecutable) { return ([executable], context = {}) => { + // An omitted fixture means the CLI is unavailable, not an installed CLI + // reporting an empty list (which is valid evidence of a missing plugin). + if (executable === "zcode" && !Object.hasOwn(recordsByExecutable, executable)) { + return { status: null, error: Object.assign(new Error("zcode unavailable"), { code: "ENOENT" }) }; + } const output = Object.hasOwn(recordsByExecutable, executable) ? recordsByExecutable[executable] : executable === "codewhale" @@ -910,7 +916,7 @@ test("DeepSeek Harness hook mount check passes when the home patch names the sou fs.mkdirSync(dshHome, { recursive: true }); fs.writeFileSync( path.join(dshHome, "cordis.patch.yml"), - `- id: thinloop-continuity\n name: ${pathToFileURL( + `- insert:\n - id: thinloop-continuity\n name: ${pathToFileURL( path.join(root, ".dsh-plugin", "continuity.mjs"), ).href}\n`, ); @@ -932,6 +938,7 @@ test("DeepSeek Harness hook mount check passes when the home patch names the sou ); assert.equal(hooks.status, "PASS"); assert.match(hooks.detail, new RegExp(escapeRegex("cordis.patch.yml"))); + assert.match(hooks.detail, /static.*runtime.*not verified/i); } finally { fs.rmSync(homeDir, { recursive: true, force: true }); } @@ -945,7 +952,7 @@ test("DeepSeek Harness hook mount check accepts a profile patch with an absolute fs.mkdirSync(profileDir, { recursive: true }); fs.writeFileSync( path.join(profileDir, "cordis.patch.yml"), - `- id: thinloop-continuity\n name: ${path.join(root, ".dsh-plugin", "continuity.mjs")}\n`, + `- insert:\n - id: thinloop-continuity\n name: ${path.join(root, ".dsh-plugin", "continuity.mjs")}\n`, ); const report = inspectInstallations({ registryPath, @@ -985,7 +992,7 @@ test("DeepSeek Harness hook mount check ignores bundle patches and foreign rows" fs.mkdirSync(bundlePatch, { recursive: true }); fs.writeFileSync( path.join(bundlePatch, "cordis.patch.yml"), - `- id: thinloop-continuity\n name: ${path.join( + `- insert:\n - id: thinloop-continuity\n name: ${path.join( homeDir, "elsewhere", ".dsh-plugin", @@ -1019,7 +1026,7 @@ test("DeepSeek Harness hook mount honors DSH_HOME", () => { fs.mkdirSync(dshHome, { recursive: true }); fs.writeFileSync( path.join(dshHome, "cordis.patch.yml"), - `- id: thinloop-continuity\n name: ${pathToFileURL( + `- insert:\n - id: thinloop-continuity\n name: ${pathToFileURL( path.join(root, ".dsh-plugin", "continuity.mjs"), ).href}\n`, ); @@ -1150,17 +1157,19 @@ for (const [label, mutate, expected] of [ ["hook matcher wrong", p => { p.hookDetails[0].matcher = "other"; }, "FAIL"], ["hook source wrong", p => { p.hookDetails[0].sourcePath = "/wrong/hooks.json"; }, "FAIL"], ]) { - test(`ZCode ${label} does not produce false PASS`, () => { - const installPath = makePluginInstall("zcode"); - try { - const record = zcodeRecord(installPath); - mutate(record); - const report = inspectInstallations({ registryPath, sourceRoot: root, platformId: "zcode", - runCommand: pluginRunner({ zcode: { plugins: [record], diagnostics: [] } }), - }); - assert.equal(report.results[0].status, expected); - } finally { fs.rmSync(installPath, { recursive: true, force: true }); } - }); + for (const shape of ["array", "envelope"]) { + test(`ZCode ${shape} ${label} does not produce false PASS`, () => { + const installPath = makePluginInstall("zcode"); + try { + const record = zcodeRecord(installPath); + mutate(record); + const report = inspectInstallations({ registryPath, sourceRoot: root, platformId: "zcode", + runCommand: pluginRunner({ zcode: shape === "array" ? [record] : { plugins: [record], diagnostics: [] } }), + }); + assert.equal(report.results[0].status, expected); + } finally { fs.rmSync(installPath, { recursive: true, force: true }); } + }); + } } test("ZCode rejects missing and altered nested payload without executing it", () => { @@ -1186,6 +1195,9 @@ test("ZCode distinguishes missing, ambiguous and failed plugin evidence", () => const installPath = makePluginInstall("zcode"); try { for (const [response, expected] of [ + [[], "FAIL"], + [[null], "FAIL"], + [[zcodeRecord(installPath), zcodeRecord(installPath)], "FAIL"], [{ plugins: [] }, "FAIL"], [{ plugins: [null] }, "FAIL"], [{ plugins: [zcodeRecord(installPath), zcodeRecord(installPath)] }, "FAIL"], @@ -1199,3 +1211,103 @@ test("ZCode distinguishes missing, ambiguous and failed plugin evidence", () => } } finally { fs.rmSync(installPath, { recursive: true, force: true }); } }); + +for (const shape of ["array", "envelope"]) { + test(`ZCode representative ${shape} fixture verifies the complete installation`, () => { + const installPath = makePluginInstall("zcode"); + try { + const response = zcodeFixture(shape, { installPath, version: expectedVersion, sourceRoot: root }); + const report = inspectInstallations({ registryPath, sourceRoot: root, platformId: "zcode", + runCommand: pluginRunner({ zcode: response }), + }); + assert.equal(report.results[0].status, "PASS"); + assert.ok(report.results[0].checks.every(check => check.status === "PASS")); + } finally { fs.rmSync(installPath, { recursive: true, force: true }); } + }); +} + +for (const response of [null, 42, "plugins", {}, { plugins: null }, { plugins: {} }]) { + test(`ZCode malformed shape ${JSON.stringify(response)} stays UNVERIFIED`, () => { + const report = inspectInstallations({ registryPath, sourceRoot: root, platformId: "zcode", + runCommand: pluginRunner({ zcode: response }), + }); + assert.equal(report.results[0].status, "UNVERIFIED"); + }); +} + +for (const [label, patch] of [ + ["commented insert", h => `# - insert:\n# - id: thinloop-continuity\n# name: ${h}\n`], + ["bare update row", h => `- id: thinloop-continuity\n name: ${h}\n`], + ["quoted false disabled", h => `- insert:\n - id: thinloop-continuity\n name: ${h}\n disabled: "false"\n`], + ["mixed-case false disabled", h => `- insert:\n - id: thinloop-continuity\n name: ${h}\n disabled: FaLsE\n`], + ["duplicate inserted id", h => `- insert:\n - id: thinloop-continuity\n name: ${h}\n - id: thinloop-continuity\n name: other\n`], + ["unsupported alias", h => `- insert:\n - id: thinloop-continuity\n name: ${h}\n disabled: *flag\n`], + ["unsupported tag", h => `- insert:\n - id: thinloop-continuity\n name: ${h}\n disabled: !!js false\n`], + ["blocked injection", h => `- insert:\n - id: thinloop-continuity\n name: ${h}\n inject:\n missingService: {}\n`], + ["disabled insert", h => `- insert:\n - id: thinloop-continuity\n name: ${h}\n disabled: true\n`], + ["name in config only", h => `- insert:\n - id: other\n name: other-plugin\n config:\n example: ${h}\n`], + ["name as a suffix", h => `- insert:\n - id: thinloop-continuity\n name: ${h}.disabled\n`], + ["invalid YAML", h => `- insert: [\n - id: thinloop-continuity\n name: ${h}\n`], + ["later disabled", h => `- insert:\n - id: thinloop-continuity\n name: ${h}\n- id: thinloop-continuity\n disabled: true\n`], + ["nested insert", h => `- id: unknown-group\n insert:\n - id: thinloop-continuity\n name: ${h}\n`], + ["duplicate name", h => `- insert:\n - id: thinloop-continuity\n name: ${h}\n name: other-plugin\n`], +]) { + test(`DeepSeek Harness ${label} cannot be PASS`, () => { + const homeDir = makeFixture(); + try { + linkSkills(homeDir, "dsh"); + fs.writeFileSync(path.join(homeDir, ".dsh", "cordis.patch.yml"), + patch(pathToFileURL(path.join(root, ".dsh-plugin/continuity.mjs")).href)); + const report = inspectInstallations({ registryPath, sourceRoot: root, homeDir, + environment: {}, platformId: "dsh", + runCommand() { throw new Error("DSH check must remain read-only without CLI execution"); }, + }); + assert.equal(report.results[0].checks.find(check => check.name === "hooks").status, "MANUAL"); + } finally { fs.rmSync(homeDir, { recursive: true, force: true }); } + }); +} + +for (const nameStyle of ["plain", "single", "double"]) { + test(`DeepSeek Harness accepts a ${nameStyle} quoted/commented root insert`, () => { + const homeDir = makeFixture(); + try { + linkSkills(homeDir, "dsh"); + const handler = pathToFileURL(path.join(root, ".dsh-plugin/continuity.mjs")).href; + const name = nameStyle === "single" ? `'${handler}'` : nameStyle === "double" ? JSON.stringify(handler) : handler; + fs.writeFileSync(path.join(homeDir, ".dsh/cordis.patch.yml"), + `---\n# custom configuration\n- insert: # append at root\n - id: thinloop-continuity\n name: ${name} # current checkout\n disabled: false\n config: false\n...\n`); + const report = inspectInstallations({ registryPath, sourceRoot: root, homeDir, environment: {}, platformId: "dsh" }); + assert.equal(report.results[0].status, "PASS"); + } finally { fs.rmSync(homeDir, { recursive: true, force: true }); } + }); +} + +for (const [label, homePatch] of [ + ["home disables profile mount", "- id: thinloop-continuity\n disabled: true\n"], + ["malformed home", "- insert: [broken\n"], + ["empty home", ""], + ["comment-only home", "# empty home layer\n"], + ["duplicate home mount", null], +]) { + test(`DeepSeek Harness ${label} stays MANUAL`, () => { + const homeDir = makeFixture(); + try { + linkSkills(homeDir, "dsh"); + const profileDir = path.join(homeDir, ".dsh/profiles/web"); + fs.mkdirSync(profileDir, { recursive: true }); + const mount = `- insert:\n - id: thinloop-continuity\n name: ${pathToFileURL(path.join(root, ".dsh-plugin/continuity.mjs")).href}\n`; + fs.writeFileSync(path.join(profileDir, "cordis.patch.yml"), mount); + fs.writeFileSync(path.join(homeDir, ".dsh/cordis.patch.yml"), homePatch ?? mount); + const report = inspectInstallations({ registryPath, sourceRoot: root, homeDir, environment: {}, platformId: "dsh" }); + assert.equal(report.results[0].checks.find(check => check.name === "hooks").status, "MANUAL"); + } finally { fs.rmSync(homeDir, { recursive: true, force: true }); } + }); +} + +test("ZCode invalid JSON remains UNVERIFIED", () => { + const report = inspectInstallations({ registryPath, sourceRoot: root, platformId: "zcode", + runCommand: () => ({ status: 0, stdout: "{broken" }), + }); + assert.equal(report.results[0].status, "UNVERIFIED"); + assert.match(report.results[0].checks[0].detail, /invalid JSON/); +}); diff --git a/tests/refresh-install.test.mjs b/tests/refresh-install.test.mjs index 84dd026..2cd9883 100644 --- a/tests/refresh-install.test.mjs +++ b/tests/refresh-install.test.mjs @@ -3,6 +3,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import test from "node:test"; +import { zcodeFixture } from "./helpers/zcode-fixture.mjs"; import { fileURLToPath } from "node:url"; import { refreshInstallation } from "../scripts/refresh-install.mjs"; @@ -175,3 +176,47 @@ test("Claude does not reinstall when update leaves version or install-path evide } } finally { fs.rmSync(home, { recursive: true, force: true }); } }); + +for (const shape of ["array", "envelope"]) { + test(`ZCode refresh accepts ${shape} and rechecks the updated payload`, async () => { + const home = fixture(); + const installed = path.join(home, "installed"); + const commands = []; + const requests = []; + let current = "0.0.0"; + try { + const report = await refreshInstallation({ platformId: "zcode", sourceRoot: root, homeDir: home, + runCommand(command) { + commands.push(command); + return JSON.stringify(zcodeFixture(shape, { installPath: installed, version: current, sourceRoot: root })); + }, + async request(method, params) { + requests.push([method, params]); + if (method === "plugins/overview") return { marketplaces: [{ id: "thinloop", source: { source: "directory", path: root } }] }; + if (method === "plugins/update") { pluginPayload(installed); current = version; } + return { diagnostics: [] }; + }, + }); + assert.equal(report.results[0].status, "PASS"); + assert.deepEqual(commands, Array(2).fill(["zcode", "plugins", "list", "--json"])); + const workspace = { workspacePath: root, workspaceKey: root }; + assert.deepEqual(requests, [ + ["plugins/overview", { workspace }], + ["plugins/marketplace/update", { workspace, marketplace: "thinloop" }], + ["plugins/update", { workspace, pluginId: "thinloop@thinloop" }], + ]); + } finally { fs.rmSync(home, { recursive: true, force: true }); } + }); +} + +for (const response of [null, {}, { plugins: null }, { plugins: {} }, [null], + [{ id: "thinloop@thinloop", enabled: true }, { id: "thinloop@thinloop", enabled: true }]]) { + test(`ZCode refresh refuses malformed or ambiguous evidence: ${JSON.stringify(response)}`, async () => { + let requests = 0; + await assert.rejects(refreshInstallation({ platformId: "zcode", sourceRoot: root, + runCommand() { return JSON.stringify(response); }, + request() { requests++; throw new Error("unexpected update"); }, + }), /already be installed and enabled/); + assert.equal(requests, 0); + }); +}