@nylorun/runtime 0.9.0-beta → 0.11.0-beta

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 (192) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/README.md +110 -37
  3. package/dist/adapters/execution/restate.d.ts +130 -0
  4. package/dist/adapters/execution/restate.js +442 -0
  5. package/dist/adapters/media.d.ts +2 -2
  6. package/dist/adapters/media.js +2 -2
  7. package/dist/adapters/observe.d.ts +2 -1
  8. package/dist/adapters/observe.js +2 -3
  9. package/dist/adapters/sandbox/virtual.js +1 -1
  10. package/dist/adapters/streams/s2.d.ts +18 -0
  11. package/dist/adapters/streams/s2.js +361 -0
  12. package/dist/assets.d.ts +2 -2
  13. package/dist/assets.js +5 -3
  14. package/dist/core/executors.d.ts +15 -3
  15. package/dist/core/executors.js +40 -5
  16. package/dist/core/flow-host.d.ts +225 -0
  17. package/dist/core/flow-host.js +518 -0
  18. package/dist/core/limits.d.ts +40 -0
  19. package/dist/core/limits.js +76 -0
  20. package/dist/core/runtime.d.ts +5 -90
  21. package/dist/core/runtime.js +3 -1447
  22. package/dist/core/sandbox-routes.d.ts +40 -0
  23. package/dist/core/sandbox-routes.js +106 -0
  24. package/dist/core/turn-manifest.d.ts +50 -0
  25. package/dist/core/turn-manifest.js +66 -0
  26. package/dist/execution/memory.d.ts +54 -0
  27. package/dist/execution/memory.js +217 -0
  28. package/dist/execution/types.d.ts +125 -0
  29. package/dist/execution/types.js +52 -0
  30. package/dist/host/config-for.d.ts +17 -0
  31. package/dist/host/config-for.js +27 -0
  32. package/dist/host/config.d.ts +16 -0
  33. package/dist/host/create-host.d.ts +56 -0
  34. package/dist/host/create-host.js +420 -0
  35. package/dist/host/environment.d.ts +13 -0
  36. package/dist/host/environment.js +53 -0
  37. package/dist/host/execution.d.ts +61 -0
  38. package/dist/host/execution.js +69 -0
  39. package/dist/host/http.d.ts +39 -0
  40. package/dist/host/http.js +142 -0
  41. package/dist/host/index.d.ts +8 -0
  42. package/dist/host/index.js +7 -0
  43. package/dist/host/logger.d.ts +3 -0
  44. package/dist/host/logger.js +18 -0
  45. package/dist/host/main.d.ts +3 -0
  46. package/dist/host/main.js +201 -0
  47. package/dist/host/stack-config.d.ts +72 -0
  48. package/dist/host/stack-config.js +193 -0
  49. package/dist/index.d.ts +7 -2
  50. package/dist/index.js +4 -2
  51. package/dist/infra/database.d.ts +16 -0
  52. package/dist/infra/database.js +31 -0
  53. package/dist/infra/execution.d.ts +39 -0
  54. package/dist/infra/execution.js +68 -0
  55. package/dist/infra/index.d.ts +42 -0
  56. package/dist/infra/index.js +56 -0
  57. package/dist/infra/readiness.d.ts +33 -0
  58. package/dist/infra/readiness.js +53 -0
  59. package/dist/infra/streams.d.ts +17 -0
  60. package/dist/infra/streams.js +33 -0
  61. package/dist/infra/workers.d.ts +39 -0
  62. package/dist/infra/workers.js +31 -0
  63. package/dist/mcp/connect.d.ts +1 -0
  64. package/dist/mcp/connect.js +2 -0
  65. package/dist/mcp/pool.d.ts +3 -1
  66. package/dist/mcp/pool.js +2 -1
  67. package/dist/mcp/stdio.d.ts +2 -0
  68. package/dist/mcp/stdio.js +3 -1
  69. package/dist/model/auth-store.d.ts +1 -1
  70. package/dist/model/auth-store.js +2 -2
  71. package/dist/model/models.d.ts +4 -0
  72. package/dist/model/models.js +22 -26
  73. package/dist/model/pi-model.d.ts +2 -2
  74. package/dist/model/pi-model.js +6 -6
  75. package/dist/model/settings.d.ts +5 -1
  76. package/dist/model/settings.js +6 -2
  77. package/dist/sandbox/manager.d.ts +26 -8
  78. package/dist/sandbox/manager.js +95 -47
  79. package/dist/sandbox/select.d.ts +5 -4
  80. package/dist/sandbox/select.js +18 -13
  81. package/dist/sandbox/share.d.ts +23 -0
  82. package/dist/sandbox/share.js +58 -0
  83. package/dist/sandbox/types.d.ts +2 -2
  84. package/dist/store/canonical.d.ts +5 -0
  85. package/dist/store/canonical.js +17 -0
  86. package/dist/store/cursor.d.ts +9 -0
  87. package/dist/store/cursor.js +18 -0
  88. package/dist/store/memory.d.ts +57 -0
  89. package/dist/store/memory.js +643 -0
  90. package/dist/store/ownership.d.ts +19 -0
  91. package/dist/store/ownership.js +33 -0
  92. package/dist/store/postgres/connect.d.ts +25 -0
  93. package/dist/store/postgres/connect.js +25 -0
  94. package/dist/store/postgres/locking.d.ts +16 -0
  95. package/dist/store/postgres/locking.js +22 -0
  96. package/dist/store/postgres/migrations/001_initial.d.ts +27 -0
  97. package/dist/store/postgres/migrations/001_initial.js +196 -0
  98. package/dist/store/postgres/migrations/index.d.ts +49 -0
  99. package/dist/store/postgres/migrations/index.js +75 -0
  100. package/dist/store/postgres/names.d.ts +11 -0
  101. package/dist/store/postgres/names.js +42 -0
  102. package/dist/store/postgres/store.d.ts +11 -0
  103. package/dist/store/postgres/store.js +790 -0
  104. package/dist/store/postgres/tenants.d.ts +90 -0
  105. package/dist/store/postgres/tenants.js +207 -0
  106. package/dist/store/types.d.ts +440 -0
  107. package/dist/store/types.js +10 -0
  108. package/dist/streams/basin.d.ts +23 -0
  109. package/dist/streams/basin.js +51 -0
  110. package/dist/streams/memory.d.ts +25 -0
  111. package/dist/streams/memory.js +124 -0
  112. package/dist/streams/relay.d.ts +37 -0
  113. package/dist/streams/relay.js +254 -0
  114. package/dist/streams/types.d.ts +115 -0
  115. package/dist/streams/types.js +86 -0
  116. package/dist/tenant/actions.d.ts +153 -0
  117. package/dist/tenant/actions.js +191 -0
  118. package/dist/tenant/advance.d.ts +15 -0
  119. package/dist/tenant/advance.js +439 -0
  120. package/dist/tenant/auth.d.ts +11 -0
  121. package/dist/tenant/auth.js +34 -0
  122. package/dist/tenant/commands.d.ts +3 -0
  123. package/dist/tenant/commands.js +384 -0
  124. package/dist/tenant/context.d.ts +140 -0
  125. package/dist/tenant/context.js +40 -0
  126. package/dist/tenant/effects.d.ts +20 -0
  127. package/dist/tenant/effects.js +539 -0
  128. package/dist/tenant/envelope.d.ts +7 -0
  129. package/dist/tenant/envelope.js +22 -0
  130. package/dist/tenant/ephemeral.d.ts +42 -0
  131. package/dist/tenant/ephemeral.js +224 -0
  132. package/dist/tenant/http.d.ts +32 -0
  133. package/dist/tenant/http.js +51 -0
  134. package/dist/tenant/live.d.ts +92 -0
  135. package/dist/tenant/live.js +290 -0
  136. package/dist/tenant/logger.d.ts +9 -0
  137. package/dist/tenant/logger.js +25 -0
  138. package/dist/tenant/model-setting.d.ts +16 -0
  139. package/dist/tenant/model-setting.js +12 -0
  140. package/dist/tenant/module.d.ts +38 -0
  141. package/dist/tenant/module.js +319 -0
  142. package/dist/tenant/paths.d.ts +28 -0
  143. package/dist/tenant/paths.js +91 -0
  144. package/dist/tenant/pool.d.ts +10 -0
  145. package/dist/tenant/pool.js +54 -0
  146. package/dist/tenant/principals.d.ts +13 -0
  147. package/dist/tenant/principals.js +37 -0
  148. package/dist/tenant/quarantine-error.d.ts +6 -0
  149. package/dist/tenant/quarantine-error.js +9 -0
  150. package/dist/tenant/quarantine.d.ts +27 -0
  151. package/dist/tenant/quarantine.js +72 -0
  152. package/dist/tenant/reset.d.ts +22 -0
  153. package/dist/tenant/reset.js +81 -0
  154. package/dist/tenant/routes-tenant.d.ts +12 -0
  155. package/dist/tenant/routes-tenant.js +126 -0
  156. package/dist/tenant/routes.d.ts +9 -0
  157. package/dist/tenant/routes.js +142 -0
  158. package/dist/tenant/runtime.d.ts +81 -0
  159. package/dist/tenant/runtime.js +298 -0
  160. package/dist/tenant/scheduler.d.ts +25 -0
  161. package/dist/tenant/scheduler.js +84 -0
  162. package/dist/tenant/session.d.ts +60 -0
  163. package/dist/tenant/session.js +94 -0
  164. package/dist/tenant/sessions.d.ts +34 -0
  165. package/dist/tenant/sessions.js +149 -0
  166. package/dist/tenant/status.d.ts +37 -0
  167. package/dist/tenant/status.js +130 -0
  168. package/dist/tenant/store-memory.d.ts +16 -0
  169. package/dist/tenant/store-memory.js +111 -0
  170. package/dist/tenant/store-pg.d.ts +15 -0
  171. package/dist/tenant/store-pg.js +140 -0
  172. package/dist/tenant/streams.d.ts +126 -0
  173. package/dist/tenant/streams.js +419 -0
  174. package/dist/tenant/sweep.d.ts +15 -0
  175. package/dist/tenant/sweep.js +122 -0
  176. package/dist/tenant/types.d.ts +205 -0
  177. package/dist/tenant/types.js +23 -0
  178. package/dist/tenant/worker.d.ts +84 -0
  179. package/dist/tenant/worker.js +189 -0
  180. package/dist/vault/kek.d.ts +5 -2
  181. package/dist/vault/kek.js +7 -10
  182. package/dist/vault/service.d.ts +37 -31
  183. package/dist/vault/service.js +439 -359
  184. package/dist/version.d.ts +1 -1
  185. package/dist/version.js +1 -1
  186. package/package.json +14 -11
  187. package/dist/adapters/sandbox/microsandbox.d.ts +0 -2
  188. package/dist/adapters/sandbox/microsandbox.js +0 -238
  189. package/dist/core/main.js +0 -35
  190. package/dist/core/store.d.ts +0 -30
  191. package/dist/core/store.js +0 -202
  192. /package/dist/{core/main.d.ts → host/config.js} +0 -0
@@ -0,0 +1,31 @@
1
+ /** Whether a process in `role` serves the Worker endpoint. */
2
+ export function servesWorker(role) {
3
+ return role !== "api";
4
+ }
5
+ /** What `stopWorker` needs for a handle that serves the endpoint. */
6
+ const started = new WeakMap();
7
+ /**
8
+ * Starts delivering Restate invocations to `handlers` when `role` is `worker`
9
+ * or `all` (serving and registering the Worker endpoint, for Restate). For
10
+ * `api` it does nothing. Rejects if the endpoint cannot listen or register.
11
+ */
12
+ export async function startWorker(input) {
13
+ const { role, execution, handlers } = input;
14
+ const handle = Object.freeze({ role, serving: servesWorker(role) });
15
+ if (!handle.serving)
16
+ return handle;
17
+ await execution.start(handlers);
18
+ started.set(handle, { execution });
19
+ return handle;
20
+ }
21
+ /**
22
+ * Stops a Worker started by `startWorker`: aborts running advances' signals
23
+ * and waits for them. Idempotent; a no-op for the api role.
24
+ */
25
+ export async function stopWorker(handle) {
26
+ const worker = started.get(handle);
27
+ if (!worker)
28
+ return;
29
+ worker.stopped ??= worker.execution.stop();
30
+ await worker.stopped;
31
+ }
@@ -11,6 +11,7 @@ export declare function openMcpServer(input: {
11
11
  authorize?: (url: string) => Promise<AuthorizeResult>;
12
12
  pluginRoot?: string;
13
13
  pluginData: string;
14
+ childEnv?: Readonly<Record<string, string>>;
14
15
  }): Promise<LiveConnection>;
15
16
  export declare function listMcpTools(client: Client, input: {
16
17
  capabilityId: string;
@@ -10,11 +10,13 @@ export async function openMcpServer(input) {
10
10
  const launch = prepareStdioLaunch(input.server, {
11
11
  pluginRoot: input.pluginRoot,
12
12
  pluginData: input.pluginData,
13
+ childEnv: input.childEnv,
13
14
  });
14
15
  const transport = new StdioClientTransport({
15
16
  command: launch.command,
16
17
  args: [...launch.args],
17
18
  cwd: launch.cwd,
19
+ // Full explicit env so HOME/TMPDIR from the Tenant override getDefaultEnvironment().
18
20
  env: { ...launch.env },
19
21
  stderr: "pipe",
20
22
  });
@@ -6,7 +6,9 @@ export declare class McpPool {
6
6
  private readonly options;
7
7
  private readonly live;
8
8
  constructor(options: {
9
- readonly dataDir: string;
9
+ /** Absolute Tenant `plugin-data` directory. */
10
+ readonly pluginData: string;
11
+ readonly childEnv: Readonly<Record<string, string>>;
10
12
  readonly authorize: (sessionId: string, request: {
11
13
  url: string;
12
14
  serverName: string;
package/dist/mcp/pool.js CHANGED
@@ -122,7 +122,8 @@ export class McpPool {
122
122
  const connection = await openMcpServer({
123
123
  server: declared.server,
124
124
  pluginRoot: input.pluginRoots[pluginKey(declared)],
125
- pluginData: join(this.options.dataDir, "plugin-data", ...pluginKey(declared).split("/")),
125
+ pluginData: join(this.options.pluginData, ...pluginKey(declared).split("/")),
126
+ childEnv: this.options.childEnv,
126
127
  authorize: declared.server.type === "stdio"
127
128
  ? undefined
128
129
  : (url) => this.options.authorize(input.sessionId, {
@@ -12,4 +12,6 @@ export declare function prepareStdioLaunch(server: Extract<McpServerManifest, {
12
12
  }>, options: {
13
13
  readonly pluginRoot?: string;
14
14
  readonly pluginData: string;
15
+ /** Allowlisted base env (Tenant HOME/TMPDIR/PATH). Manifest env overlays it (A9). */
16
+ readonly childEnv?: Readonly<Record<string, string>>;
15
17
  }): StdioLaunch;
package/dist/mcp/stdio.js CHANGED
@@ -38,7 +38,9 @@ export function prepareStdioLaunch(server, options) {
38
38
  const cwd = server.cwd
39
39
  ? resolveContainedCwd(server.cwd, pluginRoot, pluginData)
40
40
  : (pluginRoot ?? pluginData);
41
- const env = {};
41
+ // childEnv under manifest env; HOME/TMPDIR from childEnv override Host defaults
42
+ // that StdioClientTransport merges via getDefaultEnvironment() (R2).
43
+ const env = { ...(options.childEnv ?? {}) };
42
44
  for (const [key, value] of Object.entries(server.env ?? {}))
43
45
  env[key] = expand(value, pluginRoot, pluginData);
44
46
  if (pluginRoot)
@@ -3,7 +3,7 @@ export declare class ProjectCredentialStore implements CredentialStore {
3
3
  #private;
4
4
  private readonly file;
5
5
  private readonly legacyFile?;
6
- constructor(file?: string, legacyFile?: string | undefined);
6
+ constructor(file: string, legacyFile?: string | undefined);
7
7
  read(providerId: string): Promise<Credential | undefined>;
8
8
  list(): Promise<readonly CredentialInfo[]>;
9
9
  modify(providerId: string, fn: (current: Credential | undefined) => Promise<Credential | undefined>): Promise<Credential | undefined>;
@@ -1,11 +1,11 @@
1
1
  var _a;
2
2
  import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
- import { dirname, join } from "node:path";
3
+ import { dirname } from "node:path";
4
4
  export class ProjectCredentialStore {
5
5
  file;
6
6
  legacyFile;
7
7
  #chain = Promise.resolve();
8
- constructor(file = join(process.cwd(), ".nylorun", "auth.json"), legacyFile) {
8
+ constructor(file, legacyFile) {
9
9
  this.file = file;
10
10
  this.legacyFile = legacyFile;
11
11
  }
@@ -6,6 +6,10 @@ export type Selection = Readonly<{
6
6
  baseUrl: string;
7
7
  }>;
8
8
  }>;
9
+ /**
10
+ * Build the pi-ai model registry from explicit credentials only.
11
+ * Ambient process environment / provider env auth is never consulted (A7, A10).
12
+ */
9
13
  export declare function modelsFor(selection: Selection, credentials: CredentialStore, options?: {
10
14
  environment?: boolean;
11
15
  }): import("@earendil-works/pi-ai").MutableModels;
@@ -1,31 +1,15 @@
1
- import { createProvider, defaultProviderAuthContext, envApiKeyAuth, } from "@earendil-works/pi-ai";
1
+ import { createProvider, } from "@earendil-works/pi-ai";
2
2
  import { stream, streamSimple, } from "@earendil-works/pi-ai/api/openai-completions";
3
3
  import { builtinModels } from "@earendil-works/pi-ai/providers/all";
4
+ /**
5
+ * Build the pi-ai model registry from explicit credentials only.
6
+ * Ambient process environment / provider env auth is never consulted (A7, A10).
7
+ */
4
8
  export function modelsFor(selection, credentials, options = {}) {
5
- const environmentFirst = {
6
- async read(providerId, options) {
7
- const explicit = process.env.MODEL_PROVIDER_API_KEY;
8
- if (explicit &&
9
- (!selection.provider || selection.provider === providerId))
10
- return { type: "api_key", key: explicit };
11
- const provider = models
12
- .getProviders()
13
- .find((item) => item.id === providerId);
14
- const ambient = await provider?.auth.apiKey?.resolve({
15
- ctx: defaultProviderAuthContext(),
16
- signal: options?.signal ?? new AbortController().signal,
17
- });
18
- if (ambient)
19
- return undefined;
20
- return credentials.read(providerId, options);
21
- },
22
- list: (options) => credentials.list(options),
23
- modify: (id, fn, options) => credentials.modify(id, fn, options),
24
- delete: (id, options) => credentials.delete(id, options),
25
- };
26
- const models = builtinModels({
27
- credentials: options.environment === false ? credentials : environmentFirst,
28
- });
9
+ if (options.environment) {
10
+ throw new Error("Ambient model environment is not supported; pass credentials explicitly");
11
+ }
12
+ const models = builtinModels({ credentials });
29
13
  if (!selection.custom)
30
14
  return models;
31
15
  const model = {
@@ -45,7 +29,19 @@ export function modelsFor(selection, credentials, options = {}) {
45
29
  name: "Custom OpenAI-compatible",
46
30
  baseUrl: selection.custom.baseUrl,
47
31
  auth: {
48
- apiKey: envApiKeyAuth("Custom API key", ["MODEL_PROVIDER_API_KEY"]),
32
+ apiKey: {
33
+ label: "Custom API key",
34
+ schemas: [],
35
+ async resolve() {
36
+ const credential = await credentials.read("custom");
37
+ if (!credential || credential.type !== "api_key")
38
+ return undefined;
39
+ const key = credential.key;
40
+ if (!key)
41
+ return undefined;
42
+ return { type: "api_key", auth: { apiKey: key } };
43
+ },
44
+ },
49
45
  },
50
46
  models: [model],
51
47
  api: { stream, streamSimple },
@@ -10,8 +10,8 @@ export interface PiModelOptions {
10
10
  readonly root?: string;
11
11
  readonly onPreview?: (preview: ModelPreview) => void;
12
12
  readonly media?: Pick<RuntimeMedia, "dataUrl">;
13
- readonly readHostModel?: () => HostModelSecret | undefined;
14
- readonly writeHostCredential?: (credential: Credential) => void;
13
+ readonly readHostModel?: () => HostModelSecret | undefined | Promise<HostModelSecret | undefined>;
14
+ readonly writeHostCredential?: (credential: Credential) => void | Promise<void>;
15
15
  }
16
16
  /** Node model adapter. Local provider configuration is read only when invoked. */
17
17
  export declare function piModel(options?: PiModelOptions): RuntimeModelAdapter;
@@ -13,10 +13,10 @@ const emptyUsage = () => ({
13
13
  export function piModel(options = {}) {
14
14
  return async (call, context) => {
15
15
  context.signal.throwIfAborted();
16
- const root = options.root ?? process.cwd();
17
- const stored = options.readHostModel?.();
16
+ const root = options.root ?? "";
17
+ const stored = await options.readHostModel?.();
18
18
  if (!stored)
19
- throw new Error("Model provider is not configured. Start nylorun dev in a terminal, or set it in Studio.");
19
+ throw new Error("Model provider is not configured. Set it in Studio, or run nylo configure (npx @nylorun/cli configure).");
20
20
  const requested = call.model?.id;
21
21
  const selection = {
22
22
  provider: stored.provider,
@@ -28,7 +28,7 @@ export function piModel(options = {}) {
28
28
  const registry = modelsFor(selection, hostCredentialStore(stored, options.writeHostCredential), { environment: false });
29
29
  const selected = registry.getModel(selection.provider, selection.model);
30
30
  if (!selected)
31
- throw new Error("Unknown model. Run nylorun configure.");
31
+ throw new Error("Unknown model. Run nylo configure (npx @nylorun/cli configure).");
32
32
  const signatureFor = (part) => {
33
33
  const metadata = "providerMetadata" in part ? part.providerMetadata?.pi : undefined;
34
34
  if (!metadata || typeof metadata !== "object" || Array.isArray(metadata))
@@ -290,12 +290,12 @@ function hostCredentialStore(stored, write) {
290
290
  const next = await fn(current);
291
291
  if (next === undefined)
292
292
  return current;
293
- write?.(next);
293
+ await write?.(next);
294
294
  current = next;
295
295
  return next;
296
296
  },
297
297
  async delete() {
298
- throw new Error("Replace the model provider through nylorun dev or Studio.");
298
+ throw new Error("Replace the model provider in Studio, or with nylo configure.");
299
299
  },
300
300
  };
301
301
  }
@@ -1 +1,5 @@
1
- export declare function projectSecrets(root?: string): readonly string[];
1
+ /**
2
+ * Collect likely secret strings from an explicit env map and optional auth.json
3
+ * under `root`. Callers must pass values; no ambient environment or cwd (A7).
4
+ */
5
+ export declare function projectSecrets(root: string, env?: Readonly<Record<string, string | undefined>>): readonly string[];
@@ -1,7 +1,11 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- export function projectSecrets(root = process.cwd()) {
4
- const values = Object.entries(process.env)
3
+ /**
4
+ * Collect likely secret strings from an explicit env map and optional auth.json
5
+ * under `root`. Callers must pass values; no ambient environment or cwd (A7).
6
+ */
7
+ export function projectSecrets(root, env = {}) {
8
+ const values = Object.entries(env)
5
9
  .filter(([key]) => /key|token|secret|password|credential/i.test(key))
6
10
  .flatMap(([, value]) => (value ? [value] : []));
7
11
  for (const directory of [".nylorun", ".env"])
@@ -1,5 +1,5 @@
1
1
  import { type AgentManifest, type CapabilityManifest, type SandboxToolName } from "@nylorun/core/define";
2
- import type { Store } from "../core/store.js";
2
+ import type { SessionStore } from "../store/types.js";
3
3
  import { type SandboxSelection, type SandboxSelectionReport } from "./select.js";
4
4
  import { type SandboxToolOutcome } from "./tools.js";
5
5
  import type { SandboxBackend } from "./types.js";
@@ -18,16 +18,23 @@ interface SandboxRecord {
18
18
  createdAt: string;
19
19
  updatedAt: string;
20
20
  }
21
+ export interface SandboxSweepOptions {
22
+ /** The sweep's clock, in ms since the epoch. Defaults to `Date.now()`. */
23
+ readonly now?: number;
24
+ /** Whether a session still exists; sandboxes of deleted sessions are removed. */
25
+ readonly sessionExists: (sessionId: string) => boolean | Promise<boolean>;
26
+ }
21
27
  export interface SandboxManagerOptions {
22
28
  /** Distinguishes this Runtime's sandboxes from other Runtimes on the same machine. */
23
29
  readonly scope: string;
24
- readonly store: Store;
30
+ readonly store: SessionStore;
25
31
  readonly backends: readonly SandboxBackend[];
26
- /** `auto`, `microsandbox` or `virtual`. Undefined means an invalid NYLORUN_SANDBOX value. */
32
+ /** `auto` or `virtual`. Undefined means an invalid NYLORUN_SANDBOX value. */
27
33
  readonly preference: string | undefined;
28
34
  /** Delete sandboxes on close (the Runtime's store does not outlive the process). */
29
35
  readonly ephemeral: boolean;
30
- readonly emit: (sessionId: string, turnId: string | null, type: string, payload: unknown) => void;
36
+ /** Writes a session event in its own transaction; never called inside one. */
37
+ readonly emit: (sessionId: string, turnId: string | null, type: string, payload: unknown) => void | Promise<void>;
31
38
  }
32
39
  /** The sandbox capability that owns this tool call, when the call is a built-in sandbox tool. */
33
40
  export declare function sandboxCapabilityOf(manifest: AgentManifest | undefined, capabilityId: string | undefined, toolName: string | undefined): CapabilityManifest | undefined;
@@ -37,25 +44,36 @@ export declare class SandboxManager {
37
44
  private readonly live;
38
45
  private readonly prefix;
39
46
  private closing;
47
+ private reconciled;
40
48
  constructor(options: SandboxManagerOptions);
49
+ /**
50
+ * One Tenant sweep pass:
51
+ * - stops sandboxes idle for at least their idle timeout (their files remain; the next
52
+ * call reattaches);
53
+ * - marks records whose compute this process does not hold as stopped (compute is gone
54
+ * after a Runtime restart);
55
+ * - on the first pass of this process, removes sandboxes whose session no longer exists
56
+ * (skipped for ephemeral Runtimes, which delete theirs on close).
57
+ */
58
+ sweep(options: SandboxSweepOptions): Promise<void>;
41
59
  /** Backends are probed once, on first use, and the choice holds for the life of the Runtime. */
42
60
  get ready(): Promise<SandboxSelection>;
43
61
  /** Whether any sandbox was ever recorded, so startup can skip probing for agents without one. */
44
- hasRecords(): boolean;
62
+ hasRecords(): Promise<boolean>;
45
63
  keyOf(sessionId: string): string;
46
64
  report(): Promise<SandboxSelectionReport & {
47
65
  readonly defaultImage: string;
48
66
  }>;
49
67
  run(session: SandboxSessionRef, capability: CapabilityManifest, toolName: SandboxToolName, input: unknown, signal: AbortSignal): Promise<SandboxToolOutcome>;
50
68
  private execute;
69
+ private executeOpen;
51
70
  private open;
52
- private armIdle;
53
71
  private stop;
54
72
  private record;
55
73
  /** The sandbox record for a session, if one was ever created. */
56
- status(sessionId: string): SandboxRecord | undefined;
74
+ status(sessionId: string): Promise<SandboxRecord | undefined>;
57
75
  /** Delete sandboxes this Runtime owns whose session no longer exists. */
58
- reconcile(sessionExists: (sessionId: string) => boolean): Promise<void>;
76
+ reconcile(sessionExists: (sessionId: string) => boolean | Promise<boolean>): Promise<void>;
59
77
  close(): Promise<void>;
60
78
  }
61
79
  export {};
@@ -2,6 +2,10 @@
2
2
  * Sandbox Manager: owns sandbox lifecycle for the Runtime. One sandbox per session, created on
3
3
  * the first sandbox tool call, stopped after its idle timeout, reattached on the next call.
4
4
  * Commands against one sandbox run one at a time.
5
+ *
6
+ * The manager keeps no timers. The Tenant sweep calls `sweep`, which stops sandboxes idle
7
+ * past their timeout, marks records of compute this process no longer holds as stopped
8
+ * (after a restart), and, once per process, removes sandboxes whose session is gone.
5
9
  */
6
10
  import { createHash } from "node:crypto";
7
11
  import { isSandboxToolName, } from "@nylorun/core/define";
@@ -24,15 +28,53 @@ export class SandboxManager {
24
28
  live = new Map();
25
29
  prefix;
26
30
  closing = false;
31
+ reconciled = false;
27
32
  constructor(options) {
28
33
  this.options = options;
29
34
  this.prefix = `nylorun-${options.scope}-`;
30
- // Compute is gone after a Runtime restart; files remain and the next call reattaches.
31
- options.store.tx(() => {
32
- for (const record of options.store.all("sandboxes"))
33
- if (record.state !== "stopped")
34
- options.store.put("sandboxes", record.key, { ...record, state: "stopped" });
35
+ }
36
+ /**
37
+ * One Tenant sweep pass:
38
+ * - stops sandboxes idle for at least their idle timeout (their files remain; the next
39
+ * call reattaches);
40
+ * - marks records whose compute this process does not hold as stopped (compute is gone
41
+ * after a Runtime restart);
42
+ * - on the first pass of this process, removes sandboxes whose session no longer exists
43
+ * (skipped for ephemeral Runtimes, which delete theirs on close).
44
+ */
45
+ async sweep(options) {
46
+ if (this.closing)
47
+ return;
48
+ const now = options.now ?? Date.now();
49
+ const idle = (live) => !!live.handle && live.active === 0 && now - live.lastUsedAt >= live.idleMs;
50
+ const stops = [];
51
+ for (const [key, live] of this.live) {
52
+ if (!idle(live))
53
+ continue;
54
+ // Queued behind any call already waiting; checked again when its turn comes.
55
+ live.tail = live.tail
56
+ .then(() => (idle(live) ? this.stop(key, live) : undefined))
57
+ .catch(() => undefined);
58
+ stops.push(live.tail);
59
+ }
60
+ await Promise.all(stops);
61
+ await this.options.store.tx(async (t) => {
62
+ for (const record of await t.listSandboxes()) {
63
+ if (record.state === "stopped")
64
+ continue;
65
+ const live = this.live.get(record.key);
66
+ if (live && (live.handle || live.active > 0))
67
+ continue;
68
+ await t.put("sandboxes", record.key, { ...record, state: "stopped" });
69
+ }
35
70
  });
71
+ if (this.reconciled || this.options.ephemeral)
72
+ return;
73
+ this.reconciled = true;
74
+ if (!(await this.hasRecords()))
75
+ return;
76
+ await this.reconcile(async (sessionId) => [...this.live.values()].some((live) => live.sessionId === sessionId) ||
77
+ (await options.sessionExists(sessionId)));
36
78
  }
37
79
  /** Backends are probed once, on first use, and the choice holds for the life of the Runtime. */
38
80
  get ready() {
@@ -42,15 +84,15 @@ export class SandboxManager {
42
84
  ? selectSandboxBackend(this.options.backends, preference)
43
85
  : Promise.resolve({
44
86
  preference: "auto",
45
- reason: `NYLORUN_SANDBOX='${this.options.preference}' is not valid; use auto, microsandbox or virtual`,
87
+ reason: `NYLORUN_SANDBOX='${this.options.preference}' is not valid; use auto or virtual`,
46
88
  probes: [],
47
89
  });
48
90
  }
49
91
  return this.selection;
50
92
  }
51
93
  /** Whether any sandbox was ever recorded, so startup can skip probing for agents without one. */
52
- hasRecords() {
53
- return this.options.store.all("sandboxes").length > 0;
94
+ async hasRecords() {
95
+ return (await this.options.store.tx((t) => t.counts())).sandboxes > 0;
54
96
  }
55
97
  keyOf(sessionId) {
56
98
  return this.prefix + createHash("sha256").update(sessionId).digest("hex").slice(0, 16);
@@ -84,7 +126,14 @@ export class SandboxManager {
84
126
  };
85
127
  let live = this.live.get(key);
86
128
  if (!live) {
87
- live = { sessionId: session.id, backend, idleMs: idleMsOf(capability.sandbox), tail: Promise.resolve() };
129
+ live = {
130
+ sessionId: session.id,
131
+ backend,
132
+ idleMs: idleMsOf(capability.sandbox),
133
+ active: 0,
134
+ lastUsedAt: Date.now(),
135
+ tail: Promise.resolve(),
136
+ };
88
137
  this.live.set(key, live);
89
138
  }
90
139
  const entry = live;
@@ -97,15 +146,24 @@ export class SandboxManager {
97
146
  throw new Error("Turn cancelled");
98
147
  if (this.closing)
99
148
  throw new Error("Runtime is shutting down");
100
- clearTimeout(live.timer);
149
+ live.active += 1;
150
+ try {
151
+ return await this.executeOpen(live, session, spec, toolName, input, signal);
152
+ }
153
+ finally {
154
+ live.active -= 1;
155
+ live.lastUsedAt = Date.now();
156
+ }
157
+ }
158
+ async executeOpen(live, session, spec, toolName, input, signal) {
101
159
  if (!live.handle) {
102
160
  try {
103
161
  live.handle = await this.open(live, session, spec);
104
162
  }
105
163
  catch (error) {
106
164
  const message = error instanceof Error ? error.message : String(error);
107
- this.record(spec.key, session.id, live.backend.name, spec.image, "stopped");
108
- this.options.emit(session.id, session.activeTurnId, "sandbox.state", {
165
+ await this.record(spec.key, session.id, live.backend.name, spec.image, "stopped");
166
+ await this.options.emit(session.id, session.activeTurnId, "sandbox.state", {
109
167
  state: "stopped",
110
168
  backend: live.backend.name,
111
169
  error: message,
@@ -124,7 +182,7 @@ export class SandboxManager {
124
182
  const outcome = await runSandboxTool(live.handle, toolName, (input ?? {}), signal, (value) => {
125
183
  report = value;
126
184
  });
127
- this.options.emit(session.id, session.activeTurnId, "sandbox.exec", {
185
+ await this.options.emit(session.id, session.activeTurnId, "sandbox.exec", {
128
186
  tool: toolName,
129
187
  ...report,
130
188
  ...(report.command ? { command: report.command.slice(0, 500) } : {}),
@@ -145,38 +203,26 @@ export class SandboxManager {
145
203
  message: error instanceof Error ? error.message : String(error),
146
204
  };
147
205
  }
148
- finally {
149
- this.armIdle(spec.key, live);
150
- }
151
206
  }
152
207
  async open(live, session, spec) {
153
- const existing = this.options.store.get("sandboxes", spec.key);
208
+ const existing = await this.options.store.tx((t) => t.get("sandboxes", spec.key));
154
209
  const payload = {
155
210
  backend: live.backend.name,
156
211
  isolation: live.backend.isolation,
157
212
  image: spec.image,
158
213
  network: describeNetwork(spec.network),
159
214
  };
160
- this.record(spec.key, session.id, live.backend.name, spec.image, "creating", existing);
161
- this.options.emit(session.id, session.activeTurnId, "sandbox.state", {
215
+ await this.record(spec.key, session.id, live.backend.name, spec.image, "creating", existing);
216
+ await this.options.emit(session.id, session.activeTurnId, "sandbox.state", {
162
217
  state: "creating",
163
218
  ...payload,
164
219
  ...(existing ? { reattach: true } : {}),
165
220
  });
166
221
  const handle = await live.backend.open(spec);
167
- this.record(spec.key, session.id, live.backend.name, spec.image, "running", existing);
168
- this.options.emit(session.id, session.activeTurnId, "sandbox.state", { state: "running", ...payload });
222
+ await this.record(spec.key, session.id, live.backend.name, spec.image, "running", existing);
223
+ await this.options.emit(session.id, session.activeTurnId, "sandbox.state", { state: "running", ...payload });
169
224
  return handle;
170
225
  }
171
- armIdle(key, live) {
172
- clearTimeout(live.timer);
173
- if (this.closing)
174
- return;
175
- live.timer = setTimeout(() => {
176
- live.tail = live.tail.then(() => this.stop(key, live)).catch(() => undefined);
177
- }, live.idleMs);
178
- live.timer.unref();
179
- }
180
226
  async stop(key, live) {
181
227
  const handle = live.handle;
182
228
  if (!handle)
@@ -186,25 +232,28 @@ export class SandboxManager {
186
232
  await handle.stop();
187
233
  }
188
234
  finally {
189
- this.record(key, live.sessionId, live.backend.name, undefined, "stopped");
190
- this.options.emit(live.sessionId, null, "sandbox.state", { state: "stopped", backend: live.backend.name });
235
+ await this.record(key, live.sessionId, live.backend.name, undefined, "stopped");
236
+ await this.options.emit(live.sessionId, null, "sandbox.state", { state: "stopped", backend: live.backend.name });
191
237
  }
192
238
  }
193
- record(key, sessionId, backend, image, state, existing = this.options.store.get("sandboxes", key)) {
239
+ async record(key, sessionId, backend, image, state, known) {
194
240
  const now = new Date().toISOString();
195
- this.options.store.tx(() => this.options.store.put("sandboxes", key, {
196
- key,
197
- sessionId,
198
- backend,
199
- image: image ?? existing?.image ?? "",
200
- state,
201
- createdAt: existing?.createdAt ?? now,
202
- updatedAt: now,
203
- }));
241
+ await this.options.store.tx(async (t) => {
242
+ const existing = known ?? (await t.get("sandboxes", key));
243
+ await t.put("sandboxes", key, {
244
+ key,
245
+ sessionId,
246
+ backend,
247
+ image: image ?? existing?.image ?? "",
248
+ state,
249
+ createdAt: existing?.createdAt ?? now,
250
+ updatedAt: now,
251
+ });
252
+ });
204
253
  }
205
254
  /** The sandbox record for a session, if one was ever created. */
206
255
  status(sessionId) {
207
- return this.options.store.get("sandboxes", this.keyOf(sessionId));
256
+ return this.options.store.tx((t) => t.get("sandboxes", this.keyOf(sessionId)));
208
257
  }
209
258
  /** Delete sandboxes this Runtime owns whose session no longer exists. */
210
259
  async reconcile(sessionExists) {
@@ -212,11 +261,11 @@ export class SandboxManager {
212
261
  if (!backend)
213
262
  return;
214
263
  for (const key of await backend.list(this.prefix)) {
215
- const record = this.options.store.get("sandboxes", key);
216
- if (record && sessionExists(record.sessionId))
264
+ const record = await this.options.store.tx((t) => t.get("sandboxes", key));
265
+ if (record && (await sessionExists(record.sessionId)))
217
266
  continue;
218
267
  await backend.remove(key);
219
- this.options.store.tx(() => this.options.store.delete("sandboxes", key));
268
+ await this.options.store.tx((t) => t.delete("sandboxes", key));
220
269
  }
221
270
  }
222
271
  async close() {
@@ -224,7 +273,6 @@ export class SandboxManager {
224
273
  if (!this.selection)
225
274
  return;
226
275
  const tasks = [...this.live.entries()].map(async ([key, live]) => {
227
- clearTimeout(live.timer);
228
276
  await live.tail;
229
277
  if (this.options.ephemeral) {
230
278
  live.handle = undefined;
@@ -7,7 +7,7 @@ export interface SandboxSelection {
7
7
  readonly reason: string;
8
8
  readonly probes: readonly SandboxProbe[];
9
9
  }
10
- /** Serializable view of a selection for the CLI and `GET /v1/host/sandbox`. */
10
+ /** Serializable view of a selection for the CLI and `GET /v1/tenant/sandbox`. */
11
11
  export interface SandboxSelectionReport {
12
12
  readonly preference: SandboxPreference;
13
13
  readonly backend: SandboxBackendName | null;
@@ -15,7 +15,7 @@ export interface SandboxSelectionReport {
15
15
  readonly reason: string;
16
16
  readonly probes: readonly SandboxProbe[];
17
17
  }
18
- /** The default order: strongest isolation first, then the backend that always works. */
18
+ /** The backends a Runtime probes, in order. `auto` picks the first available one. */
19
19
  export declare function defaultSandboxBackends(options: {
20
20
  readonly root: string;
21
21
  }): SandboxBackend[];
@@ -27,9 +27,10 @@ export declare function parseSandboxPreference(value: string | undefined): Sandb
27
27
  export declare function selectSandboxBackend(backends: readonly SandboxBackend[], preference: SandboxPreference): Promise<SandboxSelection>;
28
28
  export declare function reportSelection(selection: SandboxSelection): SandboxSelectionReport;
29
29
  /**
30
- * Probe this machine the way a Runtime would at startup. Used by `nylorun doctor sandbox`.
31
- * Nothing is created or booted.
30
+ * Probe backends the way a Tenant Runtime would at open.
31
+ * Callers must pass an explicit probe root; no ambient env or OS temp (A7).
32
32
  */
33
33
  export declare function probeSandboxBackends(options?: {
34
34
  readonly preference?: string;
35
+ readonly root?: string;
35
36
  }): Promise<SandboxSelectionReport>;