@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
package/dist/plugin.js ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The tools plugin — `mount(ctx)` is the only installation path (nothing
3
+ * self-registers at runtime), and it publishes exactly three services:
4
+ *
5
+ * - `ToolRegistryService` → the assembled registry (schema → guard → execute);
6
+ * - `SandboxService` → the execution boundary `run_shell` orchestrates;
7
+ * - `ProcessRegistryService` → the session-scoped background process registry.
8
+ *
9
+ * The guard chain is mounted here because **order is security semantics**: the
10
+ * path whitelist must run before any tool execution, and `CELESTEA_TOOL_GUARD=0`
11
+ * is the only (explicit, documented) way to skip it.
12
+ *
13
+ * `run_code` (W255) is mounted here too, not in `builtinTools`: it needs a
14
+ * late-bound handle on the very registry it will dispatch sub-calls through,
15
+ * which only the assembly can bind — exactly like the runtime compose
16
+ * (`crates/runtime/src/tools.rs`). Pass `runCode: false` to leave it out.
17
+ */
18
+ import { definePlugin, SANDBOX_SERVICE, TOOL_REGISTRY_SERVICE, } from "@celestea/core";
19
+ import { builtinTools } from "./builtin.js";
20
+ import { mountProductionGuards } from "./guard/path-guard.js";
21
+ import { HttpTargetPolicy } from "./http/ssrf.js";
22
+ import { PROCESS_REGISTRY_SERVICE, ProcessRegistry } from "./process/registry.js";
23
+ import { ToolRegistryImpl } from "./registry.js";
24
+ import { sessionSandboxConfig } from "./sandbox/config.js";
25
+ import { selectSandbox } from "./sandbox/provider.js";
26
+ import { RegistryHandle, runCodeToolWithHandle } from "./tools/run-code.js";
27
+ export const TOOLS_PLUGIN_NAME = "celestea.tools";
28
+ /** Build the tool assembly without mounting it (compose roots / tests). */
29
+ export function assembleTools(options = {}) {
30
+ const env = options.env ?? process.env;
31
+ const grants = options.grants ?? {};
32
+ const scope = options.scope ?? null;
33
+ const processes = options.processes ?? new ProcessRegistry();
34
+ // W768: ONE scope feeds both halves of the boundary — the sandbox's cwd/root
35
+ // and the guard's writable workspace — so "where the shell starts" and "what
36
+ // the path tools may touch" are the same directory by construction.
37
+ const sandbox = options.sandbox ?? selectSandbox({ env, grants, config: sessionSandboxConfig(scope, env) });
38
+ const registry = new ToolRegistryImpl();
39
+ // W783: the injected question service must reach the DEFAULT tool set too, not
40
+ // only an explicitly supplied one — otherwise a host that mounts the service
41
+ // through `assembleTools` would silently lose the tool.
42
+ const tools = options.tools
43
+ ?? builtinTools({
44
+ sandbox,
45
+ processes,
46
+ http: httpOptions(env, grants),
47
+ // W884: the skill layers hang off the SAME session scope the sandbox and
48
+ // the path guard use (W768) — never the process cwd.
49
+ workspace: scope?.workspace ?? null,
50
+ env,
51
+ ...(options.questions === undefined ? {} : { questions: options.questions }),
52
+ ...(options.attachments === undefined ? {} : { attachments: options.attachments }),
53
+ ...(options.imageInputAllowed === undefined ? {} : { imageInputAllowed: options.imageInputAllowed }),
54
+ ...(options.model === undefined ? {} : { model: options.model }),
55
+ });
56
+ for (const tool of tools)
57
+ registry.register(tool);
58
+ const runCode = mountRunCode(registry, sandbox, options);
59
+ let guardMounted = false;
60
+ if (options.guard === null)
61
+ guardMounted = false;
62
+ else if (options.guard !== undefined) {
63
+ registry.addGuard(options.guard);
64
+ guardMounted = true;
65
+ }
66
+ else
67
+ guardMounted = mountProductionGuards(registry, env, grants, scope);
68
+ return { registry, sandbox, processes, guardMounted, runCode };
69
+ }
70
+ /** The `http_request` options of this assembly (grants merged into allow). */
71
+ export function httpOptions(env, grants) {
72
+ return { env, policy: HttpTargetPolicy.fromEnv(env, grants) };
73
+ }
74
+ /**
75
+ * Mount the W255 `run_code` tool: register it into [registry], then bind the
76
+ * handle to that same registry (the tool must live inside the registry it
77
+ * dispatches through). A caller-supplied `run_code` tool always wins.
78
+ */
79
+ function mountRunCode(registry, sandbox, options) {
80
+ if (options.runCode === false)
81
+ return null;
82
+ if (registry.get("run_code") !== undefined)
83
+ return null;
84
+ const mount = options.runCode ?? {};
85
+ const { tool, handle } = runCodeToolWithHandle({
86
+ sandbox,
87
+ ...(mount.config === undefined ? {} : { config: mount.config }),
88
+ ...(mount.events === undefined ? {} : { events: mount.events }),
89
+ });
90
+ registry.register(tool);
91
+ handle.set(registry);
92
+ return handle;
93
+ }
94
+ export function toolsPlugin(options = {}) {
95
+ return definePlugin(TOOLS_PLUGIN_NAME, (ctx) => {
96
+ const assembly = assembleTools(options);
97
+ ctx.provide(TOOL_REGISTRY_SERVICE, assembly.registry);
98
+ ctx.provide(SANDBOX_SERVICE, assembly.sandbox);
99
+ ctx.provide(PROCESS_REGISTRY_SERVICE, assembly.processes);
100
+ });
101
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Capped stream buffers for background processes (`crates/tools/src/process.rs`).
3
+ *
4
+ * A background process outlives the turn, so its output must not grow without
5
+ * bound: each stream keeps the LAST N bytes (older bytes are dropped and the
6
+ * truncation flag latched), `poll` returns a short tail, and the completion
7
+ * record keeps an even shorter tail with newline runs folded so the mailbox
8
+ * message stays compact.
9
+ */
10
+ /** Bytes kept per stream in the registry ring buffer (last N bytes win). */
11
+ export declare const MAX_STREAM_BUFFER: number;
12
+ /** Bytes returned by `poll` as `stdout_tail` / `stderr_tail`. */
13
+ export declare const TAIL_BYTES: number;
14
+ /** Bytes kept per stream in a completion record. */
15
+ export declare const COMPLETION_TAIL_BYTES = 1024;
16
+ /** Append-only ring buffer over bytes: keeps the newest `cap` bytes. */
17
+ export declare class RingBuffer {
18
+ private chunks;
19
+ private size;
20
+ private overflow;
21
+ private readonly cap;
22
+ constructor(cap?: number);
23
+ append(data: Buffer): void;
24
+ private dropOldest;
25
+ get truncated(): boolean;
26
+ get length(): number;
27
+ toBuffer(): Buffer;
28
+ /** The last `bytes` bytes, decoded as UTF-8 (partial sequences are lossy). */
29
+ tail(bytes: number): string;
30
+ }
31
+ /** Last [COMPLETION_TAIL_BYTES] of a stream with newline runs folded. */
32
+ export declare function completionTail(buffer: RingBuffer, bytes?: number): string;
33
+ export declare function foldNewlines(text: string): string;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Capped stream buffers for background processes (`crates/tools/src/process.rs`).
3
+ *
4
+ * A background process outlives the turn, so its output must not grow without
5
+ * bound: each stream keeps the LAST N bytes (older bytes are dropped and the
6
+ * truncation flag latched), `poll` returns a short tail, and the completion
7
+ * record keeps an even shorter tail with newline runs folded so the mailbox
8
+ * message stays compact.
9
+ */
10
+ /** Bytes kept per stream in the registry ring buffer (last N bytes win). */
11
+ export const MAX_STREAM_BUFFER = 512 * 1024;
12
+ /** Bytes returned by `poll` as `stdout_tail` / `stderr_tail`. */
13
+ export const TAIL_BYTES = 4 * 1024;
14
+ /** Bytes kept per stream in a completion record. */
15
+ export const COMPLETION_TAIL_BYTES = 1024;
16
+ /** Append-only ring buffer over bytes: keeps the newest `cap` bytes. */
17
+ export class RingBuffer {
18
+ chunks = [];
19
+ size = 0;
20
+ overflow = false;
21
+ cap;
22
+ constructor(cap = MAX_STREAM_BUFFER) {
23
+ this.cap = cap;
24
+ }
25
+ append(data) {
26
+ if (data.length >= this.cap) {
27
+ this.chunks = [data.subarray(data.length - this.cap)];
28
+ this.size = this.cap;
29
+ this.overflow = true;
30
+ return;
31
+ }
32
+ this.chunks.push(data);
33
+ this.size += data.length;
34
+ this.dropOldest();
35
+ }
36
+ dropOldest() {
37
+ while (this.size > this.cap) {
38
+ const head = this.chunks[0];
39
+ if (head === undefined)
40
+ return;
41
+ const excess = this.size - this.cap;
42
+ if (head.length <= excess) {
43
+ this.chunks.shift();
44
+ this.size -= head.length;
45
+ }
46
+ else {
47
+ this.chunks[0] = head.subarray(excess);
48
+ this.size -= excess;
49
+ }
50
+ this.overflow = true;
51
+ }
52
+ }
53
+ get truncated() {
54
+ return this.overflow;
55
+ }
56
+ get length() {
57
+ return this.size;
58
+ }
59
+ toBuffer() {
60
+ return Buffer.concat(this.chunks);
61
+ }
62
+ /** The last `bytes` bytes, decoded as UTF-8 (partial sequences are lossy). */
63
+ tail(bytes) {
64
+ const all = this.toBuffer();
65
+ return all.subarray(Math.max(all.length - bytes, 0)).toString("utf8");
66
+ }
67
+ }
68
+ /** Last [COMPLETION_TAIL_BYTES] of a stream with newline runs folded. */
69
+ export function completionTail(buffer, bytes = COMPLETION_TAIL_BYTES) {
70
+ return foldNewlines(buffer.tail(bytes));
71
+ }
72
+ export function foldNewlines(text) {
73
+ let out = "";
74
+ let previousWasNewline = false;
75
+ for (const ch of text) {
76
+ if (ch !== "\n") {
77
+ out += ch;
78
+ previousWasNewline = false;
79
+ continue;
80
+ }
81
+ if (!previousWasNewline)
82
+ out += ch;
83
+ previousWasNewline = true;
84
+ }
85
+ return out;
86
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Session-scoped registry of background processes (`crates/tools/src/process.rs`).
3
+ *
4
+ * A `run_shell(background: true)` spawn registers its detached child here.
5
+ * Entries survive across turns — that is the point: a server started in one
6
+ * turn stays controllable from later turns. Each child gets a reaper that
7
+ * drains stdout/stderr into capped ring buffers and records the exit exactly
8
+ * once. W6: a finished process is retained as a BOUNDED tombstone (most recent
9
+ * `MAX_TOMBSTONES`, at most `TOMBSTONE_TTL_MS`), so a `poll` AFTER exit still
10
+ * returns `{running:false, exit_code, signal, stdout_tail, stderr_tail}` instead
11
+ * of `unknown handle`; a CPU-cap kill is marked `cpu_exceeded`. For a **natural**
12
+ * exit the completion sink may fire once (a host CAN push a `[process] … exited
13
+ * …` message into a session mailbox, though this studio host does not wire one —
14
+ * `process_control(action=poll)` is the durable read). `kill`/`killAll` paths
15
+ * are deliberately silent: the caller already got `{killed: true}` back.
16
+ */
17
+ import type { SandboxChild } from "@celestea/core";
18
+ /** Well-known token for the process registry service in a Context. */
19
+ export declare const PROCESS_REGISTRY_SERVICE = "celestea.tools.ProcessRegistry";
20
+ /** Grace between SIGTERM and SIGKILL in `kill`. */
21
+ export declare const KILL_GRACE_MS = 1000;
22
+ /** Upper bound for the SIGKILL reap wait. */
23
+ export declare const KILL_WAIT_MS = 2000;
24
+ /** Upper bound for one stdin line write. */
25
+ export declare const STDIN_WRITE_TIMEOUT_MS = 5000;
26
+ /** W6: how many terminal records stay pollable (most recent wins). */
27
+ export declare const MAX_TOMBSTONES = 32;
28
+ /** W6: how long a terminal record stays pollable. */
29
+ export declare const TOMBSTONE_TTL_MS: number;
30
+ /** One natural-exit completion handed to the sink (W251 parity). */
31
+ export interface ProcessCompletion {
32
+ handle: string;
33
+ pid: number | null;
34
+ exit_code: number | null;
35
+ stdout_tail: string;
36
+ stderr_tail: string;
37
+ elapsed_ms: number;
38
+ }
39
+ export type CompletionSink = (completion: ProcessCompletion) => void;
40
+ export interface ProcessRegistryOptions {
41
+ killGraceMs?: number;
42
+ killWaitMs?: number;
43
+ maxStreamBuffer?: number;
44
+ tailBytes?: number;
45
+ /** W6: cap on retained terminal records (default [MAX_TOMBSTONES]). */
46
+ maxTombstones?: number;
47
+ /** W6: TTL of a terminal record (default [TOMBSTONE_TTL_MS]). */
48
+ tombstoneTtlMs?: number;
49
+ /** Clock (tests pin it). */
50
+ now?: () => number;
51
+ }
52
+ /** W6: per-insert facts the terminal record needs (the effective CPU cap). */
53
+ export interface ProcessInsertOptions {
54
+ /** Effective `RLIMIT_CPU` of this process; enables the `cpu_exceeded` marker. */
55
+ cpuSec?: number | null;
56
+ }
57
+ export interface ProcessHandle {
58
+ handle: string;
59
+ pid: number | null;
60
+ }
61
+ export declare class ProcessRegistry {
62
+ private readonly map;
63
+ /** W6: finished processes (bounded in count and age), still pollable. */
64
+ private readonly tombstones;
65
+ private readonly options;
66
+ private readonly now;
67
+ private nextHandle;
68
+ private completionSink;
69
+ constructor(options?: ProcessRegistryOptions);
70
+ /** Install (or clear) the natural-exit sink: one per registry, last wins. */
71
+ setCompletionSink(sink: CompletionSink | null): void;
72
+ /** Register a spawned child: takes over its pipes and starts the reaper. */
73
+ insert(child: SandboxChild, notify?: boolean, opts?: ProcessInsertOptions): ProcessHandle;
74
+ get size(): number;
75
+ get isEmpty(): boolean;
76
+ /** `process_control(action=poll)`: running flag, capped tails, exit code. */
77
+ poll(handle: string): Record<string, unknown>;
78
+ /** `process_control(action=kill)`: SIGTERM, grace, then SIGKILL. Silent. */
79
+ kill(handle: string): Promise<Record<string, unknown>>;
80
+ /** `process_control(action=stdin)`: write one line (content + newline). */
81
+ stdinLine(handle: string, line: string): Promise<Record<string, unknown>>;
82
+ private write;
83
+ /** Kill every still-registered child (shutdown path); idempotent. */
84
+ killAll(): void;
85
+ /** Alias of [killAll] for `Runtime.shutdown` call sites. */
86
+ dispose(): void;
87
+ private waitExited;
88
+ /** Reaper tail: record the exit once, tombstone it, then maybe notify. */
89
+ private finish;
90
+ /** A live tombstone, or undefined (evicting it once its TTL has passed). */
91
+ private tombstone;
92
+ /** W6: the terminal answer `poll` gives after a process has exited. */
93
+ private tombstonePoll;
94
+ /** W6: RLIMIT_CPU termination (SIGXCPU, or the SIGKILL that follows it). */
95
+ private cpuExceeded;
96
+ /** Evict terminal records past the TTL, then oldest-first past the count cap. */
97
+ private evictTombstones;
98
+ }
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Session-scoped registry of background processes (`crates/tools/src/process.rs`).
3
+ *
4
+ * A `run_shell(background: true)` spawn registers its detached child here.
5
+ * Entries survive across turns — that is the point: a server started in one
6
+ * turn stays controllable from later turns. Each child gets a reaper that
7
+ * drains stdout/stderr into capped ring buffers and records the exit exactly
8
+ * once. W6: a finished process is retained as a BOUNDED tombstone (most recent
9
+ * `MAX_TOMBSTONES`, at most `TOMBSTONE_TTL_MS`), so a `poll` AFTER exit still
10
+ * returns `{running:false, exit_code, signal, stdout_tail, stderr_tail}` instead
11
+ * of `unknown handle`; a CPU-cap kill is marked `cpu_exceeded`. For a **natural**
12
+ * exit the completion sink may fire once (a host CAN push a `[process] … exited
13
+ * …` message into a session mailbox, though this studio host does not wire one —
14
+ * `process_control(action=poll)` is the durable read). `kill`/`killAll` paths
15
+ * are deliberately silent: the caller already got `{killed: true}` back.
16
+ */
17
+ import { delay, TIMED_OUT, withTimeout } from "../sandbox/async.js";
18
+ import { completionTail, MAX_STREAM_BUFFER, RingBuffer, TAIL_BYTES, } from "./buffers.js";
19
+ /** Well-known token for the process registry service in a Context. */
20
+ export const PROCESS_REGISTRY_SERVICE = "celestea.tools.ProcessRegistry";
21
+ /** Grace between SIGTERM and SIGKILL in `kill`. */
22
+ export const KILL_GRACE_MS = 1_000;
23
+ /** Upper bound for the SIGKILL reap wait. */
24
+ export const KILL_WAIT_MS = 2_000;
25
+ /** Upper bound for one stdin line write. */
26
+ export const STDIN_WRITE_TIMEOUT_MS = 5_000;
27
+ /** Exit-poll interval while waiting for a kill to land. */
28
+ const POLL_INTERVAL_MS = 20;
29
+ /** W6: how many terminal records stay pollable (most recent wins). */
30
+ export const MAX_TOMBSTONES = 32;
31
+ /** W6: how long a terminal record stays pollable. */
32
+ export const TOMBSTONE_TTL_MS = 10 * 60 * 1_000;
33
+ class ProcState {
34
+ exited = false;
35
+ exitCode = null;
36
+ /** W6: terminating signal (SIGXCPU/SIGKILL on a CPU cap, …). */
37
+ signal = null;
38
+ /** kill/shutdown path: record the exit, never fire the completion sink. */
39
+ killPath = false;
40
+ writeChain = Promise.resolve();
41
+ stdout;
42
+ stderr;
43
+ spawnedAt = Date.now();
44
+ constructor(maxStreamBuffer) {
45
+ this.stdout = new RingBuffer(maxStreamBuffer);
46
+ this.stderr = new RingBuffer(maxStreamBuffer);
47
+ }
48
+ }
49
+ export class ProcessRegistry {
50
+ map = new Map();
51
+ /** W6: finished processes (bounded in count and age), still pollable. */
52
+ tombstones = new Map();
53
+ options;
54
+ now;
55
+ nextHandle = 0;
56
+ completionSink = null;
57
+ constructor(options = {}) {
58
+ this.options = {
59
+ killGraceMs: options.killGraceMs ?? KILL_GRACE_MS,
60
+ killWaitMs: options.killWaitMs ?? KILL_WAIT_MS,
61
+ maxStreamBuffer: options.maxStreamBuffer ?? MAX_STREAM_BUFFER,
62
+ tailBytes: options.tailBytes ?? TAIL_BYTES,
63
+ maxTombstones: options.maxTombstones ?? MAX_TOMBSTONES,
64
+ tombstoneTtlMs: options.tombstoneTtlMs ?? TOMBSTONE_TTL_MS,
65
+ };
66
+ this.now = options.now ?? Date.now;
67
+ }
68
+ /** Install (or clear) the natural-exit sink: one per registry, last wins. */
69
+ setCompletionSink(sink) {
70
+ this.completionSink = sink;
71
+ }
72
+ /** Register a spawned child: takes over its pipes and starts the reaper. */
73
+ insert(child, notify = true, opts = {}) {
74
+ const handle = `proc-${this.nextHandle}`;
75
+ this.nextHandle += 1;
76
+ const state = new ProcState(this.options.maxStreamBuffer);
77
+ const entry = { handle, pid: child.pid, child, state, notify, cpuSec: opts.cpuSec ?? null };
78
+ this.map.set(handle, entry);
79
+ child.stdout?.on("data", (chunk) => state.stdout.append(chunk));
80
+ child.stderr?.on("data", (chunk) => state.stderr.append(chunk));
81
+ void child.wait().then((exit) => this.finish(entry, exit));
82
+ return { handle, pid: child.pid };
83
+ }
84
+ get size() {
85
+ return this.map.size;
86
+ }
87
+ get isEmpty() {
88
+ return this.map.size === 0;
89
+ }
90
+ /** `process_control(action=poll)`: running flag, capped tails, exit code. */
91
+ poll(handle) {
92
+ const entry = this.map.get(handle);
93
+ if (entry !== undefined) {
94
+ const state = entry.state;
95
+ return {
96
+ ok: true,
97
+ handle: entry.handle,
98
+ pid: entry.pid,
99
+ running: !state.exited,
100
+ stdout_tail: state.stdout.tail(this.options.tailBytes),
101
+ stderr_tail: state.stderr.tail(this.options.tailBytes),
102
+ stdout_truncated: state.stdout.truncated,
103
+ stderr_truncated: state.stderr.truncated,
104
+ exit_code: state.exited ? state.exitCode : null,
105
+ };
106
+ }
107
+ // W6: a finished process stays pollable from its bounded tombstone.
108
+ const done = this.tombstone(handle);
109
+ return done === undefined ? unknownHandle(handle) : this.tombstonePoll(done);
110
+ }
111
+ /** `process_control(action=kill)`: SIGTERM, grace, then SIGKILL. Silent. */
112
+ async kill(handle) {
113
+ const entry = this.map.get(handle);
114
+ if (entry === undefined) {
115
+ const done = this.tombstone(handle);
116
+ return done === undefined ? unknownHandle(handle) : { ok: false, error: exitedMessage(done) };
117
+ }
118
+ entry.state.killPath = true;
119
+ entry.child.terminate();
120
+ if (!(await this.waitExited(entry, this.options.killGraceMs))) {
121
+ entry.child.kill();
122
+ await this.waitExited(entry, this.options.killWaitMs);
123
+ }
124
+ return { ok: true, killed: true, handle: entry.handle };
125
+ }
126
+ /** `process_control(action=stdin)`: write one line (content + newline). */
127
+ async stdinLine(handle, line) {
128
+ const entry = this.map.get(handle);
129
+ if (entry === undefined) {
130
+ const done = this.tombstone(handle);
131
+ return done === undefined ? unknownHandle(handle) : { ok: false, error: `process ${handle} already exited` };
132
+ }
133
+ if (entry.state.exited)
134
+ return { ok: false, error: `process ${handle} already exited` };
135
+ if (entry.child.stdin === null)
136
+ return { ok: false, error: `process ${handle} stdin unavailable` };
137
+ const payload = `${line}\n`;
138
+ // Serialize writes per process: two concurrent stdin actions must not
139
+ // interleave and corrupt a line-oriented child.
140
+ const chained = entry.state.writeChain.then(() => this.write(entry, payload));
141
+ entry.state.writeChain = chained.catch(() => undefined);
142
+ const settled = await withTimeout(chained.then(() => ({ ok: true }), (e) => ({ ok: false, error: `stdin write failed: ${e instanceof Error ? e.message : String(e)}` })), STDIN_WRITE_TIMEOUT_MS);
143
+ if (settled === TIMED_OUT)
144
+ return { ok: false, error: "stdin write timed out (5s)" };
145
+ if (!settled.ok)
146
+ return { ok: false, error: settled.error };
147
+ return { ok: true, handle: entry.handle, written: Buffer.byteLength(payload) };
148
+ }
149
+ write(entry, payload) {
150
+ const stdin = entry.child.stdin;
151
+ if (stdin === null)
152
+ return Promise.resolve();
153
+ return new Promise((resolve, reject) => {
154
+ stdin.write(payload, (error) => (error === null || error === undefined ? resolve() : reject(error)));
155
+ });
156
+ }
157
+ /** Kill every still-registered child (shutdown path); idempotent. */
158
+ killAll() {
159
+ for (const entry of [...this.map.values()]) {
160
+ entry.state.killPath = true;
161
+ entry.child.kill();
162
+ }
163
+ this.map.clear();
164
+ this.tombstones.clear();
165
+ }
166
+ /** Alias of [killAll] for `Runtime.shutdown` call sites. */
167
+ dispose() {
168
+ this.killAll();
169
+ }
170
+ async waitExited(entry, windowMs) {
171
+ const deadline = Date.now() + windowMs;
172
+ while (Date.now() < deadline) {
173
+ if (entry.state.exited)
174
+ return true;
175
+ await delay(POLL_INTERVAL_MS);
176
+ }
177
+ return entry.state.exited;
178
+ }
179
+ /** Reaper tail: record the exit once, tombstone it, then maybe notify. */
180
+ finish(entry, exit) {
181
+ const state = entry.state;
182
+ if (state.exited)
183
+ return;
184
+ state.exited = true;
185
+ state.exitCode = exit.code;
186
+ state.signal = exit.signal ?? null;
187
+ this.map.delete(entry.handle);
188
+ // W6: keep a bounded terminal record so poll-after-exit still answers.
189
+ this.tombstones.set(entry.handle, {
190
+ handle: entry.handle,
191
+ pid: entry.pid,
192
+ exitCode: state.exitCode,
193
+ signal: state.signal,
194
+ cpuSec: entry.cpuSec,
195
+ stdoutTail: state.stdout.tail(this.options.tailBytes),
196
+ stderrTail: state.stderr.tail(this.options.tailBytes),
197
+ stdoutTruncated: state.stdout.truncated,
198
+ stderrTruncated: state.stderr.truncated,
199
+ spawnedAt: state.spawnedAt,
200
+ finishedAt: this.now(),
201
+ killPath: state.killPath,
202
+ });
203
+ this.evictTombstones();
204
+ if (!entry.notify || state.killPath)
205
+ return;
206
+ this.completionSink?.({
207
+ handle: entry.handle,
208
+ pid: entry.pid,
209
+ exit_code: exit.code,
210
+ stdout_tail: completionTail(state.stdout),
211
+ stderr_tail: completionTail(state.stderr),
212
+ elapsed_ms: this.now() - state.spawnedAt,
213
+ });
214
+ }
215
+ /** A live tombstone, or undefined (evicting it once its TTL has passed). */
216
+ tombstone(handle) {
217
+ const done = this.tombstones.get(handle);
218
+ if (done === undefined)
219
+ return undefined;
220
+ if (this.now() - done.finishedAt > this.options.tombstoneTtlMs) {
221
+ this.tombstones.delete(handle);
222
+ return undefined;
223
+ }
224
+ return done;
225
+ }
226
+ /** W6: the terminal answer `poll` gives after a process has exited. */
227
+ tombstonePoll(done) {
228
+ const cpuExceeded = this.cpuExceeded(done);
229
+ return {
230
+ ok: true,
231
+ handle: done.handle,
232
+ pid: done.pid,
233
+ running: false,
234
+ stdout_tail: done.stdoutTail,
235
+ stderr_tail: done.stderrTail,
236
+ stdout_truncated: done.stdoutTruncated,
237
+ stderr_truncated: done.stderrTruncated,
238
+ exit_code: done.exitCode,
239
+ signal: done.signal,
240
+ ...(cpuExceeded ? { cpu_exceeded: true, message: `CPU time limit ${done.cpuSec}s exceeded` } : {}),
241
+ };
242
+ }
243
+ /** W6: RLIMIT_CPU termination (SIGXCPU, or the SIGKILL that follows it). */
244
+ cpuExceeded(done) {
245
+ if (done.killPath || done.cpuSec === null)
246
+ return false;
247
+ return done.signal === "SIGXCPU" || done.signal === "SIGKILL";
248
+ }
249
+ /** Evict terminal records past the TTL, then oldest-first past the count cap. */
250
+ evictTombstones() {
251
+ const now = this.now();
252
+ for (const [handle, done] of [...this.tombstones]) {
253
+ if (now - done.finishedAt > this.options.tombstoneTtlMs)
254
+ this.tombstones.delete(handle);
255
+ }
256
+ while (this.tombstones.size > this.options.maxTombstones) {
257
+ let oldest = null;
258
+ let oldestAt = Infinity;
259
+ for (const [handle, done] of this.tombstones) {
260
+ if (done.finishedAt < oldestAt) {
261
+ oldestAt = done.finishedAt;
262
+ oldest = handle;
263
+ }
264
+ }
265
+ if (oldest === null)
266
+ break;
267
+ this.tombstones.delete(oldest);
268
+ }
269
+ }
270
+ }
271
+ function unknownHandle(handle) {
272
+ return { ok: false, error: `unknown handle: ${handle}` };
273
+ }
274
+ /** W6: a clear terminal error for a handle whose process has already exited. */
275
+ function exitedMessage(done) {
276
+ const tail = done.exitCode === null
277
+ ? done.signal === null
278
+ ? "no exit status"
279
+ : `signal=${done.signal}`
280
+ : `exit_code=${done.exitCode}`;
281
+ return `process ${done.handle} already exited (${tail})`;
282
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * `ToolRegistryImpl` — the dispatch pipeline of the tool seam.
3
+ *
4
+ * One dispatch runs four stages, in this order (a hard pipeline, not a
5
+ * convention):
6
+ * 1. **schema** — `args` are validated against `tool.spec().parameters`
7
+ * (`toolargs: code=schema …`, the tool never runs);
8
+ * 2. **guard** — the guard chain runs in registration order and the FIRST
9
+ * non-`Allow` decision short-circuits (`denied: toolguard: …`) — a later
10
+ * `Allow` never un-denies an earlier `Deny`;
11
+ * 3. **execute** — through `executeWith` when the tool overrides it, else
12
+ * `execute(args)`;
13
+ * 4. **structure** — the result is a `ToolOutput`: canonical `value`, an
14
+ * optional authored `render`, captured `error`, and the guard `decision`
15
+ * as a first-class field.
16
+ *
17
+ * Errors are captured, never thrown across the seam (parity:
18
+ * `crates/tools/src/registry.rs`).
19
+ *
20
+ * **The verdict never lies (W738 P1)**: `decision` describes what the seam did
21
+ * with the call, so a call the seam REFUSED to run (unknown tool, schema
22
+ * rejection) is a `deny`, never an `allow` — an `allow` there would tell the
23
+ * caller (and the audit log) that a rejected call passed every check. A tool that
24
+ * did run and then failed keeps `allow`: the guards really did allow it and the
25
+ * verdict is not a success flag.
26
+ */
27
+ import type { Tool, ToolGuard, ToolInput, ToolOutput, ToolRegistry, ToolSpec } from "@celestea/core";
28
+ export declare class ToolRegistryImpl implements ToolRegistry {
29
+ private readonly tools;
30
+ private readonly guards;
31
+ register(tool: Tool): void;
32
+ addGuard(guard: ToolGuard): void;
33
+ get(name: string): Tool | undefined;
34
+ /** Registered tool names, in registration order (`schemas()` sorts instead). */
35
+ names(): string[];
36
+ /** The guard chain, in evaluation order (diagnostics / compose assertions). */
37
+ guardChain(): readonly ToolGuard[];
38
+ schemas(): ToolSpec[];
39
+ dispatch(input: ToolInput): Promise<ToolOutput>;
40
+ private runTool;
41
+ private runGuards;
42
+ }
43
+ /** Registry seeded with tools + guards (guards run in the given order). */
44
+ export declare function createToolRegistry(tools?: readonly Tool[], guards?: readonly ToolGuard[]): ToolRegistryImpl;
45
+ /**
46
+ * Best-effort human-readable rendering of a successful result (legacy
47
+ * `human_render`): a `{stdout, stderr, exit_code}` object condenses to a stream
48
+ * summary; plain text and everything else keep the generic value view (`null`).
49
+ */
50
+ export declare function humanRender(value: unknown): string | null;
51
+ /** Stamp a guard denial reason with the contract prefix (compose-time helper). */
52
+ export declare function guardDenyReason(code: string, message: string): string;