skill-family-harness-node 0.1.3 → 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/README.md +20 -4
- package/package.json +2 -2
- 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/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@0.
|
|
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@0.
|
|
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/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,7 +27,7 @@
|
|
|
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
32
|
"check": "node --test",
|
|
33
33
|
"test": "node --test"
|
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";
|