@rivet-dev/agentos-core 0.0.0-feat-on-limit-warning.62a2bc2 → 0.0.0-main.ab2ce7c

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +4 -7
  2. package/dist/agent-os.d.ts +116 -46
  3. package/dist/agent-os.js +740 -541
  4. package/dist/agentos-package.d.ts +29 -0
  5. package/dist/agentos-package.js +88 -0
  6. package/dist/base-filesystem.d.ts +2 -15
  7. package/dist/base-filesystem.js +23 -171
  8. package/dist/cron/types.d.ts +1 -1
  9. package/dist/default-software.d.ts +8 -0
  10. package/dist/default-software.js +10 -0
  11. package/dist/generated/AcpLimitsConfig.d.ts +4 -0
  12. package/dist/generated/AcpLimitsConfig.js +2 -0
  13. package/dist/generated/CreateVmConfig.d.ts +26 -0
  14. package/dist/generated/CreateVmConfig.js +1 -0
  15. package/dist/generated/FsPermissionRule.d.ts +6 -0
  16. package/dist/generated/FsPermissionRule.js +1 -0
  17. package/dist/generated/FsPermissionRuleSet.d.ts +6 -0
  18. package/dist/generated/FsPermissionRuleSet.js +1 -0
  19. package/dist/generated/FsPermissionScope.d.ts +3 -0
  20. package/dist/generated/FsPermissionScope.js +1 -0
  21. package/dist/generated/HttpLimitsConfig.d.ts +3 -0
  22. package/dist/generated/HttpLimitsConfig.js +2 -0
  23. package/dist/generated/JsModuleResolution.d.ts +1 -0
  24. package/dist/generated/JsModuleResolution.js +2 -0
  25. package/dist/generated/JsRuntimeConfig.d.ts +31 -0
  26. package/dist/generated/JsRuntimeConfig.js +1 -0
  27. package/dist/generated/JsRuntimeLimitsConfig.d.ts +11 -0
  28. package/dist/generated/JsRuntimeLimitsConfig.js +2 -0
  29. package/dist/generated/JsRuntimePlatform.d.ts +1 -0
  30. package/dist/generated/JsRuntimePlatform.js +2 -0
  31. package/dist/generated/MountPluginDescriptor.d.ts +4 -0
  32. package/dist/generated/MountPluginDescriptor.js +2 -0
  33. package/dist/generated/NativeRootFilesystemConfig.d.ts +5 -0
  34. package/dist/generated/NativeRootFilesystemConfig.js +1 -0
  35. package/dist/generated/PatternPermissionRule.d.ts +6 -0
  36. package/dist/generated/PatternPermissionRule.js +1 -0
  37. package/dist/generated/PatternPermissionRuleSet.d.ts +6 -0
  38. package/dist/generated/PatternPermissionRuleSet.js +1 -0
  39. package/dist/generated/PatternPermissionScope.d.ts +3 -0
  40. package/dist/generated/PatternPermissionScope.js +1 -0
  41. package/dist/generated/PermissionMode.d.ts +1 -0
  42. package/dist/generated/PermissionMode.js +2 -0
  43. package/dist/generated/PermissionsPolicy.d.ts +10 -0
  44. package/dist/generated/PermissionsPolicy.js +1 -0
  45. package/dist/generated/PluginLimitsConfig.d.ts +4 -0
  46. package/dist/generated/PluginLimitsConfig.js +2 -0
  47. package/dist/generated/PythonLimitsConfig.d.ts +6 -0
  48. package/dist/generated/PythonLimitsConfig.js +2 -0
  49. package/dist/generated/ResourceLimitsConfig.d.ts +24 -0
  50. package/dist/generated/ResourceLimitsConfig.js +2 -0
  51. package/dist/generated/RootFilesystemConfig.d.ts +9 -0
  52. package/dist/generated/RootFilesystemConfig.js +1 -0
  53. package/dist/generated/RootFilesystemEntry.d.ts +13 -0
  54. package/dist/generated/RootFilesystemEntry.js +1 -0
  55. package/dist/generated/RootFilesystemEntryEncoding.d.ts +1 -0
  56. package/dist/generated/RootFilesystemEntryEncoding.js +2 -0
  57. package/dist/generated/RootFilesystemEntryKind.d.ts +1 -0
  58. package/dist/generated/RootFilesystemEntryKind.js +2 -0
  59. package/dist/generated/RootFilesystemLowerDescriptor.d.ts +7 -0
  60. package/dist/generated/RootFilesystemLowerDescriptor.js +1 -0
  61. package/dist/generated/RootFilesystemMode.d.ts +1 -0
  62. package/dist/generated/RootFilesystemMode.js +2 -0
  63. package/dist/generated/ToolLimitsConfig.d.ts +10 -0
  64. package/dist/generated/ToolLimitsConfig.js +2 -0
  65. package/dist/generated/VmDnsConfig.d.ts +6 -0
  66. package/dist/generated/VmDnsConfig.js +2 -0
  67. package/dist/generated/VmLimitsConfig.d.ts +18 -0
  68. package/dist/generated/VmLimitsConfig.js +1 -0
  69. package/dist/generated/VmListenPolicyConfig.d.ts +5 -0
  70. package/dist/generated/VmListenPolicyConfig.js +2 -0
  71. package/dist/generated/WasmLimitsConfig.d.ts +7 -0
  72. package/dist/generated/WasmLimitsConfig.js +2 -0
  73. package/dist/host-dir-mount.d.ts +1 -1
  74. package/dist/host-tools.js +9 -0
  75. package/dist/index.d.ts +2 -1
  76. package/dist/index.js +1 -1
  77. package/dist/layers.d.ts +2 -2
  78. package/dist/layers.js +19 -10
  79. package/dist/options-schema.d.ts +19 -4
  80. package/dist/options-schema.js +30 -22
  81. package/dist/packages.d.ts +17 -116
  82. package/dist/packages.js +35 -322
  83. package/dist/runtime-compat.d.ts +22 -9
  84. package/dist/runtime-compat.js +126 -141
  85. package/dist/runtime.d.ts +32 -5
  86. package/dist/sandbox.d.ts +112 -0
  87. package/dist/sandbox.js +264 -0
  88. package/dist/sidecar/agentos-protocol.d.ts +88 -1
  89. package/dist/sidecar/agentos-protocol.js +134 -18
  90. package/dist/sidecar/native-process-client.d.ts +6 -3
  91. package/dist/sidecar/native-process-client.js +5 -3
  92. package/dist/sidecar/permissions.d.ts +1 -1
  93. package/dist/sidecar/permissions.js +3 -3
  94. package/dist/sidecar/rpc-client.d.ts +62 -4
  95. package/dist/sidecar/rpc-client.js +339 -85
  96. package/dist/types.d.ts +10 -3
  97. package/package.json +36 -32
  98. package/dist/agents.d.ts +0 -63
  99. package/dist/agents.js +0 -42
  100. package/fixtures/AGENTOS_SYSTEM_PROMPT.md +0 -21
package/dist/agent-os.js CHANGED
@@ -1,10 +1,8 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
- import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from "node:fs";
4
- import { tmpdir } from "node:os";
5
- import { dirname, join, posix as posixPath, resolve as resolveHostPath, } from "node:path";
3
+ import { existsSync, readdirSync, statSync } from "node:fs";
4
+ import { join, posix as posixPath, resolve as resolveHostPath, } from "node:path";
6
5
  import { fileURLToPath } from "node:url";
7
- import commonSoftware from "@agentos-software/common";
8
6
  import { validateToolkits } from "./host-tools.js";
9
7
  import { zodToJsonSchema } from "./host-tools-zod.js";
10
8
  import { parseAgentOsOptions } from "./options-schema.js";
@@ -14,14 +12,6 @@ export { isAcpTimeoutErrorData } from "./json-rpc.js";
14
12
  const ACP_PROTOCOL_VERSION = 1;
15
13
  const ACP_EXTENSION_NAMESPACE = "dev.rivet.agent-os.acp";
16
14
  const SHELL_DISPOSE_TIMEOUT_MS = 5_000;
17
- /**
18
- * Reserved `env` key on `AcpResumeSessionRequest` carrying the resolved adapter
19
- * bin entrypoint. The resume wire request omits a dedicated `adapterEntrypoint`
20
- * field; the sidecar reads the entrypoint from this key and strips it before
21
- * launching the adapter. Must stay in sync with the sidecar constant of the same
22
- * name in `crates/agentos-sidecar/src/acp_extension.rs`.
23
- */
24
- const RESUME_ADAPTER_ENTRYPOINT_ENV = "AGENT_OS_RESUME_ADAPTER_ENTRYPOINT";
25
15
  function defaultAcpClientCapabilities() {
26
16
  return {
27
17
  fs: {
@@ -42,15 +32,14 @@ async function waitForTrackedExitPromises(promises, timeoutMs) {
42
32
  }),
43
33
  ]);
44
34
  }
45
- import { AGENT_CONFIGS } from "./agents.js";
46
- import { getBaseEnvironment, getBaseFilesystemEntries, } from "./base-filesystem.js";
35
+ import { getBaseEnvironment } from "./base-filesystem.js";
47
36
  import { CronManager } from "./cron/cron-manager.js";
48
37
  import { TimerScheduleDriver } from "./cron/timer-driver.js";
49
38
  import { snapshotVirtualFilesystem, sortFilesystemEntries, } from "./filesystem-snapshot.js";
50
- import { createHostDirBackend } from "./host-dir-mount.js";
51
39
  import { serializeMountConfigForSidecar, } from "./js-bridge.js";
52
40
  import { createSnapshotExport, } from "./layers.js";
53
- import { processSoftware, resolvePackageDir, } from "./packages.js";
41
+ import { OPT_AGENTOS_ROOT, } from "./agentos-package.js";
42
+ import { resolveDefaultSoftware } from "./default-software.js";
54
43
  import { allowAll } from "./runtime-compat.js";
55
44
  import { AcpRuntimeKind, decodeAcpCallback, decodeAcpEvent, decodeAcpResponse, encodeAcpCallbackResponse, encodeAcpRequest, } from "./sidecar/agentos-protocol.js";
56
45
  import { serializePermissionsForSidecar } from "./sidecar/permissions.js";
@@ -58,6 +47,9 @@ import { createAgentOsSidecarClient, NATIVE_SIDECAR_FRAME_TIMEOUT_MS, NativeSide
58
47
  function defaultAgentStderrHandler(event) {
59
48
  process.stderr.write(event.chunk);
60
49
  }
50
+ function defaultAgentExitHandler(event) {
51
+ 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`);
52
+ }
61
53
  const LEGACY_PERMISSION_METHOD = "request/permission";
62
54
  const ACP_PERMISSION_METHOD = "session/request_permission";
63
55
  class AcpDispatchError extends Error {
@@ -110,6 +102,28 @@ function toRecord(value) {
110
102
  ? value
111
103
  : {};
112
104
  }
105
+ function normalizePackageRef(value) {
106
+ // The single package reference is `packagePath`: the packed `.aospkg` file
107
+ // (registry-built packages export `{ packagePath }`), or a package dir for
108
+ // local transition fixtures. A raw string is shorthand for the same path.
109
+ if (typeof value === "string") {
110
+ return { path: value };
111
+ }
112
+ const record = toRecord(value);
113
+ if (typeof record.packagePath === "string") {
114
+ return { path: record.packagePath };
115
+ }
116
+ // Recognizably-legacy shapes fail loudly: silently dropping a software
117
+ // entry boots a VM with missing packages and no diagnostic.
118
+ for (const legacy of ["packageTar", "packageDir", "dir"]) {
119
+ if (typeof record[legacy] === "string") {
120
+ throw new Error(`agentOS package ref uses removed field "${legacy}" (value: ${JSON.stringify(record[legacy])}); ` +
121
+ "packages are referenced by a single `packagePath` — update the package " +
122
+ "(rebuild @agentos-software/* dependencies) or pass { packagePath }");
123
+ }
124
+ }
125
+ return undefined;
126
+ }
113
127
  function parseAcpJson(value, context) {
114
128
  if (value === null) {
115
129
  return undefined;
@@ -165,11 +179,11 @@ class BoundedSet {
165
179
  }
166
180
  this.limit = limit;
167
181
  }
168
- add(value) {
182
+ add(value, associated) {
169
183
  if (this.#entries.has(value)) {
170
184
  this.#entries.delete(value);
171
185
  }
172
- this.#entries.set(value, undefined);
186
+ this.#entries.set(value, associated);
173
187
  if (this.#entries.size <= this.limit) {
174
188
  return;
175
189
  }
@@ -181,6 +195,9 @@ class BoundedSet {
181
195
  has(value) {
182
196
  return this.#entries.has(value);
183
197
  }
198
+ get(value) {
199
+ return this.#entries.get(value);
200
+ }
184
201
  delete(value) {
185
202
  return this.#entries.delete(value);
186
203
  }
@@ -228,6 +245,7 @@ function sessionEntryFromInit(sessionId, agentType, initData) {
228
245
  agentInfo: initData.agentInfo ?? null,
229
246
  eventHandlers: new Set(),
230
247
  permissionHandlers: new Set(),
248
+ warnedNoPermissionHandler: false,
231
249
  configOverrides: new Map(),
232
250
  pendingPermissionReplies: new Map(),
233
251
  };
@@ -396,8 +414,31 @@ const KERNEL_POSIX_BOOTSTRAP_DIRS = [
396
414
  "/var/tmp",
397
415
  "/etc/agentos",
398
416
  ];
399
- const NODE_RUNTIME_BOOTSTRAP_COMMANDS = ["node", "npm", "npx"];
400
- const KERNEL_COMMAND_STUB = "#!/bin/sh\n# kernel command stub\n";
417
+ // Standard POSIX metadata for the bootstrap dirs whose mode/owner is NOT the
418
+ // default (`755`, root:root). Replicated as a constant so building the no-base
419
+ // bootstrap layer needs no `base-filesystem.json` read — when a base IS present
420
+ // the sidecar's embedded base layer is authoritative and these are never emitted.
421
+ const KERNEL_POSIX_BOOTSTRAP_DIR_METADATA = {
422
+ "/tmp": { mode: "1777", uid: 0, gid: 0 },
423
+ "/root": { mode: "700", uid: 0, gid: 0 },
424
+ "/sys": { mode: "555", uid: 0, gid: 0 },
425
+ "/home/agentos": { mode: "2755", uid: 1000, gid: 1000 },
426
+ "/workspace": { mode: "755", uid: 1000, gid: 1000 },
427
+ "/var/empty": { mode: "555", uid: 0, gid: 0 },
428
+ "/var/lock": { mode: "777", uid: 0, gid: 0 },
429
+ "/var/run": { mode: "777", uid: 0, gid: 0 },
430
+ "/var/tmp": { mode: "1777", uid: 0, gid: 0 },
431
+ };
432
+ // Runtime commands that get a `/bin/<cmd>` stub at bootstrap so the guest shell
433
+ // resolves them on PATH (e.g. `sh -c "python ..."`, pipelines). The sidecar
434
+ // intercepts these by name and routes them to the embedded V8 / Pyodide runtime.
435
+ const RUNTIME_BOOTSTRAP_COMMANDS = [
436
+ "node",
437
+ "npm",
438
+ "npx",
439
+ "python",
440
+ "python3",
441
+ ];
401
442
  const REPO_ROOT = fileURLToPath(new URL("../../..", import.meta.url));
402
443
  const SIDECAR_BINARY = join(REPO_ROOT, "target/debug/agentos-sidecar");
403
444
  const SIDECAR_BUILD_INPUTS = [
@@ -409,114 +450,6 @@ const SIDECAR_BUILD_INPUTS = [
409
450
  join(REPO_ROOT, "crates/sidecar"),
410
451
  ];
411
452
  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
453
  function collectConfiguredLowerPaths(config) {
521
454
  const paths = new Set();
522
455
  for (const lower of config?.lowers ?? []) {
@@ -539,16 +472,15 @@ function findBootstrapSeedEntry(config, path) {
539
472
  return entry;
540
473
  }
541
474
  }
542
- return getBaseFilesystemEntries().find((entry) => entry.path === path);
475
+ // No base-filesystem JSON read: standard non-default dir metadata comes from
476
+ // the constant table. When a base layer IS present these dirs are never
477
+ // emitted (see createKernelBootstrapLower), so this only seeds the no-base case.
478
+ const meta = KERNEL_POSIX_BOOTSTRAP_DIR_METADATA[path];
479
+ return meta ? { path, type: "directory", ...meta } : undefined;
543
480
  }
544
- function createKernelBootstrapLower(config, commandNames, extraEntries = []) {
481
+ function createKernelBootstrapLower(config, extraEntries = []) {
545
482
  const includesBundledBaseLayer = !(config?.disableDefaultBaseLayer ?? false);
546
483
  const existingPaths = collectConfiguredLowerPaths(config);
547
- if (includesBundledBaseLayer) {
548
- for (const entry of getBaseFilesystemEntries()) {
549
- existingPaths.add(entry.path);
550
- }
551
- }
552
484
  const entries = [
553
485
  {
554
486
  path: "/",
@@ -558,18 +490,24 @@ function createKernelBootstrapLower(config, commandNames, extraEntries = []) {
558
490
  gid: 0,
559
491
  },
560
492
  ];
561
- for (const dir of KERNEL_POSIX_BOOTSTRAP_DIRS) {
562
- if (existingPaths.has(dir)) {
563
- continue;
493
+ // Only run the FS bootstrap (creating the POSIX dir tree) when there is NO
494
+ // base layer. When the bundled base IS present, the sidecar's embedded base
495
+ // layer already provides every POSIX dir with the correct mode/owner, so we
496
+ // emit nothing here and never read its filesystem table.
497
+ if (!includesBundledBaseLayer) {
498
+ for (const dir of KERNEL_POSIX_BOOTSTRAP_DIRS) {
499
+ if (existingPaths.has(dir)) {
500
+ continue;
501
+ }
502
+ const seed = findBootstrapSeedEntry(config, dir);
503
+ entries.push({
504
+ path: dir,
505
+ type: "directory",
506
+ mode: seed?.type === "directory" ? seed.mode : "755",
507
+ uid: seed?.uid ?? 0,
508
+ gid: seed?.gid ?? 0,
509
+ });
564
510
  }
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
511
  }
574
512
  if (!includesBundledBaseLayer && !existingPaths.has("/usr/bin/env")) {
575
513
  entries.push({
@@ -582,22 +520,6 @@ function createKernelBootstrapLower(config, commandNames, extraEntries = []) {
582
520
  encoding: "base64",
583
521
  });
584
522
  }
585
- const uniqueCommands = [...new Set(commandNames)].sort((a, b) => a.localeCompare(b));
586
- for (const command of uniqueCommands) {
587
- const stubPath = `/bin/${command}`;
588
- if (existingPaths.has(stubPath)) {
589
- continue;
590
- }
591
- entries.push({
592
- path: stubPath,
593
- type: "file",
594
- mode: "755",
595
- uid: 0,
596
- gid: 0,
597
- content: KERNEL_COMMAND_STUB,
598
- encoding: "utf8",
599
- });
600
- }
601
523
  for (const entry of sortFilesystemEntries(extraEntries)) {
602
524
  if (existingPaths.has(entry.path)) {
603
525
  continue;
@@ -712,28 +634,6 @@ function latestMtimeMs(path) {
712
634
  }
713
635
  return latest;
714
636
  }
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
637
  async function resolveCompatLocalMounts(mounts) {
738
638
  if (!mounts) {
739
639
  return [];
@@ -802,52 +702,24 @@ function collectSidecarMountPlan(options) {
802
702
  }
803
703
  for (const mount of options.mounts ?? []) {
804
704
  if (!isNativeMountConfig(mount)) {
705
+ sidecarMounts.push({
706
+ guestPath: mount.path,
707
+ readOnly: isOverlayMountConfig(mount)
708
+ ? (mount.filesystem.mode ?? "ephemeral") === "read-only"
709
+ : (mount.readOnly ?? false),
710
+ plugin: {
711
+ id: "js_bridge",
712
+ config: {},
713
+ },
714
+ });
805
715
  continue;
806
716
  }
807
717
  pushMount(mount);
808
718
  }
809
- for (const root of options.softwareRoots) {
810
- pushMount({
811
- path: root.vmPath,
812
- plugin: createHostDirBackend({
813
- hostPath: root.hostPath,
814
- readOnly: true,
815
- }),
816
- readOnly: true,
817
- });
818
- }
819
- for (const [index, commandDir] of options.commandDirs.entries()) {
820
- pushMount({
821
- path: `/__secure_exec/commands/${index}`,
822
- plugin: createHostDirBackend({
823
- hostPath: commandDir,
824
- readOnly: true,
825
- }),
826
- readOnly: true,
827
- });
828
- }
829
- if (options.shimDir) {
830
- pushMount({
831
- path: "/usr/local/bin",
832
- plugin: createHostDirBackend({
833
- hostPath: options.shimDir,
834
- readOnly: true,
835
- }),
836
- readOnly: true,
837
- });
838
- }
839
719
  hostMounts.sort((left, right) => right.vmPath.length - left.vmPath.length);
840
720
  hostPathMappings.sort((left, right) => right.vmPath.length - left.vmPath.length);
841
721
  return { sidecarMounts, hostMounts, hostPathMappings };
842
722
  }
843
- function materializeToolShimDir(toolKits) {
844
- const shimDir = mkdtempSync(join(tmpdir(), "agentos-host-tools-shims-"));
845
- writeFileSync(join(shimDir, "agentos"), KERNEL_COMMAND_STUB, { mode: 0o755 });
846
- for (const toolKit of toolKits) {
847
- writeFileSync(join(shimDir, `agentos-${toolKit.name}`), KERNEL_COMMAND_STUB, { mode: 0o755 });
848
- }
849
- return shimDir;
850
- }
851
723
  function collectToolkitBootstrapCommands(toolKits) {
852
724
  if (toolKits.length === 0) {
853
725
  return [];
@@ -1044,7 +916,7 @@ async function handleHostCallback(request, context) {
1044
916
  return {
1045
917
  type: "host_callback_result",
1046
918
  invocation_id: payload.invocation_id,
1047
- error: `EACCES: blocked by tool.invoke policy for ${payload.callback_key}`,
919
+ error: `EACCES: blocked by binding.invoke policy for ${payload.callback_key}`,
1048
920
  };
1049
921
  }
1050
922
  const parsed = tool.inputSchema.safeParse(payload.input);
@@ -1079,6 +951,134 @@ function buildToolMap(toolKits) {
1079
951
  }
1080
952
  return toolMap;
1081
953
  }
954
+ function bridgeErrorMessage(error) {
955
+ return error instanceof Error ? error.message : String(error);
956
+ }
957
+ function toBridgeArgs(value) {
958
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
959
+ throw new Error("js_bridge args must be an object");
960
+ }
961
+ return value;
962
+ }
963
+ function bridgePath(mountId, value) {
964
+ if (!mountId.startsWith("/")) {
965
+ throw new Error(`Unsupported js_bridge mount id: ${mountId}`);
966
+ }
967
+ if (typeof value !== "string") {
968
+ throw new Error("js_bridge path argument must be a string");
969
+ }
970
+ return posixPath.normalize(posixPath.join(mountId, value));
971
+ }
972
+ function requireBridgeNumber(value, field) {
973
+ if (typeof value !== "number" || !Number.isFinite(value)) {
974
+ throw new Error(`js_bridge args.${field} must be a number`);
975
+ }
976
+ return value;
977
+ }
978
+ function decodeBridgeBytes(value, field) {
979
+ if (typeof value === "string") {
980
+ return new Uint8Array(Buffer.from(value, "base64"));
981
+ }
982
+ if (Array.isArray(value) &&
983
+ value.every((entry) => Number.isInteger(entry) && entry >= 0 && entry <= 255)) {
984
+ return new Uint8Array(value);
985
+ }
986
+ throw new Error(`js_bridge args.${field} must be base64 bytes`);
987
+ }
988
+ async function handleJsBridgeCall(request, context) {
989
+ try {
990
+ const args = toBridgeArgs(request.args);
991
+ const fs = context.filesystem;
992
+ const path = () => bridgePath(request.mount_id, args.path);
993
+ let result;
994
+ switch (request.operation) {
995
+ case "readFile":
996
+ result = Buffer.from(await fs.readFile(path())).toString("base64");
997
+ break;
998
+ case "readDir":
999
+ result = await fs.readDir(path());
1000
+ break;
1001
+ case "readDirWithTypes":
1002
+ result = await fs.readDirWithTypes(path());
1003
+ break;
1004
+ case "writeFile":
1005
+ await fs.writeFile(path(), decodeBridgeBytes(args.content, "content"));
1006
+ break;
1007
+ case "createDir":
1008
+ await fs.createDir(path());
1009
+ break;
1010
+ case "mkdir":
1011
+ await fs.mkdir(path(), { recursive: args.recursive !== false });
1012
+ break;
1013
+ case "exists":
1014
+ result = await fs.exists(path());
1015
+ break;
1016
+ case "stat":
1017
+ result = await fs.stat(path());
1018
+ break;
1019
+ case "removeFile":
1020
+ await fs.removeFile(path());
1021
+ break;
1022
+ case "removeDir":
1023
+ await fs.removeDir(path());
1024
+ break;
1025
+ case "rename":
1026
+ await fs.rename(bridgePath(request.mount_id, args.oldPath), bridgePath(request.mount_id, args.newPath));
1027
+ break;
1028
+ case "realpath":
1029
+ result = await fs.realpath(path());
1030
+ break;
1031
+ case "symlink": {
1032
+ if (typeof args.target !== "string") {
1033
+ throw new Error("js_bridge args.target must be a string");
1034
+ }
1035
+ await fs.symlink(args.target, bridgePath(request.mount_id, args.linkPath));
1036
+ break;
1037
+ }
1038
+ case "readlink":
1039
+ result = await fs.readlink(path());
1040
+ break;
1041
+ case "lstat":
1042
+ result = await fs.lstat(path());
1043
+ break;
1044
+ case "link":
1045
+ await fs.link(bridgePath(request.mount_id, args.oldPath), bridgePath(request.mount_id, args.newPath));
1046
+ break;
1047
+ case "chmod":
1048
+ await fs.chmod(path(), requireBridgeNumber(args.mode, "mode"));
1049
+ break;
1050
+ case "chown":
1051
+ await fs.chown(path(), requireBridgeNumber(args.uid, "uid"), requireBridgeNumber(args.gid, "gid"));
1052
+ break;
1053
+ case "utimes":
1054
+ await fs.utimes(path(), requireBridgeNumber(args.atimeMs, "atimeMs"), requireBridgeNumber(args.mtimeMs, "mtimeMs"));
1055
+ break;
1056
+ case "truncate":
1057
+ await fs.truncate(path(), requireBridgeNumber(args.length, "length"));
1058
+ break;
1059
+ case "pread":
1060
+ result = Buffer.from(await fs.pread(path(), requireBridgeNumber(args.offset, "offset"), requireBridgeNumber(args.length, "length"))).toString("base64");
1061
+ break;
1062
+ case "pwrite":
1063
+ await fs.pwrite(path(), requireBridgeNumber(args.offset, "offset"), decodeBridgeBytes(args.content, "content"));
1064
+ break;
1065
+ default:
1066
+ throw new Error(`Unsupported js_bridge operation: ${request.operation}`);
1067
+ }
1068
+ return {
1069
+ type: "js_bridge_result",
1070
+ call_id: request.call_id,
1071
+ ...(result === undefined ? {} : { result }),
1072
+ };
1073
+ }
1074
+ catch (error) {
1075
+ return {
1076
+ type: "js_bridge_result",
1077
+ call_id: request.call_id,
1078
+ error: bridgeErrorMessage(error),
1079
+ };
1080
+ }
1081
+ }
1082
1082
  function parseHostCommandCallbackInput(input) {
1083
1083
  const value = asRecord(input);
1084
1084
  if (value.type !== "command" ||
@@ -1159,7 +1159,7 @@ async function invokeHostTool({ toolKit, toolName, args, cwd, context, }) {
1159
1159
  const callbackKey = `${toolKit.name}:${toolName}`;
1160
1160
  const permissionMode = toolPermissionMode(context.permissions, callbackKey);
1161
1161
  if (permissionMode !== "allow") {
1162
- throw new Error(`EACCES: blocked by tool.invoke policy for ${callbackKey}`);
1162
+ throw new Error(`EACCES: blocked by binding.invoke policy for ${callbackKey}`);
1163
1163
  }
1164
1164
  const input = await parseHostToolInput(tool, args, cwd, context.readFile);
1165
1165
  return executeHostTool(tool, callbackKey, input);
@@ -1315,7 +1315,7 @@ function describeToolPayload(toolKit, toolName) {
1315
1315
  };
1316
1316
  }
1317
1317
  function toolPermissionMode(permissions, callbackKey) {
1318
- const scope = permissions.tool;
1318
+ const scope = permissions.binding;
1319
1319
  if (!scope) {
1320
1320
  return "deny";
1321
1321
  }
@@ -1389,13 +1389,14 @@ export class AgentOs {
1389
1389
  _pendingSessionRequestResolvers = new Map();
1390
1390
  _processes = new Map();
1391
1391
  _shells = new Map();
1392
+ // Value is the recorded exit code (undefined until/unless the exit
1393
+ // resolves) so waitShell can still report it after the entry is dropped.
1392
1394
  _closedShellIds = new BoundedSet(CLOSED_SHELL_ID_RETENTION_LIMIT);
1393
1395
  _pendingShellExitPromises = new Set();
1394
1396
  _shellCounter = 0;
1395
1397
  _acpTerminals = new Map();
1396
1398
  _acpTerminalCounter = 0;
1397
1399
  _softwareRoots;
1398
- _softwareAgentConfigs;
1399
1400
  _cronManager;
1400
1401
  _toolKits = [];
1401
1402
  _toolReference = "";
@@ -1403,25 +1404,28 @@ export class AgentOs {
1403
1404
  _hostMounts;
1404
1405
  _env;
1405
1406
  _rootFilesystem;
1407
+ _additionalInstructions;
1406
1408
  _sidecarLease = null;
1407
1409
  _sidecarClient;
1408
1410
  _sidecarSession;
1409
1411
  _sidecarVm;
1410
1412
  _disposeSidecarEventListener;
1411
1413
  _agentStderrHandler;
1414
+ _agentExitHandler;
1412
1415
  _limitWarningHandler;
1413
- constructor(kernel, sidecar, softwareRoots, softwareAgentConfigs, hostMounts, env, rootFilesystem, sidecarClient, sidecarSession, sidecarVm, agentStderrHandler, limitWarningHandler) {
1416
+ constructor(kernel, sidecar, softwareRoots, hostMounts, env, rootFilesystem, sidecarClient, sidecarSession, sidecarVm, additionalInstructions, agentStderrHandler, agentExitHandler, limitWarningHandler) {
1414
1417
  this.#kernel = kernel;
1415
1418
  this.sidecar = sidecar;
1416
1419
  this._softwareRoots = softwareRoots;
1417
- this._softwareAgentConfigs = softwareAgentConfigs;
1418
1420
  this._hostMounts = hostMounts;
1419
1421
  this._env = env;
1420
1422
  this._rootFilesystem = rootFilesystem;
1421
1423
  this._sidecarClient = sidecarClient;
1422
1424
  this._sidecarSession = sidecarSession;
1423
1425
  this._sidecarVm = sidecarVm;
1426
+ this._additionalInstructions = additionalInstructions;
1424
1427
  this._agentStderrHandler = agentStderrHandler;
1428
+ this._agentExitHandler = agentExitHandler;
1425
1429
  this._limitWarningHandler = limitWarningHandler;
1426
1430
  this._disposeSidecarEventListener = this._sidecarClient.onEvent((event) => {
1427
1431
  this._handleSidecarEvent(event);
@@ -1441,75 +1445,89 @@ export class AgentOs {
1441
1445
  }
1442
1446
  static async create(options) {
1443
1447
  options = parseAgentOsOptions(options);
1448
+ // Default software is FULLY DYNAMIC: this package's own NON-agent
1449
+ // @agentos-software/* dependencies (e.g. common), each default-exporting
1450
+ // its registry-built descriptor. Agent packages are NOT projected here —
1451
+ // createSession(id) links the matching agent dependency into the running
1452
+ // VM on first use, so agent closures (and pi's V8 snapshot bundle) only
1453
+ // enter VMs that run them. Unbuilt packages throw with build
1454
+ // instructions; opt out via defaultSoftware: false.
1455
+ const defaultSoftware = options?.defaultSoftware === false ? [] : resolveDefaultSoftware();
1444
1456
  const software = options?.defaultSoftware === false
1445
1457
  ? (options.software ?? [])
1446
- : [commonSoftware, ...(options?.software ?? [])];
1447
- const processed = processSoftware(software);
1458
+ : [...defaultSoftware, ...(options?.software ?? [])];
1459
+ // Packages are projected by the SIDECAR: the client forwards only the
1460
+ // package `path` over `configureVm` and the sidecar reads metadata from
1461
+ // the packed vbare manifest (chunk1 of the `.aospkg`).
1462
+ const flatSoftware = software.flat();
1463
+ // Honor the AgentOsOptions.defaultSoftware contract ("entries already present
1464
+ // in `software` are not duplicated"): the default bundle and an explicitly
1465
+ // passed one resolve to the same package paths, so dedup by path. Without
1466
+ // this the sidecar rejects the second projection with a duplicate-command
1467
+ // error (e.g. coreutils' `[`).
1468
+ const seenPackagePaths = new Set();
1469
+ const sidecarPackages = flatSoftware.flatMap((entry) => {
1470
+ const ref = normalizePackageRef(entry);
1471
+ if (!ref || seenPackagePaths.has(ref.path)) {
1472
+ return [];
1473
+ }
1474
+ seenPackagePaths.add(ref.path);
1475
+ return [{ path: ref.path }];
1476
+ });
1477
+ // All package software is projected into `/opt/agentos` by the sidecar. The
1478
+ // client stages nothing host-side and parses NO package manifests: the
1479
+ // sidecar owns agent resolution, agent enumeration, and agent snapshot
1480
+ // bundle loading from the projected package dirs.
1448
1481
  const localMounts = await resolveCompatLocalMounts(options?.mounts);
1449
1482
  const toolKits = options?.toolKits;
1450
1483
  if (toolKits && toolKits.length > 0) {
1451
1484
  validateToolkits(toolKits);
1452
1485
  }
1486
+ // Resolve the sidecar handle up front so every VM created here leases the
1487
+ // one shared native sidecar process owned by that handle.
1488
+ const sidecar = resolveAgentOsSidecar(options?.sidecar);
1453
1489
  const createVmAdmin = async () => {
1454
- const preparedCommandDirs = prepareCommandDirs(processed.commandPackages);
1490
+ // The `/opt/agentos` projection is built by the sidecar from the
1491
+ // forwarded `packages` (it owns the staging dir + read-only mount, and
1492
+ // runtime `linkSoftware` appends to that live dir). The client no longer
1493
+ // stages packages host-side.
1455
1494
  const toolBootstrapCommands = collectToolkitBootstrapCommands(toolKits ?? []);
1456
- const bootstrapLower = createKernelBootstrapLower(options?.rootFilesystem, [
1457
- ...collectBootstrapWasmCommands(preparedCommandDirs.commandDirs),
1458
- ...NODE_RUNTIME_BOOTSTRAP_COMMANDS,
1495
+ const bootstrapCommands = [
1496
+ ...RUNTIME_BOOTSTRAP_COMMANDS,
1459
1497
  ...toolBootstrapCommands,
1460
- ]);
1498
+ ];
1499
+ const bootstrapLower = createKernelBootstrapLower(options?.rootFilesystem);
1461
1500
  let toolReference = "";
1462
1501
  let rootBridge = null;
1463
1502
  let kernel = null;
1464
1503
  let client = null;
1465
- let toolShimDir = null;
1504
+ let createdNativeVm = null;
1505
+ let nativeSession = null;
1466
1506
  let cleanedUp = false;
1467
1507
  const cleanup = async () => {
1468
1508
  if (cleanedUp) {
1469
1509
  return;
1470
1510
  }
1471
1511
  cleanedUp = true;
1472
- if (toolShimDir) {
1473
- rmSync(toolShimDir, { recursive: true, force: true });
1474
- toolShimDir = null;
1475
- }
1476
- preparedCommandDirs.dispose();
1477
1512
  };
1478
1513
  try {
1479
1514
  const env = getBaseEnvironment();
1480
- if (toolKits && toolKits.length > 0) {
1481
- toolShimDir = materializeToolShimDir(toolKits);
1482
- }
1483
- const commandGuestPaths = collectGuestCommandPaths(preparedCommandDirs.commandDirs);
1484
- const requestedMounts = options?.moduleAccessCwd
1485
- ? [
1486
- ...(options.mounts ?? []),
1487
- {
1488
- path: "/root/node_modules",
1489
- plugin: createHostDirBackend({
1490
- hostPath: join(options.moduleAccessCwd, "node_modules"),
1491
- readOnly: true,
1492
- }),
1493
- readOnly: true,
1494
- },
1495
- ]
1496
- : options?.mounts;
1515
+ // Guest command paths. The sidecar owns the `/opt/agentos` projection and
1516
+ // reports the exact projected package commands after `configureVm`.
1517
+ // Tool-shim commands are added below.
1518
+ const commandGuestPaths = new Map();
1497
1519
  const { sidecarMounts, hostMounts, hostPathMappings } = collectSidecarMountPlan({
1498
- mounts: requestedMounts,
1499
- softwareRoots: processed.softwareRoots,
1500
- commandDirs: preparedCommandDirs.commandDirs,
1501
- shimDir: toolShimDir,
1502
- });
1503
- client = SidecarProcess.spawn({
1504
- cwd: REPO_ROOT,
1505
- command: ensureNativeSidecarBinary(),
1506
- args: [],
1507
- frameTimeoutMs: NATIVE_SIDECAR_FRAME_TIMEOUT_MS,
1520
+ mounts: options?.mounts,
1508
1521
  });
1509
- const session = await client.authenticateAndOpenSession();
1522
+ // Reuse the sidecar handle's single shared native process; this VM
1523
+ // becomes another tenant of it rather than spawning its own process.
1524
+ const shared = await ensureSharedSidecarNativeProcess(sidecar);
1525
+ client = shared.client;
1526
+ const session = shared.session;
1527
+ nativeSession = session;
1510
1528
  const hostPermissions = options?.permissions ?? {
1511
1529
  ...allowAll,
1512
- tool: "allow",
1530
+ binding: "allow",
1513
1531
  };
1514
1532
  const sidecarPermissions = serializePermissionsForSidecar(hostPermissions);
1515
1533
  const createVmConfig = {
@@ -1518,18 +1536,25 @@ export class AgentOs {
1518
1536
  permissions: sidecarPermissions,
1519
1537
  limits: options?.limits,
1520
1538
  loopbackExemptPorts: options?.loopbackExemptPorts ?? [],
1539
+ bootstrapCommands,
1521
1540
  // 0.3: the Node builtin allow-list moved from configureVm to
1522
1541
  // VM creation. `undefined` => engine default allow-list;
1523
1542
  // `[]` => deny all; `[..]` => exactly those. Platform and
1524
1543
  // module resolution keep their engine defaults (full Node
1525
1544
  // emulation), matching the prior behavior where Agent OS only
1526
1545
  // constrained the builtin allow-list.
1527
- ...(options?.allowedNodeBuiltins !== undefined
1546
+ ...(options?.allowedNodeBuiltins !== undefined ||
1547
+ options?.highResolutionTime !== undefined
1528
1548
  ? {
1529
1549
  jsRuntime: {
1530
1550
  platform: "node",
1531
1551
  moduleResolution: "node",
1532
- allowedBuiltins: options.allowedNodeBuiltins,
1552
+ ...(options?.allowedNodeBuiltins !== undefined
1553
+ ? { allowedBuiltins: options.allowedNodeBuiltins }
1554
+ : {}),
1555
+ ...(options?.highResolutionTime !== undefined
1556
+ ? { highResolutionTime: options.highResolutionTime }
1557
+ : {}),
1533
1558
  },
1534
1559
  }
1535
1560
  : {}),
@@ -1538,14 +1563,25 @@ export class AgentOs {
1538
1563
  runtime: "java_script",
1539
1564
  config: createVmConfig,
1540
1565
  });
1566
+ createdNativeVm = nativeVm;
1567
+ // Scope the readiness wait to THIS VM's ownership; on a shared process
1568
+ // other VMs are emitting their own lifecycle events concurrently.
1541
1569
  await client.waitForEvent((event) => event.payload.type === "vm_lifecycle" &&
1542
- event.payload.state === "ready", 10_000);
1543
- await client.configureVm(session, nativeVm, {
1570
+ event.payload.state === "ready" &&
1571
+ event.ownership.scope === "vm" &&
1572
+ event.ownership.vm_id === nativeVm.vmId, 10_000);
1573
+ const configuredVm = await client.configureVm(session, nativeVm, {
1544
1574
  mounts: sidecarMounts,
1545
1575
  permissions: sidecarPermissions,
1546
- commandPermissions: processed.commandPermissions,
1576
+ commandPermissions: {},
1547
1577
  loopbackExemptPorts: options?.loopbackExemptPorts,
1578
+ packages: sidecarPackages,
1579
+ packagesMountAt: OPT_AGENTOS_ROOT,
1580
+ toolShimCommands: toolBootstrapCommands,
1548
1581
  });
1582
+ for (const command of configuredVm.projectedCommands) {
1583
+ commandGuestPaths.set(command.name, command.guestPath);
1584
+ }
1549
1585
  if (toolKits && toolKits.length > 0) {
1550
1586
  toolReference = await registerToolkitsOnSidecar(client, session, nativeVm, toolKits);
1551
1587
  commandGuestPaths.set("agentos", "/bin/agentos");
@@ -1560,8 +1596,15 @@ export class AgentOs {
1560
1596
  env,
1561
1597
  cwd: "/workspace",
1562
1598
  localMounts,
1599
+ sidecarMounts,
1600
+ permissions: sidecarPermissions,
1601
+ commandPermissions: {},
1602
+ loopbackExemptPorts: options?.loopbackExemptPorts,
1563
1603
  commandGuestPaths,
1564
1604
  onDispose: cleanup,
1605
+ // The native process is owned by the AgentOsSidecar handle and
1606
+ // shared across VMs; disposing this VM must not kill the process.
1607
+ ownsClient: false,
1565
1608
  });
1566
1609
  await bootstrapLiveBootstrapDirectories(client, session, nativeVm, options?.rootFilesystem);
1567
1610
  kernel = rootBridge;
@@ -1571,6 +1614,10 @@ export class AgentOs {
1571
1614
  hostMounts,
1572
1615
  kernel,
1573
1616
  rootView: rootBridge.createRootView(),
1617
+ sidecarMounts,
1618
+ sidecarPermissions,
1619
+ commandPermissions: {},
1620
+ loopbackExemptPorts: options?.loopbackExemptPorts,
1574
1621
  sidecarClient: client,
1575
1622
  sidecarSession: session,
1576
1623
  sidecarVm: nativeVm,
@@ -1595,6 +1642,8 @@ export class AgentOs {
1595
1642
  };
1596
1643
  }
1597
1644
  catch (error) {
1645
+ // The native process is shared and owned by the sidecar handle, so
1646
+ // never dispose the client here — only tear down this VM's resources.
1598
1647
  if (kernel) {
1599
1648
  await kernel.dispose().catch(() => { });
1600
1649
  }
@@ -1602,20 +1651,23 @@ export class AgentOs {
1602
1651
  await rootBridge.dispose().catch(() => { });
1603
1652
  }
1604
1653
  else {
1605
- await client?.dispose().catch(() => { });
1654
+ if (createdNativeVm && nativeSession && client) {
1655
+ await client
1656
+ .disposeVm(nativeSession, createdNativeVm)
1657
+ .catch(() => { });
1658
+ }
1606
1659
  await cleanup();
1607
1660
  }
1608
1661
  throw error;
1609
1662
  }
1610
1663
  };
1611
- const sidecar = resolveAgentOsSidecar(options?.sidecar);
1612
1664
  let sidecarLease = null;
1613
1665
  try {
1614
1666
  sidecarLease = await leaseAgentOsSidecarVm(sidecar, {
1615
1667
  createVm: async () => createVmAdmin(),
1616
1668
  });
1617
1669
  const vmAdmin = sidecarLease.admin;
1618
- const vm = new AgentOs(vmAdmin.kernel, sidecar, processed.softwareRoots, processed.agentConfigs, vmAdmin.hostMounts, vmAdmin.env, vmAdmin.rootView, vmAdmin.sidecarClient, vmAdmin.sidecarSession, vmAdmin.sidecarVm, options?.onAgentStderr ?? defaultAgentStderrHandler, options?.onLimitWarning);
1670
+ const vm = new AgentOs(vmAdmin.kernel, sidecar, [], vmAdmin.hostMounts, vmAdmin.env, vmAdmin.rootView, vmAdmin.sidecarClient, vmAdmin.sidecarSession, vmAdmin.sidecarVm, options?.additionalInstructions, options?.onAgentStderr ?? defaultAgentStderrHandler, options?.onAgentExit ?? defaultAgentExitHandler, options?.onLimitWarning);
1619
1671
  vm._sidecarLease = sidecarLease;
1620
1672
  vm._toolKits = vmAdmin.toolKits;
1621
1673
  vm._toolReference = vmAdmin.toolReference;
@@ -1646,6 +1698,11 @@ export class AgentOs {
1646
1698
  exitHandlers,
1647
1699
  };
1648
1700
  this._processes.set(proc.pid, entry);
1701
+ // NOTE: do NOT delete from `_processes` on exit — the public API contract
1702
+ // (getProcess/listProcesses/stopProcess, see process-management.test.ts)
1703
+ // requires exited processes to stay queryable (running:false, exitCode set).
1704
+ // `_processes` is a process table for this VM's lifetime; it is freed wholesale
1705
+ // in dispose(). (H5: the leak was that dispose() never cleared it.)
1649
1706
  void proc.wait().then((code) => {
1650
1707
  for (const h of exitHandlers)
1651
1708
  h(code);
@@ -1679,14 +1736,14 @@ export class AgentOs {
1679
1736
  const entry = this._processes.get(pid);
1680
1737
  if (!entry)
1681
1738
  throw new Error(`Process not found: ${pid}`);
1682
- entry.proc.writeStdin(data);
1739
+ return entry.proc.writeStdin(data);
1683
1740
  }
1684
1741
  /** Close a process's stdin stream. */
1685
1742
  closeProcessStdin(pid) {
1686
1743
  const entry = this._processes.get(pid);
1687
1744
  if (!entry)
1688
1745
  throw new Error(`Process not found: ${pid}`);
1689
- entry.proc.closeStdin();
1746
+ return entry.proc.closeStdin();
1690
1747
  }
1691
1748
  /** Subscribe to stdout data from a process. Returns an unsubscribe function. */
1692
1749
  onProcessStdout(pid, handler) {
@@ -1747,35 +1804,6 @@ export class AgentOs {
1747
1804
  _vfs() {
1748
1805
  return this.#kernel.vfs;
1749
1806
  }
1750
- async _copyPath(from, to) {
1751
- const stat = await this._vfs().lstat(from);
1752
- if (stat.isSymbolicLink) {
1753
- const target = await this._vfs().readlink(from);
1754
- await this._vfs().symlink(target, to);
1755
- return;
1756
- }
1757
- if (stat.isDirectory) {
1758
- await this._mkdirp(posixPath.dirname(to));
1759
- if (!(await this.#kernel.exists(to))) {
1760
- await this.#kernel.mkdir(to);
1761
- }
1762
- await this._vfs().chmod(to, stat.mode);
1763
- await this._vfs().chown(to, stat.uid, stat.gid);
1764
- const entries = await this.#kernel.readdir(from);
1765
- for (const entry of entries) {
1766
- if (entry === "." || entry === "..")
1767
- continue;
1768
- const fromPath = from === "/" ? `/${entry}` : `${from}/${entry}`;
1769
- const toPath = to === "/" ? `/${entry}` : `${to}/${entry}`;
1770
- await this._copyPath(fromPath, toPath);
1771
- }
1772
- return;
1773
- }
1774
- const content = await this.#kernel.readFile(from);
1775
- await this.writeFile(to, content);
1776
- await this._vfs().chmod(to, stat.mode);
1777
- await this._vfs().chown(to, stat.uid, stat.gid);
1778
- }
1779
1807
  async readFile(path) {
1780
1808
  this._assertSafeAbsolutePath(path);
1781
1809
  return this.#kernel.readFile(path);
@@ -1828,14 +1856,13 @@ export class AgentOs {
1828
1856
  /** Recursively create directories (mkdir -p). */
1829
1857
  async _mkdirp(path) {
1830
1858
  this._assertWritableAbsolutePath(path);
1831
- const parts = path.split("/").filter(Boolean);
1832
- let current = "";
1833
- for (const part of parts) {
1834
- current += `/${part}`;
1835
- if (!(await this.#kernel.exists(current))) {
1836
- await this.#kernel.mkdir(current);
1837
- }
1838
- }
1859
+ // `kernel.mkdir` is already recursive (it defaults to recursive=true on both
1860
+ // the native sidecar and compat kernels) and creating an existing directory is
1861
+ // a no-op, so a single call is sufficient. Do NOT probe each ancestor with
1862
+ // `exists()` first: on the native sidecar every read-side op
1863
+ // (exists/stat/readFile) triggers a full shadow-tree walk, so a per-component
1864
+ // exists() loop makes `mkdir -p` cost O(components * tree).
1865
+ await this.#kernel.mkdir(path);
1839
1866
  }
1840
1867
  async mkdir(path, options) {
1841
1868
  if (options?.recursive) {
@@ -1850,49 +1877,31 @@ export class AgentOs {
1850
1877
  }
1851
1878
  async readdirRecursive(path, options) {
1852
1879
  this._assertSafeAbsolutePath(path);
1853
- const maxDepth = options?.maxDepth;
1854
1880
  const exclude = options?.exclude ? new Set(options.exclude) : undefined;
1881
+ const entries = await this.#kernel.readdirRecursive(path, {
1882
+ maxDepth: options?.maxDepth,
1883
+ });
1884
+ const excludedPrefixes = [];
1855
1885
  const results = [];
1856
- // BFS queue: [dirPath, currentDepth]
1857
- const queue = [[path, 0]];
1858
- while (queue.length > 0) {
1859
- const item = queue.shift();
1860
- if (!item)
1861
- break;
1862
- const [dirPath, depth] = item;
1863
- const entries = await this.#kernel.readdir(dirPath);
1864
- for (const name of entries) {
1865
- if (name === "." || name === "..")
1866
- continue;
1867
- if (exclude?.has(name))
1868
- continue;
1869
- const fullPath = dirPath === "/" ? `/${name}` : `${dirPath}/${name}`;
1870
- const s = await this.#kernel.stat(fullPath);
1871
- if (s.isSymbolicLink) {
1872
- results.push({
1873
- path: fullPath,
1874
- type: "symlink",
1875
- size: s.size,
1876
- });
1877
- }
1878
- else if (s.isDirectory) {
1879
- results.push({
1880
- path: fullPath,
1881
- type: "directory",
1882
- size: s.size,
1883
- });
1884
- if (maxDepth === undefined || depth < maxDepth) {
1885
- queue.push([fullPath, depth + 1]);
1886
- }
1887
- }
1888
- else {
1889
- results.push({
1890
- path: fullPath,
1891
- type: "file",
1892
- size: s.size,
1893
- });
1886
+ for (const entry of entries) {
1887
+ if (excludedPrefixes.some((prefix) => entry.path === prefix || entry.path.startsWith(`${prefix}/`))) {
1888
+ continue;
1889
+ }
1890
+ if (exclude?.has(entry.name)) {
1891
+ if (entry.isDirectory && !entry.isSymbolicLink) {
1892
+ excludedPrefixes.push(entry.path);
1894
1893
  }
1894
+ continue;
1895
1895
  }
1896
+ results.push({
1897
+ path: entry.path,
1898
+ type: entry.isSymbolicLink
1899
+ ? "symlink"
1900
+ : entry.isDirectory
1901
+ ? "directory"
1902
+ : "file",
1903
+ size: entry.size,
1904
+ });
1896
1905
  }
1897
1906
  return results;
1898
1907
  }
@@ -1920,30 +1929,15 @@ export class AgentOs {
1920
1929
  this.#kernel.unmountFs(path);
1921
1930
  }
1922
1931
  async move(from, to) {
1923
- this._assertSafeAbsolutePath(from);
1924
- this._assertSafeAbsolutePath(to);
1925
- const sourceStat = await this._vfs().lstat(from);
1926
- if (!sourceStat.isDirectory || sourceStat.isSymbolicLink) {
1927
- return this.#kernel.rename(from, to);
1928
- }
1929
- await this._copyPath(from, to);
1930
- await this.delete(from, { recursive: true });
1932
+ this._assertWritableAbsolutePath(from);
1933
+ this._assertWritableAbsolutePath(to);
1934
+ await this.#kernel.movePath(from, to);
1931
1935
  }
1932
1936
  async delete(path, options) {
1933
- this._assertSafeAbsolutePath(path);
1934
- const s = await this.#kernel.stat(path);
1935
- if (s.isDirectory) {
1936
- if (options?.recursive) {
1937
- const entries = await this.#kernel.readdir(path);
1938
- for (const entry of entries) {
1939
- if (entry === "." || entry === "..")
1940
- continue;
1941
- await this.delete(`${path}/${entry}`, { recursive: true });
1942
- }
1943
- }
1944
- return this.#kernel.removeDir(path);
1945
- }
1946
- return this.#kernel.removeFile(path);
1937
+ this._assertWritableAbsolutePath(path);
1938
+ await this.#kernel.removePath(path, {
1939
+ recursive: options?.recursive ?? false,
1940
+ });
1947
1941
  }
1948
1942
  async fetch(port, request) {
1949
1943
  const url = new URL(request.url);
@@ -1978,15 +1972,24 @@ export class AgentOs {
1978
1972
  const entry = {
1979
1973
  handle,
1980
1974
  dataHandlers,
1981
- exitPromise: Promise.resolve(),
1975
+ exitPromise: Promise.resolve(0),
1982
1976
  };
1983
- const exitPromise = handle.wait().then(() => undefined, () => undefined);
1984
- entry.exitPromise = exitPromise.finally(() => {
1977
+ const exitPromise = handle.wait();
1978
+ const finalize = (exitCode) => {
1985
1979
  this._pendingShellExitPromises.delete(entry.exitPromise);
1986
1980
  if (this._shells.get(shellId) === entry) {
1987
1981
  this._shells.delete(shellId);
1988
- this._closedShellIds.add(shellId);
1989
1982
  }
1983
+ // Record the exit code even when closeShell already dropped the
1984
+ // entry, so a waitShell issued after exit still resolves with it.
1985
+ this._closedShellIds.add(shellId, exitCode);
1986
+ };
1987
+ entry.exitPromise = exitPromise.then((exitCode) => {
1988
+ finalize(exitCode);
1989
+ return exitCode;
1990
+ }, (error) => {
1991
+ finalize();
1992
+ throw error;
1990
1993
  });
1991
1994
  this._pendingShellExitPromises.add(entry.exitPromise);
1992
1995
  this._shells.set(shellId, entry);
@@ -2000,7 +2003,7 @@ export class AgentOs {
2000
2003
  const entry = this._shells.get(shellId);
2001
2004
  if (!entry)
2002
2005
  throw new Error(`Shell not found: ${shellId}`);
2003
- entry.handle.write(data);
2006
+ return entry.handle.write(data);
2004
2007
  }
2005
2008
  /** Subscribe to data output from a shell. Returns an unsubscribe function. */
2006
2009
  onShellData(shellId, handler) {
@@ -2019,6 +2022,21 @@ export class AgentOs {
2019
2022
  throw new Error(`Shell not found: ${shellId}`);
2020
2023
  entry.handle.resize(cols, rows);
2021
2024
  }
2025
+ /**
2026
+ * Wait for a shell to exit and return its process exit code. Resolves
2027
+ * immediately for a shell that has already exited (within the closed-shell
2028
+ * retention window).
2029
+ */
2030
+ waitShell(shellId) {
2031
+ const entry = this._shells.get(shellId);
2032
+ if (!entry) {
2033
+ const exitCode = this._closedShellIds.get(shellId);
2034
+ if (exitCode !== undefined)
2035
+ return Promise.resolve(exitCode);
2036
+ throw new Error(`Shell not found: ${shellId}`);
2037
+ }
2038
+ return entry.exitPromise;
2039
+ }
2022
2040
  /** Kill a shell process and remove it from tracking. */
2023
2041
  closeShell(shellId) {
2024
2042
  const entry = this._shells.get(shellId);
@@ -2133,53 +2151,56 @@ export class AgentOs {
2133
2151
  }
2134
2152
  return session;
2135
2153
  }
2136
- /** Returns all registered agents with their installation status. */
2137
- listAgents() {
2138
- // Collect all agent IDs from both package configs and hardcoded configs.
2139
- const allIds = new Set([
2140
- ...this._softwareAgentConfigs.keys(),
2141
- ...Object.keys(AGENT_CONFIGS),
2142
- ]);
2143
- return [...allIds]
2144
- .map((id) => {
2145
- const config = this._resolveAgentConfig(id);
2146
- if (!config)
2147
- return null;
2148
- let installed = false;
2149
- try {
2150
- // Check software roots first, then the host dir behind the
2151
- // `/root/node_modules` mount.
2152
- const vmPrefix = `/root/node_modules/${config.acpAdapter}`;
2153
- let hostPkgJsonPath = null;
2154
- for (const root of this._softwareRoots) {
2155
- if (root.vmPath === vmPrefix) {
2156
- hostPkgJsonPath = join(root.hostPath, "package.json");
2157
- break;
2158
- }
2159
- }
2160
- if (!hostPkgJsonPath) {
2161
- const nodeModulesRoot = this._nodeModulesHostRoot();
2162
- if (nodeModulesRoot) {
2163
- hostPkgJsonPath = join(resolvePackageDir(nodeModulesRoot, config.acpAdapter), "package.json");
2164
- }
2165
- }
2166
- if (!hostPkgJsonPath) {
2167
- throw new Error("no package source");
2168
- }
2169
- readFileSync(hostPkgJsonPath);
2170
- installed = true;
2171
- }
2172
- catch {
2173
- // Package not installed
2174
- }
2175
- return {
2176
- id,
2177
- acpAdapter: config.acpAdapter,
2178
- agentPackage: config.agentPackage,
2179
- installed,
2180
- };
2181
- })
2182
- .filter((entry) => entry !== null);
2154
+ /**
2155
+ * Dynamically link a software package into the RUNNING VM. The package's
2156
+ * `bin/` commands appear under `/opt/agentos/bin` (on `$PATH`) and its `share/man`
2157
+ * pages under MANPATH immediately — the `/opt/agentos` mount is host-backed, so
2158
+ * writing into its staging dir is reflected live with no reboot. An `agent`
2159
+ * block registers the package for `createSession(name)`. Persists for the VM's
2160
+ * lifetime (and across a snapshot iff the volume persists).
2161
+ */
2162
+ async linkSoftware(descriptor) {
2163
+ const ref = normalizePackageRef(descriptor);
2164
+ if (!ref) {
2165
+ throw new Error("Invalid agentOS package reference");
2166
+ }
2167
+ // Forward to the sidecar, which owns the `/opt/agentos` projection and
2168
+ // appends the package to its live host-backed staging dir; the commands
2169
+ // appear under `/opt/agentos/bin` immediately. The sidecar rejects a
2170
+ // duplicate command, surfaced here as a thrown error.
2171
+ const commands = await this._sidecarClient.linkPackage(this._sidecarSession, this._sidecarVm, { path: ref.path });
2172
+ if (this.#kernel instanceof NativeSidecarKernelProxy) {
2173
+ this.#kernel.registerCommandGuestPaths(new Map(commands.projectedCommands.map((command) => [
2174
+ command.name,
2175
+ command.guestPath,
2176
+ ])));
2177
+ }
2178
+ // The client parses no manifests: an `agent` block in the linked package is
2179
+ // picked up by the sidecar (it owns the projected `/opt/agentos` and answers
2180
+ // createSession/listAgents from it). Nothing to record client-side.
2181
+ }
2182
+ async providedCommands() {
2183
+ return this._sidecarClient.providedCommands(this._sidecarSession, this._sidecarVm);
2184
+ }
2185
+ /**
2186
+ * Returns all registered agents with their installation status. Thin forwarder:
2187
+ * sends `AcpListAgentsRequest` and maps the response. The sidecar enumerates the
2188
+ * projected `/opt/agentos` packages (the client parses no manifests). Every such
2189
+ * agent is a package materialized into the VM, so `installed` is always `true`.
2190
+ */
2191
+ async listAgents() {
2192
+ const response = await this._sendAcpRequest({
2193
+ tag: "AcpListAgentsRequest",
2194
+ val: { reserved: false },
2195
+ });
2196
+ if (response.tag !== "AcpListAgentsResponse") {
2197
+ throw new Error(`unexpected list_agents response: ${response.tag}`);
2198
+ }
2199
+ return response.val.agents.map((agent) => ({
2200
+ id: agent.id,
2201
+ installed: agent.installed,
2202
+ adapterEntrypoint: agent.adapterEntrypoint,
2203
+ }));
2183
2204
  }
2184
2205
  _syncSessionState(session, state) {
2185
2206
  session.processId = state.processId;
@@ -2258,6 +2279,60 @@ export class AgentOs {
2258
2279
  session.eventHandlers.delete(subscriber);
2259
2280
  };
2260
2281
  }
2282
+ /**
2283
+ * Warn once per session (host-visible) that a tool-permission request was
2284
+ * auto-denied because no `onPermissionRequest` handler is registered. Shared
2285
+ * by both the bare-callback and JSON-RPC permission paths so the message and
2286
+ * the once-per-session guard cannot drift between them.
2287
+ */
2288
+ _warnNoPermissionHandlerOnce(session, params) {
2289
+ if (session.warnedNoPermissionHandler) {
2290
+ return;
2291
+ }
2292
+ session.warnedNoPermissionHandler = true;
2293
+ this._emitSessionWarning(session, `agentos: a tool-permission request (${this._permissionToolLabel(params)}) was ` +
2294
+ `auto-denied because no onPermissionRequest handler is registered for session ` +
2295
+ `${session.sessionId}. Register one with vm.onPermissionRequest(sessionId, ...) and ` +
2296
+ `reply via vm.respondPermission(...) to let the agent use tools.`);
2297
+ }
2298
+ /** Best-effort human label for the tool named in a permission request. */
2299
+ _permissionToolLabel(params) {
2300
+ if (typeof params.toolName === "string") {
2301
+ return params.toolName;
2302
+ }
2303
+ const toolCall = params.toolCall;
2304
+ if (toolCall &&
2305
+ typeof toolCall === "object" &&
2306
+ typeof toolCall.title === "string") {
2307
+ return toolCall.title;
2308
+ }
2309
+ return "a tool";
2310
+ }
2311
+ /**
2312
+ * Emit a host-visible warning for a session through the same agent-process log
2313
+ * channel that surfaces adapter stderr (`onAgentStderr`, default: process
2314
+ * stderr). Used for agent-os-owned diagnostics — e.g. a permission request
2315
+ * that was auto-denied because no host hook is registered — so they never fire
2316
+ * silently inside the sidecar.
2317
+ */
2318
+ _emitSessionWarning(session, message) {
2319
+ const handler = this._agentStderrHandler;
2320
+ if (!handler) {
2321
+ return;
2322
+ }
2323
+ try {
2324
+ handler({
2325
+ sessionId: session.sessionId,
2326
+ agentType: session.agentType,
2327
+ processId: session.processId,
2328
+ pid: session.pid,
2329
+ chunk: new TextEncoder().encode(`${message}\n`),
2330
+ });
2331
+ }
2332
+ catch {
2333
+ // A warning sink failure must never affect permission handling.
2334
+ }
2335
+ }
2261
2336
  _recordAgentStderr(event) {
2262
2337
  const session = (event.sessionId ? this._sessions.get(event.sessionId) : undefined) ??
2263
2338
  [...this._sessions.values()].find((candidate) => candidate.processId === event.processId);
@@ -2282,6 +2357,28 @@ export class AgentOs {
2282
2357
  // Ignore subscriber callback failures and keep event delivery moving.
2283
2358
  }
2284
2359
  }
2360
+ _recordAgentExit(event) {
2361
+ const session = this._sessions.get(event.sessionId);
2362
+ const handler = this._agentExitHandler;
2363
+ if (!handler) {
2364
+ return;
2365
+ }
2366
+ try {
2367
+ handler({
2368
+ sessionId: event.sessionId,
2369
+ agentType: event.agentType || session?.agentType || "",
2370
+ processId: event.processId,
2371
+ pid: session?.pid ?? null,
2372
+ exitCode: event.exitCode,
2373
+ restart: event.restart,
2374
+ restartCount: event.restartCount,
2375
+ maxRestarts: event.maxRestarts,
2376
+ });
2377
+ }
2378
+ catch {
2379
+ // Ignore subscriber callback failures and keep event delivery moving.
2380
+ }
2381
+ }
2285
2382
  _applySyntheticConfigOverrides(session) {
2286
2383
  if (session.configOverrides.size === 0) {
2287
2384
  return;
@@ -2367,6 +2464,10 @@ export class AgentOs {
2367
2464
  this._recordAgentStderr(event.val);
2368
2465
  return;
2369
2466
  }
2467
+ case "AcpAgentExitedEvent": {
2468
+ this._recordAgentExit(event.val);
2469
+ return;
2470
+ }
2370
2471
  }
2371
2472
  }
2372
2473
  catch {
@@ -2582,37 +2683,30 @@ export class AgentOs {
2582
2683
  this._syncSessionState(session, state);
2583
2684
  }
2584
2685
  async createSession(agentType, options) {
2585
- const config = this._resolveAgentConfig(agentType);
2586
- if (!config) {
2587
- throw new Error(`Unknown agent type: ${agentType}`);
2588
- }
2589
- // System-prompt assembly and injection (launch args / OPENCODE_CONTEXTPATHS) are owned by
2590
- // the sidecar at AcpCreateSessionRequest. The host only forwards additionalInstructions /
2591
- // skipOsInstructions plus the agent's static launch args and env.
2592
- const launchArgs = [...(config.launchArgs ?? [])];
2593
- let launchEnv = { ...config.defaultEnv, ...options?.env };
2686
+ // The client is npm-agnostic: it sends only the agent NAME. The sidecar
2687
+ // resolves the name -> package -> entrypoint/env/launchArgs from the
2688
+ // projected `/opt/agentos/<name>/current/agentos-package.json` and spawns
2689
+ // (including the agent's static launch args and manifest env defaults).
2690
+ // System-prompt assembly/injection (launch args / OPENCODE_CONTEXTPATHS) is
2691
+ // owned by the sidecar; the host only forwards additionalInstructions /
2692
+ // skipOsInstructions plus the caller's env.
2693
+ const launchEnv = { ...options?.env };
2594
2694
  const sessionCwd = options?.cwd ?? "/workspace";
2595
- const adapterEntrypoint = this._resolveAdapterBin(config.acpAdapter);
2596
- if ((agentType === "pi" || agentType === "pi-cli") &&
2597
- !launchEnv.PI_ACP_PI_COMMAND) {
2598
- launchEnv = {
2599
- ...launchEnv,
2600
- PI_ACP_PI_COMMAND: this._resolvePackageBin(config.agentPackage, "pi"),
2601
- };
2602
- }
2603
2695
  const response = await this._sendAcpRequest({
2604
2696
  tag: "AcpCreateSessionRequest",
2605
2697
  val: {
2606
2698
  agentType: String(agentType),
2607
2699
  runtime: AcpRuntimeKind.JavaScript,
2608
- adapterEntrypoint,
2609
- args: launchArgs,
2700
+ args: [],
2610
2701
  env: new Map(Object.entries(launchEnv)),
2611
2702
  cwd: sessionCwd,
2612
2703
  mcpServers: JSON.stringify(options?.mcpServers ?? []),
2613
2704
  protocolVersion: ACP_PROTOCOL_VERSION,
2614
2705
  clientCapabilities: JSON.stringify(defaultAcpClientCapabilities()),
2615
- additionalInstructions: combineInstructions(options?.additionalInstructions, this._toolReference),
2706
+ additionalInstructions: combineInstructions([this._additionalInstructions, options?.additionalInstructions]
2707
+ .map((part) => part?.trim())
2708
+ .filter((part) => Boolean(part))
2709
+ .join("\n\n") || undefined, this._toolReference),
2616
2710
  skipOsInstructions: options?.skipOsInstructions ?? false,
2617
2711
  },
2618
2712
  });
@@ -2663,27 +2757,11 @@ export class AgentOs {
2663
2757
  * there is no surviving store and the fallback tier always runs.
2664
2758
  */
2665
2759
  async resumeSession(sessionId, agentType, options) {
2666
- const config = this._resolveAgentConfig(agentType);
2667
- if (!config) {
2668
- throw new Error(`Unknown agent type: ${agentType}`);
2669
- }
2670
- const adapterEntrypoint = this._resolveAdapterBin(config.acpAdapter);
2671
- let launchEnv = { ...config.defaultEnv, ...options?.env };
2760
+ // The client is npm-agnostic: it sends only the agent NAME. The sidecar
2761
+ // resolves the name -> package -> entrypoint/env/launchArgs from the
2762
+ // projected manifest, exactly as createSession does.
2672
2763
  const sessionCwd = options?.cwd ?? "/workspace";
2673
- if ((agentType === "pi" || agentType === "pi-cli") &&
2674
- !launchEnv.PI_ACP_PI_COMMAND) {
2675
- launchEnv = {
2676
- ...launchEnv,
2677
- PI_ACP_PI_COMMAND: this._resolvePackageBin(config.agentPackage, "pi"),
2678
- };
2679
- }
2680
- // The resume wire request has no dedicated `adapterEntrypoint` field; carry
2681
- // the resolved entrypoint through env under the sidecar's reserved key. The
2682
- // sidecar reads it and strips it before launching the adapter.
2683
- launchEnv = {
2684
- ...launchEnv,
2685
- [RESUME_ADAPTER_ENTRYPOINT_ENV]: adapterEntrypoint,
2686
- };
2764
+ const launchEnv = { ...options?.env };
2687
2765
  const response = await this._sendAcpRequest({
2688
2766
  tag: "AcpResumeSessionRequest",
2689
2767
  val: {
@@ -2699,6 +2777,10 @@ export class AgentOs {
2699
2777
  }
2700
2778
  const { sessionId: liveSessionId, mode } = response.val;
2701
2779
  // Register + hydrate the live session so subsequent prompts route to it.
2780
+ const existing = this._sessions.get(liveSessionId);
2781
+ if (existing !== undefined && !existing.closed) {
2782
+ throw new Error(`session id collision: ${liveSessionId}`);
2783
+ }
2702
2784
  const session = sessionEntryFromInit(liveSessionId, String(agentType), {});
2703
2785
  this._closedSessionIds.delete(liveSessionId);
2704
2786
  this._sessions.set(liveSessionId, session);
@@ -2711,65 +2793,6 @@ export class AgentOs {
2711
2793
  }
2712
2794
  return { sessionId: liveSessionId, mode };
2713
2795
  }
2714
- /**
2715
- * Resolve the VM bin entry point of an ACP adapter package.
2716
- * Reads from the host filesystem since kernel.readFile() resolves through
2717
- * mounts; adapter package.json is read directly off the host dir backing
2718
- * the relevant mount.
2719
- */
2720
- _resolveAdapterBin(adapterPackage) {
2721
- return this._resolvePackageBin(adapterPackage);
2722
- }
2723
- /**
2724
- * Parent of the host directory backing the `/root/node_modules` mount, if
2725
- * the caller supplied one (e.g. via `nodeModulesMount(...)`). Used as the
2726
- * `resolvePackageDir` start dir for adapter/agent package resolution when no
2727
- * software root matches. `nodeModulesMount` mounts `<dir>/node_modules`, so
2728
- * the start dir is `<dir>` (one level above the node_modules tree).
2729
- */
2730
- _nodeModulesHostRoot() {
2731
- for (const mount of this._hostMounts) {
2732
- if (mount.vmPath === "/root/node_modules") {
2733
- return dirname(mount.hostPath);
2734
- }
2735
- }
2736
- return null;
2737
- }
2738
- _resolvePackageBin(packageName, binName) {
2739
- const vmPrefix = `/root/node_modules/${packageName}`;
2740
- let hostPkgJsonPath = null;
2741
- for (const root of this._softwareRoots) {
2742
- if (root.vmPath === vmPrefix) {
2743
- hostPkgJsonPath = join(root.hostPath, "package.json");
2744
- break;
2745
- }
2746
- }
2747
- // Fall back to the host dir behind the `/root/node_modules` mount.
2748
- if (!hostPkgJsonPath) {
2749
- const nodeModulesRoot = this._nodeModulesHostRoot();
2750
- if (!nodeModulesRoot) {
2751
- throw new Error(`Cannot resolve package "${packageName}": no software root provides it and ` +
2752
- `no /root/node_modules mount was supplied. Pass ` +
2753
- `mounts: [nodeModulesMount("<host>/node_modules")] to AgentOs.create().`);
2754
- }
2755
- hostPkgJsonPath = join(resolvePackageDir(nodeModulesRoot, packageName), "package.json");
2756
- }
2757
- const pkg = JSON.parse(readFileSync(hostPkgJsonPath, "utf-8"));
2758
- let binEntry;
2759
- if (typeof pkg.bin === "string") {
2760
- binEntry = pkg.bin;
2761
- }
2762
- else if (typeof pkg.bin === "object" && pkg.bin !== null) {
2763
- binEntry =
2764
- (binName ? pkg.bin[binName] : undefined) ??
2765
- pkg.bin[packageName] ??
2766
- Object.values(pkg.bin)[0];
2767
- }
2768
- if (!binEntry) {
2769
- throw new Error(`No bin entry found in ${packageName}/package.json`);
2770
- }
2771
- return `${vmPrefix}/${binEntry}`;
2772
- }
2773
2796
  _installSidecarRequestHandler() {
2774
2797
  const context = {
2775
2798
  toolKits: this._toolKits,
@@ -2782,10 +2805,8 @@ export class AgentOs {
2782
2805
  case "host_callback":
2783
2806
  return handleHostCallback(request, context);
2784
2807
  case "js_bridge_call":
2785
- return Promise.resolve({
2786
- type: "js_bridge_result",
2787
- call_id: request.payload.call_id,
2788
- error: `unsupported sidecar request type: ${request.payload.type}`,
2808
+ return handleJsBridgeCall(request.payload, {
2809
+ filesystem: this.#kernel.vfs,
2789
2810
  });
2790
2811
  case "ext":
2791
2812
  return this._handleAcpExtSidecarRequest(request.payload.envelope);
@@ -2972,6 +2993,10 @@ export class AgentOs {
2972
2993
  _acpMethod: request.method,
2973
2994
  };
2974
2995
  if (session.permissionHandlers.size === 0) {
2996
+ // Default-closed deny; warn once (host-visible) so a forgotten
2997
+ // onPermissionRequest handler is an observable cause rather than a
2998
+ // silent denial. See _warnNoPermissionHandlerOnce.
2999
+ this._warnNoPermissionHandlerOnce(session, permissionParams);
2975
3000
  return this._buildAcpPermissionResult("reject", permissionParams);
2976
3001
  }
2977
3002
  const reply = await new Promise((resolve, reject) => {
@@ -3254,6 +3279,10 @@ export class AgentOs {
3254
3279
  return "reject";
3255
3280
  }
3256
3281
  if (session.permissionHandlers.size === 0) {
3282
+ // Default-closed: deny when no host hook is listening, and warn once
3283
+ // (host-visible) so a forgotten onPermissionRequest handler is not an
3284
+ // invisible cause of an agent that cannot use any tool.
3285
+ this._warnNoPermissionHandlerOnce(session, params);
3257
3286
  return "reject";
3258
3287
  }
3259
3288
  try {
@@ -3283,14 +3312,6 @@ export class AgentOs {
3283
3312
  return "reject";
3284
3313
  }
3285
3314
  }
3286
- /**
3287
- * Resolve an agent config by ID. Package-provided configs take
3288
- * precedence over the hardcoded AGENT_CONFIGS.
3289
- */
3290
- _resolveAgentConfig(agentType) {
3291
- return (this._softwareAgentConfigs.get(agentType) ??
3292
- AGENT_CONFIGS[agentType]);
3293
- }
3294
3315
  /**
3295
3316
  * Gracefully destroy a session: cancel any pending work, close the client,
3296
3317
  * and remove from tracking. Unlike close() which is abrupt, this attempts
@@ -3474,6 +3495,7 @@ export class AgentOs {
3474
3495
  terminalExitPromises.push(terminal.waitPromise.then(() => undefined, () => undefined));
3475
3496
  }
3476
3497
  this._acpTerminals.clear();
3498
+ this._processes.clear();
3477
3499
  await waitForTrackedExitPromises([...shellExitPromises, ...terminalExitPromises], SHELL_DISPOSE_TIMEOUT_MS);
3478
3500
  this._disposeSidecarEventListener();
3479
3501
  const sidecarLease = this._sidecarLease;
@@ -3503,6 +3525,164 @@ function resolveAgentOsSidecar(config) {
3503
3525
  }
3504
3526
  const sidecarStates = new WeakMap();
3505
3527
  const sharedSidecars = new Map();
3528
+ let sidecarProcessExitHookInstalled = false;
3529
+ /**
3530
+ * Install a one-time, synchronous `process.on("exit")` hook that SIGKILLs any
3531
+ * pooled shared sidecar child. Once a one-shot host process is allowed to exit
3532
+ * (its sidecar handles are unref'd at 0 leases), this reaps the sidecar
3533
+ * immediately instead of waiting for its stdin-EOF grace window — no orphan, no
3534
+ * delay. We deliberately do NOT install SIGINT/SIGTERM handlers: a library
3535
+ * should not hijack the host's signal handling. SIGINT still reaches the sidecar
3536
+ * via the process group, and SIGTERM-driven exit still closes its stdin.
3537
+ */
3538
+ function ensureSidecarProcessExitCleanup() {
3539
+ if (sidecarProcessExitHookInstalled)
3540
+ return;
3541
+ sidecarProcessExitHookInstalled = true;
3542
+ process.on("exit", () => {
3543
+ for (const sidecar of sharedSidecars.values()) {
3544
+ try {
3545
+ sidecarStates.get(sidecar)?.sharedChild?.kill?.("SIGKILL");
3546
+ }
3547
+ catch {
3548
+ // best-effort reap; the process is exiting regardless
3549
+ }
3550
+ }
3551
+ });
3552
+ }
3553
+ function sidecarChildHandle(client) {
3554
+ // SidecarProcess -> StdioSidecarProtocolClient.child (the spawned ChildProcess).
3555
+ const protocolClient = client?.protocolClient;
3556
+ return protocolClient?.child ?? undefined;
3557
+ }
3558
+ /**
3559
+ * Apply the current hold state to the shared sidecar's child + stdio: ref them
3560
+ * while ≥1 hold is live so in-flight VM work keeps the host process alive; unref
3561
+ * them at 0 so a one-shot script exits on its own after `dispose()`. The sidecar
3562
+ * process itself stays running (reusable) and self-exits on stdin EOF when the
3563
+ * host finally goes away. Best-effort: never let ref/unref break VM lifecycle.
3564
+ */
3565
+ function applySharedSidecarHold(state) {
3566
+ const child = state.sharedChild;
3567
+ if (!child)
3568
+ return;
3569
+ const hold = (state.eventLoopHolds ?? 0) > 0;
3570
+ for (const handle of [child, child.stdin, child.stdout, child.stderr]) {
3571
+ if (!handle)
3572
+ continue;
3573
+ try {
3574
+ if (hold)
3575
+ handle.ref?.();
3576
+ else
3577
+ handle.unref?.();
3578
+ }
3579
+ catch {
3580
+ // ref/unref is an optimization, not correctness-critical
3581
+ }
3582
+ }
3583
+ }
3584
+ /**
3585
+ * Take a hold for the entire create→use→dispose lifetime of one VM lease. Taken
3586
+ * BEFORE VM creation starts (not when the lease lands in `activeLeases`) so a VM
3587
+ * that is still mid-creation keeps the sidecar ref'd and a concurrent dispose
3588
+ * cannot unref it out from under the in-flight create.
3589
+ */
3590
+ function acquireSharedSidecarHold(state) {
3591
+ state.eventLoopHolds = (state.eventLoopHolds ?? 0) + 1;
3592
+ if (state.eventLoopHolds === 1)
3593
+ applySharedSidecarHold(state);
3594
+ }
3595
+ /** Release a hold taken by {@link acquireSharedSidecarHold}; unref at 0. */
3596
+ function releaseSharedSidecarHold(state) {
3597
+ const current = state.eventLoopHolds ?? 0;
3598
+ if (current <= 0) {
3599
+ // The `holdReleased` guard makes each lease release exactly once, so this
3600
+ // should be unreachable. Warn rather than silently floor, per the repo's
3601
+ // no-silent-masking rule, so an accounting bug surfaces instead of hiding.
3602
+ state.eventLoopHolds = 0;
3603
+ console.warn("[agentos] shared sidecar event-loop hold released more than acquired");
3604
+ return;
3605
+ }
3606
+ state.eventLoopHolds = current - 1;
3607
+ if (state.eventLoopHolds === 0)
3608
+ applySharedSidecarHold(state);
3609
+ }
3610
+ /**
3611
+ * Spawn-once accessor for a sidecar handle's shared native process. Concurrent
3612
+ * callers await the same promise, so one `AgentOsSidecar` maps to exactly one
3613
+ * `agent-os-sidecar` OS process for its whole lifetime.
3614
+ */
3615
+ function ensureSharedSidecarNativeProcess(sidecar) {
3616
+ const state = getSidecarState(sidecar);
3617
+ if (!state.nativeProcess) {
3618
+ ensureSidecarProcessExitCleanup();
3619
+ state.nativeProcess = (async () => {
3620
+ const client = SidecarProcess.spawn({
3621
+ cwd: REPO_ROOT,
3622
+ command: ensureNativeSidecarBinary(),
3623
+ args: [],
3624
+ frameTimeoutMs: NATIVE_SIDECAR_FRAME_TIMEOUT_MS,
3625
+ });
3626
+ // Track the child immediately — BEFORE the handshake await — so a
3627
+ // failed `authenticateAndOpenSession()` can still reap it (otherwise
3628
+ // the spawned child is untracked, unreapable, and pins the loop).
3629
+ state.sharedChild = sidecarChildHandle(client);
3630
+ if (!state.sharedChild) {
3631
+ // We reached into @rivet-dev/agentos-runtime-core internals to get the child for
3632
+ // idle-unref. If that shape ever changes this returns undefined and
3633
+ // the optimization silently stops working (one-shot scripts would
3634
+ // hang again). Make it loud rather than a silent regression.
3635
+ console.warn("[agentos] could not resolve the shared sidecar child handle; " +
3636
+ "standalone scripts may not exit cleanly after dispose(). " +
3637
+ "This usually means @rivet-dev/agentos-runtime-core internals changed.");
3638
+ }
3639
+ // Apply the current hold state to the just-spawned child.
3640
+ applySharedSidecarHold(state);
3641
+ try {
3642
+ const session = await client.authenticateAndOpenSession();
3643
+ return { client, session };
3644
+ }
3645
+ catch (error) {
3646
+ // Spawn/handshake failed: reap the child, drop the cached handle,
3647
+ // and CLEAR the rejected promise so the next create() retries
3648
+ // instead of permanently wedging on a rejected `nativeProcess`.
3649
+ try {
3650
+ state.sharedChild?.kill?.("SIGKILL");
3651
+ }
3652
+ catch {
3653
+ // already gone
3654
+ }
3655
+ state.sharedChild = undefined;
3656
+ state.nativeProcess = undefined;
3657
+ throw error;
3658
+ }
3659
+ })();
3660
+ }
3661
+ return state.nativeProcess;
3662
+ }
3663
+ /** Dispose a sidecar handle's shared native process, if one was spawned. */
3664
+ async function disposeSharedSidecarNativeProcess(state) {
3665
+ const pending = state.nativeProcess;
3666
+ if (!pending) {
3667
+ return;
3668
+ }
3669
+ state.nativeProcess = undefined;
3670
+ // The cached child is now dead; drop it (symmetric with the assignment in
3671
+ // ensureSharedSidecarNativeProcess). We deliberately do NOT zero
3672
+ // `eventLoopHolds` here: this runs only from `AgentOsSidecar.dispose()`, which
3673
+ // has already set the handle to `disposing` (so no new lease can acquire) and
3674
+ // drained `activeLeases`; the disposed handle's state is then abandoned. Force-
3675
+ // zeroing a shared counter could clobber a hold on a freshly re-acquired
3676
+ // process generation, so it is left to the balanced acquire/release pairs.
3677
+ state.sharedChild = undefined;
3678
+ try {
3679
+ const { client } = await pending;
3680
+ await client.dispose();
3681
+ }
3682
+ catch {
3683
+ // Process may have already exited; nothing to reclaim.
3684
+ }
3685
+ }
3506
3686
  export class AgentOsSidecar {
3507
3687
  constructor(sidecarId, placement, sharedPool) {
3508
3688
  sidecarStates.set(this, {
@@ -3537,6 +3717,8 @@ export class AgentOsSidecar {
3537
3717
  }
3538
3718
  state.activeLeases.clear();
3539
3719
  state.description.activeVmCount = 0;
3720
+ // Tear down the shared native process after all leased VMs are gone.
3721
+ await disposeSharedSidecarNativeProcess(state);
3540
3722
  state.description.state = "disposed";
3541
3723
  if (state.sharedPool && sharedSidecars.get(state.sharedPool) === this) {
3542
3724
  sharedSidecars.delete(state.sharedPool);
@@ -3598,6 +3780,18 @@ async function leaseAgentOsSidecarVm(sidecar, options) {
3598
3780
  return transport;
3599
3781
  },
3600
3782
  });
3783
+ // Hold the shared sidecar's event-loop ref for this lease's WHOLE lifetime —
3784
+ // taken now, before VM creation, so a concurrent dispose cannot unref the
3785
+ // sidecar while this create is still in flight. Released exactly once on
3786
+ // dispose or on a failed create.
3787
+ acquireSharedSidecarHold(state);
3788
+ let holdReleased = false;
3789
+ const releaseHold = () => {
3790
+ if (holdReleased)
3791
+ return;
3792
+ holdReleased = true;
3793
+ releaseSharedSidecarHold(state);
3794
+ };
3601
3795
  let disposed = false;
3602
3796
  let leaseRecord;
3603
3797
  try {
@@ -3622,6 +3816,10 @@ async function leaseAgentOsSidecarVm(sidecar, options) {
3622
3816
  state.activeLeases.delete(leaseRecord);
3623
3817
  state.description.activeVmCount = state.activeLeases.size;
3624
3818
  await client.dispose();
3819
+ // Release this lease's hold; the shared sidecar is unref'd only
3820
+ // once the last hold (across all in-flight + active leases) drops,
3821
+ // so a one-shot host process can then exit on its own.
3822
+ releaseHold();
3625
3823
  },
3626
3824
  };
3627
3825
  leaseRecord = {
@@ -3633,6 +3831,7 @@ async function leaseAgentOsSidecarVm(sidecar, options) {
3633
3831
  }
3634
3832
  catch (error) {
3635
3833
  await client.dispose().catch(() => { });
3834
+ releaseHold();
3636
3835
  throw error;
3637
3836
  }
3638
3837
  }