brepjs-verify 0.8.0 → 0.24.1
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 +579 -0
- package/README.md +18 -18
- package/dist/brepjs-verify.cjs +1 -1
- package/dist/brepjs-verify.js +1 -1
- package/dist/cli/main.cjs +1 -1
- package/dist/cli/main.js +1 -1
- package/dist/{diff-DDEu6YJM.cjs → diff-3ivpxBET.cjs} +1 -1
- package/dist/{diff-BCECMYSQ.js → diff-DyilrTFJ.js} +1 -1
- package/dist/mcp/server.cjs +121 -32
- package/dist/mcp/server.js +122 -33
- package/dist/sandbox/runProgram.d.ts +14 -0
- package/package.json +8 -9
- package/viewer/dist/assets/brepjs-dcJyzEyf.js +60 -0
- package/viewer/dist/assets/{index-CnQ8btWD.js → index-CMFwgb8F.js} +1 -1
- package/viewer/dist/assets/{kernelWorker-CMzbzBfs.js → kernelWorker-BEdKKhct.js} +1 -1
- package/viewer/dist/index.html +1 -1
- package/viewer/dist/wasm/occt-wasm.wasm +0 -0
- package/viewer/dist/assets/brepjs-NH9yA5tB.js +0 -59
package/README.md
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
Agent skill + verify/preview tooling for authoring parametric CAD with [brepjs](https://github.com/andymai/brepjs).
|
|
4
4
|
|
|
5
|
-
It ships as **two cooperating pieces on two rails
|
|
5
|
+
It ships as **two cooperating pieces on two rails**; install both.
|
|
6
6
|
|
|
7
7
|
## 1. The skill (Claude Code plugin)
|
|
8
8
|
|
|
9
|
-
Teaches an agent the authoring workflow. Delivered via the brepjs marketplace (git), **not** npm
|
|
9
|
+
Teaches an agent the authoring workflow. Delivered via the brepjs marketplace (git), **not** npm. Claude Code discovers skills from plugins, never from `node_modules`:
|
|
10
10
|
|
|
11
11
|
```
|
|
12
12
|
/plugin marketplace add andymai/brepjs
|
|
@@ -23,10 +23,10 @@ npm i -D brepjs-verify brepjs occt-wasm
|
|
|
23
23
|
|
|
24
24
|
## API reference
|
|
25
25
|
|
|
26
|
-
The package bundles brepjs's full API reference for offline/agent use
|
|
26
|
+
The package bundles brepjs's full API reference for offline/agent use: the complete export surface with signatures and examples:
|
|
27
27
|
|
|
28
|
-
- `reference/llms-full.txt
|
|
29
|
-
- `reference/llms.txt
|
|
28
|
+
- `reference/llms-full.txt`: every export, full signatures (the deep reference)
|
|
29
|
+
- `reference/llms.txt`: the same content as a quicker index
|
|
30
30
|
|
|
31
31
|
Point your agent at `node_modules/brepjs-verify/reference/llms-full.txt` for anything the skill's curated references don't cover.
|
|
32
32
|
|
|
@@ -51,7 +51,7 @@ npx -y brepjs-verify part.brep.ts --serve --no-open # previe
|
|
|
51
51
|
|
|
52
52
|
`--snapshot`/`--serve` use the bundled viewer (shipped under `viewer/dist`, including the OCCT WASM). The `--serve` link is interactive: a toolbar offers view presets + fit, solid/wireframe/x-ray modes, edge/grid toggles, a turntable, click-to-inspect face picking, a section/clipping plane, a measurements panel, and an in-browser PNG screenshot. `--snapshot` loads the same page with `ui=0` to suppress the toolbar, and burns the bounding-box size into each PNG (`dims=1`) so the agent can read scale from the image.
|
|
53
53
|
|
|
54
|
-
`--serve` prints the viewer URL and, in an interactive terminal, opens it in your default browser. Auto-open is skipped when it would be unwanted
|
|
54
|
+
`--serve` prints the viewer URL and, in an interactive terminal, opens it in your default browser. Auto-open is skipped when it would be unwanted: when the server is reused (a tab is already open), under CI, when output is piped (non-TTY, e.g. agent runs), or on Linux with no display server. Pass `--no-open` to always suppress it.
|
|
55
55
|
|
|
56
56
|
## CLI reference
|
|
57
57
|
|
|
@@ -65,8 +65,8 @@ The `brepjs-verify` bin is a multi-command CLI. `verify` is the default command,
|
|
|
65
65
|
| `brepjs-verify export <file>` | Batch artifacts behind a validity gate: `--step`, `--glb`, `--stl`, or `--all`; `--out <dir>` (default `.`). Exits non-zero on failure. |
|
|
66
66
|
| `brepjs-verify measure <a> [b]` | Measurements for one part; with a second module, the distance between the two parts. |
|
|
67
67
|
| `brepjs-verify diff <a> <b>` | Compares the measurements of a baseline and a comparison module. |
|
|
68
|
-
| `brepjs-verify snapshot` | Multi-view PNG capture
|
|
69
|
-
| `brepjs-verify serve` | Preview server with a `?dir=&file=` deep link
|
|
68
|
+
| `brepjs-verify snapshot` | Multi-view PNG capture, surfaced via `verify --snapshot <dir>`. Requires the optional `puppeteer`/Chrome dependency; degrades with a clear message when absent. |
|
|
69
|
+
| `brepjs-verify serve` | Preview server with a `?dir=&file=` deep link, surfaced via `verify --serve`. Auto-opens the browser in an interactive terminal (suppressed under CI / non-TTY / no display, or with `--no-open`). |
|
|
70
70
|
|
|
71
71
|
Every command writes a single machine-readable JSON document to stdout; diagnostics (paths, kernel chatter, watch notices) go to stderr.
|
|
72
72
|
|
|
@@ -82,7 +82,7 @@ This is the closed _build → verify_ loop as a single call: the agent sends par
|
|
|
82
82
|
|
|
83
83
|
### Connect (local build)
|
|
84
84
|
|
|
85
|
-
Build the package, then register the server by absolute path. Run both commands from the package root (`packages/brepjs-verify`), where `dist/` is emitted
|
|
85
|
+
Build the package, then register the server by absolute path. Run both commands from the package root (`packages/brepjs-verify`), where `dist/` is emitted. `$(pwd)` is resolved by your shell at that location:
|
|
86
86
|
|
|
87
87
|
```bash
|
|
88
88
|
npm run build # emits dist/mcp/server.js
|
|
@@ -95,24 +95,24 @@ Once the package is published to npm, the same server is available without a loc
|
|
|
95
95
|
claude mcp add brepjs-verify -- npx -y --package brepjs-verify brepjs-verify-mcp
|
|
96
96
|
```
|
|
97
97
|
|
|
98
|
-
The server runs locally as a child process of your agent (stdio)
|
|
98
|
+
The server runs locally as a child process of your agent (stdio); geometry never leaves your machine.
|
|
99
99
|
|
|
100
100
|
## Examples gallery
|
|
101
101
|
|
|
102
102
|
Few-shot examples live under `skill/examples/<name>.brep.ts`, each with a `<name>.expected.json` baseline. Grouped by category:
|
|
103
103
|
|
|
104
|
-
- **Primitives + booleans
|
|
105
|
-
- **2D sketch → solid
|
|
106
|
-
- **Modifiers
|
|
107
|
-
- **Gridfinity primitives
|
|
104
|
+
- **Primitives + booleans**: `mounting-bracket`, `flanged-coupler`, `transform-bracket`
|
|
105
|
+
- **2D sketch → solid**: `extruded-bracket` (extrude), `revolved-pulley` (revolve), `swept-gasket` (sweep)
|
|
106
|
+
- **Modifiers**: `rounded-block` (fillet), `chamfered-block` (chamfer), `hollow-enclosure` (shell)
|
|
107
|
+
- **Gridfinity primitives**: `gridfinity-baseplate`, `gridfinity-bin`, `gridfinity-divider`
|
|
108
108
|
|
|
109
109
|
## Eval / scorecard
|
|
110
110
|
|
|
111
|
-
`npm run eval` (`bench/run.ts`) replays every `skill/examples/*.brep.ts` with a sibling `*.expected.json` through the public `runPart` runtime, compares measured volume/area/validity/shape-type against the recorded baseline within each file's tolerance (default 0.5%), prints a PASS/FAIL scorecard, and exits non-zero on any regression. It is deterministic
|
|
111
|
+
`npm run eval` (`bench/run.ts`) replays every `skill/examples/*.brep.ts` with a sibling `*.expected.json` through the public `runPart` runtime, compares measured volume/area/validity/shape-type against the recorded baseline within each file's tolerance (default 0.5%), prints a PASS/FAIL scorecard, and exits non-zero on any regression. It is deterministic (no LLM or API key) so it runs in CI as the package's regression net. Refresh a baseline by re-recording the example's `*.expected.json` after an intentional geometry change.
|
|
112
112
|
|
|
113
113
|
### Live eval (`npm run eval:live`)
|
|
114
114
|
|
|
115
|
-
The measurement flywheel: sends ~18 natural-language part prompts (`bench/prompts.ts`) to a real model
|
|
115
|
+
The measurement flywheel: sends ~18 natural-language part prompts (`bench/prompts.ts`) to a real model (using the **deployed `SKILL.md` as the system prompt**, so it measures the actual skill), then verifies each generated part two ways:
|
|
116
116
|
|
|
117
117
|
- **Auto (objective):** `runPart --check` → valid solid + any pinned dims within tolerance.
|
|
118
118
|
- **Judge (intent):** a multimodal Claude call looks at the rendered iso/front/top/right snapshots and decides whether the part matches the request + rubric.
|
|
@@ -125,7 +125,7 @@ ANTHROPIC_API_KEY=sk-... npm run eval:live -w brepjs-verify -- --model claude-so
|
|
|
125
125
|
# --only <id|category> run a subset --keep keep the generated parts
|
|
126
126
|
```
|
|
127
127
|
|
|
128
|
-
Opt-in and **billed** (real API calls), so it does _not_ run in CI
|
|
128
|
+
Opt-in and **billed** (real API calls), so it does _not_ run in CI; the deterministic replay above is the CI gate. Snapshots (hence the judge) need `puppeteer`/Chrome; without them the run scores on auto-verify alone and notes the skipped judge.
|
|
129
129
|
|
|
130
130
|
## Programmatic API
|
|
131
131
|
|
|
@@ -138,4 +138,4 @@ console.log(serializeReport(report)); // { ok, shapeType, checks, measurements,
|
|
|
138
138
|
|
|
139
139
|
## How verification works
|
|
140
140
|
|
|
141
|
-
Deterministic checks are the source of truth
|
|
141
|
+
Deterministic checks are the source of truth (validity brands (`validSolid`), `measureVolume`/`measureArea`, and bounding box) surfaced as a JSON report. Multi-view PNG snapshots are a diagnostic layer, never a substitute for a measurement. STEP is the primary, validated artifact; GLB/STL are derived previews.
|
package/dist/brepjs-verify.cjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
const require_diff = require("./diff-
|
|
2
|
+
const require_diff = require("./diff-3ivpxBET.cjs");
|
|
3
3
|
exports.DEFAULT_TOLERANCE_PCT = require_diff.DEFAULT_TOLERANCE_PCT;
|
|
4
4
|
exports.TYPECHECK_CODE = require_diff.TYPECHECK_CODE;
|
|
5
5
|
exports.emptyReport = require_diff.emptyReport;
|
package/dist/brepjs-verify.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as typecheckPart, c as isExpectedDims, d as emptyReport, i as TYPECHECK_CODE, l as pctDelta, m as serializeReport, n as runMeasure, o as DEFAULT_TOLERANCE_PCT, r as runPart, s as evaluateExpected, t as runDiff, u as runChecks } from "./diff-
|
|
1
|
+
import { a as typecheckPart, c as isExpectedDims, d as emptyReport, i as TYPECHECK_CODE, l as pctDelta, m as serializeReport, n as runMeasure, o as DEFAULT_TOLERANCE_PCT, r as runPart, s as evaluateExpected, t as runDiff, u as runChecks } from "./diff-DyilrTFJ.js";
|
|
2
2
|
export { DEFAULT_TOLERANCE_PCT, TYPECHECK_CODE, emptyReport, evaluateExpected, isExpectedDims, pctDelta, runChecks, runDiff, runMeasure, runPart, serializeReport, typecheckPart };
|
package/dist/cli/main.cjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
|
-
const require_diff = require("../diff-
|
|
3
|
+
const require_diff = require("../diff-3ivpxBET.cjs");
|
|
4
4
|
let node_url = require("node:url");
|
|
5
5
|
let node_fs = require("node:fs");
|
|
6
6
|
let node_path = require("node:path");
|
package/dist/cli/main.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { f as pushError, h as loadBrep, m as serializeReport, n as runMeasure, p as reportOk, r as runPart, t as runDiff } from "../diff-
|
|
2
|
+
import { f as pushError, h as loadBrep, m as serializeReport, n as runMeasure, p as reportOk, r as runPart, t as runDiff } from "../diff-DyilrTFJ.js";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { existsSync, mkdirSync, realpathSync, watch, writeFileSync } from "node:fs";
|
|
5
5
|
import { basename, dirname, join, resolve } from "node:path";
|
|
@@ -738,7 +738,7 @@ async function runPart(modulePath, opts = {}) {
|
|
|
738
738
|
});
|
|
739
739
|
}
|
|
740
740
|
//#endregion
|
|
741
|
-
//#region \0@oxc-project+runtime@0.
|
|
741
|
+
//#region \0@oxc-project+runtime@0.133.0/helpers/esm/usingCtx.js
|
|
742
742
|
function _usingCtx() {
|
|
743
743
|
var r = "function" == typeof SuppressedError ? SuppressedError : function(r, e) {
|
|
744
744
|
var n = Error();
|
|
@@ -736,7 +736,7 @@ async function runPart(modulePath, opts = {}) {
|
|
|
736
736
|
});
|
|
737
737
|
}
|
|
738
738
|
//#endregion
|
|
739
|
-
//#region \0@oxc-project+runtime@0.
|
|
739
|
+
//#region \0@oxc-project+runtime@0.133.0/helpers/esm/usingCtx.js
|
|
740
740
|
function _usingCtx() {
|
|
741
741
|
var r = "function" == typeof SuppressedError ? SuppressedError : function(r, e) {
|
|
742
742
|
var n = Error();
|
package/dist/mcp/server.cjs
CHANGED
|
@@ -8,7 +8,6 @@ let node_fs_promises = require("node:fs/promises");
|
|
|
8
8
|
let _modelcontextprotocol_sdk_server_index_js = require("@modelcontextprotocol/sdk/server/index.js");
|
|
9
9
|
let _modelcontextprotocol_sdk_server_stdio_js = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
10
10
|
let _modelcontextprotocol_sdk_types_js = require("@modelcontextprotocol/sdk/types.js");
|
|
11
|
-
let node_util = require("node:util");
|
|
12
11
|
let node_crypto = require("node:crypto");
|
|
13
12
|
//#region src/sandbox/runProgram.ts
|
|
14
13
|
/**
|
|
@@ -20,11 +19,22 @@ let node_crypto = require("node:crypto");
|
|
|
20
19
|
* rather than hanging or crashing the host. Results cross the boundary as serialized JSON (never
|
|
21
20
|
* live WASM handles), and the temp program directory is always cleaned up.
|
|
22
21
|
*/
|
|
23
|
-
var execFileAsync = (0, node_util.promisify)(node_child_process.execFile);
|
|
24
22
|
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
25
23
|
var DEFAULT_MAX_MEMORY_MB = 2048;
|
|
26
24
|
var MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
|
|
27
25
|
/**
|
|
26
|
+
* In-flight sandbox process *groups*, keyed by group-leader pid. Tracked so a dying host can reap
|
|
27
|
+
* its runs (see installSandboxShutdownHandlers) — the per-run timeout can't, since its timer dies
|
|
28
|
+
* with the host.
|
|
29
|
+
*/
|
|
30
|
+
var activeGroups = /* @__PURE__ */ new Set();
|
|
31
|
+
/** SIGKILL (or `signal`) an entire detached process group by its leader pid; ignore if already gone. */
|
|
32
|
+
function killGroup(pid, signal) {
|
|
33
|
+
try {
|
|
34
|
+
process.kill(process.platform === "win32" ? pid : -pid, signal);
|
|
35
|
+
} catch {}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
28
38
|
* Clamp a caller-supplied limit to a positive, finite value, falling back to the default otherwise.
|
|
29
39
|
* Critical for the timeout: Node's `execFile` treats `timeout: 0` (and it ignores negatives) as
|
|
30
40
|
* "no timeout", which would silently disable the sandbox's only runaway protection.
|
|
@@ -53,39 +63,14 @@ async function runVerifyCli(code, makeArgs, opts) {
|
|
|
53
63
|
const partPath = (0, node_path.join)(dir, "part.brep.ts");
|
|
54
64
|
await (0, node_fs_promises.writeFile)(partPath, code);
|
|
55
65
|
const cliArgs = makeArgs(partPath);
|
|
56
|
-
|
|
57
|
-
const args = useTsx ? [
|
|
66
|
+
return await spawnCliOutcome(useTsx ? "npx" : process.execPath, useTsx ? [
|
|
58
67
|
"tsx",
|
|
59
68
|
cliEntry,
|
|
60
69
|
...cliArgs
|
|
61
|
-
] : [cliEntry, ...cliArgs]
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
killSignal: "SIGKILL",
|
|
66
|
-
maxBuffer: MAX_OUTPUT_BYTES,
|
|
67
|
-
env: {
|
|
68
|
-
...process.env,
|
|
69
|
-
NODE_OPTIONS: [process.env["NODE_OPTIONS"], `--max-old-space-size=${maxMemoryMb}`].filter(Boolean).join(" ")
|
|
70
|
-
}
|
|
71
|
-
});
|
|
72
|
-
return {
|
|
73
|
-
stdout,
|
|
74
|
-
stderr,
|
|
75
|
-
timedOut: false,
|
|
76
|
-
outputTooLarge: false,
|
|
77
|
-
exitCode: 0
|
|
78
|
-
};
|
|
79
|
-
} catch (err) {
|
|
80
|
-
const e = err;
|
|
81
|
-
return {
|
|
82
|
-
stdout: e.stdout ?? "",
|
|
83
|
-
stderr: e.stderr ?? (err instanceof Error ? err.message : String(err)),
|
|
84
|
-
timedOut: Boolean(e.killed) && e.code !== "ERR_CHILD_PROCESS_STDIO_MAXBUFFER",
|
|
85
|
-
outputTooLarge: e.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER",
|
|
86
|
-
exitCode: typeof e.code === "number" ? e.code : null
|
|
87
|
-
};
|
|
88
|
-
}
|
|
70
|
+
] : [cliEntry, ...cliArgs], {
|
|
71
|
+
...process.env,
|
|
72
|
+
NODE_OPTIONS: [process.env["NODE_OPTIONS"], `--max-old-space-size=${maxMemoryMb}`].filter(Boolean).join(" ")
|
|
73
|
+
}, timeoutMs);
|
|
89
74
|
} finally {
|
|
90
75
|
await (0, node_fs_promises.rm)(dir, {
|
|
91
76
|
recursive: true,
|
|
@@ -93,6 +78,109 @@ async function runVerifyCli(code, makeArgs, opts) {
|
|
|
93
78
|
});
|
|
94
79
|
}
|
|
95
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* Spawn the verify CLI in its OWN process group and enforce the wall-clock budget by SIGKILLing the
|
|
83
|
+
* WHOLE group on timeout — not just the direct child.
|
|
84
|
+
*
|
|
85
|
+
* Why a process group rather than Node's built-in `execFile` timeout: in dev/test the CLI runs as
|
|
86
|
+
* `npx tsx <main.ts>`, so the process that actually executes the part (and can spin on a CPU-bound
|
|
87
|
+
* OCCT op) is a *grandchild* behind npx+tsx. `child_process`' `timeout`/`killSignal` signals only
|
|
88
|
+
* the direct child, so a fired timeout SIGKILLs `npx` and orphans the still-spinning grandchild
|
|
89
|
+
* forever (it reparents to init and keeps burning a core). Spawning `detached` makes the child a
|
|
90
|
+
* process-group leader that npx's descendants inherit, so `process.kill(-pid, …)` reaps the entire
|
|
91
|
+
* tree. The production path (`node dist/cli/main.js`) has no intermediary, but the group kill is
|
|
92
|
+
* equally correct there.
|
|
93
|
+
*/
|
|
94
|
+
function spawnCliOutcome(cmd, args, env, timeoutMs) {
|
|
95
|
+
return new Promise((resolve) => {
|
|
96
|
+
const child = (0, node_child_process.spawn)(cmd, args, {
|
|
97
|
+
env,
|
|
98
|
+
detached: true,
|
|
99
|
+
stdio: [
|
|
100
|
+
"ignore",
|
|
101
|
+
"pipe",
|
|
102
|
+
"pipe"
|
|
103
|
+
]
|
|
104
|
+
});
|
|
105
|
+
if (child.pid !== void 0) activeGroups.add(child.pid);
|
|
106
|
+
let stdout = "";
|
|
107
|
+
let stderr = "";
|
|
108
|
+
let stdoutBytes = 0;
|
|
109
|
+
let timedOut = false;
|
|
110
|
+
let outputTooLarge = false;
|
|
111
|
+
let settled = false;
|
|
112
|
+
const killTree = (signal) => {
|
|
113
|
+
if (child.pid !== void 0) killGroup(child.pid, signal);
|
|
114
|
+
};
|
|
115
|
+
const timer = setTimeout(() => {
|
|
116
|
+
timedOut = true;
|
|
117
|
+
killTree("SIGKILL");
|
|
118
|
+
}, timeoutMs);
|
|
119
|
+
child.stdout?.on("data", (chunk) => {
|
|
120
|
+
if (outputTooLarge) return;
|
|
121
|
+
stdoutBytes += chunk.length;
|
|
122
|
+
if (stdoutBytes > MAX_OUTPUT_BYTES) {
|
|
123
|
+
outputTooLarge = true;
|
|
124
|
+
killTree("SIGKILL");
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
stdout += chunk.toString();
|
|
128
|
+
});
|
|
129
|
+
let stderrBytes = 0;
|
|
130
|
+
child.stderr?.on("data", (chunk) => {
|
|
131
|
+
stderrBytes += chunk.length;
|
|
132
|
+
if (stderrBytes <= MAX_OUTPUT_BYTES) stderr += chunk.toString();
|
|
133
|
+
});
|
|
134
|
+
const finish = (exitCode) => {
|
|
135
|
+
if (settled) return;
|
|
136
|
+
settled = true;
|
|
137
|
+
clearTimeout(timer);
|
|
138
|
+
if (child.pid !== void 0) activeGroups.delete(child.pid);
|
|
139
|
+
resolve({
|
|
140
|
+
stdout,
|
|
141
|
+
stderr,
|
|
142
|
+
timedOut,
|
|
143
|
+
outputTooLarge,
|
|
144
|
+
exitCode
|
|
145
|
+
});
|
|
146
|
+
};
|
|
147
|
+
child.on("error", (err) => {
|
|
148
|
+
if (!stderr) stderr = err.message;
|
|
149
|
+
finish(null);
|
|
150
|
+
});
|
|
151
|
+
child.on("close", (code) => finish(code));
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
/** SIGKILL every in-flight sandbox process group now. Exported so a host can reap explicitly. */
|
|
155
|
+
function killActiveSandboxes(signal = "SIGKILL") {
|
|
156
|
+
for (const pid of activeGroups) killGroup(pid, signal);
|
|
157
|
+
activeGroups.clear();
|
|
158
|
+
}
|
|
159
|
+
var shutdownHandlersInstalled = false;
|
|
160
|
+
/**
|
|
161
|
+
* Install process-shutdown hooks that reap any in-flight sandbox process groups when THIS process
|
|
162
|
+
* (the host — e.g. the MCP server) terminates. Idempotent; call once at startup from an entrypoint.
|
|
163
|
+
*
|
|
164
|
+
* Rationale: the per-run timeout (`spawnCliOutcome`) only protects a run while the host is alive —
|
|
165
|
+
* its timer dies with the host. If the host is stopped (the agent disconnects) before a run's
|
|
166
|
+
* budget elapses, the `detached` sandbox group is in its own session and survives, burning a core
|
|
167
|
+
* indefinitely. These hooks SIGKILL every tracked group on the way down so a dying host doesn't
|
|
168
|
+
* leak its children. (A hard SIGKILL of the host can't be trapped — that residual needs the kernel's
|
|
169
|
+
* PR_SET_PDEATHSIG, which Node doesn't expose.)
|
|
170
|
+
*/
|
|
171
|
+
function installSandboxShutdownHandlers() {
|
|
172
|
+
if (shutdownHandlersInstalled) return;
|
|
173
|
+
shutdownHandlersInstalled = true;
|
|
174
|
+
process.on("exit", () => killActiveSandboxes("SIGKILL"));
|
|
175
|
+
process.once("SIGTERM", () => {
|
|
176
|
+
killActiveSandboxes("SIGKILL");
|
|
177
|
+
process.exit(143);
|
|
178
|
+
});
|
|
179
|
+
process.once("SIGINT", () => {
|
|
180
|
+
killActiveSandboxes("SIGKILL");
|
|
181
|
+
process.exit(130);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
96
184
|
function tryParse(out) {
|
|
97
185
|
if (!out.trim()) return null;
|
|
98
186
|
try {
|
|
@@ -346,6 +434,7 @@ async function exportPartTool(args) {
|
|
|
346
434
|
* "build → verify" step the agent loop is built on. Uses the SDK's low-level `Server` with plain
|
|
347
435
|
* JSON-Schema tool definitions (no direct zod dependency in this package).
|
|
348
436
|
*/
|
|
437
|
+
installSandboxShutdownHandlers();
|
|
349
438
|
var pkgPath = (0, node_path.join)((0, node_path.dirname)((0, node_url.fileURLToPath)({}.url)), "..", "..", "package.json");
|
|
350
439
|
var server = new _modelcontextprotocol_sdk_server_index_js.Server({
|
|
351
440
|
name: "brepjs-verify",
|
package/dist/mcp/server.js
CHANGED
|
@@ -3,12 +3,11 @@ import { fileURLToPath } from "node:url";
|
|
|
3
3
|
import { existsSync, readFileSync } from "node:fs";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
|
-
import {
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
7
|
import { appendFile, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
8
8
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
9
9
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
10
10
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
11
|
-
import { promisify } from "node:util";
|
|
12
11
|
import { createHash } from "node:crypto";
|
|
13
12
|
//#region src/sandbox/runProgram.ts
|
|
14
13
|
/**
|
|
@@ -20,11 +19,22 @@ import { createHash } from "node:crypto";
|
|
|
20
19
|
* rather than hanging or crashing the host. Results cross the boundary as serialized JSON (never
|
|
21
20
|
* live WASM handles), and the temp program directory is always cleaned up.
|
|
22
21
|
*/
|
|
23
|
-
var execFileAsync = promisify(execFile);
|
|
24
22
|
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
25
23
|
var DEFAULT_MAX_MEMORY_MB = 2048;
|
|
26
24
|
var MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
|
|
27
25
|
/**
|
|
26
|
+
* In-flight sandbox process *groups*, keyed by group-leader pid. Tracked so a dying host can reap
|
|
27
|
+
* its runs (see installSandboxShutdownHandlers) — the per-run timeout can't, since its timer dies
|
|
28
|
+
* with the host.
|
|
29
|
+
*/
|
|
30
|
+
var activeGroups = /* @__PURE__ */ new Set();
|
|
31
|
+
/** SIGKILL (or `signal`) an entire detached process group by its leader pid; ignore if already gone. */
|
|
32
|
+
function killGroup(pid, signal) {
|
|
33
|
+
try {
|
|
34
|
+
process.kill(process.platform === "win32" ? pid : -pid, signal);
|
|
35
|
+
} catch {}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
28
38
|
* Clamp a caller-supplied limit to a positive, finite value, falling back to the default otherwise.
|
|
29
39
|
* Critical for the timeout: Node's `execFile` treats `timeout: 0` (and it ignores negatives) as
|
|
30
40
|
* "no timeout", which would silently disable the sandbox's only runaway protection.
|
|
@@ -53,39 +63,14 @@ async function runVerifyCli(code, makeArgs, opts) {
|
|
|
53
63
|
const partPath = join(dir, "part.brep.ts");
|
|
54
64
|
await writeFile(partPath, code);
|
|
55
65
|
const cliArgs = makeArgs(partPath);
|
|
56
|
-
|
|
57
|
-
const args = useTsx ? [
|
|
66
|
+
return await spawnCliOutcome(useTsx ? "npx" : process.execPath, useTsx ? [
|
|
58
67
|
"tsx",
|
|
59
68
|
cliEntry,
|
|
60
69
|
...cliArgs
|
|
61
|
-
] : [cliEntry, ...cliArgs]
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
killSignal: "SIGKILL",
|
|
66
|
-
maxBuffer: MAX_OUTPUT_BYTES,
|
|
67
|
-
env: {
|
|
68
|
-
...process.env,
|
|
69
|
-
NODE_OPTIONS: [process.env["NODE_OPTIONS"], `--max-old-space-size=${maxMemoryMb}`].filter(Boolean).join(" ")
|
|
70
|
-
}
|
|
71
|
-
});
|
|
72
|
-
return {
|
|
73
|
-
stdout,
|
|
74
|
-
stderr,
|
|
75
|
-
timedOut: false,
|
|
76
|
-
outputTooLarge: false,
|
|
77
|
-
exitCode: 0
|
|
78
|
-
};
|
|
79
|
-
} catch (err) {
|
|
80
|
-
const e = err;
|
|
81
|
-
return {
|
|
82
|
-
stdout: e.stdout ?? "",
|
|
83
|
-
stderr: e.stderr ?? (err instanceof Error ? err.message : String(err)),
|
|
84
|
-
timedOut: Boolean(e.killed) && e.code !== "ERR_CHILD_PROCESS_STDIO_MAXBUFFER",
|
|
85
|
-
outputTooLarge: e.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER",
|
|
86
|
-
exitCode: typeof e.code === "number" ? e.code : null
|
|
87
|
-
};
|
|
88
|
-
}
|
|
70
|
+
] : [cliEntry, ...cliArgs], {
|
|
71
|
+
...process.env,
|
|
72
|
+
NODE_OPTIONS: [process.env["NODE_OPTIONS"], `--max-old-space-size=${maxMemoryMb}`].filter(Boolean).join(" ")
|
|
73
|
+
}, timeoutMs);
|
|
89
74
|
} finally {
|
|
90
75
|
await rm(dir, {
|
|
91
76
|
recursive: true,
|
|
@@ -93,6 +78,109 @@ async function runVerifyCli(code, makeArgs, opts) {
|
|
|
93
78
|
});
|
|
94
79
|
}
|
|
95
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* Spawn the verify CLI in its OWN process group and enforce the wall-clock budget by SIGKILLing the
|
|
83
|
+
* WHOLE group on timeout — not just the direct child.
|
|
84
|
+
*
|
|
85
|
+
* Why a process group rather than Node's built-in `execFile` timeout: in dev/test the CLI runs as
|
|
86
|
+
* `npx tsx <main.ts>`, so the process that actually executes the part (and can spin on a CPU-bound
|
|
87
|
+
* OCCT op) is a *grandchild* behind npx+tsx. `child_process`' `timeout`/`killSignal` signals only
|
|
88
|
+
* the direct child, so a fired timeout SIGKILLs `npx` and orphans the still-spinning grandchild
|
|
89
|
+
* forever (it reparents to init and keeps burning a core). Spawning `detached` makes the child a
|
|
90
|
+
* process-group leader that npx's descendants inherit, so `process.kill(-pid, …)` reaps the entire
|
|
91
|
+
* tree. The production path (`node dist/cli/main.js`) has no intermediary, but the group kill is
|
|
92
|
+
* equally correct there.
|
|
93
|
+
*/
|
|
94
|
+
function spawnCliOutcome(cmd, args, env, timeoutMs) {
|
|
95
|
+
return new Promise((resolve) => {
|
|
96
|
+
const child = spawn(cmd, args, {
|
|
97
|
+
env,
|
|
98
|
+
detached: true,
|
|
99
|
+
stdio: [
|
|
100
|
+
"ignore",
|
|
101
|
+
"pipe",
|
|
102
|
+
"pipe"
|
|
103
|
+
]
|
|
104
|
+
});
|
|
105
|
+
if (child.pid !== void 0) activeGroups.add(child.pid);
|
|
106
|
+
let stdout = "";
|
|
107
|
+
let stderr = "";
|
|
108
|
+
let stdoutBytes = 0;
|
|
109
|
+
let timedOut = false;
|
|
110
|
+
let outputTooLarge = false;
|
|
111
|
+
let settled = false;
|
|
112
|
+
const killTree = (signal) => {
|
|
113
|
+
if (child.pid !== void 0) killGroup(child.pid, signal);
|
|
114
|
+
};
|
|
115
|
+
const timer = setTimeout(() => {
|
|
116
|
+
timedOut = true;
|
|
117
|
+
killTree("SIGKILL");
|
|
118
|
+
}, timeoutMs);
|
|
119
|
+
child.stdout?.on("data", (chunk) => {
|
|
120
|
+
if (outputTooLarge) return;
|
|
121
|
+
stdoutBytes += chunk.length;
|
|
122
|
+
if (stdoutBytes > MAX_OUTPUT_BYTES) {
|
|
123
|
+
outputTooLarge = true;
|
|
124
|
+
killTree("SIGKILL");
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
stdout += chunk.toString();
|
|
128
|
+
});
|
|
129
|
+
let stderrBytes = 0;
|
|
130
|
+
child.stderr?.on("data", (chunk) => {
|
|
131
|
+
stderrBytes += chunk.length;
|
|
132
|
+
if (stderrBytes <= MAX_OUTPUT_BYTES) stderr += chunk.toString();
|
|
133
|
+
});
|
|
134
|
+
const finish = (exitCode) => {
|
|
135
|
+
if (settled) return;
|
|
136
|
+
settled = true;
|
|
137
|
+
clearTimeout(timer);
|
|
138
|
+
if (child.pid !== void 0) activeGroups.delete(child.pid);
|
|
139
|
+
resolve({
|
|
140
|
+
stdout,
|
|
141
|
+
stderr,
|
|
142
|
+
timedOut,
|
|
143
|
+
outputTooLarge,
|
|
144
|
+
exitCode
|
|
145
|
+
});
|
|
146
|
+
};
|
|
147
|
+
child.on("error", (err) => {
|
|
148
|
+
if (!stderr) stderr = err.message;
|
|
149
|
+
finish(null);
|
|
150
|
+
});
|
|
151
|
+
child.on("close", (code) => finish(code));
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
/** SIGKILL every in-flight sandbox process group now. Exported so a host can reap explicitly. */
|
|
155
|
+
function killActiveSandboxes(signal = "SIGKILL") {
|
|
156
|
+
for (const pid of activeGroups) killGroup(pid, signal);
|
|
157
|
+
activeGroups.clear();
|
|
158
|
+
}
|
|
159
|
+
var shutdownHandlersInstalled = false;
|
|
160
|
+
/**
|
|
161
|
+
* Install process-shutdown hooks that reap any in-flight sandbox process groups when THIS process
|
|
162
|
+
* (the host — e.g. the MCP server) terminates. Idempotent; call once at startup from an entrypoint.
|
|
163
|
+
*
|
|
164
|
+
* Rationale: the per-run timeout (`spawnCliOutcome`) only protects a run while the host is alive —
|
|
165
|
+
* its timer dies with the host. If the host is stopped (the agent disconnects) before a run's
|
|
166
|
+
* budget elapses, the `detached` sandbox group is in its own session and survives, burning a core
|
|
167
|
+
* indefinitely. These hooks SIGKILL every tracked group on the way down so a dying host doesn't
|
|
168
|
+
* leak its children. (A hard SIGKILL of the host can't be trapped — that residual needs the kernel's
|
|
169
|
+
* PR_SET_PDEATHSIG, which Node doesn't expose.)
|
|
170
|
+
*/
|
|
171
|
+
function installSandboxShutdownHandlers() {
|
|
172
|
+
if (shutdownHandlersInstalled) return;
|
|
173
|
+
shutdownHandlersInstalled = true;
|
|
174
|
+
process.on("exit", () => killActiveSandboxes("SIGKILL"));
|
|
175
|
+
process.once("SIGTERM", () => {
|
|
176
|
+
killActiveSandboxes("SIGKILL");
|
|
177
|
+
process.exit(143);
|
|
178
|
+
});
|
|
179
|
+
process.once("SIGINT", () => {
|
|
180
|
+
killActiveSandboxes("SIGKILL");
|
|
181
|
+
process.exit(130);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
96
184
|
function tryParse(out) {
|
|
97
185
|
if (!out.trim()) return null;
|
|
98
186
|
try {
|
|
@@ -346,6 +434,7 @@ async function exportPartTool(args) {
|
|
|
346
434
|
* "build → verify" step the agent loop is built on. Uses the SDK's low-level `Server` with plain
|
|
347
435
|
* JSON-Schema tool definitions (no direct zod dependency in this package).
|
|
348
436
|
*/
|
|
437
|
+
installSandboxShutdownHandlers();
|
|
349
438
|
var pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
|
|
350
439
|
var server = new Server({
|
|
351
440
|
name: "brepjs-verify",
|
|
@@ -53,6 +53,20 @@ export interface ExportFormats {
|
|
|
53
53
|
* "no timeout", which would silently disable the sandbox's only runaway protection.
|
|
54
54
|
*/
|
|
55
55
|
export declare function positiveOrDefault(value: number | undefined, fallback: number): number;
|
|
56
|
+
/** SIGKILL every in-flight sandbox process group now. Exported so a host can reap explicitly. */
|
|
57
|
+
export declare function killActiveSandboxes(signal?: NodeJS.Signals): void;
|
|
58
|
+
/**
|
|
59
|
+
* Install process-shutdown hooks that reap any in-flight sandbox process groups when THIS process
|
|
60
|
+
* (the host — e.g. the MCP server) terminates. Idempotent; call once at startup from an entrypoint.
|
|
61
|
+
*
|
|
62
|
+
* Rationale: the per-run timeout (`spawnCliOutcome`) only protects a run while the host is alive —
|
|
63
|
+
* its timer dies with the host. If the host is stopped (the agent disconnects) before a run's
|
|
64
|
+
* budget elapses, the `detached` sandbox group is in its own session and survives, burning a core
|
|
65
|
+
* indefinitely. These hooks SIGKILL every tracked group on the way down so a dying host doesn't
|
|
66
|
+
* leak its children. (A hard SIGKILL of the host can't be trapped — that residual needs the kernel's
|
|
67
|
+
* PR_SET_PDEATHSIG, which Node doesn't expose.)
|
|
68
|
+
*/
|
|
69
|
+
export declare function installSandboxShutdownHandlers(): void;
|
|
56
70
|
/** Execute `code` (an agent-authored `.brep.ts` module) in an isolated, resource-bounded child. */
|
|
57
71
|
export declare function runProgram(code: string, opts?: RunProgramOptions): Promise<RunProgramResult>;
|
|
58
72
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brepjs-verify",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.1",
|
|
4
4
|
"description": "Agent skill + verify/preview tooling for authoring parametric brepjs CAD code",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -53,9 +53,8 @@
|
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
55
|
"@modelcontextprotocol/sdk": "1.29.0",
|
|
56
|
-
"
|
|
57
|
-
"
|
|
58
|
-
"commander": "^13.0.0",
|
|
56
|
+
"brepjs": "^18.83.2",
|
|
57
|
+
"commander": "^15.0.0",
|
|
59
58
|
"occt-wasm": "^3.0.0",
|
|
60
59
|
"typescript": "^6.0.3"
|
|
61
60
|
},
|
|
@@ -63,20 +62,20 @@
|
|
|
63
62
|
"puppeteer": "^25.0.4"
|
|
64
63
|
},
|
|
65
64
|
"devDependencies": {
|
|
66
|
-
"@anthropic-ai/sdk": "0.
|
|
65
|
+
"@anthropic-ai/sdk": "0.102.0",
|
|
67
66
|
"@react-three/drei": "^10.7.7",
|
|
68
67
|
"@react-three/fiber": "^9.6.1",
|
|
69
|
-
"@types/node": "
|
|
68
|
+
"@types/node": "25.9.2",
|
|
70
69
|
"@types/react": "^19.2.15",
|
|
71
70
|
"@types/react-dom": "^19.2.3",
|
|
72
71
|
"@types/three": "^0.184.1",
|
|
73
72
|
"@vitejs/plugin-react": "^6.0.2",
|
|
74
73
|
"brepjs-viewer": "*",
|
|
75
74
|
"eslint": "^10.4.0",
|
|
76
|
-
"react": "
|
|
77
|
-
"react-dom": "
|
|
75
|
+
"react": "19.2.7",
|
|
76
|
+
"react-dom": "19.2.7",
|
|
78
77
|
"three": "^0.184.0",
|
|
79
|
-
"tsx": "
|
|
78
|
+
"tsx": "4.22.4",
|
|
80
79
|
"vite": "^8.0.0",
|
|
81
80
|
"vite-plugin-dts": "^5.0.1",
|
|
82
81
|
"vitest": "^4.0.0",
|