@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 +202 -0
- package/dist/index.d.ts +283 -0
- package/dist/index.js +810 -0
- package/package.json +48 -0
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.
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|