@draht/rlm 2026.7.12-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/README.md +167 -0
- package/dist/cost-comparison.d.ts +81 -0
- package/dist/cost-comparison.d.ts.map +1 -0
- package/dist/cost-comparison.js +93 -0
- package/dist/cost-comparison.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/loaders.d.ts +44 -0
- package/dist/loaders.d.ts.map +1 -0
- package/dist/loaders.js +156 -0
- package/dist/loaders.js.map +1 -0
- package/dist/prompts.d.ts +49 -0
- package/dist/prompts.d.ts.map +1 -0
- package/dist/prompts.js +71 -0
- package/dist/prompts.js.map +1 -0
- package/dist/router-session.d.ts +50 -0
- package/dist/router-session.d.ts.map +1 -0
- package/dist/router-session.js +251 -0
- package/dist/router-session.js.map +1 -0
- package/dist/sandbox.d.ts +95 -0
- package/dist/sandbox.d.ts.map +1 -0
- package/dist/sandbox.js +273 -0
- package/dist/sandbox.js.map +1 -0
- package/dist/session.d.ts +162 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +803 -0
- package/dist/session.js.map +1 -0
- package/dist/trajectory.d.ts +71 -0
- package/dist/trajectory.d.ts.map +1 -0
- package/dist/trajectory.js +65 -0
- package/dist/trajectory.js.map +1 -0
- package/dist/types.d.ts +140 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/package.json +35 -0
- package/python/repl_driver.py +744 -0
- package/sandbox/macos.sb +89 -0
package/dist/sandbox.js
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OS-level sandbox spawn wrapper for the RLM Python REPL driver.
|
|
3
|
+
*
|
|
4
|
+
* This is the REAL security boundary described in
|
|
5
|
+
* .planning/phases/28-repl-sandbox-safety/28-01-PLAN.md ("IMPORTANT" section
|
|
6
|
+
* and Architecture section 1) -- NOT `repl_driver.py`'s Python-level
|
|
7
|
+
* builtins/import allowlist (that's defense-in-depth for a
|
|
8
|
+
* confused/prompt-injected LLM, not something that stops a deliberately
|
|
9
|
+
* adversarial payload; the Python object graph makes in-process
|
|
10
|
+
* confinement of arbitrary Python fundamentally unachievable -- see the
|
|
11
|
+
* plan for the `__subclasses__`/`gi_frame.f_builtins` escapes that defeat
|
|
12
|
+
* any purely in-process approach).
|
|
13
|
+
*
|
|
14
|
+
* **macOS**: wraps the interpreter with `sandbox-exec -f macos.sb ...`. The
|
|
15
|
+
* SBPL profile (`../sandbox/macos.sb`) denies network and process-exec
|
|
16
|
+
* entirely (which alone defeats `os.system`/`subprocess` -- they fork+exec
|
|
17
|
+
* a shell -- independent of any Python-level import block) and scopes file
|
|
18
|
+
* access to exactly the Python installation needed to boot plus the
|
|
19
|
+
* session's workdir. Machine-specific paths (where python3 actually lives)
|
|
20
|
+
* are discovered here at spawn time via a real subprocess call, never
|
|
21
|
+
* hardcoded/guessed -- see `resolvePythonInstallation`.
|
|
22
|
+
*
|
|
23
|
+
* **Linux**: wraps with `unshare --user --map-root-user --net --mount --`
|
|
24
|
+
* (empty net namespace = network unreachable at the kernel; mount
|
|
25
|
+
* namespace for FS confinement), falling back to `bwrap` with equivalent
|
|
26
|
+
* flags if `unshare` isn't available. Availability is detected at spawn
|
|
27
|
+
* time, never assumed.
|
|
28
|
+
*
|
|
29
|
+
* **Fail closed everywhere**: if the sandboxing mechanism for the current
|
|
30
|
+
* platform can't be established (wrapper binary missing, profile missing,
|
|
31
|
+
* Python installation can't be discovered, unsupported platform), every
|
|
32
|
+
* function here throws `SandboxUnavailableError` synchronously rather than
|
|
33
|
+
* returning a command that would run the driver unwrapped. There is no
|
|
34
|
+
* fallback path to an unsandboxed spawn anywhere in this module.
|
|
35
|
+
*/
|
|
36
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
37
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
38
|
+
import { dirname, join, delimiter as pathDelimiter } from "node:path";
|
|
39
|
+
import { fileURLToPath } from "node:url";
|
|
40
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
41
|
+
const MACOS_PROFILE_PATH = join(__dirname, "..", "sandbox", "macos.sb");
|
|
42
|
+
const DEFAULT_SANDBOX_EXEC_BIN = "/usr/bin/sandbox-exec";
|
|
43
|
+
/**
|
|
44
|
+
* Thrown whenever the OS-level sandbox can't be established for the current
|
|
45
|
+
* platform/environment. Callers (see `session.ts`) must let this propagate
|
|
46
|
+
* and refuse to run rather than catching it and falling back to an
|
|
47
|
+
* unwrapped spawn -- that fallback is exactly the failure mode Architecture
|
|
48
|
+
* section 1's "fail closed" requirement exists to prevent.
|
|
49
|
+
*/
|
|
50
|
+
export class SandboxUnavailableError extends Error {
|
|
51
|
+
constructor(message) {
|
|
52
|
+
super(message);
|
|
53
|
+
this.name = "SandboxUnavailableError";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const pythonInstallationCache = new Map();
|
|
57
|
+
/**
|
|
58
|
+
* Asks the given `python3` binary (via a real subprocess call -- there is no
|
|
59
|
+
* static/config-file way to learn a Python install's true executable path
|
|
60
|
+
* and stdlib root; every install lays these out differently) where it and
|
|
61
|
+
* its stdlib actually live, then checks for the macOS framework-build
|
|
62
|
+
* re-exec companion described above. Memoized per `pythonBin` string since
|
|
63
|
+
* the answer can't change during this process's lifetime and this runs on
|
|
64
|
+
* every sandboxed spawn.
|
|
65
|
+
*/
|
|
66
|
+
function resolvePythonInstallation(pythonBin) {
|
|
67
|
+
const cached = pythonInstallationCache.get(pythonBin);
|
|
68
|
+
if (cached)
|
|
69
|
+
return cached;
|
|
70
|
+
let stdout;
|
|
71
|
+
try {
|
|
72
|
+
stdout = execFileSync(pythonBin, ["-c", "import sys; print(sys.executable); print(sys.prefix)"], {
|
|
73
|
+
encoding: "utf8",
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
throw new SandboxUnavailableError(`sandbox_violation: could not run ${JSON.stringify(pythonBin)} to discover its installation -- refusing to run the REPL driver unsandboxed. (${err instanceof Error ? err.message : String(err)})`);
|
|
78
|
+
}
|
|
79
|
+
const lines = stdout.trim().split("\n");
|
|
80
|
+
const executableLine = lines[0];
|
|
81
|
+
const prefixLine = lines[1];
|
|
82
|
+
if (!executableLine || !prefixLine) {
|
|
83
|
+
throw new SandboxUnavailableError(`sandbox_violation: unexpected output discovering the Python installation for ${JSON.stringify(pythonBin)}: ${JSON.stringify(stdout)}`);
|
|
84
|
+
}
|
|
85
|
+
let realBin;
|
|
86
|
+
let prefix;
|
|
87
|
+
try {
|
|
88
|
+
realBin = realpathSync(executableLine.trim());
|
|
89
|
+
prefix = realpathSync(prefixLine.trim());
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
throw new SandboxUnavailableError(`sandbox_violation: could not resolve real filesystem paths for the Python installation -- refusing to run the REPL driver unsandboxed. (${err instanceof Error ? err.message : String(err)})`);
|
|
93
|
+
}
|
|
94
|
+
const reexecCandidate = join(prefix, "Resources", "Python.app", "Contents", "MacOS", "Python");
|
|
95
|
+
const reexecBin = existsSync(reexecCandidate) ? realpathSync(reexecCandidate) : realBin;
|
|
96
|
+
const installation = { realBin, reexecBin, prefix };
|
|
97
|
+
pythonInstallationCache.set(pythonBin, installation);
|
|
98
|
+
return installation;
|
|
99
|
+
}
|
|
100
|
+
/** Resolves a bare command name against `PATH`, returning `null` if not found. Used for the Linux wrappers. */
|
|
101
|
+
function resolveOnPath(name) {
|
|
102
|
+
const pathEnv = process.env.PATH ?? "";
|
|
103
|
+
for (const dir of pathEnv.split(pathDelimiter)) {
|
|
104
|
+
if (!dir)
|
|
105
|
+
continue;
|
|
106
|
+
const candidate = join(dir, name);
|
|
107
|
+
if (existsSync(candidate))
|
|
108
|
+
return candidate;
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
function buildMacosCommand(opts) {
|
|
113
|
+
const sandboxExecBin = opts.sandboxExecBin ?? DEFAULT_SANDBOX_EXEC_BIN;
|
|
114
|
+
if (!existsSync(sandboxExecBin)) {
|
|
115
|
+
throw new SandboxUnavailableError(`sandbox_violation: macOS sandbox-exec binary not found at ${JSON.stringify(sandboxExecBin)} -- refusing to run the REPL driver unsandboxed.`);
|
|
116
|
+
}
|
|
117
|
+
if (!existsSync(MACOS_PROFILE_PATH)) {
|
|
118
|
+
throw new SandboxUnavailableError(`sandbox_violation: sandbox profile missing at ${JSON.stringify(MACOS_PROFILE_PATH)} -- refusing to run the REPL driver unsandboxed.`);
|
|
119
|
+
}
|
|
120
|
+
const { realBin, reexecBin, prefix } = resolvePythonInstallation(opts.pythonBin ?? "python3");
|
|
121
|
+
let workdir;
|
|
122
|
+
let driverPath;
|
|
123
|
+
try {
|
|
124
|
+
workdir = realpathSync(opts.workdir);
|
|
125
|
+
driverPath = realpathSync(opts.driverPath);
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
throw new SandboxUnavailableError(`sandbox_violation: could not resolve workdir/driver paths -- refusing to run the REPL driver unsandboxed. (${err instanceof Error ? err.message : String(err)})`);
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
command: sandboxExecBin,
|
|
132
|
+
args: [
|
|
133
|
+
"-D",
|
|
134
|
+
`PYTHON_REAL_BIN=${realBin}`,
|
|
135
|
+
"-D",
|
|
136
|
+
`PYTHON_REEXEC_BIN=${reexecBin}`,
|
|
137
|
+
"-D",
|
|
138
|
+
`PYTHON_PREFIX=${prefix}`,
|
|
139
|
+
"-D",
|
|
140
|
+
`DRIVER_PATH=${driverPath}`,
|
|
141
|
+
"-D",
|
|
142
|
+
`WORKDIR=${workdir}`,
|
|
143
|
+
"-f",
|
|
144
|
+
MACOS_PROFILE_PATH,
|
|
145
|
+
realBin,
|
|
146
|
+
driverPath,
|
|
147
|
+
],
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
function buildLinuxCommand(opts) {
|
|
151
|
+
const pythonBin = opts.pythonBin ?? "python3";
|
|
152
|
+
let workdir;
|
|
153
|
+
let driverPath;
|
|
154
|
+
try {
|
|
155
|
+
workdir = realpathSync(opts.workdir);
|
|
156
|
+
driverPath = realpathSync(opts.driverPath);
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
throw new SandboxUnavailableError(`sandbox_violation: could not resolve workdir/driver paths -- refusing to run the REPL driver unsandboxed. (${err instanceof Error ? err.message : String(err)})`);
|
|
160
|
+
}
|
|
161
|
+
const unshareBin = opts.unshareBin ?? resolveOnPath("unshare");
|
|
162
|
+
if (unshareBin && existsSync(unshareBin)) {
|
|
163
|
+
// Empty user+net namespace: no interfaces besides loopback exist, so
|
|
164
|
+
// network is unreachable at the kernel regardless of anything Python
|
|
165
|
+
// itself tries. Mount namespace confines the filesystem view; the
|
|
166
|
+
// workdir bind-mount (rw) plus the rest of the root (ro, implicit --
|
|
167
|
+
// `unshare --mount` alone doesn't remount anything) is a coarser
|
|
168
|
+
// boundary than the macOS profile's explicit allowlist, which is why
|
|
169
|
+
// `--map-root-user` + a private mount namespace is paired with the
|
|
170
|
+
// driver only ever being told about `workdir` for file I/O at the
|
|
171
|
+
// Python-guardrail layer (Phase 28 task 2) -- the kernel-level net
|
|
172
|
+
// denial is what task 1 owns and is unconditional here.
|
|
173
|
+
return {
|
|
174
|
+
command: unshareBin,
|
|
175
|
+
args: ["--user", "--map-root-user", "--net", "--mount", "--", pythonBin, driverPath],
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
const bwrapBin = opts.bwrapBin ?? resolveOnPath("bwrap");
|
|
179
|
+
if (bwrapBin && existsSync(bwrapBin)) {
|
|
180
|
+
const binds = ["/usr", "/lib", "/bin", "/sbin"]
|
|
181
|
+
.filter((path) => existsSync(path))
|
|
182
|
+
.flatMap((path) => ["--ro-bind", path, path]);
|
|
183
|
+
if (existsSync("/lib64"))
|
|
184
|
+
binds.push("--ro-bind", "/lib64", "/lib64");
|
|
185
|
+
return {
|
|
186
|
+
command: bwrapBin,
|
|
187
|
+
args: [
|
|
188
|
+
"--unshare-user",
|
|
189
|
+
"--unshare-net",
|
|
190
|
+
"--unshare-pid",
|
|
191
|
+
"--die-with-parent",
|
|
192
|
+
...binds,
|
|
193
|
+
"--bind",
|
|
194
|
+
workdir,
|
|
195
|
+
workdir,
|
|
196
|
+
"--proc",
|
|
197
|
+
"/proc",
|
|
198
|
+
"--dev",
|
|
199
|
+
"/dev",
|
|
200
|
+
"--chdir",
|
|
201
|
+
workdir,
|
|
202
|
+
pythonBin,
|
|
203
|
+
driverPath,
|
|
204
|
+
],
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
throw new SandboxUnavailableError("sandbox_violation: neither `unshare` nor `bwrap` is available on this Linux host -- refusing to run the REPL driver unsandboxed.");
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Computes the sandboxed command/args for the current platform without
|
|
211
|
+
* spawning anything. Exposed separately from `spawnSandboxed` so tests can
|
|
212
|
+
* assert on the resolved invocation and so fail-closed behavior (throwing
|
|
213
|
+
* `SandboxUnavailableError`) is observable synchronously, before any
|
|
214
|
+
* process exists.
|
|
215
|
+
*/
|
|
216
|
+
export function resolveSandboxedCommand(opts) {
|
|
217
|
+
if (process.platform === "darwin")
|
|
218
|
+
return buildMacosCommand(opts);
|
|
219
|
+
if (process.platform === "linux")
|
|
220
|
+
return buildLinuxCommand(opts);
|
|
221
|
+
throw new SandboxUnavailableError(`sandbox_violation: no OS-level sandbox implementation for platform ${JSON.stringify(process.platform)} -- refusing to run the REPL driver unsandboxed.`);
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Environment variables the sandboxed interpreter is allowed to see, by
|
|
225
|
+
* allowlist rather than denylist. The OS sandbox itself denies network
|
|
226
|
+
* access outright, so this isn't the primary defense against exfiltrating
|
|
227
|
+
* anything read from here -- but the same object-graph escapes that defeat
|
|
228
|
+
* in-process Python restriction (see this module's doc comment) can still
|
|
229
|
+
* reach `os.environ` and write it into the session workdir even with
|
|
230
|
+
* network denied, so the child process should never see more of the parent
|
|
231
|
+
* Node process's environment (API keys, tokens, credentials, ...) than it
|
|
232
|
+
* actually needs to boot a working interpreter.
|
|
233
|
+
*/
|
|
234
|
+
const SAFE_ENV_ALLOWLIST = [
|
|
235
|
+
"PATH",
|
|
236
|
+
"HOME",
|
|
237
|
+
"LANG",
|
|
238
|
+
"LANGUAGE",
|
|
239
|
+
"LC_ALL",
|
|
240
|
+
"LC_CTYPE",
|
|
241
|
+
"TMPDIR",
|
|
242
|
+
"TZ",
|
|
243
|
+
"PYTHONHASHSEED",
|
|
244
|
+
"PYTHONIOENCODING",
|
|
245
|
+
"PYTHONUTF8",
|
|
246
|
+
];
|
|
247
|
+
/**
|
|
248
|
+
* Builds a scrubbed environment for the sandboxed child process from
|
|
249
|
+
* `SAFE_ENV_ALLOWLIST` -- deliberately NOT `{ ...process.env }` (see
|
|
250
|
+
* `SAFE_ENV_ALLOWLIST`'s doc comment for why the full parent environment,
|
|
251
|
+
* secrets included, must not be handed to a process running LLM-authored
|
|
252
|
+
* code).
|
|
253
|
+
*/
|
|
254
|
+
function buildScrubbedEnv() {
|
|
255
|
+
const scrubbed = {};
|
|
256
|
+
for (const key of SAFE_ENV_ALLOWLIST) {
|
|
257
|
+
const value = process.env[key];
|
|
258
|
+
if (value !== undefined)
|
|
259
|
+
scrubbed[key] = value;
|
|
260
|
+
}
|
|
261
|
+
return scrubbed;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Spawns `repl_driver.py` wrapped in the platform's OS-level sandbox.
|
|
265
|
+
* Throws `SandboxUnavailableError` synchronously (before any process is
|
|
266
|
+
* created) if the sandbox can't be established -- there is no unwrapped
|
|
267
|
+
* fallback path.
|
|
268
|
+
*/
|
|
269
|
+
export function spawnSandboxed(opts) {
|
|
270
|
+
const { command, args } = resolveSandboxedCommand(opts);
|
|
271
|
+
return spawn(command, args, { stdio: ["pipe", "pipe", "pipe"], env: buildScrubbedEnv() });
|
|
272
|
+
}
|
|
273
|
+
//# sourceMappingURL=sandbox.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sandbox.js","sourceRoot":"","sources":["../src/sandbox.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * OS-level sandbox spawn wrapper for the RLM Python REPL driver.\n *\n * This is the REAL security boundary described in\n * .planning/phases/28-repl-sandbox-safety/28-01-PLAN.md (\"IMPORTANT\" section\n * and Architecture section 1) -- NOT `repl_driver.py`'s Python-level\n * builtins/import allowlist (that's defense-in-depth for a\n * confused/prompt-injected LLM, not something that stops a deliberately\n * adversarial payload; the Python object graph makes in-process\n * confinement of arbitrary Python fundamentally unachievable -- see the\n * plan for the `__subclasses__`/`gi_frame.f_builtins` escapes that defeat\n * any purely in-process approach).\n *\n * **macOS**: wraps the interpreter with `sandbox-exec -f macos.sb ...`. The\n * SBPL profile (`../sandbox/macos.sb`) denies network and process-exec\n * entirely (which alone defeats `os.system`/`subprocess` -- they fork+exec\n * a shell -- independent of any Python-level import block) and scopes file\n * access to exactly the Python installation needed to boot plus the\n * session's workdir. Machine-specific paths (where python3 actually lives)\n * are discovered here at spawn time via a real subprocess call, never\n * hardcoded/guessed -- see `resolvePythonInstallation`.\n *\n * **Linux**: wraps with `unshare --user --map-root-user --net --mount --`\n * (empty net namespace = network unreachable at the kernel; mount\n * namespace for FS confinement), falling back to `bwrap` with equivalent\n * flags if `unshare` isn't available. Availability is detected at spawn\n * time, never assumed.\n *\n * **Fail closed everywhere**: if the sandboxing mechanism for the current\n * platform can't be established (wrapper binary missing, profile missing,\n * Python installation can't be discovered, unsupported platform), every\n * function here throws `SandboxUnavailableError` synchronously rather than\n * returning a command that would run the driver unwrapped. There is no\n * fallback path to an unsandboxed spawn anywhere in this module.\n */\n\nimport { type ChildProcess, execFileSync, spawn } from \"node:child_process\";\nimport { existsSync, realpathSync } from \"node:fs\";\nimport { dirname, join, delimiter as pathDelimiter } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nconst __dirname = dirname(fileURLToPath(import.meta.url));\nconst MACOS_PROFILE_PATH = join(__dirname, \"..\", \"sandbox\", \"macos.sb\");\nconst DEFAULT_SANDBOX_EXEC_BIN = \"/usr/bin/sandbox-exec\";\n\n/**\n * Thrown whenever the OS-level sandbox can't be established for the current\n * platform/environment. Callers (see `session.ts`) must let this propagate\n * and refuse to run rather than catching it and falling back to an\n * unwrapped spawn -- that fallback is exactly the failure mode Architecture\n * section 1's \"fail closed\" requirement exists to prevent.\n */\nexport class SandboxUnavailableError extends Error {\n\tconstructor(message: string) {\n\t\tsuper(message);\n\t\tthis.name = \"SandboxUnavailableError\";\n\t}\n}\n\nexport interface SandboxSpawnOptions {\n\t/** Absolute path to `repl_driver.py`. */\n\tdriverPath: string;\n\t/**\n\t * Absolute path to the session's scratch workdir. The only place the\n\t * sandboxed process may read *and* write freely (besides the read-only\n\t * Python installation itself).\n\t */\n\tworkdir: string;\n\t/**\n\t * Which `python3` to sandbox. Defaults to whatever `python3` resolves to\n\t * on `PATH`. Overridable mainly so tests can point at a specific\n\t * interpreter without mutating `PATH`.\n\t */\n\tpythonBin?: string;\n\t/**\n\t * Override for the macOS `sandbox-exec` binary path. Mainly a test\n\t * injection point for proving fail-closed behavior (point it at a\n\t * nonexistent path and assert setup refuses rather than falling back to\n\t * an unwrapped spawn) -- see Architecture section 1's fail-closed\n\t * requirement and this plan's task 1 test 7.\n\t */\n\tsandboxExecBin?: string;\n\t/** Override for the Linux `unshare` binary path. Mainly for tests. */\n\tunshareBin?: string;\n\t/** Override for the Linux `bwrap` binary path (used if `unshare` is unavailable). Mainly for tests. */\n\tbwrapBin?: string;\n}\n\n/** The concrete command + args a `child_process.spawn` call should use. */\nexport interface SandboxedCommand {\n\tcommand: string;\n\targs: string[];\n}\n\ninterface PythonInstallation {\n\t/** `realpath()`'d absolute path to the interpreter binary that will actually run. */\n\trealBin: string;\n\t/**\n\t * Some macOS \"framework\" Python builds (the python.org installer,\n\t * Homebrew's `--with-framework`) internally `posix_spawn()` themselves\n\t * into a second binary at startup\n\t * (`Resources/Python.app/Contents/MacOS/Python`) before any user code\n\t * runs, to get a normal `.app` bundle identity (Tk/Cocoa access, Dock\n\t * icon, etc.). Confirmed empirically while scoping this profile: without\n\t * allowing this second exec, dyld aborts before `main()` ever runs. Equal\n\t * to `realBin` when no such companion binary exists.\n\t */\n\treexecBin: string;\n\t/** `realpath()`'d `sys.prefix` -- the install root the interpreter needs read access to in order to boot. */\n\tprefix: string;\n}\n\nconst pythonInstallationCache = new Map<string, PythonInstallation>();\n\n/**\n * Asks the given `python3` binary (via a real subprocess call -- there is no\n * static/config-file way to learn a Python install's true executable path\n * and stdlib root; every install lays these out differently) where it and\n * its stdlib actually live, then checks for the macOS framework-build\n * re-exec companion described above. Memoized per `pythonBin` string since\n * the answer can't change during this process's lifetime and this runs on\n * every sandboxed spawn.\n */\nfunction resolvePythonInstallation(pythonBin: string): PythonInstallation {\n\tconst cached = pythonInstallationCache.get(pythonBin);\n\tif (cached) return cached;\n\n\tlet stdout: string;\n\ttry {\n\t\tstdout = execFileSync(pythonBin, [\"-c\", \"import sys; print(sys.executable); print(sys.prefix)\"], {\n\t\t\tencoding: \"utf8\",\n\t\t});\n\t} catch (err) {\n\t\tthrow new SandboxUnavailableError(\n\t\t\t`sandbox_violation: could not run ${JSON.stringify(pythonBin)} to discover its installation -- refusing to run the REPL driver unsandboxed. (${\n\t\t\t\terr instanceof Error ? err.message : String(err)\n\t\t\t})`,\n\t\t);\n\t}\n\n\tconst lines = stdout.trim().split(\"\\n\");\n\tconst executableLine = lines[0];\n\tconst prefixLine = lines[1];\n\tif (!executableLine || !prefixLine) {\n\t\tthrow new SandboxUnavailableError(\n\t\t\t`sandbox_violation: unexpected output discovering the Python installation for ${JSON.stringify(pythonBin)}: ${JSON.stringify(stdout)}`,\n\t\t);\n\t}\n\n\tlet realBin: string;\n\tlet prefix: string;\n\ttry {\n\t\trealBin = realpathSync(executableLine.trim());\n\t\tprefix = realpathSync(prefixLine.trim());\n\t} catch (err) {\n\t\tthrow new SandboxUnavailableError(\n\t\t\t`sandbox_violation: could not resolve real filesystem paths for the Python installation -- refusing to run the REPL driver unsandboxed. (${\n\t\t\t\terr instanceof Error ? err.message : String(err)\n\t\t\t})`,\n\t\t);\n\t}\n\n\tconst reexecCandidate = join(prefix, \"Resources\", \"Python.app\", \"Contents\", \"MacOS\", \"Python\");\n\tconst reexecBin = existsSync(reexecCandidate) ? realpathSync(reexecCandidate) : realBin;\n\n\tconst installation: PythonInstallation = { realBin, reexecBin, prefix };\n\tpythonInstallationCache.set(pythonBin, installation);\n\treturn installation;\n}\n\n/** Resolves a bare command name against `PATH`, returning `null` if not found. Used for the Linux wrappers. */\nfunction resolveOnPath(name: string): string | null {\n\tconst pathEnv = process.env.PATH ?? \"\";\n\tfor (const dir of pathEnv.split(pathDelimiter)) {\n\t\tif (!dir) continue;\n\t\tconst candidate = join(dir, name);\n\t\tif (existsSync(candidate)) return candidate;\n\t}\n\treturn null;\n}\n\nfunction buildMacosCommand(opts: SandboxSpawnOptions): SandboxedCommand {\n\tconst sandboxExecBin = opts.sandboxExecBin ?? DEFAULT_SANDBOX_EXEC_BIN;\n\tif (!existsSync(sandboxExecBin)) {\n\t\tthrow new SandboxUnavailableError(\n\t\t\t`sandbox_violation: macOS sandbox-exec binary not found at ${JSON.stringify(sandboxExecBin)} -- refusing to run the REPL driver unsandboxed.`,\n\t\t);\n\t}\n\tif (!existsSync(MACOS_PROFILE_PATH)) {\n\t\tthrow new SandboxUnavailableError(\n\t\t\t`sandbox_violation: sandbox profile missing at ${JSON.stringify(MACOS_PROFILE_PATH)} -- refusing to run the REPL driver unsandboxed.`,\n\t\t);\n\t}\n\n\tconst { realBin, reexecBin, prefix } = resolvePythonInstallation(opts.pythonBin ?? \"python3\");\n\n\tlet workdir: string;\n\tlet driverPath: string;\n\ttry {\n\t\tworkdir = realpathSync(opts.workdir);\n\t\tdriverPath = realpathSync(opts.driverPath);\n\t} catch (err) {\n\t\tthrow new SandboxUnavailableError(\n\t\t\t`sandbox_violation: could not resolve workdir/driver paths -- refusing to run the REPL driver unsandboxed. (${\n\t\t\t\terr instanceof Error ? err.message : String(err)\n\t\t\t})`,\n\t\t);\n\t}\n\n\treturn {\n\t\tcommand: sandboxExecBin,\n\t\targs: [\n\t\t\t\"-D\",\n\t\t\t`PYTHON_REAL_BIN=${realBin}`,\n\t\t\t\"-D\",\n\t\t\t`PYTHON_REEXEC_BIN=${reexecBin}`,\n\t\t\t\"-D\",\n\t\t\t`PYTHON_PREFIX=${prefix}`,\n\t\t\t\"-D\",\n\t\t\t`DRIVER_PATH=${driverPath}`,\n\t\t\t\"-D\",\n\t\t\t`WORKDIR=${workdir}`,\n\t\t\t\"-f\",\n\t\t\tMACOS_PROFILE_PATH,\n\t\t\trealBin,\n\t\t\tdriverPath,\n\t\t],\n\t};\n}\n\nfunction buildLinuxCommand(opts: SandboxSpawnOptions): SandboxedCommand {\n\tconst pythonBin = opts.pythonBin ?? \"python3\";\n\n\tlet workdir: string;\n\tlet driverPath: string;\n\ttry {\n\t\tworkdir = realpathSync(opts.workdir);\n\t\tdriverPath = realpathSync(opts.driverPath);\n\t} catch (err) {\n\t\tthrow new SandboxUnavailableError(\n\t\t\t`sandbox_violation: could not resolve workdir/driver paths -- refusing to run the REPL driver unsandboxed. (${\n\t\t\t\terr instanceof Error ? err.message : String(err)\n\t\t\t})`,\n\t\t);\n\t}\n\n\tconst unshareBin = opts.unshareBin ?? resolveOnPath(\"unshare\");\n\tif (unshareBin && existsSync(unshareBin)) {\n\t\t// Empty user+net namespace: no interfaces besides loopback exist, so\n\t\t// network is unreachable at the kernel regardless of anything Python\n\t\t// itself tries. Mount namespace confines the filesystem view; the\n\t\t// workdir bind-mount (rw) plus the rest of the root (ro, implicit --\n\t\t// `unshare --mount` alone doesn't remount anything) is a coarser\n\t\t// boundary than the macOS profile's explicit allowlist, which is why\n\t\t// `--map-root-user` + a private mount namespace is paired with the\n\t\t// driver only ever being told about `workdir` for file I/O at the\n\t\t// Python-guardrail layer (Phase 28 task 2) -- the kernel-level net\n\t\t// denial is what task 1 owns and is unconditional here.\n\t\treturn {\n\t\t\tcommand: unshareBin,\n\t\t\targs: [\"--user\", \"--map-root-user\", \"--net\", \"--mount\", \"--\", pythonBin, driverPath],\n\t\t};\n\t}\n\n\tconst bwrapBin = opts.bwrapBin ?? resolveOnPath(\"bwrap\");\n\tif (bwrapBin && existsSync(bwrapBin)) {\n\t\tconst binds = [\"/usr\", \"/lib\", \"/bin\", \"/sbin\"]\n\t\t\t.filter((path) => existsSync(path))\n\t\t\t.flatMap((path) => [\"--ro-bind\", path, path]);\n\t\tif (existsSync(\"/lib64\")) binds.push(\"--ro-bind\", \"/lib64\", \"/lib64\");\n\t\treturn {\n\t\t\tcommand: bwrapBin,\n\t\t\targs: [\n\t\t\t\t\"--unshare-user\",\n\t\t\t\t\"--unshare-net\",\n\t\t\t\t\"--unshare-pid\",\n\t\t\t\t\"--die-with-parent\",\n\t\t\t\t...binds,\n\t\t\t\t\"--bind\",\n\t\t\t\tworkdir,\n\t\t\t\tworkdir,\n\t\t\t\t\"--proc\",\n\t\t\t\t\"/proc\",\n\t\t\t\t\"--dev\",\n\t\t\t\t\"/dev\",\n\t\t\t\t\"--chdir\",\n\t\t\t\tworkdir,\n\t\t\t\tpythonBin,\n\t\t\t\tdriverPath,\n\t\t\t],\n\t\t};\n\t}\n\n\tthrow new SandboxUnavailableError(\n\t\t\"sandbox_violation: neither `unshare` nor `bwrap` is available on this Linux host -- refusing to run the REPL driver unsandboxed.\",\n\t);\n}\n\n/**\n * Computes the sandboxed command/args for the current platform without\n * spawning anything. Exposed separately from `spawnSandboxed` so tests can\n * assert on the resolved invocation and so fail-closed behavior (throwing\n * `SandboxUnavailableError`) is observable synchronously, before any\n * process exists.\n */\nexport function resolveSandboxedCommand(opts: SandboxSpawnOptions): SandboxedCommand {\n\tif (process.platform === \"darwin\") return buildMacosCommand(opts);\n\tif (process.platform === \"linux\") return buildLinuxCommand(opts);\n\tthrow new SandboxUnavailableError(\n\t\t`sandbox_violation: no OS-level sandbox implementation for platform ${JSON.stringify(process.platform)} -- refusing to run the REPL driver unsandboxed.`,\n\t);\n}\n\n/**\n * Environment variables the sandboxed interpreter is allowed to see, by\n * allowlist rather than denylist. The OS sandbox itself denies network\n * access outright, so this isn't the primary defense against exfiltrating\n * anything read from here -- but the same object-graph escapes that defeat\n * in-process Python restriction (see this module's doc comment) can still\n * reach `os.environ` and write it into the session workdir even with\n * network denied, so the child process should never see more of the parent\n * Node process's environment (API keys, tokens, credentials, ...) than it\n * actually needs to boot a working interpreter.\n */\nconst SAFE_ENV_ALLOWLIST = [\n\t\"PATH\",\n\t\"HOME\",\n\t\"LANG\",\n\t\"LANGUAGE\",\n\t\"LC_ALL\",\n\t\"LC_CTYPE\",\n\t\"TMPDIR\",\n\t\"TZ\",\n\t\"PYTHONHASHSEED\",\n\t\"PYTHONIOENCODING\",\n\t\"PYTHONUTF8\",\n] as const;\n\n/**\n * Builds a scrubbed environment for the sandboxed child process from\n * `SAFE_ENV_ALLOWLIST` -- deliberately NOT `{ ...process.env }` (see\n * `SAFE_ENV_ALLOWLIST`'s doc comment for why the full parent environment,\n * secrets included, must not be handed to a process running LLM-authored\n * code).\n */\nfunction buildScrubbedEnv(): NodeJS.ProcessEnv {\n\tconst scrubbed: NodeJS.ProcessEnv = {};\n\tfor (const key of SAFE_ENV_ALLOWLIST) {\n\t\tconst value = process.env[key];\n\t\tif (value !== undefined) scrubbed[key] = value;\n\t}\n\treturn scrubbed;\n}\n\n/**\n * Spawns `repl_driver.py` wrapped in the platform's OS-level sandbox.\n * Throws `SandboxUnavailableError` synchronously (before any process is\n * created) if the sandbox can't be established -- there is no unwrapped\n * fallback path.\n */\nexport function spawnSandboxed(opts: SandboxSpawnOptions): ChildProcess {\n\tconst { command, args } = resolveSandboxedCommand(opts);\n\treturn spawn(command, args, { stdio: [\"pipe\", \"pipe\", \"pipe\"], env: buildScrubbedEnv() });\n}\n"]}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `RlmSession` — the RLM root loop.
|
|
3
|
+
*
|
|
4
|
+
* root-LLM-produces-code -> REPL-executes -> truncated-stdout ->
|
|
5
|
+
* history-append -> FINAL-check, per
|
|
6
|
+
* .planning/phases/26-rlm-core-primitives/26-01-PLAN.md, Architecture
|
|
7
|
+
* section 4. Talks to `../python/repl_driver.py` over the newline-delimited
|
|
8
|
+
* JSON wire protocol documented in that file's module docstring (and
|
|
9
|
+
* section 2-3 of the plan).
|
|
10
|
+
*/
|
|
11
|
+
import type { RlmHistoryEntry, RlmResult, RlmSessionOptions } from "./types.js";
|
|
12
|
+
/**
|
|
13
|
+
* Extracts Python source from a root-LLM response.
|
|
14
|
+
*
|
|
15
|
+
* Per R26-RLM.4, the response is expected to contain a ```repl or ```python
|
|
16
|
+
* fenced block (possibly surrounded by prose explaining the plan). Falls
|
|
17
|
+
* back to the whole (trimmed) response when no fence is present, so mocked
|
|
18
|
+
* `rootLlm` callbacks in tests can return raw code directly.
|
|
19
|
+
*/
|
|
20
|
+
export declare function extractPythonCode(response: string): string;
|
|
21
|
+
/**
|
|
22
|
+
* Truncates captured stdout to `limit` characters, appending an explicit
|
|
23
|
+
* marker for how many characters were cut — matching the `[truncated N
|
|
24
|
+
* chars]` format R28-SBX.4 (Phase 28) will enforce as a hard cap. This phase
|
|
25
|
+
* only applies it as a plain formatting rule, not a safety mechanism.
|
|
26
|
+
*/
|
|
27
|
+
export declare function truncateStdout(stdout: string, limit: number): string;
|
|
28
|
+
/**
|
|
29
|
+
* Restricted Python-literal parser used to turn a `FINAL_VAR`-produced
|
|
30
|
+
* `repr()` string back into a real JS value.
|
|
31
|
+
*
|
|
32
|
+
* Handles the JSON-safe subset the plan calls out: `None`/`True`/`False`,
|
|
33
|
+
* int/float, single- or double-quoted strings (with the common backslash
|
|
34
|
+
* escapes), lists, tuples (mapped to arrays — JS has no tuple type), and
|
|
35
|
+
* dicts (mapped to plain objects; keys are coerced with `String(...)`).
|
|
36
|
+
* Anything it can't parse (custom object reprs like `<Foo object at
|
|
37
|
+
* 0x...>`, sets, NaN/Infinity, etc.) falls back to the raw repr string
|
|
38
|
+
* rather than throwing, so a result is never lost — see the module-level
|
|
39
|
+
* note in this file's neighboring test for why raw-string fallback beats
|
|
40
|
+
* failing the whole run.
|
|
41
|
+
*/
|
|
42
|
+
export declare function pythonReprToValue(repr: string): unknown;
|
|
43
|
+
export declare class RlmSession {
|
|
44
|
+
private readonly opts;
|
|
45
|
+
private readonly child;
|
|
46
|
+
private readonly workdir;
|
|
47
|
+
private readonly stdoutTruncateChars;
|
|
48
|
+
private readonly maxIterations;
|
|
49
|
+
private readonly stepTimeoutMs;
|
|
50
|
+
private readonly maxRssBytes;
|
|
51
|
+
private readonly rssPollIntervalMs;
|
|
52
|
+
private readonly maxSubCalls;
|
|
53
|
+
private readonly maxTotalCostUsd;
|
|
54
|
+
private readonly getAccumulatedCostUsd;
|
|
55
|
+
private readonly history;
|
|
56
|
+
private readonly ready;
|
|
57
|
+
private buffer;
|
|
58
|
+
private messageQueue;
|
|
59
|
+
private waiters;
|
|
60
|
+
private terminated;
|
|
61
|
+
private terminationError;
|
|
62
|
+
private lastFinal;
|
|
63
|
+
private subCallCount;
|
|
64
|
+
private rssPollTimer;
|
|
65
|
+
private rssPollInFlight;
|
|
66
|
+
constructor(opts: RlmSessionOptions);
|
|
67
|
+
private onStdoutChunk;
|
|
68
|
+
private markTerminated;
|
|
69
|
+
/**
|
|
70
|
+
* Hard-kills the driver subprocess and marks the session terminated with
|
|
71
|
+
* `error` in one step. Used by every resource-limit enforcement path (RSS
|
|
72
|
+
* ceiling, wall-clock timeout, budget exhaustion) -- these are Node-side
|
|
73
|
+
* hard kills, not attempts at graceful in-process recovery (Phase 28
|
|
74
|
+
* Architecture sections 3 and 6). `markTerminated`'s first-write-wins
|
|
75
|
+
* guard means whichever of these fires first supplies the message callers
|
|
76
|
+
* actually see, even though the child's own `exit` handler will also fire
|
|
77
|
+
* shortly after and try to (harmlessly, redundantly) mark terminated too.
|
|
78
|
+
*/
|
|
79
|
+
private killAndTerminate;
|
|
80
|
+
/**
|
|
81
|
+
* Starts the Node-side RSS poll loop (Phase 28 Architecture section 3) --
|
|
82
|
+
* the real memory enforcement mechanism, not any Python-level
|
|
83
|
+
* `RLIMIT_AS`. Runs for the child process's whole lifetime; `unref()`d so
|
|
84
|
+
* it never by itself keeps the host Node process alive past everything
|
|
85
|
+
* else finishing. A `maxRssBytes <= 0` disables polling entirely.
|
|
86
|
+
*/
|
|
87
|
+
private startRssPolling;
|
|
88
|
+
private pollRss;
|
|
89
|
+
/**
|
|
90
|
+
* Pre-dispatch sub-call budget check (Phase 28 Architecture section 6,
|
|
91
|
+
* R28-SBX.5): called *before* an `llm_query_request` is ever forwarded to
|
|
92
|
+
* `opts.llmQuery`, so the (budget+1)th attempted sub-call never actually
|
|
93
|
+
* dispatches -- the session is killed right here instead. Deliberately
|
|
94
|
+
* NOT also checked before dispatching a whole step: a step that never
|
|
95
|
+
* attempts another sub-call can still legitimately finish (e.g. call
|
|
96
|
+
* FINAL immediately) even after the budget is otherwise exhausted, and
|
|
97
|
+
* pre-emptively killing such a step here would be wrong.
|
|
98
|
+
*/
|
|
99
|
+
private checkSubCallBudget;
|
|
100
|
+
/**
|
|
101
|
+
* Pre-dispatch cost budget check (Phase 28 Architecture section 6,
|
|
102
|
+
* R28-SBX.5): called before dispatching a step's `rootLlm` turn and
|
|
103
|
+
* before forwarding a sub-call to `opts.llmQuery`. No-ops unless both
|
|
104
|
+
* `maxTotalCostUsd` and `getAccumulatedCostUsd` are supplied -- see
|
|
105
|
+
* `RlmSessionOptions.maxTotalCostUsd`'s doc comment for why an unpaired
|
|
106
|
+
* option is a no-op rather than a guess.
|
|
107
|
+
*/
|
|
108
|
+
private checkCostBudget;
|
|
109
|
+
/**
|
|
110
|
+
* Races `promise` against the configured wall-clock step timeout
|
|
111
|
+
* (Phase 28 Architecture section 3, R28-SBX.3) -- the real timeout
|
|
112
|
+
* enforcement mechanism. On expiry, hard-kills the driver subprocess and
|
|
113
|
+
* rejects with `RlmStepTimeoutError`, even if `promise` itself is stuck
|
|
114
|
+
* awaiting something that `markTerminated`'s waiter-rejection can't reach
|
|
115
|
+
* (e.g. a slow/hung `opts.llmQuery` callback, as opposed to a pending
|
|
116
|
+
* `nextMessage()` wait) -- the explicit `reject` here is what actually
|
|
117
|
+
* unblocks the caller in that case, not just a formality.
|
|
118
|
+
*/
|
|
119
|
+
private withStepTimeout;
|
|
120
|
+
private nextMessage;
|
|
121
|
+
private send;
|
|
122
|
+
/**
|
|
123
|
+
* Sends one `exec` message and handles any `llm_query_request` round-trips
|
|
124
|
+
* until the matching `exec_result`, all under the wall-clock step timeout
|
|
125
|
+
* (`withStepTimeout`, Phase 28 Architecture section 3).
|
|
126
|
+
*/
|
|
127
|
+
private runExec;
|
|
128
|
+
/** The message loop `runExec` races against the step timeout -- split out so `withStepTimeout` can wrap just this. */
|
|
129
|
+
private collectExecResult;
|
|
130
|
+
private seedContext;
|
|
131
|
+
/**
|
|
132
|
+
* Runtime proof the OS-level sandbox is actually enforcing for *this*
|
|
133
|
+
* process (Phase 28 Architecture section 1) -- sends `{"type":
|
|
134
|
+
* "self_test"}` and requires the driver's `self_test_result` response to
|
|
135
|
+
* report both a network connect attempt and an out-of-workdir file read
|
|
136
|
+
* failing. Throws `RlmSandboxViolationError` (fail-closed, refusing to run
|
|
137
|
+
* at all) if:
|
|
138
|
+
* - the driver never responds (crash/hang -- guarded by the same
|
|
139
|
+
* wall-clock timeout as a normal step, via `withStepTimeout`),
|
|
140
|
+
* - the response isn't a well-formed `self_test_result`, or
|
|
141
|
+
* - either check reports the sandbox did NOT block the attempt.
|
|
142
|
+
*
|
|
143
|
+
* Deliberately does not go through `runExec`/the guarded `exec()` wire
|
|
144
|
+
* message: `repl_driver.py`'s `_run_self_test` handles `self_test`
|
|
145
|
+
* on a route that bypasses its own Python-level guardrails (restricted
|
|
146
|
+
* builtins, import allowlist, AST screen) entirely, because a self-test
|
|
147
|
+
* *routed through* those guardrails would be a false positive -- e.g.
|
|
148
|
+
* `import socket` failing on the guardrail import allowlist looks
|
|
149
|
+
* identical to `import socket` failing because the OS sandbox denied the
|
|
150
|
+
* connection, even with the OS sandbox completely disabled. See
|
|
151
|
+
* `_run_self_test`'s docstring in repl_driver.py for the full reasoning.
|
|
152
|
+
*/
|
|
153
|
+
private runSelfTest;
|
|
154
|
+
private resolveFinal;
|
|
155
|
+
/** One root-LLM turn: get code, exec it, append history, may resolve a final. */
|
|
156
|
+
step(): Promise<RlmHistoryEntry>;
|
|
157
|
+
/** Loops `step()` until a FINAL/FINAL_VAR, `maxIterations`, or an unrecoverable driver error. */
|
|
158
|
+
run(): Promise<RlmResult>;
|
|
159
|
+
/** Kills the underlying python3 subprocess and removes its scratch workdir. Safe to call more than once. */
|
|
160
|
+
dispose(): void;
|
|
161
|
+
}
|
|
162
|
+
//# sourceMappingURL=session.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AASH,OAAO,KAAK,EAAE,eAAe,EAAE,SAAS,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AA4GhF;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAI1D;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAIpE;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CASvD;AAoND,qBAAa,UAAU;IAwBV,OAAO,CAAC,QAAQ,CAAC,IAAI;IAvBjC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAe;IACrC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAAS;IAC7C,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAS;IACrC,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAS;IAC3C,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAqB;IACjD,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAqB;IACrD,OAAO,CAAC,QAAQ,CAAC,qBAAqB,CAA6B;IACnE,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAyB;IACjD,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAgB;IAEtC,OAAO,CAAC,MAAM,CAAM;IACpB,OAAO,CAAC,YAAY,CAAuB;IAC3C,OAAO,CAAC,OAAO,CAAuB;IACtC,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,gBAAgB,CAAsB;IAC9C,OAAO,CAAC,SAAS,CAAmC;IACpD,OAAO,CAAC,YAAY,CAAK;IACzB,OAAO,CAAC,YAAY,CAA6B;IACjD,OAAO,CAAC,eAAe,CAAS;IAEhC,YAA6B,IAAI,EAAE,iBAAiB,EAyEnD;IAED,OAAO,CAAC,aAAa;IAqBrB,OAAO,CAAC,cAAc;IAatB;;;;;;;;;OASG;IACH,OAAO,CAAC,gBAAgB;IAKxB;;;;;;OAMG;IACH,OAAO,CAAC,eAAe;YAST,OAAO;IAoBrB;;;;;;;;;OASG;IACH,OAAO,CAAC,kBAAkB;IAW1B;;;;;;;OAOG;IACH,OAAO,CAAC,eAAe;IAYvB;;;;;;;;;OASG;IACH,OAAO,CAAC,eAAe;IAwBvB,OAAO,CAAC,WAAW;IAWnB,OAAO,CAAC,IAAI;IAOZ;;;;OAIG;YACW,OAAO;IAKrB,sHAAsH;YACxG,iBAAiB;YA4BjB,WAAW;IAOzB;;;;;;;;;;;;;;;;;;;;;OAqBG;YACW,WAAW;IAyBzB,OAAO,CAAC,YAAY;IAWpB,iFAAiF;IAC3E,IAAI,IAAI,OAAO,CAAC,eAAe,CAAC,CAyBrC;IAED,iGAAiG;IAC3F,GAAG,IAAI,OAAO,CAAC,SAAS,CAAC,CAoD9B;IAED,4GAA4G;IAC5G,OAAO,IAAI,IAAI,CAUd;CACD"}
|