@hilbras/omninode 2.0.0-alpha.3 → 2.0.0-alpha.4

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 CHANGED
@@ -18,7 +18,9 @@ existing intelligence; it does not try to become another model.
18
18
 
19
19
  ## Status
20
20
 
21
- **v2.0.0-alpha.3 — v2 Phase 3: Agent Protocol v2** (after Phase 1: architecture & API stabilization, Phase 2: execution reliability) (the v2
21
+ **v2.0.0-alpha.4 — v2 Phase 4: Agent Adapter Hardening**
22
+ (after Phase 1: architecture & API stabilization, Phase 2: execution reliability,
23
+ Phase 3: agent protocol v2 — the reliability & interoperability line) (the v2
22
24
  roadmap is [docs/ROADMAP_V2.md](docs/ROADMAP_V2.md); the v1 plan is complete:
23
25
  the
24
26
  full loop from §28 runs in one command —
@@ -27,14 +29,28 @@ planner → plan → execution → result` — with audit logging, error recover
27
29
  production hardening (see [docs/DEVELOPMENT_PLAN.md](docs/DEVELOPMENT_PLAN.md)
28
30
  for the plan and [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the design).
29
31
 
30
- ### What changed in v2 Phase 1
31
-
32
- - All local stores now build on one shared `JsonFileStore` (atomic writes,
33
- serialized read-modify-write, schema-versioned files, corruption tolerance)
34
- with a `ProjectStores` facade.
35
- - Dependency direction corrected (adapters no longer import engine modules).
36
- - API surface stabilized and guarded: [API_STABILITY.md](docs/API_STABILITY.md),
37
- [API.md](docs/API.md), [DEPRECATIONS.md](docs/DEPRECATIONS.md).
32
+ ### What's new in v2 (so far)
33
+
34
+ - **Phase 4 — Agent Adapter Hardening**: process-tree cleanup (agents run in
35
+ their own process group; timeout, cancel and Ctrl-C reap the whole tree, so
36
+ no orphaned children), explicit environment policies
37
+ (`inherit` / `allowlist` / `denylist` / `explicit`), working-directory
38
+ validation against project boundaries, per-stream output limits that kill
39
+ runaway agents, binary-safe output decoding, human-readable exit/signal
40
+ reporting, and a native `IAgentAdapter` registry so new integration styles
41
+ plug in without touching core.
42
+ - **Phase 3 — Agent Protocol v2**: versioned protocol
43
+ (`omninode-agent-protocol/2`), fully correlated envelopes, the complete
44
+ message-type set, HELLO handshake, QUESTION/RESPONSE round-trips, and
45
+ malformed input that can never crash OmniNode. Spec:
46
+ [PROTOCOL.md](docs/PROTOCOL.md).
47
+ - **Phase 2 — Execution Reliability**: a task killed after dispatch is
48
+ `unknown`, **not** `failed` — retries never duplicate side effects blindly;
49
+ bounded retries with backoff, execution identity and history.
50
+ - **Phase 1 — Architecture & API Stabilization**: one shared `JsonFileStore`
51
+ behind every local store, corrected dependency direction, and a guarded
52
+ public API ([API_STABILITY.md](docs/API_STABILITY.md), [API.md](docs/API.md),
53
+ [DEPRECATIONS.md](docs/DEPRECATIONS.md)).
38
54
 
39
55
  ### What's in v1.0
40
56
 
package/dist/cli/index.js CHANGED
@@ -6,7 +6,7 @@ import { realpathSync } from "fs";
6
6
  import { Command } from "commander";
7
7
 
8
8
  // src/version.ts
9
- var OMNINODE_VERSION = "2.0.0-alpha.3";
9
+ var OMNINODE_VERSION = "2.0.0-alpha.4";
10
10
 
11
11
  // src/errors/index.ts
12
12
  var OmniNodeError = class extends Error {
@@ -83,6 +83,11 @@ var agentConfigSchema = z.object({
83
83
  input_mode: z.enum(["stdin", "arg", "protocol"]).default("stdin"),
84
84
  timeout_ms: z.number().int().positive().optional(),
85
85
  inherit_env: z.boolean().optional(),
86
+ env_policy: z.enum(["inherit", "allowlist", "denylist", "explicit"]).optional(),
87
+ env_allowlist: z.array(z.string()).optional(),
88
+ env_denylist: z.array(z.string()).optional(),
89
+ max_output_bytes: z.number().int().positive().optional(),
90
+ allow_external_cwd: z.boolean().optional(),
86
91
  metadata: z.record(z.unknown()).optional()
87
92
  }).strict();
88
93
  var roleConfigSchema = z.object({
@@ -257,6 +262,11 @@ function toAgentConfig(raw) {
257
262
  inputMode: raw.input_mode,
258
263
  timeoutMs: raw.timeout_ms,
259
264
  inheritEnv: raw.inherit_env,
265
+ envPolicy: raw.env_policy,
266
+ envAllowlist: raw.env_allowlist,
267
+ envDenylist: raw.env_denylist,
268
+ maxOutputBytes: raw.max_output_bytes,
269
+ allowExternalCwd: raw.allow_external_cwd,
260
270
  metadata: raw.metadata
261
271
  };
262
272
  }
@@ -1214,20 +1224,94 @@ var ProtocolSession = class {
1214
1224
  sawV2 = false;
1215
1225
  };
1216
1226
 
1217
- // src/agents/process/index.ts
1218
- var DEFAULT_TIMEOUT_MS2 = 6e5;
1219
- var KILL_GRACE_MS = 5e3;
1227
+ // src/agents/cwd.ts
1228
+ import { statSync } from "fs";
1229
+ import { isAbsolute, relative, resolve } from "path";
1230
+ function validateWorkingDirectory(cwd, options = {}) {
1231
+ if (!cwd) return void 0;
1232
+ const normalized = isAbsolute(cwd) ? resolve(cwd) : resolve(process.cwd(), cwd);
1233
+ let stats;
1234
+ try {
1235
+ stats = statSync(normalized);
1236
+ } catch (error) {
1237
+ throw new AgentError(
1238
+ "AGENT_NOT_FOUND",
1239
+ `Working directory "${normalized}" does not exist or is not accessible.`,
1240
+ { cause: error, details: { cwd: normalized } }
1241
+ );
1242
+ }
1243
+ if (!stats.isDirectory()) {
1244
+ throw new AgentError("AGENT_NOT_FOUND", `Working directory "${normalized}" is not a directory.`, {
1245
+ details: { cwd: normalized }
1246
+ });
1247
+ }
1248
+ try {
1249
+ statSync(resolve(normalized, "."));
1250
+ } catch (error) {
1251
+ throw new AgentError(
1252
+ "AGENT_NOT_FOUND",
1253
+ `Working directory "${normalized}" is not accessible.`,
1254
+ { cause: error }
1255
+ );
1256
+ }
1257
+ const projectRoot = resolve(options.projectRoot ?? process.cwd());
1258
+ const rel = relative(projectRoot, normalized);
1259
+ const outsideProject = rel.startsWith("..") || isAbsolute(rel);
1260
+ if (outsideProject && options.allowExternal !== true) {
1261
+ throw new AgentError(
1262
+ "AGENT_NOT_FOUND",
1263
+ `Working directory "${normalized}" is outside the project root "${projectRoot}". Set allow_external_cwd: true on the agent to permit this.`,
1264
+ { details: { cwd: normalized, projectRoot } }
1265
+ );
1266
+ }
1267
+ return { cwd: normalized, outsideProject };
1268
+ }
1269
+
1270
+ // src/agents/env.ts
1271
+ var BASELINE_KEYS = ["PATH", "HOME"];
1272
+ function resolveEnvPolicy(config) {
1273
+ if (config.envPolicy !== void 0) return config.envPolicy;
1274
+ if (config.inheritEnv === false) return "explicit";
1275
+ return "inherit";
1276
+ }
1220
1277
  function buildChildEnv(config) {
1221
1278
  const configured = config.env ?? {};
1222
- if (config.inheritEnv !== false) {
1279
+ const policy = resolveEnvPolicy(config);
1280
+ if (policy === "inherit") {
1223
1281
  return { ...process.env, ...configured };
1224
1282
  }
1225
- return {
1226
- PATH: process.env.PATH ?? "",
1227
- HOME: process.env.HOME ?? "",
1228
- ...configured
1229
- };
1283
+ const base = {};
1284
+ for (const key of BASELINE_KEYS) {
1285
+ const value = process.env[key];
1286
+ if (value !== void 0) base[key] = value;
1287
+ }
1288
+ switch (policy) {
1289
+ case "explicit":
1290
+ return { ...base, ...configured };
1291
+ case "allowlist": {
1292
+ const allowed = { ...base };
1293
+ for (const key of config.envAllowlist ?? []) {
1294
+ const value = process.env[key];
1295
+ if (value !== void 0) allowed[key] = value;
1296
+ }
1297
+ return { ...allowed, ...configured };
1298
+ }
1299
+ case "denylist": {
1300
+ const denied = new Set((config.envDenylist ?? []).map((k) => k.toUpperCase()));
1301
+ const filtered = {};
1302
+ for (const [key, value] of Object.entries(process.env)) {
1303
+ if (denied.has(key.toUpperCase())) continue;
1304
+ filtered[key] = value;
1305
+ }
1306
+ return { ...filtered, ...configured };
1307
+ }
1308
+ }
1230
1309
  }
1310
+
1311
+ // src/agents/process/index.ts
1312
+ var DEFAULT_TIMEOUT_MS2 = 6e5;
1313
+ var KILL_GRACE_MS = 5e3;
1314
+ var DEFAULT_MAX_OUTPUT_BYTES = 5 * 1024 * 1024;
1231
1315
  function composeTaskText(input) {
1232
1316
  const sections = [`# Objective
1233
1317
  ${input.objective}`];
@@ -1289,40 +1373,73 @@ var ProcessAgent = class {
1289
1373
  }
1290
1374
  const mode = this.config.inputMode ?? "stdin";
1291
1375
  const timeoutMs = this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS2;
1292
- return new Promise((resolve, reject) => {
1376
+ return new Promise((resolve2, reject) => {
1377
+ let cwd;
1378
+ try {
1379
+ cwd = validateWorkingDirectory(this.config.cwd, {
1380
+ allowExternal: this.config.allowExternalCwd
1381
+ })?.cwd;
1382
+ } catch (error) {
1383
+ reject(error);
1384
+ return;
1385
+ }
1293
1386
  const child = spawn(
1294
1387
  command,
1295
1388
  [...this.config.args ?? [], ...mode === "arg" ? [composeTaskText(input)] : []],
1296
1389
  {
1297
- cwd: this.config.cwd,
1390
+ ...cwd !== void 0 ? { cwd } : {},
1298
1391
  env: buildChildEnv(this.config),
1299
- stdio: ["pipe", "pipe", "pipe"]
1392
+ stdio: ["pipe", "pipe", "pipe"],
1393
+ // Own process group so the whole tree can be terminated together
1394
+ // (roadmap §8: no orphaned children).
1395
+ ...process.platform === "win32" ? {} : { detached: true }
1300
1396
  }
1301
1397
  );
1302
1398
  this.running.set(input.taskId, child);
1399
+ GLOBAL_RUNNING.add(child);
1400
+ installSignalForwarding();
1303
1401
  let stdout = "";
1304
1402
  let stderr = "";
1305
1403
  let timedOut = false;
1404
+ let outputLimitExceeded = false;
1306
1405
  let settled = false;
1406
+ const maxOutputBytes = this.config.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
1307
1407
  const timer = setTimeout(() => {
1308
1408
  timedOut = true;
1309
- this.log.warn(`Task ${input.taskId} exceeded ${timeoutMs}ms \u2014 killing process.`);
1310
- child.kill("SIGTERM");
1311
- setTimeout(() => child.kill("SIGKILL"), KILL_GRACE_MS).unref();
1409
+ this.log.warn(`Task ${input.taskId} exceeded ${timeoutMs}ms \u2014 killing process tree.`);
1410
+ killTree(child, "SIGTERM");
1411
+ setTimeout(() => killTree(child, "SIGKILL"), KILL_GRACE_MS).unref();
1312
1412
  }, timeoutMs);
1313
1413
  const settle = (finish) => {
1314
1414
  if (settled) return;
1315
1415
  settled = true;
1316
1416
  clearTimeout(timer);
1317
1417
  this.running.delete(input.taskId);
1418
+ GLOBAL_RUNNING.delete(child);
1318
1419
  finish();
1319
1420
  };
1320
- child.stdout?.on("data", (chunk) => {
1321
- stdout += chunk.toString();
1322
- });
1323
- child.stderr?.on("data", (chunk) => {
1324
- stderr += chunk.toString();
1325
- });
1421
+ const collect = (into, chunk) => {
1422
+ if (outputLimitExceeded) return;
1423
+ const text = sanitizeChunk(chunk);
1424
+ if (into === "stdout") {
1425
+ if (stdout.length + text.length > maxOutputBytes) {
1426
+ stdout = (stdout + text).slice(0, maxOutputBytes);
1427
+ outputLimitExceeded = true;
1428
+ this.log.warn(`Agent "${this.config.name}" exceeded the ${maxOutputBytes} byte output limit \u2014 killing it.`);
1429
+ killTree(child, "SIGKILL");
1430
+ return;
1431
+ }
1432
+ stdout += text;
1433
+ } else {
1434
+ if (stderr.length + text.length > maxOutputBytes) {
1435
+ stderr = (stderr + text).slice(0, maxOutputBytes);
1436
+ return;
1437
+ }
1438
+ stderr += text;
1439
+ }
1440
+ };
1441
+ child.stdout?.on("data", (chunk) => collect("stdout", chunk));
1442
+ child.stderr?.on("data", (chunk) => collect("stderr", chunk));
1326
1443
  child.stdin?.on("error", () => {
1327
1444
  });
1328
1445
  if (mode !== "arg") {
@@ -1376,7 +1493,7 @@ var ProcessAgent = class {
1376
1493
  const logs = stderr.trim().length > 0 ? stderr.split("\n") : void 0;
1377
1494
  if (mode === "protocol") {
1378
1495
  try {
1379
- resolve(
1496
+ resolve2(
1380
1497
  interpretProtocolRun({
1381
1498
  input,
1382
1499
  agentName: this.config.name,
@@ -1391,14 +1508,14 @@ var ProcessAgent = class {
1391
1508
  return;
1392
1509
  }
1393
1510
  const trimmed = stdout.trim();
1394
- resolve({
1511
+ resolve2({
1395
1512
  taskId: input.taskId,
1396
- status: code === 0 ? "completed" : "failed",
1513
+ status: code === 0 && !outputLimitExceeded ? "completed" : "failed",
1397
1514
  summary: trimmed.length > 0 ? firstLine(trimmed) : void 0,
1398
1515
  rawOutput: trimmed.length > 0 ? trimmed : void 0,
1399
1516
  exitCode: code ?? void 0,
1400
1517
  logs,
1401
- error: code === 0 ? void 0 : `Process exited with code ${code ?? "unknown"}${signal ? ` (signal: ${signal})` : ""}`
1518
+ ...outputLimitExceeded ? { error: `Agent exceeded the ${maxOutputBytes} byte output limit and was terminated.` } : code === 0 ? {} : { error: describeExit(code, signal) }
1402
1519
  });
1403
1520
  });
1404
1521
  });
@@ -1407,7 +1524,7 @@ var ProcessAgent = class {
1407
1524
  /** Ask the agent to stop current work (SIGTERM). */
1408
1525
  async cancel(taskId) {
1409
1526
  const child = this.running.get(taskId);
1410
- if (child) child.kill("SIGTERM");
1527
+ if (child) killTree(child, "SIGTERM");
1411
1528
  }
1412
1529
  runningTaskIds() {
1413
1530
  return [...this.running.keys()];
@@ -1482,6 +1599,43 @@ function interpretProtocolRun({
1482
1599
  ...exitCode !== 0 ? { error: `Process exited with code ${exitCode ?? "unknown"}` } : {}
1483
1600
  };
1484
1601
  }
1602
+ var GLOBAL_RUNNING = /* @__PURE__ */ new Set();
1603
+ var signalForwardingInstalled = false;
1604
+ function installSignalForwarding() {
1605
+ if (signalForwardingInstalled) return;
1606
+ signalForwardingInstalled = true;
1607
+ for (const signal of ["SIGINT", "SIGTERM"]) {
1608
+ process.on(signal, () => {
1609
+ for (const child of GLOBAL_RUNNING) {
1610
+ killTree(child, "SIGTERM");
1611
+ }
1612
+ });
1613
+ }
1614
+ }
1615
+ function killTree(child, signal) {
1616
+ if (child.pid === void 0) return;
1617
+ if (process.platform !== "win32") {
1618
+ try {
1619
+ process.kill(-child.pid, signal);
1620
+ return;
1621
+ } catch {
1622
+ }
1623
+ }
1624
+ try {
1625
+ child.kill(signal);
1626
+ } catch {
1627
+ }
1628
+ }
1629
+ function describeExit(code, signal) {
1630
+ if (signal !== null) {
1631
+ return signal === "SIGKILL" ? "Process was killed (SIGKILL) \u2014 usually an external kill or OOM." : `Process terminated by signal ${signal}.`;
1632
+ }
1633
+ if (code === null) return "Process terminated without an exit code.";
1634
+ return `Process exited with code ${code}.`;
1635
+ }
1636
+ function sanitizeChunk(chunk) {
1637
+ return chunk.toString("utf8").replace(/\u0000/g, "");
1638
+ }
1485
1639
  function normalizeReport(payload, taskId, agentName, index) {
1486
1640
  if (payload === null || typeof payload !== "object") return void 0;
1487
1641
  const raw = payload;
@@ -1501,6 +1655,40 @@ function firstLine(text, maxLength = 200) {
1501
1655
  return line.length > maxLength ? `${line.slice(0, maxLength)}\u2026` : line;
1502
1656
  }
1503
1657
 
1658
+ // src/agents/adapters.ts
1659
+ var AgentAdapterRegistry = class {
1660
+ adapters = /* @__PURE__ */ new Map();
1661
+ register(adapter) {
1662
+ this.adapters.set(adapter.integration, adapter);
1663
+ }
1664
+ get(integration) {
1665
+ return this.adapters.get(integration);
1666
+ }
1667
+ list() {
1668
+ return [...this.adapters.values()];
1669
+ }
1670
+ create(config) {
1671
+ const adapter = this.get(config.integration);
1672
+ if (!adapter) {
1673
+ throw new AgentError(
1674
+ "AGENT_NOT_FOUND",
1675
+ `No agent adapter registered for integration "${config.integration}". Registered: ${[...this.adapters.keys()].join(", ") || "(none)"}.`
1676
+ );
1677
+ }
1678
+ return adapter.create(config);
1679
+ }
1680
+ };
1681
+ var ProcessAgentAdapter = class {
1682
+ constructor(factory) {
1683
+ this.factory = factory;
1684
+ }
1685
+ factory;
1686
+ integration = "process";
1687
+ create(config) {
1688
+ return this.factory(config);
1689
+ }
1690
+ };
1691
+
1504
1692
  // src/agents/registry.ts
1505
1693
  var AgentRegistry = class {
1506
1694
  agents = /* @__PURE__ */ new Map();
@@ -1516,14 +1704,15 @@ var AgentRegistry = class {
1516
1704
  };
1517
1705
 
1518
1706
  // src/agents/factory.ts
1519
- function createAgent(config) {
1520
- if (config.integration === "process") {
1521
- return new ProcessAgent(config);
1522
- }
1707
+ var defaultAgentAdapters = new AgentAdapterRegistry();
1708
+ defaultAgentAdapters.register(new ProcessAgentAdapter((config) => new ProcessAgent(config)));
1709
+ function createAgent(config, adapters = defaultAgentAdapters) {
1710
+ const registered = adapters.get(config.integration);
1711
+ if (registered) return registered.create(config);
1523
1712
  if (config.integration === "native") {
1524
1713
  throw new OmniNodeError(
1525
1714
  "NOT_IMPLEMENTED",
1526
- `Native agent adapters are planned for a later phase. Use input over stdin/stdout (type "cli") for "${config.name}" for now.`
1715
+ `No native adapter is registered for "${config.name}". Register an IAgentAdapter (createAgent(config, adapters)) or use input over stdin/stdout (type "cli").`
1527
1716
  );
1528
1717
  }
1529
1718
  throw new OmniNodeError("NOT_IMPLEMENTED", `Agent integration "${config.integration}" has no adapter yet.`);
@@ -1566,7 +1755,7 @@ function registerAgentCommands(program) {
1566
1755
  "--input-mode <mode>",
1567
1756
  "How the task is delivered: stdin (prompt on stdin), arg (prompt as last argument) or protocol (\xA721 JSON-lines task protocol).",
1568
1757
  "stdin"
1569
- ).option(
1758
+ ).option("--env-policy <policy>", "Environment policy: inherit, allowlist, denylist or explicit.").option("--allow-env <name>", "Variable to forward under the allowlist policy (repeatable).", collectArgs).option("--deny-env <name>", "Variable to remove under the denylist policy (repeatable).", collectArgs).option("--max-output-bytes <n>", "Per-stream output budget in bytes (default 5 MiB).", (value) => Number(value)).option("--allow-external-cwd", "Allow a working directory outside the project root.").option(
1570
1759
  "--timeout-ms <ms>",
1571
1760
  "Per-task timeout in milliseconds.",
1572
1761
  (value) => Number(value)
@@ -1581,7 +1770,12 @@ async function addAgent(options) {
1581
1770
  ...options.arg !== void 0 && options.arg.length > 0 ? { args: options.arg } : {},
1582
1771
  ...options.cwd !== void 0 ? { cwd: options.cwd } : {},
1583
1772
  input_mode: options.inputMode,
1584
- ...options.timeoutMs !== void 0 ? { timeout_ms: options.timeoutMs } : {}
1773
+ ...options.timeoutMs !== void 0 ? { timeout_ms: options.timeoutMs } : {},
1774
+ ...options.envPolicy !== void 0 ? { env_policy: options.envPolicy } : {},
1775
+ ...options.allowEnv !== void 0 && options.allowEnv.length > 0 ? { env_allowlist: options.allowEnv } : {},
1776
+ ...options.denyEnv !== void 0 && options.denyEnv.length > 0 ? { env_denylist: options.denyEnv } : {},
1777
+ ...options.maxOutputBytes !== void 0 ? { max_output_bytes: options.maxOutputBytes } : {},
1778
+ ...options.allowExternalCwd !== void 0 ? { allow_external_cwd: options.allowExternalCwd } : {}
1585
1779
  });
1586
1780
  if (!candidate.success) {
1587
1781
  const issues = candidate.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
@@ -1985,7 +2179,7 @@ function classifyThrown(error) {
1985
2179
  return "failed";
1986
2180
  }
1987
2181
  function sleep(ms) {
1988
- return new Promise((resolve) => setTimeout(resolve, ms));
2182
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
1989
2183
  }
1990
2184
 
1991
2185
  // src/audit/index.ts