@rulvar/executor 1.59.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/LICENSE ADDED
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
@@ -0,0 +1,283 @@
1
+ import { IsolatedExecRequest, IsolatedExecutorTag, SchemaSpec, ToolDef, ToolExecutorProvider, ToolRisk } from "@rulvar/core";
2
+
3
+ //#region src/spi.d.ts
4
+ /** Why an isolated dispatch failed. */
5
+ type ExecutorErrorCode = "config" | "timeout" | "aborted" | "output-cap" | "exit" | "protocol" | "spawn";
6
+ /**
7
+ * A failed isolated dispatch. The engine catches whatever a
8
+ * ToolExecutorProvider throws and turns it into the call's error tool
9
+ * result, so `message` is what the model sees: it is kept concise and
10
+ * carries a stderr tail on `exit`.
11
+ */
12
+ declare class ExecutorError extends Error {
13
+ readonly code: ExecutorErrorCode;
14
+ constructor(code: ExecutorErrorCode, message: string);
15
+ }
16
+ /** One dispatch's side-effect facts, for the ledger. */
17
+ interface ToolEffectRecord {
18
+ /** The stable per-call idempotency key (createEngine derives it). */
19
+ idempotencyKey: string;
20
+ runId: string;
21
+ spanId: string;
22
+ tool: string;
23
+ /** sha256 of the canonical arguments: correlates without storing them. */
24
+ argsHash: string;
25
+ executor: IsolatedExecutorTag;
26
+ /** The ephemeral working directory the dispatch ran in. */
27
+ workdir: string;
28
+ startedAt: number;
29
+ durationMs: number;
30
+ outcome: "ok" | "error" | "timeout";
31
+ /** Child exit code, or null when terminated by a signal. */
32
+ exitCode: number | null;
33
+ /** The terminating signal, when any. */
34
+ signal: string | null;
35
+ }
36
+ /**
37
+ * The side-effect ledger seam. An executor calls `record` once per
38
+ * dispatch (success or failure). Binding an approval to its effect is
39
+ * then a lookup: the approval entry and the effect share
40
+ * (runId, tool, argsHash), and the idempotency key is stable across a
41
+ * rerun of the same call.
42
+ */
43
+ interface ToolEffectLedger {
44
+ record(entry: ToolEffectRecord): void | Promise<void>;
45
+ }
46
+ /** An in-memory ledger for tests and single-process hosts. */
47
+ declare function memoryEffectLedger(): ToolEffectLedger & {
48
+ entries(): readonly ToolEffectRecord[];
49
+ };
50
+ /**
51
+ * A stable content hash of the arguments for the ledger's `argsHash`. It
52
+ * canonicalizes object key order so equal arguments hash equally
53
+ * regardless of property order.
54
+ */
55
+ declare function hashArgs(args: unknown): string;
56
+ /**
57
+ * The tool-program result protocol: the child's stdout, trimmed, is the
58
+ * JSON result. Empty stdout is the null result; anything else must parse
59
+ * as JSON or the dispatch fails typed `protocol`. Diagnostics belong on
60
+ * stderr, which never enters the result.
61
+ */
62
+ declare function parseToolResult(stdout: string, tool: string): unknown;
63
+ //#endregion
64
+ //#region src/subprocess.d.ts
65
+ /** The command a subprocess tool runs, carried on its `executorSpec`. */
66
+ interface SubprocessCommandSpec {
67
+ command: string;
68
+ args?: readonly string[];
69
+ }
70
+ interface SubprocessExecutorOptions {
71
+ /**
72
+ * Host environment variable names copied into the child. DEFAULT: none.
73
+ * The child's environment is otherwise empty except the per-call vars
74
+ * the executor injects, so host credentials in process.env never reach
75
+ * the tool. A bare command name needs 'PATH' here to be resolvable;
76
+ * prefer an absolute command path instead.
77
+ */
78
+ allowEnv?: readonly string[];
79
+ /**
80
+ * Mints short-lived credentials for one dispatch, injected as child
81
+ * environment variables. Called fresh per call, so a rotating or
82
+ * request-scoped token is minted at use and never lives in the host
83
+ * environment. Return an empty object to inject none.
84
+ */
85
+ credentials?: (request: IsolatedExecRequest) => Record<string, string> | Promise<Record<string, string>>;
86
+ /** Hard wall-clock ceiling per call; the child is killed on expiry. Default 30_000. */
87
+ timeoutMs?: number;
88
+ /** Grace between SIGTERM and SIGKILL. Default 2_000. */
89
+ killGraceMs?: number;
90
+ /** Max stdout/stderr bytes captured; exceeding it kills the child. Default 1 MiB. */
91
+ maxOutputBytes?: number;
92
+ /** Base directory for the per-call ephemeral workdir. Default os.tmpdir(). */
93
+ workdirBase?: string;
94
+ /**
95
+ * A sandbox launcher whose argv is prepended to the command: the real
96
+ * filesystem and network isolation plug in here. It receives the
97
+ * resolved workdir and the request and returns the wrapper argv (for
98
+ * example `['bwrap', '--unshare-net', '--bind', workdir, workdir, ...]`).
99
+ * Default: none.
100
+ */
101
+ sandbox?: (context: {
102
+ workdir: string;
103
+ request: IsolatedExecRequest;
104
+ }) => readonly string[];
105
+ /** Records every dispatch; the host owns retention and approval binding. */
106
+ ledger?: ToolEffectLedger;
107
+ /** Fallback command when a tool's executorSpec omits one. */
108
+ command?: string;
109
+ /** Argv prepended before the tool's own args (e.g. a fixed runner script). */
110
+ args?: readonly string[];
111
+ /** Injectable clock for the ledger's timing fields (tests). */
112
+ now?: () => number;
113
+ }
114
+ /**
115
+ * Builds a subprocess ToolExecutorProvider. Register it on the engine as
116
+ * `createEngine({ executors: { subprocess: subprocessExecutor(...) } })`;
117
+ * tools declaring `executor: 'subprocess'` (see {@link subprocessTool})
118
+ * then dispatch through it.
119
+ */
120
+ declare function subprocessExecutor(options?: SubprocessExecutorOptions): ToolExecutorProvider;
121
+ interface SubprocessToolInit<S extends SchemaSpec> {
122
+ name: string;
123
+ description: string;
124
+ parameters: S;
125
+ /** Contract version, part of toolsetHash. */
126
+ version?: string;
127
+ /** The program to run, and its fixed argv. */
128
+ command: string;
129
+ args?: readonly string[];
130
+ /** The terminal permission default asks when true. */
131
+ needsApproval?: boolean;
132
+ /** Policy metadata; never identity. */
133
+ risk?: ToolRisk;
134
+ }
135
+ /**
136
+ * Defines a tool that runs under a subprocess (or container) executor.
137
+ * The returned ToolDef declares `executor: 'subprocess'` and carries the
138
+ * command on `executorSpec`; its `execute` closure exists only as a
139
+ * guard, and throws if ever called in process, because dispatch routes to
140
+ * the registered executor instead. Register that executor on the engine
141
+ * for the tool to run.
142
+ */
143
+ declare function subprocessTool<S extends SchemaSpec>(init: SubprocessToolInit<S>): ToolDef<S>;
144
+ //#endregion
145
+ //#region src/container.d.ts
146
+ interface ContainerExecutorOptions {
147
+ /** The image the tool runs in (required). */
148
+ image: string;
149
+ /** The docker-compatible CLI. Default 'docker'. */
150
+ docker?: string;
151
+ /** `--network`. Default 'none' (no network at all). */
152
+ network?: string;
153
+ /** `--memory`. Default '256m'. */
154
+ memory?: string;
155
+ /** `--cpus`. Default '1.0'. */
156
+ cpus?: string;
157
+ /** `--pids-limit`. Default 128. */
158
+ pidsLimit?: number;
159
+ /** `--read-only` root filesystem. Default true. */
160
+ readOnly?: boolean;
161
+ /** Capabilities to drop. Default ['ALL']. */
162
+ capDrop?: readonly string[];
163
+ /** Where the ephemeral workdir is mounted inside the container. Default '/work'. */
164
+ workMount?: string;
165
+ /** Extra raw `docker run` flags, appended before the image. */
166
+ extraDockerArgs?: readonly string[];
167
+ /** Host env names forwarded INTO the container (not the daemon env). Default none. */
168
+ forwardEnv?: readonly string[];
169
+ /** Host env names the docker CLI itself may read. Default the daemon set. */
170
+ daemonEnv?: readonly string[];
171
+ /** Mints per-call short-lived credentials, forwarded into the container. */
172
+ credentials?: (request: IsolatedExecRequest) => Record<string, string> | Promise<Record<string, string>>;
173
+ /** Hard wall-clock ceiling per call. Default 30_000. */
174
+ timeoutMs?: number;
175
+ /** Grace between SIGTERM and SIGKILL of the docker CLI. Default 5_000. */
176
+ killGraceMs?: number;
177
+ /** Max stdout/stderr bytes captured. Default 1 MiB. */
178
+ maxOutputBytes?: number;
179
+ /** Base directory for the per-call ephemeral workdir. Default os.tmpdir(). */
180
+ workdirBase?: string;
181
+ /** Records every dispatch. */
182
+ ledger?: ToolEffectLedger;
183
+ /** Fallback command (inside the container) when executorSpec omits one. */
184
+ command?: string;
185
+ /** Argv prepended before the tool's own args. */
186
+ args?: readonly string[];
187
+ /** Injectable clock for the ledger's timing fields (tests). */
188
+ now?: () => number;
189
+ }
190
+ /**
191
+ * Builds a container ToolExecutorProvider over a docker-compatible CLI.
192
+ * Register it as
193
+ * `createEngine({ executors: { container: containerExecutor({ image }) } })`;
194
+ * tools declaring `executor: 'container'` dispatch through it. Define such
195
+ * tools with {@link subprocessTool} and set `executor` to 'container', or
196
+ * hand-build a ToolDef.
197
+ */
198
+ declare function containerExecutor(options: ContainerExecutorOptions): ToolExecutorProvider;
199
+ //#endregion
200
+ //#region src/conformance.d.ts
201
+ /** The executor options the shared contract exercises. */
202
+ interface ConformanceExecutorConfig {
203
+ command: string;
204
+ args: string[];
205
+ allowEnv?: string[];
206
+ credentials?: (request: IsolatedExecRequest) => Record<string, string>;
207
+ timeoutMs?: number;
208
+ maxOutputBytes?: number;
209
+ ledger?: ReturnType<typeof memoryEffectLedger>;
210
+ }
211
+ /** Builds the provider under test from a shared-contract config. */
212
+ type ConformanceExecutorFactory = (config: ConformanceExecutorConfig) => ToolExecutorProvider;
213
+ interface ExecutorConformanceCheck {
214
+ id: string;
215
+ title: string;
216
+ run(): Promise<void>;
217
+ }
218
+ interface ExecutorConformanceSuite {
219
+ name: string;
220
+ checks: readonly ExecutorConformanceCheck[];
221
+ run(): Promise<void>;
222
+ }
223
+ /** Structural subset of the Vitest/Jest registration API. */
224
+ interface ExecutorTestRegistrar {
225
+ describe(name: string, factory: () => void): void;
226
+ it(name: string, fn: () => Promise<void>): void;
227
+ }
228
+ declare function registerExecutorConformance(suite: ExecutorConformanceSuite, api: ExecutorTestRegistrar): void;
229
+ /**
230
+ * Builds the conformance suite. `factory` produces the provider under
231
+ * test from a shared config; the kit supplies the command (its own
232
+ * runner, run by `runtime`, default the current Node) and the per-check
233
+ * options.
234
+ */
235
+ declare function executorConformance(factory: ConformanceExecutorFactory, options?: {
236
+ runtime?: string;
237
+ }): ExecutorConformanceSuite;
238
+ //#endregion
239
+ //#region src/child.d.ts
240
+ interface ChildSpec {
241
+ command: string;
242
+ args: readonly string[];
243
+ /**
244
+ * The child's COMPLETE environment. It replaces the host environment
245
+ * rather than extending it: whatever is not listed here is absent from
246
+ * the child, which is how host credentials in process.env are kept out
247
+ * of the tool.
248
+ */
249
+ env: Record<string, string>;
250
+ cwd: string;
251
+ /** Written to the child's stdin, which is then closed. */
252
+ stdinData: string;
253
+ /** Hard wall-clock ceiling; on expiry the child is SIGTERM'd then SIGKILL'd. */
254
+ timeoutMs: number;
255
+ /** Grace between SIGTERM and the SIGKILL that follows if it ignores it. */
256
+ killGraceMs: number;
257
+ /** Captured stdout/stderr are each bounded to this many bytes. */
258
+ maxOutputBytes: number;
259
+ /** Cancels the child immediately when it fires (run abort, budget, limits). */
260
+ signal?: AbortSignal;
261
+ }
262
+ type ChildStopReason = "timeout" | "aborted" | "output-cap";
263
+ interface ChildResult {
264
+ stdout: string;
265
+ stderr: string;
266
+ /** Process exit code; null when the child was terminated by a signal. */
267
+ code: number | null;
268
+ /** The terminating signal, when any. */
269
+ signal: NodeJS.Signals | null;
270
+ /** True when the runner (not the child) ended it, with the reason why. */
271
+ stopped: boolean;
272
+ reason?: ChildStopReason;
273
+ }
274
+ /**
275
+ * Spawns one child and resolves with its captured output and exit status,
276
+ * or rejects if the process could not be spawned at all (e.g. the command
277
+ * is a bare name and PATH is not in `env`, so it cannot be resolved). A
278
+ * child that exits non-zero or is killed resolves normally; interpreting
279
+ * that is the caller's job.
280
+ */
281
+ declare function runChildProcess(spec: ChildSpec): Promise<ChildResult>;
282
+ //#endregion
283
+ export { type ChildResult, type ChildSpec, type ChildStopReason, type ConformanceExecutorConfig, type ConformanceExecutorFactory, type ContainerExecutorOptions, type ExecutorConformanceCheck, type ExecutorConformanceSuite, ExecutorError, type ExecutorErrorCode, type ExecutorTestRegistrar, type SubprocessCommandSpec, type SubprocessExecutorOptions, type SubprocessToolInit, type ToolEffectLedger, type ToolEffectRecord, containerExecutor, executorConformance, hashArgs, memoryEffectLedger, parseToolResult, registerExecutorConformance, runChildProcess, subprocessExecutor, subprocessTool };
package/dist/index.js ADDED
@@ -0,0 +1,810 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { tool } from "@rulvar/core";
5
+ import { spawn } from "node:child_process";
6
+ import { createHash } from "node:crypto";
7
+ import { existsSync, mkdtempSync, writeFileSync } from "node:fs";
8
+ //#region src/child.ts
9
+ /**
10
+ * The shared child-process runner both reference executors build on. It
11
+ * owns the parts that must be exactly right for isolation to hold: a
12
+ * replaced (not inherited) environment, a hard wall-clock timeout that
13
+ * escalates SIGTERM to SIGKILL, cancellation via the run's AbortSignal,
14
+ * and a hard cap on captured output so a runaway child cannot exhaust
15
+ * host memory. Nothing here decides policy; subprocess.ts and
16
+ * container.ts assemble the command and environment and interpret the
17
+ * result.
18
+ */
19
+ /**
20
+ * Spawns one child and resolves with its captured output and exit status,
21
+ * or rejects if the process could not be spawned at all (e.g. the command
22
+ * is a bare name and PATH is not in `env`, so it cannot be resolved). A
23
+ * child that exits non-zero or is killed resolves normally; interpreting
24
+ * that is the caller's job.
25
+ */
26
+ function runChildProcess(spec) {
27
+ return new Promise((resolve, reject) => {
28
+ let child;
29
+ try {
30
+ child = spawn(spec.command, [...spec.args], {
31
+ cwd: spec.cwd,
32
+ env: spec.env,
33
+ stdio: [
34
+ "pipe",
35
+ "pipe",
36
+ "pipe"
37
+ ]
38
+ });
39
+ } catch (err) {
40
+ reject(err instanceof Error ? err : new Error(String(err)));
41
+ return;
42
+ }
43
+ const stdoutChunks = [];
44
+ const stderrChunks = [];
45
+ let stdoutBytes = 0;
46
+ let stderrBytes = 0;
47
+ let stopped = false;
48
+ let reason;
49
+ let settled = false;
50
+ const timers = {};
51
+ const clearTimers = () => {
52
+ if (timers.kill !== void 0) clearTimeout(timers.kill);
53
+ if (timers.grace !== void 0) clearTimeout(timers.grace);
54
+ if (spec.signal !== void 0) spec.signal.removeEventListener("abort", onAbort);
55
+ };
56
+ const escalateKill = () => {
57
+ timers.grace = setTimeout(() => {
58
+ child.kill("SIGKILL");
59
+ }, spec.killGraceMs);
60
+ child.kill("SIGTERM");
61
+ };
62
+ const stop = (why) => {
63
+ if (stopped) return;
64
+ stopped = true;
65
+ reason = why;
66
+ escalateKill();
67
+ };
68
+ function onAbort() {
69
+ stop("aborted");
70
+ }
71
+ timers.kill = setTimeout(() => stop("timeout"), spec.timeoutMs);
72
+ if (spec.signal !== void 0) if (spec.signal.aborted) stop("aborted");
73
+ else spec.signal.addEventListener("abort", onAbort);
74
+ child.stdout.on("data", (chunk) => {
75
+ if (stdoutBytes >= spec.maxOutputBytes) return;
76
+ const room = spec.maxOutputBytes - stdoutBytes;
77
+ stdoutChunks.push(chunk.length > room ? chunk.subarray(0, room) : chunk);
78
+ stdoutBytes += Math.min(chunk.length, room);
79
+ if (stdoutBytes >= spec.maxOutputBytes) stop("output-cap");
80
+ });
81
+ child.stderr.on("data", (chunk) => {
82
+ if (stderrBytes >= spec.maxOutputBytes) return;
83
+ const room = spec.maxOutputBytes - stderrBytes;
84
+ stderrChunks.push(chunk.length > room ? chunk.subarray(0, room) : chunk);
85
+ stderrBytes += Math.min(chunk.length, room);
86
+ });
87
+ child.on("error", (err) => {
88
+ if (settled) return;
89
+ settled = true;
90
+ clearTimers();
91
+ reject(err);
92
+ });
93
+ child.on("close", (code, signal) => {
94
+ if (settled) return;
95
+ settled = true;
96
+ clearTimers();
97
+ resolve({
98
+ stdout: Buffer.concat(stdoutChunks).toString("utf8"),
99
+ stderr: Buffer.concat(stderrChunks).toString("utf8"),
100
+ code,
101
+ signal,
102
+ stopped,
103
+ ...reason === void 0 ? {} : { reason }
104
+ });
105
+ });
106
+ child.stdin.on("error", () => void 0);
107
+ child.stdin.end(spec.stdinData);
108
+ });
109
+ }
110
+ //#endregion
111
+ //#region src/spi.ts
112
+ /**
113
+ * The types shared by the reference executors: the typed failure a
114
+ * provider throws (which the engine surfaces to the model as the call's
115
+ * error tool result), and the side-effect ledger that records every
116
+ * dispatch so a host can bind an approval to the effect it authorized.
117
+ *
118
+ * The provider seam itself (ToolExecutorProvider, IsolatedExecRequest,
119
+ * IsolatedExecContext) is defined in and re-exported from `@rulvar/core`.
120
+ */
121
+ /**
122
+ * A failed isolated dispatch. The engine catches whatever a
123
+ * ToolExecutorProvider throws and turns it into the call's error tool
124
+ * result, so `message` is what the model sees: it is kept concise and
125
+ * carries a stderr tail on `exit`.
126
+ */
127
+ var ExecutorError = class extends Error {
128
+ code;
129
+ constructor(code, message) {
130
+ super(message);
131
+ this.name = "ExecutorError";
132
+ this.code = code;
133
+ }
134
+ };
135
+ /** An in-memory ledger for tests and single-process hosts. */
136
+ function memoryEffectLedger() {
137
+ const rows = [];
138
+ return {
139
+ record(entry) {
140
+ rows.push(entry);
141
+ },
142
+ entries() {
143
+ return rows;
144
+ }
145
+ };
146
+ }
147
+ /**
148
+ * A stable content hash of the arguments for the ledger's `argsHash`. It
149
+ * canonicalizes object key order so equal arguments hash equally
150
+ * regardless of property order.
151
+ */
152
+ function hashArgs(args) {
153
+ return createHash("sha256").update(stableStringify(args), "utf8").digest("hex");
154
+ }
155
+ function stableStringify(value) {
156
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
157
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
158
+ const record = value;
159
+ return `{${Object.keys(record).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(record[k])}`).join(",")}}`;
160
+ }
161
+ /**
162
+ * The tool-program result protocol: the child's stdout, trimmed, is the
163
+ * JSON result. Empty stdout is the null result; anything else must parse
164
+ * as JSON or the dispatch fails typed `protocol`. Diagnostics belong on
165
+ * stderr, which never enters the result.
166
+ */
167
+ function parseToolResult(stdout, tool) {
168
+ const trimmed = stdout.trim();
169
+ if (trimmed === "") return null;
170
+ try {
171
+ return JSON.parse(trimmed);
172
+ } catch {
173
+ throw new ExecutorError("protocol", `tool '${tool}' did not write a JSON result to stdout (write diagnostics to stderr)`);
174
+ }
175
+ }
176
+ //#endregion
177
+ //#region src/subprocess.ts
178
+ /**
179
+ * The subprocess reference executor (RV-216): runs a tool's work in a
180
+ * child process with a REPLACED environment (host credentials scrubbed),
181
+ * a fresh ephemeral working directory per call, a hard timeout that kills
182
+ * the child, a bounded output capture, and per-call short-lived
183
+ * credentials. It records every dispatch to the side-effect ledger.
184
+ *
185
+ * What it does and does not isolate is stated plainly in the guide:
186
+ * scrubbing the environment removes the host's ambient credentials (the
187
+ * usual exfiltration path), and the timeout and output cap bound a
188
+ * runaway child. It does NOT by itself block a child from reading
189
+ * world-readable host files or opening sockets: for that, either pass a
190
+ * `sandbox` launcher (bwrap, firejail, sandbox-exec, nsjail) or use the
191
+ * container executor, which drops the network and mounts the filesystem
192
+ * read-only.
193
+ *
194
+ * The tool-program protocol: the child reads one JSON line on stdin,
195
+ * `{ tool, args, idempotencyKey }`, does its work, and writes its JSON
196
+ * result to stdout. Diagnostics go to stderr.
197
+ *
198
+ * Docs: https://docs.rulvar.com/guide/isolated-executor.
199
+ */
200
+ const wallClock$1 = Date.now.bind(globalThis);
201
+ function resolveCommand(request, options) {
202
+ const spec = request.spec ?? {};
203
+ const command = typeof spec.command === "string" ? spec.command : options.command;
204
+ if (command === void 0 || command === "") throw new ExecutorError("config", `tool '${request.tool}' has no command: set executorSpec.command on the tool or command on the executor`);
205
+ const specArgs = Array.isArray(spec.args) ? spec.args.filter((a) => typeof a === "string") : [];
206
+ return {
207
+ command,
208
+ args: [...options.args ?? [], ...specArgs]
209
+ };
210
+ }
211
+ /**
212
+ * Builds a subprocess ToolExecutorProvider. Register it on the engine as
213
+ * `createEngine({ executors: { subprocess: subprocessExecutor(...) } })`;
214
+ * tools declaring `executor: 'subprocess'` (see {@link subprocessTool})
215
+ * then dispatch through it.
216
+ */
217
+ function subprocessExecutor(options = {}) {
218
+ const timeoutMs = options.timeoutMs ?? 3e4;
219
+ const killGraceMs = options.killGraceMs ?? 2e3;
220
+ const maxOutputBytes = options.maxOutputBytes ?? 1024 * 1024;
221
+ const workdirBase = options.workdirBase ?? tmpdir();
222
+ const now = options.now ?? wallClock$1;
223
+ return { async run(request) {
224
+ const { command, args } = resolveCommand(request, options);
225
+ const workdir = await mkdtemp(join(workdirBase, `rulvar-exec-${request.tool}-`));
226
+ const startedAt = now();
227
+ let outcome = "ok";
228
+ let exitCode = null;
229
+ let signal = null;
230
+ try {
231
+ const env = {};
232
+ for (const name of options.allowEnv ?? []) {
233
+ const value = process.env[name];
234
+ if (value !== void 0) env[name] = value;
235
+ }
236
+ const creds = options.credentials === void 0 ? {} : await options.credentials(request);
237
+ Object.assign(env, creds);
238
+ env.RULVAR_TOOL = request.tool;
239
+ env.RULVAR_RUN_ID = request.ctx.runId;
240
+ env.RULVAR_IDEMPOTENCY_KEY = request.ctx.idempotencyKey;
241
+ const wrapper = options.sandbox === void 0 ? [] : [...options.sandbox({
242
+ workdir,
243
+ request
244
+ })];
245
+ const [spawnCommand, ...spawnPrefix] = wrapper.length > 0 ? wrapper : [command];
246
+ const spawnArgs = wrapper.length > 0 ? [
247
+ ...spawnPrefix,
248
+ command,
249
+ ...args
250
+ ] : args;
251
+ let child;
252
+ try {
253
+ child = await runChildProcess({
254
+ command: spawnCommand,
255
+ args: spawnArgs,
256
+ env,
257
+ cwd: workdir,
258
+ stdinData: JSON.stringify({
259
+ tool: request.tool,
260
+ args: request.args,
261
+ idempotencyKey: request.ctx.idempotencyKey
262
+ }),
263
+ timeoutMs,
264
+ killGraceMs,
265
+ maxOutputBytes,
266
+ signal: request.ctx.signal
267
+ });
268
+ } catch (err) {
269
+ outcome = "error";
270
+ throw new ExecutorError("spawn", `tool '${request.tool}' could not be spawned: ${err instanceof Error ? err.message : String(err)}`);
271
+ }
272
+ exitCode = child.code;
273
+ signal = child.signal;
274
+ if (child.stopped && child.reason === "timeout") {
275
+ outcome = "timeout";
276
+ throw new ExecutorError("timeout", `tool '${request.tool}' exceeded ${timeoutMs}ms and was killed`);
277
+ }
278
+ if (child.stopped && child.reason === "aborted") {
279
+ outcome = "error";
280
+ throw new ExecutorError("aborted", `tool '${request.tool}' was cancelled`);
281
+ }
282
+ if (child.stopped && child.reason === "output-cap") {
283
+ outcome = "error";
284
+ throw new ExecutorError("output-cap", `tool '${request.tool}' wrote more than ${maxOutputBytes} bytes and was killed`);
285
+ }
286
+ if (child.code !== 0) {
287
+ outcome = "error";
288
+ const tail = child.stderr.trim().slice(-500);
289
+ throw new ExecutorError("exit", `tool '${request.tool}' exited ${child.code ?? "null"}${child.signal === null ? "" : ` (signal ${child.signal})`}${tail === "" ? "" : `: ${tail}`}`);
290
+ }
291
+ return parseToolResult(child.stdout, request.tool);
292
+ } finally {
293
+ const durationMs = now() - startedAt;
294
+ if (options.ledger !== void 0) await options.ledger.record({
295
+ idempotencyKey: request.ctx.idempotencyKey,
296
+ runId: request.ctx.runId,
297
+ spanId: request.ctx.spanId,
298
+ tool: request.tool,
299
+ argsHash: hashArgs(request.args),
300
+ executor: request.executor,
301
+ workdir,
302
+ startedAt,
303
+ durationMs,
304
+ outcome,
305
+ exitCode,
306
+ signal
307
+ });
308
+ await rm(workdir, {
309
+ recursive: true,
310
+ force: true
311
+ });
312
+ }
313
+ } };
314
+ }
315
+ /**
316
+ * Defines a tool that runs under a subprocess (or container) executor.
317
+ * The returned ToolDef declares `executor: 'subprocess'` and carries the
318
+ * command on `executorSpec`; its `execute` closure exists only as a
319
+ * guard, and throws if ever called in process, because dispatch routes to
320
+ * the registered executor instead. Register that executor on the engine
321
+ * for the tool to run.
322
+ */
323
+ function subprocessTool(init) {
324
+ return tool({
325
+ name: init.name,
326
+ description: init.description,
327
+ parameters: init.parameters,
328
+ ...init.version === void 0 ? {} : { version: init.version },
329
+ executor: "subprocess",
330
+ executorSpec: {
331
+ command: init.command,
332
+ ...init.args === void 0 ? {} : { args: [...init.args] }
333
+ },
334
+ ...init.needsApproval === void 0 ? {} : { needsApproval: init.needsApproval },
335
+ ...init.risk === void 0 ? {} : { risk: init.risk },
336
+ execute: () => Promise.reject(new ExecutorError("config", `tool '${init.name}' runs under an out-of-process executor; register it via createEngine({ executors: { subprocess: subprocessExecutor(...) } })`))
337
+ });
338
+ }
339
+ //#endregion
340
+ //#region src/container.ts
341
+ /**
342
+ * The container reference executor (RV-216): runs a tool's work inside a
343
+ * one-shot container, which is where the isolation the subprocess
344
+ * executor cannot promise actually holds. By default it drops the network
345
+ * entirely (`--network none`), mounts the root filesystem read-only
346
+ * (`--read-only`), caps memory, CPU, and process count, and drops all
347
+ * Linux capabilities (`--cap-drop ALL`). The only writable path is the
348
+ * per-call ephemeral workdir, bind-mounted at `/work`.
349
+ *
350
+ * Host credentials never enter the container: the container starts from
351
+ * the image's environment plus exactly the variables the executor
352
+ * forwards by name, and those values live in the docker CLI process's
353
+ * environment, not in the argv. Short-lived credentials are minted per
354
+ * call and forwarded the same way.
355
+ *
356
+ * The tool-program protocol is identical to the subprocess executor: one
357
+ * JSON line on stdin, the JSON result on stdout, diagnostics on stderr.
358
+ *
359
+ * A microVM adapter (Firecracker, gVisor, Kata) implements the same
360
+ * ToolExecutorProvider seam; this docker adapter is the batteries-included
361
+ * reference. Docs: https://docs.rulvar.com/guide/isolated-executor.
362
+ */
363
+ const wallClock = Date.now.bind(globalThis);
364
+ /** The default host variables the docker CLI needs to reach its daemon. */
365
+ const DEFAULT_DAEMON_ENV = [
366
+ "PATH",
367
+ "HOME",
368
+ "DOCKER_HOST",
369
+ "DOCKER_TLS_VERIFY",
370
+ "DOCKER_CERT_PATH"
371
+ ];
372
+ /**
373
+ * Builds a container ToolExecutorProvider over a docker-compatible CLI.
374
+ * Register it as
375
+ * `createEngine({ executors: { container: containerExecutor({ image }) } })`;
376
+ * tools declaring `executor: 'container'` dispatch through it. Define such
377
+ * tools with {@link subprocessTool} and set `executor` to 'container', or
378
+ * hand-build a ToolDef.
379
+ */
380
+ function containerExecutor(options) {
381
+ const docker = options.docker ?? "docker";
382
+ const network = options.network ?? "none";
383
+ const memory = options.memory ?? "256m";
384
+ const cpus = options.cpus ?? "1.0";
385
+ const pidsLimit = options.pidsLimit ?? 128;
386
+ const readOnly = options.readOnly ?? true;
387
+ const capDrop = options.capDrop ?? ["ALL"];
388
+ const workMount = options.workMount ?? "/work";
389
+ const timeoutMs = options.timeoutMs ?? 3e4;
390
+ const killGraceMs = options.killGraceMs ?? 5e3;
391
+ const maxOutputBytes = options.maxOutputBytes ?? 1024 * 1024;
392
+ const workdirBase = options.workdirBase ?? tmpdir();
393
+ const now = options.now ?? wallClock;
394
+ return { async run(request) {
395
+ const spec = request.spec ?? {};
396
+ const command = typeof spec.command === "string" ? spec.command : options.command;
397
+ if (command === void 0 || command === "") throw new ExecutorError("config", `tool '${request.tool}' has no command: set executorSpec.command or the executor command`);
398
+ const specArgs = Array.isArray(spec.args) ? spec.args.filter((a) => typeof a === "string") : [];
399
+ const toolArgs = [...options.args ?? [], ...specArgs];
400
+ const workdir = await mkdtemp(join(workdirBase, `rulvar-cexec-${request.tool}-`));
401
+ const startedAt = now();
402
+ let outcome = "ok";
403
+ let exitCode = null;
404
+ let signal = null;
405
+ try {
406
+ const daemonEnv = options.daemonEnv ?? DEFAULT_DAEMON_ENV;
407
+ const env = {};
408
+ for (const name of daemonEnv) {
409
+ const value = process.env[name];
410
+ if (value !== void 0) env[name] = value;
411
+ }
412
+ const forwardNames = /* @__PURE__ */ new Set([
413
+ "RULVAR_TOOL",
414
+ "RULVAR_RUN_ID",
415
+ "RULVAR_IDEMPOTENCY_KEY"
416
+ ]);
417
+ for (const name of options.forwardEnv ?? []) {
418
+ const value = process.env[name];
419
+ if (value !== void 0) {
420
+ env[name] = value;
421
+ forwardNames.add(name);
422
+ }
423
+ }
424
+ const creds = options.credentials === void 0 ? {} : await options.credentials(request);
425
+ for (const [name, value] of Object.entries(creds)) {
426
+ env[name] = value;
427
+ forwardNames.add(name);
428
+ }
429
+ env.RULVAR_TOOL = request.tool;
430
+ env.RULVAR_RUN_ID = request.ctx.runId;
431
+ env.RULVAR_IDEMPOTENCY_KEY = request.ctx.idempotencyKey;
432
+ const dockerArgs = [
433
+ "run",
434
+ "--rm",
435
+ "-i",
436
+ "--network",
437
+ network
438
+ ];
439
+ dockerArgs.push("--memory", memory, "--cpus", cpus, "--pids-limit", String(pidsLimit));
440
+ if (readOnly) dockerArgs.push("--read-only");
441
+ for (const cap of capDrop) dockerArgs.push("--cap-drop", cap);
442
+ for (const name of forwardNames) dockerArgs.push("-e", name);
443
+ dockerArgs.push("-v", `${workdir}:${workMount}`, "-w", workMount);
444
+ dockerArgs.push(...options.extraDockerArgs ?? []);
445
+ dockerArgs.push(options.image, command, ...toolArgs);
446
+ let child;
447
+ try {
448
+ child = await runChildProcess({
449
+ command: docker,
450
+ args: dockerArgs,
451
+ env,
452
+ cwd: workdir,
453
+ stdinData: JSON.stringify({
454
+ tool: request.tool,
455
+ args: request.args,
456
+ idempotencyKey: request.ctx.idempotencyKey
457
+ }),
458
+ timeoutMs,
459
+ killGraceMs,
460
+ maxOutputBytes,
461
+ signal: request.ctx.signal
462
+ });
463
+ } catch (err) {
464
+ outcome = "error";
465
+ throw new ExecutorError("spawn", `container tool '${request.tool}' could not launch '${docker}': ${err instanceof Error ? err.message : String(err)}`);
466
+ }
467
+ exitCode = child.code;
468
+ signal = child.signal;
469
+ if (child.stopped && child.reason === "timeout") {
470
+ outcome = "timeout";
471
+ throw new ExecutorError("timeout", `container tool '${request.tool}' exceeded ${timeoutMs}ms and was killed`);
472
+ }
473
+ if (child.stopped && child.reason === "aborted") {
474
+ outcome = "error";
475
+ throw new ExecutorError("aborted", `container tool '${request.tool}' was cancelled`);
476
+ }
477
+ if (child.stopped && child.reason === "output-cap") {
478
+ outcome = "error";
479
+ throw new ExecutorError("output-cap", `container tool '${request.tool}' wrote more than ${maxOutputBytes} bytes and was killed`);
480
+ }
481
+ if (child.code !== 0) {
482
+ outcome = "error";
483
+ const tail = child.stderr.trim().slice(-500);
484
+ throw new ExecutorError("exit", `container tool '${request.tool}' exited ${child.code ?? "null"}${tail === "" ? "" : `: ${tail}`}`);
485
+ }
486
+ return parseToolResult(child.stdout, request.tool);
487
+ } finally {
488
+ const durationMs = now() - startedAt;
489
+ if (options.ledger !== void 0) await options.ledger.record({
490
+ idempotencyKey: request.ctx.idempotencyKey,
491
+ runId: request.ctx.runId,
492
+ spanId: request.ctx.spanId,
493
+ tool: request.tool,
494
+ argsHash: hashArgs(request.args),
495
+ executor: request.executor,
496
+ workdir,
497
+ startedAt,
498
+ durationMs,
499
+ outcome,
500
+ exitCode,
501
+ signal
502
+ });
503
+ await rm(workdir, {
504
+ recursive: true,
505
+ force: true
506
+ });
507
+ }
508
+ } };
509
+ }
510
+ //#endregion
511
+ //#region src/conformance.ts
512
+ /**
513
+ * The executable executor conformance kit (RV-216): the shared-contract
514
+ * battery every command-based ToolExecutorProvider must pass, mirroring
515
+ * @rulvar/store-conformance. It drives a provider through the tool-program
516
+ * protocol and asserts the isolation and durability properties the seam
517
+ * promises, foremost the gate the epic exists for: a hostile tool cannot
518
+ * read the host's ambient credentials.
519
+ *
520
+ * The subprocess reference executor passes it directly. A container or
521
+ * microVM adapter passes it too when its runtime can run the kit's runner
522
+ * (see the docker-gated container test), and additionally proves the
523
+ * network and filesystem isolation only a container can enforce.
524
+ *
525
+ * Usage under Vitest:
526
+ *
527
+ * const suite = executorConformance((cfg) => subprocessExecutor(cfg));
528
+ * registerExecutorConformance(suite, { describe, it });
529
+ *
530
+ * Docs: https://docs.rulvar.com/guide/isolated-executor.
531
+ */
532
+ function registerExecutorConformance(suite, api) {
533
+ api.describe(suite.name, () => {
534
+ for (const check of suite.checks) api.it(`${check.id}: ${check.title}`, () => check.run());
535
+ });
536
+ }
537
+ function ensure(condition, id, message) {
538
+ if (!condition) throw new Error(`executor-conformance ${id}: ${message}`);
539
+ }
540
+ const RUNNER_SOURCE = `
541
+ let input = '';
542
+ process.stdin.on('data', (c) => { input += c; });
543
+ process.stdin.on('end', () => {
544
+ let msg;
545
+ try { msg = JSON.parse(input || '{}'); } catch { process.stderr.write('bad protocol input'); process.exit(2); }
546
+ const args = (msg && msg.args) || {};
547
+ const done = (v) => { process.stdout.write(JSON.stringify(v)); process.exit(0); };
548
+ switch (args.behavior) {
549
+ case 'echo': return done({ tool: msg.tool, payload: args.payload ?? null, idempotencyKey: msg.idempotencyKey });
550
+ case 'read-env': return done({ value: process.env[args.name] ?? null });
551
+ case 'sleep': return setTimeout(() => done({ slept: true }), args.ms ?? 100000);
552
+ case 'huge': {
553
+ const chunk = 'x'.repeat(64 * 1024);
554
+ for (let i = 0; i < (args.chunks ?? 128); i++) process.stdout.write(chunk);
555
+ return;
556
+ }
557
+ case 'exit': { if (args.stderr) process.stderr.write(String(args.stderr)); return process.exit(args.code ?? 1); }
558
+ case 'garbage': { process.stdout.write('this is not json'); return process.exit(0); }
559
+ case 'workdir': {
560
+ const fs = require('node:fs');
561
+ const before = fs.readdirSync(process.cwd()).length;
562
+ fs.writeFileSync('marker.txt', 'x');
563
+ return done({ before });
564
+ }
565
+ default: return done({ unknown: args.behavior ?? null });
566
+ }
567
+ });
568
+ `;
569
+ /**
570
+ * Builds the conformance suite. `factory` produces the provider under
571
+ * test from a shared config; the kit supplies the command (its own
572
+ * runner, run by `runtime`, default the current Node) and the per-check
573
+ * options.
574
+ */
575
+ function executorConformance(factory, options = {}) {
576
+ const runtime = options.runtime ?? process.execPath;
577
+ const runner = join(mkdtempSync(join(tmpdir(), "rulvar-exec-conf-")), "runner.cjs");
578
+ writeFileSync(runner, RUNNER_SOURCE, "utf8");
579
+ const baseArgs = [runner];
580
+ const request = (tool, args, over = {}) => ({
581
+ executor: "subprocess",
582
+ tool,
583
+ args,
584
+ spec: {},
585
+ ctx: {
586
+ runId: "conf-run",
587
+ spanId: "conf-span",
588
+ agentType: "conf",
589
+ idempotencyKey: "conf-key",
590
+ signal: new AbortController().signal,
591
+ log: () => void 0,
592
+ ...over
593
+ }
594
+ });
595
+ const asExecutorError = async (id, promise) => {
596
+ try {
597
+ await promise;
598
+ } catch (err) {
599
+ ensure(err instanceof ExecutorError, id, `expected ExecutorError, got ${String(err)}`);
600
+ return err;
601
+ }
602
+ throw new Error(`executor-conformance ${id}: expected the dispatch to reject`);
603
+ };
604
+ const checks = [
605
+ {
606
+ id: "e1",
607
+ title: "round-trips the tool-program protocol (stdin args to stdout JSON result)",
608
+ async run() {
609
+ const result = await factory({
610
+ command: runtime,
611
+ args: baseArgs
612
+ }).run(request("echo_tool", {
613
+ behavior: "echo",
614
+ payload: {
615
+ hello: "world",
616
+ n: 7
617
+ }
618
+ }));
619
+ ensure(result.tool === "echo_tool", "e1", "the tool name did not reach the program");
620
+ ensure(JSON.stringify(result.payload) === JSON.stringify({
621
+ hello: "world",
622
+ n: 7
623
+ }), "e1", "the arguments did not round-trip");
624
+ }
625
+ },
626
+ {
627
+ id: "e2",
628
+ title: "scrubs the host environment: a hostile tool cannot read a host secret",
629
+ async run() {
630
+ const secretName = "RV_CONFORMANCE_SECRET";
631
+ process.env[secretName] = "sk-live-should-never-reach-a-tool";
632
+ try {
633
+ const result = await factory({
634
+ command: runtime,
635
+ args: baseArgs
636
+ }).run(request("reader", {
637
+ behavior: "read-env",
638
+ name: secretName
639
+ }));
640
+ ensure(result.value === null, "e2", `the host secret reached the tool (${String(result.value)}); the environment was not scrubbed`);
641
+ } finally {
642
+ delete process.env[secretName];
643
+ }
644
+ }
645
+ },
646
+ {
647
+ id: "e3",
648
+ title: "honors the environment allowlist (a named var passes through)",
649
+ async run() {
650
+ const name = "RV_CONFORMANCE_ALLOWED";
651
+ process.env[name] = "passed-through";
652
+ try {
653
+ ensure((await factory({
654
+ command: runtime,
655
+ args: baseArgs,
656
+ allowEnv: [name]
657
+ }).run(request("reader", {
658
+ behavior: "read-env",
659
+ name
660
+ }))).value === "passed-through", "e3", "an allowlisted var did not pass through");
661
+ } finally {
662
+ delete process.env[name];
663
+ }
664
+ }
665
+ },
666
+ {
667
+ id: "e4",
668
+ title: "injects per-call short-lived credentials",
669
+ async run() {
670
+ ensure((await factory({
671
+ command: runtime,
672
+ args: baseArgs,
673
+ credentials: () => ({ RV_CRED: "minted-token" })
674
+ }).run(request("reader", {
675
+ behavior: "read-env",
676
+ name: "RV_CRED"
677
+ }))).value === "minted-token", "e4", "the minted credential did not reach the tool");
678
+ }
679
+ },
680
+ {
681
+ id: "e5",
682
+ title: "forwards the idempotency key to the tool",
683
+ async run() {
684
+ ensure((await factory({
685
+ command: runtime,
686
+ args: baseArgs
687
+ }).run(request("reader", {
688
+ behavior: "read-env",
689
+ name: "RULVAR_IDEMPOTENCY_KEY"
690
+ }, { idempotencyKey: "key-abc" }))).value === "key-abc", "e5", "the idempotency key did not reach the tool");
691
+ }
692
+ },
693
+ {
694
+ id: "e6",
695
+ title: "kills a tool that exceeds its wall-clock timeout",
696
+ async run() {
697
+ const provider = factory({
698
+ command: runtime,
699
+ args: baseArgs,
700
+ timeoutMs: 300
701
+ });
702
+ const startedAt = Date.now();
703
+ const err = await asExecutorError("e6", provider.run(request("slow", {
704
+ behavior: "sleep",
705
+ ms: 1e5
706
+ })));
707
+ ensure(err.code === "timeout", "e6", `expected code 'timeout', got '${err.code}'`);
708
+ ensure(Date.now() - startedAt < 5e3, "e6", "the timeout did not fire promptly");
709
+ }
710
+ },
711
+ {
712
+ id: "e7",
713
+ title: "bounds captured output and kills a runaway writer",
714
+ async run() {
715
+ const provider = factory({
716
+ command: runtime,
717
+ args: baseArgs,
718
+ maxOutputBytes: 32 * 1024
719
+ });
720
+ const err = await asExecutorError("e7", provider.run(request("flood", {
721
+ behavior: "huge",
722
+ chunks: 256
723
+ })));
724
+ ensure(err.code === "output-cap", "e7", `expected code 'output-cap', got '${err.code}'`);
725
+ }
726
+ },
727
+ {
728
+ id: "e8",
729
+ title: "surfaces a non-zero exit as a typed error carrying the stderr tail",
730
+ async run() {
731
+ const provider = factory({
732
+ command: runtime,
733
+ args: baseArgs
734
+ });
735
+ const err = await asExecutorError("e8", provider.run(request("crasher", {
736
+ behavior: "exit",
737
+ code: 3,
738
+ stderr: "boom-diagnostic"
739
+ })));
740
+ ensure(err.code === "exit", "e8", `expected code 'exit', got '${err.code}'`);
741
+ ensure(err.message.includes("boom-diagnostic"), "e8", "the stderr tail was not surfaced");
742
+ }
743
+ },
744
+ {
745
+ id: "e9",
746
+ title: "rejects a tool that does not write a JSON result",
747
+ async run() {
748
+ const provider = factory({
749
+ command: runtime,
750
+ args: baseArgs
751
+ });
752
+ const err = await asExecutorError("e9", provider.run(request("sloppy", { behavior: "garbage" })));
753
+ ensure(err.code === "protocol", "e9", `expected code 'protocol', got '${err.code}'`);
754
+ }
755
+ },
756
+ {
757
+ id: "e10",
758
+ title: "gives each call a fresh, empty, ephemeral working directory",
759
+ async run() {
760
+ const ledger = memoryEffectLedger();
761
+ const provider = factory({
762
+ command: runtime,
763
+ args: baseArgs,
764
+ ledger
765
+ });
766
+ const first = await provider.run(request("w", { behavior: "workdir" }));
767
+ const second = await provider.run(request("w", { behavior: "workdir" }));
768
+ ensure(first.before === 0, "e10", "the first workdir was not empty");
769
+ ensure(second.before === 0, "e10", "the second call saw leftovers from the first");
770
+ for (const record of ledger.entries()) ensure(!existsSync(record.workdir), "e10", "the ephemeral workdir was not removed after the call");
771
+ }
772
+ },
773
+ {
774
+ id: "e11",
775
+ title: "records every dispatch to the side-effect ledger",
776
+ async run() {
777
+ const ledger = memoryEffectLedger();
778
+ const provider = factory({
779
+ command: runtime,
780
+ args: baseArgs,
781
+ ledger
782
+ });
783
+ await provider.run(request("ok_tool", {
784
+ behavior: "echo",
785
+ payload: 1
786
+ }, { idempotencyKey: "k-ok" }));
787
+ await asExecutorError("e11", provider.run(request("bad_tool", {
788
+ behavior: "exit",
789
+ code: 1
790
+ }, { idempotencyKey: "k-bad" })));
791
+ const rows = ledger.entries();
792
+ ensure(rows.length === 2, "e11", `expected 2 ledger records, got ${rows.length}`);
793
+ const ok = rows.find((r) => r.tool === "ok_tool");
794
+ const bad = rows.find((r) => r.tool === "bad_tool");
795
+ ensure(ok?.outcome === "ok" && ok.idempotencyKey === "k-ok", "e11", "the ok record is wrong");
796
+ ensure(bad?.outcome === "error" && bad.exitCode === 1, "e11", "the error record is wrong");
797
+ ensure(rows.every((r) => typeof r.argsHash === "string" && r.argsHash.length === 64), "e11", "every record must carry a content argsHash");
798
+ }
799
+ }
800
+ ];
801
+ return {
802
+ name: "executor conformance (RV-216 shared contract)",
803
+ checks,
804
+ async run() {
805
+ for (const check of checks) await check.run();
806
+ }
807
+ };
808
+ }
809
+ //#endregion
810
+ export { ExecutorError, containerExecutor, executorConformance, hashArgs, memoryEffectLedger, parseToolResult, registerExecutorConformance, runChildProcess, subprocessExecutor, subprocessTool };
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@rulvar/executor",
3
+ "version": "1.59.0",
4
+ "description": "Rulvar isolated tool executors: reference ToolExecutorProvider adapters that run tool work out of process (subprocess and container) so hostile or model-generated scripts cannot reach host capabilities.",
5
+ "type": "module",
6
+ "license": "Apache-2.0",
7
+ "engines": {
8
+ "node": ">=22.12.0"
9
+ },
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "./package.json": "./package.json"
16
+ },
17
+ "files": [
18
+ "dist"
19
+ ],
20
+ "sideEffects": false,
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "dependencies": {
25
+ "@rulvar/core": "1.59.0"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^22.20.0",
29
+ "tsdown": "^0.22.3",
30
+ "typescript": "~6.0.3",
31
+ "@rulvar/testing": "1.59.0"
32
+ },
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/o-stepper/rulvar.git",
36
+ "directory": "packages/executor"
37
+ },
38
+ "homepage": "https://rulvar.com",
39
+ "bugs": {
40
+ "url": "https://github.com/o-stepper/rulvar/issues"
41
+ },
42
+ "scripts": {
43
+ "build": "tsdown",
44
+ "typecheck": "tsc --noEmit",
45
+ "lint": "eslint .",
46
+ "pack-check": "publint --pack pnpm && attw --pack . --profile esm-only"
47
+ }
48
+ }