@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.
Files changed (140) hide show
  1. package/LICENSE +21 -0
  2. package/dist/args.d.ts +15 -0
  3. package/dist/args.js +55 -0
  4. package/dist/attachments/image-header.d.ts +28 -0
  5. package/dist/attachments/image-header.js +148 -0
  6. package/dist/attachments/store.d.ts +56 -0
  7. package/dist/attachments/store.js +148 -0
  8. package/dist/browser/cdp.d.ts +162 -0
  9. package/dist/browser/cdp.js +269 -0
  10. package/dist/browser/launch.d.ts +92 -0
  11. package/dist/browser/launch.js +232 -0
  12. package/dist/browser/memory-guard.d.ts +55 -0
  13. package/dist/browser/memory-guard.js +210 -0
  14. package/dist/browser/session.d.ts +146 -0
  15. package/dist/browser/session.js +371 -0
  16. package/dist/browser/snapshot.d.ts +69 -0
  17. package/dist/browser/snapshot.js +163 -0
  18. package/dist/browser/types.d.ts +51 -0
  19. package/dist/browser/types.js +8 -0
  20. package/dist/builtin.d.ts +54 -0
  21. package/dist/builtin.js +77 -0
  22. package/dist/desc.d.ts +20 -0
  23. package/dist/desc.js +22 -0
  24. package/dist/disclosure.d.ts +113 -0
  25. package/dist/disclosure.js +141 -0
  26. package/dist/env.d.ts +10 -0
  27. package/dist/env.js +33 -0
  28. package/dist/errors.d.ts +25 -0
  29. package/dist/errors.js +60 -0
  30. package/dist/exposure.d.ts +145 -0
  31. package/dist/exposure.js +244 -0
  32. package/dist/fn-tool.d.ts +14 -0
  33. package/dist/fn-tool.js +14 -0
  34. package/dist/fs/file-io.d.ts +78 -0
  35. package/dist/fs/file-io.js +239 -0
  36. package/dist/guard/path-guard.d.ts +144 -0
  37. package/dist/guard/path-guard.js +289 -0
  38. package/dist/guard/paths.d.ts +35 -0
  39. package/dist/guard/paths.js +100 -0
  40. package/dist/http/errors.d.ts +24 -0
  41. package/dist/http/errors.js +64 -0
  42. package/dist/http/headers.d.ts +19 -0
  43. package/dist/http/headers.js +62 -0
  44. package/dist/http/redirects.d.ts +31 -0
  45. package/dist/http/redirects.js +76 -0
  46. package/dist/http/ssrf.d.ts +105 -0
  47. package/dist/http/ssrf.js +272 -0
  48. package/dist/http/transport.d.ts +50 -0
  49. package/dist/http/transport.js +130 -0
  50. package/dist/index.d.ts +114 -0
  51. package/dist/index.js +129 -0
  52. package/dist/memory/log.d.ts +75 -0
  53. package/dist/memory/log.js +157 -0
  54. package/dist/memory/store.d.ts +47 -0
  55. package/dist/memory/store.js +61 -0
  56. package/dist/platform/exec.d.ts +79 -0
  57. package/dist/platform/exec.js +218 -0
  58. package/dist/platform/index.d.ts +12 -0
  59. package/dist/platform/index.js +12 -0
  60. package/dist/platform/paths.d.ts +51 -0
  61. package/dist/platform/paths.js +60 -0
  62. package/dist/platform/quote.d.ts +72 -0
  63. package/dist/platform/quote.js +102 -0
  64. package/dist/plugin.d.ts +96 -0
  65. package/dist/plugin.js +101 -0
  66. package/dist/process/buffers.d.ts +33 -0
  67. package/dist/process/buffers.js +86 -0
  68. package/dist/process/registry.d.ts +98 -0
  69. package/dist/process/registry.js +282 -0
  70. package/dist/registry.d.ts +52 -0
  71. package/dist/registry.js +161 -0
  72. package/dist/run-code/broker.d.ts +68 -0
  73. package/dist/run-code/broker.js +465 -0
  74. package/dist/run-code/limits.d.ts +69 -0
  75. package/dist/run-code/limits.js +88 -0
  76. package/dist/run-code/lines.d.ts +69 -0
  77. package/dist/run-code/lines.js +199 -0
  78. package/dist/run-code/sdk-ts.d.ts +34 -0
  79. package/dist/run-code/sdk-ts.js +276 -0
  80. package/dist/run-code/sdk.d.ts +39 -0
  81. package/dist/run-code/sdk.js +294 -0
  82. package/dist/sandbox/async.d.ts +10 -0
  83. package/dist/sandbox/async.js +26 -0
  84. package/dist/sandbox/bwrap-argv.d.ts +62 -0
  85. package/dist/sandbox/bwrap-argv.js +113 -0
  86. package/dist/sandbox/bwrap.d.ts +94 -0
  87. package/dist/sandbox/bwrap.js +159 -0
  88. package/dist/sandbox/child.d.ts +38 -0
  89. package/dist/sandbox/child.js +98 -0
  90. package/dist/sandbox/config.d.ts +89 -0
  91. package/dist/sandbox/config.js +149 -0
  92. package/dist/sandbox/fake-sandbox.d.ts +57 -0
  93. package/dist/sandbox/fake-sandbox.js +110 -0
  94. package/dist/sandbox/launch.d.ts +51 -0
  95. package/dist/sandbox/launch.js +134 -0
  96. package/dist/sandbox/limits.d.ts +63 -0
  97. package/dist/sandbox/limits.js +113 -0
  98. package/dist/sandbox/probe.d.ts +46 -0
  99. package/dist/sandbox/probe.js +102 -0
  100. package/dist/sandbox/provider.d.ts +83 -0
  101. package/dist/sandbox/provider.js +126 -0
  102. package/dist/sandbox/rlimit.d.ts +60 -0
  103. package/dist/sandbox/rlimit.js +76 -0
  104. package/dist/sandbox/seccomp.d.ts +48 -0
  105. package/dist/sandbox/seccomp.js +115 -0
  106. package/dist/sandbox/userspace.d.ts +65 -0
  107. package/dist/sandbox/userspace.js +107 -0
  108. package/dist/sandbox/workdir.d.ts +13 -0
  109. package/dist/sandbox/workdir.js +44 -0
  110. package/dist/schema.d.ts +20 -0
  111. package/dist/schema.js +135 -0
  112. package/dist/testing/platform-gates.d.ts +54 -0
  113. package/dist/testing/platform-gates.js +62 -0
  114. package/dist/tool-failure.d.ts +13 -0
  115. package/dist/tool-failure.js +19 -0
  116. package/dist/tools/ask-user.d.ts +32 -0
  117. package/dist/tools/ask-user.js +145 -0
  118. package/dist/tools/browser.d.ts +24 -0
  119. package/dist/tools/browser.js +132 -0
  120. package/dist/tools/http-request.d.ts +31 -0
  121. package/dist/tools/http-request.js +117 -0
  122. package/dist/tools/list-dir.d.ts +9 -0
  123. package/dist/tools/list-dir.js +45 -0
  124. package/dist/tools/load-skill.d.ts +37 -0
  125. package/dist/tools/load-skill.js +76 -0
  126. package/dist/tools/memory.d.ts +46 -0
  127. package/dist/tools/memory.js +131 -0
  128. package/dist/tools/process-control.d.ts +14 -0
  129. package/dist/tools/process-control.js +49 -0
  130. package/dist/tools/read-file.d.ts +11 -0
  131. package/dist/tools/read-file.js +81 -0
  132. package/dist/tools/read-image.d.ts +33 -0
  133. package/dist/tools/read-image.js +144 -0
  134. package/dist/tools/run-code.d.ts +48 -0
  135. package/dist/tools/run-code.js +115 -0
  136. package/dist/tools/run-shell.d.ts +22 -0
  137. package/dist/tools/run-shell.js +81 -0
  138. package/dist/tools/write-file.d.ts +8 -0
  139. package/dist/tools/write-file.js +31 -0
  140. 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;