@rivet-dev/agentos-core 0.0.0-agentos-dylib-plugin.bbfdf95 → 0.0.0-chdir-fix.3d9feab

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 (103) hide show
  1. package/README.md +8 -11
  2. package/dist/agent-os.d.ts +185 -43
  3. package/dist/agent-os.js +834 -536
  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/cron-manager.js +1 -4
  9. package/dist/cron/types.d.ts +1 -1
  10. package/dist/default-software.d.ts +8 -0
  11. package/dist/default-software.js +10 -0
  12. package/dist/generated/AcpLimitsConfig.d.ts +4 -0
  13. package/dist/generated/AcpLimitsConfig.js +2 -0
  14. package/dist/generated/CreateVmConfig.d.ts +26 -0
  15. package/dist/generated/CreateVmConfig.js +1 -0
  16. package/dist/generated/FsPermissionRule.d.ts +6 -0
  17. package/dist/generated/FsPermissionRule.js +1 -0
  18. package/dist/generated/FsPermissionRuleSet.d.ts +6 -0
  19. package/dist/generated/FsPermissionRuleSet.js +1 -0
  20. package/dist/generated/FsPermissionScope.d.ts +3 -0
  21. package/dist/generated/FsPermissionScope.js +1 -0
  22. package/dist/generated/HttpLimitsConfig.d.ts +3 -0
  23. package/dist/generated/HttpLimitsConfig.js +2 -0
  24. package/dist/generated/JsModuleResolution.d.ts +1 -0
  25. package/dist/generated/JsModuleResolution.js +2 -0
  26. package/dist/generated/JsRuntimeConfig.d.ts +31 -0
  27. package/dist/generated/JsRuntimeConfig.js +1 -0
  28. package/dist/generated/JsRuntimeLimitsConfig.d.ts +11 -0
  29. package/dist/generated/JsRuntimeLimitsConfig.js +2 -0
  30. package/dist/generated/JsRuntimePlatform.d.ts +1 -0
  31. package/dist/generated/JsRuntimePlatform.js +2 -0
  32. package/dist/generated/MountPluginDescriptor.d.ts +4 -0
  33. package/dist/generated/MountPluginDescriptor.js +2 -0
  34. package/dist/generated/NativeRootFilesystemConfig.d.ts +5 -0
  35. package/dist/generated/NativeRootFilesystemConfig.js +1 -0
  36. package/dist/generated/PatternPermissionRule.d.ts +6 -0
  37. package/dist/generated/PatternPermissionRule.js +1 -0
  38. package/dist/generated/PatternPermissionRuleSet.d.ts +6 -0
  39. package/dist/generated/PatternPermissionRuleSet.js +1 -0
  40. package/dist/generated/PatternPermissionScope.d.ts +3 -0
  41. package/dist/generated/PatternPermissionScope.js +1 -0
  42. package/dist/generated/PermissionMode.d.ts +1 -0
  43. package/dist/generated/PermissionMode.js +2 -0
  44. package/dist/generated/PermissionsPolicy.d.ts +10 -0
  45. package/dist/generated/PermissionsPolicy.js +1 -0
  46. package/dist/generated/PluginLimitsConfig.d.ts +4 -0
  47. package/dist/generated/PluginLimitsConfig.js +2 -0
  48. package/dist/generated/PythonLimitsConfig.d.ts +6 -0
  49. package/dist/generated/PythonLimitsConfig.js +2 -0
  50. package/dist/generated/ResourceLimitsConfig.d.ts +24 -0
  51. package/dist/generated/ResourceLimitsConfig.js +2 -0
  52. package/dist/generated/RootFilesystemConfig.d.ts +9 -0
  53. package/dist/generated/RootFilesystemConfig.js +1 -0
  54. package/dist/generated/RootFilesystemEntry.d.ts +13 -0
  55. package/dist/generated/RootFilesystemEntry.js +1 -0
  56. package/dist/generated/RootFilesystemEntryEncoding.d.ts +1 -0
  57. package/dist/generated/RootFilesystemEntryEncoding.js +2 -0
  58. package/dist/generated/RootFilesystemEntryKind.d.ts +1 -0
  59. package/dist/generated/RootFilesystemEntryKind.js +2 -0
  60. package/dist/generated/RootFilesystemLowerDescriptor.d.ts +7 -0
  61. package/dist/generated/RootFilesystemLowerDescriptor.js +1 -0
  62. package/dist/generated/RootFilesystemMode.d.ts +1 -0
  63. package/dist/generated/RootFilesystemMode.js +2 -0
  64. package/dist/generated/ToolLimitsConfig.d.ts +10 -0
  65. package/dist/generated/ToolLimitsConfig.js +2 -0
  66. package/dist/generated/VmDnsConfig.d.ts +6 -0
  67. package/dist/generated/VmDnsConfig.js +2 -0
  68. package/dist/generated/VmLimitsConfig.d.ts +18 -0
  69. package/dist/generated/VmLimitsConfig.js +1 -0
  70. package/dist/generated/VmListenPolicyConfig.d.ts +5 -0
  71. package/dist/generated/VmListenPolicyConfig.js +2 -0
  72. package/dist/generated/WasmLimitsConfig.d.ts +7 -0
  73. package/dist/generated/WasmLimitsConfig.js +2 -0
  74. package/dist/host-dir-mount.d.ts +1 -1
  75. package/dist/host-tools-zod.js +27 -7
  76. package/dist/host-tools.js +9 -0
  77. package/dist/index.d.ts +3 -1
  78. package/dist/index.js +3 -2
  79. package/dist/layers.d.ts +3 -3
  80. package/dist/layers.js +22 -13
  81. package/dist/options-schema.d.ts +453 -0
  82. package/dist/options-schema.js +268 -0
  83. package/dist/packages.d.ts +17 -116
  84. package/dist/packages.js +35 -322
  85. package/dist/runtime-compat.d.ts +27 -7
  86. package/dist/runtime-compat.js +133 -147
  87. package/dist/runtime.d.ts +37 -7
  88. package/dist/sandbox.d.ts +112 -0
  89. package/dist/sandbox.js +264 -0
  90. package/dist/sidecar/{agent-os-protocol.d.ts → agentos-protocol.d.ts} +99 -1
  91. package/dist/sidecar/{agent-os-protocol.js → agentos-protocol.js} +156 -19
  92. package/dist/sidecar/native-process-client.d.ts +6 -2
  93. package/dist/sidecar/native-process-client.js +8 -1
  94. package/dist/sidecar/permissions.d.ts +1 -1
  95. package/dist/sidecar/permissions.js +4 -4
  96. package/dist/sidecar/rpc-client.d.ts +87 -6
  97. package/dist/sidecar/rpc-client.js +433 -93
  98. package/dist/test/sandbox-agent.js +1 -1
  99. package/dist/types.d.ts +10 -3
  100. package/package.json +42 -35
  101. package/dist/agents.d.ts +0 -63
  102. package/dist/agents.js +0 -42
  103. package/fixtures/AGENTOS_SYSTEM_PROMPT.md +0 -21
package/dist/agent-os.js CHANGED
@@ -1,25 +1,17 @@
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
6
  import { validateToolkits } from "./host-tools.js";
8
7
  import { zodToJsonSchema } from "./host-tools-zod.js";
8
+ import { parseAgentOsOptions } from "./options-schema.js";
9
9
  import { resolvePublishedSidecarBinary } from "./sidecar/binary.js";
10
10
  import { findCargoBinary, resolveCargoBinary } from "./sidecar/cargo.js";
11
11
  export { isAcpTimeoutErrorData } from "./json-rpc.js";
12
12
  const ACP_PROTOCOL_VERSION = 1;
13
13
  const ACP_EXTENSION_NAMESPACE = "dev.rivet.agent-os.acp";
14
14
  const SHELL_DISPOSE_TIMEOUT_MS = 5_000;
15
- /**
16
- * Reserved `env` key on `AcpResumeSessionRequest` carrying the resolved adapter
17
- * bin entrypoint. The resume wire request omits a dedicated `adapterEntrypoint`
18
- * field; the sidecar reads the entrypoint from this key and strips it before
19
- * launching the adapter. Must stay in sync with the sidecar constant of the same
20
- * name in `crates/agentos-sidecar/src/acp_extension.rs`.
21
- */
22
- const RESUME_ADAPTER_ENTRYPOINT_ENV = "AGENT_OS_RESUME_ADAPTER_ENTRYPOINT";
23
15
  function defaultAcpClientCapabilities() {
24
16
  return {
25
17
  fs: {
@@ -40,19 +32,24 @@ async function waitForTrackedExitPromises(promises, timeoutMs) {
40
32
  }),
41
33
  ]);
42
34
  }
43
- import { AGENT_CONFIGS } from "./agents.js";
44
- import { getBaseEnvironment, getBaseFilesystemEntries, } from "./base-filesystem.js";
35
+ import { getBaseEnvironment } from "./base-filesystem.js";
45
36
  import { CronManager } from "./cron/cron-manager.js";
46
37
  import { TimerScheduleDriver } from "./cron/timer-driver.js";
47
38
  import { snapshotVirtualFilesystem, sortFilesystemEntries, } from "./filesystem-snapshot.js";
48
- import { createHostDirBackend } from "./host-dir-mount.js";
49
39
  import { serializeMountConfigForSidecar, } from "./js-bridge.js";
50
40
  import { createSnapshotExport, } from "./layers.js";
51
- import { processSoftware, resolvePackageDir, } from "./packages.js";
41
+ import { OPT_AGENTOS_ROOT, } from "./agentos-package.js";
42
+ import { resolveDefaultSoftware } from "./default-software.js";
52
43
  import { allowAll } from "./runtime-compat.js";
53
- import { AcpRuntimeKind, decodeAcpCallback, decodeAcpEvent, decodeAcpResponse, encodeAcpCallbackResponse, encodeAcpRequest, } from "./sidecar/agent-os-protocol.js";
44
+ import { AcpRuntimeKind, decodeAcpCallback, decodeAcpEvent, decodeAcpResponse, encodeAcpCallbackResponse, encodeAcpRequest, } from "./sidecar/agentos-protocol.js";
54
45
  import { serializePermissionsForSidecar } from "./sidecar/permissions.js";
55
- import { createAgentOsSidecarClient, NATIVE_SIDECAR_FRAME_TIMEOUT_MS, NativeSidecarKernelProxy, SidecarProcess, serializeRootFilesystemForSidecar, } from "./sidecar/rpc-client.js";
46
+ import { createAgentOsSidecarClient, NativeSidecarKernelProxy, SidecarProcess, serializeRootFilesystemForSidecar, } from "./sidecar/rpc-client.js";
47
+ function defaultAgentStderrHandler(event) {
48
+ process.stderr.write(event.chunk);
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
+ }
56
53
  const LEGACY_PERMISSION_METHOD = "request/permission";
57
54
  const ACP_PERMISSION_METHOD = "session/request_permission";
58
55
  class AcpDispatchError extends Error {
@@ -105,6 +102,28 @@ function toRecord(value) {
105
102
  ? value
106
103
  : {};
107
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
+ }
108
127
  function parseAcpJson(value, context) {
109
128
  if (value === null) {
110
129
  return undefined;
@@ -160,11 +179,11 @@ class BoundedSet {
160
179
  }
161
180
  this.limit = limit;
162
181
  }
163
- add(value) {
182
+ add(value, associated) {
164
183
  if (this.#entries.has(value)) {
165
184
  this.#entries.delete(value);
166
185
  }
167
- this.#entries.set(value, undefined);
186
+ this.#entries.set(value, associated);
168
187
  if (this.#entries.size <= this.limit) {
169
188
  return;
170
189
  }
@@ -176,6 +195,9 @@ class BoundedSet {
176
195
  has(value) {
177
196
  return this.#entries.has(value);
178
197
  }
198
+ get(value) {
199
+ return this.#entries.get(value);
200
+ }
179
201
  delete(value) {
180
202
  return this.#entries.delete(value);
181
203
  }
@@ -223,6 +245,7 @@ function sessionEntryFromInit(sessionId, agentType, initData) {
223
245
  agentInfo: initData.agentInfo ?? null,
224
246
  eventHandlers: new Set(),
225
247
  permissionHandlers: new Set(),
248
+ warnedNoPermissionHandler: false,
226
249
  configOverrides: new Map(),
227
250
  pendingPermissionReplies: new Map(),
228
251
  };
@@ -366,7 +389,8 @@ const KERNEL_POSIX_BOOTSTRAP_DIRS = [
366
389
  "/mnt",
367
390
  "/media",
368
391
  "/home",
369
- "/home/user",
392
+ "/home/agentos",
393
+ "/workspace",
370
394
  "/usr",
371
395
  "/usr/bin",
372
396
  "/usr/games",
@@ -390,8 +414,31 @@ const KERNEL_POSIX_BOOTSTRAP_DIRS = [
390
414
  "/var/tmp",
391
415
  "/etc/agentos",
392
416
  ];
393
- const NODE_RUNTIME_BOOTSTRAP_COMMANDS = ["node", "npm", "npx"];
394
- 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
+ ];
395
442
  const REPO_ROOT = fileURLToPath(new URL("../../..", import.meta.url));
396
443
  const SIDECAR_BINARY = join(REPO_ROOT, "target/debug/agentos-sidecar");
397
444
  const SIDECAR_BUILD_INPUTS = [
@@ -403,114 +450,6 @@ const SIDECAR_BUILD_INPUTS = [
403
450
  join(REPO_ROOT, "crates/sidecar"),
404
451
  ];
405
452
  let ensuredSidecarBinary = null;
406
- function isWasmBinaryFile(path) {
407
- try {
408
- const header = readFileSync(path);
409
- return (header.length >= 4 &&
410
- header[0] === 0x00 &&
411
- header[1] === 0x61 &&
412
- header[2] === 0x73 &&
413
- header[3] === 0x6d);
414
- }
415
- catch {
416
- return false;
417
- }
418
- }
419
- function collectBootstrapWasmCommands(commandDirs) {
420
- const commands = [];
421
- const seen = new Set();
422
- for (const dir of commandDirs) {
423
- let entries;
424
- try {
425
- entries = readdirSync(dir).sort((a, b) => a.localeCompare(b));
426
- }
427
- catch {
428
- continue;
429
- }
430
- for (const entry of entries) {
431
- if (entry.startsWith(".")) {
432
- continue;
433
- }
434
- const fullPath = join(dir, entry);
435
- try {
436
- if (statSync(fullPath).isDirectory()) {
437
- continue;
438
- }
439
- }
440
- catch {
441
- continue;
442
- }
443
- if (!isWasmBinaryFile(fullPath) || seen.has(entry)) {
444
- continue;
445
- }
446
- seen.add(entry);
447
- commands.push(entry);
448
- }
449
- }
450
- return commands;
451
- }
452
- function resolveDeclaredCommandSource(commandDir, commandName, aliases) {
453
- let current = commandName;
454
- const visited = new Set();
455
- while (!visited.has(current)) {
456
- visited.add(current);
457
- const candidatePath = join(commandDir, current);
458
- if (isWasmBinaryFile(candidatePath)) {
459
- return candidatePath;
460
- }
461
- const next = aliases[current];
462
- if (!next) {
463
- return null;
464
- }
465
- current = next;
466
- }
467
- return null;
468
- }
469
- function prepareCommandDirs(commandPackages) {
470
- const commandDirs = [];
471
- const tempDirs = [];
472
- try {
473
- for (const commandPackage of commandPackages) {
474
- commandDirs.push(commandPackage.commandDir);
475
- const aliasEntries = Object.entries(commandPackage.aliases)
476
- .sort(([leftAlias], [rightAlias]) => leftAlias.localeCompare(rightAlias))
477
- .flatMap(([aliasName]) => {
478
- const aliasPath = join(commandPackage.commandDir, aliasName);
479
- if (isWasmBinaryFile(aliasPath)) {
480
- return [];
481
- }
482
- const sourcePath = resolveDeclaredCommandSource(commandPackage.commandDir, aliasName, commandPackage.aliases);
483
- if (!sourcePath) {
484
- return [];
485
- }
486
- return [[aliasName, sourcePath]];
487
- });
488
- if (aliasEntries.length === 0) {
489
- continue;
490
- }
491
- const aliasDir = mkdtempSync(join(tmpdir(), "agent-os-command-aliases-"));
492
- for (const [aliasName, sourcePath] of aliasEntries) {
493
- writeFileSync(join(aliasDir, aliasName), readFileSync(sourcePath));
494
- }
495
- tempDirs.push(aliasDir);
496
- commandDirs.push(aliasDir);
497
- }
498
- }
499
- catch (error) {
500
- for (const tempDir of tempDirs) {
501
- rmSync(tempDir, { recursive: true, force: true });
502
- }
503
- throw error;
504
- }
505
- return {
506
- commandDirs,
507
- dispose() {
508
- for (const tempDir of tempDirs) {
509
- rmSync(tempDir, { recursive: true, force: true });
510
- }
511
- },
512
- };
513
- }
514
453
  function collectConfiguredLowerPaths(config) {
515
454
  const paths = new Set();
516
455
  for (const lower of config?.lowers ?? []) {
@@ -533,16 +472,15 @@ function findBootstrapSeedEntry(config, path) {
533
472
  return entry;
534
473
  }
535
474
  }
536
- 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;
537
480
  }
538
- function createKernelBootstrapLower(config, commandNames, extraEntries = []) {
481
+ function createKernelBootstrapLower(config, extraEntries = []) {
539
482
  const includesBundledBaseLayer = !(config?.disableDefaultBaseLayer ?? false);
540
483
  const existingPaths = collectConfiguredLowerPaths(config);
541
- if (includesBundledBaseLayer) {
542
- for (const entry of getBaseFilesystemEntries()) {
543
- existingPaths.add(entry.path);
544
- }
545
- }
546
484
  const entries = [
547
485
  {
548
486
  path: "/",
@@ -552,18 +490,24 @@ function createKernelBootstrapLower(config, commandNames, extraEntries = []) {
552
490
  gid: 0,
553
491
  },
554
492
  ];
555
- for (const dir of KERNEL_POSIX_BOOTSTRAP_DIRS) {
556
- if (existingPaths.has(dir)) {
557
- 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
+ });
558
510
  }
559
- const seed = findBootstrapSeedEntry(config, dir);
560
- entries.push({
561
- path: dir,
562
- type: "directory",
563
- mode: seed?.type === "directory" ? seed.mode : "755",
564
- uid: seed?.uid ?? 0,
565
- gid: seed?.gid ?? 0,
566
- });
567
511
  }
568
512
  if (!includesBundledBaseLayer && !existingPaths.has("/usr/bin/env")) {
569
513
  entries.push({
@@ -576,22 +520,6 @@ function createKernelBootstrapLower(config, commandNames, extraEntries = []) {
576
520
  encoding: "base64",
577
521
  });
578
522
  }
579
- const uniqueCommands = [...new Set(commandNames)].sort((a, b) => a.localeCompare(b));
580
- for (const command of uniqueCommands) {
581
- const stubPath = `/bin/${command}`;
582
- if (existingPaths.has(stubPath)) {
583
- continue;
584
- }
585
- entries.push({
586
- path: stubPath,
587
- type: "file",
588
- mode: "755",
589
- uid: 0,
590
- gid: 0,
591
- content: KERNEL_COMMAND_STUB,
592
- encoding: "utf8",
593
- });
594
- }
595
523
  for (const entry of sortFilesystemEntries(extraEntries)) {
596
524
  if (existingPaths.has(entry.path)) {
597
525
  continue;
@@ -706,28 +634,6 @@ function latestMtimeMs(path) {
706
634
  }
707
635
  return latest;
708
636
  }
709
- function collectGuestCommandPaths(commandDirs) {
710
- const guestPaths = new Map();
711
- for (const [index, commandDir] of commandDirs.entries()) {
712
- let entries;
713
- try {
714
- entries = readdirSync(commandDir).sort((left, right) => left.localeCompare(right));
715
- }
716
- catch {
717
- continue;
718
- }
719
- for (const entry of entries) {
720
- if (entry.startsWith(".")) {
721
- continue;
722
- }
723
- if (!isWasmBinaryFile(join(commandDir, entry)) || guestPaths.has(entry)) {
724
- continue;
725
- }
726
- guestPaths.set(entry, `/__secure_exec/commands/${index}/${entry}`);
727
- }
728
- }
729
- return guestPaths;
730
- }
731
637
  async function resolveCompatLocalMounts(mounts) {
732
638
  if (!mounts) {
733
639
  return [];
@@ -796,52 +702,24 @@ function collectSidecarMountPlan(options) {
796
702
  }
797
703
  for (const mount of options.mounts ?? []) {
798
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
+ });
799
715
  continue;
800
716
  }
801
717
  pushMount(mount);
802
718
  }
803
- for (const root of options.softwareRoots) {
804
- pushMount({
805
- path: root.vmPath,
806
- plugin: createHostDirBackend({
807
- hostPath: root.hostPath,
808
- readOnly: true,
809
- }),
810
- readOnly: true,
811
- });
812
- }
813
- for (const [index, commandDir] of options.commandDirs.entries()) {
814
- pushMount({
815
- path: `/__secure_exec/commands/${index}`,
816
- plugin: createHostDirBackend({
817
- hostPath: commandDir,
818
- readOnly: true,
819
- }),
820
- readOnly: true,
821
- });
822
- }
823
- if (options.shimDir) {
824
- pushMount({
825
- path: "/usr/local/bin",
826
- plugin: createHostDirBackend({
827
- hostPath: options.shimDir,
828
- readOnly: true,
829
- }),
830
- readOnly: true,
831
- });
832
- }
833
719
  hostMounts.sort((left, right) => right.vmPath.length - left.vmPath.length);
834
720
  hostPathMappings.sort((left, right) => right.vmPath.length - left.vmPath.length);
835
721
  return { sidecarMounts, hostMounts, hostPathMappings };
836
722
  }
837
- function materializeToolShimDir(toolKits) {
838
- const shimDir = mkdtempSync(join(tmpdir(), "agent-os-host-tools-shims-"));
839
- writeFileSync(join(shimDir, "agentos"), KERNEL_COMMAND_STUB, { mode: 0o755 });
840
- for (const toolKit of toolKits) {
841
- writeFileSync(join(shimDir, `agentos-${toolKit.name}`), KERNEL_COMMAND_STUB, { mode: 0o755 });
842
- }
843
- return shimDir;
844
- }
845
723
  function collectToolkitBootstrapCommands(toolKits) {
846
724
  if (toolKits.length === 0) {
847
725
  return [];
@@ -1038,7 +916,7 @@ async function handleHostCallback(request, context) {
1038
916
  return {
1039
917
  type: "host_callback_result",
1040
918
  invocation_id: payload.invocation_id,
1041
- error: `EACCES: blocked by tool.invoke policy for ${payload.callback_key}`,
919
+ error: `EACCES: blocked by binding.invoke policy for ${payload.callback_key}`,
1042
920
  };
1043
921
  }
1044
922
  const parsed = tool.inputSchema.safeParse(payload.input);
@@ -1073,6 +951,134 @@ function buildToolMap(toolKits) {
1073
951
  }
1074
952
  return toolMap;
1075
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
+ }
1076
1082
  function parseHostCommandCallbackInput(input) {
1077
1083
  const value = asRecord(input);
1078
1084
  if (value.type !== "command" ||
@@ -1153,7 +1159,7 @@ async function invokeHostTool({ toolKit, toolName, args, cwd, context, }) {
1153
1159
  const callbackKey = `${toolKit.name}:${toolName}`;
1154
1160
  const permissionMode = toolPermissionMode(context.permissions, callbackKey);
1155
1161
  if (permissionMode !== "allow") {
1156
- throw new Error(`EACCES: blocked by tool.invoke policy for ${callbackKey}`);
1162
+ throw new Error(`EACCES: blocked by binding.invoke policy for ${callbackKey}`);
1157
1163
  }
1158
1164
  const input = await parseHostToolInput(tool, args, cwd, context.readFile);
1159
1165
  return executeHostTool(tool, callbackKey, input);
@@ -1309,7 +1315,7 @@ function describeToolPayload(toolKit, toolName) {
1309
1315
  };
1310
1316
  }
1311
1317
  function toolPermissionMode(permissions, callbackKey) {
1312
- const scope = permissions.tool;
1318
+ const scope = permissions.binding;
1313
1319
  if (!scope) {
1314
1320
  return "deny";
1315
1321
  }
@@ -1383,13 +1389,14 @@ export class AgentOs {
1383
1389
  _pendingSessionRequestResolvers = new Map();
1384
1390
  _processes = new Map();
1385
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.
1386
1394
  _closedShellIds = new BoundedSet(CLOSED_SHELL_ID_RETENTION_LIMIT);
1387
1395
  _pendingShellExitPromises = new Set();
1388
1396
  _shellCounter = 0;
1389
1397
  _acpTerminals = new Map();
1390
1398
  _acpTerminalCounter = 0;
1391
1399
  _softwareRoots;
1392
- _softwareAgentConfigs;
1393
1400
  _cronManager;
1394
1401
  _toolKits = [];
1395
1402
  _toolReference = "";
@@ -1397,22 +1404,29 @@ export class AgentOs {
1397
1404
  _hostMounts;
1398
1405
  _env;
1399
1406
  _rootFilesystem;
1407
+ _additionalInstructions;
1400
1408
  _sidecarLease = null;
1401
1409
  _sidecarClient;
1402
1410
  _sidecarSession;
1403
1411
  _sidecarVm;
1404
1412
  _disposeSidecarEventListener;
1405
- constructor(kernel, sidecar, softwareRoots, softwareAgentConfigs, hostMounts, env, rootFilesystem, sidecarClient, sidecarSession, sidecarVm) {
1413
+ _agentStderrHandler;
1414
+ _agentExitHandler;
1415
+ _limitWarningHandler;
1416
+ constructor(kernel, sidecar, softwareRoots, hostMounts, env, rootFilesystem, sidecarClient, sidecarSession, sidecarVm, additionalInstructions, agentStderrHandler, agentExitHandler, limitWarningHandler) {
1406
1417
  this.#kernel = kernel;
1407
1418
  this.sidecar = sidecar;
1408
1419
  this._softwareRoots = softwareRoots;
1409
- this._softwareAgentConfigs = softwareAgentConfigs;
1410
1420
  this._hostMounts = hostMounts;
1411
1421
  this._env = env;
1412
1422
  this._rootFilesystem = rootFilesystem;
1413
1423
  this._sidecarClient = sidecarClient;
1414
1424
  this._sidecarSession = sidecarSession;
1415
1425
  this._sidecarVm = sidecarVm;
1426
+ this._additionalInstructions = additionalInstructions;
1427
+ this._agentStderrHandler = agentStderrHandler;
1428
+ this._agentExitHandler = agentExitHandler;
1429
+ this._limitWarningHandler = limitWarningHandler;
1416
1430
  this._disposeSidecarEventListener = this._sidecarClient.onEvent((event) => {
1417
1431
  this._handleSidecarEvent(event);
1418
1432
  });
@@ -1430,59 +1444,90 @@ export class AgentOs {
1430
1444
  return getSharedAgentOsSidecarInternal(options);
1431
1445
  }
1432
1446
  static async create(options) {
1433
- const processed = processSoftware(options?.software ?? []);
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();
1456
+ const software = options?.defaultSoftware === false
1457
+ ? (options.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.
1434
1481
  const localMounts = await resolveCompatLocalMounts(options?.mounts);
1435
1482
  const toolKits = options?.toolKits;
1436
1483
  if (toolKits && toolKits.length > 0) {
1437
1484
  validateToolkits(toolKits);
1438
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);
1439
1489
  const createVmAdmin = async () => {
1440
- 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.
1441
1494
  const toolBootstrapCommands = collectToolkitBootstrapCommands(toolKits ?? []);
1442
- const bootstrapLower = createKernelBootstrapLower(options?.rootFilesystem, [
1443
- ...collectBootstrapWasmCommands(preparedCommandDirs.commandDirs),
1444
- ...NODE_RUNTIME_BOOTSTRAP_COMMANDS,
1495
+ const bootstrapCommands = [
1496
+ ...RUNTIME_BOOTSTRAP_COMMANDS,
1445
1497
  ...toolBootstrapCommands,
1446
- ]);
1498
+ ];
1499
+ const bootstrapLower = createKernelBootstrapLower(options?.rootFilesystem);
1447
1500
  let toolReference = "";
1448
1501
  let rootBridge = null;
1449
1502
  let kernel = null;
1450
1503
  let client = null;
1451
- let toolShimDir = null;
1504
+ let createdNativeVm = null;
1505
+ let nativeSession = null;
1452
1506
  let cleanedUp = false;
1453
1507
  const cleanup = async () => {
1454
1508
  if (cleanedUp) {
1455
1509
  return;
1456
1510
  }
1457
1511
  cleanedUp = true;
1458
- if (toolShimDir) {
1459
- rmSync(toolShimDir, { recursive: true, force: true });
1460
- toolShimDir = null;
1461
- }
1462
- preparedCommandDirs.dispose();
1463
1512
  };
1464
1513
  try {
1465
1514
  const env = getBaseEnvironment();
1466
- if (toolKits && toolKits.length > 0) {
1467
- toolShimDir = materializeToolShimDir(toolKits);
1468
- }
1469
- const commandGuestPaths = collectGuestCommandPaths(preparedCommandDirs.commandDirs);
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();
1470
1519
  const { sidecarMounts, hostMounts, hostPathMappings } = collectSidecarMountPlan({
1471
1520
  mounts: options?.mounts,
1472
- softwareRoots: processed.softwareRoots,
1473
- commandDirs: preparedCommandDirs.commandDirs,
1474
- shimDir: toolShimDir,
1475
- });
1476
- client = SidecarProcess.spawn({
1477
- cwd: REPO_ROOT,
1478
- command: ensureNativeSidecarBinary(),
1479
- args: [],
1480
- frameTimeoutMs: NATIVE_SIDECAR_FRAME_TIMEOUT_MS,
1481
1521
  });
1482
- 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;
1483
1528
  const hostPermissions = options?.permissions ?? {
1484
1529
  ...allowAll,
1485
- tool: "allow",
1530
+ binding: "allow",
1486
1531
  };
1487
1532
  const sidecarPermissions = serializePermissionsForSidecar(hostPermissions);
1488
1533
  const createVmConfig = {
@@ -1491,18 +1536,25 @@ export class AgentOs {
1491
1536
  permissions: sidecarPermissions,
1492
1537
  limits: options?.limits,
1493
1538
  loopbackExemptPorts: options?.loopbackExemptPorts ?? [],
1539
+ bootstrapCommands,
1494
1540
  // 0.3: the Node builtin allow-list moved from configureVm to
1495
1541
  // VM creation. `undefined` => engine default allow-list;
1496
1542
  // `[]` => deny all; `[..]` => exactly those. Platform and
1497
1543
  // module resolution keep their engine defaults (full Node
1498
1544
  // emulation), matching the prior behavior where Agent OS only
1499
1545
  // constrained the builtin allow-list.
1500
- ...(options?.allowedNodeBuiltins !== undefined
1546
+ ...(options?.allowedNodeBuiltins !== undefined ||
1547
+ options?.highResolutionTime !== undefined
1501
1548
  ? {
1502
1549
  jsRuntime: {
1503
1550
  platform: "node",
1504
1551
  moduleResolution: "node",
1505
- allowedBuiltins: options.allowedNodeBuiltins,
1552
+ ...(options?.allowedNodeBuiltins !== undefined
1553
+ ? { allowedBuiltins: options.allowedNodeBuiltins }
1554
+ : {}),
1555
+ ...(options?.highResolutionTime !== undefined
1556
+ ? { highResolutionTime: options.highResolutionTime }
1557
+ : {}),
1506
1558
  },
1507
1559
  }
1508
1560
  : {}),
@@ -1511,14 +1563,25 @@ export class AgentOs {
1511
1563
  runtime: "java_script",
1512
1564
  config: createVmConfig,
1513
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.
1514
1569
  await client.waitForEvent((event) => event.payload.type === "vm_lifecycle" &&
1515
- event.payload.state === "ready", 10_000);
1516
- 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, {
1517
1574
  mounts: sidecarMounts,
1518
1575
  permissions: sidecarPermissions,
1519
- commandPermissions: processed.commandPermissions,
1576
+ commandPermissions: {},
1520
1577
  loopbackExemptPorts: options?.loopbackExemptPorts,
1578
+ packages: sidecarPackages,
1579
+ packagesMountAt: OPT_AGENTOS_ROOT,
1580
+ toolShimCommands: toolBootstrapCommands,
1521
1581
  });
1582
+ for (const command of configuredVm.projectedCommands) {
1583
+ commandGuestPaths.set(command.name, command.guestPath);
1584
+ }
1522
1585
  if (toolKits && toolKits.length > 0) {
1523
1586
  toolReference = await registerToolkitsOnSidecar(client, session, nativeVm, toolKits);
1524
1587
  commandGuestPaths.set("agentos", "/bin/agentos");
@@ -1531,10 +1594,23 @@ export class AgentOs {
1531
1594
  session,
1532
1595
  vm: nativeVm,
1533
1596
  env,
1534
- cwd: "/home/user",
1597
+ cwd: "/workspace",
1535
1598
  localMounts,
1599
+ sidecarMounts,
1600
+ permissions: sidecarPermissions,
1601
+ commandPermissions: {},
1602
+ loopbackExemptPorts: options?.loopbackExemptPorts,
1603
+ // Retained for runtime mount reconfigures: `configure_vm` is
1604
+ // replace-on-write for the whole payload, so post-boot mountFs
1605
+ // must resend the boot packages and tool shims.
1606
+ packages: sidecarPackages,
1607
+ packagesMountAt: OPT_AGENTOS_ROOT,
1608
+ toolShimCommands: toolBootstrapCommands,
1536
1609
  commandGuestPaths,
1537
1610
  onDispose: cleanup,
1611
+ // The native process is owned by the AgentOsSidecar handle and
1612
+ // shared across VMs; disposing this VM must not kill the process.
1613
+ ownsClient: false,
1538
1614
  });
1539
1615
  await bootstrapLiveBootstrapDirectories(client, session, nativeVm, options?.rootFilesystem);
1540
1616
  kernel = rootBridge;
@@ -1544,6 +1620,10 @@ export class AgentOs {
1544
1620
  hostMounts,
1545
1621
  kernel,
1546
1622
  rootView: rootBridge.createRootView(),
1623
+ sidecarMounts,
1624
+ sidecarPermissions,
1625
+ commandPermissions: {},
1626
+ loopbackExemptPorts: options?.loopbackExemptPorts,
1547
1627
  sidecarClient: client,
1548
1628
  sidecarSession: session,
1549
1629
  sidecarVm: nativeVm,
@@ -1568,6 +1648,8 @@ export class AgentOs {
1568
1648
  };
1569
1649
  }
1570
1650
  catch (error) {
1651
+ // The native process is shared and owned by the sidecar handle, so
1652
+ // never dispose the client here — only tear down this VM's resources.
1571
1653
  if (kernel) {
1572
1654
  await kernel.dispose().catch(() => { });
1573
1655
  }
@@ -1575,20 +1657,23 @@ export class AgentOs {
1575
1657
  await rootBridge.dispose().catch(() => { });
1576
1658
  }
1577
1659
  else {
1578
- await client?.dispose().catch(() => { });
1660
+ if (createdNativeVm && nativeSession && client) {
1661
+ await client
1662
+ .disposeVm(nativeSession, createdNativeVm)
1663
+ .catch(() => { });
1664
+ }
1579
1665
  await cleanup();
1580
1666
  }
1581
1667
  throw error;
1582
1668
  }
1583
1669
  };
1584
- const sidecar = resolveAgentOsSidecar(options?.sidecar);
1585
1670
  let sidecarLease = null;
1586
1671
  try {
1587
1672
  sidecarLease = await leaseAgentOsSidecarVm(sidecar, {
1588
1673
  createVm: async () => createVmAdmin(),
1589
1674
  });
1590
1675
  const vmAdmin = sidecarLease.admin;
1591
- const vm = new AgentOs(vmAdmin.kernel, sidecar, processed.softwareRoots, processed.agentConfigs, vmAdmin.hostMounts, vmAdmin.env, vmAdmin.rootView, vmAdmin.sidecarClient, vmAdmin.sidecarSession, vmAdmin.sidecarVm);
1676
+ 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);
1592
1677
  vm._sidecarLease = sidecarLease;
1593
1678
  vm._toolKits = vmAdmin.toolKits;
1594
1679
  vm._toolReference = vmAdmin.toolReference;
@@ -1605,6 +1690,10 @@ export class AgentOs {
1605
1690
  async exec(command, options) {
1606
1691
  return this.#kernel.exec(command, options);
1607
1692
  }
1693
+ async execArgv(command, args = [], options) {
1694
+ const kernel = this.#kernel;
1695
+ return kernel.execArgv(command, args, options);
1696
+ }
1608
1697
  _trackProcess(proc, command, args, stdoutHandlers, stderrHandlers, exitHandlers) {
1609
1698
  const entry = {
1610
1699
  proc,
@@ -1615,6 +1704,11 @@ export class AgentOs {
1615
1704
  exitHandlers,
1616
1705
  };
1617
1706
  this._processes.set(proc.pid, entry);
1707
+ // NOTE: do NOT delete from `_processes` on exit — the public API contract
1708
+ // (getProcess/listProcesses/stopProcess, see process-management.test.ts)
1709
+ // requires exited processes to stay queryable (running:false, exitCode set).
1710
+ // `_processes` is a process table for this VM's lifetime; it is freed wholesale
1711
+ // in dispose(). (H5: the leak was that dispose() never cleared it.)
1618
1712
  void proc.wait().then((code) => {
1619
1713
  for (const h of exitHandlers)
1620
1714
  h(code);
@@ -1648,14 +1742,14 @@ export class AgentOs {
1648
1742
  const entry = this._processes.get(pid);
1649
1743
  if (!entry)
1650
1744
  throw new Error(`Process not found: ${pid}`);
1651
- entry.proc.writeStdin(data);
1745
+ return entry.proc.writeStdin(data);
1652
1746
  }
1653
1747
  /** Close a process's stdin stream. */
1654
1748
  closeProcessStdin(pid) {
1655
1749
  const entry = this._processes.get(pid);
1656
1750
  if (!entry)
1657
1751
  throw new Error(`Process not found: ${pid}`);
1658
- entry.proc.closeStdin();
1752
+ return entry.proc.closeStdin();
1659
1753
  }
1660
1754
  /** Subscribe to stdout data from a process. Returns an unsubscribe function. */
1661
1755
  onProcessStdout(pid, handler) {
@@ -1716,35 +1810,6 @@ export class AgentOs {
1716
1810
  _vfs() {
1717
1811
  return this.#kernel.vfs;
1718
1812
  }
1719
- async _copyPath(from, to) {
1720
- const stat = await this._vfs().lstat(from);
1721
- if (stat.isSymbolicLink) {
1722
- const target = await this._vfs().readlink(from);
1723
- await this._vfs().symlink(target, to);
1724
- return;
1725
- }
1726
- if (stat.isDirectory) {
1727
- await this._mkdirp(posixPath.dirname(to));
1728
- if (!(await this.#kernel.exists(to))) {
1729
- await this.#kernel.mkdir(to);
1730
- }
1731
- await this._vfs().chmod(to, stat.mode);
1732
- await this._vfs().chown(to, stat.uid, stat.gid);
1733
- const entries = await this.#kernel.readdir(from);
1734
- for (const entry of entries) {
1735
- if (entry === "." || entry === "..")
1736
- continue;
1737
- const fromPath = from === "/" ? `/${entry}` : `${from}/${entry}`;
1738
- const toPath = to === "/" ? `/${entry}` : `${to}/${entry}`;
1739
- await this._copyPath(fromPath, toPath);
1740
- }
1741
- return;
1742
- }
1743
- const content = await this.#kernel.readFile(from);
1744
- await this.writeFile(to, content);
1745
- await this._vfs().chmod(to, stat.mode);
1746
- await this._vfs().chown(to, stat.uid, stat.gid);
1747
- }
1748
1813
  async readFile(path) {
1749
1814
  this._assertSafeAbsolutePath(path);
1750
1815
  return this.#kernel.readFile(path);
@@ -1797,14 +1862,13 @@ export class AgentOs {
1797
1862
  /** Recursively create directories (mkdir -p). */
1798
1863
  async _mkdirp(path) {
1799
1864
  this._assertWritableAbsolutePath(path);
1800
- const parts = path.split("/").filter(Boolean);
1801
- let current = "";
1802
- for (const part of parts) {
1803
- current += `/${part}`;
1804
- if (!(await this.#kernel.exists(current))) {
1805
- await this.#kernel.mkdir(current);
1806
- }
1807
- }
1865
+ // `kernel.mkdir` is already recursive (it defaults to recursive=true on both
1866
+ // the native sidecar and compat kernels) and creating an existing directory is
1867
+ // a no-op, so a single call is sufficient. Do NOT probe each ancestor with
1868
+ // `exists()` first: on the native sidecar every read-side op
1869
+ // (exists/stat/readFile) triggers a full shadow-tree walk, so a per-component
1870
+ // exists() loop makes `mkdir -p` cost O(components * tree).
1871
+ await this.#kernel.mkdir(path);
1808
1872
  }
1809
1873
  async mkdir(path, options) {
1810
1874
  if (options?.recursive) {
@@ -1819,49 +1883,31 @@ export class AgentOs {
1819
1883
  }
1820
1884
  async readdirRecursive(path, options) {
1821
1885
  this._assertSafeAbsolutePath(path);
1822
- const maxDepth = options?.maxDepth;
1823
1886
  const exclude = options?.exclude ? new Set(options.exclude) : undefined;
1887
+ const entries = await this.#kernel.readdirRecursive(path, {
1888
+ maxDepth: options?.maxDepth,
1889
+ });
1890
+ const excludedPrefixes = [];
1824
1891
  const results = [];
1825
- // BFS queue: [dirPath, currentDepth]
1826
- const queue = [[path, 0]];
1827
- while (queue.length > 0) {
1828
- const item = queue.shift();
1829
- if (!item)
1830
- break;
1831
- const [dirPath, depth] = item;
1832
- const entries = await this.#kernel.readdir(dirPath);
1833
- for (const name of entries) {
1834
- if (name === "." || name === "..")
1835
- continue;
1836
- if (exclude?.has(name))
1837
- continue;
1838
- const fullPath = dirPath === "/" ? `/${name}` : `${dirPath}/${name}`;
1839
- const s = await this.#kernel.stat(fullPath);
1840
- if (s.isSymbolicLink) {
1841
- results.push({
1842
- path: fullPath,
1843
- type: "symlink",
1844
- size: s.size,
1845
- });
1846
- }
1847
- else if (s.isDirectory) {
1848
- results.push({
1849
- path: fullPath,
1850
- type: "directory",
1851
- size: s.size,
1852
- });
1853
- if (maxDepth === undefined || depth < maxDepth) {
1854
- queue.push([fullPath, depth + 1]);
1855
- }
1856
- }
1857
- else {
1858
- results.push({
1859
- path: fullPath,
1860
- type: "file",
1861
- size: s.size,
1862
- });
1892
+ for (const entry of entries) {
1893
+ if (excludedPrefixes.some((prefix) => entry.path === prefix || entry.path.startsWith(`${prefix}/`))) {
1894
+ continue;
1895
+ }
1896
+ if (exclude?.has(entry.name)) {
1897
+ if (entry.isDirectory && !entry.isSymbolicLink) {
1898
+ excludedPrefixes.push(entry.path);
1863
1899
  }
1900
+ continue;
1864
1901
  }
1902
+ results.push({
1903
+ path: entry.path,
1904
+ type: entry.isSymbolicLink
1905
+ ? "symlink"
1906
+ : entry.isDirectory
1907
+ ? "directory"
1908
+ : "file",
1909
+ size: entry.size,
1910
+ });
1865
1911
  }
1866
1912
  return results;
1867
1913
  }
@@ -1880,39 +1926,30 @@ export class AgentOs {
1880
1926
  }
1881
1927
  return createSnapshotExport(await snapshotVirtualFilesystem(this._rootFilesystem));
1882
1928
  }
1883
- mountFs(path, driver, options) {
1929
+ /**
1930
+ * Mount a filesystem into the running VM. Resolves once the mount has been
1931
+ * delivered to the native sidecar, so guest code can use it immediately
1932
+ * after the returned promise settles; a delivery failure rejects instead of
1933
+ * leaving the mount silently host-only.
1934
+ */
1935
+ async mountFs(path, driver, options) {
1884
1936
  this._assertSafeAbsolutePath(path);
1885
- this.#kernel.mountFs(path, driver, { readOnly: options?.readOnly });
1937
+ await this.#kernel.mountFs(path, driver, { readOnly: options?.readOnly });
1886
1938
  }
1887
- unmountFs(path) {
1939
+ async unmountFs(path) {
1888
1940
  this._assertSafeAbsolutePath(path);
1889
- this.#kernel.unmountFs(path);
1941
+ await this.#kernel.unmountFs(path);
1890
1942
  }
1891
1943
  async move(from, to) {
1892
- this._assertSafeAbsolutePath(from);
1893
- this._assertSafeAbsolutePath(to);
1894
- const sourceStat = await this._vfs().lstat(from);
1895
- if (!sourceStat.isDirectory || sourceStat.isSymbolicLink) {
1896
- return this.#kernel.rename(from, to);
1897
- }
1898
- await this._copyPath(from, to);
1899
- await this.delete(from, { recursive: true });
1944
+ this._assertWritableAbsolutePath(from);
1945
+ this._assertWritableAbsolutePath(to);
1946
+ await this.#kernel.movePath(from, to);
1900
1947
  }
1901
1948
  async delete(path, options) {
1902
- this._assertSafeAbsolutePath(path);
1903
- const s = await this.#kernel.stat(path);
1904
- if (s.isDirectory) {
1905
- if (options?.recursive) {
1906
- const entries = await this.#kernel.readdir(path);
1907
- for (const entry of entries) {
1908
- if (entry === "." || entry === "..")
1909
- continue;
1910
- await this.delete(`${path}/${entry}`, { recursive: true });
1911
- }
1912
- }
1913
- return this.#kernel.removeDir(path);
1914
- }
1915
- return this.#kernel.removeFile(path);
1949
+ this._assertWritableAbsolutePath(path);
1950
+ await this.#kernel.removePath(path, {
1951
+ recursive: options?.recursive ?? false,
1952
+ });
1916
1953
  }
1917
1954
  async fetch(port, request) {
1918
1955
  const url = new URL(request.url);
@@ -1947,15 +1984,24 @@ export class AgentOs {
1947
1984
  const entry = {
1948
1985
  handle,
1949
1986
  dataHandlers,
1950
- exitPromise: Promise.resolve(),
1987
+ exitPromise: Promise.resolve(0),
1951
1988
  };
1952
- const exitPromise = handle.wait().then(() => undefined, () => undefined);
1953
- entry.exitPromise = exitPromise.finally(() => {
1989
+ const exitPromise = handle.wait();
1990
+ const finalize = (exitCode) => {
1954
1991
  this._pendingShellExitPromises.delete(entry.exitPromise);
1955
1992
  if (this._shells.get(shellId) === entry) {
1956
1993
  this._shells.delete(shellId);
1957
- this._closedShellIds.add(shellId);
1958
1994
  }
1995
+ // Record the exit code even when closeShell already dropped the
1996
+ // entry, so a waitShell issued after exit still resolves with it.
1997
+ this._closedShellIds.add(shellId, exitCode);
1998
+ };
1999
+ entry.exitPromise = exitPromise.then((exitCode) => {
2000
+ finalize(exitCode);
2001
+ return exitCode;
2002
+ }, (error) => {
2003
+ finalize();
2004
+ throw error;
1959
2005
  });
1960
2006
  this._pendingShellExitPromises.add(entry.exitPromise);
1961
2007
  this._shells.set(shellId, entry);
@@ -1969,7 +2015,7 @@ export class AgentOs {
1969
2015
  const entry = this._shells.get(shellId);
1970
2016
  if (!entry)
1971
2017
  throw new Error(`Shell not found: ${shellId}`);
1972
- entry.handle.write(data);
2018
+ return entry.handle.write(data);
1973
2019
  }
1974
2020
  /** Subscribe to data output from a shell. Returns an unsubscribe function. */
1975
2021
  onShellData(shellId, handler) {
@@ -1988,6 +2034,21 @@ export class AgentOs {
1988
2034
  throw new Error(`Shell not found: ${shellId}`);
1989
2035
  entry.handle.resize(cols, rows);
1990
2036
  }
2037
+ /**
2038
+ * Wait for a shell to exit and return its process exit code. Resolves
2039
+ * immediately for a shell that has already exited (within the closed-shell
2040
+ * retention window).
2041
+ */
2042
+ waitShell(shellId) {
2043
+ const entry = this._shells.get(shellId);
2044
+ if (!entry) {
2045
+ const exitCode = this._closedShellIds.get(shellId);
2046
+ if (exitCode !== undefined)
2047
+ return Promise.resolve(exitCode);
2048
+ throw new Error(`Shell not found: ${shellId}`);
2049
+ }
2050
+ return entry.exitPromise;
2051
+ }
1991
2052
  /** Kill a shell process and remove it from tracking. */
1992
2053
  closeShell(shellId) {
1993
2054
  const entry = this._shells.get(shellId);
@@ -2102,53 +2163,60 @@ export class AgentOs {
2102
2163
  }
2103
2164
  return session;
2104
2165
  }
2105
- /** Returns all registered agents with their installation status. */
2106
- listAgents() {
2107
- // Collect all agent IDs from both package configs and hardcoded configs.
2108
- const allIds = new Set([
2109
- ...this._softwareAgentConfigs.keys(),
2110
- ...Object.keys(AGENT_CONFIGS),
2111
- ]);
2112
- return [...allIds]
2113
- .map((id) => {
2114
- const config = this._resolveAgentConfig(id);
2115
- if (!config)
2116
- return null;
2117
- let installed = false;
2118
- try {
2119
- // Check software roots first, then the host dir behind the
2120
- // `/root/node_modules` mount.
2121
- const vmPrefix = `/root/node_modules/${config.acpAdapter}`;
2122
- let hostPkgJsonPath = null;
2123
- for (const root of this._softwareRoots) {
2124
- if (root.vmPath === vmPrefix) {
2125
- hostPkgJsonPath = join(root.hostPath, "package.json");
2126
- break;
2127
- }
2128
- }
2129
- if (!hostPkgJsonPath) {
2130
- const nodeModulesRoot = this._nodeModulesHostRoot();
2131
- if (nodeModulesRoot) {
2132
- hostPkgJsonPath = join(resolvePackageDir(nodeModulesRoot, config.acpAdapter), "package.json");
2133
- }
2134
- }
2135
- if (!hostPkgJsonPath) {
2136
- throw new Error("no package source");
2137
- }
2138
- readFileSync(hostPkgJsonPath);
2139
- installed = true;
2140
- }
2141
- catch {
2142
- // Package not installed
2143
- }
2144
- return {
2145
- id,
2146
- acpAdapter: config.acpAdapter,
2147
- agentPackage: config.agentPackage,
2148
- installed,
2149
- };
2150
- })
2151
- .filter((entry) => entry !== null);
2166
+ /**
2167
+ * Dynamically link a software package into the RUNNING VM. The package's
2168
+ * `bin/` commands appear under `/opt/agentos/bin` (on `$PATH`) and its `share/man`
2169
+ * pages under MANPATH immediately — the `/opt/agentos` mount is host-backed, so
2170
+ * writing into its staging dir is reflected live with no reboot. An `agent`
2171
+ * block registers the package for `createSession(name)`. Persists for the VM's
2172
+ * lifetime (and across a snapshot iff the volume persists).
2173
+ */
2174
+ async linkSoftware(descriptor) {
2175
+ const ref = normalizePackageRef(descriptor);
2176
+ if (!ref) {
2177
+ throw new Error("Invalid agentOS package reference");
2178
+ }
2179
+ // Forward to the sidecar, which owns the `/opt/agentos` projection and
2180
+ // appends the package to its live host-backed staging dir; the commands
2181
+ // appear under `/opt/agentos/bin` immediately. The sidecar rejects a
2182
+ // duplicate command, surfaced here as a thrown error.
2183
+ const commands = await this._sidecarClient.linkPackage(this._sidecarSession, this._sidecarVm, { path: ref.path });
2184
+ if (this.#kernel instanceof NativeSidecarKernelProxy) {
2185
+ this.#kernel.registerCommandGuestPaths(new Map(commands.projectedCommands.map((command) => [
2186
+ command.name,
2187
+ command.guestPath,
2188
+ ])));
2189
+ // Retain the linked package for runtime mount reconfigures:
2190
+ // `configure_vm` is replace-on-write, so a later `mountFs` that
2191
+ // resent only the boot packages would unproject this one.
2192
+ this.#kernel.registerLinkedPackage({ path: ref.path });
2193
+ }
2194
+ // The client parses no manifests: an `agent` block in the linked package is
2195
+ // picked up by the sidecar (it owns the projected `/opt/agentos` and answers
2196
+ // createSession/listAgents from it). Nothing to record client-side.
2197
+ }
2198
+ async providedCommands() {
2199
+ return this._sidecarClient.providedCommands(this._sidecarSession, this._sidecarVm);
2200
+ }
2201
+ /**
2202
+ * Returns all registered agents with their installation status. Thin forwarder:
2203
+ * sends `AcpListAgentsRequest` and maps the response. The sidecar enumerates the
2204
+ * projected `/opt/agentos` packages (the client parses no manifests). Every such
2205
+ * agent is a package materialized into the VM, so `installed` is always `true`.
2206
+ */
2207
+ async listAgents() {
2208
+ const response = await this._sendAcpRequest({
2209
+ tag: "AcpListAgentsRequest",
2210
+ val: { reserved: false },
2211
+ });
2212
+ if (response.tag !== "AcpListAgentsResponse") {
2213
+ throw new Error(`unexpected list_agents response: ${response.tag}`);
2214
+ }
2215
+ return response.val.agents.map((agent) => ({
2216
+ id: agent.id,
2217
+ installed: agent.installed,
2218
+ adapterEntrypoint: agent.adapterEntrypoint,
2219
+ }));
2152
2220
  }
2153
2221
  _syncSessionState(session, state) {
2154
2222
  session.processId = state.processId;
@@ -2227,6 +2295,106 @@ export class AgentOs {
2227
2295
  session.eventHandlers.delete(subscriber);
2228
2296
  };
2229
2297
  }
2298
+ /**
2299
+ * Warn once per session (host-visible) that a tool-permission request was
2300
+ * auto-denied because no `onPermissionRequest` handler is registered. Shared
2301
+ * by both the bare-callback and JSON-RPC permission paths so the message and
2302
+ * the once-per-session guard cannot drift between them.
2303
+ */
2304
+ _warnNoPermissionHandlerOnce(session, params) {
2305
+ if (session.warnedNoPermissionHandler) {
2306
+ return;
2307
+ }
2308
+ session.warnedNoPermissionHandler = true;
2309
+ this._emitSessionWarning(session, `agentos: a tool-permission request (${this._permissionToolLabel(params)}) was ` +
2310
+ `auto-denied because no onPermissionRequest handler is registered for session ` +
2311
+ `${session.sessionId}. Register one with vm.onPermissionRequest(sessionId, ...) and ` +
2312
+ `reply via vm.respondPermission(...) to let the agent use tools.`);
2313
+ }
2314
+ /** Best-effort human label for the tool named in a permission request. */
2315
+ _permissionToolLabel(params) {
2316
+ if (typeof params.toolName === "string") {
2317
+ return params.toolName;
2318
+ }
2319
+ const toolCall = params.toolCall;
2320
+ if (toolCall &&
2321
+ typeof toolCall === "object" &&
2322
+ typeof toolCall.title === "string") {
2323
+ return toolCall.title;
2324
+ }
2325
+ return "a tool";
2326
+ }
2327
+ /**
2328
+ * Emit a host-visible warning for a session through the same agent-process log
2329
+ * channel that surfaces adapter stderr (`onAgentStderr`, default: process
2330
+ * stderr). Used for agent-os-owned diagnostics — e.g. a permission request
2331
+ * that was auto-denied because no host hook is registered — so they never fire
2332
+ * silently inside the sidecar.
2333
+ */
2334
+ _emitSessionWarning(session, message) {
2335
+ const handler = this._agentStderrHandler;
2336
+ if (!handler) {
2337
+ return;
2338
+ }
2339
+ try {
2340
+ handler({
2341
+ sessionId: session.sessionId,
2342
+ agentType: session.agentType,
2343
+ processId: session.processId,
2344
+ pid: session.pid,
2345
+ chunk: new TextEncoder().encode(`${message}\n`),
2346
+ });
2347
+ }
2348
+ catch {
2349
+ // A warning sink failure must never affect permission handling.
2350
+ }
2351
+ }
2352
+ _recordAgentStderr(event) {
2353
+ const session = (event.sessionId ? this._sessions.get(event.sessionId) : undefined) ??
2354
+ [...this._sessions.values()].find((candidate) => candidate.processId === event.processId);
2355
+ const sessionId = event.sessionId || session?.sessionId;
2356
+ if (!sessionId) {
2357
+ return;
2358
+ }
2359
+ const handler = this._agentStderrHandler;
2360
+ if (!handler) {
2361
+ return;
2362
+ }
2363
+ try {
2364
+ handler({
2365
+ sessionId,
2366
+ agentType: event.agentType || session?.agentType || "",
2367
+ processId: event.processId,
2368
+ pid: session?.pid ?? null,
2369
+ chunk: new Uint8Array(event.chunk),
2370
+ });
2371
+ }
2372
+ catch {
2373
+ // Ignore subscriber callback failures and keep event delivery moving.
2374
+ }
2375
+ }
2376
+ _recordAgentExit(event) {
2377
+ const session = this._sessions.get(event.sessionId);
2378
+ const handler = this._agentExitHandler;
2379
+ if (!handler) {
2380
+ return;
2381
+ }
2382
+ try {
2383
+ handler({
2384
+ sessionId: event.sessionId,
2385
+ agentType: event.agentType || session?.agentType || "",
2386
+ processId: event.processId,
2387
+ pid: session?.pid ?? null,
2388
+ exitCode: event.exitCode,
2389
+ restart: event.restart,
2390
+ restartCount: event.restartCount,
2391
+ maxRestarts: event.maxRestarts,
2392
+ });
2393
+ }
2394
+ catch {
2395
+ // Ignore subscriber callback failures and keep event delivery moving.
2396
+ }
2397
+ }
2230
2398
  _applySyntheticConfigOverrides(session) {
2231
2399
  if (session.configOverrides.size === 0) {
2232
2400
  return;
@@ -2249,6 +2417,10 @@ export class AgentOs {
2249
2417
  if (event.payload.type !== "structured") {
2250
2418
  return;
2251
2419
  }
2420
+ if (event.payload.name === "limit_warning") {
2421
+ this._handleLimitWarning(event.payload.detail);
2422
+ return;
2423
+ }
2252
2424
  if (event.payload.name !== "acp.session_event") {
2253
2425
  return;
2254
2426
  }
@@ -2268,6 +2440,27 @@ export class AgentOs {
2268
2440
  // Ignore malformed event payloads from the sidecar.
2269
2441
  }
2270
2442
  }
2443
+ _handleLimitWarning(detail) {
2444
+ if (!this._limitWarningHandler) {
2445
+ return;
2446
+ }
2447
+ const toNumber = (value) => {
2448
+ const parsed = Number(value);
2449
+ return Number.isFinite(parsed) ? parsed : 0;
2450
+ };
2451
+ try {
2452
+ this._limitWarningHandler({
2453
+ limit: detail.limit ?? "",
2454
+ category: detail.category ?? "",
2455
+ observed: toNumber(detail.observed),
2456
+ capacity: toNumber(detail.capacity),
2457
+ fillPercent: toNumber(detail.fillPercent),
2458
+ });
2459
+ }
2460
+ catch {
2461
+ // A throwing handler must never break the sidecar event loop.
2462
+ }
2463
+ }
2271
2464
  _handleAcpExtEvent(envelope) {
2272
2465
  if (envelope.namespace !== ACP_EXTENSION_NAMESPACE) {
2273
2466
  return;
@@ -2283,6 +2476,14 @@ export class AgentOs {
2283
2476
  this._recordSessionNotification(session, toJsonRpcNotification(JSON.parse(event.val.notification)));
2284
2477
  return;
2285
2478
  }
2479
+ case "AcpAgentStderrEvent": {
2480
+ this._recordAgentStderr(event.val);
2481
+ return;
2482
+ }
2483
+ case "AcpAgentExitedEvent": {
2484
+ this._recordAgentExit(event.val);
2485
+ return;
2486
+ }
2286
2487
  }
2287
2488
  }
2288
2489
  catch {
@@ -2498,37 +2699,30 @@ export class AgentOs {
2498
2699
  this._syncSessionState(session, state);
2499
2700
  }
2500
2701
  async createSession(agentType, options) {
2501
- const config = this._resolveAgentConfig(agentType);
2502
- if (!config) {
2503
- throw new Error(`Unknown agent type: ${agentType}`);
2504
- }
2505
- // System-prompt assembly and injection (launch args / OPENCODE_CONTEXTPATHS) are owned by
2506
- // the sidecar at AcpCreateSessionRequest. The host only forwards additionalInstructions /
2507
- // skipOsInstructions plus the agent's static launch args and env.
2508
- const launchArgs = [...(config.launchArgs ?? [])];
2509
- let launchEnv = { ...config.defaultEnv, ...options?.env };
2510
- const sessionCwd = options?.cwd ?? "/home/user";
2511
- const adapterEntrypoint = this._resolveAdapterBin(config.acpAdapter);
2512
- if ((agentType === "pi" || agentType === "pi-cli") &&
2513
- !launchEnv.PI_ACP_PI_COMMAND) {
2514
- launchEnv = {
2515
- ...launchEnv,
2516
- PI_ACP_PI_COMMAND: this._resolvePackageBin(config.agentPackage, "pi"),
2517
- };
2518
- }
2702
+ // The client is npm-agnostic: it sends only the agent NAME. The sidecar
2703
+ // resolves the name -> package -> entrypoint/env/launchArgs from the
2704
+ // projected `/opt/agentos/<name>/current/agentos-package.json` and spawns
2705
+ // (including the agent's static launch args and manifest env defaults).
2706
+ // System-prompt assembly/injection (launch args / OPENCODE_CONTEXTPATHS) is
2707
+ // owned by the sidecar; the host only forwards additionalInstructions /
2708
+ // skipOsInstructions plus the caller's env.
2709
+ const launchEnv = { ...options?.env };
2710
+ const sessionCwd = options?.cwd ?? "/workspace";
2519
2711
  const response = await this._sendAcpRequest({
2520
2712
  tag: "AcpCreateSessionRequest",
2521
2713
  val: {
2522
2714
  agentType: String(agentType),
2523
2715
  runtime: AcpRuntimeKind.JavaScript,
2524
- adapterEntrypoint,
2525
- args: launchArgs,
2716
+ args: [],
2526
2717
  env: new Map(Object.entries(launchEnv)),
2527
2718
  cwd: sessionCwd,
2528
2719
  mcpServers: JSON.stringify(options?.mcpServers ?? []),
2529
2720
  protocolVersion: ACP_PROTOCOL_VERSION,
2530
2721
  clientCapabilities: JSON.stringify(defaultAcpClientCapabilities()),
2531
- additionalInstructions: combineInstructions(options?.additionalInstructions, this._toolReference),
2722
+ additionalInstructions: combineInstructions([this._additionalInstructions, options?.additionalInstructions]
2723
+ .map((part) => part?.trim())
2724
+ .filter((part) => Boolean(part))
2725
+ .join("\n\n") || undefined, this._toolReference),
2532
2726
  skipOsInstructions: options?.skipOsInstructions ?? false,
2533
2727
  },
2534
2728
  });
@@ -2579,27 +2773,11 @@ export class AgentOs {
2579
2773
  * there is no surviving store and the fallback tier always runs.
2580
2774
  */
2581
2775
  async resumeSession(sessionId, agentType, options) {
2582
- const config = this._resolveAgentConfig(agentType);
2583
- if (!config) {
2584
- throw new Error(`Unknown agent type: ${agentType}`);
2585
- }
2586
- const adapterEntrypoint = this._resolveAdapterBin(config.acpAdapter);
2587
- let launchEnv = { ...config.defaultEnv, ...options?.env };
2588
- const sessionCwd = options?.cwd ?? "/home/user";
2589
- if ((agentType === "pi" || agentType === "pi-cli") &&
2590
- !launchEnv.PI_ACP_PI_COMMAND) {
2591
- launchEnv = {
2592
- ...launchEnv,
2593
- PI_ACP_PI_COMMAND: this._resolvePackageBin(config.agentPackage, "pi"),
2594
- };
2595
- }
2596
- // The resume wire request has no dedicated `adapterEntrypoint` field; carry
2597
- // the resolved entrypoint through env under the sidecar's reserved key. The
2598
- // sidecar reads it and strips it before launching the adapter.
2599
- launchEnv = {
2600
- ...launchEnv,
2601
- [RESUME_ADAPTER_ENTRYPOINT_ENV]: adapterEntrypoint,
2602
- };
2776
+ // The client is npm-agnostic: it sends only the agent NAME. The sidecar
2777
+ // resolves the name -> package -> entrypoint/env/launchArgs from the
2778
+ // projected manifest, exactly as createSession does.
2779
+ const sessionCwd = options?.cwd ?? "/workspace";
2780
+ const launchEnv = { ...options?.env };
2603
2781
  const response = await this._sendAcpRequest({
2604
2782
  tag: "AcpResumeSessionRequest",
2605
2783
  val: {
@@ -2615,6 +2793,10 @@ export class AgentOs {
2615
2793
  }
2616
2794
  const { sessionId: liveSessionId, mode } = response.val;
2617
2795
  // Register + hydrate the live session so subsequent prompts route to it.
2796
+ const existing = this._sessions.get(liveSessionId);
2797
+ if (existing !== undefined && !existing.closed) {
2798
+ throw new Error(`session id collision: ${liveSessionId}`);
2799
+ }
2618
2800
  const session = sessionEntryFromInit(liveSessionId, String(agentType), {});
2619
2801
  this._closedSessionIds.delete(liveSessionId);
2620
2802
  this._sessions.set(liveSessionId, session);
@@ -2627,65 +2809,6 @@ export class AgentOs {
2627
2809
  }
2628
2810
  return { sessionId: liveSessionId, mode };
2629
2811
  }
2630
- /**
2631
- * Resolve the VM bin entry point of an ACP adapter package.
2632
- * Reads from the host filesystem since kernel.readFile() resolves through
2633
- * mounts; adapter package.json is read directly off the host dir backing
2634
- * the relevant mount.
2635
- */
2636
- _resolveAdapterBin(adapterPackage) {
2637
- return this._resolvePackageBin(adapterPackage);
2638
- }
2639
- /**
2640
- * Parent of the host directory backing the `/root/node_modules` mount, if
2641
- * the caller supplied one (e.g. via `nodeModulesMount(...)`). Used as the
2642
- * `resolvePackageDir` start dir for adapter/agent package resolution when no
2643
- * software root matches. `nodeModulesMount` mounts `<dir>/node_modules`, so
2644
- * the start dir is `<dir>` (one level above the node_modules tree).
2645
- */
2646
- _nodeModulesHostRoot() {
2647
- for (const mount of this._hostMounts) {
2648
- if (mount.vmPath === "/root/node_modules") {
2649
- return dirname(mount.hostPath);
2650
- }
2651
- }
2652
- return null;
2653
- }
2654
- _resolvePackageBin(packageName, binName) {
2655
- const vmPrefix = `/root/node_modules/${packageName}`;
2656
- let hostPkgJsonPath = null;
2657
- for (const root of this._softwareRoots) {
2658
- if (root.vmPath === vmPrefix) {
2659
- hostPkgJsonPath = join(root.hostPath, "package.json");
2660
- break;
2661
- }
2662
- }
2663
- // Fall back to the host dir behind the `/root/node_modules` mount.
2664
- if (!hostPkgJsonPath) {
2665
- const nodeModulesRoot = this._nodeModulesHostRoot();
2666
- if (!nodeModulesRoot) {
2667
- throw new Error(`Cannot resolve package "${packageName}": no software root provides it and ` +
2668
- `no /root/node_modules mount was supplied. Pass ` +
2669
- `mounts: [nodeModulesMount("<host>/node_modules")] to AgentOs.create().`);
2670
- }
2671
- hostPkgJsonPath = join(resolvePackageDir(nodeModulesRoot, packageName), "package.json");
2672
- }
2673
- const pkg = JSON.parse(readFileSync(hostPkgJsonPath, "utf-8"));
2674
- let binEntry;
2675
- if (typeof pkg.bin === "string") {
2676
- binEntry = pkg.bin;
2677
- }
2678
- else if (typeof pkg.bin === "object" && pkg.bin !== null) {
2679
- binEntry =
2680
- (binName ? pkg.bin[binName] : undefined) ??
2681
- pkg.bin[packageName] ??
2682
- Object.values(pkg.bin)[0];
2683
- }
2684
- if (!binEntry) {
2685
- throw new Error(`No bin entry found in ${packageName}/package.json`);
2686
- }
2687
- return `${vmPrefix}/${binEntry}`;
2688
- }
2689
2812
  _installSidecarRequestHandler() {
2690
2813
  const context = {
2691
2814
  toolKits: this._toolKits,
@@ -2698,10 +2821,8 @@ export class AgentOs {
2698
2821
  case "host_callback":
2699
2822
  return handleHostCallback(request, context);
2700
2823
  case "js_bridge_call":
2701
- return Promise.resolve({
2702
- type: "js_bridge_result",
2703
- call_id: request.payload.call_id,
2704
- error: `unsupported sidecar request type: ${request.payload.type}`,
2824
+ return handleJsBridgeCall(request.payload, {
2825
+ filesystem: this.#kernel.vfs,
2705
2826
  });
2706
2827
  case "ext":
2707
2828
  return this._handleAcpExtSidecarRequest(request.payload.envelope);
@@ -2888,6 +3009,10 @@ export class AgentOs {
2888
3009
  _acpMethod: request.method,
2889
3010
  };
2890
3011
  if (session.permissionHandlers.size === 0) {
3012
+ // Default-closed deny; warn once (host-visible) so a forgotten
3013
+ // onPermissionRequest handler is an observable cause rather than a
3014
+ // silent denial. See _warnNoPermissionHandlerOnce.
3015
+ this._warnNoPermissionHandlerOnce(session, permissionParams);
2891
3016
  return this._buildAcpPermissionResult("reject", permissionParams);
2892
3017
  }
2893
3018
  const reply = await new Promise((resolve, reject) => {
@@ -3170,6 +3295,10 @@ export class AgentOs {
3170
3295
  return "reject";
3171
3296
  }
3172
3297
  if (session.permissionHandlers.size === 0) {
3298
+ // Default-closed: deny when no host hook is listening, and warn once
3299
+ // (host-visible) so a forgotten onPermissionRequest handler is not an
3300
+ // invisible cause of an agent that cannot use any tool.
3301
+ this._warnNoPermissionHandlerOnce(session, params);
3173
3302
  return "reject";
3174
3303
  }
3175
3304
  try {
@@ -3199,14 +3328,6 @@ export class AgentOs {
3199
3328
  return "reject";
3200
3329
  }
3201
3330
  }
3202
- /**
3203
- * Resolve an agent config by ID. Package-provided configs take
3204
- * precedence over the hardcoded AGENT_CONFIGS.
3205
- */
3206
- _resolveAgentConfig(agentType) {
3207
- return (this._softwareAgentConfigs.get(agentType) ??
3208
- AGENT_CONFIGS[agentType]);
3209
- }
3210
3331
  /**
3211
3332
  * Gracefully destroy a session: cancel any pending work, close the client,
3212
3333
  * and remove from tracking. Unlike close() which is abrupt, this attempts
@@ -3390,6 +3511,7 @@ export class AgentOs {
3390
3511
  terminalExitPromises.push(terminal.waitPromise.then(() => undefined, () => undefined));
3391
3512
  }
3392
3513
  this._acpTerminals.clear();
3514
+ this._processes.clear();
3393
3515
  await waitForTrackedExitPromises([...shellExitPromises, ...terminalExitPromises], SHELL_DISPOSE_TIMEOUT_MS);
3394
3516
  this._disposeSidecarEventListener();
3395
3517
  const sidecarLease = this._sidecarLease;
@@ -3419,6 +3541,163 @@ function resolveAgentOsSidecar(config) {
3419
3541
  }
3420
3542
  const sidecarStates = new WeakMap();
3421
3543
  const sharedSidecars = new Map();
3544
+ let sidecarProcessExitHookInstalled = false;
3545
+ /**
3546
+ * Install a one-time, synchronous `process.on("exit")` hook that SIGKILLs any
3547
+ * pooled shared sidecar child. Once a one-shot host process is allowed to exit
3548
+ * (its sidecar handles are unref'd at 0 leases), this reaps the sidecar
3549
+ * immediately instead of waiting for its stdin-EOF grace window — no orphan, no
3550
+ * delay. We deliberately do NOT install SIGINT/SIGTERM handlers: a library
3551
+ * should not hijack the host's signal handling. SIGINT still reaches the sidecar
3552
+ * via the process group, and SIGTERM-driven exit still closes its stdin.
3553
+ */
3554
+ function ensureSidecarProcessExitCleanup() {
3555
+ if (sidecarProcessExitHookInstalled)
3556
+ return;
3557
+ sidecarProcessExitHookInstalled = true;
3558
+ process.on("exit", () => {
3559
+ for (const sidecar of sharedSidecars.values()) {
3560
+ try {
3561
+ sidecarStates.get(sidecar)?.sharedChild?.kill?.("SIGKILL");
3562
+ }
3563
+ catch {
3564
+ // best-effort reap; the process is exiting regardless
3565
+ }
3566
+ }
3567
+ });
3568
+ }
3569
+ function sidecarChildHandle(client) {
3570
+ // SidecarProcess -> StdioSidecarProtocolClient.child (the spawned ChildProcess).
3571
+ const protocolClient = client?.protocolClient;
3572
+ return protocolClient?.child ?? undefined;
3573
+ }
3574
+ /**
3575
+ * Apply the current hold state to the shared sidecar's child + stdio: ref them
3576
+ * while ≥1 hold is live so in-flight VM work keeps the host process alive; unref
3577
+ * them at 0 so a one-shot script exits on its own after `dispose()`. The sidecar
3578
+ * process itself stays running (reusable) and self-exits on stdin EOF when the
3579
+ * host finally goes away. Best-effort: never let ref/unref break VM lifecycle.
3580
+ */
3581
+ function applySharedSidecarHold(state) {
3582
+ const child = state.sharedChild;
3583
+ if (!child)
3584
+ return;
3585
+ const hold = (state.eventLoopHolds ?? 0) > 0;
3586
+ for (const handle of [child, child.stdin, child.stdout, child.stderr]) {
3587
+ if (!handle)
3588
+ continue;
3589
+ try {
3590
+ if (hold)
3591
+ handle.ref?.();
3592
+ else
3593
+ handle.unref?.();
3594
+ }
3595
+ catch {
3596
+ // ref/unref is an optimization, not correctness-critical
3597
+ }
3598
+ }
3599
+ }
3600
+ /**
3601
+ * Take a hold for the entire create→use→dispose lifetime of one VM lease. Taken
3602
+ * BEFORE VM creation starts (not when the lease lands in `activeLeases`) so a VM
3603
+ * that is still mid-creation keeps the sidecar ref'd and a concurrent dispose
3604
+ * cannot unref it out from under the in-flight create.
3605
+ */
3606
+ function acquireSharedSidecarHold(state) {
3607
+ state.eventLoopHolds = (state.eventLoopHolds ?? 0) + 1;
3608
+ if (state.eventLoopHolds === 1)
3609
+ applySharedSidecarHold(state);
3610
+ }
3611
+ /** Release a hold taken by {@link acquireSharedSidecarHold}; unref at 0. */
3612
+ function releaseSharedSidecarHold(state) {
3613
+ const current = state.eventLoopHolds ?? 0;
3614
+ if (current <= 0) {
3615
+ // The `holdReleased` guard makes each lease release exactly once, so this
3616
+ // should be unreachable. Warn rather than silently floor, per the repo's
3617
+ // no-silent-masking rule, so an accounting bug surfaces instead of hiding.
3618
+ state.eventLoopHolds = 0;
3619
+ console.warn("[agentos] shared sidecar event-loop hold released more than acquired");
3620
+ return;
3621
+ }
3622
+ state.eventLoopHolds = current - 1;
3623
+ if (state.eventLoopHolds === 0)
3624
+ applySharedSidecarHold(state);
3625
+ }
3626
+ /**
3627
+ * Spawn-once accessor for a sidecar handle's shared native process. Concurrent
3628
+ * callers await the same promise, so one `AgentOsSidecar` maps to exactly one
3629
+ * `agent-os-sidecar` OS process for its whole lifetime.
3630
+ */
3631
+ function ensureSharedSidecarNativeProcess(sidecar) {
3632
+ const state = getSidecarState(sidecar);
3633
+ if (!state.nativeProcess) {
3634
+ ensureSidecarProcessExitCleanup();
3635
+ state.nativeProcess = (async () => {
3636
+ const client = SidecarProcess.spawn({
3637
+ cwd: REPO_ROOT,
3638
+ command: ensureNativeSidecarBinary(),
3639
+ args: [],
3640
+ });
3641
+ // Track the child immediately — BEFORE the handshake await — so a
3642
+ // failed `authenticateAndOpenSession()` can still reap it (otherwise
3643
+ // the spawned child is untracked, unreapable, and pins the loop).
3644
+ state.sharedChild = sidecarChildHandle(client);
3645
+ if (!state.sharedChild) {
3646
+ // We reached into @rivet-dev/agentos-runtime-core internals to get the child for
3647
+ // idle-unref. If that shape ever changes this returns undefined and
3648
+ // the optimization silently stops working (one-shot scripts would
3649
+ // hang again). Make it loud rather than a silent regression.
3650
+ console.warn("[agentos] could not resolve the shared sidecar child handle; " +
3651
+ "standalone scripts may not exit cleanly after dispose(). " +
3652
+ "This usually means @rivet-dev/agentos-runtime-core internals changed.");
3653
+ }
3654
+ // Apply the current hold state to the just-spawned child.
3655
+ applySharedSidecarHold(state);
3656
+ try {
3657
+ const session = await client.authenticateAndOpenSession();
3658
+ return { client, session };
3659
+ }
3660
+ catch (error) {
3661
+ // Spawn/handshake failed: reap the child, drop the cached handle,
3662
+ // and CLEAR the rejected promise so the next create() retries
3663
+ // instead of permanently wedging on a rejected `nativeProcess`.
3664
+ try {
3665
+ state.sharedChild?.kill?.("SIGKILL");
3666
+ }
3667
+ catch {
3668
+ // already gone
3669
+ }
3670
+ state.sharedChild = undefined;
3671
+ state.nativeProcess = undefined;
3672
+ throw error;
3673
+ }
3674
+ })();
3675
+ }
3676
+ return state.nativeProcess;
3677
+ }
3678
+ /** Dispose a sidecar handle's shared native process, if one was spawned. */
3679
+ async function disposeSharedSidecarNativeProcess(state) {
3680
+ const pending = state.nativeProcess;
3681
+ if (!pending) {
3682
+ return;
3683
+ }
3684
+ state.nativeProcess = undefined;
3685
+ // The cached child is now dead; drop it (symmetric with the assignment in
3686
+ // ensureSharedSidecarNativeProcess). We deliberately do NOT zero
3687
+ // `eventLoopHolds` here: this runs only from `AgentOsSidecar.dispose()`, which
3688
+ // has already set the handle to `disposing` (so no new lease can acquire) and
3689
+ // drained `activeLeases`; the disposed handle's state is then abandoned. Force-
3690
+ // zeroing a shared counter could clobber a hold on a freshly re-acquired
3691
+ // process generation, so it is left to the balanced acquire/release pairs.
3692
+ state.sharedChild = undefined;
3693
+ try {
3694
+ const { client } = await pending;
3695
+ await client.dispose();
3696
+ }
3697
+ catch {
3698
+ // Process may have already exited; nothing to reclaim.
3699
+ }
3700
+ }
3422
3701
  export class AgentOsSidecar {
3423
3702
  constructor(sidecarId, placement, sharedPool) {
3424
3703
  sidecarStates.set(this, {
@@ -3453,6 +3732,8 @@ export class AgentOsSidecar {
3453
3732
  }
3454
3733
  state.activeLeases.clear();
3455
3734
  state.description.activeVmCount = 0;
3735
+ // Tear down the shared native process after all leased VMs are gone.
3736
+ await disposeSharedSidecarNativeProcess(state);
3456
3737
  state.description.state = "disposed";
3457
3738
  if (state.sharedPool && sharedSidecars.get(state.sharedPool) === this) {
3458
3739
  sharedSidecars.delete(state.sharedPool);
@@ -3498,7 +3779,7 @@ function getSharedAgentOsSidecarInternal(options = {}) {
3498
3779
  if (existing && existing.describe().state !== "disposed") {
3499
3780
  return existing;
3500
3781
  }
3501
- const sidecar = new AgentOsSidecar(`agent-os-shared-sidecar:${pool}`, { kind: "shared", ...(pool ? { pool } : {}) }, pool);
3782
+ const sidecar = new AgentOsSidecar(`agentos-shared-sidecar:${pool}`, { kind: "shared", ...(pool ? { pool } : {}) }, pool);
3502
3783
  sharedSidecars.set(pool, sidecar);
3503
3784
  return sidecar;
3504
3785
  }
@@ -3514,6 +3795,18 @@ async function leaseAgentOsSidecarVm(sidecar, options) {
3514
3795
  return transport;
3515
3796
  },
3516
3797
  });
3798
+ // Hold the shared sidecar's event-loop ref for this lease's WHOLE lifetime —
3799
+ // taken now, before VM creation, so a concurrent dispose cannot unref the
3800
+ // sidecar while this create is still in flight. Released exactly once on
3801
+ // dispose or on a failed create.
3802
+ acquireSharedSidecarHold(state);
3803
+ let holdReleased = false;
3804
+ const releaseHold = () => {
3805
+ if (holdReleased)
3806
+ return;
3807
+ holdReleased = true;
3808
+ releaseSharedSidecarHold(state);
3809
+ };
3517
3810
  let disposed = false;
3518
3811
  let leaseRecord;
3519
3812
  try {
@@ -3538,6 +3831,10 @@ async function leaseAgentOsSidecarVm(sidecar, options) {
3538
3831
  state.activeLeases.delete(leaseRecord);
3539
3832
  state.description.activeVmCount = state.activeLeases.size;
3540
3833
  await client.dispose();
3834
+ // Release this lease's hold; the shared sidecar is unref'd only
3835
+ // once the last hold (across all in-flight + active leases) drops,
3836
+ // so a one-shot host process can then exit on its own.
3837
+ releaseHold();
3541
3838
  },
3542
3839
  };
3543
3840
  leaseRecord = {
@@ -3549,6 +3846,7 @@ async function leaseAgentOsSidecarVm(sidecar, options) {
3549
3846
  }
3550
3847
  catch (error) {
3551
3848
  await client.dispose().catch(() => { });
3849
+ releaseHold();
3552
3850
  throw error;
3553
3851
  }
3554
3852
  }