skill-family-harness-node 0.12.0 → 0.13.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/CHANGELOG.md +19 -0
- package/CHANGELOG.zh-CN.md +19 -0
- package/README.md +19 -7
- package/README.zh-CN.md +19 -7
- package/SECURITY.md +2 -2
- package/package.json +2 -2
- package/release-notes/0.13.0.yaml +19 -0
- package/src/bound-read.mjs +2 -1
- package/src/filesystem-observation.mjs +74 -0
- package/src/index.mjs +4 -0
- package/src/native/assemble-stable-prebuild.mjs +1 -1
- package/src/native/bound_read.c +144 -0
- package/src/native/loader.mjs +1 -1
- package/src/native/prebuild-manifest.json +8 -4
- package/src/native/prebuild-release-receipt.json +10 -10
- package/src/native/prebuild-sbom.json +5 -5
- package/src/native/prebuilds/darwin-arm64/bound_read.darwin-arm64.node +0 -0
- package/src/native/prebuilds/darwin-x64/bound_read.darwin-x64.node +0 -0
- package/src/native/prebuilds/linux-arm64-gnu/bound_read.linux-arm64-gnu.node +0 -0
- package/src/native/prebuilds/linux-x64-gnu/bound_read.linux-x64-gnu.node +0 -0
- package/src/supervise-process.mjs +67 -13
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
<!-- release-skill:changelog:start version=0.13.0 locale=en baseline=sha256:ba0667eb4b4805aafc2423cd79c04df3c835b8ae9bb07391867175f6ad96c36b -->
|
|
4
|
+
## [0.13.0] - 2026-08-26
|
|
5
|
+
|
|
6
|
+
Harness 0.13.0 is a source candidate for complete bound-tree observations and optional subprocess output limits.
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- Adds observeFilesystemTree({ root, rootBinding }) as a public candidate entry for fresh private tree facts, including file bytes and POSIX mode observations.
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Extends superviseProcess with independent stdout/stderr byte limits using the existing termination path. Equal-to-limit output is allowed; uncapped behavior remains compatible.
|
|
15
|
+
|
|
16
|
+
### Upgrade Notes
|
|
17
|
+
|
|
18
|
+
Pin Contracts and Harness together. Tree observation does not apply payload acceptance policy or promise a transaction snapshot. Candidate preparation is not publication.
|
|
19
|
+
<!-- release-skill:changelog:end version=0.13.0 locale=en -->
|
|
20
|
+
|
|
21
|
+
|
|
3
22
|
<!-- release-skill:changelog:start version=0.12.0 locale=en baseline=sha256:0074df3a947706f6cd6d8593968a348cd4d3f38d8f2bf75f15a04c8183d76618 -->
|
|
4
23
|
## [0.12.0] - 2026-08-26
|
|
5
24
|
|
package/CHANGELOG.zh-CN.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# 变更日志
|
|
2
2
|
|
|
3
|
+
<!-- release-skill:changelog:start version=0.13.0 locale=zh-CN baseline=sha256:1356b2ecff87da4a8bbe5bc04980b125ddf181873ff2b536be87a2e246b59609 -->
|
|
4
|
+
## [0.13.0] - 2026-08-26
|
|
5
|
+
|
|
6
|
+
Harness 0.13.0 源码候选增加完整绑定树观察与可选子进程输出上限。
|
|
7
|
+
|
|
8
|
+
### 新增
|
|
9
|
+
|
|
10
|
+
- 新增公开候选入口 observeFilesystemTree({ root, rootBinding }),每次读取私有树事实,包含文件字节与 POSIX 模式观察。
|
|
11
|
+
|
|
12
|
+
### 变更
|
|
13
|
+
|
|
14
|
+
- 为 superviseProcess 增加 stdout/stderr 独立字节上限,复用既有终止路径;等于上限允许,未设置上限保持旧行为。
|
|
15
|
+
|
|
16
|
+
### 升级说明
|
|
17
|
+
|
|
18
|
+
Contracts 与 Harness 须精确锁步。树观察不执行载荷接受政策,也不承诺事务快照。候选准备不代表发布完成。
|
|
19
|
+
<!-- release-skill:changelog:end version=0.13.0 locale=zh-CN -->
|
|
20
|
+
|
|
21
|
+
|
|
3
22
|
<!-- release-skill:changelog:start version=0.12.0 locale=zh-CN baseline=sha256:2e7db41a08c30d9e0bdc16b4cd27853104ef79dc02f7e9925a1383a6546f5111 -->
|
|
4
23
|
## [0.12.0] - 2026-08-26
|
|
5
24
|
|
package/README.md
CHANGED
|
@@ -4,22 +4,26 @@
|
|
|
4
4
|
|
|
5
5
|
# skill-family-harness-node
|
|
6
6
|
|
|
7
|
-
<!-- release-skill:release-version: 0.
|
|
7
|
+
<!-- release-skill:release-version: 0.13.0 -->
|
|
8
8
|
|
|
9
9
|
The **single default Node implementation** of the Contracts mechanism protocol. This is a thin runtime: it only implements the mechanism protocol, introduces no business semantics, and does not provide a second-language implementation.
|
|
10
10
|
|
|
11
11
|
<!-- release-skill:managed:start id=latest-release -->
|
|
12
|
-
**0.
|
|
12
|
+
**0.13.0** (2026-08-26)
|
|
13
13
|
|
|
14
|
-
Harness 0.
|
|
14
|
+
Harness 0.13.0 is a source candidate for complete bound-tree observations and optional subprocess output limits.
|
|
15
|
+
|
|
16
|
+
**Added**
|
|
17
|
+
|
|
18
|
+
- Adds observeFilesystemTree({ root, rootBinding }) as a public candidate entry for fresh private tree facts, including file bytes and POSIX mode observations.
|
|
15
19
|
|
|
16
20
|
**Changed**
|
|
17
21
|
|
|
18
|
-
-
|
|
22
|
+
- Extends superviseProcess with independent stdout/stderr byte limits using the existing termination path. Equal-to-limit output is allowed; uncapped behavior remains compatible.
|
|
19
23
|
|
|
20
24
|
**Upgrade Notes**
|
|
21
25
|
|
|
22
|
-
|
|
26
|
+
Pin Contracts and Harness together. Tree observation does not apply payload acceptance policy or promise a transaction snapshot. Candidate preparation is not publication.
|
|
23
27
|
<!-- release-skill:managed:end id=latest-release -->
|
|
24
28
|
|
|
25
29
|
## Problem It Solves
|
|
@@ -32,15 +36,17 @@ The Harness consumes `skill-family-contracts` (a workspace dependency), reusing
|
|
|
32
36
|
|
|
33
37
|
## Installation and Minimal Example
|
|
34
38
|
|
|
39
|
+
Version 0.13.0 is not published. The registry command below is for use after publication; this iteration installs the three local candidate tarballs in an isolated directory.
|
|
40
|
+
|
|
35
41
|
```sh
|
|
36
|
-
npm install skill-family-harness-node@0.
|
|
42
|
+
npm install skill-family-harness-node@0.13.0
|
|
37
43
|
npm info skill-family-harness-node --help
|
|
38
44
|
```
|
|
39
45
|
|
|
40
46
|
The minimal example shows validating a contract document inside Node:
|
|
41
47
|
|
|
42
48
|
```js
|
|
43
|
-
// Run from an empty directory: npm install skill-family-harness-node@0.
|
|
49
|
+
// Run from an empty directory: npm install skill-family-harness-node@0.13.0
|
|
44
50
|
import { validateContractDocument } from "skill-family-harness-node";
|
|
45
51
|
|
|
46
52
|
const document = {
|
|
@@ -218,3 +224,9 @@ Mechanism failures uniformly throw `SFC2004` (EXECUTION_FAILED), with `details.k
|
|
|
218
224
|
- Package-local source: `src/*.mjs`.
|
|
219
225
|
- Package-local candidate source: `candidate/quickstart-profile.mjs`; canonical public import: `skill-family-harness-node/quickstart-profile`; historical migration alias: `skill-family-harness-node/candidate/quickstart-profile`.
|
|
220
226
|
<!-- agent-quick-reference:end -->
|
|
227
|
+
|
|
228
|
+
## Complete Plugin Candidate
|
|
229
|
+
|
|
230
|
+
The candidate observeFilesystemTree({ root, rootBinding }) reads complete tree facts. Existing superviseProcess accepts optional per-stream raw-byte caps. Observing a payload does not accept it.
|
|
231
|
+
|
|
232
|
+
Version 0.13.0 is a local source candidate and is not published. Consume the three locally verified tarballs; a version marker, unit test or successful install is not complete host qualification or release approval.
|
package/README.zh-CN.md
CHANGED
|
@@ -5,22 +5,26 @@
|
|
|
5
5
|
|
|
6
6
|
# skill-family-harness-node
|
|
7
7
|
|
|
8
|
-
<!-- release-skill:release-version: 0.
|
|
8
|
+
<!-- release-skill:release-version: 0.13.0 -->
|
|
9
9
|
|
|
10
10
|
Contracts 机制协议的**唯一默认 Node 实现**。这是一个薄运行时(thin runtime):只实现机制协议,不引入业务语义,不做第二语言实现。
|
|
11
11
|
|
|
12
12
|
<!-- release-skill:managed:start id=latest-release -->
|
|
13
|
-
**0.
|
|
13
|
+
**0.13.0** (2026-08-26)
|
|
14
14
|
|
|
15
|
-
Harness 0.
|
|
15
|
+
Harness 0.13.0 源码候选增加完整绑定树观察与可选子进程输出上限。
|
|
16
|
+
|
|
17
|
+
**新增**
|
|
18
|
+
|
|
19
|
+
- 新增公开候选入口 observeFilesystemTree({ root, rootBinding }),每次读取私有树事实,包含文件字节与 POSIX 模式观察。
|
|
16
20
|
|
|
17
21
|
**变更**
|
|
18
22
|
|
|
19
|
-
-
|
|
23
|
+
- 为 superviseProcess 增加 stdout/stderr 独立字节上限,复用既有终止路径;等于上限允许,未设置上限保持旧行为。
|
|
20
24
|
|
|
21
25
|
**升级说明**
|
|
22
26
|
|
|
23
|
-
|
|
27
|
+
Contracts 与 Harness 须精确锁步。树观察不执行载荷接受政策,也不承诺事务快照。候选准备不代表发布完成。
|
|
24
28
|
<!-- release-skill:managed:end id=latest-release -->
|
|
25
29
|
|
|
26
30
|
## 解决的问题
|
|
@@ -33,15 +37,17 @@ Harness 消费 `skill-family-contracts`(工作区依赖),复用其方言
|
|
|
33
37
|
|
|
34
38
|
## 安装和最小示例
|
|
35
39
|
|
|
40
|
+
0.13.0 尚未发布。下面的 registry 安装命令供发布后使用;本轮验证应在隔离目录安装三个本地候选 tarball。
|
|
41
|
+
|
|
36
42
|
```sh
|
|
37
|
-
npm install skill-family-harness-node@0.
|
|
43
|
+
npm install skill-family-harness-node@0.13.0
|
|
38
44
|
npm info skill-family-harness-node --help
|
|
39
45
|
```
|
|
40
46
|
|
|
41
47
|
最小示例演示在 Node 内校验一份契约文档:
|
|
42
48
|
|
|
43
49
|
```js
|
|
44
|
-
// 从空目录运行:npm install skill-family-harness-node@0.
|
|
50
|
+
// 从空目录运行:npm install skill-family-harness-node@0.13.0
|
|
45
51
|
import { validateContractDocument } from "skill-family-harness-node";
|
|
46
52
|
|
|
47
53
|
const document = {
|
|
@@ -218,3 +224,9 @@ v2 机制会重算每个 path-backed output 和 evidence Resource 的真实字
|
|
|
218
224
|
- 包内源:`src/*.mjs`。
|
|
219
225
|
- 包内 Candidate 源:`candidate/quickstart-profile.mjs`;规范公共导入:`skill-family-harness-node/quickstart-profile`;历史迁移别名:`skill-family-harness-node/candidate/quickstart-profile`。
|
|
220
226
|
<!-- agent-quick-reference:end -->
|
|
227
|
+
|
|
228
|
+
## 完整插件候选能力
|
|
229
|
+
|
|
230
|
+
新增候选 observeFilesystemTree({ root, rootBinding }) 读取完整树事实;既有 superviseProcess 支持可选每流原始字节上限。观察完成不等于接受载荷。
|
|
231
|
+
|
|
232
|
+
0.13.0 为本地源码候选,尚未发布。消费本地已验证的三包 tarball,不能把版本标记、单元测试或安装成功当作完整宿主资格与发布批准。
|
package/SECURITY.md
CHANGED
package/package.json
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@pnpm/lockfile.fs": "1001.1.35",
|
|
12
12
|
"ipaddr.js": "2.5.0",
|
|
13
|
-
"skill-family-contracts": "0.
|
|
13
|
+
"skill-family-contracts": "0.13.0",
|
|
14
14
|
"yaml": "2.9.0"
|
|
15
15
|
},
|
|
16
16
|
"description": "Thin Node.js mechanism runtime for Skill Family engineering contracts.",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"url": "https://github.com/ifoohoo/skill-family-harness-node.git"
|
|
49
49
|
},
|
|
50
50
|
"type": "module",
|
|
51
|
-
"version": "0.
|
|
51
|
+
"version": "0.13.0",
|
|
52
52
|
"scripts": {
|
|
53
53
|
"check": "node --test",
|
|
54
54
|
"test": "node --test"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
version: 0.13.0
|
|
2
|
+
date: 2026-08-26
|
|
3
|
+
locales:
|
|
4
|
+
en:
|
|
5
|
+
summary: Harness 0.13.0 is a source candidate for complete bound-tree observations and optional subprocess output limits.
|
|
6
|
+
changes:
|
|
7
|
+
added:
|
|
8
|
+
- Adds observeFilesystemTree({ root, rootBinding }) as a public candidate entry for fresh private tree facts, including file bytes and POSIX mode observations.
|
|
9
|
+
changed:
|
|
10
|
+
- Extends superviseProcess with independent stdout/stderr byte limits using the existing termination path. Equal-to-limit output is allowed; uncapped behavior remains compatible.
|
|
11
|
+
upgradeNotes: Pin Contracts and Harness together. Tree observation does not apply payload acceptance policy or promise a transaction snapshot. Candidate preparation is not publication.
|
|
12
|
+
zh-CN:
|
|
13
|
+
summary: Harness 0.13.0 源码候选增加完整绑定树观察与可选子进程输出上限。
|
|
14
|
+
changes:
|
|
15
|
+
added:
|
|
16
|
+
- 新增公开候选入口 observeFilesystemTree({ root, rootBinding }),每次读取私有树事实,包含文件字节与 POSIX 模式观察。
|
|
17
|
+
changed:
|
|
18
|
+
- 为 superviseProcess 增加 stdout/stderr 独立字节上限,复用既有终止路径;等于上限允许,未设置上限保持旧行为。
|
|
19
|
+
upgradeNotes: Contracts 与 Harness 须精确锁步。树观察不执行载荷接受政策,也不承诺事务快照。候选准备不代表发布完成。
|
package/src/bound-read.mjs
CHANGED
|
@@ -161,7 +161,7 @@ export async function createFilesystemRootBinding(root) {
|
|
|
161
161
|
}
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
-
function assertRelativePath(relPath) {
|
|
164
|
+
export function assertRelativePath(relPath) {
|
|
165
165
|
if (typeof relPath !== "string" || relPath.length === 0 || relPath.includes("\0") ||
|
|
166
166
|
relPath.includes("\\") || path.posix.isAbsolute(relPath)) {
|
|
167
167
|
throw mechanismError(HARNESS_ERROR_KINDS.INVALID_PATH, "bound read path is not a contained relative path");
|
|
@@ -269,6 +269,7 @@ export async function readFileBound(root, relPath, { rootBinding, encoding, expe
|
|
|
269
269
|
sha256,
|
|
270
270
|
bytes: bytes.length,
|
|
271
271
|
mode: Number(native.leafMode),
|
|
272
|
+
statMode: Number(native.statMode),
|
|
272
273
|
rootMode: captured.identity.mode,
|
|
273
274
|
});
|
|
274
275
|
} finally {
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { digestDocument } from "skill-family-contracts";
|
|
3
|
+
import { digestBytes } from "./closure.mjs";
|
|
4
|
+
import { HARNESS_ERROR_KINDS, mechanismError } from "./errors.mjs";
|
|
5
|
+
import { assertRelativePath, createFilesystemRootBinding } from "./bound-read.mjs";
|
|
6
|
+
import { loadNativeBoundReadAddon } from "./native/loader.mjs";
|
|
7
|
+
|
|
8
|
+
const SHA256_PATTERN = /^[0-9a-f]{64}$/u;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Observe every directory and regular-file member of one already bound tree.
|
|
12
|
+
* Native code opens each member relative to the root and supplies its bytes
|
|
13
|
+
* and descriptor facts; this layer only derives the public contract shape.
|
|
14
|
+
*/
|
|
15
|
+
export async function observeFilesystemTree({ root, rootBinding } = {}) {
|
|
16
|
+
if (typeof root !== "string" || !path.isAbsolute(root) || path.normalize(root) !== root) {
|
|
17
|
+
throw new TypeError("observeFilesystemTree: root must be a normalized absolute path");
|
|
18
|
+
}
|
|
19
|
+
if (!rootBinding || typeof rootBinding !== "object" ||
|
|
20
|
+
typeof rootBinding.digest !== "string" || !SHA256_PATTERN.test(rootBinding.digest)) {
|
|
21
|
+
throw new TypeError("observeFilesystemTree: rootBinding is required");
|
|
22
|
+
}
|
|
23
|
+
const startBinding = await createFilesystemRootBinding(root);
|
|
24
|
+
if (startBinding.digest !== rootBinding.digest) {
|
|
25
|
+
throw mechanismError(HARNESS_ERROR_KINDS.UNSAFE_STATE_ENTRY, "approved root binding does not match the current root", {
|
|
26
|
+
boundReadDisposition: "boundary-indeterminate",
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
const { addon } = await loadNativeBoundReadAddon();
|
|
30
|
+
if (typeof addon.observeFilesystemTreeNative !== "function") {
|
|
31
|
+
throw mechanismError(HARNESS_ERROR_KINDS.UNSUPPORTED_PLATFORM, "native filesystem observation is unavailable in this prebuild");
|
|
32
|
+
}
|
|
33
|
+
let native;
|
|
34
|
+
try {
|
|
35
|
+
native = addon.observeFilesystemTreeNative(root);
|
|
36
|
+
} catch (cause) {
|
|
37
|
+
throw mechanismError(HARNESS_ERROR_KINDS.READ_FAILED, "filesystem tree observation failed", {
|
|
38
|
+
cause: cause?.message ?? "unknown",
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
const endBinding = await createFilesystemRootBinding(root);
|
|
42
|
+
if (endBinding.digest !== rootBinding.digest || native.rootDevice === undefined || native.rootInode === undefined ||
|
|
43
|
+
Number(native.rootMode) !== Number.parseInt(native.rootMode, 10)) {
|
|
44
|
+
throw mechanismError(HARNESS_ERROR_KINDS.UNSAFE_STATE_ENTRY, "root identity changed during filesystem tree observation", {
|
|
45
|
+
boundReadDisposition: "boundary-indeterminate",
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
const paths = new Set();
|
|
49
|
+
const members = Array.from(native.members ?? [], (member) => {
|
|
50
|
+
assertRelativePath(member.path);
|
|
51
|
+
const alias = member.path.toLowerCase();
|
|
52
|
+
if (paths.has(alias)) throw mechanismError(HARNESS_ERROR_KINDS.INVALID_PATH, "filesystem tree contains duplicate or case-alias member paths");
|
|
53
|
+
paths.add(alias);
|
|
54
|
+
if (member.type === "directory") {
|
|
55
|
+
return { path: member.path, type: "directory", statMode: Number(member.statMode) };
|
|
56
|
+
}
|
|
57
|
+
const bytes = Buffer.from(member.content);
|
|
58
|
+
return {
|
|
59
|
+
path: member.path,
|
|
60
|
+
type: "file",
|
|
61
|
+
sha256: digestBytes(bytes),
|
|
62
|
+
bytes: bytes.length,
|
|
63
|
+
statMode: Number(member.statMode),
|
|
64
|
+
contentBase64: bytes.toString("base64"),
|
|
65
|
+
};
|
|
66
|
+
}).sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
|
|
67
|
+
return Object.freeze({
|
|
68
|
+
schemaVersion: 1,
|
|
69
|
+
kind: "skill-family.filesystem-tree-observation",
|
|
70
|
+
rootBinding,
|
|
71
|
+
members: Object.freeze(members.map((member) => Object.freeze(member))),
|
|
72
|
+
membersDigest: digestDocument(members),
|
|
73
|
+
});
|
|
74
|
+
}
|
package/src/index.mjs
CHANGED
|
@@ -49,6 +49,10 @@ export { HarnessError, HARNESS_ERROR_KINDS, mechanismError } from "./errors.mjs"
|
|
|
49
49
|
|
|
50
50
|
export { classifyPathInput, resolveContained, readFileContained } from "./paths.mjs";
|
|
51
51
|
|
|
52
|
+
// Complete bound-tree facts for plugin verification. The native reader owns
|
|
53
|
+
// descriptor-relative traversal and same-descriptor fstat/read checks.
|
|
54
|
+
export { observeFilesystemTree } from "./filesystem-observation.mjs";
|
|
55
|
+
|
|
52
56
|
// Strict contained read (FG-1): no-follow, regular-file identity assertion,
|
|
53
57
|
// and a post-read sha256 digest receipt for existing authority files.
|
|
54
58
|
export { readFileStrict } from "./strict-read.mjs";
|
|
@@ -16,7 +16,7 @@ const PLATFORMS = Object.freeze({
|
|
|
16
16
|
"linux-arm64-gnu": { os: "linux", arch: "arm64", libc: "glibc" },
|
|
17
17
|
"linux-x64-gnu": { os: "linux", arch: "x64", libc: "glibc" },
|
|
18
18
|
});
|
|
19
|
-
const EXPORTS = Object.freeze(["closeParentDirectory", "openParentDirectory", "platform", "readFileBoundNative", "renameDirectoryNoReplace"]);
|
|
19
|
+
const EXPORTS = Object.freeze(["closeParentDirectory", "observeFilesystemTreeNative", "openParentDirectory", "platform", "readFileBoundNative", "renameDirectoryNoReplace"]);
|
|
20
20
|
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
21
21
|
const bindingGypSha256 = sha256(readFileSync(BINDING_GYP));
|
|
22
22
|
|
package/src/native/bound_read.c
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
#include <string.h>
|
|
12
12
|
#include <sys/stat.h>
|
|
13
13
|
#include <sys/types.h>
|
|
14
|
+
#include <dirent.h>
|
|
14
15
|
#include <unistd.h>
|
|
15
16
|
|
|
16
17
|
#if defined(__APPLE__)
|
|
@@ -254,6 +255,8 @@ static napi_value ReadFileBoundNative(napi_env env, napi_callback_info info) {
|
|
|
254
255
|
NAPI_OK(napi_set_named_property(env, result, "rootMode", value), "cannot set root mode");
|
|
255
256
|
NAPI_OK(napi_create_int64(env, (int64_t)(leaf_stat.st_mode & 0777), &value), "cannot encode leaf mode");
|
|
256
257
|
NAPI_OK(napi_set_named_property(env, result, "leafMode", value), "cannot set leaf mode");
|
|
258
|
+
NAPI_OK(napi_create_int64(env, (int64_t)leaf_stat.st_mode, &value), "cannot encode leaf stat mode");
|
|
259
|
+
NAPI_OK(napi_set_named_property(env, result, "statMode", value), "cannot set leaf stat mode");
|
|
257
260
|
char decimal[64];
|
|
258
261
|
snprintf(decimal, sizeof(decimal), "%llu", (unsigned long long)root_stat.st_dev);
|
|
259
262
|
if (set_string(env, result, "rootDevice", decimal) == NULL) return NULL;
|
|
@@ -418,10 +421,151 @@ static napi_value RenameDirectoryNoReplace(napi_env env, napi_callback_info info
|
|
|
418
421
|
return result;
|
|
419
422
|
}
|
|
420
423
|
|
|
424
|
+
/* A complete bound-tree observation is deliberately implemented beside the
|
|
425
|
+
* existing descriptor-relative reader. Every regular member is opened with
|
|
426
|
+
* openat(O_NOFOLLOW), fstat'd on that descriptor, read from that descriptor,
|
|
427
|
+
* and fstat'd again before the descriptor is closed. JavaScript only receives
|
|
428
|
+
* the bytes and facts from that one descriptor; it never supplements them
|
|
429
|
+
* with path-based lstat calls. */
|
|
430
|
+
static int same_member_stat(const struct stat *before, const struct stat *after) {
|
|
431
|
+
return before->st_dev == after->st_dev && before->st_ino == after->st_ino &&
|
|
432
|
+
before->st_mode == after->st_mode && before->st_nlink == after->st_nlink &&
|
|
433
|
+
before->st_size == after->st_size && before->st_mtime == after->st_mtime &&
|
|
434
|
+
before->st_ctime == after->st_ctime;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
static int set_int64(napi_env env, napi_value object, const char *name, int64_t value) {
|
|
438
|
+
napi_value encoded;
|
|
439
|
+
if (napi_create_int64(env, value, &encoded) != napi_ok ||
|
|
440
|
+
napi_set_named_property(env, object, name, encoded) != napi_ok) {
|
|
441
|
+
napi_throw_error(env, NULL, "cannot encode filesystem observation fact");
|
|
442
|
+
return 0;
|
|
443
|
+
}
|
|
444
|
+
return 1;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
static int set_member_string(napi_env env, napi_value object, const char *name, const char *value) {
|
|
448
|
+
return set_string(env, object, name, value) != NULL;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
static int append_directory_member(napi_env env, napi_value members, uint32_t *count,
|
|
452
|
+
const char *relative, const struct stat *st) {
|
|
453
|
+
napi_value member;
|
|
454
|
+
if (napi_create_object(env, &member) != napi_ok) { napi_throw_error(env, NULL, "cannot create directory observation"); return 0; }
|
|
455
|
+
if (!set_member_string(env, member, "path", relative) ||
|
|
456
|
+
!set_member_string(env, member, "type", "directory") ||
|
|
457
|
+
!set_int64(env, member, "statMode", (int64_t)st->st_mode)) return 0;
|
|
458
|
+
if (napi_set_element(env, members, (*count)++, member) != napi_ok) { napi_throw_error(env, NULL, "cannot append directory observation"); return 0; }
|
|
459
|
+
return 1;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
static int append_file_member(napi_env env, napi_value members, uint32_t *count,
|
|
463
|
+
const char *relative, const struct stat *st,
|
|
464
|
+
const unsigned char *bytes, size_t length) {
|
|
465
|
+
napi_value member, buffer;
|
|
466
|
+
if (napi_create_object(env, &member) != napi_ok) { napi_throw_error(env, NULL, "cannot create file observation"); return 0; }
|
|
467
|
+
if (!set_member_string(env, member, "path", relative) ||
|
|
468
|
+
!set_member_string(env, member, "type", "file") ||
|
|
469
|
+
!set_int64(env, member, "statMode", (int64_t)st->st_mode) ||
|
|
470
|
+
!set_int64(env, member, "bytes", (int64_t)length)) return 0;
|
|
471
|
+
if (napi_create_buffer_copy(env, length, bytes, NULL, &buffer) != napi_ok ||
|
|
472
|
+
napi_set_named_property(env, member, "content", buffer) != napi_ok ||
|
|
473
|
+
napi_set_element(env, members, (*count)++, member) != napi_ok) { napi_throw_error(env, NULL, "cannot append file observation"); return 0; }
|
|
474
|
+
return 1;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
static int observe_directory(napi_env env, int dir_fd, const char *prefix,
|
|
478
|
+
napi_value members, uint32_t *count) {
|
|
479
|
+
int scan_fd = dup(dir_fd);
|
|
480
|
+
if (scan_fd < 0) { napi_throw_error(env, NULL, "filesystem observation directory duplication failed"); return 0; }
|
|
481
|
+
DIR *directory = fdopendir(scan_fd);
|
|
482
|
+
if (directory == NULL) { close(scan_fd); napi_throw_error(env, NULL, "filesystem observation directory open failed"); return 0; }
|
|
483
|
+
struct dirent *entry;
|
|
484
|
+
while ((entry = readdir(directory)) != NULL) {
|
|
485
|
+
if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) continue;
|
|
486
|
+
char relative[PATH_MAX];
|
|
487
|
+
int written = snprintf(relative, sizeof(relative), "%s%s%s", prefix,
|
|
488
|
+
prefix[0] == '\0' ? "" : "/", entry->d_name);
|
|
489
|
+
if (written < 1 || (size_t)written >= sizeof(relative)) {
|
|
490
|
+
closedir(directory); napi_throw_error(env, NULL, "filesystem observation path is too long"); return 0;
|
|
491
|
+
}
|
|
492
|
+
struct stat named_stat;
|
|
493
|
+
if (fstatat(dir_fd, entry->d_name, &named_stat, AT_SYMLINK_NOFOLLOW) != 0 ||
|
|
494
|
+
(!S_ISDIR(named_stat.st_mode) && !S_ISREG(named_stat.st_mode))) {
|
|
495
|
+
closedir(directory); napi_throw_error(env, NULL, "filesystem observation encountered a symlink, special file, or changed member"); return 0;
|
|
496
|
+
}
|
|
497
|
+
int child_fd = openat(dir_fd, entry->d_name, O_RDONLY | O_NOFOLLOW | O_CLOEXEC);
|
|
498
|
+
if (child_fd < 0) { closedir(directory); napi_throw_error(env, NULL, "filesystem observation member changed or cannot be opened"); return 0; }
|
|
499
|
+
struct stat before;
|
|
500
|
+
if (fstat(child_fd, &before) != 0) { close(child_fd); closedir(directory); napi_throw_error(env, NULL, "filesystem observation member cannot be stat'd"); return 0; }
|
|
501
|
+
if (S_ISDIR(before.st_mode)) {
|
|
502
|
+
if (!append_directory_member(env, members, count, relative, &before) ||
|
|
503
|
+
!observe_directory(env, child_fd, relative, members, count)) {
|
|
504
|
+
close(child_fd); closedir(directory); return 0;
|
|
505
|
+
}
|
|
506
|
+
close(child_fd);
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
if (!S_ISREG(before.st_mode) || before.st_nlink != 1 || before.st_size < 0) {
|
|
510
|
+
close(child_fd); closedir(directory); napi_throw_error(env, NULL, "filesystem observation encountered an unsupported member"); return 0;
|
|
511
|
+
}
|
|
512
|
+
size_t length = 0, capacity = before.st_size > 0 ? (size_t)before.st_size : 1;
|
|
513
|
+
unsigned char *bytes = malloc(capacity);
|
|
514
|
+
if (bytes == NULL) { close(child_fd); closedir(directory); napi_throw_error(env, NULL, "filesystem observation allocation failed"); return 0; }
|
|
515
|
+
for (;;) {
|
|
516
|
+
if (length == capacity) {
|
|
517
|
+
if (capacity > SIZE_MAX / 2) { free(bytes); close(child_fd); closedir(directory); napi_throw_error(env, NULL, "filesystem observation file is too large"); return 0; }
|
|
518
|
+
size_t next = capacity * 2; unsigned char *grown = realloc(bytes, next);
|
|
519
|
+
if (grown == NULL) { free(bytes); close(child_fd); closedir(directory); napi_throw_error(env, NULL, "filesystem observation allocation failed"); return 0; }
|
|
520
|
+
bytes = grown; capacity = next;
|
|
521
|
+
}
|
|
522
|
+
ssize_t read_count = read(child_fd, bytes + length, capacity - length);
|
|
523
|
+
if (read_count < 0) { free(bytes); close(child_fd); closedir(directory); napi_throw_error(env, NULL, "filesystem observation read failed"); return 0; }
|
|
524
|
+
if (read_count == 0) break;
|
|
525
|
+
length += (size_t)read_count;
|
|
526
|
+
}
|
|
527
|
+
struct stat after;
|
|
528
|
+
if (fstat(child_fd, &after) != 0 || !same_member_stat(&before, &after) || (size_t)after.st_size != length) {
|
|
529
|
+
free(bytes); close(child_fd); closedir(directory); napi_throw_error(env, NULL, "filesystem observation member changed while being read"); return 0;
|
|
530
|
+
}
|
|
531
|
+
int ok = append_file_member(env, members, count, relative, &after, bytes, length);
|
|
532
|
+
free(bytes); close(child_fd);
|
|
533
|
+
if (!ok) { closedir(directory); return 0; }
|
|
534
|
+
}
|
|
535
|
+
if (closedir(directory) != 0) { napi_throw_error(env, NULL, "filesystem observation directory close failed"); return 0; }
|
|
536
|
+
return 1;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
static napi_value ObserveFilesystemTreeNative(napi_env env, napi_callback_info info) {
|
|
540
|
+
size_t argc = 1; napi_value argv[1], result, members;
|
|
541
|
+
char root[PATH_MAX];
|
|
542
|
+
NAPI_OK(napi_get_cb_info(env, info, &argc, argv, NULL, NULL), "cannot read native arguments");
|
|
543
|
+
if (argc != 1 || !read_string(env, argv[0], root, sizeof(root), "root must be a bounded absolute path") || root[0] != '/') return NULL;
|
|
544
|
+
int root_fd = open(root, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
|
545
|
+
if (root_fd < 0) return throw_last(env, "filesystem observation root cannot be opened");
|
|
546
|
+
struct stat root_stat;
|
|
547
|
+
if (fstat(root_fd, &root_stat) != 0 || !S_ISDIR(root_stat.st_mode)) { close(root_fd); return throw_last(env, "filesystem observation root is not a directory"); }
|
|
548
|
+
NAPI_OK(napi_create_object(env, &result), "cannot create filesystem observation");
|
|
549
|
+
NAPI_OK(napi_create_array(env, &members), "cannot create filesystem observation members");
|
|
550
|
+
uint32_t count = 0;
|
|
551
|
+
if (!observe_directory(env, root_fd, "", members, &count)) { close(root_fd); return NULL; }
|
|
552
|
+
close(root_fd);
|
|
553
|
+
NAPI_OK(napi_set_named_property(env, result, "members", members), "cannot set filesystem observation members");
|
|
554
|
+
if (!set_int64(env, result, "rootMode", (int64_t)root_stat.st_mode)) return NULL;
|
|
555
|
+
char decimal[64];
|
|
556
|
+
snprintf(decimal, sizeof(decimal), "%llu", (unsigned long long)root_stat.st_dev);
|
|
557
|
+
if (!set_member_string(env, result, "rootDevice", decimal)) return NULL;
|
|
558
|
+
snprintf(decimal, sizeof(decimal), "%llu", (unsigned long long)root_stat.st_ino);
|
|
559
|
+
if (!set_member_string(env, result, "rootInode", decimal)) return NULL;
|
|
560
|
+
return result;
|
|
561
|
+
}
|
|
562
|
+
|
|
421
563
|
static napi_value Init(napi_env env, napi_value exports) {
|
|
422
564
|
napi_value value;
|
|
423
565
|
NAPI_OK(napi_create_function(env, "readFileBoundNative", NAPI_AUTO_LENGTH, ReadFileBoundNative, NULL, &value), "cannot export bound read");
|
|
424
566
|
NAPI_OK(napi_set_named_property(env, exports, "readFileBoundNative", value), "cannot export bound read");
|
|
567
|
+
NAPI_OK(napi_create_function(env, "observeFilesystemTreeNative", NAPI_AUTO_LENGTH, ObserveFilesystemTreeNative, NULL, &value), "cannot export filesystem observation");
|
|
568
|
+
NAPI_OK(napi_set_named_property(env, exports, "observeFilesystemTreeNative", value), "cannot export filesystem observation");
|
|
425
569
|
NAPI_OK(napi_create_function(env, "openParentDirectory", NAPI_AUTO_LENGTH, OpenParentDirectory, NULL, &value), "cannot export openParentDirectory");
|
|
426
570
|
NAPI_OK(napi_set_named_property(env, exports, "openParentDirectory", value), "cannot export openParentDirectory");
|
|
427
571
|
NAPI_OK(napi_create_function(env, "closeParentDirectory", NAPI_AUTO_LENGTH, CloseParentDirectory, NULL, &value), "cannot export closeParentDirectory");
|
package/src/native/loader.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { fileURLToPath } from "node:url";
|
|
|
9
9
|
const PACKAGE_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
10
10
|
const MANIFEST_PATH = path.join(PACKAGE_ROOT, "prebuild-manifest.json");
|
|
11
11
|
const SHA256_PATTERN = /^[0-9a-f]{64}$/u;
|
|
12
|
-
const EXPECTED_EXPORTS = Object.freeze(["closeParentDirectory", "openParentDirectory", "platform", "readFileBoundNative", "renameDirectoryNoReplace"]);
|
|
12
|
+
const EXPECTED_EXPORTS = Object.freeze(["closeParentDirectory", "observeFilesystemTreeNative", "openParentDirectory", "platform", "readFileBoundNative", "renameDirectoryNoReplace"]);
|
|
13
13
|
const PLATFORM_KEYS = Object.freeze(["darwin-arm64", "darwin-x64", "linux-arm64-gnu", "linux-x64-gnu"]);
|
|
14
14
|
|
|
15
15
|
function fail(message) {
|
|
@@ -10,10 +10,11 @@
|
|
|
10
10
|
"libc": "none",
|
|
11
11
|
"binary": "prebuilds/darwin-arm64/bound_read.darwin-arm64.node",
|
|
12
12
|
"mode": 420,
|
|
13
|
-
"sha256": "
|
|
13
|
+
"sha256": "19ce71026b737ba6b56da78ffabca4bb25f21249c30b545d68633d4b77547fde",
|
|
14
14
|
"napi": 10,
|
|
15
15
|
"exports": [
|
|
16
16
|
"closeParentDirectory",
|
|
17
|
+
"observeFilesystemTreeNative",
|
|
17
18
|
"openParentDirectory",
|
|
18
19
|
"platform",
|
|
19
20
|
"readFileBoundNative",
|
|
@@ -27,10 +28,11 @@
|
|
|
27
28
|
"libc": "none",
|
|
28
29
|
"binary": "prebuilds/darwin-x64/bound_read.darwin-x64.node",
|
|
29
30
|
"mode": 420,
|
|
30
|
-
"sha256": "
|
|
31
|
+
"sha256": "e01e4a1df3554da857189f483174d0480eb36063ce17b5857a23fa8a02f4a664",
|
|
31
32
|
"napi": 10,
|
|
32
33
|
"exports": [
|
|
33
34
|
"closeParentDirectory",
|
|
35
|
+
"observeFilesystemTreeNative",
|
|
34
36
|
"openParentDirectory",
|
|
35
37
|
"platform",
|
|
36
38
|
"readFileBoundNative",
|
|
@@ -44,10 +46,11 @@
|
|
|
44
46
|
"libc": "glibc",
|
|
45
47
|
"binary": "prebuilds/linux-arm64-gnu/bound_read.linux-arm64-gnu.node",
|
|
46
48
|
"mode": 420,
|
|
47
|
-
"sha256": "
|
|
49
|
+
"sha256": "7b7fd1188d2bbf356fe8c44690fd80bc7fb02e9f6f9dc7ee1c10834bf1c519b1",
|
|
48
50
|
"napi": 10,
|
|
49
51
|
"exports": [
|
|
50
52
|
"closeParentDirectory",
|
|
53
|
+
"observeFilesystemTreeNative",
|
|
51
54
|
"openParentDirectory",
|
|
52
55
|
"platform",
|
|
53
56
|
"readFileBoundNative",
|
|
@@ -61,10 +64,11 @@
|
|
|
61
64
|
"libc": "glibc",
|
|
62
65
|
"binary": "prebuilds/linux-x64-gnu/bound_read.linux-x64-gnu.node",
|
|
63
66
|
"mode": 420,
|
|
64
|
-
"sha256": "
|
|
67
|
+
"sha256": "749b69ba1b7da3496e59f92f2ca8d04800031b2479c13b3ca63555ee69671acc",
|
|
65
68
|
"napi": 10,
|
|
66
69
|
"exports": [
|
|
67
70
|
"closeParentDirectory",
|
|
71
|
+
"observeFilesystemTreeNative",
|
|
68
72
|
"openParentDirectory",
|
|
69
73
|
"platform",
|
|
70
74
|
"readFileBoundNative",
|
|
@@ -8,33 +8,33 @@
|
|
|
8
8
|
},
|
|
9
9
|
"source": {
|
|
10
10
|
"path": "packages/skill-family-harness-node/src/native/bound_read.c",
|
|
11
|
-
"sha256": "
|
|
11
|
+
"sha256": "10cfc3ff44343a532629879841f9ae7a12b78d818d596f85af3915cea9832c15"
|
|
12
12
|
},
|
|
13
13
|
"buildRecipe": {
|
|
14
14
|
"path": "packages/skill-family-harness-node/src/native/binding.gyp",
|
|
15
15
|
"sha256": "1631420afe903b1978a856dbeadd37a655a704cd7ded9d1ab6696f87e1116d7f"
|
|
16
16
|
},
|
|
17
|
-
"manifestSha256": "
|
|
17
|
+
"manifestSha256": "76a1e6f99ef5980043df4f328bbe9623dfc8cbba6655e008a8bb1d53d9e49ace",
|
|
18
18
|
"inputs": [
|
|
19
19
|
{
|
|
20
20
|
"platformKey": "darwin-arm64",
|
|
21
|
-
"platformReceiptSha256": "
|
|
22
|
-
"binarySha256": "
|
|
21
|
+
"platformReceiptSha256": "eff6c0350d7cb7f949346593af1b65f5791b5545e67eba192ef66bd7af7db93d",
|
|
22
|
+
"binarySha256": "19ce71026b737ba6b56da78ffabca4bb25f21249c30b545d68633d4b77547fde"
|
|
23
23
|
},
|
|
24
24
|
{
|
|
25
25
|
"platformKey": "darwin-x64",
|
|
26
|
-
"platformReceiptSha256": "
|
|
27
|
-
"binarySha256": "
|
|
26
|
+
"platformReceiptSha256": "55254e51f14fbe7fa5e6d5a64bf23db3926a49758fbe6afb20bc4c8dbc8b30bb",
|
|
27
|
+
"binarySha256": "e01e4a1df3554da857189f483174d0480eb36063ce17b5857a23fa8a02f4a664"
|
|
28
28
|
},
|
|
29
29
|
{
|
|
30
30
|
"platformKey": "linux-arm64-gnu",
|
|
31
|
-
"platformReceiptSha256": "
|
|
32
|
-
"binarySha256": "
|
|
31
|
+
"platformReceiptSha256": "3e4df89d8b211d0700f436314ec6302999e086602bf26d68797d285d40ab7941",
|
|
32
|
+
"binarySha256": "7b7fd1188d2bbf356fe8c44690fd80bc7fb02e9f6f9dc7ee1c10834bf1c519b1"
|
|
33
33
|
},
|
|
34
34
|
{
|
|
35
35
|
"platformKey": "linux-x64-gnu",
|
|
36
|
-
"platformReceiptSha256": "
|
|
37
|
-
"binarySha256": "
|
|
36
|
+
"platformReceiptSha256": "672d066df845073da0999f69659e772dec3cf0d6b3e34bf74907a5e7fd5190c9",
|
|
37
|
+
"binarySha256": "749b69ba1b7da3496e59f92f2ca8d04800031b2479c13b3ca63555ee69671acc"
|
|
38
38
|
}
|
|
39
39
|
]
|
|
40
40
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"status": "stable",
|
|
5
5
|
"source": {
|
|
6
6
|
"path": "packages/skill-family-harness-node/src/native/bound_read.c",
|
|
7
|
-
"sha256": "
|
|
7
|
+
"sha256": "10cfc3ff44343a532629879841f9ae7a12b78d818d596f85af3915cea9832c15"
|
|
8
8
|
},
|
|
9
9
|
"buildRecipe": {
|
|
10
10
|
"path": "packages/skill-family-harness-node/src/native/binding.gyp",
|
|
@@ -14,25 +14,25 @@
|
|
|
14
14
|
{
|
|
15
15
|
"platformKey": "darwin-arm64",
|
|
16
16
|
"binary": "prebuilds/darwin-arm64/bound_read.darwin-arm64.node",
|
|
17
|
-
"sha256": "
|
|
17
|
+
"sha256": "19ce71026b737ba6b56da78ffabca4bb25f21249c30b545d68633d4b77547fde",
|
|
18
18
|
"license": "Apache-2.0"
|
|
19
19
|
},
|
|
20
20
|
{
|
|
21
21
|
"platformKey": "darwin-x64",
|
|
22
22
|
"binary": "prebuilds/darwin-x64/bound_read.darwin-x64.node",
|
|
23
|
-
"sha256": "
|
|
23
|
+
"sha256": "e01e4a1df3554da857189f483174d0480eb36063ce17b5857a23fa8a02f4a664",
|
|
24
24
|
"license": "Apache-2.0"
|
|
25
25
|
},
|
|
26
26
|
{
|
|
27
27
|
"platformKey": "linux-arm64-gnu",
|
|
28
28
|
"binary": "prebuilds/linux-arm64-gnu/bound_read.linux-arm64-gnu.node",
|
|
29
|
-
"sha256": "
|
|
29
|
+
"sha256": "7b7fd1188d2bbf356fe8c44690fd80bc7fb02e9f6f9dc7ee1c10834bf1c519b1",
|
|
30
30
|
"license": "Apache-2.0"
|
|
31
31
|
},
|
|
32
32
|
{
|
|
33
33
|
"platformKey": "linux-x64-gnu",
|
|
34
34
|
"binary": "prebuilds/linux-x64-gnu/bound_read.linux-x64-gnu.node",
|
|
35
|
-
"sha256": "
|
|
35
|
+
"sha256": "749b69ba1b7da3496e59f92f2ca8d04800031b2479c13b3ca63555ee69671acc",
|
|
36
36
|
"license": "Apache-2.0"
|
|
37
37
|
}
|
|
38
38
|
]
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -61,6 +61,7 @@ export const WATCHDOG_REASONS = Object.freeze([
|
|
|
61
61
|
"no_terminal_result",
|
|
62
62
|
"residual_process_group",
|
|
63
63
|
"context_budget_exhausted",
|
|
64
|
+
"output_limit_exceeded",
|
|
64
65
|
]);
|
|
65
66
|
|
|
66
67
|
/** Termination reason: the deterministic mapping consumers consume (8 values). */
|
|
@@ -73,6 +74,7 @@ export const TERMINATION_REASONS = Object.freeze([
|
|
|
73
74
|
"completed",
|
|
74
75
|
"child_exit",
|
|
75
76
|
"context_budget_exhausted",
|
|
77
|
+
"output_limit_exceeded",
|
|
76
78
|
]);
|
|
77
79
|
|
|
78
80
|
/** Process status of the supervised process (7 values). */
|
|
@@ -115,6 +117,25 @@ const TIMEOUT_WATCHDOG_REASONS = new Set([
|
|
|
115
117
|
"tool_lease_expired",
|
|
116
118
|
]);
|
|
117
119
|
|
|
120
|
+
const OUTPUT_LIMIT_FIELDS = Object.freeze(["stdout", "stderr"]);
|
|
121
|
+
const MAX_SAFE_OUTPUT_BYTES = Number.MAX_SAFE_INTEGER;
|
|
122
|
+
|
|
123
|
+
function validateOutputByteLimits(limits) {
|
|
124
|
+
if (limits === undefined) return null;
|
|
125
|
+
if (!isPlainObject(limits)) return "outputByteLimits must be a plain object";
|
|
126
|
+
const fields = Object.keys(limits);
|
|
127
|
+
if (fields.length === 0 || fields.some((field) => !OUTPUT_LIMIT_FIELDS.includes(field))) {
|
|
128
|
+
return "outputByteLimits must contain stdout and/or stderr only";
|
|
129
|
+
}
|
|
130
|
+
for (const field of fields) {
|
|
131
|
+
const value = limits[field];
|
|
132
|
+
if (!Number.isInteger(value) || value < 0 || value > MAX_SAFE_OUTPUT_BYTES) {
|
|
133
|
+
return `outputByteLimits.${field} must be an integer from 0 to Number.MAX_SAFE_INTEGER`;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
118
139
|
// ---------------------------------------------------------------------------
|
|
119
140
|
// Option and policy validation
|
|
120
141
|
// ---------------------------------------------------------------------------
|
|
@@ -164,7 +185,7 @@ function validateOptions(options) {
|
|
|
164
185
|
const {
|
|
165
186
|
command, args, cwd, env,
|
|
166
187
|
timeoutPolicy, progressPaths, terminalProgressPaths, terminalGraceSeconds,
|
|
167
|
-
checkIntervalSeconds, longToolActive, budgetExhausted, evidencePath, rawSink, rawStreamSink,
|
|
188
|
+
checkIntervalSeconds, longToolActive, budgetExhausted, evidencePath, rawSink, rawStreamSink, outputByteLimits,
|
|
168
189
|
} = options;
|
|
169
190
|
if (typeof command !== "string" || command.length === 0) {
|
|
170
191
|
throw new TypeError("superviseProcess: command must be a non-empty string");
|
|
@@ -220,6 +241,8 @@ function validateOptions(options) {
|
|
|
220
241
|
const problem = validateRawSink(rawSink);
|
|
221
242
|
if (problem) throw new TypeError(`superviseProcess: ${problem}`);
|
|
222
243
|
}
|
|
244
|
+
const outputLimitProblem = validateOutputByteLimits(outputByteLimits);
|
|
245
|
+
if (outputLimitProblem) throw new TypeError(`superviseProcess: ${outputLimitProblem}`);
|
|
223
246
|
const policyProblem = validateTimeoutPolicy(timeoutPolicy);
|
|
224
247
|
if (policyProblem) {
|
|
225
248
|
throw mechanismError("timeout-policy-invalid", policyProblem);
|
|
@@ -308,8 +331,8 @@ async function openRawSinkFile(captured, filename) {
|
|
|
308
331
|
return handle;
|
|
309
332
|
}
|
|
310
333
|
|
|
311
|
-
function makeRawStreamState(handle) {
|
|
312
|
-
return { handle, hash: createHash("sha256"), bytes: 0, queue: Promise.resolve(), closed: false };
|
|
334
|
+
function makeRawStreamState(handle, limit) {
|
|
335
|
+
return { handle, hash: createHash("sha256"), bytes: 0, limit, queue: Promise.resolve(), closed: false };
|
|
313
336
|
}
|
|
314
337
|
|
|
315
338
|
function enqueueRawChunk(state, chunk) {
|
|
@@ -503,6 +526,7 @@ const WATCHDOG_TO_TERMINATION = Object.freeze({
|
|
|
503
526
|
no_terminal_result: "completed",
|
|
504
527
|
residual_process_group: "child_exit",
|
|
505
528
|
context_budget_exhausted: "context_budget_exhausted",
|
|
529
|
+
output_limit_exceeded: "output_limit_exceeded",
|
|
506
530
|
});
|
|
507
531
|
|
|
508
532
|
function mapWatchdogToTermination(watchdogReason) {
|
|
@@ -547,6 +571,8 @@ function buildEnvelope({
|
|
|
547
571
|
signalSequence,
|
|
548
572
|
forcedKill,
|
|
549
573
|
timeoutPolicy,
|
|
574
|
+
outputByteLimits,
|
|
575
|
+
outputLimitExceeded,
|
|
550
576
|
progressPaths,
|
|
551
577
|
terminalProgressPaths,
|
|
552
578
|
terminalSeen,
|
|
@@ -564,9 +590,9 @@ function buildEnvelope({
|
|
|
564
590
|
sawLeaderExit,
|
|
565
591
|
);
|
|
566
592
|
const ok = watchdogReason === "terminal_progress"
|
|
567
|
-
?
|
|
593
|
+
? outputLimitExceeded === null
|
|
568
594
|
: watchdogReason === null
|
|
569
|
-
? exitStatus === 0
|
|
595
|
+
? exitStatus === 0 && outputLimitExceeded === null
|
|
570
596
|
: false;
|
|
571
597
|
const evidence = {
|
|
572
598
|
command,
|
|
@@ -587,6 +613,7 @@ function buildEnvelope({
|
|
|
587
613
|
startupProgressPaths: progressPaths,
|
|
588
614
|
terminalProgressPaths,
|
|
589
615
|
timeoutPolicy,
|
|
616
|
+
...(outputByteLimits !== undefined ? { outputByteLimits, outputLimitExceeded } : {}),
|
|
590
617
|
...(terminalSeen ? { terminalIdentity: terminalSeen.identity } : {}),
|
|
591
618
|
...(terminalSeen?.status !== undefined ? { terminalStatus: terminalSeen.status } : {}),
|
|
592
619
|
...(residualGroupCleanupCompleted !== undefined
|
|
@@ -634,6 +661,9 @@ function buildEnvelope({
|
|
|
634
661
|
* terminal record must persist before the mechanism closes the process
|
|
635
662
|
* with watchdog_reason terminal_progress.
|
|
636
663
|
* @param {number} [options.checkIntervalSeconds=1] - supervision cadence.
|
|
664
|
+
* @param {{stdout?: number, stderr?: number}} [options.outputByteLimits] -
|
|
665
|
+
* optional independent raw-byte caps; equal is allowed, first excess
|
|
666
|
+
* immediately enters the existing process-group termination path.
|
|
637
667
|
* @param {() => boolean} [options.longToolActive] - consumer-owned long-tool
|
|
638
668
|
* activity probe; enables the tool-lease dimension (requires
|
|
639
669
|
* toolLeaseSeconds). Stream-idle is suspended while the probe is true.
|
|
@@ -679,6 +709,7 @@ export async function superviseProcess(options, deps = {}) {
|
|
|
679
709
|
evidencePath,
|
|
680
710
|
} = options;
|
|
681
711
|
const sink = options.rawSink ?? null;
|
|
712
|
+
const outputByteLimits = options.outputByteLimits;
|
|
682
713
|
let rawStreams = null;
|
|
683
714
|
if (sink !== null) {
|
|
684
715
|
try {
|
|
@@ -692,8 +723,8 @@ export async function superviseProcess(options, deps = {}) {
|
|
|
692
723
|
throw error;
|
|
693
724
|
}
|
|
694
725
|
rawStreams = {
|
|
695
|
-
stdout: makeRawStreamState(stdoutHandle),
|
|
696
|
-
stderr: makeRawStreamState(stderrHandle),
|
|
726
|
+
stdout: makeRawStreamState(stdoutHandle, outputByteLimits?.stdout),
|
|
727
|
+
stderr: makeRawStreamState(stderrHandle, outputByteLimits?.stderr),
|
|
697
728
|
};
|
|
698
729
|
} catch (error) {
|
|
699
730
|
if (rawStreams?.stdout) await closeRawStreamState(rawStreams.stdout).catch(() => {});
|
|
@@ -759,6 +790,8 @@ export async function superviseProcess(options, deps = {}) {
|
|
|
759
790
|
residualGroupCleanupCompleted: undefined,
|
|
760
791
|
watchdogSignalSucceeded: false,
|
|
761
792
|
finalizing: false,
|
|
793
|
+
outputLimitExceeded: null,
|
|
794
|
+
outputBytes: { stdout: 0, stderr: 0 },
|
|
762
795
|
};
|
|
763
796
|
const signalSequence = [];
|
|
764
797
|
let interval = null;
|
|
@@ -867,6 +900,9 @@ export async function superviseProcess(options, deps = {}) {
|
|
|
867
900
|
let watchdogReason = lifecycle.vanish
|
|
868
901
|
? "no_terminal_result"
|
|
869
902
|
: (termination?.reason ?? null);
|
|
903
|
+
if (watchdogReason === null && lifecycle.outputLimitExceeded !== null) {
|
|
904
|
+
watchdogReason = "output_limit_exceeded";
|
|
905
|
+
}
|
|
870
906
|
|
|
871
907
|
// Terminal requirement reconciliation (R9-equivalent): when terminal
|
|
872
908
|
// progress paths are configured and the close was authoritative
|
|
@@ -916,6 +952,8 @@ export async function superviseProcess(options, deps = {}) {
|
|
|
916
952
|
signalSequence,
|
|
917
953
|
forcedKill: termination?.forcedKill === true,
|
|
918
954
|
timeoutPolicy,
|
|
955
|
+
outputByteLimits,
|
|
956
|
+
outputLimitExceeded: lifecycle.outputLimitExceeded,
|
|
919
957
|
progressPaths,
|
|
920
958
|
terminalProgressPaths,
|
|
921
959
|
terminalSeen,
|
|
@@ -1005,6 +1043,8 @@ export async function superviseProcess(options, deps = {}) {
|
|
|
1005
1043
|
signalSequence: [],
|
|
1006
1044
|
forcedKill: false,
|
|
1007
1045
|
timeoutPolicy,
|
|
1046
|
+
outputByteLimits,
|
|
1047
|
+
outputLimitExceeded: null,
|
|
1008
1048
|
progressPaths,
|
|
1009
1049
|
terminalProgressPaths,
|
|
1010
1050
|
terminalSeen: null,
|
|
@@ -1260,6 +1300,24 @@ export async function superviseProcess(options, deps = {}) {
|
|
|
1260
1300
|
}
|
|
1261
1301
|
}
|
|
1262
1302
|
|
|
1303
|
+
function handleOutputChunk(stream, chunk) {
|
|
1304
|
+
const bytes = Buffer.from(chunk);
|
|
1305
|
+
lifecycle.outputBytes[stream] += bytes.length;
|
|
1306
|
+
const limit = outputByteLimits?.[stream];
|
|
1307
|
+
let saved = bytes;
|
|
1308
|
+
if (limit !== undefined) {
|
|
1309
|
+
const allowed = Math.max(0, limit - (lifecycle.outputBytes[stream] - bytes.length));
|
|
1310
|
+
saved = bytes.subarray(0, allowed);
|
|
1311
|
+
if (lifecycle.outputBytes[stream] > limit) {
|
|
1312
|
+
lifecycle.outputLimitExceeded ??= stream;
|
|
1313
|
+
if (beginTermination("output_limit_exceeded", d.nowMs())) superviseLifecycle(d.nowMs());
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
if (rawStreams && saved.length > 0) {
|
|
1317
|
+
void enqueueRawChunk(rawStreams[stream], saved).catch((error) => { lifecycle.runtimeError = error; });
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1263
1321
|
// Wire the child events.
|
|
1264
1322
|
child.stdout?.on("data", () => {
|
|
1265
1323
|
lastStreamActivityAtMs = d.nowMs();
|
|
@@ -1267,12 +1325,8 @@ export async function superviseProcess(options, deps = {}) {
|
|
|
1267
1325
|
child.stderr?.on("data", () => {
|
|
1268
1326
|
lastStreamActivityAtMs = d.nowMs();
|
|
1269
1327
|
});
|
|
1270
|
-
child.stdout?.on("data", (chunk) =>
|
|
1271
|
-
|
|
1272
|
-
});
|
|
1273
|
-
child.stderr?.on("data", (chunk) => {
|
|
1274
|
-
if (rawStreams) void enqueueRawChunk(rawStreams.stderr, chunk).catch((error) => { lifecycle.runtimeError = error; });
|
|
1275
|
-
});
|
|
1328
|
+
child.stdout?.on("data", (chunk) => handleOutputChunk("stdout", chunk));
|
|
1329
|
+
child.stderr?.on("data", (chunk) => handleOutputChunk("stderr", chunk));
|
|
1276
1330
|
child.on("exit", (code, signalCode) => {
|
|
1277
1331
|
lifecycle.leaderResult = { code, signalCode };
|
|
1278
1332
|
superviseLifecycle(d.nowMs());
|