@better-fs-tools/shell 0.0.0-tegami-trusted-publish-setup → 0.1.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 +21 -0
- package/README.md +269 -6
- package/dist/contract/context.d.ts +10 -0
- package/dist/contract/context.js +0 -0
- package/dist/contract/deps.d.ts +43 -0
- package/dist/contract/deps.js +0 -0
- package/dist/contract/extensions.d.ts +94 -0
- package/dist/contract/extensions.js +0 -0
- package/dist/contract/format.d.ts +22 -0
- package/dist/contract/format.js +0 -0
- package/dist/contract/input.d.ts +15 -0
- package/dist/contract/input.js +0 -0
- package/dist/contract/limits.d.ts +16 -0
- package/dist/contract/limits.js +0 -0
- package/dist/contract/messages.d.ts +72 -0
- package/dist/contract/messages.js +0 -0
- package/dist/contract/result.d.ts +77 -0
- package/dist/contract/result.js +0 -0
- package/dist/contract/runner.d.ts +55 -0
- package/dist/contract/runner.js +0 -0
- package/dist/core/abort.d.ts +12 -0
- package/dist/core/abort.js +41 -0
- package/dist/core/capture.d.ts +11 -0
- package/dist/core/capture.js +171 -0
- package/dist/core/create-bash-tool.d.ts +8 -0
- package/dist/core/create-bash-tool.js +18 -0
- package/dist/core/deps.d.ts +5 -0
- package/dist/core/deps.js +103 -0
- package/dist/core/env.d.ts +14 -0
- package/dist/core/env.js +32 -0
- package/dist/core/execute.d.ts +11 -0
- package/dist/core/execute.js +272 -0
- package/dist/core/input.d.ts +12 -0
- package/dist/core/input.js +46 -0
- package/dist/core/limits.d.ts +10 -0
- package/dist/core/limits.js +44 -0
- package/dist/core/messages.d.ts +12 -0
- package/dist/core/messages.js +95 -0
- package/dist/core/pipeline.d.ts +4 -0
- package/dist/core/pipeline.js +185 -0
- package/dist/core/stages.d.ts +20 -0
- package/dist/core/stages.js +166 -0
- package/dist/core/stop.d.ts +17 -0
- package/dist/core/stop.js +50 -0
- package/dist/formatters/default.d.ts +17 -0
- package/dist/formatters/default.js +62 -0
- package/dist/formatters/index.d.ts +2 -0
- package/dist/formatters/index.js +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +7 -0
- package/dist/signature/bash.d.ts +28 -0
- package/dist/signature/bash.js +158 -0
- package/dist/signature/index.d.ts +3 -0
- package/dist/signature/index.js +2 -0
- package/docs/architecture.md +74 -0
- package/docs/result-schema.md +151 -0
- package/package.json +41 -2
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { delay, unref } from "./abort.js";
|
|
2
|
+
import { OutputCapture } from "./capture.js";
|
|
3
|
+
import { isRecord } from "./input.js";
|
|
4
|
+
import { extensionFailure } from "./stages.js";
|
|
5
|
+
import { StageStop, errorNote, messageOf, warning } from "./stop.js";
|
|
6
|
+
const DRAIN_AFTER_EXIT_MS = 1_000;
|
|
7
|
+
const EXIT_WAIT_AFTER_KILL_GRACE_MS = 1_000;
|
|
8
|
+
export async function execute(scope, runner, planned, env) {
|
|
9
|
+
const { deps } = scope;
|
|
10
|
+
const { limits, messages } = deps;
|
|
11
|
+
const spill = await openSpill(scope, planned);
|
|
12
|
+
const controller = new AbortController();
|
|
13
|
+
let stoppedBy = null;
|
|
14
|
+
let exited = false;
|
|
15
|
+
const stop = (reason) => {
|
|
16
|
+
if (exited || stoppedBy !== null)
|
|
17
|
+
return;
|
|
18
|
+
stoppedBy = reason;
|
|
19
|
+
controller.abort();
|
|
20
|
+
};
|
|
21
|
+
const { signal } = scope.call;
|
|
22
|
+
const onAbort = () => stop("abort");
|
|
23
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
24
|
+
const timer = setTimeout(() => stop("timeout"), planned.timeoutMs);
|
|
25
|
+
unref(timer);
|
|
26
|
+
const release = () => {
|
|
27
|
+
clearTimeout(timer);
|
|
28
|
+
signal?.removeEventListener("abort", onAbort);
|
|
29
|
+
};
|
|
30
|
+
const started = Date.now();
|
|
31
|
+
let handle;
|
|
32
|
+
try {
|
|
33
|
+
handle = startRun(runner, {
|
|
34
|
+
command: planned.command,
|
|
35
|
+
cwd: planned.cwd,
|
|
36
|
+
env,
|
|
37
|
+
signal: controller.signal,
|
|
38
|
+
killGraceMs: limits.killGraceMs,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
release();
|
|
43
|
+
await spill?.close().catch(() => undefined);
|
|
44
|
+
throw startFailure(scope, messageOf(error));
|
|
45
|
+
}
|
|
46
|
+
if (signal?.aborted)
|
|
47
|
+
stop("abort");
|
|
48
|
+
const capture = new OutputCapture(limits);
|
|
49
|
+
const iterator = handle.output[Symbol.asyncIterator]();
|
|
50
|
+
let skippedChunks = 0;
|
|
51
|
+
let streamFailed = null;
|
|
52
|
+
let streamEnded = false;
|
|
53
|
+
let outputReleased = false;
|
|
54
|
+
const drain = (async () => {
|
|
55
|
+
try {
|
|
56
|
+
for (;;) {
|
|
57
|
+
const next = await iterator.next();
|
|
58
|
+
if (outputReleased)
|
|
59
|
+
return;
|
|
60
|
+
if (next.done === true) {
|
|
61
|
+
streamEnded = true;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const chunk = validChunk(next.value);
|
|
65
|
+
if (chunk === null) {
|
|
66
|
+
skippedChunks += 1;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
capture.push(chunk);
|
|
70
|
+
spill?.write(chunk.bytes);
|
|
71
|
+
if (capture.totalBytes > limits.maxCaptureBytes)
|
|
72
|
+
stop("output-cap");
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
streamFailed = messageOf(error);
|
|
77
|
+
}
|
|
78
|
+
})();
|
|
79
|
+
const exitPromise = settleExit(handle.exit).then((exit) => {
|
|
80
|
+
exited = true;
|
|
81
|
+
release();
|
|
82
|
+
return exit;
|
|
83
|
+
});
|
|
84
|
+
const exit = await exitOrGiveUpAfterStop(exitPromise, controller.signal, limits.killGraceMs);
|
|
85
|
+
const drainMs = exit === null ? 0 : DRAIN_AFTER_EXIT_MS;
|
|
86
|
+
const drainWait = delay(drainMs);
|
|
87
|
+
await Promise.race([drain, drainWait.done]);
|
|
88
|
+
drainWait.cancel();
|
|
89
|
+
// A background child may still hold the output stream open.
|
|
90
|
+
outputReleased = true;
|
|
91
|
+
void Promise.resolve(iterator.return?.()).catch(() => undefined);
|
|
92
|
+
release();
|
|
93
|
+
const durationMs = Date.now() - started;
|
|
94
|
+
if (exit?.error !== undefined) {
|
|
95
|
+
await spill?.close().catch(() => undefined);
|
|
96
|
+
throw startError(scope, exit.error.reason, exit.error.detail ?? null, planned.cwd);
|
|
97
|
+
}
|
|
98
|
+
const spillRef = await (spill?.close() ?? Promise.resolve(null));
|
|
99
|
+
if (spill?.failed === true) {
|
|
100
|
+
scope.notes.push(warning("spill-failed", messages.spillFailed({ sink: deps.spill?.id ?? "unknown" })));
|
|
101
|
+
}
|
|
102
|
+
const reason = stoppedBy;
|
|
103
|
+
if (exit === null)
|
|
104
|
+
scope.notes.push(warning("unconfirmed-stop", messages.unconfirmedStop()));
|
|
105
|
+
// After a stop, a runner may end its stream early or with an error.
|
|
106
|
+
const failed = reason === null ? streamFailed : null;
|
|
107
|
+
const unfinished = reason === null && failed === null && !streamEnded ? drainMs : null;
|
|
108
|
+
if (failed !== null || unfinished !== null || skippedChunks > 0) {
|
|
109
|
+
scope.notes.push(warning("output-incomplete", messages.outputIncomplete({ detail: failed, skippedChunks, drainMs: unfinished }), {
|
|
110
|
+
skippedChunks,
|
|
111
|
+
...(failed === null ? {} : { detail: failed }),
|
|
112
|
+
...(unfinished === null ? {} : { drainMs: unfinished }),
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
run: {
|
|
117
|
+
command: planned.command,
|
|
118
|
+
cwd: planned.cwd,
|
|
119
|
+
timeoutMs: planned.timeoutMs,
|
|
120
|
+
exitCode: exit?.code ?? null,
|
|
121
|
+
signal: exit?.signal ?? null,
|
|
122
|
+
durationMs,
|
|
123
|
+
stoppedBy: reason,
|
|
124
|
+
unconfirmedStop: exit === null,
|
|
125
|
+
},
|
|
126
|
+
output: capture.view(limits, spillRef),
|
|
127
|
+
stop: runStop(scope, reason, durationMs),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function runStop(scope, reason, durationMs) {
|
|
131
|
+
const { messages, limits } = scope.deps;
|
|
132
|
+
if (reason === "abort") {
|
|
133
|
+
return new StageStop("ABORTED", "run", errorNote("ABORTED", messages.aborted({ durationMs })));
|
|
134
|
+
}
|
|
135
|
+
if (reason === "output-cap") {
|
|
136
|
+
const message = messages.outputCap({ limit: limits.maxCaptureBytes });
|
|
137
|
+
return new StageStop("OUTPUT_CAP", "run", errorNote("OUTPUT_CAP", message));
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
function startRun(runner, request) {
|
|
142
|
+
const handle = runner.run(request);
|
|
143
|
+
if (!isRecord(handle) ||
|
|
144
|
+
!isRecord(handle.output) ||
|
|
145
|
+
typeof handle.output[Symbol.asyncIterator] !==
|
|
146
|
+
"function" ||
|
|
147
|
+
!isRecord(handle.exit) ||
|
|
148
|
+
typeof handle.exit.then !== "function") {
|
|
149
|
+
throw new TypeError("the runner returned a malformed handle");
|
|
150
|
+
}
|
|
151
|
+
return handle;
|
|
152
|
+
}
|
|
153
|
+
async function exitOrGiveUpAfterStop(exit, stopped, killGraceMs) {
|
|
154
|
+
let onStop;
|
|
155
|
+
let giveUp;
|
|
156
|
+
const late = new Promise((resolve) => {
|
|
157
|
+
onStop = () => {
|
|
158
|
+
giveUp = delay(killGraceMs + EXIT_WAIT_AFTER_KILL_GRACE_MS);
|
|
159
|
+
void giveUp.done.then(() => resolve(null));
|
|
160
|
+
};
|
|
161
|
+
if (stopped.aborted)
|
|
162
|
+
onStop();
|
|
163
|
+
else
|
|
164
|
+
stopped.addEventListener("abort", onStop, { once: true });
|
|
165
|
+
});
|
|
166
|
+
try {
|
|
167
|
+
return await Promise.race([exit, late]);
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
if (onStop !== undefined)
|
|
171
|
+
stopped.removeEventListener("abort", onStop);
|
|
172
|
+
giveUp?.cancel();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
async function settleExit(exit) {
|
|
176
|
+
try {
|
|
177
|
+
const value = await exit;
|
|
178
|
+
if (!isRecord(value))
|
|
179
|
+
throw new TypeError("malformed exit");
|
|
180
|
+
const code = typeof value.code === "number" ? value.code : null;
|
|
181
|
+
const signal = typeof value.signal === "string" ? value.signal : null;
|
|
182
|
+
const { error } = value;
|
|
183
|
+
if (isRecord(error) && typeof error.reason === "string") {
|
|
184
|
+
const reason = error.reason;
|
|
185
|
+
const detail = typeof error.detail === "string" ? error.detail : undefined;
|
|
186
|
+
return { code, signal, error: detail === undefined ? { reason } : { reason, detail } };
|
|
187
|
+
}
|
|
188
|
+
return { code, signal };
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
return {
|
|
192
|
+
code: null,
|
|
193
|
+
signal: null,
|
|
194
|
+
error: { reason: "spawn-failed", detail: messageOf(error) },
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function validChunk(value) {
|
|
199
|
+
if (!isRecord(value) || !(value.bytes instanceof Uint8Array))
|
|
200
|
+
return null;
|
|
201
|
+
return { stream: value.stream === "stderr" ? "stderr" : "stdout", bytes: value.bytes };
|
|
202
|
+
}
|
|
203
|
+
function startFailure(scope, detail) {
|
|
204
|
+
const message = scope.deps.messages.spawnFailed({ detail });
|
|
205
|
+
return new StageStop("SPAWN_FAILED", "run", errorNote("SPAWN_FAILED", message));
|
|
206
|
+
}
|
|
207
|
+
function startError(scope, reason, detail, cwd) {
|
|
208
|
+
const { messages } = scope.deps;
|
|
209
|
+
if (reason === "cwd-not-found") {
|
|
210
|
+
const message = messages.cwdNotFound({ cwd });
|
|
211
|
+
return new StageStop("CWD_NOT_FOUND", "run", errorNote("CWD_NOT_FOUND", message));
|
|
212
|
+
}
|
|
213
|
+
if (reason === "cwd-not-a-directory") {
|
|
214
|
+
const message = messages.cwdNotADirectory({ cwd });
|
|
215
|
+
return new StageStop("CWD_NOT_A_DIRECTORY", "run", errorNote("CWD_NOT_A_DIRECTORY", message));
|
|
216
|
+
}
|
|
217
|
+
return startFailure(scope, detail);
|
|
218
|
+
}
|
|
219
|
+
async function openSpill(scope, planned) {
|
|
220
|
+
const sink = scope.deps.spill;
|
|
221
|
+
if (sink === null)
|
|
222
|
+
return null;
|
|
223
|
+
let writer;
|
|
224
|
+
try {
|
|
225
|
+
writer = await sink.open(planned, scope.ctx);
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
throw extensionFailure(scope, sink, "spill");
|
|
229
|
+
}
|
|
230
|
+
if (!isRecord(writer) ||
|
|
231
|
+
typeof writer.write !== "function" ||
|
|
232
|
+
typeof writer.close !== "function") {
|
|
233
|
+
throw extensionFailure(scope, sink, "spill");
|
|
234
|
+
}
|
|
235
|
+
let chain = Promise.resolve();
|
|
236
|
+
let failed = false;
|
|
237
|
+
return {
|
|
238
|
+
get failed() {
|
|
239
|
+
return failed;
|
|
240
|
+
},
|
|
241
|
+
write(bytes) {
|
|
242
|
+
if (failed)
|
|
243
|
+
return;
|
|
244
|
+
const copy = bytes.slice();
|
|
245
|
+
chain = chain.then(async () => {
|
|
246
|
+
if (failed)
|
|
247
|
+
return;
|
|
248
|
+
try {
|
|
249
|
+
await writer.write(copy);
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
failed = true;
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
},
|
|
256
|
+
async close() {
|
|
257
|
+
await chain;
|
|
258
|
+
try {
|
|
259
|
+
const ref = await writer.close();
|
|
260
|
+
if (failed || typeof ref !== "string") {
|
|
261
|
+
failed = true;
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
return ref;
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
failed = true;
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { BashRequest } from "../contract/input.ts";
|
|
2
|
+
import type { ShellLimits } from "../contract/limits.ts";
|
|
3
|
+
/**
|
|
4
|
+
* Checks canonical input and fills defaults. Throws TypeError on bad input.
|
|
5
|
+
* `command` must hold a non-whitespace character and no NUL.
|
|
6
|
+
* `timeoutMs` must be a positive finite number. It is rounded up to a whole
|
|
7
|
+
* millisecond and clamped to maxTimeoutMs; the tool adds a clamped note.
|
|
8
|
+
* `cwd` must be a non-blank string without NUL.
|
|
9
|
+
*/
|
|
10
|
+
export declare function parseBashInput(input: unknown, limits: Readonly<ShellLimits>): BashRequest;
|
|
11
|
+
export declare function clampedTimeout(input: unknown, request: BashRequest): number | null;
|
|
12
|
+
export declare function isRecord(value: unknown): value is Record<string, unknown>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const KEYS = new Set(["command", "timeoutMs", "cwd"]);
|
|
2
|
+
/**
|
|
3
|
+
* Checks canonical input and fills defaults. Throws TypeError on bad input.
|
|
4
|
+
* `command` must hold a non-whitespace character and no NUL.
|
|
5
|
+
* `timeoutMs` must be a positive finite number. It is rounded up to a whole
|
|
6
|
+
* millisecond and clamped to maxTimeoutMs; the tool adds a clamped note.
|
|
7
|
+
* `cwd` must be a non-blank string without NUL.
|
|
8
|
+
*/
|
|
9
|
+
export function parseBashInput(input, limits) {
|
|
10
|
+
if (!isRecord(input))
|
|
11
|
+
throw new TypeError("the input must be an object with a command");
|
|
12
|
+
for (const key of Object.keys(input)) {
|
|
13
|
+
if (!KEYS.has(key))
|
|
14
|
+
throw new TypeError(`unknown key ${key}. Expected command, timeoutMs, cwd`);
|
|
15
|
+
}
|
|
16
|
+
const { command, timeoutMs, cwd } = input;
|
|
17
|
+
if (typeof command !== "string" || command.trim() === "") {
|
|
18
|
+
throw new TypeError("command must be a non-blank string");
|
|
19
|
+
}
|
|
20
|
+
if (command.includes("\u0000"))
|
|
21
|
+
throw new TypeError("command must not hold a NUL character");
|
|
22
|
+
let timeout = limits.defaultTimeoutMs;
|
|
23
|
+
if (timeoutMs !== undefined) {
|
|
24
|
+
if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
25
|
+
throw new TypeError("timeoutMs must be a positive number");
|
|
26
|
+
}
|
|
27
|
+
timeout = Math.min(Math.ceil(timeoutMs), limits.maxTimeoutMs);
|
|
28
|
+
}
|
|
29
|
+
let requestedCwd = null;
|
|
30
|
+
if (cwd !== undefined) {
|
|
31
|
+
if (typeof cwd !== "string" || cwd.trim() === "" || cwd.includes("\u0000")) {
|
|
32
|
+
throw new TypeError("cwd must be a non-blank string without NUL");
|
|
33
|
+
}
|
|
34
|
+
requestedCwd = cwd;
|
|
35
|
+
}
|
|
36
|
+
return Object.freeze({ command, timeoutMs: timeout, cwd: requestedCwd });
|
|
37
|
+
}
|
|
38
|
+
export function clampedTimeout(input, request) {
|
|
39
|
+
if (!isRecord(input) || typeof input.timeoutMs !== "number")
|
|
40
|
+
return null;
|
|
41
|
+
const requested = Math.ceil(input.timeoutMs);
|
|
42
|
+
return requested > request.timeoutMs ? requested : null;
|
|
43
|
+
}
|
|
44
|
+
export function isRecord(value) {
|
|
45
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
46
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ShellLimits } from "../contract/limits.ts";
|
|
2
|
+
/** Defaults, not ceilings. A host may raise any of these. */
|
|
3
|
+
export declare const defaultShellLimits: Readonly<ShellLimits>;
|
|
4
|
+
/**
|
|
5
|
+
* Merges key by key. Throws TypeError on an unknown key, on a value that is
|
|
6
|
+
* not a positive safe integer, on a headPercent over 100, or on a
|
|
7
|
+
* `defaultTimeoutMs` you set above `maxTimeoutMs`. The default
|
|
8
|
+
* `defaultTimeoutMs` over a `maxTimeoutMs` you set is lowered to it.
|
|
9
|
+
*/
|
|
10
|
+
export declare function resolveShellLimits(overrides?: Partial<ShellLimits>): Readonly<ShellLimits>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Defaults, not ceilings. A host may raise any of these. */
|
|
2
|
+
export const defaultShellLimits = Object.freeze({
|
|
3
|
+
defaultTimeoutMs: 120_000,
|
|
4
|
+
maxTimeoutMs: 600_000,
|
|
5
|
+
killGraceMs: 2_000,
|
|
6
|
+
maxOutputBytes: 30_000,
|
|
7
|
+
maxOutputLines: 2_000,
|
|
8
|
+
headPercent: 20,
|
|
9
|
+
maxCaptureBytes: 10 * 1_024 * 1_024,
|
|
10
|
+
});
|
|
11
|
+
const LIMIT_KEYS = Object.keys(defaultShellLimits);
|
|
12
|
+
/**
|
|
13
|
+
* Merges key by key. Throws TypeError on an unknown key, on a value that is
|
|
14
|
+
* not a positive safe integer, on a headPercent over 100, or on a
|
|
15
|
+
* `defaultTimeoutMs` you set above `maxTimeoutMs`. The default
|
|
16
|
+
* `defaultTimeoutMs` over a `maxTimeoutMs` you set is lowered to it.
|
|
17
|
+
*/
|
|
18
|
+
export function resolveShellLimits(overrides = {}) {
|
|
19
|
+
if (overrides === null || typeof overrides !== "object" || Array.isArray(overrides)) {
|
|
20
|
+
throw new TypeError("limits must be an object");
|
|
21
|
+
}
|
|
22
|
+
const unknown = Object.keys(overrides).find((key) => !Object.hasOwn(defaultShellLimits, key));
|
|
23
|
+
if (unknown !== undefined)
|
|
24
|
+
throw new TypeError(`Unknown shell limit: ${unknown}`);
|
|
25
|
+
const resolved = { ...defaultShellLimits };
|
|
26
|
+
for (const key of LIMIT_KEYS) {
|
|
27
|
+
const value = overrides[key];
|
|
28
|
+
if (value === undefined)
|
|
29
|
+
continue;
|
|
30
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
31
|
+
throw new TypeError(`${key} must be a positive safe integer`);
|
|
32
|
+
}
|
|
33
|
+
resolved[key] = value;
|
|
34
|
+
}
|
|
35
|
+
if (resolved.headPercent > 100)
|
|
36
|
+
throw new TypeError("headPercent must be 100 or less");
|
|
37
|
+
if (resolved.defaultTimeoutMs > resolved.maxTimeoutMs) {
|
|
38
|
+
if (overrides.defaultTimeoutMs !== undefined) {
|
|
39
|
+
throw new TypeError("defaultTimeoutMs must not be more than maxTimeoutMs");
|
|
40
|
+
}
|
|
41
|
+
resolved.defaultTimeoutMs = resolved.maxTimeoutMs;
|
|
42
|
+
}
|
|
43
|
+
return Object.freeze(resolved);
|
|
44
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ShellMessageCatalog } from "../contract/messages.ts";
|
|
2
|
+
/** "0.4 s", "12 s", "2 min 5 s". */
|
|
3
|
+
export declare function formatDuration(ms: number): string;
|
|
4
|
+
/** "812 B", "29.3 KB", "1.8 MB". Decimal units. */
|
|
5
|
+
export declare function formatBytes(bytes: number): string;
|
|
6
|
+
export declare const defaultShellMessages: Readonly<ShellMessageCatalog>;
|
|
7
|
+
/**
|
|
8
|
+
* Merges key by key over the defaults. The default texts use the host's
|
|
9
|
+
* `param` and `duration`, so they show the host's names and units. Throws
|
|
10
|
+
* TypeError on an unknown key or a value that is not a function.
|
|
11
|
+
*/
|
|
12
|
+
export declare function resolveShellMessages(overrides?: Partial<ShellMessageCatalog>): Readonly<ShellMessageCatalog>;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/** "0.4 s", "12 s", "2 min 5 s". */
|
|
2
|
+
export function formatDuration(ms) {
|
|
3
|
+
if (ms < 10_000)
|
|
4
|
+
return `${(Math.round(ms / 100) / 10).toString()} s`;
|
|
5
|
+
const seconds = Math.round(ms / 1_000);
|
|
6
|
+
if (seconds < 120)
|
|
7
|
+
return `${seconds} s`;
|
|
8
|
+
const minutes = Math.floor(seconds / 60);
|
|
9
|
+
const rest = seconds % 60;
|
|
10
|
+
return rest === 0 ? `${minutes} min` : `${minutes} min ${rest} s`;
|
|
11
|
+
}
|
|
12
|
+
/** "812 B", "29.3 KB", "1.8 MB". Decimal units. */
|
|
13
|
+
export function formatBytes(bytes) {
|
|
14
|
+
if (bytes < 1_000)
|
|
15
|
+
return `${bytes} B`;
|
|
16
|
+
if (bytes < 1_000_000)
|
|
17
|
+
return `${(Math.round(bytes / 100) / 10).toString()} KB`;
|
|
18
|
+
return `${(Math.round(bytes / 100_000) / 10).toString()} MB`;
|
|
19
|
+
}
|
|
20
|
+
function catalogFor(param, duration) {
|
|
21
|
+
const cwdAdvice = () => {
|
|
22
|
+
const name = param("cwd");
|
|
23
|
+
return name === "" ? "" : ` Check the ${name} value.`;
|
|
24
|
+
};
|
|
25
|
+
return {
|
|
26
|
+
param,
|
|
27
|
+
duration,
|
|
28
|
+
pathRepaired: ({ from, to }) => `The requested directory ${JSON.stringify(from)} was repaired to ${JSON.stringify(to)}.`,
|
|
29
|
+
denied: ({ path, detail }) => `The command in ${path} was refused by policy${detail === null ? "" : ` (${detail})`}.`,
|
|
30
|
+
invalidInput: ({ detail }) => `The bash input was rejected: ${detail}`,
|
|
31
|
+
cwdNotFound: ({ cwd }) => `The working directory ${cwd} does not exist.${cwdAdvice()}`,
|
|
32
|
+
cwdNotADirectory: ({ cwd }) => `The working directory ${cwd} is not a directory.${cwdAdvice()}`,
|
|
33
|
+
refused: ({ hook }) => `The command was refused by the ${hook} check.`,
|
|
34
|
+
spawnFailed: ({ detail }) => `The command could not start${detail === null ? "" : ` (${detail})`}. This is a host problem, not a problem with the command.`,
|
|
35
|
+
extensionFailed: ({ extension, phase }) => `The ${extension} extension failed during the ${phase} phase. This is a host problem, not a problem with the command.`,
|
|
36
|
+
timeoutClamped: ({ requestedMs, maxMs }) => `The requested timeout of ${duration(requestedMs)} is over the maximum, so ${duration(maxMs)} was used.`,
|
|
37
|
+
exited: ({ code, signal, durationMs }) => {
|
|
38
|
+
const time = formatDuration(durationMs);
|
|
39
|
+
if (code !== null)
|
|
40
|
+
return `Exit code ${code} · ${time}`;
|
|
41
|
+
return `Ended by ${signal ?? "an unknown signal"} · ${time}`;
|
|
42
|
+
},
|
|
43
|
+
timedOut: ({ timeoutMs }) => `Timed out after ${formatDuration(timeoutMs)}. The process tree was stopped.`,
|
|
44
|
+
aborted: ({ durationMs }) => `Aborted after ${formatDuration(durationMs)}. The process tree was stopped.`,
|
|
45
|
+
outputCap: ({ limit }) => `The command wrote more than ${formatBytes(limit)} of output, so it was stopped. Send less output, for example with a filter, a count, or a redirect to a file.`,
|
|
46
|
+
unconfirmedStop: () => "The runner did not confirm that the process tree stopped. Processes may still be running.",
|
|
47
|
+
abortedBeforeStart: () => "The call was aborted before the command started.",
|
|
48
|
+
spillFailed: ({ sink }) => `The ${sink} spill sink failed, so the full output was not saved. The command was not affected.`,
|
|
49
|
+
outputIncomplete: ({ detail, skippedChunks, drainMs }) => {
|
|
50
|
+
const causes = [
|
|
51
|
+
...(detail === null ? [] : [`the output stream failed (${detail})`]),
|
|
52
|
+
...(drainMs === null
|
|
53
|
+
? []
|
|
54
|
+
: [`the output had not ended ${drainMs} ms after the command exited`]),
|
|
55
|
+
...(skippedChunks === 0
|
|
56
|
+
? []
|
|
57
|
+
: [
|
|
58
|
+
`the runner gave ${skippedChunks} malformed output ${skippedChunks === 1 ? "chunk" : "chunks"}, which ${skippedChunks === 1 ? "was" : "were"} skipped`,
|
|
59
|
+
]),
|
|
60
|
+
];
|
|
61
|
+
return `The output may be incomplete: ${causes.join("; ")}. The exit status is still the command's.`;
|
|
62
|
+
},
|
|
63
|
+
noOutput: () => "(no output)",
|
|
64
|
+
omitted: ({ lines, bytes, spill }) => `[… ${lines} lines (${formatBytes(bytes)}) not shown]${spill === null ? "" : ` Full output: ${spill}`}`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
export const defaultShellMessages = Object.freeze(catalogFor((name) => name, formatDuration));
|
|
68
|
+
const MESSAGE_KEYS = Object.keys(defaultShellMessages);
|
|
69
|
+
/**
|
|
70
|
+
* Merges key by key over the defaults. The default texts use the host's
|
|
71
|
+
* `param` and `duration`, so they show the host's names and units. Throws
|
|
72
|
+
* TypeError on an unknown key or a value that is not a function.
|
|
73
|
+
*/
|
|
74
|
+
export function resolveShellMessages(overrides = {}) {
|
|
75
|
+
if (overrides === null || typeof overrides !== "object" || Array.isArray(overrides)) {
|
|
76
|
+
throw new TypeError("messages must be an object");
|
|
77
|
+
}
|
|
78
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
79
|
+
if (!MESSAGE_KEYS.includes(key)) {
|
|
80
|
+
throw new TypeError(`Unknown shell message: ${key}`);
|
|
81
|
+
}
|
|
82
|
+
if (value !== undefined && typeof value !== "function") {
|
|
83
|
+
throw new TypeError(`messages.${key} must be a function`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const param = overrides.param ?? defaultShellMessages.param;
|
|
87
|
+
const duration = overrides.duration ?? defaultShellMessages.duration;
|
|
88
|
+
const base = catalogFor(param, duration);
|
|
89
|
+
const merged = { ...base };
|
|
90
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
91
|
+
if (value !== undefined)
|
|
92
|
+
merged[key] = value;
|
|
93
|
+
}
|
|
94
|
+
return Object.freeze(merged);
|
|
95
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ToolCallContext } from "@better-fs-tools/read";
|
|
2
|
+
import type { ShellDependencies } from "../contract/deps.ts";
|
|
3
|
+
import type { ShellResult } from "../contract/result.ts";
|
|
4
|
+
export declare function runBash<THost>(deps: ShellDependencies<THost>, input: unknown, call: ToolCallContext<THost>): Promise<ShellResult>;
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { defaultShellFormatter } from "../formatters/default.js";
|
|
2
|
+
import { AbortStop } from "./abort.js";
|
|
3
|
+
import { execute } from "./execute.js";
|
|
4
|
+
import { clampedTimeout, isRecord, parseBashInput } from "./input.js";
|
|
5
|
+
import { authorize, baseCwd, beforeRun, environment, extensionFailure, resolveCwd, runnerFor, } from "./stages.js";
|
|
6
|
+
import { StageStop, errorNote, extensionId, info, isNoteList, messageOf, warning } from "./stop.js";
|
|
7
|
+
export async function runBash(deps, input, call) {
|
|
8
|
+
const notes = [];
|
|
9
|
+
const { messages, limits } = deps;
|
|
10
|
+
let request;
|
|
11
|
+
try {
|
|
12
|
+
request = parseBashInput(input, limits);
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
const note = errorNote("INVALID_INPUT", messages.invalidInput({ detail: messageOf(error) }));
|
|
16
|
+
return finish(deps, call, failedBeforeStart(null, new StageStop("INVALID_INPUT", "input", note), notes));
|
|
17
|
+
}
|
|
18
|
+
const requested = clampedTimeout(input, request);
|
|
19
|
+
if (requested !== null) {
|
|
20
|
+
const max = limits.maxTimeoutMs;
|
|
21
|
+
const message = messages.timeoutClamped({ requestedMs: requested, maxMs: max });
|
|
22
|
+
notes.push(info("clamped", message, { param: "timeoutMs", requested, max }));
|
|
23
|
+
}
|
|
24
|
+
const ctx = {
|
|
25
|
+
tool: "bash",
|
|
26
|
+
request,
|
|
27
|
+
limits,
|
|
28
|
+
messages,
|
|
29
|
+
digest: deps.digest,
|
|
30
|
+
clock: deps.clock,
|
|
31
|
+
call,
|
|
32
|
+
};
|
|
33
|
+
const scope = { deps, call, notes, ctx };
|
|
34
|
+
// A function, so TypeScript does not narrow the check away after an await.
|
|
35
|
+
const aborted = () => call.signal?.aborted === true;
|
|
36
|
+
try {
|
|
37
|
+
if (aborted())
|
|
38
|
+
throw new AbortStop("input");
|
|
39
|
+
const runner = runnerFor(deps, call);
|
|
40
|
+
const base = baseCwd(deps, call, runner);
|
|
41
|
+
const cwd = await resolveCwd(scope, base, request.cwd);
|
|
42
|
+
const run = await beforeRun(scope, {
|
|
43
|
+
command: request.command,
|
|
44
|
+
cwd,
|
|
45
|
+
timeoutMs: request.timeoutMs,
|
|
46
|
+
});
|
|
47
|
+
await authorize(scope, run, base);
|
|
48
|
+
const env = await environment(scope, run);
|
|
49
|
+
if (aborted())
|
|
50
|
+
throw new AbortStop("run");
|
|
51
|
+
const executed = await execute(scope, runner, run, env);
|
|
52
|
+
return finish(deps, call, await afterRun(scope, executed));
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (error instanceof AbortStop) {
|
|
56
|
+
const note = errorNote("ABORTED", messages.abortedBeforeStart());
|
|
57
|
+
return finish(deps, call, failedBeforeStart(request, new StageStop("ABORTED", error.phase, note), notes));
|
|
58
|
+
}
|
|
59
|
+
if (error instanceof StageStop)
|
|
60
|
+
return finish(deps, call, failedBeforeStart(request, error, notes));
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function statusOf(run) {
|
|
65
|
+
if (run.stoppedBy === "timeout")
|
|
66
|
+
return "timeout";
|
|
67
|
+
return run.exitCode === 0 ? "ok" : "failed";
|
|
68
|
+
}
|
|
69
|
+
async function afterRun(scope, executed) {
|
|
70
|
+
const { run, stop } = executed;
|
|
71
|
+
const request = scope.ctx.request;
|
|
72
|
+
const error = stop === null ? null : errorOf(stop);
|
|
73
|
+
let output = executed.output;
|
|
74
|
+
let notes = stop === null ? [...scope.notes] : [stop.note, ...scope.notes];
|
|
75
|
+
const outcome = () => error === null
|
|
76
|
+
? { status: statusOf(run), run, output, notes }
|
|
77
|
+
: { status: "error", error, run, output, notes };
|
|
78
|
+
for (const hook of scope.deps.afterRun) {
|
|
79
|
+
let next;
|
|
80
|
+
try {
|
|
81
|
+
next = await hook.afterRun(outcome(), scope.ctx);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return failedHook(scope, run, output, notes, hook);
|
|
85
|
+
}
|
|
86
|
+
if (!isRecord(next))
|
|
87
|
+
return failedHook(scope, run, output, notes, hook);
|
|
88
|
+
const nextOutput = next.output ?? output;
|
|
89
|
+
const nextNotes = next.notes ?? notes;
|
|
90
|
+
if (!isOutput(nextOutput) || !isNoteList(nextNotes)) {
|
|
91
|
+
return failedHook(scope, run, output, notes, hook);
|
|
92
|
+
}
|
|
93
|
+
output = nextOutput;
|
|
94
|
+
notes = nextNotes;
|
|
95
|
+
}
|
|
96
|
+
return error === null
|
|
97
|
+
? ranReport(request, statusOf(run), run, output, notes)
|
|
98
|
+
: failureReport(request, error, run, output, notes);
|
|
99
|
+
}
|
|
100
|
+
function failedHook(scope, run, output, notes, hook) {
|
|
101
|
+
const failure = extensionFailure(scope, hook, "afterRun");
|
|
102
|
+
return failureReport(scope.ctx.request, errorOf(failure), run, output, [failure.note, ...notes]);
|
|
103
|
+
}
|
|
104
|
+
function isOutput(value) {
|
|
105
|
+
if (!isRecord(value))
|
|
106
|
+
return false;
|
|
107
|
+
const numbers = [
|
|
108
|
+
"totalBytes",
|
|
109
|
+
"totalLines",
|
|
110
|
+
"omittedBytes",
|
|
111
|
+
"omittedLines",
|
|
112
|
+
"stdoutBytes",
|
|
113
|
+
"stderrBytes",
|
|
114
|
+
];
|
|
115
|
+
return (typeof value.head === "string" &&
|
|
116
|
+
(value.tail === null || typeof value.tail === "string") &&
|
|
117
|
+
(value.spill === null || typeof value.spill === "string") &&
|
|
118
|
+
numbers.every((key) => typeof value[key] === "number"));
|
|
119
|
+
}
|
|
120
|
+
function errorOf(stop) {
|
|
121
|
+
const { note } = stop;
|
|
122
|
+
return {
|
|
123
|
+
code: stop.code,
|
|
124
|
+
phase: stop.phase,
|
|
125
|
+
message: note.message,
|
|
126
|
+
...(note.data === undefined ? {} : { data: note.data }),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
function failedBeforeStart(request, stop, notes) {
|
|
130
|
+
return failureReport(request, errorOf(stop), null, null, [stop.note, ...notes]);
|
|
131
|
+
}
|
|
132
|
+
function ranReport(request, status, run, output, notes) {
|
|
133
|
+
return Object.freeze({
|
|
134
|
+
tool: "bash",
|
|
135
|
+
status,
|
|
136
|
+
request,
|
|
137
|
+
run,
|
|
138
|
+
output,
|
|
139
|
+
notes: Object.freeze([...notes]),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
function failureReport(request, error, run, output, notes) {
|
|
143
|
+
return Object.freeze({
|
|
144
|
+
tool: "bash",
|
|
145
|
+
status: "error",
|
|
146
|
+
error,
|
|
147
|
+
request,
|
|
148
|
+
run,
|
|
149
|
+
output,
|
|
150
|
+
notes: Object.freeze([...notes]),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
function finish(deps, call, done) {
|
|
154
|
+
const context = {
|
|
155
|
+
digest: deps.digest,
|
|
156
|
+
limits: deps.limits,
|
|
157
|
+
messages: deps.messages,
|
|
158
|
+
mode: "model",
|
|
159
|
+
call,
|
|
160
|
+
};
|
|
161
|
+
let content = null;
|
|
162
|
+
try {
|
|
163
|
+
content = toContent(deps.formatter.format(done, context));
|
|
164
|
+
}
|
|
165
|
+
catch { }
|
|
166
|
+
if (content !== null)
|
|
167
|
+
return Object.freeze({ ...done, content });
|
|
168
|
+
const id = extensionId(deps.formatter);
|
|
169
|
+
const note = warning("extension-failed", deps.messages.extensionFailed({ extension: id, phase: "format" }), { extension: "formatter", id });
|
|
170
|
+
const kept = Object.freeze({
|
|
171
|
+
...done,
|
|
172
|
+
notes: Object.freeze([...done.notes, note]),
|
|
173
|
+
});
|
|
174
|
+
return Object.freeze({
|
|
175
|
+
...kept,
|
|
176
|
+
content: toContent(defaultShellFormatter().format(kept, context)) ?? [],
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
function toContent(value) {
|
|
180
|
+
if (typeof value === "string")
|
|
181
|
+
return Object.freeze([{ type: "text", text: value }]);
|
|
182
|
+
if (Array.isArray(value))
|
|
183
|
+
return Object.freeze([...value]);
|
|
184
|
+
return null;
|
|
185
|
+
}
|