orbit-agent-runtime 0.8.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/orbit.mjs CHANGED
@@ -1,523 +1,561 @@
1
- #!/usr/bin/env node
2
- /**
3
- * orbit — command line interface for Orbit Agent Runtime.
4
- *
5
- * Three commands form the deterministic-replay loop:
6
- * orbit record <script> Run a script against a live kernel and
7
- * capture every channel call into a trace.
8
- * orbit replay <trace> Re-run the recorded script with ZERO
9
- * real channel calls, then reconcile the
10
- * replayed chain against the original.
11
- * orbit diff <a> <trace-b> Compare two traces and locate the first
12
- * digest-chain breakpoint.
13
- *
14
- * Zero third-party dependencies: only Node built-ins. The kernel is loaded
15
- * from the compiled CommonJS bundle via createRequire so the CLI works both
16
- * from the repo (node bin/orbit.mjs) and after `npm i -g`.
17
- *
18
- * Usage:
19
- * orbit record <script.js> [--out trace.jsonl] [--config orbit.config.json]
20
- * orbit replay <trace.jsonl> [--via script.js] [--config orbit.config.json]
21
- * orbit diff <a.jsonl> <b.jsonl>
22
- * orbit --version
23
- * orbit help
24
- *
25
- * Every command accepts --json for machine-readable output.
26
- */
27
-
28
- import { createRequire } from "node:module";
29
- import { fileURLToPath, pathToFileURL } from "node:url";
30
- import * as fs from "node:fs";
31
- import * as path from "node:path";
32
-
33
- const require = createRequire(import.meta.url);
34
- const __filename = fileURLToPath(import.meta.url);
35
- const __dirname = path.dirname(__filename);
36
-
37
- const PKG = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8"));
38
-
39
- // Load the compiled kernel (CommonJS). Named exports are reachable through the
40
- // default-import interop object.
41
- const orbit = require("../dist/src/index.js");
42
- const {
43
- OrbitRuntimeHost,
44
- ChannelKind,
45
- ReplayEngine,
46
- saveRecordJournal,
47
- loadRecordJournal,
48
- TraceFileInvalidError,
49
- OpenAICompatChannel,
50
- FileChannel,
51
- ShellChannel,
52
- digestInputs
53
- } = orbit;
54
-
55
- // ----------------------------------------------------------------- helpers
56
-
57
- function out(obj) {
58
- process.stdout.write(`${JSON.stringify(obj, null, 2)}\n`);
59
- }
60
-
61
- function fail(msg, code) {
62
- process.stderr.write(`orbit: ${msg}\n`);
63
- process.exitCode = code;
64
- }
65
-
66
- function isJsonFlag(v) {
67
- return v === true || v === "true";
68
- }
69
-
70
- /** Minimal argv parser: positionals + `--key value` / `--key=value` / `--flag`. */
71
- function parseArgs(argv) {
72
- const positionals = [];
73
- const flags = {};
74
- for (let i = 0; i < argv.length; i += 1) {
75
- const a = argv[i];
76
- if (a.startsWith("--")) {
77
- const eq = a.indexOf("=");
78
- if (eq !== -1) {
79
- flags[a.slice(2, eq)] = a.slice(eq + 1);
80
- } else {
81
- const key = a.slice(2);
82
- const next = argv[i + 1];
83
- if (next !== undefined && !next.startsWith("--")) {
84
- flags[key] = next;
85
- i += 1;
86
- } else {
87
- flags[key] = true;
88
- }
89
- }
90
- } else {
91
- positionals.push(a);
92
- }
93
- }
94
- return { positionals, flags };
95
- }
96
-
97
- function mergeDeep(target, src) {
98
- if (typeof src !== "object" || src === null) return target;
99
- for (const k of Object.keys(src)) {
100
- const v = src[k];
101
- if (
102
- typeof v === "object" &&
103
- v !== null &&
104
- !Array.isArray(v) &&
105
- typeof target[k] === "object" &&
106
- target[k] !== null
107
- ) {
108
- mergeDeep(target[k], v);
109
- } else {
110
- target[k] = v;
111
- }
112
- }
113
- return target;
114
- }
115
-
116
- /** Drop secrets before persisting the config into a trace's meta file. */
117
- function sanitizeConfig(cfg) {
118
- const clean = JSON.parse(JSON.stringify(cfg));
119
- if (clean.llm) delete clean.llm.apiKey;
120
- if (clean.shell) delete clean.shell.apiKey;
121
- return clean;
122
- }
123
-
124
- /**
125
- * Resolve the runtime configuration.
126
- * Priority (low -> high): built-in defaults < orbit.config.json < env vars.
127
- */
128
- function loadConfig(configPath) {
129
- const base = {
130
- llm: { kind: "mock" },
131
- file: { enabled: false },
132
- shell: { enabled: false }
133
- };
134
- const file = configPath
135
- ? path.resolve(process.cwd(), configPath)
136
- : path.join(process.cwd(), "orbit.config.json");
137
- if (fs.existsSync(file)) {
138
- let userCfg;
139
- try {
140
- userCfg = JSON.parse(fs.readFileSync(file, "utf8"));
141
- } catch (err) {
142
- throw new Error(`invalid JSON in ${file}: ${err.message}`);
143
- }
144
- mergeDeep(base, userCfg || {});
145
- }
146
-
147
- if (process.env.ORBIT_LLM_BASE_URL) {
148
- base.llm = {
149
- kind: "openai-compat",
150
- baseUrl: process.env.ORBIT_LLM_BASE_URL,
151
- apiKey: process.env.ORBIT_LLM_API_KEY ?? "",
152
- model: process.env.ORBIT_LLM_MODEL ?? "deepseek-chat"
153
- };
154
- }
155
- if (process.env.ORBIT_FILE_ROOT) {
156
- base.file = { enabled: true, rootDir: process.env.ORBIT_FILE_ROOT };
157
- }
158
- if (process.env.ORBIT_SHELL_ALLOW) {
159
- base.shell = {
160
- enabled: true,
161
- allowedCommands: process.env.ORBIT_SHELL_ALLOW.split(",").map((s) => s.trim()).filter(Boolean),
162
- envAllowlist: process.env.ORBIT_SHELL_ENV
163
- ? process.env.ORBIT_SHELL_ENV.split(",").map((s) => s.trim()).filter(Boolean)
164
- : []
165
- };
166
- }
167
- return base;
168
- }
169
-
170
- /**
171
- * Assemble a kernel host from a config: register LLM / File / Shell channels
172
- * as requested, set them up, then boot the host (which wires built-ins).
173
- */
174
- async function buildHost(config) {
175
- const host = new OrbitRuntimeHost();
176
- const hostCtx = { traceMarkId: `orbit-cli-${Date.now()}`, maxWaitMs: 30_000 };
177
-
178
- if (config.llm && config.llm.kind === "openai-compat") {
179
- const ch = new OpenAICompatChannel({
180
- apiKey: config.llm.apiKey ?? "",
181
- baseUrl: config.llm.baseUrl,
182
- model: config.llm.model
183
- });
184
- host.channelHub.registerPluginExtChannel(ChannelKind.LLM_ACCESS, ch);
185
- await ch.setup(hostCtx);
186
- }
187
- if (config.file && config.file.enabled) {
188
- const ch = new FileChannel({ rootDir: config.file.rootDir });
189
- host.channelHub.registerPluginExtChannel(ChannelKind.FILE_SYSTEM, ch);
190
- await ch.setup(hostCtx);
191
- }
192
- if (config.shell && config.shell.enabled) {
193
- const ch = new ShellChannel({
194
- allowedCommands: config.shell.allowedCommands,
195
- envAllowlist: config.shell.envAllowlist,
196
- workDir: config.shell.workDir,
197
- timeoutMs: config.shell.timeoutMs
198
- });
199
- host.channelHub.registerPluginExtChannel(ChannelKind.SHELL_EXEC, ch);
200
- await ch.setup(hostCtx);
201
- }
202
-
203
- await host.bootHost();
204
- return host;
205
- }
206
-
207
- /**
208
- * The context handed to a user script. `call` fires a channel method under the
209
- * current mode (record or replay); no pluginUnitId is set, so the capability
210
- * gate is skipped — CLI scripts are trusted host-level code. `llm.chat` is
211
- * sugar over the LLM channel.
212
- */
213
- function makeScriptContext(host, replayMode) {
214
- let seq = 0;
215
- const call = (kind, funcName, ...args) => {
216
- const ctx = { traceMarkId: `orbit-cli-call-${seq++}`, maxWaitMs: 60_000, replayMode };
217
- return host.channelHub.fireChannelCall(kind, ctx, funcName, ...args);
218
- };
219
- return {
220
- host,
221
- hub: host.channelHub,
222
- ChannelKind,
223
- call,
224
- llm: {
225
- chat: (prompt, opts) => {
226
- const ctx = { traceMarkId: `orbit-cli-llm-${seq++}`, maxWaitMs: 60_000, replayMode };
227
- return host.channelHub.fireChannelCall(ChannelKind.LLM_ACCESS, ctx, "chatRound", prompt, opts);
228
- }
229
- }
230
- };
231
- }
232
-
233
- /** Import a user script and invoke its default-exported async function(ctx). */
234
- async function runScript(scriptPath, ctx) {
235
- const abs = path.resolve(process.cwd(), scriptPath);
236
- let mod;
237
- try {
238
- mod = await import(pathToFileURL(abs).href);
239
- } catch (err) {
240
- throw new Error(
241
- `failed to load script ${scriptPath}: ${err.message}` +
242
- "\n(orbit CLI runs JavaScript modules only — compile TypeScript first, e.g. with tsx/tsc)"
243
- );
244
- }
245
- const fn = mod.default ?? mod;
246
- if (typeof fn !== "function") {
247
- throw new Error(`script ${scriptPath} must default-export an async function: export default async (ctx) => { ... }`);
248
- }
249
- return fn(ctx);
250
- }
251
-
252
- // ----------------------------------------------------------------- commands
253
-
254
- async function cmdRecord(scriptPath, opts) {
255
- if (!scriptPath) {
256
- fail("record requires <script>", 2);
257
- return;
258
- }
259
- const config = loadConfig(opts.config);
260
- const host = await buildHost(config);
261
- const journal = host.beginRecording();
262
- const ctx = makeScriptContext(host, "record");
263
- const startedAt = Date.now();
264
- try {
265
- await runScript(scriptPath, ctx);
266
- const outPath = opts.out
267
- ? path.resolve(process.cwd(), opts.out)
268
- : path.resolve(process.cwd(), "orbit-trace.jsonl");
269
- const count = await saveRecordJournal(journal, outPath);
270
- const meta = {
271
- script: scriptPath,
272
- orbitVersion: PKG.version,
273
- nodeVersion: process.version,
274
- createdAt: new Date().toISOString(),
275
- recordCount: count,
276
- config: sanitizeConfig(config)
277
- };
278
- const metaPath = `${outPath}.meta.json`;
279
- fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2));
280
- const ms = Date.now() - startedAt;
281
- if (isJsonFlag(opts.json)) {
282
- out({ ok: true, trace: outPath, meta: metaPath, calls: count, elapsedMs: ms });
283
- } else {
284
- process.stdout.write(
285
- `✓ recorded ${count} channel calls from ${scriptPath}\n` +
286
- ` trace : ${outPath}\n` +
287
- ` meta : ${metaPath}\n` +
288
- ` took : ${ms}ms\n`
289
- );
290
- }
291
- } finally {
292
- await host.shutdownHost();
293
- }
294
- }
295
-
296
- async function cmdReplay(tracePath, opts) {
297
- if (!tracePath) {
298
- fail("replay requires <trace>", 2);
299
- return;
300
- }
301
- const abs = path.resolve(process.cwd(), tracePath);
302
- let journal;
303
- try {
304
- journal = await loadRecordJournal(abs);
305
- } catch (err) {
306
- if (err instanceof TraceFileInvalidError) {
307
- fail(err.message, 2);
308
- return;
309
- }
310
- throw err;
311
- }
312
-
313
- const metaPath = `${abs}.meta.json`;
314
- let script = opts.via;
315
- let replayConfig = null;
316
- if (fs.existsSync(metaPath)) {
317
- const meta = JSON.parse(fs.readFileSync(metaPath, "utf8"));
318
- if (!script) script = meta.script;
319
- replayConfig = meta.config || null;
320
- }
321
- if (!script) {
322
- fail(`trace ${tracePath} has no driving script; re-run record or pass --via <script>`, 2);
323
- return;
324
- }
325
-
326
- const config = replayConfig || loadConfig(opts.config);
327
- const host = await buildHost(config);
328
- const replayJournal = host.beginRecording();
329
- host.attachReplayEngine(journal);
330
- const ctx = makeScriptContext(host, "replay");
331
- try {
332
- await runScript(script, ctx);
333
- } finally {
334
- await host.shutdownHost();
335
- }
336
-
337
- const report = new ReplayEngine(journal).reconcile(journal.snapshot(), replayJournal.snapshot());
338
- if (isJsonFlag(opts.json)) {
339
- out({ ok: report.digestChainConsistent, ...report });
340
- } else {
341
- process.stdout.write(
342
- `replay of ${tracePath}\n` +
343
- ` original calls : ${report.originalCount}\n` +
344
- ` replayed calls : ${report.replayedCount}\n`
345
- );
346
- if (report.digestChainConsistent) {
347
- process.stdout.write(" result : ✓ VERIFIED — digest chain consistent\n");
348
- } else {
349
- process.stdout.write(` result : ✗ DRIFT at call #${report.driftAtOrderIndex}\n`);
350
- process.exitCode = 1;
351
- }
352
- }
353
- }
354
-
355
- async function cmdDiff(aPath, bPath, opts) {
356
- if (!aPath || !bPath) {
357
- fail("diff requires <a> <b>", 2);
358
- return;
359
- }
360
- const A = await loadRecordJournal(path.resolve(process.cwd(), aPath));
361
- const B = await loadRecordJournal(path.resolve(process.cwd(), bPath));
362
- const a = A.snapshot();
363
- const b = B.snapshot();
364
- const n = Math.min(a.length, b.length);
365
-
366
- let firstDrift = -1;
367
- let driftField = null;
368
- let driftA = null;
369
- let driftB = null;
370
- for (let i = 0; i < n; i += 1) {
371
- const x = a[i];
372
- const y = b[i];
373
- if (x.channelKind !== y.channelKind) {
374
- firstDrift = i; driftField = "channelKind"; driftA = x.channelKind; driftB = y.channelKind; break;
375
- }
376
- if (x.funcName !== y.funcName) {
377
- firstDrift = i; driftField = "funcName"; driftA = x.funcName; driftB = y.funcName; break;
378
- }
379
- if (x.inputDigest !== y.inputDigest) {
380
- firstDrift = i; driftField = "inputDigest"; driftA = x.inputDigest; driftB = y.inputDigest; break;
381
- }
382
- if (digestInputs(x.outputSnapshot) !== digestInputs(y.outputSnapshot)) {
383
- firstDrift = i; driftField = "outputSnapshot"; driftA = "(digest differs)"; driftB = "(digest differs)"; break;
384
- }
385
- }
386
-
387
- const consistent = firstDrift === -1;
388
- const report = {
389
- a: aPath,
390
- b: bPath,
391
- aCount: a.length,
392
- bCount: b.length,
393
- lengthMatch: a.length === b.length,
394
- firstDriftIndex: firstDrift,
395
- driftField,
396
- consistent
397
- };
398
- if (isJsonFlag(opts.json)) {
399
- out(report);
400
- } else {
401
- process.stdout.write(`diff ${aPath} vs ${bPath}\n calls: a=${a.length} b=${b.length}\n`);
402
- if (consistent) {
403
- process.stdout.write(" result: ✓ identical call chains\n");
404
- } else {
405
- process.stdout.write(` result: ✗ divergence at call #${firstDrift} (${driftField})\n`);
406
- process.stdout.write(` a: ${driftA}\n`);
407
- process.stdout.write(` b: ${driftB}\n`);
408
- process.exitCode = 1;
409
- }
410
- }
411
- }
412
-
413
- async function cmdAudit(tracePath, opts) {
414
- if (!tracePath) {
415
- fail("audit requires <trace.wal.jsonl>", 2);
416
- return;
417
- }
418
- const file = path.resolve(process.cwd(), tracePath);
419
- const journal = new orbit.PersistedTraceJournal(file);
420
- await journal.load();
421
- const entries = journal.snapshot();
422
- if (!opts.key) {
423
- const summary = {
424
- file,
425
- entries: entries.length,
426
- signed: false,
427
- note: "pass --key <hmac-key> to verify the chain signature"
428
- };
429
- if (isJsonFlag(opts.json)) {
430
- out(summary);
431
- } else {
432
- process.stdout.write(`audit ${file}\n entries : ${entries.length}\n signed : no key given — pass --key to verify the chain\n`);
433
- }
434
- return;
435
- }
436
- const report = orbit.verifyAuditChain(entries, opts.key);
437
- if (isJsonFlag(opts.json)) {
438
- out({ file, ...report });
439
- } else {
440
- process.stdout.write(`audit ${file}\n entries : ${report.total}\n signed : ${report.signed}\n`);
441
- if (report.consistent) {
442
- process.stdout.write(" result : ✓ audit chain consistent (no tampering detected)\n");
443
- } else {
444
- process.stdout.write(` result : ✗ chain broken at entry #${report.brokenAt} — ${report.brokenReason}\n`);
445
- process.exitCode = 1;
446
- }
447
- }
448
- }
449
-
450
- function printUsage() {
451
- process.stdout.write(
452
- `orbit ${PKG.version} — deterministic-replay CLI for Orbit Agent Runtime
453
-
454
- Usage:
455
- orbit record <script.js> [--out trace.jsonl] [--config orbit.config.json]
456
- orbit replay <trace.jsonl> [--via script.js] [--config orbit.config.json]
457
- orbit diff <a.jsonl> <b.jsonl>
458
- orbit audit <trace.wal.jsonl> [--key <hmac-key>] [--json]
459
- orbit --version
460
- orbit help
461
-
462
- Options:
463
- --out <path> Output trace path for record (default: ./orbit-trace.jsonl)
464
- --via <script> Driving script for replay (default: read from trace meta)
465
- --config <path> Config file (default: ./orbit.config.json)
466
- --json Machine-readable output
467
-
468
- Script contract:
469
- export default async function (ctx) {
470
- const reply = await ctx.llm.chat("hello");
471
- const prev = await ctx.call(ctx.ChannelKind.MEM_KV_STORE, "readEntry", "k");
472
- return { reply, prev };
473
- }
474
-
475
- Config (orbit.config.json) all keys optional:
476
- { "llm": { "kind": "mock" | "openai-compat", "baseUrl": "...", "model": "..." },
477
- "file": { "enabled": true, "rootDir": "./sandbox-fs" },
478
- "shell": { "enabled": true, "allowedCommands": ["node","echo"], "envAllowlist": ["PATH"] } }
479
- Env overrides: ORBIT_LLM_BASE_URL / ORBIT_LLM_API_KEY / ORBIT_LLM_MODEL,
480
- ORBIT_FILE_ROOT, ORBIT_SHELL_ALLOW (csv) / ORBIT_SHELL_ENV (csv).
481
- `
482
- );
483
- }
484
-
485
- // --------------------------------------------------------------------- entry
486
-
487
- async function main() {
488
- const { positionals, flags } = parseArgs(process.argv.slice(2));
489
- const cmd = positionals.shift();
490
- const json = isJsonFlag(flags.json);
491
-
492
- try {
493
- switch (cmd) {
494
- case "record":
495
- await cmdRecord(positionals[0], { out: flags.out, config: flags.config, json });
496
- break;
497
- case "replay":
498
- await cmdReplay(positionals[0], { via: flags.via, config: flags.config, json });
499
- break;
500
- case "diff":
501
- await cmdDiff(positionals[0], positionals[1], { json });
502
- break;
503
- case "audit":
504
- await cmdAudit(positionals[0], { key: flags.key, json });
505
- break;
506
- case "help":
507
- case undefined:
508
- printUsage();
509
- break;
510
- case "--version":
511
- case "version":
512
- process.stdout.write(`${PKG.version}\n`);
513
- break;
514
- default:
515
- fail(`unknown command: ${cmd}`, 2);
516
- printUsage();
517
- }
518
- } catch (err) {
519
- fail(err instanceof Error ? err.message : String(err), 1);
520
- }
521
- }
522
-
523
- main();
1
+ #!/usr/bin/env node
2
+ /**
3
+ * orbit — command line interface for Orbit Agent Runtime.
4
+ *
5
+ * Three commands form the deterministic-replay loop:
6
+ * orbit record <script> Run a script against a live kernel and
7
+ * capture every channel call into a trace.
8
+ * orbit replay <trace> Re-run the recorded script with ZERO
9
+ * real channel calls, then reconcile the
10
+ * replayed chain against the original.
11
+ * orbit diff <a> <trace-b> Compare two traces and locate the first
12
+ * digest-chain breakpoint.
13
+ *
14
+ * Zero third-party dependencies: only Node built-ins. The kernel is loaded
15
+ * from the compiled CommonJS bundle via createRequire so the CLI works both
16
+ * from the repo (node bin/orbit.mjs) and after `npm i -g`.
17
+ *
18
+ * Usage:
19
+ * orbit record <script.js> [--out trace.jsonl] [--config orbit.config.json]
20
+ * orbit replay <trace.jsonl> [--via script.js] [--config orbit.config.json]
21
+ * orbit diff <a.jsonl> <b.jsonl>
22
+ * orbit --version
23
+ * orbit help
24
+ *
25
+ * Every command accepts --json for machine-readable output.
26
+ */
27
+
28
+ import { createRequire } from "node:module";
29
+ import { fileURLToPath, pathToFileURL } from "node:url";
30
+ import * as fs from "node:fs";
31
+ import * as path from "node:path";
32
+
33
+ const require = createRequire(import.meta.url);
34
+ const __filename = fileURLToPath(import.meta.url);
35
+ const __dirname = path.dirname(__filename);
36
+
37
+ const PKG = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8"));
38
+
39
+ // Load the compiled kernel (CommonJS). Named exports are reachable through the
40
+ // default-import interop object.
41
+ const orbit = require("../dist/src/index.js");
42
+ const {
43
+ OrbitRuntimeHost,
44
+ ChannelKind,
45
+ ReplayEngine,
46
+ saveRecordJournal,
47
+ loadRecordJournal,
48
+ TraceFileInvalidError,
49
+ OpenAICompatChannel,
50
+ FileChannel,
51
+ ShellChannel,
52
+ digestInputs
53
+ } = orbit;
54
+
55
+ // ----------------------------------------------------------------- helpers
56
+
57
+ function out(obj) {
58
+ process.stdout.write(`${JSON.stringify(obj, null, 2)}\n`);
59
+ }
60
+
61
+ function fail(msg, code) {
62
+ process.stderr.write(`orbit: ${msg}\n`);
63
+ process.exitCode = code;
64
+ }
65
+
66
+ function isJsonFlag(v) {
67
+ return v === true || v === "true";
68
+ }
69
+
70
+ /** Minimal argv parser: positionals + `--key value` / `--key=value` / `--flag`. */
71
+ function parseArgs(argv) {
72
+ const positionals = [];
73
+ const flags = {};
74
+ for (let i = 0; i < argv.length; i += 1) {
75
+ const a = argv[i];
76
+ if (a.startsWith("--")) {
77
+ const eq = a.indexOf("=");
78
+ if (eq !== -1) {
79
+ flags[a.slice(2, eq)] = a.slice(eq + 1);
80
+ } else {
81
+ const key = a.slice(2);
82
+ const next = argv[i + 1];
83
+ if (next !== undefined && !next.startsWith("--")) {
84
+ flags[key] = next;
85
+ i += 1;
86
+ } else {
87
+ flags[key] = true;
88
+ }
89
+ }
90
+ } else {
91
+ positionals.push(a);
92
+ }
93
+ }
94
+ return { positionals, flags };
95
+ }
96
+
97
+ function mergeDeep(target, src) {
98
+ if (typeof src !== "object" || src === null) return target;
99
+ for (const k of Object.keys(src)) {
100
+ const v = src[k];
101
+ if (
102
+ typeof v === "object" &&
103
+ v !== null &&
104
+ !Array.isArray(v) &&
105
+ typeof target[k] === "object" &&
106
+ target[k] !== null
107
+ ) {
108
+ mergeDeep(target[k], v);
109
+ } else {
110
+ target[k] = v;
111
+ }
112
+ }
113
+ return target;
114
+ }
115
+
116
+ /** Drop secrets before persisting the config into a trace's meta file. */
117
+ function sanitizeConfig(cfg) {
118
+ const clean = JSON.parse(JSON.stringify(cfg));
119
+ if (clean.llm) delete clean.llm.apiKey;
120
+ if (clean.shell) delete clean.shell.apiKey;
121
+ return clean;
122
+ }
123
+
124
+ /**
125
+ * Resolve the runtime configuration.
126
+ * Priority (low -> high): built-in defaults < orbit.config.json < env vars.
127
+ */
128
+ function loadConfig(configPath) {
129
+ const base = {
130
+ llm: { kind: "mock" },
131
+ file: { enabled: false },
132
+ shell: { enabled: false }
133
+ };
134
+ const file = configPath
135
+ ? path.resolve(process.cwd(), configPath)
136
+ : path.join(process.cwd(), "orbit.config.json");
137
+ if (fs.existsSync(file)) {
138
+ let userCfg;
139
+ try {
140
+ userCfg = JSON.parse(fs.readFileSync(file, "utf8"));
141
+ } catch (err) {
142
+ throw new Error(`invalid JSON in ${file}: ${err.message}`);
143
+ }
144
+ mergeDeep(base, userCfg || {});
145
+ }
146
+
147
+ if (process.env.ORBIT_LLM_BASE_URL) {
148
+ base.llm = {
149
+ kind: "openai-compat",
150
+ baseUrl: process.env.ORBIT_LLM_BASE_URL,
151
+ apiKey: process.env.ORBIT_LLM_API_KEY ?? "",
152
+ model: process.env.ORBIT_LLM_MODEL ?? "deepseek-chat"
153
+ };
154
+ }
155
+ if (process.env.ORBIT_FILE_ROOT) {
156
+ base.file = { enabled: true, rootDir: process.env.ORBIT_FILE_ROOT };
157
+ }
158
+ if (process.env.ORBIT_SHELL_ALLOW) {
159
+ base.shell = {
160
+ enabled: true,
161
+ allowedCommands: process.env.ORBIT_SHELL_ALLOW.split(",").map((s) => s.trim()).filter(Boolean),
162
+ envAllowlist: process.env.ORBIT_SHELL_ENV
163
+ ? process.env.ORBIT_SHELL_ENV.split(",").map((s) => s.trim()).filter(Boolean)
164
+ : []
165
+ };
166
+ }
167
+ return base;
168
+ }
169
+
170
+ /**
171
+ * Assemble a kernel host from a config: register LLM / File / Shell channels
172
+ * as requested, set them up, then boot the host (which wires built-ins).
173
+ */
174
+ async function buildHost(config) {
175
+ const host = new OrbitRuntimeHost();
176
+ const hostCtx = { traceMarkId: `orbit-cli-${Date.now()}`, maxWaitMs: 30_000 };
177
+
178
+ if (config.llm && config.llm.kind === "openai-compat") {
179
+ const ch = new OpenAICompatChannel({
180
+ apiKey: config.llm.apiKey ?? "",
181
+ baseUrl: config.llm.baseUrl,
182
+ model: config.llm.model
183
+ });
184
+ host.channelHub.registerPluginExtChannel(ChannelKind.LLM_ACCESS, ch);
185
+ await ch.setup(hostCtx);
186
+ }
187
+ if (config.file && config.file.enabled) {
188
+ const ch = new FileChannel({ rootDir: config.file.rootDir });
189
+ host.channelHub.registerPluginExtChannel(ChannelKind.FILE_SYSTEM, ch);
190
+ await ch.setup(hostCtx);
191
+ }
192
+ if (config.shell && config.shell.enabled) {
193
+ const ch = new ShellChannel({
194
+ allowedCommands: config.shell.allowedCommands,
195
+ envAllowlist: config.shell.envAllowlist,
196
+ workDir: config.shell.workDir,
197
+ timeoutMs: config.shell.timeoutMs
198
+ });
199
+ host.channelHub.registerPluginExtChannel(ChannelKind.SHELL_EXEC, ch);
200
+ await ch.setup(hostCtx);
201
+ }
202
+
203
+ await host.bootHost();
204
+ return host;
205
+ }
206
+
207
+ /**
208
+ * The context handed to a user script. `call` fires a channel method under the
209
+ * current mode (record or replay); no pluginUnitId is set, so the capability
210
+ * gate is skipped — CLI scripts are trusted host-level code. `llm.chat` is
211
+ * sugar over the LLM channel.
212
+ */
213
+ function makeScriptContext(host, replayMode) {
214
+ let seq = 0;
215
+ const call = (kind, funcName, ...args) => {
216
+ const ctx = { traceMarkId: `orbit-cli-call-${seq++}`, maxWaitMs: 60_000, replayMode };
217
+ return host.channelHub.fireChannelCall(kind, ctx, funcName, ...args);
218
+ };
219
+ return {
220
+ host,
221
+ hub: host.channelHub,
222
+ ChannelKind,
223
+ call,
224
+ llm: {
225
+ chat: (prompt, opts) => {
226
+ const ctx = { traceMarkId: `orbit-cli-llm-${seq++}`, maxWaitMs: 60_000, replayMode };
227
+ return host.channelHub.fireChannelCall(ChannelKind.LLM_ACCESS, ctx, "chatRound", prompt, opts);
228
+ }
229
+ }
230
+ };
231
+ }
232
+
233
+ /** Import a user script and invoke its default-exported async function(ctx). */
234
+ async function runScript(scriptPath, ctx) {
235
+ const abs = path.resolve(process.cwd(), scriptPath);
236
+ let mod;
237
+ try {
238
+ mod = await import(pathToFileURL(abs).href);
239
+ } catch (err) {
240
+ throw new Error(
241
+ `failed to load script ${scriptPath}: ${err.message}` +
242
+ "\n(orbit CLI runs JavaScript modules only — compile TypeScript first, e.g. with tsx/tsc)"
243
+ );
244
+ }
245
+ const fn = mod.default ?? mod;
246
+ if (typeof fn !== "function") {
247
+ throw new Error(`script ${scriptPath} must default-export an async function: export default async (ctx) => { ... }`);
248
+ }
249
+ return fn(ctx);
250
+ }
251
+
252
+ // ----------------------------------------------------------------- commands
253
+
254
+ async function cmdRecord(scriptPath, opts) {
255
+ if (!scriptPath) {
256
+ fail("record requires <script>", 2);
257
+ return;
258
+ }
259
+ const config = loadConfig(opts.config);
260
+ const host = await buildHost(config);
261
+ const journal = host.beginRecording();
262
+ const ctx = makeScriptContext(host, "record");
263
+ const startedAt = Date.now();
264
+ try {
265
+ await runScript(scriptPath, ctx);
266
+ const outPath = opts.out
267
+ ? path.resolve(process.cwd(), opts.out)
268
+ : path.resolve(process.cwd(), "orbit-trace.jsonl");
269
+ const count = await saveRecordJournal(journal, outPath);
270
+ const meta = {
271
+ script: scriptPath,
272
+ orbitVersion: PKG.version,
273
+ nodeVersion: process.version,
274
+ createdAt: new Date().toISOString(),
275
+ recordCount: count,
276
+ config: sanitizeConfig(config)
277
+ };
278
+ const metaPath = `${outPath}.meta.json`;
279
+ fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2));
280
+ const ms = Date.now() - startedAt;
281
+ if (isJsonFlag(opts.json)) {
282
+ out({ ok: true, trace: outPath, meta: metaPath, calls: count, elapsedMs: ms });
283
+ } else {
284
+ process.stdout.write(
285
+ `✓ recorded ${count} channel calls from ${scriptPath}\n` +
286
+ ` trace : ${outPath}\n` +
287
+ ` meta : ${metaPath}\n` +
288
+ ` took : ${ms}ms\n`
289
+ );
290
+ }
291
+ } finally {
292
+ await host.shutdownHost();
293
+ }
294
+ }
295
+
296
+ async function cmdReplay(tracePath, opts) {
297
+ if (!tracePath) {
298
+ fail("replay requires <trace>", 2);
299
+ return;
300
+ }
301
+ const abs = path.resolve(process.cwd(), tracePath);
302
+ let journal;
303
+ try {
304
+ journal = await loadRecordJournal(abs);
305
+ } catch (err) {
306
+ if (err instanceof TraceFileInvalidError) {
307
+ fail(err.message, 2);
308
+ return;
309
+ }
310
+ throw err;
311
+ }
312
+
313
+ const metaPath = `${abs}.meta.json`;
314
+ let script = opts.via;
315
+ let replayConfig = null;
316
+ if (fs.existsSync(metaPath)) {
317
+ const meta = JSON.parse(fs.readFileSync(metaPath, "utf8"));
318
+ if (!script) script = meta.script;
319
+ replayConfig = meta.config || null;
320
+ }
321
+ if (!script) {
322
+ fail(`trace ${tracePath} has no driving script; re-run record or pass --via <script>`, 2);
323
+ return;
324
+ }
325
+
326
+ const config = replayConfig || loadConfig(opts.config);
327
+ const host = await buildHost(config);
328
+ const replayJournal = host.beginRecording();
329
+ host.attachReplayEngine(journal);
330
+ const ctx = makeScriptContext(host, "replay");
331
+ try {
332
+ await runScript(script, ctx);
333
+ } finally {
334
+ await host.shutdownHost();
335
+ }
336
+
337
+ const report = new ReplayEngine(journal).reconcile(journal.snapshot(), replayJournal.snapshot());
338
+ if (isJsonFlag(opts.json)) {
339
+ out({ ok: report.digestChainConsistent, ...report });
340
+ } else {
341
+ process.stdout.write(
342
+ `replay of ${tracePath}\n` +
343
+ ` original calls : ${report.originalCount}\n` +
344
+ ` replayed calls : ${report.replayedCount}\n`
345
+ );
346
+ if (report.digestChainConsistent) {
347
+ process.stdout.write(" result : ✓ VERIFIED — digest chain consistent\n");
348
+ } else {
349
+ process.stdout.write(` result : ✗ DRIFT at call #${report.driftAtOrderIndex}\n`);
350
+ process.exitCode = 1;
351
+ }
352
+ }
353
+ }
354
+
355
+ async function cmdDiff(aPath, bPath, opts) {
356
+ if (!aPath || !bPath) {
357
+ fail("diff requires <a> <b>", 2);
358
+ return;
359
+ }
360
+ const A = await loadRecordJournal(path.resolve(process.cwd(), aPath));
361
+ const B = await loadRecordJournal(path.resolve(process.cwd(), bPath));
362
+ const a = A.snapshot();
363
+ const b = B.snapshot();
364
+ const n = Math.min(a.length, b.length);
365
+
366
+ let firstDrift = -1;
367
+ let driftField = null;
368
+ let driftA = null;
369
+ let driftB = null;
370
+ for (let i = 0; i < n; i += 1) {
371
+ const x = a[i];
372
+ const y = b[i];
373
+ if (x.channelKind !== y.channelKind) {
374
+ firstDrift = i; driftField = "channelKind"; driftA = x.channelKind; driftB = y.channelKind; break;
375
+ }
376
+ if (x.funcName !== y.funcName) {
377
+ firstDrift = i; driftField = "funcName"; driftA = x.funcName; driftB = y.funcName; break;
378
+ }
379
+ if (x.inputDigest !== y.inputDigest) {
380
+ firstDrift = i; driftField = "inputDigest"; driftA = x.inputDigest; driftB = y.inputDigest; break;
381
+ }
382
+ if (digestInputs(x.outputSnapshot) !== digestInputs(y.outputSnapshot)) {
383
+ firstDrift = i; driftField = "outputSnapshot"; driftA = "(digest differs)"; driftB = "(digest differs)"; break;
384
+ }
385
+ }
386
+
387
+ const consistent = firstDrift === -1;
388
+ const report = {
389
+ a: aPath,
390
+ b: bPath,
391
+ aCount: a.length,
392
+ bCount: b.length,
393
+ lengthMatch: a.length === b.length,
394
+ firstDriftIndex: firstDrift,
395
+ driftField,
396
+ consistent
397
+ };
398
+ if (isJsonFlag(opts.json)) {
399
+ out(report);
400
+ } else {
401
+ process.stdout.write(`diff ${aPath} vs ${bPath}\n calls: a=${a.length} b=${b.length}\n`);
402
+ if (consistent) {
403
+ process.stdout.write(" result: ✓ identical call chains\n");
404
+ } else {
405
+ process.stdout.write(` result: ✗ divergence at call #${firstDrift} (${driftField})\n`);
406
+ process.stdout.write(` a: ${driftA}\n`);
407
+ process.stdout.write(` b: ${driftB}\n`);
408
+ process.exitCode = 1;
409
+ }
410
+ }
411
+ }
412
+
413
+ async function cmdAudit(tracePath, opts) {
414
+ if (!tracePath) {
415
+ fail("audit requires <trace.wal.jsonl>", 2);
416
+ return;
417
+ }
418
+ const file = path.resolve(process.cwd(), tracePath);
419
+ const journal = new orbit.PersistedTraceJournal(file);
420
+ await journal.load();
421
+ const entries = journal.snapshot();
422
+ if (!opts.key) {
423
+ const summary = {
424
+ file,
425
+ entries: entries.length,
426
+ signed: false,
427
+ note: "pass --key <hmac-key> to verify the chain signature"
428
+ };
429
+ if (isJsonFlag(opts.json)) {
430
+ out(summary);
431
+ } else {
432
+ process.stdout.write(`audit ${file}\n entries : ${entries.length}\n signed : no key given — pass --key to verify the chain\n`);
433
+ }
434
+ return;
435
+ }
436
+ const report = orbit.verifyAuditChain(entries, opts.key);
437
+ if (isJsonFlag(opts.json)) {
438
+ out({ file, ...report });
439
+ } else {
440
+ process.stdout.write(`audit ${file}\n entries : ${report.total}\n signed : ${report.signed}\n`);
441
+ if (report.consistent) {
442
+ process.stdout.write(" result : ✓ audit chain consistent (no tampering detected)\n");
443
+ } else {
444
+ process.stdout.write(` result : ✗ chain broken at entry #${report.brokenAt} — ${report.brokenReason}\n`);
445
+ process.exitCode = 1;
446
+ }
447
+ }
448
+ }
449
+
450
+ async function cmdVerifyReport(reportPath, opts) {
451
+ if (!reportPath) {
452
+ fail("verify-report requires <report.json>", 2);
453
+ return;
454
+ }
455
+ if (!opts["public-key"]) {
456
+ fail("verify-report requires --public-key <pem|hex-seed>", 2);
457
+ return;
458
+ }
459
+ const file = path.resolve(process.cwd(), reportPath);
460
+ const report = JSON.parse(await fs.promises.readFile(file, "utf8"));
461
+ // The public key may be a PEM file path, an inline PEM string, or the
462
+ // operator's 32-byte hex seed (from which the public key is derived
463
+ // deterministically).
464
+ const keyArg = opts["public-key"];
465
+ let publicKeyPem = keyArg;
466
+ const looksLikePath = !keyArg.includes("-----BEGIN") && keyArg.includes(".");
467
+ if (looksLikePath) {
468
+ publicKeyPem = (await fs.promises.readFile(path.resolve(process.cwd(), keyArg), "utf8")).trim();
469
+ }
470
+ if (!publicKeyPem.includes("-----BEGIN")) {
471
+ publicKeyPem = orbit.deriveReportKeyPair(publicKeyPem).publicKeyPem;
472
+ }
473
+ const result = orbit.verifyComplianceReport(report, publicKeyPem);
474
+ if (isJsonFlag(opts.json)) {
475
+ out({ file, ok: result.ok, reason: result.reason ?? null });
476
+ } else if (result.ok) {
477
+ process.stdout.write(`verify-report ${file}\n result : signature valid (ed25519 · ${report.sig.publicKeyFingerprint})\n`);
478
+ } else {
479
+ process.stdout.write(`verify-report ${file}\n result : ${result.reason}\n`);
480
+ process.exitCode = 1;
481
+ }
482
+ }
483
+
484
+ function printUsage() {
485
+ process.stdout.write(
486
+ `orbit ${PKG.version} — deterministic-replay CLI for Orbit Agent Runtime
487
+
488
+ Usage:
489
+ orbit record <script.js> [--out trace.jsonl] [--config orbit.config.json]
490
+ orbit replay <trace.jsonl> [--via script.js] [--config orbit.config.json]
491
+ orbit diff <a.jsonl> <b.jsonl>
492
+ orbit audit <trace.wal.jsonl> [--key <hmac-key>] [--json]
493
+ orbit verify-report <report.json> --public-key <pem|hex-seed> [--json]
494
+ orbit --version
495
+ orbit help
496
+
497
+ Options:
498
+ --out <path> Output trace path for record (default: ./orbit-trace.jsonl)
499
+ --via <script> Driving script for replay (default: read from trace meta)
500
+ --config <path> Config file (default: ./orbit.config.json)
501
+ --json Machine-readable output
502
+
503
+ Script contract:
504
+ export default async function (ctx) {
505
+ const reply = await ctx.llm.chat("hello");
506
+ const prev = await ctx.call(ctx.ChannelKind.MEM_KV_STORE, "readEntry", "k");
507
+ return { reply, prev };
508
+ }
509
+
510
+ Config (orbit.config.json) — all keys optional:
511
+ { "llm": { "kind": "mock" | "openai-compat", "baseUrl": "...", "model": "..." },
512
+ "file": { "enabled": true, "rootDir": "./sandbox-fs" },
513
+ "shell": { "enabled": true, "allowedCommands": ["node","echo"], "envAllowlist": ["PATH"] } }
514
+ Env overrides: ORBIT_LLM_BASE_URL / ORBIT_LLM_API_KEY / ORBIT_LLM_MODEL,
515
+ ORBIT_FILE_ROOT, ORBIT_SHELL_ALLOW (csv) / ORBIT_SHELL_ENV (csv).
516
+ `
517
+ );
518
+ }
519
+
520
+ // --------------------------------------------------------------------- entry
521
+
522
+ async function main() {
523
+ const { positionals, flags } = parseArgs(process.argv.slice(2));
524
+ const cmd = positionals.shift();
525
+ const json = isJsonFlag(flags.json);
526
+
527
+ try {
528
+ switch (cmd) {
529
+ case "record":
530
+ await cmdRecord(positionals[0], { out: flags.out, config: flags.config, json });
531
+ break;
532
+ case "replay":
533
+ await cmdReplay(positionals[0], { via: flags.via, config: flags.config, json });
534
+ break;
535
+ case "diff":
536
+ await cmdDiff(positionals[0], positionals[1], { json });
537
+ break;
538
+ case "audit":
539
+ await cmdAudit(positionals[0], { key: flags.key, json });
540
+ break;
541
+ case "verify-report":
542
+ await cmdVerifyReport(positionals[0], { "public-key": flags["public-key"], json });
543
+ break;
544
+ case "help":
545
+ case undefined:
546
+ printUsage();
547
+ break;
548
+ case "--version":
549
+ case "version":
550
+ process.stdout.write(`${PKG.version}\n`);
551
+ break;
552
+ default:
553
+ fail(`unknown command: ${cmd}`, 2);
554
+ printUsage();
555
+ }
556
+ } catch (err) {
557
+ fail(err instanceof Error ? err.message : String(err), 1);
558
+ }
559
+ }
560
+
561
+ main();