skill-family-harness-node 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +13 -67
- package/README.md +20 -4
- package/SECURITY.md +4 -9
- package/package.json +4 -4
- package/src/errors.mjs +16 -0
- package/src/host.mjs +245 -0
- package/src/index.mjs +51 -0
- package/src/report.mjs +618 -0
- package/src/state-store.mjs +1050 -0
package/CONTRIBUTING.md
CHANGED
|
@@ -1,79 +1,25 @@
|
|
|
1
|
-
# 贡献指南(
|
|
1
|
+
# 贡献指南(skill-family-harness-node)
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
验证流程与 PR 规则。行为准则见 [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md),
|
|
5
|
-
Git 写操作的授权边界见仓库内文档站的生命周期指南(`docs/git-lifecycle.md`)。
|
|
3
|
+
本仓是 `skill-family-harness-node` 的公开发布镜像,只承载版本化发布物。开发在私有工作区进行,不在本仓公开。
|
|
6
4
|
|
|
7
|
-
|
|
8
|
-
(`skill-family-contracts`、`skill-family-harness-node`、
|
|
9
|
-
`skill-family-engineering-kit`)是版本化发布镜像,不承载完整开发历史与
|
|
10
|
-
测试。本指南描述的是私有工作区内经授权的开发流程;消费坐标与发布状态
|
|
11
|
-
唯一见 `docs/status/current.md`。
|
|
5
|
+
## 问题报告
|
|
12
6
|
|
|
13
|
-
|
|
7
|
+
Bug 报告请在本仓 GitHub Issues 提交:https://github.com/ifoohoo/skill-family-harness-node/issues
|
|
14
8
|
|
|
15
|
-
|
|
16
|
-
`https://github.com/ifoohoo/skill-family-engineering-kit/issues`)。提交
|
|
17
|
-
bug 报告时请尽量提供:受影响包与版本、最小复现步骤、预期与实际行为。
|
|
9
|
+
提交时请尽量提供:受影响版本、最小复现步骤、预期与实际行为。
|
|
18
10
|
|
|
19
|
-
|
|
20
|
-
通过 Issue 讨论变更的动机与范围,获得维护者确认后再提交 PR。这是为了
|
|
21
|
-
避免重复劳动、确保变更与本项目的架构裁决和发布合同一致。
|
|
11
|
+
## 代码贡献
|
|
22
12
|
|
|
23
|
-
|
|
13
|
+
未经事先讨论的 Pull Request 不予接受。请先通过 Issue 讨论变更的动机与范围,获得维护者确认后再提交 PR。
|
|
24
14
|
|
|
25
|
-
|
|
15
|
+
## 行为准则
|
|
26
16
|
|
|
27
|
-
|
|
28
|
-
- pnpm:10.30.0(`packageManager` 精确锁定)
|
|
29
|
-
- Python:3.10 或更高(仅文档站构建需要)
|
|
17
|
+
互动请遵守本仓 CODE_OF_CONDUCT.md。
|
|
30
18
|
|
|
31
|
-
|
|
32
|
-
pnpm install
|
|
33
|
-
pnpm synth
|
|
34
|
-
pnpm check
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
- `pnpm synth` 再生成 projen 受管文件。受管文件只能通过修改
|
|
38
|
-
`.projenrc.js` 间接变更;手写源码、文档和 fixture 不会被 synth 覆盖。
|
|
39
|
-
- `pnpm check` 是统一根门禁:10 个稳定门禁 ID 按固定顺序串联执行,
|
|
40
|
-
任一步骤非 0 即整体失败。
|
|
41
|
-
|
|
42
|
-
## 提交 PR 前必须做到
|
|
43
|
-
|
|
44
|
-
1. 只修改当前任务授权的文件,不顺手改动无关文件;
|
|
45
|
-
2. 修改 `.projenrc.js` 后运行 `pnpm synth`,并保证连续两次 synth 零漂移;
|
|
46
|
-
3. 本地完整通过根门禁:
|
|
47
|
-
|
|
48
|
-
```bash
|
|
49
|
-
pnpm check
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
4. 涉及文档时额外运行文档事实与链接检查:
|
|
53
|
-
|
|
54
|
-
```bash
|
|
55
|
-
node scripts/docs/fact-check.mjs
|
|
56
|
-
node scripts/docs/link-check.mjs
|
|
57
|
-
```
|
|
58
|
-
|
|
59
|
-
5. 涉及三个叶子包时,确认发布字节合同仍然成立:
|
|
60
|
-
|
|
61
|
-
```bash
|
|
62
|
-
pnpm run release-artifacts:build -- --output .artifacts/publish-candidates
|
|
63
|
-
pnpm run release-artifacts:verify -- --root .artifacts/publish-candidates --manifest .artifacts/publish-candidates/release-artifacts.manifest.json
|
|
64
|
-
```
|
|
65
|
-
|
|
66
|
-
## PR 规则
|
|
19
|
+
## 安全报告
|
|
67
20
|
|
|
68
|
-
|
|
69
|
-
- 不得为转绿而弱化任何产品门禁、Action pin、权限、泄漏策略或 tarball 合同;
|
|
70
|
-
- 不新增运行时依赖,除非先经评审并显式修订泄漏策略中的公开依赖清单;
|
|
71
|
-
- 不提交生成物与本地状态:`node_modules`、`site/`、`.artifacts/`、缓存、
|
|
72
|
-
日志、凭据或本机绝对路径一律不进入提交;
|
|
73
|
-
- 受管投影必须与产生它的源变更在同一 PR 内提交,禁止手改受管文件;
|
|
74
|
-
- 动态状态(实时发布状态、远端对象状态)不写入版本化文档,唯一产品状态
|
|
75
|
-
口径见 `docs/status/current.md`。
|
|
21
|
+
漏洞报告渠道见本仓 SECURITY.md。请走 Private vulnerability reporting,不要以公开 Issue 提交漏洞。
|
|
76
22
|
|
|
77
|
-
##
|
|
23
|
+
## 许可证
|
|
78
24
|
|
|
79
|
-
|
|
25
|
+
贡献内容默认按 Apache-2.0 授权,与本包许可证一致。
|
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ Contracts 机制协议的**唯一默认 Node 实现**。这是一个薄运行时
|
|
|
8
8
|
## 边界
|
|
9
9
|
|
|
10
10
|
- 消费 `skill-family-contracts`(工作区依赖),复用其方言路由的 Ajv validator、Kernel Protocol、冻结错误码与 fixture;不复制协议定义,不重新解释 Schema。
|
|
11
|
-
- 只实现机制:Schema 校验、原子写、路径收容、临时工作区、资源闭包、operation-request → operation-result
|
|
11
|
+
- 只实现机制:Schema 校验、原子写、路径收容、临时工作区、资源闭包、operation-request → operation-result 管道、业务中立的事件日志与派生快照。
|
|
12
12
|
- 明确排除:业务语义、任务编排、Git 写入、模型调用、远程网络、发布状态。见 `HARNESS_EXCLUSIONS`。
|
|
13
13
|
|
|
14
14
|
## 公共 API
|
|
@@ -23,6 +23,22 @@ Contracts 机制协议的**唯一默认 Node 实现**。这是一个薄运行时
|
|
|
23
23
|
| `TemporaryWorkspace` / `createTemporaryWorkspace` / `withTemporaryWorkspace` | 自动清理的临时工作区,异常路径也清理。 |
|
|
24
24
|
| `digestBytes` / `computeResourceClosure` / `closureContains` | 资源闭包与确定性 sha256 摘要。 |
|
|
25
25
|
| `parseRequest` / `processRequest` | 解析 `operation-request`,输出终态 `operation-result`。 |
|
|
26
|
+
| `validateReportModel` / `renderReportMarkdown` / `buildBinding` / `checkReport` | 消费经 Contracts 验证的 report model,确定性渲染中性 Markdown 并校验来源/结果/报告绑定;不解释业务输出。 |
|
|
27
|
+
| `normalizeAdapterSource` / `buildAdapterClosure` / `verifyAdapterBuildManifest` / `materializeAdapterBuild` | 通用文本 source closure、manifest 全摘要复验和目标集合原子落盘;具体 Profile/driver 不在 Harness。 |
|
|
28
|
+
| `probeVersionVector` | 默认禁用 spawn 的版本探测机制;显式启用时只执行绝对、无 symlink 的受审计向量,不使用 PATH/shell。 |
|
|
29
|
+
| `openStateStore` / `appendEvent` / `readEvents` / `verifyStateStore` / `closeStateStore` | 严格单写者的 append-only 事件存储;事件目录是唯一状态权威,`chain-head.json` 只是缓存。 |
|
|
30
|
+
| `readSnapshot` / `writeSnapshot` / `rebuildSnapshot` | 原子派生快照与完整事件重建;坏事件不能被旧快照掩盖,坏快照可被重建忽略。 |
|
|
31
|
+
| `inspectStateStoreLock` / `recoverStateStoreLock` | 只读锁诊断与显式恢复;恢复必须对观测到的 owner + fencing 做精确匹配。 |
|
|
32
|
+
|
|
33
|
+
## 状态存储的锁与恢复边界
|
|
34
|
+
|
|
35
|
+
- 锁使用 exclusive create,第二写者立即收到 `store-locked`;不排队,也不按时间、PID 或租约过期偷锁。
|
|
36
|
+
- `inspectStateStoreLock` 不创建任何文件,只返回 `owner`、单调 `fencing`、`ageMs` 和恢复中标记。`ageMs` 仅供诊断,从不参与正确性判断。
|
|
37
|
+
- 崩溃遗留锁只能由调用方在 Foundation 之外确认旧写者已经终止后,调用 `recoverStateStoreLock`,同时提交精确匹配的 `expectedOwner`、`expectedFencing` 与 `confirmOwnerTerminated: true`。不匹配或缺少确认均失败关闭。
|
|
38
|
+
- 恢复产生更大的 fencing。旧 handle 每次 append 都重新核对 owner、fencing 和 acquisition id;事件最终发布使用同目录临时普通文件、fsync 和 exclusive link,绝不覆盖既有 sequence。
|
|
39
|
+
- append、snapshot、close 与 recovery 由短期 `writer-mutation.lock` 串行化;恢复不能越过已经持有 mutation guard 的权威写入。
|
|
40
|
+
- 如果恢复进程自身在持有 `writer-recovery.lock` 时崩溃,系统保持可诊断的锁死状态,不自动删除该 guard。它需要新的外部取证与人工处置;当前 API 不声称解决不可信调用方谎报“旧写者已终止”的场景。
|
|
41
|
+
- state root、`events/`、`snapshots/`、事件和快照拒绝 symlink、硬链接、FIFO、设备与其它非普通条目。payload 必须是纯 JSON,且 `eventType + payloadSchemaVersion` 必须命中调用方在 open/recover 时冻结的 Schema 对。
|
|
26
42
|
|
|
27
43
|
## 稳定错误码
|
|
28
44
|
|
|
@@ -43,19 +59,19 @@ Contracts 机制协议的**唯一默认 Node 实现**。这是一个薄运行时
|
|
|
43
59
|
|
|
44
60
|
## 测试
|
|
45
61
|
|
|
46
|
-
`node --test` 覆盖:Contracts fixture
|
|
62
|
+
`node --test` 覆盖:Contracts fixture 全量回放、安全反例、原子性失败路径、临时工作区、闭包确定性、报告事实绑定与 Markdown 注入、宿主 manifest/路径/命令信任,以及状态存储的崩溃、并发、损坏、fencing、显式恢复、symlink、硬链接与 FIFO 反例。
|
|
47
63
|
|
|
48
64
|
## 安装
|
|
49
65
|
|
|
50
66
|
```sh
|
|
51
|
-
npm install skill-family-harness-node
|
|
67
|
+
npm install skill-family-harness-node@0.2.0
|
|
52
68
|
npm info skill-family-harness-node --help
|
|
53
69
|
```
|
|
54
70
|
|
|
55
71
|
## 最小示例
|
|
56
72
|
|
|
57
73
|
```js
|
|
58
|
-
// 从空目录运行:npm install skill-family-harness-node
|
|
74
|
+
// 从空目录运行:npm install skill-family-harness-node@0.2.0
|
|
59
75
|
import { validateContractDocument } from "skill-family-harness-node";
|
|
60
76
|
|
|
61
77
|
const document = {
|
package/SECURITY.md
CHANGED
|
@@ -12,15 +12,10 @@
|
|
|
12
12
|
## 报告漏洞
|
|
13
13
|
|
|
14
14
|
请勿通过公开 issue 直接披露未修复的安全漏洞。private workspace 仓永久
|
|
15
|
-
private
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
**Private vulnerability reporting**(仓库 Security 页签的
|
|
20
|
-
"Report a vulnerability");
|
|
21
|
-
2. **备选**:如上述通道不可用,请通过邮件联系仓库维护者
|
|
22
|
-
(`ifoohoo` GitHub 组织的 `security` 团队,或仓库 `README.md` 中列出的
|
|
23
|
-
维护者邮箱)。
|
|
15
|
+
private,不是公开报告渠道;请通过任一 public 发布镜像仓(例如
|
|
16
|
+
`https://github.com/ifoohoo/skill-family-engineering-kit`)在 GitHub 上使用
|
|
17
|
+
**Private vulnerability reporting**(仓库 Security 页签的
|
|
18
|
+
"Report a vulnerability")私下报告。
|
|
24
19
|
|
|
25
20
|
报告时请尽量提供:
|
|
26
21
|
|
package/package.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"url": "https://github.com/ifoohoo/skill-family-harness-node/issues"
|
|
6
6
|
},
|
|
7
7
|
"dependencies": {
|
|
8
|
-
"skill-family-contracts": "0.
|
|
8
|
+
"skill-family-contracts": "0.2.0"
|
|
9
9
|
},
|
|
10
10
|
"description": "Thin Node.js mechanism runtime for Skill Family engineering contracts.",
|
|
11
11
|
"engines": {
|
|
@@ -27,9 +27,9 @@
|
|
|
27
27
|
"url": "https://github.com/ifoohoo/skill-family-harness-node.git"
|
|
28
28
|
},
|
|
29
29
|
"type": "module",
|
|
30
|
-
"version": "0.
|
|
30
|
+
"version": "0.2.0",
|
|
31
31
|
"scripts": {
|
|
32
|
-
"check": "node --
|
|
33
|
-
"test": "node --
|
|
32
|
+
"check": "node --test",
|
|
33
|
+
"test": "node --test"
|
|
34
34
|
}
|
|
35
35
|
}
|
package/src/errors.mjs
CHANGED
|
@@ -41,6 +41,22 @@ export const HARNESS_ERROR_KINDS = Object.freeze({
|
|
|
41
41
|
UNSUPPORTED_POLICY: "unsupported-policy",
|
|
42
42
|
EXECUTION_FAILED: "execution-failed",
|
|
43
43
|
INVALID_RESULT: "invalid-result",
|
|
44
|
+
STORE_CLOSED: "store-closed",
|
|
45
|
+
STORE_LOCKED: "store-locked",
|
|
46
|
+
LOCK_CORRUPT: "lock-corrupt",
|
|
47
|
+
LOCK_RECOVERY_REFUSED: "lock-recovery-refused",
|
|
48
|
+
UNSAFE_STATE_ENTRY: "unsafe-state-entry",
|
|
49
|
+
EVENT_SCHEMA_INVALID: "event-schema-invalid",
|
|
50
|
+
DUPLICATE_SEQUENCE: "duplicate-sequence",
|
|
51
|
+
IDEMPOTENCY_CONFLICT: "idempotency-conflict",
|
|
52
|
+
CHAIN_BROKEN: "chain-broken",
|
|
53
|
+
SNAPSHOT_MISMATCH: "snapshot-mismatch",
|
|
54
|
+
HOST_CONTRACT_INVALID: "host-contract-invalid",
|
|
55
|
+
HOST_BUILD_FAILED: "host-build-failed",
|
|
56
|
+
HOST_PROBE_FAILED: "host-probe-failed",
|
|
57
|
+
UNTRUSTED_EXECUTABLE: "untrusted-executable",
|
|
58
|
+
PORTABLE_PATH_COLLISION: "portable-path-collision",
|
|
59
|
+
MANIFEST_MISMATCH: "manifest-mismatch",
|
|
44
60
|
});
|
|
45
61
|
|
|
46
62
|
/**
|
package/src/host.mjs
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { access, lstat, mkdtemp, readFile, realpath, rename, rm } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { canonicalJson, digestDocument, validateDocument } from "skill-family-contracts";
|
|
6
|
+
import { writeFileAtomic } from "./atomic.mjs";
|
|
7
|
+
import { digestBytes } from "./closure.mjs";
|
|
8
|
+
import { HARNESS_ERROR_KINDS, HarnessError, mechanismError } from "./errors.mjs";
|
|
9
|
+
import { classifyPathInput } from "./paths.mjs";
|
|
10
|
+
|
|
11
|
+
const SCHEMAS = Object.freeze({
|
|
12
|
+
adapterSource: "https://contracts.skill-family.example/v1/adapter-source.json",
|
|
13
|
+
buildManifest: "https://contracts.skill-family.example/v1/adapter-build-manifest.json",
|
|
14
|
+
capabilityFact: "https://contracts.skill-family.example/v1/host-capability-fact.json",
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function invalidParams(message, details) {
|
|
18
|
+
return new HarnessError("SFC2003", message, { ...(details ?? {}), kind: "invalid-params" });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function validateContract(document, schemaId, message) {
|
|
22
|
+
const result = validateDocument(document, { schemaId, dialect: "2020-12", policy: "strict" });
|
|
23
|
+
if (!result.valid) throw mechanismError(HARNESS_ERROR_KINDS.HOST_CONTRACT_INVALID, message, { errors: result.errors });
|
|
24
|
+
return result.data;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function assertRelative(relPath, label) {
|
|
28
|
+
const classified = classifyPathInput(relPath);
|
|
29
|
+
const normalized = typeof relPath === "string" ? path.posix.normalize(relPath) : "";
|
|
30
|
+
if (!classified.ok || normalized !== relPath || normalized === "." || normalized.startsWith("../")) {
|
|
31
|
+
throw invalidParams(`${label} must be a normalized contained relative path`, { path: relPath, pathKind: classified.kind ?? "non-normalized" });
|
|
32
|
+
}
|
|
33
|
+
return relPath;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function portablePathKey(value) {
|
|
37
|
+
return value.normalize("NFC").toLocaleLowerCase("en-US");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function assertUniquePortablePaths(paths, label) {
|
|
41
|
+
const seen = new Map();
|
|
42
|
+
for (const value of paths) {
|
|
43
|
+
const key = portablePathKey(value);
|
|
44
|
+
if (seen.has(key)) {
|
|
45
|
+
throw mechanismError(HARNESS_ERROR_KINDS.PORTABLE_PATH_COLLISION, `${label} contains a portable path collision`, { first: seen.get(key), second: value });
|
|
46
|
+
}
|
|
47
|
+
seen.set(key, value);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Validates and deterministically normalizes an adapter source closure. */
|
|
52
|
+
export function normalizeAdapterSource(input) {
|
|
53
|
+
const source = validateContract(input, SCHEMAS.adapterSource, "adapter source fails its registered contract");
|
|
54
|
+
const skillIds = source.skills.map((skill) => skill.id);
|
|
55
|
+
if (new Set(skillIds).size !== skillIds.length) throw mechanismError(HARNESS_ERROR_KINDS.HOST_CONTRACT_INVALID, "adapter source contains a duplicate skill id");
|
|
56
|
+
assertUniquePortablePaths(skillIds, "adapter skill ids");
|
|
57
|
+
|
|
58
|
+
const sources = [];
|
|
59
|
+
for (const skill of source.skills) {
|
|
60
|
+
const filePaths = skill.files.map((file) => assertRelative(file.path, "adapter source file path"));
|
|
61
|
+
if (!filePaths.includes("SKILL.md")) throw invalidParams(`skill ${skill.id} is missing SKILL.md`);
|
|
62
|
+
if (new Set(filePaths).size !== filePaths.length) throw invalidParams(`skill ${skill.id} contains a duplicate path`);
|
|
63
|
+
assertUniquePortablePaths(filePaths, `skill ${skill.id}`);
|
|
64
|
+
for (const file of skill.files) {
|
|
65
|
+
const bytes = Buffer.from(file.content, "utf8");
|
|
66
|
+
sources.push({
|
|
67
|
+
sourcePath: `${skill.id}/${file.path}`,
|
|
68
|
+
skillId: skill.id,
|
|
69
|
+
filePath: file.path,
|
|
70
|
+
content: file.content,
|
|
71
|
+
bytes,
|
|
72
|
+
sha256: digestBytes(bytes),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
assertUniquePortablePaths(sources.map((entry) => entry.sourcePath), "adapter source closure");
|
|
77
|
+
sources.sort((left, right) => left.sourcePath.localeCompare(right.sourcePath));
|
|
78
|
+
return sources;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Pure adapter build mechanism. Host-specific path categories are injected. */
|
|
82
|
+
export function buildAdapterClosure({ hostId, pathCategory, input } = {}) {
|
|
83
|
+
if (!hostId || !pathCategory) throw invalidParams("buildAdapterClosure requires hostId and pathCategory");
|
|
84
|
+
const sources = normalizeAdapterSource(input);
|
|
85
|
+
const sourceMembers = sources.map(({ sourcePath, sha256, bytes }) => ({ sourcePath, sha256, bytes: bytes.length }));
|
|
86
|
+
const sourceClosure = { digest: digestDocument(sourceMembers), members: sourceMembers };
|
|
87
|
+
const files = sources.map((source) => ({
|
|
88
|
+
sourcePath: source.sourcePath,
|
|
89
|
+
target: `${pathCategory.relPath}/${source.sourcePath}`,
|
|
90
|
+
content: source.content,
|
|
91
|
+
sha256: source.sha256,
|
|
92
|
+
bytes: source.bytes.length,
|
|
93
|
+
}));
|
|
94
|
+
assertUniquePortablePaths(files.map((entry) => entry.target), "adapter target closure");
|
|
95
|
+
const manifestBase = {
|
|
96
|
+
schemaVersion: 1,
|
|
97
|
+
kind: "skill-family.adapter-build-manifest",
|
|
98
|
+
hostId,
|
|
99
|
+
pathCategory,
|
|
100
|
+
sourceClosure,
|
|
101
|
+
members: files.map(({ sourcePath, target, sha256, bytes }) => ({ sourcePath, target, sha256, bytes })),
|
|
102
|
+
};
|
|
103
|
+
const manifest = validateContract({ ...manifestBase, digest: digestDocument(manifestBase) }, SCHEMAS.buildManifest, "adapter build manifest fails its registered contract");
|
|
104
|
+
verifyAdapterBuildManifest(manifest, { hostId, pathCategory });
|
|
105
|
+
return { status: "built", manifest, files };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Recomputes every digest and source-to-target binding before consumption. */
|
|
109
|
+
export function verifyAdapterBuildManifest(manifestInput, { hostId, pathCategory } = {}) {
|
|
110
|
+
const manifest = validateContract(manifestInput, SCHEMAS.buildManifest, "adapter build manifest fails its registered contract");
|
|
111
|
+
const { digest, ...base } = manifest;
|
|
112
|
+
if (digestDocument(base) !== digest) throw mechanismError(HARNESS_ERROR_KINDS.MANIFEST_MISMATCH, "adapter manifest digest does not match its content");
|
|
113
|
+
if (digestDocument(manifest.sourceClosure.members) !== manifest.sourceClosure.digest) {
|
|
114
|
+
throw mechanismError(HARNESS_ERROR_KINDS.MANIFEST_MISMATCH, "adapter source closure digest does not match its members");
|
|
115
|
+
}
|
|
116
|
+
if (hostId !== undefined && manifest.hostId !== hostId) throw mechanismError(HARNESS_ERROR_KINDS.MANIFEST_MISMATCH, "adapter manifest host does not match the requested host");
|
|
117
|
+
if (pathCategory !== undefined && canonicalJson(manifest.pathCategory) !== canonicalJson(pathCategory)) {
|
|
118
|
+
throw mechanismError(HARNESS_ERROR_KINDS.MANIFEST_MISMATCH, "adapter manifest category does not match the requested category");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const sourcePaths = manifest.sourceClosure.members.map((entry) => entry.sourcePath);
|
|
122
|
+
const targetPaths = manifest.members.map((entry) => entry.target);
|
|
123
|
+
assertUniquePortablePaths(sourcePaths, "manifest source closure");
|
|
124
|
+
assertUniquePortablePaths(targetPaths, "manifest target closure");
|
|
125
|
+
if (new Set(sourcePaths).size !== sourcePaths.length || new Set(targetPaths).size !== targetPaths.length) {
|
|
126
|
+
throw mechanismError(HARNESS_ERROR_KINDS.MANIFEST_MISMATCH, "adapter manifest contains duplicate members");
|
|
127
|
+
}
|
|
128
|
+
const sortedSources = [...sourcePaths].sort((left, right) => left.localeCompare(right));
|
|
129
|
+
const outputSources = manifest.members.map((entry) => entry.sourcePath);
|
|
130
|
+
if (canonicalJson(sourcePaths) !== canonicalJson(sortedSources) || canonicalJson(outputSources) !== canonicalJson(sortedSources)) {
|
|
131
|
+
throw mechanismError(HARNESS_ERROR_KINDS.MANIFEST_MISMATCH, "adapter manifest members are not the canonical complete source set");
|
|
132
|
+
}
|
|
133
|
+
const bySource = new Map(manifest.sourceClosure.members.map((entry) => [entry.sourcePath, entry]));
|
|
134
|
+
for (const member of manifest.members) {
|
|
135
|
+
assertRelative(member.sourcePath, "manifest source path");
|
|
136
|
+
assertRelative(member.target, "manifest target path");
|
|
137
|
+
const source = bySource.get(member.sourcePath);
|
|
138
|
+
const expectedTarget = `${manifest.pathCategory.relPath}/${member.sourcePath}`;
|
|
139
|
+
if (!source || source.sha256 !== member.sha256 || source.bytes !== member.bytes || member.target !== expectedTarget) {
|
|
140
|
+
throw mechanismError(HARNESS_ERROR_KINDS.MANIFEST_MISMATCH, "adapter manifest member is not bound to its declared source/category", { sourcePath: member.sourcePath, target: member.target });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return manifest;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Atomically publishes one complete, reverified build set. */
|
|
147
|
+
export async function materializeAdapterBuild({ targetRoot, build, writer = writeFileAtomic } = {}) {
|
|
148
|
+
if (!path.isAbsolute(targetRoot ?? "")) throw invalidParams("materializeAdapterBuild requires an absolute targetRoot");
|
|
149
|
+
if (!build || build.status !== "built" || !Array.isArray(build.files)) throw invalidParams("materializeAdapterBuild requires a completed in-memory build");
|
|
150
|
+
const manifest = verifyAdapterBuildManifest(build.manifest);
|
|
151
|
+
if (build.files.length !== manifest.members.length) throw mechanismError(HARNESS_ERROR_KINDS.MANIFEST_MISMATCH, "in-memory build is not the manifest's complete target set");
|
|
152
|
+
for (let index = 0; index < manifest.members.length; index += 1) {
|
|
153
|
+
const member = manifest.members[index];
|
|
154
|
+
const file = build.files[index];
|
|
155
|
+
const content = Buffer.from(file?.content ?? "", "utf8");
|
|
156
|
+
if (file?.sourcePath !== member.sourcePath || file?.target !== member.target || digestBytes(content) !== member.sha256 || content.length !== member.bytes) {
|
|
157
|
+
throw mechanismError(HARNESS_ERROR_KINDS.MANIFEST_MISMATCH, "in-memory build bytes do not match the manifest", { index });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const parent = path.dirname(targetRoot);
|
|
162
|
+
const baseName = path.basename(targetRoot);
|
|
163
|
+
const parentStat = await lstat(parent).catch(() => null);
|
|
164
|
+
if (!parentStat?.isDirectory() || parentStat.isSymbolicLink()) throw mechanismError(HARNESS_ERROR_KINDS.HOST_BUILD_FAILED, "build target parent must be an existing plain directory");
|
|
165
|
+
const resolvedParent = await realpath(parent);
|
|
166
|
+
if (resolvedParent !== path.resolve(parent)) throw mechanismError(HARNESS_ERROR_KINDS.HOST_BUILD_FAILED, "build target parent path cannot contain a symlink ancestor");
|
|
167
|
+
if (await lstat(targetRoot).then(() => true, () => false)) throw mechanismError(HARNESS_ERROR_KINDS.HOST_BUILD_FAILED, "host build target must be absent; no bytes were written");
|
|
168
|
+
const staging = await mkdtemp(path.join(parent, `.${baseName}.host-build-`));
|
|
169
|
+
try {
|
|
170
|
+
for (const file of build.files) await writer(staging, file.target, file.content);
|
|
171
|
+
await writer(staging, "adapter-build-manifest.json", `${JSON.stringify(manifest, null, 2)}\n`);
|
|
172
|
+
for (const member of manifest.members) {
|
|
173
|
+
const bytes = await readFile(path.join(staging, member.target));
|
|
174
|
+
if (digestBytes(bytes) !== member.sha256 || bytes.length !== member.bytes) throw mechanismError(HARNESS_ERROR_KINDS.HOST_BUILD_FAILED, "staged adapter bytes do not match the manifest");
|
|
175
|
+
}
|
|
176
|
+
if (await lstat(targetRoot).then(() => true, () => false)) throw mechanismError(HARNESS_ERROR_KINDS.HOST_BUILD_FAILED, "host build target appeared during staging; staged bytes were discarded");
|
|
177
|
+
// Staging is a sibling of targetRoot, so rename is same-filesystem and
|
|
178
|
+
// publishes the verified member set as one namespace operation.
|
|
179
|
+
await rename(staging, targetRoot);
|
|
180
|
+
return { targetRoot, manifest };
|
|
181
|
+
} catch (cause) {
|
|
182
|
+
await rm(staging, { recursive: true, force: true });
|
|
183
|
+
if (cause?.code?.startsWith?.("SFC")) throw cause;
|
|
184
|
+
throw mechanismError(HARNESS_ERROR_KINDS.HOST_BUILD_FAILED, "atomic host build failed; no target was published", { causeCode: cause?.code ?? "unknown" });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function fact(hostId, capability, state, evidence, extra = {}) {
|
|
189
|
+
return validateContract({ schemaVersion: 1, kind: "skill-family.host-capability-fact", hostId, capability, state, evidence, ...extra }, SCHEMAS.capabilityFact, "probe emitted an invalid capability fact");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function unknownFact(hostId, capability, unknownReason, evidence, manualStep) {
|
|
193
|
+
return fact(hostId, capability, "unknown", [evidence], { unknownReason, manualSteps: [manualStep] });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function fillLimited(hostId, capabilities, firstFacts) {
|
|
197
|
+
return [...firstFacts, ...capabilities.slice(2).map((capability) => unknownFact(hostId, capability, "driver-limited", `The frozen version-only driver does not inspect ${capability}.`, `Verify ${capability} manually using the host's documented read-only interface.`))];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function parseVersion(output) {
|
|
201
|
+
const match = String(output ?? "").match(/(?:^|[^0-9])v?((?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)(?=$|[^0-9A-Za-z.+-])/);
|
|
202
|
+
return match?.[1] ?? null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function verifyExecutable(executable) {
|
|
206
|
+
if (typeof executable !== "string" || !path.isAbsolute(executable)) throw mechanismError(HARNESS_ERROR_KINDS.UNTRUSTED_EXECUTABLE, "host probe spawn requires an explicit absolute executable path");
|
|
207
|
+
const stat = await lstat(executable).catch(() => null);
|
|
208
|
+
if (!stat?.isFile() || stat.isSymbolicLink()) throw mechanismError(HARNESS_ERROR_KINDS.UNTRUSTED_EXECUTABLE, "host probe executable must be an existing plain file");
|
|
209
|
+
const resolved = await realpath(executable);
|
|
210
|
+
if (resolved !== path.resolve(executable)) throw mechanismError(HARNESS_ERROR_KINDS.UNTRUSTED_EXECUTABLE, "host probe executable path cannot contain symlinks");
|
|
211
|
+
await access(executable, constants.X_OK).catch(() => { throw mechanismError(HARNESS_ERROR_KINDS.UNTRUSTED_EXECUTABLE, "host probe executable is not executable"); });
|
|
212
|
+
return resolved;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Runs an audited vector; spawn is disabled by default and never uses PATH. */
|
|
216
|
+
export async function probeVersionVector({ hostId, capabilities, executable, argv, allowSpawn = false, timeoutMs = 5000, runner = spawnSync } = {}) {
|
|
217
|
+
if (!Array.isArray(capabilities) || capabilities.length < 2 || capabilities[0] !== "cli" || capabilities[1] !== "version") throw invalidParams("probe capabilities must begin with cli and version");
|
|
218
|
+
if (!Array.isArray(argv) || argv.some((value) => typeof value !== "string")) throw invalidParams("probe argv must be a frozen string array");
|
|
219
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 30000) throw invalidParams("timeoutMs must be an integer from 1 to 30000");
|
|
220
|
+
if (!allowSpawn) return capabilities.map((capability) => unknownFact(hostId, capability, "spawn-restricted", "Process spawning is disabled by default; no host command ran.", "Provide an audited absolute executable and explicitly opt in to the frozen non-interactive version probe."));
|
|
221
|
+
const trustedExecutable = await verifyExecutable(executable);
|
|
222
|
+
let result;
|
|
223
|
+
try {
|
|
224
|
+
result = runner(trustedExecutable, [...argv], {
|
|
225
|
+
cwd: process.cwd(), env: {}, encoding: "utf8", shell: false,
|
|
226
|
+
windowsHide: true, timeout: timeoutMs, maxBuffer: 1024 * 1024, stdio: ["ignore", "pipe", "pipe"],
|
|
227
|
+
});
|
|
228
|
+
} catch (cause) {
|
|
229
|
+
return fillLimited(hostId, capabilities, [fact(hostId, "cli", "error", [`Version probe runner threw ${cause?.name ?? "Error"}.`]), fact(hostId, "version", "error", ["No version output was produced because the probe runner threw."])]);
|
|
230
|
+
}
|
|
231
|
+
if (result?.error) {
|
|
232
|
+
const code = result.error.code ?? "unknown";
|
|
233
|
+
if (code === "ENOENT") return fillLimited(hostId, capabilities, [fact(hostId, "cli", "unavailable", ["The explicitly bound executable was not found."]), fact(hostId, "version", "unavailable", ["A version cannot be observed because the CLI is unavailable."])]);
|
|
234
|
+
const reason = code === "ETIMEDOUT" ? "timeout" : code === "EACCES" || code === "EPERM" ? "permission-denied" : null;
|
|
235
|
+
if (reason) {
|
|
236
|
+
const step = reason === "timeout" ? "Run the same frozen version command manually and inspect whether it completes without interaction." : "Run the same frozen version command in a context with read/execute permission.";
|
|
237
|
+
return fillLimited(hostId, capabilities, [unknownFact(hostId, "cli", reason, `The frozen version probe ended with ${code}.`, step), unknownFact(hostId, "version", reason, "No trustworthy version was observed.", step)]);
|
|
238
|
+
}
|
|
239
|
+
return fillLimited(hostId, capabilities, [fact(hostId, "cli", "error", [`The process API reported spawn error ${code}.`]), fact(hostId, "version", "error", ["No trustworthy version was observed after the spawn error."])]);
|
|
240
|
+
}
|
|
241
|
+
if (result?.status !== 0) return fillLimited(hostId, capabilities, [fact(hostId, "cli", "available", ["The explicitly bound executable started without a spawn error."]), fact(hostId, "version", "error", [`The version command exited with status ${String(result?.status)}.`])]);
|
|
242
|
+
const observedVersion = parseVersion(`${result.stdout ?? ""}\n${result.stderr ?? ""}`);
|
|
243
|
+
if (!observedVersion) return fillLimited(hostId, capabilities, [fact(hostId, "cli", "available", ["The explicitly bound executable completed successfully."]), fact(hostId, "version", "error", ["Successful output did not contain a parseable semantic version."])]);
|
|
244
|
+
return fillLimited(hostId, capabilities, [fact(hostId, "cli", "available", ["The explicitly bound executable completed successfully."]), fact(hostId, "version", "available", ["A semantic version was parsed from the frozen version output."], { observedVersion })]);
|
|
245
|
+
}
|
package/src/index.mjs
CHANGED
|
@@ -17,6 +17,9 @@ export const HARNESS_CAPABILITIES = Object.freeze([
|
|
|
17
17
|
"temporary-workspace",
|
|
18
18
|
"resource-closure",
|
|
19
19
|
"operation-envelope",
|
|
20
|
+
"host-adapter-mechanism",
|
|
21
|
+
"report-rendering",
|
|
22
|
+
"durable-state-store",
|
|
20
23
|
]);
|
|
21
24
|
|
|
22
25
|
export const HARNESS_EXCLUSIONS = Object.freeze([
|
|
@@ -50,3 +53,51 @@ export {
|
|
|
50
53
|
} from "./validation.mjs";
|
|
51
54
|
|
|
52
55
|
export { parseRequest, processRequest } from "./request.mjs";
|
|
56
|
+
|
|
57
|
+
// Generic host mechanism. Concrete host profiles and audited driver vectors
|
|
58
|
+
// are injected by downstream orchestration; the harness never imports them.
|
|
59
|
+
export {
|
|
60
|
+
normalizeAdapterSource,
|
|
61
|
+
buildAdapterClosure,
|
|
62
|
+
verifyAdapterBuildManifest,
|
|
63
|
+
materializeAdapterBuild,
|
|
64
|
+
probeVersionVector,
|
|
65
|
+
} from "./host.mjs";
|
|
66
|
+
|
|
67
|
+
// Deterministic report layer (FND-ADR-005): validate -> render -> bind -> check.
|
|
68
|
+
// Pure functions only: no clock, no environment, no network, no model calls.
|
|
69
|
+
export {
|
|
70
|
+
REPORT_RENDERER_NAME,
|
|
71
|
+
REPORT_RENDERER_VERSION,
|
|
72
|
+
SUPPORTED_REPORT_LOCALES,
|
|
73
|
+
EXECUTION_STATUSES,
|
|
74
|
+
RESULT_STATE_EXECUTION_STATUSES,
|
|
75
|
+
REPORT_AUDIENCES,
|
|
76
|
+
REPORT_STYLE_RULES,
|
|
77
|
+
validateReportModel,
|
|
78
|
+
renderReportMarkdown,
|
|
79
|
+
computeResultDigest,
|
|
80
|
+
computeModelDigest,
|
|
81
|
+
digestReport,
|
|
82
|
+
buildBinding,
|
|
83
|
+
verifyBinding,
|
|
84
|
+
checkReport,
|
|
85
|
+
collectStyleWarnings,
|
|
86
|
+
} from "./report.mjs";
|
|
87
|
+
|
|
88
|
+
// Durable state mechanism only. Event meaning and reducer transitions remain
|
|
89
|
+
// consumer-owned; the harness owns ordering, integrity, fencing and recovery.
|
|
90
|
+
export {
|
|
91
|
+
STATE_GENESIS_DIGEST,
|
|
92
|
+
openStateStore,
|
|
93
|
+
inspectStateStoreLock,
|
|
94
|
+
recoverStateStoreLock,
|
|
95
|
+
appendEvent,
|
|
96
|
+
readEvents,
|
|
97
|
+
readSnapshot,
|
|
98
|
+
writeSnapshot,
|
|
99
|
+
verifyStateStore,
|
|
100
|
+
rebuildSnapshot,
|
|
101
|
+
closeStateStore,
|
|
102
|
+
close,
|
|
103
|
+
} from "./state-store.mjs";
|