@rivet-dev/agentos-core 0.2.4 → 0.2.5-rc.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/dist/agent-os.js CHANGED
@@ -2,9 +2,8 @@ import { execFileSync } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
- import { dirname, join, posix as posixPath, resolve as resolveHostPath, } from "node:path";
5
+ import { join, posix as posixPath, resolve as resolveHostPath, } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
- import commonSoftware from "@agentos-software/common";
8
7
  import { validateToolkits } from "./host-tools.js";
9
8
  import { zodToJsonSchema } from "./host-tools-zod.js";
10
9
  import { parseAgentOsOptions } from "./options-schema.js";
@@ -42,15 +41,16 @@ async function waitForTrackedExitPromises(promises, timeoutMs) {
42
41
  }),
43
42
  ]);
44
43
  }
45
- import { AGENT_CONFIGS } from "./agents.js";
46
- import { getBaseEnvironment, getBaseFilesystemEntries, } from "./base-filesystem.js";
44
+ import { getBaseEnvironment } from "./base-filesystem.js";
47
45
  import { CronManager } from "./cron/cron-manager.js";
48
46
  import { TimerScheduleDriver } from "./cron/timer-driver.js";
49
47
  import { snapshotVirtualFilesystem, sortFilesystemEntries, } from "./filesystem-snapshot.js";
50
48
  import { createHostDirBackend } from "./host-dir-mount.js";
51
49
  import { serializeMountConfigForSidecar, } from "./js-bridge.js";
52
50
  import { createSnapshotExport, } from "./layers.js";
53
- import { processSoftware, resolveAgentSnapshotBundle, resolvePackageDir, } from "./packages.js";
51
+ import { resolveAgentSnapshotBundle, } from "./packages.js";
52
+ import { OPT_AGENTOS_BIN, OPT_AGENTOS_ROOT, tryReadAgentosPackageManifest, } from "./agentos-package.js";
53
+ import { resolveDefaultSoftware, resolveDependencyAgents, } from "./default-software.js";
54
54
  import { allowAll } from "./runtime-compat.js";
55
55
  import { AcpRuntimeKind, decodeAcpCallback, decodeAcpEvent, decodeAcpResponse, encodeAcpCallbackResponse, encodeAcpRequest, } from "./sidecar/agentos-protocol.js";
56
56
  import { serializePermissionsForSidecar } from "./sidecar/permissions.js";
@@ -58,6 +58,9 @@ import { createAgentOsSidecarClient, NATIVE_SIDECAR_FRAME_TIMEOUT_MS, NativeSide
58
58
  function defaultAgentStderrHandler(event) {
59
59
  process.stderr.write(event.chunk);
60
60
  }
61
+ function defaultAgentExitHandler(event) {
62
+ process.stderr.write(`[agentos] agent adapter exited unexpectedly: session=${event.sessionId} agent=${event.agentType} exitCode=${event.exitCode ?? "unknown"} restart=${event.restart} (${event.restartCount}/${event.maxRestarts})\n`);
63
+ }
61
64
  const LEGACY_PERMISSION_METHOD = "request/permission";
62
65
  const ACP_PERMISSION_METHOD = "session/request_permission";
63
66
  class AcpDispatchError extends Error {
@@ -110,6 +113,53 @@ function toRecord(value) {
110
113
  ? value
111
114
  : {};
112
115
  }
116
+ function normalizePackageRef(value) {
117
+ if (typeof value === "string") {
118
+ return { dir: value };
119
+ }
120
+ const record = toRecord(value);
121
+ if (typeof record.packageDir === "string") {
122
+ return {
123
+ dir: record.packageDir,
124
+ legacyManifest: legacyPackageManifest(record),
125
+ };
126
+ }
127
+ if (typeof record.dir === "string") {
128
+ return {
129
+ dir: record.dir,
130
+ legacyManifest: legacyPackageManifest(record),
131
+ };
132
+ }
133
+ return undefined;
134
+ }
135
+ function legacyPackageManifest(record) {
136
+ if (typeof record.name !== "string") {
137
+ return undefined;
138
+ }
139
+ const manifest = { name: record.name };
140
+ const agent = toRecord(record.agent);
141
+ if (typeof agent.acpEntrypoint === "string") {
142
+ manifest.agent = {
143
+ acpEntrypoint: agent.acpEntrypoint,
144
+ ...(isStringRecord(agent.env) ? { env: agent.env } : {}),
145
+ ...(Array.isArray(agent.launchArgs) &&
146
+ agent.launchArgs.every((arg) => typeof arg === "string")
147
+ ? { launchArgs: agent.launchArgs }
148
+ : {}),
149
+ ...(typeof agent.snapshot === "boolean" ? { snapshot: agent.snapshot } : {}),
150
+ };
151
+ }
152
+ return manifest;
153
+ }
154
+ function readPackageManifestForClient(ref) {
155
+ return tryReadAgentosPackageManifest(ref.dir) ?? ref.legacyManifest;
156
+ }
157
+ function isStringRecord(value) {
158
+ return (value !== null &&
159
+ typeof value === "object" &&
160
+ !Array.isArray(value) &&
161
+ Object.values(value).every((entry) => typeof entry === "string"));
162
+ }
113
163
  function parseAcpJson(value, context) {
114
164
  if (value === null) {
115
165
  return undefined;
@@ -165,11 +215,11 @@ class BoundedSet {
165
215
  }
166
216
  this.limit = limit;
167
217
  }
168
- add(value) {
218
+ add(value, associated) {
169
219
  if (this.#entries.has(value)) {
170
220
  this.#entries.delete(value);
171
221
  }
172
- this.#entries.set(value, undefined);
222
+ this.#entries.set(value, associated);
173
223
  if (this.#entries.size <= this.limit) {
174
224
  return;
175
225
  }
@@ -181,6 +231,9 @@ class BoundedSet {
181
231
  has(value) {
182
232
  return this.#entries.has(value);
183
233
  }
234
+ get(value) {
235
+ return this.#entries.get(value);
236
+ }
184
237
  delete(value) {
185
238
  return this.#entries.delete(value);
186
239
  }
@@ -228,6 +281,7 @@ function sessionEntryFromInit(sessionId, agentType, initData) {
228
281
  agentInfo: initData.agentInfo ?? null,
229
282
  eventHandlers: new Set(),
230
283
  permissionHandlers: new Set(),
284
+ warnedNoPermissionHandler: false,
231
285
  configOverrides: new Map(),
232
286
  pendingPermissionReplies: new Map(),
233
287
  };
@@ -396,7 +450,31 @@ const KERNEL_POSIX_BOOTSTRAP_DIRS = [
396
450
  "/var/tmp",
397
451
  "/etc/agentos",
398
452
  ];
399
- const NODE_RUNTIME_BOOTSTRAP_COMMANDS = ["node", "npm", "npx"];
453
+ // Standard POSIX metadata for the bootstrap dirs whose mode/owner is NOT the
454
+ // default (`755`, root:root). Replicated as a constant so building the no-base
455
+ // bootstrap layer needs no `base-filesystem.json` read — when a base IS present
456
+ // the sidecar's embedded base layer is authoritative and these are never emitted.
457
+ const KERNEL_POSIX_BOOTSTRAP_DIR_METADATA = {
458
+ "/tmp": { mode: "1777", uid: 0, gid: 0 },
459
+ "/root": { mode: "700", uid: 0, gid: 0 },
460
+ "/sys": { mode: "555", uid: 0, gid: 0 },
461
+ "/home/agentos": { mode: "2755", uid: 1000, gid: 1000 },
462
+ "/workspace": { mode: "755", uid: 1000, gid: 1000 },
463
+ "/var/empty": { mode: "555", uid: 0, gid: 0 },
464
+ "/var/lock": { mode: "777", uid: 0, gid: 0 },
465
+ "/var/run": { mode: "777", uid: 0, gid: 0 },
466
+ "/var/tmp": { mode: "1777", uid: 0, gid: 0 },
467
+ };
468
+ // Runtime commands that get a `/bin/<cmd>` stub at bootstrap so the guest shell
469
+ // resolves them on PATH (e.g. `sh -c "python ..."`, pipelines). The sidecar
470
+ // intercepts these by name and routes them to the embedded V8 / Pyodide runtime.
471
+ const RUNTIME_BOOTSTRAP_COMMANDS = [
472
+ "node",
473
+ "npm",
474
+ "npx",
475
+ "python",
476
+ "python3",
477
+ ];
400
478
  const KERNEL_COMMAND_STUB = "#!/bin/sh\n# kernel command stub\n";
401
479
  const REPO_ROOT = fileURLToPath(new URL("../../..", import.meta.url));
402
480
  const SIDECAR_BINARY = join(REPO_ROOT, "target/debug/agentos-sidecar");
@@ -409,114 +487,6 @@ const SIDECAR_BUILD_INPUTS = [
409
487
  join(REPO_ROOT, "crates/sidecar"),
410
488
  ];
411
489
  let ensuredSidecarBinary = null;
412
- function isWasmBinaryFile(path) {
413
- try {
414
- const header = readFileSync(path);
415
- return (header.length >= 4 &&
416
- header[0] === 0x00 &&
417
- header[1] === 0x61 &&
418
- header[2] === 0x73 &&
419
- header[3] === 0x6d);
420
- }
421
- catch {
422
- return false;
423
- }
424
- }
425
- function collectBootstrapWasmCommands(commandDirs) {
426
- const commands = [];
427
- const seen = new Set();
428
- for (const dir of commandDirs) {
429
- let entries;
430
- try {
431
- entries = readdirSync(dir).sort((a, b) => a.localeCompare(b));
432
- }
433
- catch {
434
- continue;
435
- }
436
- for (const entry of entries) {
437
- if (entry.startsWith(".")) {
438
- continue;
439
- }
440
- const fullPath = join(dir, entry);
441
- try {
442
- if (statSync(fullPath).isDirectory()) {
443
- continue;
444
- }
445
- }
446
- catch {
447
- continue;
448
- }
449
- if (!isWasmBinaryFile(fullPath) || seen.has(entry)) {
450
- continue;
451
- }
452
- seen.add(entry);
453
- commands.push(entry);
454
- }
455
- }
456
- return commands;
457
- }
458
- function resolveDeclaredCommandSource(commandDir, commandName, aliases) {
459
- let current = commandName;
460
- const visited = new Set();
461
- while (!visited.has(current)) {
462
- visited.add(current);
463
- const candidatePath = join(commandDir, current);
464
- if (isWasmBinaryFile(candidatePath)) {
465
- return candidatePath;
466
- }
467
- const next = aliases[current];
468
- if (!next) {
469
- return null;
470
- }
471
- current = next;
472
- }
473
- return null;
474
- }
475
- function prepareCommandDirs(commandPackages) {
476
- const commandDirs = [];
477
- const tempDirs = [];
478
- try {
479
- for (const commandPackage of commandPackages) {
480
- commandDirs.push(commandPackage.commandDir);
481
- const aliasEntries = Object.entries(commandPackage.aliases)
482
- .sort(([leftAlias], [rightAlias]) => leftAlias.localeCompare(rightAlias))
483
- .flatMap(([aliasName]) => {
484
- const aliasPath = join(commandPackage.commandDir, aliasName);
485
- if (isWasmBinaryFile(aliasPath)) {
486
- return [];
487
- }
488
- const sourcePath = resolveDeclaredCommandSource(commandPackage.commandDir, aliasName, commandPackage.aliases);
489
- if (!sourcePath) {
490
- return [];
491
- }
492
- return [[aliasName, sourcePath]];
493
- });
494
- if (aliasEntries.length === 0) {
495
- continue;
496
- }
497
- const aliasDir = mkdtempSync(join(tmpdir(), "agentos-command-aliases-"));
498
- for (const [aliasName, sourcePath] of aliasEntries) {
499
- writeFileSync(join(aliasDir, aliasName), readFileSync(sourcePath));
500
- }
501
- tempDirs.push(aliasDir);
502
- commandDirs.push(aliasDir);
503
- }
504
- }
505
- catch (error) {
506
- for (const tempDir of tempDirs) {
507
- rmSync(tempDir, { recursive: true, force: true });
508
- }
509
- throw error;
510
- }
511
- return {
512
- commandDirs,
513
- dispose() {
514
- for (const tempDir of tempDirs) {
515
- rmSync(tempDir, { recursive: true, force: true });
516
- }
517
- },
518
- };
519
- }
520
490
  function collectConfiguredLowerPaths(config) {
521
491
  const paths = new Set();
522
492
  for (const lower of config?.lowers ?? []) {
@@ -539,16 +509,15 @@ function findBootstrapSeedEntry(config, path) {
539
509
  return entry;
540
510
  }
541
511
  }
542
- return getBaseFilesystemEntries().find((entry) => entry.path === path);
512
+ // No base-filesystem JSON read: standard non-default dir metadata comes from
513
+ // the constant table. When a base layer IS present these dirs are never
514
+ // emitted (see createKernelBootstrapLower), so this only seeds the no-base case.
515
+ const meta = KERNEL_POSIX_BOOTSTRAP_DIR_METADATA[path];
516
+ return meta ? { path, type: "directory", ...meta } : undefined;
543
517
  }
544
518
  function createKernelBootstrapLower(config, commandNames, extraEntries = []) {
545
519
  const includesBundledBaseLayer = !(config?.disableDefaultBaseLayer ?? false);
546
520
  const existingPaths = collectConfiguredLowerPaths(config);
547
- if (includesBundledBaseLayer) {
548
- for (const entry of getBaseFilesystemEntries()) {
549
- existingPaths.add(entry.path);
550
- }
551
- }
552
521
  const entries = [
553
522
  {
554
523
  path: "/",
@@ -558,18 +527,24 @@ function createKernelBootstrapLower(config, commandNames, extraEntries = []) {
558
527
  gid: 0,
559
528
  },
560
529
  ];
561
- for (const dir of KERNEL_POSIX_BOOTSTRAP_DIRS) {
562
- if (existingPaths.has(dir)) {
563
- continue;
530
+ // Only run the FS bootstrap (creating the POSIX dir tree) when there is NO
531
+ // base layer. When the bundled base IS present, the sidecar's embedded base
532
+ // layer already provides every POSIX dir with the correct mode/owner, so we
533
+ // emit nothing here and never read its filesystem table.
534
+ if (!includesBundledBaseLayer) {
535
+ for (const dir of KERNEL_POSIX_BOOTSTRAP_DIRS) {
536
+ if (existingPaths.has(dir)) {
537
+ continue;
538
+ }
539
+ const seed = findBootstrapSeedEntry(config, dir);
540
+ entries.push({
541
+ path: dir,
542
+ type: "directory",
543
+ mode: seed?.type === "directory" ? seed.mode : "755",
544
+ uid: seed?.uid ?? 0,
545
+ gid: seed?.gid ?? 0,
546
+ });
564
547
  }
565
- const seed = findBootstrapSeedEntry(config, dir);
566
- entries.push({
567
- path: dir,
568
- type: "directory",
569
- mode: seed?.type === "directory" ? seed.mode : "755",
570
- uid: seed?.uid ?? 0,
571
- gid: seed?.gid ?? 0,
572
- });
573
548
  }
574
549
  if (!includesBundledBaseLayer && !existingPaths.has("/usr/bin/env")) {
575
550
  entries.push({
@@ -712,28 +687,6 @@ function latestMtimeMs(path) {
712
687
  }
713
688
  return latest;
714
689
  }
715
- function collectGuestCommandPaths(commandDirs) {
716
- const guestPaths = new Map();
717
- for (const [index, commandDir] of commandDirs.entries()) {
718
- let entries;
719
- try {
720
- entries = readdirSync(commandDir).sort((left, right) => left.localeCompare(right));
721
- }
722
- catch {
723
- continue;
724
- }
725
- for (const entry of entries) {
726
- if (entry.startsWith(".")) {
727
- continue;
728
- }
729
- if (!isWasmBinaryFile(join(commandDir, entry)) || guestPaths.has(entry)) {
730
- continue;
731
- }
732
- guestPaths.set(entry, `/__secure_exec/commands/${index}/${entry}`);
733
- }
734
- }
735
- return guestPaths;
736
- }
737
690
  async function resolveCompatLocalMounts(mounts) {
738
691
  if (!mounts) {
739
692
  return [];
@@ -816,26 +769,6 @@ function collectSidecarMountPlan(options) {
816
769
  }
817
770
  pushMount(mount);
818
771
  }
819
- for (const root of options.softwareRoots) {
820
- pushMount({
821
- path: root.vmPath,
822
- plugin: createHostDirBackend({
823
- hostPath: root.hostPath,
824
- readOnly: true,
825
- }),
826
- readOnly: true,
827
- });
828
- }
829
- for (const [index, commandDir] of options.commandDirs.entries()) {
830
- pushMount({
831
- path: `/__secure_exec/commands/${index}`,
832
- plugin: createHostDirBackend({
833
- hostPath: commandDir,
834
- readOnly: true,
835
- }),
836
- readOnly: true,
837
- });
838
- }
839
772
  if (options.shimDir) {
840
773
  pushMount({
841
774
  path: "/usr/local/bin",
@@ -1527,9 +1460,13 @@ export class AgentOs {
1527
1460
  _pendingSessionRequestResolvers = new Map();
1528
1461
  _processes = new Map();
1529
1462
  _shells = new Map();
1463
+ // Value is the recorded exit code (undefined until/unless the exit
1464
+ // resolves) so waitShell can still report it after the entry is dropped.
1530
1465
  _closedShellIds = new BoundedSet(CLOSED_SHELL_ID_RETENTION_LIMIT);
1531
1466
  _pendingShellExitPromises = new Set();
1532
1467
  _shellCounter = 0;
1468
+ /** Command names linked into `/opt/agentos/bin` at runtime (via the sidecar). */
1469
+ _linkedCommands = new Set();
1533
1470
  _acpTerminals = new Map();
1534
1471
  _acpTerminalCounter = 0;
1535
1472
  _softwareRoots;
@@ -1548,8 +1485,9 @@ export class AgentOs {
1548
1485
  _sidecarVm;
1549
1486
  _disposeSidecarEventListener;
1550
1487
  _agentStderrHandler;
1488
+ _agentExitHandler;
1551
1489
  _limitWarningHandler;
1552
- constructor(kernel, sidecar, softwareRoots, softwareAgentConfigs, hostMounts, env, rootFilesystem, sidecarClient, sidecarSession, sidecarVm, additionalInstructions, agentStderrHandler, limitWarningHandler) {
1490
+ constructor(kernel, sidecar, softwareRoots, softwareAgentConfigs, hostMounts, env, rootFilesystem, sidecarClient, sidecarSession, sidecarVm, additionalInstructions, agentStderrHandler, agentExitHandler, limitWarningHandler) {
1553
1491
  this.#kernel = kernel;
1554
1492
  this.sidecar = sidecar;
1555
1493
  this._softwareRoots = softwareRoots;
@@ -1562,6 +1500,7 @@ export class AgentOs {
1562
1500
  this._sidecarVm = sidecarVm;
1563
1501
  this._additionalInstructions = additionalInstructions;
1564
1502
  this._agentStderrHandler = agentStderrHandler;
1503
+ this._agentExitHandler = agentExitHandler;
1565
1504
  this._limitWarningHandler = limitWarningHandler;
1566
1505
  this._disposeSidecarEventListener = this._sidecarClient.onEvent((event) => {
1567
1506
  this._handleSidecarEvent(event);
@@ -1581,13 +1520,45 @@ export class AgentOs {
1581
1520
  }
1582
1521
  static async create(options) {
1583
1522
  options = parseAgentOsOptions(options);
1523
+ // Default software is FULLY DYNAMIC: this package's own NON-agent
1524
+ // @agentos-software/* dependencies (e.g. common), each default-exporting
1525
+ // its registry-built descriptor. Agent packages are NOT projected here —
1526
+ // createSession(id) links the matching agent dependency into the running
1527
+ // VM on first use, so agent closures (and pi's V8 snapshot bundle) only
1528
+ // enter VMs that run them. Unbuilt packages throw with build
1529
+ // instructions; opt out via defaultSoftware: false.
1530
+ const defaultSoftware = options?.defaultSoftware === false ? [] : await resolveDefaultSoftware();
1584
1531
  const software = options?.defaultSoftware === false
1585
1532
  ? (options.software ?? [])
1586
- : [commonSoftware, ...(options?.software ?? [])];
1587
- const processed = processSoftware(software);
1533
+ : [...defaultSoftware, ...(options?.software ?? [])];
1534
+ // Package dirs are projected by the SIDECAR: the client forwards only
1535
+ // `{dir}` over `configureVm` and the sidecar reads package metadata from
1536
+ // `<dir>/agentos-package.json`.
1537
+ const flatSoftware = software.flat();
1538
+ const packageRefs = flatSoftware.flatMap((entry) => {
1539
+ const ref = normalizePackageRef(entry);
1540
+ return ref ? [ref] : [];
1541
+ });
1542
+ const sidecarPackages = packageRefs.map((ref) => ({ dir: ref.dir }));
1543
+ // All package software is projected into `/opt/agentos` by the sidecar. The
1544
+ // client stages nothing host-side; it only derives the agent configs.
1545
+ const agentConfigs = new Map();
1546
+ // Register `/opt/agentos` agent packages so `createSession(<name>)`
1547
+ // launches via `/opt/agentos/bin/<acpEntrypoint>`. The wire no longer
1548
+ // carries agent metadata; read it from the package manifest.
1549
+ for (const ref of packageRefs) {
1550
+ const manifest = readPackageManifestForClient(ref);
1551
+ if (!manifest?.agent)
1552
+ continue;
1553
+ agentConfigs.set(manifest.name, {
1554
+ adapterEntrypoint: `${OPT_AGENTOS_BIN}/${manifest.agent.acpEntrypoint}`,
1555
+ launchArgs: manifest.agent.launchArgs,
1556
+ defaultEnv: manifest.agent.env,
1557
+ });
1558
+ }
1588
1559
  // Agent-SDK snapshot bundle (loaded once per sidecar into the V8 startup
1589
1560
  // snapshot, reused across sessions) for any snapshot-enabled agent.
1590
- const snapshotUserlandCode = resolveAgentSnapshotBundle(software);
1561
+ const snapshotUserlandCode = resolveAgentSnapshotBundle(packageRefs.map((ref) => ({ packageDir: ref.dir })));
1591
1562
  const localMounts = await resolveCompatLocalMounts(options?.mounts);
1592
1563
  const toolKits = options?.toolKits;
1593
1564
  if (toolKits && toolKits.length > 0) {
@@ -1597,13 +1568,12 @@ export class AgentOs {
1597
1568
  // one shared native sidecar process owned by that handle.
1598
1569
  const sidecar = resolveAgentOsSidecar(options?.sidecar);
1599
1570
  const createVmAdmin = async () => {
1600
- const preparedCommandDirs = prepareCommandDirs(processed.commandPackages);
1571
+ // The `/opt/agentos` projection is built by the sidecar from the
1572
+ // forwarded `packages` (it owns the staging dir + read-only mount, and
1573
+ // runtime `linkSoftware` appends to that live dir). The client no longer
1574
+ // stages packages host-side.
1601
1575
  const toolBootstrapCommands = collectToolkitBootstrapCommands(toolKits ?? []);
1602
- const bootstrapLower = createKernelBootstrapLower(options?.rootFilesystem, [
1603
- ...collectBootstrapWasmCommands(preparedCommandDirs.commandDirs),
1604
- ...NODE_RUNTIME_BOOTSTRAP_COMMANDS,
1605
- ...toolBootstrapCommands,
1606
- ]);
1576
+ const bootstrapLower = createKernelBootstrapLower(options?.rootFilesystem, [...RUNTIME_BOOTSTRAP_COMMANDS, ...toolBootstrapCommands]);
1607
1577
  let toolReference = "";
1608
1578
  let rootBridge = null;
1609
1579
  let kernel = null;
@@ -1621,14 +1591,47 @@ export class AgentOs {
1621
1591
  rmSync(toolShimDir, { recursive: true, force: true });
1622
1592
  toolShimDir = null;
1623
1593
  }
1624
- preparedCommandDirs.dispose();
1625
1594
  };
1626
1595
  try {
1627
1596
  const env = getBaseEnvironment();
1628
1597
  if (toolKits && toolKits.length > 0) {
1629
1598
  toolShimDir = materializeToolShimDir(toolKits);
1630
1599
  }
1631
- const commandGuestPaths = collectGuestCommandPaths(preparedCommandDirs.commandDirs);
1600
+ // Guest command paths. The sidecar owns the `/opt/agentos` projection,
1601
+ // but the client's command map (rpc-client) still needs to KNOW the
1602
+ // projected command names so its shell-exec guard (`this.commands.has("sh")`)
1603
+ // and wasmvm routing work. Seed each projected package's commands from its
1604
+ // `bin` map (mirroring the sidecar projection's command derivation), mapped
1605
+ // to their projected `/opt/agentos/bin/<cmd>` path. Tool-shim commands are
1606
+ // added below.
1607
+ const commandGuestPaths = new Map();
1608
+ const deriveProjectedCommandNames = (dir) => {
1609
+ try {
1610
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
1611
+ if (pkg.bin && typeof pkg.bin === "object") {
1612
+ return Object.keys(pkg.bin);
1613
+ }
1614
+ if (typeof pkg.bin === "string") {
1615
+ const base = pkg.name?.split("/").pop();
1616
+ if (base)
1617
+ return [base];
1618
+ }
1619
+ }
1620
+ catch {
1621
+ // no/invalid package.json — fall through to the bin/ scan
1622
+ }
1623
+ try {
1624
+ return readdirSync(join(dir, "bin"));
1625
+ }
1626
+ catch {
1627
+ return [];
1628
+ }
1629
+ };
1630
+ for (const ref of packageRefs) {
1631
+ for (const cmd of deriveProjectedCommandNames(ref.dir)) {
1632
+ commandGuestPaths.set(cmd, `/opt/agentos/bin/${cmd}`);
1633
+ }
1634
+ }
1632
1635
  const requestedMounts = options?.moduleAccessCwd
1633
1636
  ? [
1634
1637
  ...(options.mounts ?? []),
@@ -1644,8 +1647,6 @@ export class AgentOs {
1644
1647
  : options?.mounts;
1645
1648
  const { sidecarMounts, hostMounts, hostPathMappings } = collectSidecarMountPlan({
1646
1649
  mounts: requestedMounts,
1647
- softwareRoots: processed.softwareRoots,
1648
- commandDirs: preparedCommandDirs.commandDirs,
1649
1650
  shimDir: toolShimDir,
1650
1651
  });
1651
1652
  // Reuse the sidecar handle's single shared native process; this VM
@@ -1672,6 +1673,7 @@ export class AgentOs {
1672
1673
  // emulation), matching the prior behavior where Agent OS only
1673
1674
  // constrained the builtin allow-list.
1674
1675
  ...(options?.allowedNodeBuiltins !== undefined ||
1676
+ options?.highResolutionTime !== undefined ||
1675
1677
  snapshotUserlandCode !== undefined
1676
1678
  ? {
1677
1679
  jsRuntime: {
@@ -1680,6 +1682,9 @@ export class AgentOs {
1680
1682
  ...(options?.allowedNodeBuiltins !== undefined
1681
1683
  ? { allowedBuiltins: options.allowedNodeBuiltins }
1682
1684
  : {}),
1685
+ ...(options?.highResolutionTime !== undefined
1686
+ ? { highResolutionTime: options.highResolutionTime }
1687
+ : {}),
1683
1688
  ...(snapshotUserlandCode !== undefined
1684
1689
  ? { snapshotUserlandCode }
1685
1690
  : {}),
@@ -1701,8 +1706,10 @@ export class AgentOs {
1701
1706
  await client.configureVm(session, nativeVm, {
1702
1707
  mounts: sidecarMounts,
1703
1708
  permissions: sidecarPermissions,
1704
- commandPermissions: processed.commandPermissions,
1709
+ commandPermissions: {},
1705
1710
  loopbackExemptPorts: options?.loopbackExemptPorts,
1711
+ packages: sidecarPackages,
1712
+ packagesMountAt: OPT_AGENTOS_ROOT,
1706
1713
  });
1707
1714
  if (toolKits && toolKits.length > 0) {
1708
1715
  toolReference = await registerToolkitsOnSidecar(client, session, nativeVm, toolKits);
@@ -1720,7 +1727,7 @@ export class AgentOs {
1720
1727
  localMounts,
1721
1728
  sidecarMounts,
1722
1729
  permissions: sidecarPermissions,
1723
- commandPermissions: processed.commandPermissions,
1730
+ commandPermissions: {},
1724
1731
  loopbackExemptPorts: options?.loopbackExemptPorts,
1725
1732
  commandGuestPaths,
1726
1733
  onDispose: cleanup,
@@ -1738,7 +1745,7 @@ export class AgentOs {
1738
1745
  rootView: rootBridge.createRootView(),
1739
1746
  sidecarMounts,
1740
1747
  sidecarPermissions,
1741
- commandPermissions: processed.commandPermissions,
1748
+ commandPermissions: {},
1742
1749
  loopbackExemptPorts: options?.loopbackExemptPorts,
1743
1750
  sidecarClient: client,
1744
1751
  sidecarSession: session,
@@ -1789,7 +1796,7 @@ export class AgentOs {
1789
1796
  createVm: async () => createVmAdmin(),
1790
1797
  });
1791
1798
  const vmAdmin = sidecarLease.admin;
1792
- const vm = new AgentOs(vmAdmin.kernel, sidecar, processed.softwareRoots, processed.agentConfigs, vmAdmin.hostMounts, vmAdmin.env, vmAdmin.rootView, vmAdmin.sidecarClient, vmAdmin.sidecarSession, vmAdmin.sidecarVm, options?.additionalInstructions, options?.onAgentStderr ?? defaultAgentStderrHandler, options?.onLimitWarning);
1799
+ const vm = new AgentOs(vmAdmin.kernel, sidecar, [], agentConfigs, vmAdmin.hostMounts, vmAdmin.env, vmAdmin.rootView, vmAdmin.sidecarClient, vmAdmin.sidecarSession, vmAdmin.sidecarVm, options?.additionalInstructions, options?.onAgentStderr ?? defaultAgentStderrHandler, options?.onAgentExit ?? defaultAgentExitHandler, options?.onLimitWarning);
1793
1800
  vm._sidecarLease = sidecarLease;
1794
1801
  vm._toolKits = vmAdmin.toolKits;
1795
1802
  vm._toolReference = vmAdmin.toolReference;
@@ -1858,14 +1865,14 @@ export class AgentOs {
1858
1865
  const entry = this._processes.get(pid);
1859
1866
  if (!entry)
1860
1867
  throw new Error(`Process not found: ${pid}`);
1861
- entry.proc.writeStdin(data);
1868
+ return entry.proc.writeStdin(data);
1862
1869
  }
1863
1870
  /** Close a process's stdin stream. */
1864
1871
  closeProcessStdin(pid) {
1865
1872
  const entry = this._processes.get(pid);
1866
1873
  if (!entry)
1867
1874
  throw new Error(`Process not found: ${pid}`);
1868
- entry.proc.closeStdin();
1875
+ return entry.proc.closeStdin();
1869
1876
  }
1870
1877
  /** Subscribe to stdout data from a process. Returns an unsubscribe function. */
1871
1878
  onProcessStdout(pid, handler) {
@@ -2156,15 +2163,24 @@ export class AgentOs {
2156
2163
  const entry = {
2157
2164
  handle,
2158
2165
  dataHandlers,
2159
- exitPromise: Promise.resolve(),
2166
+ exitPromise: Promise.resolve(0),
2160
2167
  };
2161
- const exitPromise = handle.wait().then(() => undefined, () => undefined);
2162
- entry.exitPromise = exitPromise.finally(() => {
2168
+ const exitPromise = handle.wait();
2169
+ const finalize = (exitCode) => {
2163
2170
  this._pendingShellExitPromises.delete(entry.exitPromise);
2164
2171
  if (this._shells.get(shellId) === entry) {
2165
2172
  this._shells.delete(shellId);
2166
- this._closedShellIds.add(shellId);
2167
2173
  }
2174
+ // Record the exit code even when closeShell already dropped the
2175
+ // entry, so a waitShell issued after exit still resolves with it.
2176
+ this._closedShellIds.add(shellId, exitCode);
2177
+ };
2178
+ entry.exitPromise = exitPromise.then((exitCode) => {
2179
+ finalize(exitCode);
2180
+ return exitCode;
2181
+ }, (error) => {
2182
+ finalize();
2183
+ throw error;
2168
2184
  });
2169
2185
  this._pendingShellExitPromises.add(entry.exitPromise);
2170
2186
  this._shells.set(shellId, entry);
@@ -2178,7 +2194,7 @@ export class AgentOs {
2178
2194
  const entry = this._shells.get(shellId);
2179
2195
  if (!entry)
2180
2196
  throw new Error(`Shell not found: ${shellId}`);
2181
- entry.handle.write(data);
2197
+ return entry.handle.write(data);
2182
2198
  }
2183
2199
  /** Subscribe to data output from a shell. Returns an unsubscribe function. */
2184
2200
  onShellData(shellId, handler) {
@@ -2197,6 +2213,21 @@ export class AgentOs {
2197
2213
  throw new Error(`Shell not found: ${shellId}`);
2198
2214
  entry.handle.resize(cols, rows);
2199
2215
  }
2216
+ /**
2217
+ * Wait for a shell to exit and return its process exit code. Resolves
2218
+ * immediately for a shell that has already exited (within the closed-shell
2219
+ * retention window).
2220
+ */
2221
+ waitShell(shellId) {
2222
+ const entry = this._shells.get(shellId);
2223
+ if (!entry) {
2224
+ const exitCode = this._closedShellIds.get(shellId);
2225
+ if (exitCode !== undefined)
2226
+ return Promise.resolve(exitCode);
2227
+ throw new Error(`Shell not found: ${shellId}`);
2228
+ }
2229
+ return entry.exitPromise;
2230
+ }
2200
2231
  /** Kill a shell process and remove it from tracking. */
2201
2232
  closeShell(shellId) {
2202
2233
  const entry = this._shells.get(shellId);
@@ -2311,22 +2342,70 @@ export class AgentOs {
2311
2342
  }
2312
2343
  return session;
2313
2344
  }
2345
+ /**
2346
+ * Dynamically link a software package into the RUNNING VM. The package's
2347
+ * `bin/` commands appear under `/opt/agentos/bin` (on `$PATH`) and its `share/man`
2348
+ * pages under MANPATH immediately — the `/opt/agentos` mount is host-backed, so
2349
+ * writing into its staging dir is reflected live with no reboot. An `agent`
2350
+ * block registers the package for `createSession(name)`. Persists for the VM's
2351
+ * lifetime (and across a snapshot iff the volume persists).
2352
+ */
2353
+ async linkSoftware(descriptor) {
2354
+ const ref = normalizePackageRef(descriptor);
2355
+ if (!ref) {
2356
+ throw new Error("Invalid agentOS package reference");
2357
+ }
2358
+ // Forward to the sidecar, which owns the `/opt/agentos` projection and
2359
+ // appends the package to its live host-backed staging dir; the commands
2360
+ // appear under `/opt/agentos/bin` immediately. The sidecar rejects a
2361
+ // duplicate command, surfaced here as a thrown error.
2362
+ const commands = await this._sidecarClient.linkPackage(this._sidecarSession, this._sidecarVm, { dir: ref.dir });
2363
+ for (const command of commands) {
2364
+ this._linkedCommands.add(command);
2365
+ }
2366
+ const manifest = readPackageManifestForClient(ref);
2367
+ if (manifest?.agent) {
2368
+ this._softwareAgentConfigs.set(manifest.name, {
2369
+ adapterEntrypoint: `${OPT_AGENTOS_BIN}/${manifest.agent.acpEntrypoint}`,
2370
+ launchArgs: manifest.agent.launchArgs,
2371
+ defaultEnv: manifest.agent.env,
2372
+ });
2373
+ }
2374
+ }
2314
2375
  /** Returns all registered agents with their installation status. */
2315
2376
  listAgents() {
2316
- // Collect all agent IDs from both package configs and hardcoded configs.
2377
+ // Collect agent IDs from package configs, the hardcoded configs, and the
2378
+ // @agentos-software/* agent dependencies (linked lazily on first
2379
+ // createSession — see createSession).
2380
+ const dependencyAgents = resolveDependencyAgents();
2317
2381
  const allIds = new Set([
2318
2382
  ...this._softwareAgentConfigs.keys(),
2319
- ...Object.keys(AGENT_CONFIGS),
2383
+ ...dependencyAgents.keys(),
2320
2384
  ]);
2321
2385
  return [...allIds]
2322
2386
  .map((id) => {
2323
- const config = this._resolveAgentConfig(id);
2324
- if (!config)
2325
- return null;
2387
+ let config = this._resolveAgentConfig(id);
2388
+ if (!config) {
2389
+ // Dependency agent not linked yet — report it from its manifest.
2390
+ const dependencyAgent = dependencyAgents.get(id);
2391
+ if (!dependencyAgent)
2392
+ return null;
2393
+ config = {
2394
+ adapterEntrypoint: `${OPT_AGENTOS_BIN}/${dependencyAgent.acpEntrypoint}`,
2395
+ };
2396
+ }
2397
+ // An `/opt/agentos` agent package is materialized into the VM at
2398
+ // boot, so it is always "installed" — its adapter is a real command.
2399
+ if (config.adapterEntrypoint || !config.acpAdapter) {
2400
+ return {
2401
+ id,
2402
+ adapterEntrypoint: config.adapterEntrypoint,
2403
+ installed: true,
2404
+ };
2405
+ }
2326
2406
  let installed = false;
2327
2407
  try {
2328
- // Check software roots first, then the host dir behind the
2329
- // `/root/node_modules` mount.
2408
+ // Check the software roots that provide this adapter package.
2330
2409
  const vmPrefix = `/root/node_modules/${config.acpAdapter}`;
2331
2410
  let hostPkgJsonPath = null;
2332
2411
  for (const root of this._softwareRoots) {
@@ -2335,12 +2414,6 @@ export class AgentOs {
2335
2414
  break;
2336
2415
  }
2337
2416
  }
2338
- if (!hostPkgJsonPath) {
2339
- const nodeModulesRoot = this._nodeModulesHostRoot();
2340
- if (nodeModulesRoot) {
2341
- hostPkgJsonPath = join(resolvePackageDir(nodeModulesRoot, config.acpAdapter), "package.json");
2342
- }
2343
- }
2344
2417
  if (!hostPkgJsonPath) {
2345
2418
  throw new Error("no package source");
2346
2419
  }
@@ -2436,6 +2509,60 @@ export class AgentOs {
2436
2509
  session.eventHandlers.delete(subscriber);
2437
2510
  };
2438
2511
  }
2512
+ /**
2513
+ * Warn once per session (host-visible) that a tool-permission request was
2514
+ * auto-denied because no `onPermissionRequest` handler is registered. Shared
2515
+ * by both the bare-callback and JSON-RPC permission paths so the message and
2516
+ * the once-per-session guard cannot drift between them.
2517
+ */
2518
+ _warnNoPermissionHandlerOnce(session, params) {
2519
+ if (session.warnedNoPermissionHandler) {
2520
+ return;
2521
+ }
2522
+ session.warnedNoPermissionHandler = true;
2523
+ this._emitSessionWarning(session, `agentos: a tool-permission request (${this._permissionToolLabel(params)}) was ` +
2524
+ `auto-denied because no onPermissionRequest handler is registered for session ` +
2525
+ `${session.sessionId}. Register one with vm.onPermissionRequest(sessionId, ...) and ` +
2526
+ `reply via vm.respondPermission(...) to let the agent use tools.`);
2527
+ }
2528
+ /** Best-effort human label for the tool named in a permission request. */
2529
+ _permissionToolLabel(params) {
2530
+ if (typeof params.toolName === "string") {
2531
+ return params.toolName;
2532
+ }
2533
+ const toolCall = params.toolCall;
2534
+ if (toolCall &&
2535
+ typeof toolCall === "object" &&
2536
+ typeof toolCall.title === "string") {
2537
+ return toolCall.title;
2538
+ }
2539
+ return "a tool";
2540
+ }
2541
+ /**
2542
+ * Emit a host-visible warning for a session through the same agent-process log
2543
+ * channel that surfaces adapter stderr (`onAgentStderr`, default: process
2544
+ * stderr). Used for agent-os-owned diagnostics — e.g. a permission request
2545
+ * that was auto-denied because no host hook is registered — so they never fire
2546
+ * silently inside the sidecar.
2547
+ */
2548
+ _emitSessionWarning(session, message) {
2549
+ const handler = this._agentStderrHandler;
2550
+ if (!handler) {
2551
+ return;
2552
+ }
2553
+ try {
2554
+ handler({
2555
+ sessionId: session.sessionId,
2556
+ agentType: session.agentType,
2557
+ processId: session.processId,
2558
+ pid: session.pid,
2559
+ chunk: new TextEncoder().encode(`${message}\n`),
2560
+ });
2561
+ }
2562
+ catch {
2563
+ // A warning sink failure must never affect permission handling.
2564
+ }
2565
+ }
2439
2566
  _recordAgentStderr(event) {
2440
2567
  const session = (event.sessionId ? this._sessions.get(event.sessionId) : undefined) ??
2441
2568
  [...this._sessions.values()].find((candidate) => candidate.processId === event.processId);
@@ -2460,6 +2587,28 @@ export class AgentOs {
2460
2587
  // Ignore subscriber callback failures and keep event delivery moving.
2461
2588
  }
2462
2589
  }
2590
+ _recordAgentExit(event) {
2591
+ const session = this._sessions.get(event.sessionId);
2592
+ const handler = this._agentExitHandler;
2593
+ if (!handler) {
2594
+ return;
2595
+ }
2596
+ try {
2597
+ handler({
2598
+ sessionId: event.sessionId,
2599
+ agentType: event.agentType || session?.agentType || "",
2600
+ processId: event.processId,
2601
+ pid: session?.pid ?? null,
2602
+ exitCode: event.exitCode,
2603
+ restart: event.restart,
2604
+ restartCount: event.restartCount,
2605
+ maxRestarts: event.maxRestarts,
2606
+ });
2607
+ }
2608
+ catch {
2609
+ // Ignore subscriber callback failures and keep event delivery moving.
2610
+ }
2611
+ }
2463
2612
  _applySyntheticConfigOverrides(session) {
2464
2613
  if (session.configOverrides.size === 0) {
2465
2614
  return;
@@ -2545,6 +2694,10 @@ export class AgentOs {
2545
2694
  this._recordAgentStderr(event.val);
2546
2695
  return;
2547
2696
  }
2697
+ case "AcpAgentExitedEvent": {
2698
+ this._recordAgentExit(event.val);
2699
+ return;
2700
+ }
2548
2701
  }
2549
2702
  }
2550
2703
  catch {
@@ -2760,7 +2913,19 @@ export class AgentOs {
2760
2913
  this._syncSessionState(session, state);
2761
2914
  }
2762
2915
  async createSession(agentType, options) {
2763
- const config = this._resolveAgentConfig(agentType);
2916
+ let config = this._resolveAgentConfig(agentType);
2917
+ if (!config) {
2918
+ // Lazily link an agent dependency on first use: agent packages are not
2919
+ // projected by default (each carries a full node closure), so resolve
2920
+ // the @agentos-software/* dep whose packed manifest name matches and
2921
+ // link it into the running VM now. linkSoftware registers its
2922
+ // entrypoint/env from the package's own agentos-package.json.
2923
+ const dependencyAgent = resolveDependencyAgents().get(String(agentType));
2924
+ if (dependencyAgent) {
2925
+ await this.linkSoftware({ packageDir: dependencyAgent.packageDir });
2926
+ config = this._resolveAgentConfig(agentType);
2927
+ }
2928
+ }
2764
2929
  if (!config) {
2765
2930
  throw new Error(`Unknown agent type: ${agentType}`);
2766
2931
  }
@@ -2768,15 +2933,14 @@ export class AgentOs {
2768
2933
  // the sidecar at AcpCreateSessionRequest. The host only forwards additionalInstructions /
2769
2934
  // skipOsInstructions plus the agent's static launch args and env.
2770
2935
  const launchArgs = [...(config.launchArgs ?? [])];
2771
- let launchEnv = { ...config.defaultEnv, ...options?.env };
2936
+ const launchEnv = { ...config.defaultEnv, ...options?.env };
2772
2937
  const sessionCwd = options?.cwd ?? "/workspace";
2773
- const adapterEntrypoint = this._resolveAdapterBin(config.acpAdapter);
2774
- if ((agentType === "pi" || agentType === "pi-cli") &&
2775
- !launchEnv.PI_ACP_PI_COMMAND) {
2776
- launchEnv = {
2777
- ...launchEnv,
2778
- PI_ACP_PI_COMMAND: this._resolvePackageBin(config.agentPackage, "pi"),
2779
- };
2938
+ // Every agent is an `/opt/agentos` package now: the config carries a
2939
+ // pre-resolved guest command path (`adapterEntrypoint`) that the sidecar
2940
+ // spawns directly. There is no npm adapter resolution.
2941
+ const adapterEntrypoint = config.adapterEntrypoint;
2942
+ if (!adapterEntrypoint) {
2943
+ throw new Error(`agent "${String(agentType)}" config has no adapterEntrypoint`);
2780
2944
  }
2781
2945
  const response = await this._sendAcpRequest({
2782
2946
  tag: "AcpCreateSessionRequest",
@@ -2848,21 +3012,20 @@ export class AgentOs {
2848
3012
  if (!config) {
2849
3013
  throw new Error(`Unknown agent type: ${agentType}`);
2850
3014
  }
2851
- const adapterEntrypoint = this._resolveAdapterBin(config.acpAdapter);
2852
- let launchEnv = { ...config.defaultEnv, ...options?.env };
2853
- const sessionCwd = options?.cwd ?? "/workspace";
2854
- if ((agentType === "pi" || agentType === "pi-cli") &&
2855
- !launchEnv.PI_ACP_PI_COMMAND) {
2856
- launchEnv = {
2857
- ...launchEnv,
2858
- PI_ACP_PI_COMMAND: this._resolvePackageBin(config.agentPackage, "pi"),
2859
- };
3015
+ // Every agent is an `/opt/agentos` package now: the config carries a
3016
+ // pre-resolved guest command path (`adapterEntrypoint`). There is no npm
3017
+ // adapter resolution.
3018
+ const adapterEntrypoint = config.adapterEntrypoint;
3019
+ if (!adapterEntrypoint) {
3020
+ throw new Error(`agent "${String(agentType)}" config has no adapterEntrypoint`);
2860
3021
  }
3022
+ const sessionCwd = options?.cwd ?? "/workspace";
2861
3023
  // The resume wire request has no dedicated `adapterEntrypoint` field; carry
2862
3024
  // the resolved entrypoint through env under the sidecar's reserved key. The
2863
3025
  // sidecar reads it and strips it before launching the adapter.
2864
- launchEnv = {
2865
- ...launchEnv,
3026
+ const launchEnv = {
3027
+ ...config.defaultEnv,
3028
+ ...options?.env,
2866
3029
  [RESUME_ADAPTER_ENTRYPOINT_ENV]: adapterEntrypoint,
2867
3030
  };
2868
3031
  const response = await this._sendAcpRequest({
@@ -2896,65 +3059,6 @@ export class AgentOs {
2896
3059
  }
2897
3060
  return { sessionId: liveSessionId, mode };
2898
3061
  }
2899
- /**
2900
- * Resolve the VM bin entry point of an ACP adapter package.
2901
- * Reads from the host filesystem since kernel.readFile() resolves through
2902
- * mounts; adapter package.json is read directly off the host dir backing
2903
- * the relevant mount.
2904
- */
2905
- _resolveAdapterBin(adapterPackage) {
2906
- return this._resolvePackageBin(adapterPackage);
2907
- }
2908
- /**
2909
- * Parent of the host directory backing the `/root/node_modules` mount, if
2910
- * the caller supplied one (e.g. via `nodeModulesMount(...)`). Used as the
2911
- * `resolvePackageDir` start dir for adapter/agent package resolution when no
2912
- * software root matches. `nodeModulesMount` mounts `<dir>/node_modules`, so
2913
- * the start dir is `<dir>` (one level above the node_modules tree).
2914
- */
2915
- _nodeModulesHostRoot() {
2916
- for (const mount of this._hostMounts) {
2917
- if (mount.vmPath === "/root/node_modules") {
2918
- return dirname(mount.hostPath);
2919
- }
2920
- }
2921
- return null;
2922
- }
2923
- _resolvePackageBin(packageName, binName) {
2924
- const vmPrefix = `/root/node_modules/${packageName}`;
2925
- let hostPkgJsonPath = null;
2926
- for (const root of this._softwareRoots) {
2927
- if (root.vmPath === vmPrefix) {
2928
- hostPkgJsonPath = join(root.hostPath, "package.json");
2929
- break;
2930
- }
2931
- }
2932
- // Fall back to the host dir behind the `/root/node_modules` mount.
2933
- if (!hostPkgJsonPath) {
2934
- const nodeModulesRoot = this._nodeModulesHostRoot();
2935
- if (!nodeModulesRoot) {
2936
- throw new Error(`Cannot resolve package "${packageName}": no software root provides it and ` +
2937
- `no /root/node_modules mount was supplied. Pass ` +
2938
- `mounts: [nodeModulesMount("<host>/node_modules")] to AgentOs.create().`);
2939
- }
2940
- hostPkgJsonPath = join(resolvePackageDir(nodeModulesRoot, packageName), "package.json");
2941
- }
2942
- const pkg = JSON.parse(readFileSync(hostPkgJsonPath, "utf-8"));
2943
- let binEntry;
2944
- if (typeof pkg.bin === "string") {
2945
- binEntry = pkg.bin;
2946
- }
2947
- else if (typeof pkg.bin === "object" && pkg.bin !== null) {
2948
- binEntry =
2949
- (binName ? pkg.bin[binName] : undefined) ??
2950
- pkg.bin[packageName] ??
2951
- Object.values(pkg.bin)[0];
2952
- }
2953
- if (!binEntry) {
2954
- throw new Error(`No bin entry found in ${packageName}/package.json`);
2955
- }
2956
- return `${vmPrefix}/${binEntry}`;
2957
- }
2958
3062
  _installSidecarRequestHandler() {
2959
3063
  const context = {
2960
3064
  toolKits: this._toolKits,
@@ -3155,6 +3259,10 @@ export class AgentOs {
3155
3259
  _acpMethod: request.method,
3156
3260
  };
3157
3261
  if (session.permissionHandlers.size === 0) {
3262
+ // Default-closed deny; warn once (host-visible) so a forgotten
3263
+ // onPermissionRequest handler is an observable cause rather than a
3264
+ // silent denial. See _warnNoPermissionHandlerOnce.
3265
+ this._warnNoPermissionHandlerOnce(session, permissionParams);
3158
3266
  return this._buildAcpPermissionResult("reject", permissionParams);
3159
3267
  }
3160
3268
  const reply = await new Promise((resolve, reject) => {
@@ -3437,6 +3545,10 @@ export class AgentOs {
3437
3545
  return "reject";
3438
3546
  }
3439
3547
  if (session.permissionHandlers.size === 0) {
3548
+ // Default-closed: deny when no host hook is listening, and warn once
3549
+ // (host-visible) so a forgotten onPermissionRequest handler is not an
3550
+ // invisible cause of an agent that cannot use any tool.
3551
+ this._warnNoPermissionHandlerOnce(session, params);
3440
3552
  return "reject";
3441
3553
  }
3442
3554
  try {
@@ -3467,12 +3579,12 @@ export class AgentOs {
3467
3579
  }
3468
3580
  }
3469
3581
  /**
3470
- * Resolve an agent config by ID. Package-provided configs take
3471
- * precedence over the hardcoded AGENT_CONFIGS.
3582
+ * Resolve an agent config by ID. Agents are /opt/agentos packages
3583
+ * registered from their manifests (explicit software at create, or lazily
3584
+ * linked dependency agents) — there is no hardcoded fallback config.
3472
3585
  */
3473
3586
  _resolveAgentConfig(agentType) {
3474
- return (this._softwareAgentConfigs.get(agentType) ??
3475
- AGENT_CONFIGS[agentType]);
3587
+ return this._softwareAgentConfigs.get(agentType);
3476
3588
  }
3477
3589
  /**
3478
3590
  * Gracefully destroy a session: cancel any pending work, close the client,
@@ -3687,6 +3799,88 @@ function resolveAgentOsSidecar(config) {
3687
3799
  }
3688
3800
  const sidecarStates = new WeakMap();
3689
3801
  const sharedSidecars = new Map();
3802
+ let sidecarProcessExitHookInstalled = false;
3803
+ /**
3804
+ * Install a one-time, synchronous `process.on("exit")` hook that SIGKILLs any
3805
+ * pooled shared sidecar child. Once a one-shot host process is allowed to exit
3806
+ * (its sidecar handles are unref'd at 0 leases), this reaps the sidecar
3807
+ * immediately instead of waiting for its stdin-EOF grace window — no orphan, no
3808
+ * delay. We deliberately do NOT install SIGINT/SIGTERM handlers: a library
3809
+ * should not hijack the host's signal handling. SIGINT still reaches the sidecar
3810
+ * via the process group, and SIGTERM-driven exit still closes its stdin.
3811
+ */
3812
+ function ensureSidecarProcessExitCleanup() {
3813
+ if (sidecarProcessExitHookInstalled)
3814
+ return;
3815
+ sidecarProcessExitHookInstalled = true;
3816
+ process.on("exit", () => {
3817
+ for (const sidecar of sharedSidecars.values()) {
3818
+ try {
3819
+ sidecarStates.get(sidecar)?.sharedChild?.kill?.("SIGKILL");
3820
+ }
3821
+ catch {
3822
+ // best-effort reap; the process is exiting regardless
3823
+ }
3824
+ }
3825
+ });
3826
+ }
3827
+ function sidecarChildHandle(client) {
3828
+ // SidecarProcess -> StdioSidecarProtocolClient.child (the spawned ChildProcess).
3829
+ const protocolClient = client?.protocolClient;
3830
+ return protocolClient?.child ?? undefined;
3831
+ }
3832
+ /**
3833
+ * Apply the current hold state to the shared sidecar's child + stdio: ref them
3834
+ * while ≥1 hold is live so in-flight VM work keeps the host process alive; unref
3835
+ * them at 0 so a one-shot script exits on its own after `dispose()`. The sidecar
3836
+ * process itself stays running (reusable) and self-exits on stdin EOF when the
3837
+ * host finally goes away. Best-effort: never let ref/unref break VM lifecycle.
3838
+ */
3839
+ function applySharedSidecarHold(state) {
3840
+ const child = state.sharedChild;
3841
+ if (!child)
3842
+ return;
3843
+ const hold = (state.eventLoopHolds ?? 0) > 0;
3844
+ for (const handle of [child, child.stdin, child.stdout, child.stderr]) {
3845
+ if (!handle)
3846
+ continue;
3847
+ try {
3848
+ if (hold)
3849
+ handle.ref?.();
3850
+ else
3851
+ handle.unref?.();
3852
+ }
3853
+ catch {
3854
+ // ref/unref is an optimization, not correctness-critical
3855
+ }
3856
+ }
3857
+ }
3858
+ /**
3859
+ * Take a hold for the entire create→use→dispose lifetime of one VM lease. Taken
3860
+ * BEFORE VM creation starts (not when the lease lands in `activeLeases`) so a VM
3861
+ * that is still mid-creation keeps the sidecar ref'd and a concurrent dispose
3862
+ * cannot unref it out from under the in-flight create.
3863
+ */
3864
+ function acquireSharedSidecarHold(state) {
3865
+ state.eventLoopHolds = (state.eventLoopHolds ?? 0) + 1;
3866
+ if (state.eventLoopHolds === 1)
3867
+ applySharedSidecarHold(state);
3868
+ }
3869
+ /** Release a hold taken by {@link acquireSharedSidecarHold}; unref at 0. */
3870
+ function releaseSharedSidecarHold(state) {
3871
+ const current = state.eventLoopHolds ?? 0;
3872
+ if (current <= 0) {
3873
+ // The `holdReleased` guard makes each lease release exactly once, so this
3874
+ // should be unreachable. Warn rather than silently floor, per the repo's
3875
+ // no-silent-masking rule, so an accounting bug surfaces instead of hiding.
3876
+ state.eventLoopHolds = 0;
3877
+ console.warn("[agentos] shared sidecar event-loop hold released more than acquired");
3878
+ return;
3879
+ }
3880
+ state.eventLoopHolds = current - 1;
3881
+ if (state.eventLoopHolds === 0)
3882
+ applySharedSidecarHold(state);
3883
+ }
3690
3884
  /**
3691
3885
  * Spawn-once accessor for a sidecar handle's shared native process. Concurrent
3692
3886
  * callers await the same promise, so one `AgentOsSidecar` maps to exactly one
@@ -3695,6 +3889,7 @@ const sharedSidecars = new Map();
3695
3889
  function ensureSharedSidecarNativeProcess(sidecar) {
3696
3890
  const state = getSidecarState(sidecar);
3697
3891
  if (!state.nativeProcess) {
3892
+ ensureSidecarProcessExitCleanup();
3698
3893
  state.nativeProcess = (async () => {
3699
3894
  const client = SidecarProcess.spawn({
3700
3895
  cwd: REPO_ROOT,
@@ -3702,8 +3897,39 @@ function ensureSharedSidecarNativeProcess(sidecar) {
3702
3897
  args: [],
3703
3898
  frameTimeoutMs: NATIVE_SIDECAR_FRAME_TIMEOUT_MS,
3704
3899
  });
3705
- const session = await client.authenticateAndOpenSession();
3706
- return { client, session };
3900
+ // Track the child immediately — BEFORE the handshake await — so a
3901
+ // failed `authenticateAndOpenSession()` can still reap it (otherwise
3902
+ // the spawned child is untracked, unreapable, and pins the loop).
3903
+ state.sharedChild = sidecarChildHandle(client);
3904
+ if (!state.sharedChild) {
3905
+ // We reached into @secure-exec/core internals to get the child for
3906
+ // idle-unref. If that shape ever changes this returns undefined and
3907
+ // the optimization silently stops working (one-shot scripts would
3908
+ // hang again). Make it loud rather than a silent regression.
3909
+ console.warn("[agentos] could not resolve the shared sidecar child handle; " +
3910
+ "standalone scripts may not exit cleanly after dispose(). " +
3911
+ "This usually means @secure-exec/core internals changed.");
3912
+ }
3913
+ // Apply the current hold state to the just-spawned child.
3914
+ applySharedSidecarHold(state);
3915
+ try {
3916
+ const session = await client.authenticateAndOpenSession();
3917
+ return { client, session };
3918
+ }
3919
+ catch (error) {
3920
+ // Spawn/handshake failed: reap the child, drop the cached handle,
3921
+ // and CLEAR the rejected promise so the next create() retries
3922
+ // instead of permanently wedging on a rejected `nativeProcess`.
3923
+ try {
3924
+ state.sharedChild?.kill?.("SIGKILL");
3925
+ }
3926
+ catch {
3927
+ // already gone
3928
+ }
3929
+ state.sharedChild = undefined;
3930
+ state.nativeProcess = undefined;
3931
+ throw error;
3932
+ }
3707
3933
  })();
3708
3934
  }
3709
3935
  return state.nativeProcess;
@@ -3715,6 +3941,14 @@ async function disposeSharedSidecarNativeProcess(state) {
3715
3941
  return;
3716
3942
  }
3717
3943
  state.nativeProcess = undefined;
3944
+ // The cached child is now dead; drop it (symmetric with the assignment in
3945
+ // ensureSharedSidecarNativeProcess). We deliberately do NOT zero
3946
+ // `eventLoopHolds` here: this runs only from `AgentOsSidecar.dispose()`, which
3947
+ // has already set the handle to `disposing` (so no new lease can acquire) and
3948
+ // drained `activeLeases`; the disposed handle's state is then abandoned. Force-
3949
+ // zeroing a shared counter could clobber a hold on a freshly re-acquired
3950
+ // process generation, so it is left to the balanced acquire/release pairs.
3951
+ state.sharedChild = undefined;
3718
3952
  try {
3719
3953
  const { client } = await pending;
3720
3954
  await client.dispose();
@@ -3820,6 +4054,18 @@ async function leaseAgentOsSidecarVm(sidecar, options) {
3820
4054
  return transport;
3821
4055
  },
3822
4056
  });
4057
+ // Hold the shared sidecar's event-loop ref for this lease's WHOLE lifetime —
4058
+ // taken now, before VM creation, so a concurrent dispose cannot unref the
4059
+ // sidecar while this create is still in flight. Released exactly once on
4060
+ // dispose or on a failed create.
4061
+ acquireSharedSidecarHold(state);
4062
+ let holdReleased = false;
4063
+ const releaseHold = () => {
4064
+ if (holdReleased)
4065
+ return;
4066
+ holdReleased = true;
4067
+ releaseSharedSidecarHold(state);
4068
+ };
3823
4069
  let disposed = false;
3824
4070
  let leaseRecord;
3825
4071
  try {
@@ -3844,6 +4090,10 @@ async function leaseAgentOsSidecarVm(sidecar, options) {
3844
4090
  state.activeLeases.delete(leaseRecord);
3845
4091
  state.description.activeVmCount = state.activeLeases.size;
3846
4092
  await client.dispose();
4093
+ // Release this lease's hold; the shared sidecar is unref'd only
4094
+ // once the last hold (across all in-flight + active leases) drops,
4095
+ // so a one-shot host process can then exit on its own.
4096
+ releaseHold();
3847
4097
  },
3848
4098
  };
3849
4099
  leaseRecord = {
@@ -3855,6 +4105,7 @@ async function leaseAgentOsSidecarVm(sidecar, options) {
3855
4105
  }
3856
4106
  catch (error) {
3857
4107
  await client.dispose().catch(() => { });
4108
+ releaseHold();
3858
4109
  throw error;
3859
4110
  }
3860
4111
  }