@celestea/tools 2.7.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/dist/args.d.ts +15 -0
- package/dist/args.js +55 -0
- package/dist/attachments/image-header.d.ts +28 -0
- package/dist/attachments/image-header.js +148 -0
- package/dist/attachments/store.d.ts +56 -0
- package/dist/attachments/store.js +148 -0
- package/dist/browser/cdp.d.ts +162 -0
- package/dist/browser/cdp.js +269 -0
- package/dist/browser/launch.d.ts +92 -0
- package/dist/browser/launch.js +232 -0
- package/dist/browser/memory-guard.d.ts +55 -0
- package/dist/browser/memory-guard.js +210 -0
- package/dist/browser/session.d.ts +146 -0
- package/dist/browser/session.js +371 -0
- package/dist/browser/snapshot.d.ts +69 -0
- package/dist/browser/snapshot.js +163 -0
- package/dist/browser/types.d.ts +51 -0
- package/dist/browser/types.js +8 -0
- package/dist/builtin.d.ts +54 -0
- package/dist/builtin.js +77 -0
- package/dist/desc.d.ts +20 -0
- package/dist/desc.js +22 -0
- package/dist/disclosure.d.ts +113 -0
- package/dist/disclosure.js +141 -0
- package/dist/env.d.ts +10 -0
- package/dist/env.js +33 -0
- package/dist/errors.d.ts +25 -0
- package/dist/errors.js +60 -0
- package/dist/exposure.d.ts +145 -0
- package/dist/exposure.js +244 -0
- package/dist/fn-tool.d.ts +14 -0
- package/dist/fn-tool.js +14 -0
- package/dist/fs/file-io.d.ts +78 -0
- package/dist/fs/file-io.js +239 -0
- package/dist/guard/path-guard.d.ts +144 -0
- package/dist/guard/path-guard.js +289 -0
- package/dist/guard/paths.d.ts +35 -0
- package/dist/guard/paths.js +100 -0
- package/dist/http/errors.d.ts +24 -0
- package/dist/http/errors.js +64 -0
- package/dist/http/headers.d.ts +19 -0
- package/dist/http/headers.js +62 -0
- package/dist/http/redirects.d.ts +31 -0
- package/dist/http/redirects.js +76 -0
- package/dist/http/ssrf.d.ts +105 -0
- package/dist/http/ssrf.js +272 -0
- package/dist/http/transport.d.ts +50 -0
- package/dist/http/transport.js +130 -0
- package/dist/index.d.ts +114 -0
- package/dist/index.js +129 -0
- package/dist/memory/log.d.ts +75 -0
- package/dist/memory/log.js +157 -0
- package/dist/memory/store.d.ts +47 -0
- package/dist/memory/store.js +61 -0
- package/dist/platform/exec.d.ts +79 -0
- package/dist/platform/exec.js +218 -0
- package/dist/platform/index.d.ts +12 -0
- package/dist/platform/index.js +12 -0
- package/dist/platform/paths.d.ts +51 -0
- package/dist/platform/paths.js +60 -0
- package/dist/platform/quote.d.ts +72 -0
- package/dist/platform/quote.js +102 -0
- package/dist/plugin.d.ts +96 -0
- package/dist/plugin.js +101 -0
- package/dist/process/buffers.d.ts +33 -0
- package/dist/process/buffers.js +86 -0
- package/dist/process/registry.d.ts +98 -0
- package/dist/process/registry.js +282 -0
- package/dist/registry.d.ts +52 -0
- package/dist/registry.js +161 -0
- package/dist/run-code/broker.d.ts +68 -0
- package/dist/run-code/broker.js +465 -0
- package/dist/run-code/limits.d.ts +69 -0
- package/dist/run-code/limits.js +88 -0
- package/dist/run-code/lines.d.ts +69 -0
- package/dist/run-code/lines.js +199 -0
- package/dist/run-code/sdk-ts.d.ts +34 -0
- package/dist/run-code/sdk-ts.js +276 -0
- package/dist/run-code/sdk.d.ts +39 -0
- package/dist/run-code/sdk.js +294 -0
- package/dist/sandbox/async.d.ts +10 -0
- package/dist/sandbox/async.js +26 -0
- package/dist/sandbox/bwrap-argv.d.ts +62 -0
- package/dist/sandbox/bwrap-argv.js +113 -0
- package/dist/sandbox/bwrap.d.ts +94 -0
- package/dist/sandbox/bwrap.js +159 -0
- package/dist/sandbox/child.d.ts +38 -0
- package/dist/sandbox/child.js +98 -0
- package/dist/sandbox/config.d.ts +89 -0
- package/dist/sandbox/config.js +149 -0
- package/dist/sandbox/fake-sandbox.d.ts +57 -0
- package/dist/sandbox/fake-sandbox.js +110 -0
- package/dist/sandbox/launch.d.ts +51 -0
- package/dist/sandbox/launch.js +134 -0
- package/dist/sandbox/limits.d.ts +63 -0
- package/dist/sandbox/limits.js +113 -0
- package/dist/sandbox/probe.d.ts +46 -0
- package/dist/sandbox/probe.js +102 -0
- package/dist/sandbox/provider.d.ts +83 -0
- package/dist/sandbox/provider.js +126 -0
- package/dist/sandbox/rlimit.d.ts +60 -0
- package/dist/sandbox/rlimit.js +76 -0
- package/dist/sandbox/seccomp.d.ts +48 -0
- package/dist/sandbox/seccomp.js +115 -0
- package/dist/sandbox/userspace.d.ts +65 -0
- package/dist/sandbox/userspace.js +107 -0
- package/dist/sandbox/workdir.d.ts +13 -0
- package/dist/sandbox/workdir.js +44 -0
- package/dist/schema.d.ts +20 -0
- package/dist/schema.js +135 -0
- package/dist/testing/platform-gates.d.ts +54 -0
- package/dist/testing/platform-gates.js +62 -0
- package/dist/tool-failure.d.ts +13 -0
- package/dist/tool-failure.js +19 -0
- package/dist/tools/ask-user.d.ts +32 -0
- package/dist/tools/ask-user.js +145 -0
- package/dist/tools/browser.d.ts +24 -0
- package/dist/tools/browser.js +132 -0
- package/dist/tools/http-request.d.ts +31 -0
- package/dist/tools/http-request.js +117 -0
- package/dist/tools/list-dir.d.ts +9 -0
- package/dist/tools/list-dir.js +45 -0
- package/dist/tools/load-skill.d.ts +37 -0
- package/dist/tools/load-skill.js +76 -0
- package/dist/tools/memory.d.ts +46 -0
- package/dist/tools/memory.js +131 -0
- package/dist/tools/process-control.d.ts +14 -0
- package/dist/tools/process-control.js +49 -0
- package/dist/tools/read-file.d.ts +11 -0
- package/dist/tools/read-file.js +81 -0
- package/dist/tools/read-image.d.ts +33 -0
- package/dist/tools/read-image.js +144 -0
- package/dist/tools/run-code.d.ts +48 -0
- package/dist/tools/run-code.js +115 -0
- package/dist/tools/run-shell.d.ts +22 -0
- package/dist/tools/run-shell.js +81 -0
- package/dist/tools/write-file.d.ts +8 -0
- package/dist/tools/write-file.js +31 -0
- package/package.json +28 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `BwrapSandbox` — the OS-isolated provider (P2c), behind the same `Sandbox`
|
|
3
|
+
* seam as the userspace one, so `run_shell` changes no line.
|
|
4
|
+
*
|
|
5
|
+
* Layer order (outermost → innermost), mirroring W274 §8.1:
|
|
6
|
+
*
|
|
7
|
+
* node spawn(detached) → own process group (group kill)
|
|
8
|
+
* prlimit | /bin/sh 'ulimit …; exec' → RLIMIT_CPU/AS/NPROC/FSIZE/…
|
|
9
|
+
* bwrap --unshare-all --die-with-parent → user/mount/pid/net/ipc/uts ns,
|
|
10
|
+
* --ro-bind / / --dev /dev --proc /proc read-only root, private /dev
|
|
11
|
+
* --tmpfs /tmp | --share-net | --seccomp FD
|
|
12
|
+
* /bin/sh -c <command>
|
|
13
|
+
*
|
|
14
|
+
* Two properties are non-negotiable and tested:
|
|
15
|
+
* - **argv order** (`bwrap-argv.ts`) — the W274 device regression;
|
|
16
|
+
* - **`--die-with-parent`** — orphan reaping when the Node parent is SIGKILLed
|
|
17
|
+
* (W274 §6.3: 3 orphans → 0), which the userspace path cannot do at all.
|
|
18
|
+
*
|
|
19
|
+
* If bwrap turns out unusable at call time the run is **refused** with a
|
|
20
|
+
* structured `SandboxError`; degrading to userspace is the *policy* layer's
|
|
21
|
+
* explicit decision (`provider.ts`), never this provider's silent fallback.
|
|
22
|
+
*/
|
|
23
|
+
import { mkdir } from "node:fs/promises";
|
|
24
|
+
import { SandboxError } from "@celestea/core";
|
|
25
|
+
import { wrapChild } from "./child.js";
|
|
26
|
+
import { sandboxConfigFromEnv, sanitizedEnv } from "./config.js";
|
|
27
|
+
import { BWRAP_PROVIDER, buildBwrapCommand, bwrapLabel, DEFAULT_BWRAP_OPTIONS, } from "./bwrap-argv.js";
|
|
28
|
+
import { captureRun, preview, resolveTimeout, spawnPlan, validateSandboxConfig } from "./launch.js";
|
|
29
|
+
import { limitsForCpu, limitsFromEnv, resolveCpuSec } from "./limits.js";
|
|
30
|
+
import { probeHost } from "./probe.js";
|
|
31
|
+
import { applyLimits, rlimitDiagnostics, rlimitVia } from "./rlimit.js";
|
|
32
|
+
import { openSeccompBlob } from "./seccomp.js";
|
|
33
|
+
import { resolveWorkdir } from "./workdir.js";
|
|
34
|
+
export class BwrapSandbox {
|
|
35
|
+
config;
|
|
36
|
+
probe;
|
|
37
|
+
limits;
|
|
38
|
+
options;
|
|
39
|
+
/** W885: injected platform view; `undefined` = the host's own defaults. */
|
|
40
|
+
shell;
|
|
41
|
+
rlimits;
|
|
42
|
+
seccompDir;
|
|
43
|
+
constructor(config, options = {}) {
|
|
44
|
+
this.config = config;
|
|
45
|
+
this.probe = options.probe ?? probeHost();
|
|
46
|
+
this.limits = options.limits ?? limitsFromEnv(process.env, this.probe.uidThreads);
|
|
47
|
+
this.options = { ...DEFAULT_BWRAP_OPTIONS, ...(options.run ?? {}) };
|
|
48
|
+
this.rlimits = options.rlimits ?? true;
|
|
49
|
+
this.seccompDir = options.seccompDir;
|
|
50
|
+
this.shell = options.shell;
|
|
51
|
+
}
|
|
52
|
+
static fromEnv(env = process.env) {
|
|
53
|
+
return new BwrapSandbox(sandboxConfigFromEnv(env), { probe: probeHost({ env }) });
|
|
54
|
+
}
|
|
55
|
+
async run(request) {
|
|
56
|
+
validateSandboxConfig(this.config);
|
|
57
|
+
const timeoutMs = resolveTimeout(this.config, request.timeoutMs);
|
|
58
|
+
const limits = this.limitsFor(request.cpuSec);
|
|
59
|
+
const { child, meta } = await this.launch(request.command, request.workdir, false, limits, request.noAddressSpaceLimit === true);
|
|
60
|
+
return captureRun(this.config, child, timeoutMs, meta);
|
|
61
|
+
}
|
|
62
|
+
async spawn(request) {
|
|
63
|
+
validateSandboxConfig(this.config);
|
|
64
|
+
const limits = this.limitsFor(request.cpuSec);
|
|
65
|
+
const { child, meta } = await this.launch(request.command, request.workdir, true, limits, request.noAddressSpaceLimit === true);
|
|
66
|
+
return { child: wrapChild(child, { detached: true }), sandbox: meta };
|
|
67
|
+
}
|
|
68
|
+
/** W6: the base limits with this call's `cpu_sec` merged in (clamped). */
|
|
69
|
+
limitsFor(cpuSec) {
|
|
70
|
+
return limitsForCpu(this.limits, resolveCpuSec(this.limits.cpuSec, cpuSec, this.config.maxCpuSec));
|
|
71
|
+
}
|
|
72
|
+
/** Isolation actually in force, without running anything (logs / health). */
|
|
73
|
+
describe(options = {}) {
|
|
74
|
+
const diag = rlimitDiagnostics(this.probe, this.rlimits, options.noAddressSpaceLimit === true);
|
|
75
|
+
return runtimeMeta(this.options, this.limits, this.probe, diag.via, diag.address_space_limited);
|
|
76
|
+
}
|
|
77
|
+
async launch(command, requestedWorkdir, withStdin, limits, noAddressSpaceLimit) {
|
|
78
|
+
this.assertUsable();
|
|
79
|
+
const workdir = await resolveWorkdir(this.config, requestedWorkdir);
|
|
80
|
+
// W880: the program dir must exist before bwrap can bind it; run_code
|
|
81
|
+
// normally creates it first, but a run_shell-only sandbox must not fail.
|
|
82
|
+
await mkdir(this.config.programDir, { recursive: true }).catch(() => undefined);
|
|
83
|
+
const binary = this.probe.bwrapPath;
|
|
84
|
+
const blob = this.options.seccomp ? openSeccompBlob(this.seccompDir) : null;
|
|
85
|
+
try {
|
|
86
|
+
const limited = applyLimits(binary, buildBwrapCommand(workdir, { ...this.options, programDir: this.config.programDir }, command), limits, this.probe, { enabled: this.rlimits, noAddressSpaceLimit });
|
|
87
|
+
const child = await spawnPlan({
|
|
88
|
+
program: limited.program,
|
|
89
|
+
args: limited.args,
|
|
90
|
+
workdir,
|
|
91
|
+
env: sanitizedEnv(this.config),
|
|
92
|
+
extraFds: blob === null ? [] : [blob.fd],
|
|
93
|
+
withStdin,
|
|
94
|
+
label: `${bwrapLabel(this.options)} ${preview(command, 128)}`,
|
|
95
|
+
});
|
|
96
|
+
const diag = rlimitDiagnostics(this.probe, this.rlimits, noAddressSpaceLimit);
|
|
97
|
+
return { child, meta: resultMeta(runtimeMeta(this.options, limits, this.probe, limited.via, diag.address_space_limited)) };
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
blob?.dispose();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
assertUsable() {
|
|
104
|
+
if (this.probe.bwrapUsable && this.probe.bwrapPath !== null)
|
|
105
|
+
return;
|
|
106
|
+
const reason = this.probe.bwrapRejectReason ?? "bwrap reported unusable by the host probe";
|
|
107
|
+
throw new SandboxError("config", `sandbox_unavailable: ${reason} (fix the host, pin a binary with CELESTEA_SANDBOX_BWRAP, or set CELESTEA_SANDBOX_FALLBACK=userspace to degrade explicitly)`, { provider: BWRAP_PROVIDER, reason, bwrap_path: this.probe.bwrapPath });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Which rlimit mechanism the probe leaves available.
|
|
112
|
+
*
|
|
113
|
+
* F4: the implementation moved to `rlimit.ts` (where the limit plan lives);
|
|
114
|
+
* this re-export keeps the public path `@celestea/tools` -> `bwrap.js` stable.
|
|
115
|
+
*/
|
|
116
|
+
export { rlimitVia } from "./rlimit.js";
|
|
117
|
+
function runtimeMeta(options, limits, probe, via, addressSpaceLimited) {
|
|
118
|
+
return {
|
|
119
|
+
provider: BWRAP_PROVIDER,
|
|
120
|
+
net_isolated: !options.shareNet,
|
|
121
|
+
tmp_private: !options.shareTmp,
|
|
122
|
+
seccomp: options.seccomp,
|
|
123
|
+
readonly_root: true,
|
|
124
|
+
address_space_limited: addressSpaceLimited,
|
|
125
|
+
rlimit_via: via,
|
|
126
|
+
cpu_sec: limits.cpuSec,
|
|
127
|
+
nproc: limits.nproc,
|
|
128
|
+
uid_threads: probe.uidThreads,
|
|
129
|
+
bwrap_version: probe.bwrapVersion,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* The model-visible projection: EXACTLY the `SandboxMeta` seam contract
|
|
134
|
+
* (`provider`, `net_isolated`, `tmp_private`, `seccomp`, optional `cpu_sec`).
|
|
135
|
+
*
|
|
136
|
+
* Why this exists: [runtimeMeta] also carries host diagnostics (`bwrap_version`,
|
|
137
|
+
* `rlimit_via`, `uid_threads`, `nproc`, `readonly_root`) that are identical on
|
|
138
|
+
* every single call and are NOT part of the contract. Shipping them inside every
|
|
139
|
+
* `run_shell` result floods the caller's context with constants (the userspace
|
|
140
|
+
* provider never did — see `USERSPACE_SANDBOX_META`). Diagnostics stay available
|
|
141
|
+
* on [BwrapSandbox.describe] for logs / health, never inside a tool result.
|
|
142
|
+
*/
|
|
143
|
+
function resultMeta(meta) {
|
|
144
|
+
return {
|
|
145
|
+
provider: meta.provider,
|
|
146
|
+
net_isolated: meta.net_isolated,
|
|
147
|
+
tmp_private: meta.tmp_private,
|
|
148
|
+
seccomp: meta.seccomp,
|
|
149
|
+
...(meta.cpu_sec === undefined ? {} : { cpu_sec: meta.cpu_sec }),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/** Factory with explicit knobs (tests / embeddings). */
|
|
153
|
+
export function bwrapSandboxWith(config, options = {}) {
|
|
154
|
+
return new BwrapSandbox(config, options);
|
|
155
|
+
}
|
|
156
|
+
/** Env-tuned default (`selectSandbox` builds this only when bwrap is usable). */
|
|
157
|
+
export function bwrapSandbox(config = sandboxConfigFromEnv()) {
|
|
158
|
+
return new BwrapSandbox(config);
|
|
159
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `SandboxChild` over a `node:child_process` child.
|
|
3
|
+
*
|
|
4
|
+
* The wrapper gives every sandbox provider the same handle shape (pid + three
|
|
5
|
+
* streams + wait/terminate/kill), so the process registry never touches
|
|
6
|
+
* provider internals. Signals target the whole **process group** when the child
|
|
7
|
+
* was spawned detached — a shell that forked grandchildren must die with its
|
|
8
|
+
* tree, not leave orphans behind.
|
|
9
|
+
*/
|
|
10
|
+
import { type ChildProcess } from "node:child_process";
|
|
11
|
+
import type { SandboxChild } from "@celestea/core";
|
|
12
|
+
export interface WrapOptions {
|
|
13
|
+
/** Child was spawned with `detached: true` (it leads its own process group). */
|
|
14
|
+
detached: boolean;
|
|
15
|
+
}
|
|
16
|
+
export declare function wrapChild(child: ChildProcess, options: WrapOptions): SandboxChild;
|
|
17
|
+
/** Best-effort signal of the child's whole process group (falls back to child). */
|
|
18
|
+
export declare function signalTree(child: ChildProcess, options: WrapOptions, signal: NodeJS.Signals): void;
|
|
19
|
+
/**
|
|
20
|
+
* W885 — Windows process-tree recycling, BEST EFFORT.
|
|
21
|
+
*
|
|
22
|
+
* Windows has no POSIX process group and Node's `child.kill()` signals only the
|
|
23
|
+
* DIRECT child (W883 B10), so a `cmd.exe` that forked grandchildren would leak
|
|
24
|
+
* them. `taskkill /T` walks the parent-child chain and is the only tool the OS
|
|
25
|
+
* ships for this, but it is NOT an atomic boundary — standard Windows: a child
|
|
26
|
+
* can re-parent or die between the walk and the kill (TOCTOU) — which is why the
|
|
27
|
+
* real fix is a **Job Object** and is deferred to W885 slice 2 (Job Objects +
|
|
28
|
+
* resource limits + the Windows sandbox provider).
|
|
29
|
+
*
|
|
30
|
+
* Slice-2 TODO: create the child inside a Job Object with
|
|
31
|
+
* `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` so the tree dies atomically with the
|
|
32
|
+
* parent, instead of racing `taskkill`. The behaviour here is therefore
|
|
33
|
+
* "best-effort", never a guarantee.
|
|
34
|
+
*
|
|
35
|
+
* NOT verifiable on this host (Linux): the branch selection is unit-tested
|
|
36
|
+
* (`child.test.ts` injects `platform`), the actual kill is not.
|
|
37
|
+
*/
|
|
38
|
+
export declare function taskkillTree(pid: number, platform?: string): boolean;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `SandboxChild` over a `node:child_process` child.
|
|
3
|
+
*
|
|
4
|
+
* The wrapper gives every sandbox provider the same handle shape (pid + three
|
|
5
|
+
* streams + wait/terminate/kill), so the process registry never touches
|
|
6
|
+
* provider internals. Signals target the whole **process group** when the child
|
|
7
|
+
* was spawned detached — a shell that forked grandchildren must die with its
|
|
8
|
+
* tree, not leave orphans behind.
|
|
9
|
+
*/
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
11
|
+
import { isWindows } from "../platform/paths.js";
|
|
12
|
+
export function wrapChild(child, options) {
|
|
13
|
+
let settled = null;
|
|
14
|
+
let resolveWait = null;
|
|
15
|
+
const waitPromise = new Promise((resolve) => {
|
|
16
|
+
resolveWait = resolve;
|
|
17
|
+
});
|
|
18
|
+
const settle = (exit) => {
|
|
19
|
+
if (settled !== null)
|
|
20
|
+
return;
|
|
21
|
+
settled = exit;
|
|
22
|
+
resolveWait?.(exit);
|
|
23
|
+
};
|
|
24
|
+
// `close` (not `exit`) fires once the stdio pipes are drained, so a reader
|
|
25
|
+
// that starts after `wait()` never loses buffered output.
|
|
26
|
+
child.once("close", (code, signal) => settle({ code, signal }));
|
|
27
|
+
child.once("error", (error) => settle({ code: null, signal: error.name }));
|
|
28
|
+
return {
|
|
29
|
+
pid: child.pid ?? null,
|
|
30
|
+
stdin: child.stdin,
|
|
31
|
+
stdout: child.stdout,
|
|
32
|
+
stderr: child.stderr,
|
|
33
|
+
wait: () => waitPromise,
|
|
34
|
+
terminate: () => signalTree(child, options, "SIGTERM"),
|
|
35
|
+
kill: () => signalTree(child, options, "SIGKILL"),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** Best-effort signal of the child's whole process group (falls back to child). */
|
|
39
|
+
export function signalTree(child, options, signal) {
|
|
40
|
+
const pid = child.pid;
|
|
41
|
+
if (pid !== undefined && options.detached && signalProcessGroup(pid, signal))
|
|
42
|
+
return;
|
|
43
|
+
signalChild(child, signal);
|
|
44
|
+
}
|
|
45
|
+
function signalProcessGroup(pid, signal) {
|
|
46
|
+
if (isWindows())
|
|
47
|
+
return taskkillTree(pid);
|
|
48
|
+
try {
|
|
49
|
+
// Negative pid targets the whole group: the child leads it (detached).
|
|
50
|
+
process.kill(-pid, signal);
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return false; // group already gone, or not ours to signal
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* W885 — Windows process-tree recycling, BEST EFFORT.
|
|
59
|
+
*
|
|
60
|
+
* Windows has no POSIX process group and Node's `child.kill()` signals only the
|
|
61
|
+
* DIRECT child (W883 B10), so a `cmd.exe` that forked grandchildren would leak
|
|
62
|
+
* them. `taskkill /T` walks the parent-child chain and is the only tool the OS
|
|
63
|
+
* ships for this, but it is NOT an atomic boundary — standard Windows: a child
|
|
64
|
+
* can re-parent or die between the walk and the kill (TOCTOU) — which is why the
|
|
65
|
+
* real fix is a **Job Object** and is deferred to W885 slice 2 (Job Objects +
|
|
66
|
+
* resource limits + the Windows sandbox provider).
|
|
67
|
+
*
|
|
68
|
+
* Slice-2 TODO: create the child inside a Job Object with
|
|
69
|
+
* `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` so the tree dies atomically with the
|
|
70
|
+
* parent, instead of racing `taskkill`. The behaviour here is therefore
|
|
71
|
+
* "best-effort", never a guarantee.
|
|
72
|
+
*
|
|
73
|
+
* NOT verifiable on this host (Linux): the branch selection is unit-tested
|
|
74
|
+
* (`child.test.ts` injects `platform`), the actual kill is not.
|
|
75
|
+
*/
|
|
76
|
+
export function taskkillTree(pid, platform = process.platform) {
|
|
77
|
+
if (!isWindows(platform))
|
|
78
|
+
return false;
|
|
79
|
+
try {
|
|
80
|
+
execFileSync("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore", timeout: WINDOWS_TASKKILL_TIMEOUT_MS });
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// taskkill missing, the pid already gone, or access denied: the caller
|
|
85
|
+
// falls back to `child.kill()`, which still stops the direct child.
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** taskkill is a local, bounded operation; never let it stall a timeout path. */
|
|
90
|
+
const WINDOWS_TASKKILL_TIMEOUT_MS = 5_000;
|
|
91
|
+
function signalChild(child, signal) {
|
|
92
|
+
try {
|
|
93
|
+
return child.kill(signal);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return false; // already reaped
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sandbox configuration and spawn plumbing for the userspace implementation
|
|
3
|
+
* (`crates/tools/src/sandbox.rs`, v1 userspace path).
|
|
4
|
+
*
|
|
5
|
+
* Everything an operator can tune is an env knob, read once per sandbox
|
|
6
|
+
* construction; the child environment is an **allowlist** (never the whole host
|
|
7
|
+
* environment, and deliberately never `HOME`: `~/.ssh`, `~/.aws`, `~/.gnupg`
|
|
8
|
+
* must not ride along).
|
|
9
|
+
*/
|
|
10
|
+
import { type SandboxConfig } from "@celestea/core";
|
|
11
|
+
import { type ShellResolveInput } from "../platform/exec.js";
|
|
12
|
+
/** Env var: default kill deadline in milliseconds. */
|
|
13
|
+
export declare const ENV_SHELL_TIMEOUT_MS = "CELAESTEA_RUN_SHELL_TIMEOUT_MS";
|
|
14
|
+
/** Env var: upper bound accepted for a per-call `timeout_ms`. */
|
|
15
|
+
export declare const ENV_SHELL_MAX_TIMEOUT_MS = "CELESTEA_SHELL_MAX_TIMEOUT_MS";
|
|
16
|
+
/** W6: env var for the upper bound accepted for a per-call `cpu_sec`. */
|
|
17
|
+
export declare const ENV_SHELL_MAX_CPU_SEC = "CELESTEA_SHELL_MAX_CPU_SEC";
|
|
18
|
+
/** Env var: per-stream output cap in bytes. */
|
|
19
|
+
export declare const ENV_SHELL_MAX_OUTPUT_BYTES = "CELAESTEA_RUN_SHELL_MAX_OUTPUT_BYTES";
|
|
20
|
+
/** Env var: fixed default workdir. */
|
|
21
|
+
export declare const ENV_SHELL_WORKDIR = "CELAESTEA_RUN_SHELL_WORKDIR";
|
|
22
|
+
/** Env var: canonical root every resolved workdir must stay inside. */
|
|
23
|
+
export declare const ENV_SHELL_ROOT = "CELAESTEA_RUN_SHELL_ROOT";
|
|
24
|
+
export declare const DEFAULT_TIMEOUT_MS = 30000;
|
|
25
|
+
export declare const DEFAULT_MAX_TIMEOUT_MS = 300000;
|
|
26
|
+
/** W6: `cpu_sec` above this is CLAMPED to it (not rejected). */
|
|
27
|
+
export declare const DEFAULT_MAX_CPU_SEC = 600;
|
|
28
|
+
export declare const DEFAULT_MAX_OUTPUT_BYTES: number;
|
|
29
|
+
/** Host env vars passed through to the child (whitelist, not blacklist). */
|
|
30
|
+
export declare const ENV_ALLOWLIST: readonly string[];
|
|
31
|
+
/**
|
|
32
|
+
* W768: the per-SESSION filesystem scope. One value, resolved by the HOST from
|
|
33
|
+
* the session's own workspace record — never from a process-wide env knob —
|
|
34
|
+
* because a process serves several sessions and `process.cwd()` cannot describe
|
|
35
|
+
* more than one of them.
|
|
36
|
+
*
|
|
37
|
+
* `workspace` is both the default cwd of every spawned command and the root a
|
|
38
|
+
* workdir must stay inside, so "where am I" and "what may I touch" cannot
|
|
39
|
+
* disagree.
|
|
40
|
+
*/
|
|
41
|
+
export interface SessionFsScope {
|
|
42
|
+
/** Absolute, canonical workspace root of the session being composed. */
|
|
43
|
+
workspace: string;
|
|
44
|
+
}
|
|
45
|
+
export interface SandboxConfigOverrides {
|
|
46
|
+
timeoutMs?: number;
|
|
47
|
+
maxTimeoutMs?: number;
|
|
48
|
+
maxCpuSec?: number;
|
|
49
|
+
maxOutputBytes?: number;
|
|
50
|
+
workdir?: string;
|
|
51
|
+
root?: string;
|
|
52
|
+
/** W880: override the run_code program directory (tests / embeddings). */
|
|
53
|
+
programDir?: string;
|
|
54
|
+
extraEnv?: ReadonlyArray<readonly [string, string]>;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Configuration from `CELAESTEA_RUN_SHELL_*` / `CELESTEA_SHELL_MAX_TIMEOUT_MS`.
|
|
58
|
+
*
|
|
59
|
+
* W768: `overrides` is how a session's OWN workspace replaces the process-wide
|
|
60
|
+
* default. `workdir`/`root` are the only two knobs a session may set — the
|
|
61
|
+
* limits stay operator policy — and with no override the env reading is byte for
|
|
62
|
+
* byte what it always was (the fallback path for detached/legacy sessions).
|
|
63
|
+
*/
|
|
64
|
+
export declare function sandboxConfigFromEnv(env?: NodeJS.ProcessEnv, overrides?: SandboxConfigOverrides): SandboxConfig;
|
|
65
|
+
/**
|
|
66
|
+
* W768: the sandbox config of ONE session — the session's workspace as cwd and
|
|
67
|
+
* root, the operator's limits unchanged. `null` scope = the env posture.
|
|
68
|
+
*/
|
|
69
|
+
export declare function sessionSandboxConfig(scope: SessionFsScope | null, env?: NodeJS.ProcessEnv): SandboxConfig;
|
|
70
|
+
/** Materialize a config, filling defaults (tests pin explicit knobs). */
|
|
71
|
+
export declare function buildSandboxConfig(overrides?: SandboxConfigOverrides): SandboxConfig;
|
|
72
|
+
/**
|
|
73
|
+
* `{program, args}` of the platform shell carrying exactly one command.
|
|
74
|
+
*
|
|
75
|
+
* W885: the decision moved into the injectable `resolveShell` ladder —
|
|
76
|
+
* POSIX answers the literal `/bin/sh -c <command>` (byte-identical to the
|
|
77
|
+
* pre-W885 code, asserted by `platform-exec.test.ts`), Windows walks
|
|
78
|
+
* gitbash > pwsh > cmd. A host with no usable shell now fails closed with a
|
|
79
|
+
* structured [ShellNotFoundError] instead of guessing `cmd.exe`; the
|
|
80
|
+
* `input` seam is what lets the win32 ladder be unit-tested on Linux.
|
|
81
|
+
*/
|
|
82
|
+
export declare function shellInvocation(command: string, input?: ShellResolveInput): {
|
|
83
|
+
program: string;
|
|
84
|
+
args: string[];
|
|
85
|
+
};
|
|
86
|
+
/** Allowlisted host env plus explicit operator additions (never `HOME`). */
|
|
87
|
+
export declare function sanitizedEnv(config: SandboxConfig, env?: NodeJS.ProcessEnv): Record<string, string>;
|
|
88
|
+
/** Walk up from `start` looking for a git marker (dir `.git` or a worktree file). */
|
|
89
|
+
export declare function gitToplevelOr(start: string): string;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sandbox configuration and spawn plumbing for the userspace implementation
|
|
3
|
+
* (`crates/tools/src/sandbox.rs`, v1 userspace path).
|
|
4
|
+
*
|
|
5
|
+
* Everything an operator can tune is an env knob, read once per sandbox
|
|
6
|
+
* construction; the child environment is an **allowlist** (never the whole host
|
|
7
|
+
* environment, and deliberately never `HOME`: `~/.ssh`, `~/.aws`, `~/.gnupg`
|
|
8
|
+
* must not ride along).
|
|
9
|
+
*/
|
|
10
|
+
import { realpathSync, statSync } from "node:fs";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { CELESTEA_RUN_CODE_DIR, workspaceSubdir } from "@celestea/core";
|
|
13
|
+
import { envInt, envString } from "../env.js";
|
|
14
|
+
import { resolveShell } from "../platform/exec.js";
|
|
15
|
+
/** Env var: default kill deadline in milliseconds. */
|
|
16
|
+
export const ENV_SHELL_TIMEOUT_MS = "CELAESTEA_RUN_SHELL_TIMEOUT_MS";
|
|
17
|
+
/** Env var: upper bound accepted for a per-call `timeout_ms`. */
|
|
18
|
+
export const ENV_SHELL_MAX_TIMEOUT_MS = "CELESTEA_SHELL_MAX_TIMEOUT_MS";
|
|
19
|
+
/** W6: env var for the upper bound accepted for a per-call `cpu_sec`. */
|
|
20
|
+
export const ENV_SHELL_MAX_CPU_SEC = "CELESTEA_SHELL_MAX_CPU_SEC";
|
|
21
|
+
/** Env var: per-stream output cap in bytes. */
|
|
22
|
+
export const ENV_SHELL_MAX_OUTPUT_BYTES = "CELAESTEA_RUN_SHELL_MAX_OUTPUT_BYTES";
|
|
23
|
+
/** Env var: fixed default workdir. */
|
|
24
|
+
export const ENV_SHELL_WORKDIR = "CELAESTEA_RUN_SHELL_WORKDIR";
|
|
25
|
+
/** Env var: canonical root every resolved workdir must stay inside. */
|
|
26
|
+
export const ENV_SHELL_ROOT = "CELAESTEA_RUN_SHELL_ROOT";
|
|
27
|
+
export const DEFAULT_TIMEOUT_MS = 30_000;
|
|
28
|
+
export const DEFAULT_MAX_TIMEOUT_MS = 300_000;
|
|
29
|
+
/** W6: `cpu_sec` above this is CLAMPED to it (not rejected). */
|
|
30
|
+
export const DEFAULT_MAX_CPU_SEC = 600;
|
|
31
|
+
export const DEFAULT_MAX_OUTPUT_BYTES = 64 * 1024;
|
|
32
|
+
/** Host env vars passed through to the child (whitelist, not blacklist). */
|
|
33
|
+
export const ENV_ALLOWLIST = [
|
|
34
|
+
"PATH",
|
|
35
|
+
"LANG",
|
|
36
|
+
"LC_ALL",
|
|
37
|
+
"LC_CTYPE",
|
|
38
|
+
"TZ",
|
|
39
|
+
"TERM",
|
|
40
|
+
"USER",
|
|
41
|
+
"LOGNAME",
|
|
42
|
+
"SHELL",
|
|
43
|
+
"TMPDIR",
|
|
44
|
+
"PWD",
|
|
45
|
+
];
|
|
46
|
+
/**
|
|
47
|
+
* Configuration from `CELAESTEA_RUN_SHELL_*` / `CELESTEA_SHELL_MAX_TIMEOUT_MS`.
|
|
48
|
+
*
|
|
49
|
+
* W768: `overrides` is how a session's OWN workspace replaces the process-wide
|
|
50
|
+
* default. `workdir`/`root` are the only two knobs a session may set — the
|
|
51
|
+
* limits stay operator policy — and with no override the env reading is byte for
|
|
52
|
+
* byte what it always was (the fallback path for detached/legacy sessions).
|
|
53
|
+
*/
|
|
54
|
+
export function sandboxConfigFromEnv(env = process.env, overrides = {}) {
|
|
55
|
+
const workdir = resolveOrCwd(overrides.workdir ?? envString(env, ENV_SHELL_WORKDIR) ?? process.cwd());
|
|
56
|
+
return buildSandboxConfig({
|
|
57
|
+
timeoutMs: positive(envInt(env, ENV_SHELL_TIMEOUT_MS), DEFAULT_TIMEOUT_MS),
|
|
58
|
+
maxTimeoutMs: positive(envInt(env, ENV_SHELL_MAX_TIMEOUT_MS), DEFAULT_MAX_TIMEOUT_MS),
|
|
59
|
+
maxCpuSec: positive(envInt(env, ENV_SHELL_MAX_CPU_SEC), DEFAULT_MAX_CPU_SEC),
|
|
60
|
+
maxOutputBytes: positive(envInt(env, ENV_SHELL_MAX_OUTPUT_BYTES), DEFAULT_MAX_OUTPUT_BYTES),
|
|
61
|
+
workdir,
|
|
62
|
+
root: resolveOrCwd(overrides.root ?? envString(env, ENV_SHELL_ROOT) ?? gitToplevelOr(workdir)),
|
|
63
|
+
// W880: run_code programs live under CELESTEA_HOME, never in the workspace.
|
|
64
|
+
programDir: overrides.programDir ?? workspaceSubdir(workdir, CELESTEA_RUN_CODE_DIR, { env }),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* W768: the sandbox config of ONE session — the session's workspace as cwd and
|
|
69
|
+
* root, the operator's limits unchanged. `null` scope = the env posture.
|
|
70
|
+
*/
|
|
71
|
+
export function sessionSandboxConfig(scope, env = process.env) {
|
|
72
|
+
if (scope === null)
|
|
73
|
+
return sandboxConfigFromEnv(env);
|
|
74
|
+
return sandboxConfigFromEnv(env, { workdir: scope.workspace, root: scope.workspace });
|
|
75
|
+
}
|
|
76
|
+
/** Materialize a config, filling defaults (tests pin explicit knobs). */
|
|
77
|
+
export function buildSandboxConfig(overrides = {}) {
|
|
78
|
+
const workdir = resolveOrCwd(overrides.workdir ?? process.cwd());
|
|
79
|
+
return {
|
|
80
|
+
timeoutMs: overrides.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
81
|
+
maxTimeoutMs: overrides.maxTimeoutMs ?? DEFAULT_MAX_TIMEOUT_MS,
|
|
82
|
+
maxCpuSec: overrides.maxCpuSec ?? DEFAULT_MAX_CPU_SEC,
|
|
83
|
+
maxOutputBytes: overrides.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES,
|
|
84
|
+
workdir,
|
|
85
|
+
root: resolveOrCwd(overrides.root ?? gitToplevelOr(workdir)),
|
|
86
|
+
// Explicit construction (tests / embeddings) keeps the historical in-workspace
|
|
87
|
+
// default; the ENV posture above is the one that uses CELESTEA_HOME.
|
|
88
|
+
programDir: overrides.programDir ?? join(workdir, ".celestea", "run-code"),
|
|
89
|
+
extraEnv: overrides.extraEnv ?? [],
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* `{program, args}` of the platform shell carrying exactly one command.
|
|
94
|
+
*
|
|
95
|
+
* W885: the decision moved into the injectable `resolveShell` ladder —
|
|
96
|
+
* POSIX answers the literal `/bin/sh -c <command>` (byte-identical to the
|
|
97
|
+
* pre-W885 code, asserted by `platform-exec.test.ts`), Windows walks
|
|
98
|
+
* gitbash > pwsh > cmd. A host with no usable shell now fails closed with a
|
|
99
|
+
* structured [ShellNotFoundError] instead of guessing `cmd.exe`; the
|
|
100
|
+
* `input` seam is what lets the win32 ladder be unit-tested on Linux.
|
|
101
|
+
*/
|
|
102
|
+
export function shellInvocation(command, input = {}) {
|
|
103
|
+
const shell = resolveShell(command, input);
|
|
104
|
+
return { program: shell.path, args: [...shell.argv] };
|
|
105
|
+
}
|
|
106
|
+
/** Allowlisted host env plus explicit operator additions (never `HOME`). */
|
|
107
|
+
export function sanitizedEnv(config, env = process.env) {
|
|
108
|
+
const out = {};
|
|
109
|
+
for (const name of ENV_ALLOWLIST) {
|
|
110
|
+
const value = env[name];
|
|
111
|
+
if (typeof value === "string" && value !== "")
|
|
112
|
+
out[name] = value;
|
|
113
|
+
}
|
|
114
|
+
for (const [name, value] of config.extraEnv)
|
|
115
|
+
out[name] = value;
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
/** Walk up from `start` looking for a git marker (dir `.git` or a worktree file). */
|
|
119
|
+
export function gitToplevelOr(start) {
|
|
120
|
+
const pinned = resolveOrCwd(start);
|
|
121
|
+
let current = pinned;
|
|
122
|
+
for (;;) {
|
|
123
|
+
if (statOrNull(join(current, ".git")) !== null)
|
|
124
|
+
return current;
|
|
125
|
+
const parent = dirname(current);
|
|
126
|
+
if (parent === current)
|
|
127
|
+
return pinned; // filesystem root: fully pinned
|
|
128
|
+
current = parent;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function resolveOrCwd(target) {
|
|
132
|
+
try {
|
|
133
|
+
return realpathSync(target);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return target;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function statOrNull(target) {
|
|
140
|
+
try {
|
|
141
|
+
return statSync(target);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function positive(value, fallback) {
|
|
148
|
+
return value !== undefined && value > 0 ? value : fallback;
|
|
149
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fake `Sandbox` — a scripted, in-memory implementation of the seam.
|
|
3
|
+
*
|
|
4
|
+
* Purpose: prove the seam is genuinely replaceable (ARCHITECTURE.md §7.4) and
|
|
5
|
+
* let the tool pipeline and the process registry be tested without spawning any
|
|
6
|
+
* real process: stdout/stderr are synthetic streams, exits are scripted, and
|
|
7
|
+
* `terminate()` can be made deliberately ignorable so the SIGKILL escalation
|
|
8
|
+
* path is observable. It is a test double, not a production provider.
|
|
9
|
+
*/
|
|
10
|
+
import { PassThrough, Readable } from "node:stream";
|
|
11
|
+
import type { Sandbox, SandboxChild, SandboxExit, SandboxMeta } from "@celestea/core";
|
|
12
|
+
import { type SandboxConfigOverrides } from "./config.js";
|
|
13
|
+
export interface FakeScript {
|
|
14
|
+
stdout?: readonly string[];
|
|
15
|
+
stderr?: readonly string[];
|
|
16
|
+
exitCode?: number | null;
|
|
17
|
+
/** ms before the child exits on its own; `Infinity` = never exits. */
|
|
18
|
+
exitAfterMs?: number;
|
|
19
|
+
/** true = `terminate()` is ignored (exercises the SIGKILL escalation). */
|
|
20
|
+
ignoresSigterm?: boolean;
|
|
21
|
+
}
|
|
22
|
+
export interface FakeSandboxOptions {
|
|
23
|
+
scripts?: readonly FakeScript[];
|
|
24
|
+
fallback?: FakeScript;
|
|
25
|
+
config?: SandboxConfigOverrides;
|
|
26
|
+
meta?: SandboxMeta;
|
|
27
|
+
}
|
|
28
|
+
export interface FakeSpawnRecord {
|
|
29
|
+
command: string;
|
|
30
|
+
workdir: string | undefined;
|
|
31
|
+
child: FakeChild;
|
|
32
|
+
}
|
|
33
|
+
/** Scripted child: synthetic pipes, scripted exit, observable signals. */
|
|
34
|
+
export declare class FakeChild implements SandboxChild {
|
|
35
|
+
readonly pid: number;
|
|
36
|
+
readonly stdin: PassThrough;
|
|
37
|
+
readonly stdout: Readable;
|
|
38
|
+
readonly stderr: Readable;
|
|
39
|
+
readonly written: string[];
|
|
40
|
+
termAttempts: number;
|
|
41
|
+
killAttempts: number;
|
|
42
|
+
private resolved;
|
|
43
|
+
private resolveWait;
|
|
44
|
+
private readonly waited;
|
|
45
|
+
private readonly script;
|
|
46
|
+
constructor(pid: number, script: FakeScript);
|
|
47
|
+
wait(): Promise<SandboxExit>;
|
|
48
|
+
terminate(): void;
|
|
49
|
+
kill(): void;
|
|
50
|
+
private settle;
|
|
51
|
+
}
|
|
52
|
+
export interface FakeSandbox extends Sandbox {
|
|
53
|
+
readonly spawns: FakeSpawnRecord[];
|
|
54
|
+
readonly runs: string[];
|
|
55
|
+
readonly lastChild: FakeChild | null;
|
|
56
|
+
}
|
|
57
|
+
export declare function createFakeSandbox(options?: FakeSandboxOptions): FakeSandbox;
|