@skanl/brambo-adapter-cli 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +183 -0
- package/dist/catalogue.d.ts +57 -0
- package/dist/catalogue.js +62 -0
- package/dist/executors/claude-code.d.ts +3 -0
- package/dist/executors/claude-code.js +125 -0
- package/dist/executors/codex.d.ts +3 -0
- package/dist/executors/codex.js +89 -0
- package/dist/executors/opencode.d.ts +3 -0
- package/dist/executors/opencode.js +74 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +7 -0
- package/dist/node-child-spawner.d.ts +13 -0
- package/dist/node-child-spawner.js +287 -0
- package/dist/plugin.d.ts +88 -0
- package/dist/plugin.js +181 -0
- package/dist/spawn-seam.d.ts +38 -0
- package/dist/spawn-seam.js +5 -0
- package/dist/traits.d.ts +179 -0
- package/dist/traits.js +589 -0
- package/package.json +55 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { createCliExecutorAdapter } from './traits.js';
|
|
2
|
+
export { CLAUDE_CODE_TRAITS, createClaudeCodeAdapter } from './executors/claude-code.js';
|
|
3
|
+
export { CODEX_TRAITS, createCodexAdapter } from './executors/codex.js';
|
|
4
|
+
export { OPENCODE_TRAITS, createOpenCodeAdapter } from './executors/opencode.js';
|
|
5
|
+
export { createNodeChildSpawner, routesThroughCmdShim } from './node-child-spawner.js';
|
|
6
|
+
export { DEFAULT_EXECUTOR_ID, EXECUTOR_CATALOGUE, availableExecutorIds, createExecutorAdapter, unknownExecutor, } from './catalogue.js';
|
|
7
|
+
export { DEFAULT_EXECUTOR_ACTION_COST, EXECUTOR_CONFIG_KEY, EXECUTOR_PLUGIN_ID, EXECUTOR_SERVICE, createExecutorPlugin, } from './plugin.js';
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ChildProcessSpawner } from './spawn-seam.ts';
|
|
2
|
+
/**
|
|
3
|
+
* True when this command can only be started by rerouting through `cmd.exe`.
|
|
4
|
+
*
|
|
5
|
+
* win32 cannot exec .cmd/.bat shims directly (Node refuses them since the
|
|
6
|
+
* shell-injection hardening, raising EINVAL), so the spawner reroutes them.
|
|
7
|
+
* That reroute hands the argv to a SHELL, which interprets `&`, `|`, `>`, `^`,
|
|
8
|
+
* `%VAR%` and newlines — Node's CRT-style quoting does not neutralise any of
|
|
9
|
+
* them. Callers that would put untrusted text in argv must consult this and
|
|
10
|
+
* refuse rather than let the shell see it.
|
|
11
|
+
*/
|
|
12
|
+
export declare function routesThroughCmdShim(command: string): boolean;
|
|
13
|
+
export declare function createNodeChildSpawner(): ChildProcessSpawner;
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
// stdout/stderr capture cap per stream; beyond it we truncate and flag, so a
|
|
4
|
+
// chatty executor can never grow the parent's memory without bound. JSONL
|
|
5
|
+
// executors emit an event per token, so this cap is reached in practice, not
|
|
6
|
+
// only in theory — the truncation flag is what stops a chopped stream from
|
|
7
|
+
// being read as a complete answer.
|
|
8
|
+
const STREAM_CAPTURE_CAP_BYTES = 1024 * 1024;
|
|
9
|
+
// Real spawner backed by node:child_process.
|
|
10
|
+
//
|
|
11
|
+
// Tree-kill semantics per platform:
|
|
12
|
+
// - win32: `taskkill /pid <pid> /T /F` walks and force-terminates the whole tree;
|
|
13
|
+
// Node signals cannot reach grandchildren on Windows, so taskkill is the only
|
|
14
|
+
// reliable option for spawned .exe/.cmd trees. If taskkill itself fails, the
|
|
15
|
+
// child gets a direct kill fallback so `done` always settles.
|
|
16
|
+
// - posix: the child is spawned detached into its own process group; killing
|
|
17
|
+
// -pid takes down every descendant in one signal.
|
|
18
|
+
function killTreeOf(child) {
|
|
19
|
+
const pid = child.pid;
|
|
20
|
+
if (pid === undefined)
|
|
21
|
+
return;
|
|
22
|
+
if (process.platform === 'win32') {
|
|
23
|
+
execFile('taskkill', ['/pid', String(pid), '/T', '/F'], (error) => {
|
|
24
|
+
if (error) {
|
|
25
|
+
try {
|
|
26
|
+
child.kill('SIGKILL');
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// Already exited.
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
process.kill(-pid, 'SIGKILL');
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// The group may already be gone; fall through to the direct kill below.
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
child.kill('SIGKILL');
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// Already exited.
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* True when this command can only be started by rerouting through `cmd.exe`.
|
|
50
|
+
*
|
|
51
|
+
* win32 cannot exec .cmd/.bat shims directly (Node refuses them since the
|
|
52
|
+
* shell-injection hardening, raising EINVAL), so the spawner reroutes them.
|
|
53
|
+
* That reroute hands the argv to a SHELL, which interprets `&`, `|`, `>`, `^`,
|
|
54
|
+
* `%VAR%` and newlines — Node's CRT-style quoting does not neutralise any of
|
|
55
|
+
* them. Callers that would put untrusted text in argv must consult this and
|
|
56
|
+
* refuse rather than let the shell see it.
|
|
57
|
+
*/
|
|
58
|
+
export function routesThroughCmdShim(command) {
|
|
59
|
+
return process.platform === 'win32' && /\.(cmd|bat)$/i.test(command);
|
|
60
|
+
}
|
|
61
|
+
/*
|
|
62
|
+
* The environment a child receives (see `nodeOptions.env` below): brambo's own,
|
|
63
|
+
* with the ONE variable that claims to name the working directory corrected to
|
|
64
|
+
* the directory the child is actually given.
|
|
65
|
+
*
|
|
66
|
+
* `PWD` is not decoration. A tool that resolves relative paths against
|
|
67
|
+
* `process.env.PWD` instead of `process.cwd()` writes wherever `PWD` points,
|
|
68
|
+
* and an inherited `PWD` points at the directory BRAMBO was launched from —
|
|
69
|
+
* which is how a workspace stops being a boundary. Measured for M4.A against
|
|
70
|
+
* the real binaries with `PWD` aimed at a decoy directory outside the child's
|
|
71
|
+
* cwd: `opencode` created its file in the decoy, twice; `claude` used its cwd
|
|
72
|
+
* and ignored it.
|
|
73
|
+
*
|
|
74
|
+
* Corrected rather than deleted, because a shell-hosted tool may legitimately
|
|
75
|
+
* read `$PWD` and would break on its absence; a `PWD` that agrees with `cwd`
|
|
76
|
+
* cannot mislead anything. Every other inherited variable is left alone. Of the
|
|
77
|
+
* 95 a child receives on the machine this was measured on, 39 hold a single
|
|
78
|
+
* absolute path — `HOME`, `USERPROFILE`, `APPDATA`, `TEMP`, `INIT_CWD`,
|
|
79
|
+
* `OLDPWD` among them — and exactly one of them CLAIMS to name the child's
|
|
80
|
+
* working directory. Only that one is brambo's to correct. `INIT_CWD`, the
|
|
81
|
+
* ledger's named suspect, was RULED OUT by the same measurement: it pointed at
|
|
82
|
+
* a second, different decoy that stayed empty through every run, and deleting a
|
|
83
|
+
* variable measured not to matter would be the silent scrub this story exists
|
|
84
|
+
* to avoid.
|
|
85
|
+
*
|
|
86
|
+
* What this does NOT do: it does not confine anything. An executor that asks
|
|
87
|
+
* for an absolute path outside the workspace gets it — measured, with codex —
|
|
88
|
+
* and `HOME` still points at the real one, so per-user executor state (opencode
|
|
89
|
+
* keeps ONE SQLite database there) is shared by every concurrent session. brambo
|
|
90
|
+
* makes the workspace TRUE for a workspace-relative write; it is not a sandbox.
|
|
91
|
+
*/
|
|
92
|
+
export function createNodeChildSpawner() {
|
|
93
|
+
return {
|
|
94
|
+
spawn(command, args, options) {
|
|
95
|
+
// Resolved once, so a RELATIVE cwd cannot become two different absolute
|
|
96
|
+
// paths: node resolves `cwd` against the parent's directory, and a relative
|
|
97
|
+
// `PWD` would be re-resolved against the CHILD's. `resolve` normalises but
|
|
98
|
+
// does not canonicalise, so `PWD` is the LOGICAL path — through a symlinked
|
|
99
|
+
// root it names the link where `process.cwd()` names the target, which is
|
|
100
|
+
// the ordinary shell convention for `$PWD` and not an escape.
|
|
101
|
+
const cwd = resolve(options.cwd);
|
|
102
|
+
const nodeOptions = {
|
|
103
|
+
cwd,
|
|
104
|
+
env: { ...process.env, PWD: cwd },
|
|
105
|
+
windowsHide: true,
|
|
106
|
+
detached: process.platform !== 'win32',
|
|
107
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
108
|
+
};
|
|
109
|
+
// Resolving twice is a no-op: whichever settle path lands first wins.
|
|
110
|
+
let resolveDone;
|
|
111
|
+
const done = new Promise((resolveOutcome) => {
|
|
112
|
+
resolveDone = resolveOutcome;
|
|
113
|
+
});
|
|
114
|
+
let settled = false;
|
|
115
|
+
const settle = (outcome) => {
|
|
116
|
+
if (settled)
|
|
117
|
+
return;
|
|
118
|
+
settled = true;
|
|
119
|
+
resolveDone(outcome);
|
|
120
|
+
};
|
|
121
|
+
let stdoutTruncated = false;
|
|
122
|
+
let stderrTruncated = false;
|
|
123
|
+
function createCapture(markTruncated) {
|
|
124
|
+
const targetChunks = [];
|
|
125
|
+
let bytes = 0;
|
|
126
|
+
return {
|
|
127
|
+
push(chunk) {
|
|
128
|
+
// Once at the cap nothing more is retained — and discarding is
|
|
129
|
+
// itself truncation, including when an earlier chunk filled the cap
|
|
130
|
+
// exactly. Failing to advance `bytes` here would let every later
|
|
131
|
+
// chunk append another cap-sized slice, i.e. no cap at all.
|
|
132
|
+
if (bytes >= STREAM_CAPTURE_CAP_BYTES) {
|
|
133
|
+
markTruncated();
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const remaining = STREAM_CAPTURE_CAP_BYTES - bytes;
|
|
137
|
+
if (chunk.length > remaining) {
|
|
138
|
+
targetChunks.push(chunk.subarray(0, remaining));
|
|
139
|
+
bytes = STREAM_CAPTURE_CAP_BYTES;
|
|
140
|
+
markTruncated();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
bytes += chunk.length;
|
|
144
|
+
targetChunks.push(chunk);
|
|
145
|
+
},
|
|
146
|
+
text() {
|
|
147
|
+
return Buffer.concat(targetChunks).toString('utf8');
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
const stdout = createCapture(() => {
|
|
152
|
+
stdoutTruncated = true;
|
|
153
|
+
});
|
|
154
|
+
const stderr = createCapture(() => {
|
|
155
|
+
stderrTruncated = true;
|
|
156
|
+
});
|
|
157
|
+
// No-op error listeners keep async pipe failures (EPIPE etc.) from crashing
|
|
158
|
+
// the parent as unhandled 'error' events; stdin breakage is recorded so the
|
|
159
|
+
// adapter can classify the run as failed instead of trusting the outcome.
|
|
160
|
+
let streamErrorMessage;
|
|
161
|
+
// Everything written to stdin is BUFFERED, because the cmd.exe reroute
|
|
162
|
+
// below arrives asynchronously — by then the caller has usually already
|
|
163
|
+
// written the prompt and closed stdin on the first child. Without replay
|
|
164
|
+
// the rerouted child waits on stdin forever and the run hangs.
|
|
165
|
+
const stdinChunks = [];
|
|
166
|
+
let stdinEnded = false;
|
|
167
|
+
let flushedChunks = 0;
|
|
168
|
+
let flushedEnd = false;
|
|
169
|
+
let currentSource;
|
|
170
|
+
let routedThroughCmd = routesThroughCmdShim(command);
|
|
171
|
+
const cmdArgs = () => ['/d', '/s', '/c', command, ...args];
|
|
172
|
+
function flushStdin() {
|
|
173
|
+
const source = currentSource;
|
|
174
|
+
if (source === undefined || settled || streamErrorMessage !== undefined)
|
|
175
|
+
return;
|
|
176
|
+
try {
|
|
177
|
+
while (flushedChunks < stdinChunks.length) {
|
|
178
|
+
source.stdin?.write(stdinChunks[flushedChunks]);
|
|
179
|
+
flushedChunks++;
|
|
180
|
+
}
|
|
181
|
+
if (stdinEnded && !flushedEnd) {
|
|
182
|
+
source.stdin?.end();
|
|
183
|
+
flushedEnd = true;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
streamErrorMessage = error instanceof Error ? error.message : String(error);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const settleFatal = (message) => {
|
|
191
|
+
settle({ exitCode: null, stdout: '', stderr: '', spawnErrorMessage: message });
|
|
192
|
+
};
|
|
193
|
+
function attach(source) {
|
|
194
|
+
source.stdout?.on('data', (chunk) => stdout.push(chunk));
|
|
195
|
+
source.stderr?.on('data', (chunk) => stderr.push(chunk));
|
|
196
|
+
source.stdin?.on('error', (error) => {
|
|
197
|
+
streamErrorMessage ??= error instanceof Error ? error.message : String(error);
|
|
198
|
+
});
|
|
199
|
+
source.stdout?.on('error', () => { });
|
|
200
|
+
source.stderr?.on('error', () => { });
|
|
201
|
+
source.on('error', (error) => {
|
|
202
|
+
// Node raises EINVAL asynchronously for shell-restricted commands;
|
|
203
|
+
// reroute once through cmd.exe while keeping tree-kill semantics.
|
|
204
|
+
const code = error.code;
|
|
205
|
+
if (code === 'EINVAL' && process.platform === 'win32' && !routedThroughCmd) {
|
|
206
|
+
routedThroughCmd = true;
|
|
207
|
+
launch('cmd.exe', cmdArgs());
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
// Spawn failures (ENOENT etc.) may never be followed by 'close', so the
|
|
211
|
+
// error settles the child on its own.
|
|
212
|
+
settle({
|
|
213
|
+
exitCode: null,
|
|
214
|
+
stdout: '',
|
|
215
|
+
stderr: '',
|
|
216
|
+
spawnErrorMessage: error instanceof Error ? error.message : String(error),
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
source.on('close', (exitCode) => {
|
|
220
|
+
settle({
|
|
221
|
+
exitCode,
|
|
222
|
+
stdout: stdout.text(),
|
|
223
|
+
stderr: stderr.text(),
|
|
224
|
+
...(streamErrorMessage !== undefined ? { streamErrorMessage } : {}),
|
|
225
|
+
...(stdoutTruncated ? { stdoutTruncated: true } : {}),
|
|
226
|
+
...(stderrTruncated ? { stderrTruncated: true } : {}),
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
// A rerouted child has received nothing yet: replay from the start.
|
|
230
|
+
currentSource = source;
|
|
231
|
+
flushedChunks = 0;
|
|
232
|
+
flushedEnd = false;
|
|
233
|
+
flushStdin();
|
|
234
|
+
}
|
|
235
|
+
function launch(executable, launchArgs) {
|
|
236
|
+
try {
|
|
237
|
+
attach(spawn(executable, [...launchArgs], nodeOptions));
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
settleFatal(error instanceof Error ? error.message : String(error));
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (routedThroughCmd)
|
|
246
|
+
launch('cmd.exe', cmdArgs());
|
|
247
|
+
else if (!launch(command, args))
|
|
248
|
+
return failedChild(done);
|
|
249
|
+
return {
|
|
250
|
+
get pid() {
|
|
251
|
+
return currentSource?.pid;
|
|
252
|
+
},
|
|
253
|
+
get settled() {
|
|
254
|
+
return settled;
|
|
255
|
+
},
|
|
256
|
+
writeStdin(chunk) {
|
|
257
|
+
if (stdinEnded)
|
|
258
|
+
return;
|
|
259
|
+
stdinChunks.push(chunk);
|
|
260
|
+
flushStdin();
|
|
261
|
+
},
|
|
262
|
+
endStdin() {
|
|
263
|
+
stdinEnded = true;
|
|
264
|
+
flushStdin();
|
|
265
|
+
},
|
|
266
|
+
killTree() {
|
|
267
|
+
// Signalling after the child settled could hit a RECYCLED pid, which
|
|
268
|
+
// on win32 means taskkill /T /F on somebody else's process tree.
|
|
269
|
+
if (settled || currentSource === undefined)
|
|
270
|
+
return;
|
|
271
|
+
killTreeOf(currentSource);
|
|
272
|
+
},
|
|
273
|
+
done,
|
|
274
|
+
};
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
function failedChild(done) {
|
|
279
|
+
return {
|
|
280
|
+
pid: undefined,
|
|
281
|
+
settled: true,
|
|
282
|
+
writeStdin() { },
|
|
283
|
+
endStdin() { },
|
|
284
|
+
killTree() { },
|
|
285
|
+
done,
|
|
286
|
+
};
|
|
287
|
+
}
|
package/dist/plugin.d.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { ExecutorAdapter, ResultEnvelope, RunRequest } from '@skanl/brambo-contracts';
|
|
2
|
+
import type { PluginFactory, PluginManifest } from '@skanl/brambo-kernel';
|
|
3
|
+
import { type CliExecutorAdapterOptions } from './traits.ts';
|
|
4
|
+
/** The service name this plugin provides. */
|
|
5
|
+
export declare const EXECUTOR_SERVICE = "executor";
|
|
6
|
+
/** The plugin id this plugin registers under. */
|
|
7
|
+
export declare const EXECUTOR_PLUGIN_ID = "executor";
|
|
8
|
+
/** The key this plugin reads out of the kernel's composed configuration. */
|
|
9
|
+
export declare const EXECUTOR_CONFIG_KEY = "executor";
|
|
10
|
+
/**
|
|
11
|
+
* What one executor run is ADMITTED at when nothing configures otherwise, before
|
|
12
|
+
* the vendor says what it actually spent.
|
|
13
|
+
*
|
|
14
|
+
* ponytail: still a flat 1. Brambo may not invent a token figure — estimating or
|
|
15
|
+
* tokenizing is the exact thing this story removes — so the only honest pre-run
|
|
16
|
+
* number is a placeholder in whatever unit the caller's caps are denominated in.
|
|
17
|
+
* A host budgeting tokens passes its own `cost`; the settlement then replaces it
|
|
18
|
+
* with the vendor's own figure either way. Upgrade path: a per-executor estimate,
|
|
19
|
+
* which is per-model weighting and Ask-First (deferred-work.md).
|
|
20
|
+
*/
|
|
21
|
+
export declare const DEFAULT_EXECUTOR_ACTION_COST = 1;
|
|
22
|
+
/**
|
|
23
|
+
* What `kernel.getService('executor')` hands back.
|
|
24
|
+
*
|
|
25
|
+
* Deliberately NOT an `ExecutorAdapter`. The honest limit is stated on
|
|
26
|
+
* `createExecutorPlugin` and in `deferred-work.md`: this closes the CONTAINER's
|
|
27
|
+
* surface, not the process — any package may still import a vendor factory from
|
|
28
|
+
* this one and drive an adapter itself.
|
|
29
|
+
*/
|
|
30
|
+
export interface ExecutorService {
|
|
31
|
+
/** Which shipped executor this is driving; a host-supplied adapter reports `'(injected)'`. */
|
|
32
|
+
readonly executorId: string;
|
|
33
|
+
/**
|
|
34
|
+
* Runs one request through the KERNEL's interception waterfall.
|
|
35
|
+
*
|
|
36
|
+
* `actionId` names this invocation in the record stream, and the caller owns
|
|
37
|
+
* it because the caller owns run identity — the session scopes it to the
|
|
38
|
+
* workspace so two sessions on one pipeline stay distinguishable. The COST is
|
|
39
|
+
* the plugin's, never the caller's: a caller that could price its own run
|
|
40
|
+
* could price it at zero and walk through a cost cap. So is the SETTLEMENT —
|
|
41
|
+
* the plugin observed the vendor, the caller did not, and a caller that could
|
|
42
|
+
* reconcile its own run to zero has defeated the budget just as thoroughly.
|
|
43
|
+
*/
|
|
44
|
+
run(actionId: string, request: RunRequest): Promise<ResultEnvelope>;
|
|
45
|
+
}
|
|
46
|
+
export interface ExecutorPluginOptions {
|
|
47
|
+
/**
|
|
48
|
+
* Adapter seam; a host that built its own executor passes it here and the
|
|
49
|
+
* catalogue is never consulted. It still runs through the waterfall — the
|
|
50
|
+
* seam replaces WHICH executor runs, never WHETHER the pipeline sees it.
|
|
51
|
+
*/
|
|
52
|
+
readonly createAdapter?: () => ExecutorAdapter;
|
|
53
|
+
/**
|
|
54
|
+
* Options handed to the SELECTED adapter: a child-process spawner, or a binary
|
|
55
|
+
* path that overrides the trait's command. Ignored when `createAdapter` is
|
|
56
|
+
* supplied, because then the caller built the adapter itself.
|
|
57
|
+
*/
|
|
58
|
+
readonly adapterOptions?: CliExecutorAdapterOptions;
|
|
59
|
+
/** What one run costs this kernel's budget. Defaults to `DEFAULT_EXECUTOR_ACTION_COST`. */
|
|
60
|
+
readonly cost?: number;
|
|
61
|
+
}
|
|
62
|
+
export interface ExecutorPlugin {
|
|
63
|
+
readonly manifest: PluginManifest;
|
|
64
|
+
readonly factory: PluginFactory;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The executor adapter as a kernel plugin: a manifest providing the `executor`
|
|
68
|
+
* service, a factory that resolves WHICH adapter from the kernel's own composed
|
|
69
|
+
* configuration, and a disposer that drops it.
|
|
70
|
+
*
|
|
71
|
+
* Misconfiguration REJECTS activation (a contained start failure naming this
|
|
72
|
+
* plugin), never a mid-run surprise — the same rule `@skanl/brambo-registry`'s plugin
|
|
73
|
+
* follows, and the reason a bad `executor` key cannot take the kernel down.
|
|
74
|
+
*
|
|
75
|
+
* The honest scope of the no-bypass claim, corrected on review: this SERVICE
|
|
76
|
+
* exports no path around the waterfall, and the object it hands back is frozen
|
|
77
|
+
* so no other caller can be taken off it either. Three routes remain open and
|
|
78
|
+
* are named in `deferred-work.md` rather than narrated away:
|
|
79
|
+
* - anyone who installs THIS package can call `createExecutorAdapter(id)` or
|
|
80
|
+
* any of the three vendor factories and drive an adapter directly;
|
|
81
|
+
* - this `factory` is a `PluginFactory`, so a holder can invoke it with an
|
|
82
|
+
* `ActivationContext` of their own and get a real adapter wired to their own
|
|
83
|
+
* pipeline — inherent to the plugin shape, and the reason `@skanl/brambo-session`
|
|
84
|
+
* does not re-export it;
|
|
85
|
+
* - `kernel.swap` runs a caller-supplied factory against the live registry, so
|
|
86
|
+
* a kernel holder can replace this service outright.
|
|
87
|
+
*/
|
|
88
|
+
export declare function createExecutorPlugin(options?: ExecutorPluginOptions): ExecutorPlugin;
|
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { BRAMBO_ERROR_CODES, BramboError, defineStandardSchema } from '@skanl/brambo-contracts';
|
|
2
|
+
import { isNonEmptyString, isRecord, issue } from '@skanl/brambo-contracts/validation';
|
|
3
|
+
import { DEFAULT_EXECUTOR_ID, EXECUTOR_CATALOGUE, availableExecutorIds, createExecutorAdapter, unknownExecutor, } from './catalogue.js';
|
|
4
|
+
import { USAGE_DATA_KEY } from './traits.js';
|
|
5
|
+
// The executor adapter, mounted as a kernel plugin (Story M3.B).
|
|
6
|
+
//
|
|
7
|
+
// It is the interesting one of the two this story adds, because of what its
|
|
8
|
+
// SERVICE is. A service that handed back an `ExecutorAdapter` would put `.run()`
|
|
9
|
+
// on the surface of the container, and every consumer of the kernel could then
|
|
10
|
+
// spawn an executor with no budget, no guard and no record — which is exactly
|
|
11
|
+
// the hole AD-10 exists to close. So the service is a RUNNER: the adapter is
|
|
12
|
+
// closed over and never handed out, and the only exported way to reach it
|
|
13
|
+
// registers the invocation on the kernel's own pipeline and invokes it there.
|
|
14
|
+
//
|
|
15
|
+
// Configuration comes from the plugin's own key of the kernel's layered config —
|
|
16
|
+
// `executor`, the same key brambo's `.brambo/config.json` already spells, so one
|
|
17
|
+
// composed document decides both what `brambo run` reports and what this mounts.
|
|
18
|
+
/** The service name this plugin provides. */
|
|
19
|
+
export const EXECUTOR_SERVICE = 'executor';
|
|
20
|
+
/** The plugin id this plugin registers under. */
|
|
21
|
+
export const EXECUTOR_PLUGIN_ID = 'executor';
|
|
22
|
+
/** The key this plugin reads out of the kernel's composed configuration. */
|
|
23
|
+
export const EXECUTOR_CONFIG_KEY = 'executor';
|
|
24
|
+
/**
|
|
25
|
+
* What one executor run is ADMITTED at when nothing configures otherwise, before
|
|
26
|
+
* the vendor says what it actually spent.
|
|
27
|
+
*
|
|
28
|
+
* ponytail: still a flat 1. Brambo may not invent a token figure — estimating or
|
|
29
|
+
* tokenizing is the exact thing this story removes — so the only honest pre-run
|
|
30
|
+
* number is a placeholder in whatever unit the caller's caps are denominated in.
|
|
31
|
+
* A host budgeting tokens passes its own `cost`; the settlement then replaces it
|
|
32
|
+
* with the vendor's own figure either way. Upgrade path: a per-executor estimate,
|
|
33
|
+
* which is per-model weighting and Ask-First (deferred-work.md).
|
|
34
|
+
*/
|
|
35
|
+
export const DEFAULT_EXECUTOR_ACTION_COST = 1;
|
|
36
|
+
/**
|
|
37
|
+
* The vendor's own usage figure, as the adapter put it on the envelope.
|
|
38
|
+
*
|
|
39
|
+
* Forwarded whatever it is, never sanitised here: an absent key means "nothing
|
|
40
|
+
* observed this run" and charges the estimate, while a PRESENT but broken value
|
|
41
|
+
* is a coded rejection the pipeline owns and records. Quietly turning the second
|
|
42
|
+
* into the first would hide a lying adapter behind a normal-looking run.
|
|
43
|
+
*/
|
|
44
|
+
function reportedUsage(envelope) {
|
|
45
|
+
const data = envelope.data;
|
|
46
|
+
if (!isRecord(data) || !Object.hasOwn(data, USAGE_DATA_KEY))
|
|
47
|
+
return undefined;
|
|
48
|
+
return data[USAGE_DATA_KEY];
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The plugin's own subtree of the kernel's layered configuration.
|
|
52
|
+
*
|
|
53
|
+
* That subtree is a STRING, not an object, and that is the point: brambo's
|
|
54
|
+
* `.brambo/config.json` already spells its executor selection as
|
|
55
|
+
* `{"executor": "codex"}`, so the plugin reads the document the user already
|
|
56
|
+
* writes rather than a parallel one invented for the container.
|
|
57
|
+
*/
|
|
58
|
+
const EXECUTOR_CONFIG_SCHEMA = defineStandardSchema((value) => {
|
|
59
|
+
if (value === undefined)
|
|
60
|
+
return { value: undefined };
|
|
61
|
+
if (!isNonEmptyString(value)) {
|
|
62
|
+
return {
|
|
63
|
+
issues: [
|
|
64
|
+
issue(`'${EXECUTOR_CONFIG_KEY}' must be a string naming one of: ${availableExecutorIds().join(', ')}`),
|
|
65
|
+
],
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const executorId = value.trim();
|
|
69
|
+
if (!EXECUTOR_CATALOGUE.has(executorId)) {
|
|
70
|
+
return { issues: [issue(unknownExecutor(executorId).message)] };
|
|
71
|
+
}
|
|
72
|
+
return { value: executorId };
|
|
73
|
+
});
|
|
74
|
+
/**
|
|
75
|
+
* The executor adapter as a kernel plugin: a manifest providing the `executor`
|
|
76
|
+
* service, a factory that resolves WHICH adapter from the kernel's own composed
|
|
77
|
+
* configuration, and a disposer that drops it.
|
|
78
|
+
*
|
|
79
|
+
* Misconfiguration REJECTS activation (a contained start failure naming this
|
|
80
|
+
* plugin), never a mid-run surprise — the same rule `@skanl/brambo-registry`'s plugin
|
|
81
|
+
* follows, and the reason a bad `executor` key cannot take the kernel down.
|
|
82
|
+
*
|
|
83
|
+
* The honest scope of the no-bypass claim, corrected on review: this SERVICE
|
|
84
|
+
* exports no path around the waterfall, and the object it hands back is frozen
|
|
85
|
+
* so no other caller can be taken off it either. Three routes remain open and
|
|
86
|
+
* are named in `deferred-work.md` rather than narrated away:
|
|
87
|
+
* - anyone who installs THIS package can call `createExecutorAdapter(id)` or
|
|
88
|
+
* any of the three vendor factories and drive an adapter directly;
|
|
89
|
+
* - this `factory` is a `PluginFactory`, so a holder can invoke it with an
|
|
90
|
+
* `ActivationContext` of their own and get a real adapter wired to their own
|
|
91
|
+
* pipeline — inherent to the plugin shape, and the reason `@skanl/brambo-session`
|
|
92
|
+
* does not re-export it;
|
|
93
|
+
* - `kernel.swap` runs a caller-supplied factory against the live registry, so
|
|
94
|
+
* a kernel holder can replace this service outright.
|
|
95
|
+
*/
|
|
96
|
+
export function createExecutorPlugin(options = {}) {
|
|
97
|
+
// ONE read of every caller-supplied field, at CONSTRUCTION. `createActionPipeline`
|
|
98
|
+
// states the discipline verbatim — "a budget a caller can raise after
|
|
99
|
+
// construction by mutating the object it handed in is not a budget" — and
|
|
100
|
+
// reading `cost` inside the factory reopened exactly that: mutating the
|
|
101
|
+
// options object between here and `kernel.start()` priced a run at 0 under a
|
|
102
|
+
// 0.5 cap. Measured, then closed.
|
|
103
|
+
const { createAdapter, adapterOptions, cost = DEFAULT_EXECUTOR_ACTION_COST } = options;
|
|
104
|
+
const manifest = {
|
|
105
|
+
id: EXECUTOR_PLUGIN_ID,
|
|
106
|
+
version: '0.0.0',
|
|
107
|
+
provides: [EXECUTOR_SERVICE],
|
|
108
|
+
consumes: [],
|
|
109
|
+
configSchema: EXECUTOR_CONFIG_SCHEMA,
|
|
110
|
+
};
|
|
111
|
+
const factory = (context) => {
|
|
112
|
+
// Since M7.C the KERNEL resolves `config.resolve()[manifest.id]`, validates
|
|
113
|
+
// it against this plugin's own `configSchema`, and refuses the plugin before
|
|
114
|
+
// this body runs — so the read, the subtree pick, the validate call, the
|
|
115
|
+
// promise check and the issue mapping that used to live here are gone. What
|
|
116
|
+
// arrives is the schema's own `value`, already checked.
|
|
117
|
+
//
|
|
118
|
+
// The plugin id IS the config key (`executor` both), which is the rule the
|
|
119
|
+
// kernel now states rather than the convention three plugins each spelled out.
|
|
120
|
+
const selected = context.settings;
|
|
121
|
+
const adapter = createAdapter === undefined ? createExecutorAdapter(selected, adapterOptions) : createAdapter();
|
|
122
|
+
const executorId = createAdapter === undefined ? selected ?? DEFAULT_EXECUTOR_ID : '(injected)';
|
|
123
|
+
let disposed = false;
|
|
124
|
+
// FROZEN, and not merely `readonly`: `getService` hands every caller the
|
|
125
|
+
// same object, so an un-frozen one let a kernel holder who never had the
|
|
126
|
+
// adapter overwrite `run` and take every OTHER caller off the waterfall —
|
|
127
|
+
// measured at three runs past a cap of 1 with an empty record stream. The
|
|
128
|
+
// pipeline freezes its handle and its descriptor for the same reason.
|
|
129
|
+
const service = Object.freeze({
|
|
130
|
+
executorId,
|
|
131
|
+
async run(actionId, request) {
|
|
132
|
+
if (disposed) {
|
|
133
|
+
// Coded, and it names the plugin: a handle kept past `kernel.stop()`
|
|
134
|
+
// reaching a live adapter is the disposal leak this pairing exists to
|
|
135
|
+
// prevent, and `undefined` is what it would otherwise look like.
|
|
136
|
+
throw new BramboError(BRAMBO_ERROR_CODES.kernelPluginInactive, `plugin '${EXECUTOR_PLUGIN_ID}' is inactive: the '${EXECUTOR_SERVICE}' service was disposed with its plugin`);
|
|
137
|
+
}
|
|
138
|
+
// Registered per invocation, because `ActionDefinition.run` takes no
|
|
139
|
+
// arguments: the pipeline reads the operation ONCE at registration and
|
|
140
|
+
// holds it, which is what stops a caller swapping the operation after
|
|
141
|
+
// the price was agreed. A per-request handle is the only shape that
|
|
142
|
+
// survives that rule.
|
|
143
|
+
//
|
|
144
|
+
// ponytail: a pipeline remembers every id it ever registered, so a
|
|
145
|
+
// long-lived kernel running many sessions grows that set. Workspace ids
|
|
146
|
+
// are UUIDs, so collisions are not the concern — retention is. Upgrade
|
|
147
|
+
// path: a pipeline that can retire a handle, which is the same mechanism
|
|
148
|
+
// a post-hoc cost adjustment needs (deferred-work.md).
|
|
149
|
+
const handle = context.actions.register({
|
|
150
|
+
id: actionId,
|
|
151
|
+
cost,
|
|
152
|
+
run: () => adapter.run(request),
|
|
153
|
+
// Admitted at the estimate, reconciled to what the vendor reported.
|
|
154
|
+
// Declared HERE, beside `cost` and `run`, because the pipeline reads
|
|
155
|
+
// all three once at registration and the caller supplies none of them.
|
|
156
|
+
//
|
|
157
|
+
// A failed or cancelled run still resolves an envelope, and the adapter
|
|
158
|
+
// now reads the vendor's figure off EVERY outcome — a killed child
|
|
159
|
+
// settles carrying what it had already printed — so whatever it spent
|
|
160
|
+
// before it gave up is charged. A run that produced no figure keeps its
|
|
161
|
+
// estimate, and the pipeline floors a settlement at that estimate, so
|
|
162
|
+
// there is no path on which failing, cancelling or under-reporting is
|
|
163
|
+
// cheaper than reporting honestly.
|
|
164
|
+
settle: reportedUsage,
|
|
165
|
+
});
|
|
166
|
+
return await handle.invoke();
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
return {
|
|
170
|
+
status: 'activated',
|
|
171
|
+
services: { [EXECUTOR_SERVICE]: service },
|
|
172
|
+
dispose: () => {
|
|
173
|
+
// The adapter itself owns no resource between runs (each run spawns and
|
|
174
|
+
// reaps its own child), so disposal is about the SERVICE: a handle kept
|
|
175
|
+
// past `kernel.stop()` must not still be able to spawn.
|
|
176
|
+
disposed = true;
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
};
|
|
180
|
+
return { manifest, factory };
|
|
181
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export interface SpawnOutcome {
|
|
2
|
+
/** null when the process was killed by a signal or never spawned. */
|
|
3
|
+
readonly exitCode: number | null;
|
|
4
|
+
readonly stdout: string;
|
|
5
|
+
readonly stderr: string;
|
|
6
|
+
/** Set only when the process could not be spawned at all (e.g. missing binary). */
|
|
7
|
+
readonly spawnErrorMessage?: string;
|
|
8
|
+
/** Set when a pipe to/from the child failed mid-run (e.g. EPIPE on stdin write). */
|
|
9
|
+
readonly streamErrorMessage?: string;
|
|
10
|
+
readonly stdoutTruncated?: boolean;
|
|
11
|
+
readonly stderrTruncated?: boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface SpawnedChild {
|
|
14
|
+
/** undefined when the platform refused to start the process. */
|
|
15
|
+
readonly pid: number | undefined;
|
|
16
|
+
/**
|
|
17
|
+
* Synchronously true once `done` has been resolved. Callers need this to
|
|
18
|
+
* decide SYNCHRONOUSLY whether a run already finished: `done.then(...)` only
|
|
19
|
+
* runs a microtask later, and an abort dispatched inside that window would
|
|
20
|
+
* otherwise discard a completed run as cancelled.
|
|
21
|
+
*/
|
|
22
|
+
readonly settled: boolean;
|
|
23
|
+
writeStdin(chunk: string): void;
|
|
24
|
+
endStdin(): void;
|
|
25
|
+
/**
|
|
26
|
+
* Terminate the child AND every descendant it spawned. Idempotent; safe to
|
|
27
|
+
* call even when the process already exited.
|
|
28
|
+
*/
|
|
29
|
+
killTree(): void;
|
|
30
|
+
/** Resolves exactly once, after the whole tree has settled. Never rejects. */
|
|
31
|
+
readonly done: Promise<SpawnOutcome>;
|
|
32
|
+
}
|
|
33
|
+
export interface SpawnOptions {
|
|
34
|
+
readonly cwd: string;
|
|
35
|
+
}
|
|
36
|
+
export interface ChildProcessSpawner {
|
|
37
|
+
spawn(command: string, args: readonly string[], options: SpawnOptions): SpawnedChild;
|
|
38
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// The child-process seam. The adapter talks to executors exclusively through this
|
|
2
|
+
// interface, so unit tests and the contract suite run against fake children while
|
|
3
|
+
// the real Node spawner (with Windows-safe process-tree termination) is exercised
|
|
4
|
+
// by the overhead measurement and the env-gated live smoke.
|
|
5
|
+
export {};
|