@nylorun/runtime 0.6.0-beta → 0.8.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.
- package/CHANGELOG.md +48 -0
- package/README.md +9 -5
- package/dist/adapters/sandbox/microsandbox.d.ts +2 -0
- package/dist/adapters/sandbox/microsandbox.js +238 -0
- package/dist/adapters/sandbox/virtual.d.ts +4 -0
- package/dist/adapters/sandbox/virtual.js +125 -0
- package/dist/configuration.d.ts +1 -1
- package/dist/configuration.js +1 -1
- package/dist/core/executors.d.ts +32 -0
- package/dist/core/executors.js +60 -0
- package/dist/core/main.js +7 -12
- package/dist/core/runtime.d.ts +41 -1
- package/dist/core/runtime.js +594 -53
- package/dist/core/store.d.ts +13 -0
- package/dist/core/store.js +95 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.js +0 -1
- package/dist/mcp/connect.d.ts +31 -0
- package/dist/mcp/connect.js +190 -0
- package/dist/mcp/pool.d.ts +45 -0
- package/dist/mcp/pool.js +143 -0
- package/dist/mcp/snapshot.d.ts +25 -0
- package/dist/mcp/snapshot.js +10 -0
- package/dist/mcp/stdio.d.ts +15 -0
- package/dist/mcp/stdio.js +80 -0
- package/dist/model/catalog.d.ts +12 -0
- package/dist/model/catalog.js +29 -0
- package/dist/model/models.d.ts +3 -1
- package/dist/model/models.js +4 -2
- package/dist/model/pi-model.d.ts +9 -3
- package/dist/model/pi-model.js +47 -11
- package/dist/model/settings.d.ts +0 -2
- package/dist/model/settings.js +0 -47
- package/dist/node/index.d.ts +1 -1
- package/dist/node/index.js +1 -1
- package/dist/sandbox/manager.d.ts +61 -0
- package/dist/sandbox/manager.js +239 -0
- package/dist/sandbox/policy.d.ts +13 -0
- package/dist/sandbox/policy.js +67 -0
- package/dist/sandbox/select.d.ts +35 -0
- package/dist/sandbox/select.js +76 -0
- package/dist/sandbox/tools.d.ts +25 -0
- package/dist/sandbox/tools.js +165 -0
- package/dist/sandbox/types.d.ts +66 -0
- package/dist/vault/crypto.d.ts +12 -0
- package/dist/vault/crypto.js +80 -0
- package/dist/vault/error.d.ts +4 -0
- package/dist/vault/error.js +7 -0
- package/dist/vault/kek.d.ts +6 -0
- package/dist/vault/kek.js +26 -0
- package/dist/vault/service.d.ts +99 -0
- package/dist/vault/service.js +825 -0
- package/dist/vault/url.d.ts +3 -0
- package/dist/vault/url.js +27 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +3 -0
- package/package.json +12 -6
- package/dist/config.d.ts +0 -20
- package/dist/model/defaults.d.ts +0 -17
- package/dist/model/defaults.js +0 -21
- package/dist/model/http-model.d.ts +0 -12
- package/dist/model/http-model.js +0 -299
- package/dist/node/local-sessions.d.ts +0 -5
- package/dist/node/local-sessions.js +0 -174
- package/dist/server/ag-ui.d.ts +0 -8
- package/dist/server/ag-ui.js +0 -76
- package/dist/server/delivery.d.ts +0 -24
- package/dist/server/delivery.js +0 -107
- package/dist/server/digests.d.ts +0 -13
- package/dist/server/digests.js +0 -39
- package/dist/server/host.d.ts +0 -70
- package/dist/server/host.js +0 -647
- package/dist/session/api.d.ts +0 -10
- package/dist/session/api.js +0 -15
- package/dist/session/default.d.ts +0 -5
- package/dist/session/default.js +0 -30
- package/dist/session/handle.d.ts +0 -27
- package/dist/session/handle.js +0 -199
- package/dist/session/index.d.ts +0 -2
- package/dist/session/index.js +0 -2
- package/dist/sessions/host.d.ts +0 -39
- package/dist/sessions/host.js +0 -359
- package/dist/sessions/store.d.ts +0 -41
- package/dist/sessions/store.js +0 -33
- /package/dist/{config.js → sandbox/types.js} +0 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { mkdirSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
3
|
+
const ROOT_TOKEN = "${PLUGIN_ROOT}";
|
|
4
|
+
const DATA_TOKEN = "${PLUGIN_DATA}";
|
|
5
|
+
/** Single-pass replacement. Text inserted for a token is not scanned again. */
|
|
6
|
+
export function expandPluginPlaceholders(value, pluginRoot, pluginData) {
|
|
7
|
+
let out = "";
|
|
8
|
+
for (let index = 0; index < value.length;) {
|
|
9
|
+
if (value.startsWith(ROOT_TOKEN, index)) {
|
|
10
|
+
out += pluginRoot;
|
|
11
|
+
index += ROOT_TOKEN.length;
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
if (value.startsWith(DATA_TOKEN, index)) {
|
|
15
|
+
out += pluginData;
|
|
16
|
+
index += DATA_TOKEN.length;
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
out += value[index];
|
|
20
|
+
index += 1;
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
export function prepareStdioLaunch(server, options) {
|
|
25
|
+
const pluginData = options.pluginData;
|
|
26
|
+
mkdirSync(pluginData, { recursive: true });
|
|
27
|
+
const pluginRoot = options.pluginRoot;
|
|
28
|
+
if (needsPluginRoot(server) && !pluginRoot)
|
|
29
|
+
throw new Error("stdio server requires a plugin root");
|
|
30
|
+
for (const key of Object.keys(server.env ?? {})) {
|
|
31
|
+
if (key === "PLUGIN_ROOT" || key === "PLUGIN_DATA")
|
|
32
|
+
throw new Error(`stdio env must not set ${key}`);
|
|
33
|
+
}
|
|
34
|
+
const command = server.command.startsWith("./")
|
|
35
|
+
? resolve(pluginRoot, server.command)
|
|
36
|
+
: server.command;
|
|
37
|
+
const args = (server.args ?? []).map((arg) => expand(arg, pluginRoot, pluginData));
|
|
38
|
+
const cwd = server.cwd
|
|
39
|
+
? resolveContainedCwd(server.cwd, pluginRoot, pluginData)
|
|
40
|
+
: (pluginRoot ?? pluginData);
|
|
41
|
+
const env = {};
|
|
42
|
+
for (const [key, value] of Object.entries(server.env ?? {}))
|
|
43
|
+
env[key] = expand(value, pluginRoot, pluginData);
|
|
44
|
+
if (pluginRoot)
|
|
45
|
+
env.PLUGIN_ROOT = pluginRoot;
|
|
46
|
+
env.PLUGIN_DATA = pluginData;
|
|
47
|
+
return { command, args, cwd, env };
|
|
48
|
+
}
|
|
49
|
+
function needsPluginRoot(server) {
|
|
50
|
+
if (server.command.startsWith("./"))
|
|
51
|
+
return true;
|
|
52
|
+
const values = [
|
|
53
|
+
...(server.args ?? []),
|
|
54
|
+
...Object.values(server.env ?? {}),
|
|
55
|
+
...(server.cwd === undefined ? [] : [server.cwd]),
|
|
56
|
+
];
|
|
57
|
+
return values.some((value) => value.includes(ROOT_TOKEN));
|
|
58
|
+
}
|
|
59
|
+
function expand(value, pluginRoot, pluginData) {
|
|
60
|
+
if (value.includes(ROOT_TOKEN) && !pluginRoot)
|
|
61
|
+
throw new Error("stdio server requires a plugin root");
|
|
62
|
+
return expandPluginPlaceholders(value, pluginRoot ?? "", pluginData);
|
|
63
|
+
}
|
|
64
|
+
function resolveContainedCwd(cwd, pluginRoot, pluginData) {
|
|
65
|
+
const expanded = expand(cwd, pluginRoot, pluginData);
|
|
66
|
+
const base = cwdTargetsData(cwd) ? pluginData : pluginRoot;
|
|
67
|
+
if (!base)
|
|
68
|
+
throw new Error("stdio server requires a plugin root");
|
|
69
|
+
const absolute = isAbsolute(expanded) ? expanded : resolve(base, expanded);
|
|
70
|
+
if (!isInside(base, absolute))
|
|
71
|
+
throw new Error(`Working directory escapes ${base}`);
|
|
72
|
+
return absolute;
|
|
73
|
+
}
|
|
74
|
+
function cwdTargetsData(cwd) {
|
|
75
|
+
return cwd === DATA_TOKEN || cwd.startsWith(`${DATA_TOKEN}/`);
|
|
76
|
+
}
|
|
77
|
+
function isInside(root, target) {
|
|
78
|
+
const rel = relative(root, target);
|
|
79
|
+
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
|
|
80
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type HostModelCatalog = {
|
|
2
|
+
providers: {
|
|
3
|
+
id: string;
|
|
4
|
+
name: string;
|
|
5
|
+
models: {
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
}[];
|
|
9
|
+
}[];
|
|
10
|
+
};
|
|
11
|
+
/** Public provider and model names. No credentials. */
|
|
12
|
+
export declare function hostModelCatalog(): HostModelCatalog;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { modelsFor } from "./models.js";
|
|
2
|
+
const emptyStore = {
|
|
3
|
+
async read() {
|
|
4
|
+
return undefined;
|
|
5
|
+
},
|
|
6
|
+
async list() {
|
|
7
|
+
return [];
|
|
8
|
+
},
|
|
9
|
+
async modify(_providerId, fn) {
|
|
10
|
+
return fn(undefined);
|
|
11
|
+
},
|
|
12
|
+
async delete() { },
|
|
13
|
+
};
|
|
14
|
+
/** Public provider and model names. No credentials. */
|
|
15
|
+
export function hostModelCatalog() {
|
|
16
|
+
const models = modelsFor({ provider: "", model: "" }, emptyStore, {
|
|
17
|
+
environment: false,
|
|
18
|
+
});
|
|
19
|
+
return {
|
|
20
|
+
providers: models.getProviders().map((provider) => ({
|
|
21
|
+
id: provider.id,
|
|
22
|
+
name: provider.name,
|
|
23
|
+
models: models.getModels(provider.id).map((model) => ({
|
|
24
|
+
id: model.id,
|
|
25
|
+
name: model.name,
|
|
26
|
+
})),
|
|
27
|
+
})),
|
|
28
|
+
};
|
|
29
|
+
}
|
package/dist/model/models.d.ts
CHANGED
|
@@ -6,4 +6,6 @@ export type Selection = Readonly<{
|
|
|
6
6
|
baseUrl: string;
|
|
7
7
|
}>;
|
|
8
8
|
}>;
|
|
9
|
-
export declare function modelsFor(selection: Selection, credentials: CredentialStore
|
|
9
|
+
export declare function modelsFor(selection: Selection, credentials: CredentialStore, options?: {
|
|
10
|
+
environment?: boolean;
|
|
11
|
+
}): import("@earendil-works/pi-ai").MutableModels;
|
package/dist/model/models.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createProvider, defaultProviderAuthContext, envApiKeyAuth, } 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
|
-
export function modelsFor(selection, credentials) {
|
|
4
|
+
export function modelsFor(selection, credentials, options = {}) {
|
|
5
5
|
const environmentFirst = {
|
|
6
6
|
async read(providerId, options) {
|
|
7
7
|
const explicit = process.env.MODEL_PROVIDER_API_KEY;
|
|
@@ -23,7 +23,9 @@ export function modelsFor(selection, credentials) {
|
|
|
23
23
|
modify: (id, fn, options) => credentials.modify(id, fn, options),
|
|
24
24
|
delete: (id, options) => credentials.delete(id, options),
|
|
25
25
|
};
|
|
26
|
-
const models = builtinModels({
|
|
26
|
+
const models = builtinModels({
|
|
27
|
+
credentials: options.environment === false ? credentials : environmentFirst,
|
|
28
|
+
});
|
|
27
29
|
if (!selection.custom)
|
|
28
30
|
return models;
|
|
29
31
|
const model = {
|
package/dist/model/pi-model.d.ts
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
|
+
import type { Credential } from "@earendil-works/pi-ai";
|
|
1
2
|
import type { RuntimeModelAdapter } from "../contracts.js";
|
|
2
3
|
import type { RuntimeMedia } from "../adapters/media.js";
|
|
3
|
-
import {
|
|
4
|
+
import type { HostModelSecret } from "../vault/service.js";
|
|
5
|
+
export interface ModelPreview {
|
|
6
|
+
readonly invocationId: string;
|
|
7
|
+
readonly text: string;
|
|
8
|
+
}
|
|
4
9
|
export interface PiModelOptions {
|
|
5
10
|
readonly root?: string;
|
|
6
|
-
readonly onPreview?: (preview:
|
|
7
|
-
readonly selection?: Selection;
|
|
11
|
+
readonly onPreview?: (preview: ModelPreview) => void;
|
|
8
12
|
readonly media?: Pick<RuntimeMedia, "dataUrl">;
|
|
13
|
+
readonly readHostModel?: () => HostModelSecret | undefined;
|
|
14
|
+
readonly writeHostCredential?: (credential: Credential) => void;
|
|
9
15
|
}
|
|
10
16
|
/** Node model adapter. Local provider configuration is read only when invoked. */
|
|
11
17
|
export declare function piModel(options?: PiModelOptions): RuntimeModelAdapter;
|
package/dist/model/pi-model.js
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
import { join } from "node:path";
|
|
2
1
|
import { scrub } from "../redact.js";
|
|
3
|
-
import { ProjectCredentialStore } from "./auth-store.js";
|
|
4
2
|
import { modelsFor } from "./models.js";
|
|
5
|
-
import {
|
|
3
|
+
import { projectSecrets } from "./settings.js";
|
|
6
4
|
const emptyUsage = () => ({
|
|
7
5
|
input: 0,
|
|
8
6
|
output: 0,
|
|
@@ -16,15 +14,18 @@ export function piModel(options = {}) {
|
|
|
16
14
|
return async (call, context) => {
|
|
17
15
|
context.signal.throwIfAborted();
|
|
18
16
|
const root = options.root ?? process.cwd();
|
|
19
|
-
const
|
|
17
|
+
const stored = options.readHostModel?.();
|
|
18
|
+
if (!stored)
|
|
19
|
+
throw new Error("Model provider is not configured. Start nylorun dev in a terminal, or set it in Studio.");
|
|
20
20
|
const requested = call.model?.id;
|
|
21
21
|
const selection = {
|
|
22
|
-
|
|
23
|
-
model: requested?.startsWith(`${
|
|
24
|
-
? requested.slice(
|
|
25
|
-
: (requested ??
|
|
22
|
+
provider: stored.provider,
|
|
23
|
+
model: requested?.startsWith(`${stored.provider}/`)
|
|
24
|
+
? requested.slice(stored.provider.length + 1)
|
|
25
|
+
: (requested ?? stored.model),
|
|
26
|
+
...(stored.baseUrl ? { custom: { baseUrl: stored.baseUrl } } : {}),
|
|
26
27
|
};
|
|
27
|
-
const registry = modelsFor(selection,
|
|
28
|
+
const registry = modelsFor(selection, hostCredentialStore(stored, options.writeHostCredential), { environment: false });
|
|
28
29
|
const selected = registry.getModel(selection.provider, selection.model);
|
|
29
30
|
if (!selected)
|
|
30
31
|
throw new Error("Unknown model. Run nylorun configure.");
|
|
@@ -176,7 +177,10 @@ export function piModel(options = {}) {
|
|
|
176
177
|
messages,
|
|
177
178
|
tools,
|
|
178
179
|
};
|
|
179
|
-
const secrets =
|
|
180
|
+
const secrets = [
|
|
181
|
+
...projectSecrets(root),
|
|
182
|
+
...credentialSecrets(stored.credential),
|
|
183
|
+
];
|
|
180
184
|
// Publish only portable references, never the materialized provider image bytes.
|
|
181
185
|
context.reportPreparedCall?.({
|
|
182
186
|
adapter: "runtime.pi-ai",
|
|
@@ -265,7 +269,39 @@ export function piModel(options = {}) {
|
|
|
265
269
|
};
|
|
266
270
|
}
|
|
267
271
|
catch (error) {
|
|
268
|
-
throw new Error(String(scrub(error instanceof Error ? error.message : String(error),
|
|
272
|
+
throw new Error(String(scrub(error instanceof Error ? error.message : String(error), secrets)));
|
|
269
273
|
}
|
|
270
274
|
};
|
|
271
275
|
}
|
|
276
|
+
function hostCredentialStore(stored, write) {
|
|
277
|
+
let current = stored.credential;
|
|
278
|
+
return {
|
|
279
|
+
async read(providerId) {
|
|
280
|
+
if (providerId !== stored.provider)
|
|
281
|
+
return undefined;
|
|
282
|
+
return current;
|
|
283
|
+
},
|
|
284
|
+
async list() {
|
|
285
|
+
return [{ providerId: stored.provider, type: current.type }];
|
|
286
|
+
},
|
|
287
|
+
async modify(providerId, fn) {
|
|
288
|
+
if (providerId !== stored.provider)
|
|
289
|
+
return fn(undefined);
|
|
290
|
+
const next = await fn(current);
|
|
291
|
+
if (next === undefined)
|
|
292
|
+
return current;
|
|
293
|
+
write?.(next);
|
|
294
|
+
current = next;
|
|
295
|
+
return next;
|
|
296
|
+
},
|
|
297
|
+
async delete() {
|
|
298
|
+
throw new Error("Replace the model provider through nylorun dev or Studio.");
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
function credentialSecrets(credential) {
|
|
303
|
+
const values = [credential.key, credential.access, credential.refresh];
|
|
304
|
+
if (credential.env)
|
|
305
|
+
values.push(...Object.values(credential.env));
|
|
306
|
+
return values.filter((value) => Boolean(value));
|
|
307
|
+
}
|
package/dist/model/settings.d.ts
CHANGED
package/dist/model/settings.js
CHANGED
|
@@ -1,52 +1,5 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
export function modelSelection(root = process.cwd()) {
|
|
4
|
-
const { MODEL_PROVIDER: provider, MODEL: model, MODEL_PROVIDER_BASE_URL: baseUrl, } = process.env;
|
|
5
|
-
if (provider !== undefined || model !== undefined || baseUrl !== undefined) {
|
|
6
|
-
if (!provider?.trim() || !model?.trim())
|
|
7
|
-
throw new Error("Set both MODEL_PROVIDER and MODEL, or run nylorun configure.");
|
|
8
|
-
if (provider === "custom" && !baseUrl?.trim())
|
|
9
|
-
throw new Error("Set MODEL_PROVIDER_BASE_URL for MODEL_PROVIDER=custom.");
|
|
10
|
-
if (baseUrl && provider !== "custom")
|
|
11
|
-
throw new Error("MODEL_PROVIDER_BASE_URL requires MODEL_PROVIDER=custom.");
|
|
12
|
-
if (baseUrl) {
|
|
13
|
-
let url;
|
|
14
|
-
try {
|
|
15
|
-
url = new URL(baseUrl);
|
|
16
|
-
}
|
|
17
|
-
catch {
|
|
18
|
-
throw new Error("MODEL_PROVIDER_BASE_URL must be an HTTP(S) URL.");
|
|
19
|
-
}
|
|
20
|
-
if (!["http:", "https:"].includes(url.protocol))
|
|
21
|
-
throw new Error("MODEL_PROVIDER_BASE_URL must be an HTTP(S) URL.");
|
|
22
|
-
}
|
|
23
|
-
return { provider, model, ...(baseUrl ? { custom: { baseUrl } } : {}) };
|
|
24
|
-
}
|
|
25
|
-
try {
|
|
26
|
-
let contents;
|
|
27
|
-
try {
|
|
28
|
-
contents = readFileSync(join(root, ".env", "model.json"), "utf8");
|
|
29
|
-
}
|
|
30
|
-
catch (error) {
|
|
31
|
-
if (!(error instanceof Error) ||
|
|
32
|
-
!("code" in error) ||
|
|
33
|
-
!["ENOENT", "ENOTDIR"].includes(String(error.code)))
|
|
34
|
-
throw error;
|
|
35
|
-
contents = readFileSync(join(root, "config", "model.json"), "utf8");
|
|
36
|
-
}
|
|
37
|
-
const value = JSON.parse(contents);
|
|
38
|
-
if (typeof value.provider === "string" &&
|
|
39
|
-
value.provider &&
|
|
40
|
-
typeof value.model === "string" &&
|
|
41
|
-
value.model &&
|
|
42
|
-
(value.custom === undefined || typeof value.custom.baseUrl === "string"))
|
|
43
|
-
return value;
|
|
44
|
-
}
|
|
45
|
-
catch {
|
|
46
|
-
/* Report one actionable setup error without file or credential contents. */
|
|
47
|
-
}
|
|
48
|
-
throw new Error("Run nylorun configure to connect a model provider.");
|
|
49
|
-
}
|
|
50
3
|
export function projectSecrets(root = process.cwd()) {
|
|
51
4
|
const values = Object.entries(process.env)
|
|
52
5
|
.filter(([key]) => /key|token|secret|password|credential/i.test(key))
|
package/dist/node/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export { localSessions } from "./local-sessions.js";
|
|
2
1
|
export { localMedia, MediaStore } from "../adapters/media.js";
|
|
3
2
|
export { jsonlObserver } from "../adapters/observe.js";
|
|
4
3
|
export { piModel, type PiModelOptions } from "../model/pi-model.js";
|
|
5
4
|
export { projectAsset } from "../assets.js";
|
|
5
|
+
export { probeSandboxBackends, type SandboxSelectionReport, } from "../sandbox/select.js";
|
package/dist/node/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export { localSessions } from "./local-sessions.js";
|
|
2
1
|
export { localMedia, MediaStore } from "../adapters/media.js";
|
|
3
2
|
export { jsonlObserver } from "../adapters/observe.js";
|
|
4
3
|
export { piModel } from "../model/pi-model.js";
|
|
5
4
|
export { projectAsset } from "../assets.js";
|
|
5
|
+
export { probeSandboxBackends, } from "../sandbox/select.js";
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { type AgentManifest, type CapabilityManifest, type SandboxToolName } from "@nylorun/core/define";
|
|
2
|
+
import type { Store } from "../core/store.js";
|
|
3
|
+
import { type SandboxSelection, type SandboxSelectionReport } from "./select.js";
|
|
4
|
+
import { type SandboxToolOutcome } from "./tools.js";
|
|
5
|
+
import type { SandboxBackend } from "./types.js";
|
|
6
|
+
export interface SandboxSessionRef {
|
|
7
|
+
readonly id: string;
|
|
8
|
+
readonly activeTurnId: string | null;
|
|
9
|
+
readonly manifest: AgentManifest;
|
|
10
|
+
}
|
|
11
|
+
export type SandboxState = "creating" | "running" | "stopped";
|
|
12
|
+
interface SandboxRecord {
|
|
13
|
+
key: string;
|
|
14
|
+
sessionId: string;
|
|
15
|
+
backend: string;
|
|
16
|
+
image: string;
|
|
17
|
+
state: SandboxState;
|
|
18
|
+
createdAt: string;
|
|
19
|
+
updatedAt: string;
|
|
20
|
+
}
|
|
21
|
+
export interface SandboxManagerOptions {
|
|
22
|
+
/** Distinguishes this Runtime's sandboxes from other Runtimes on the same machine. */
|
|
23
|
+
readonly scope: string;
|
|
24
|
+
readonly store: Store;
|
|
25
|
+
readonly backends: readonly SandboxBackend[];
|
|
26
|
+
/** `auto`, `microsandbox` or `virtual`. Undefined means an invalid NYLORUN_SANDBOX value. */
|
|
27
|
+
readonly preference: string | undefined;
|
|
28
|
+
/** Delete sandboxes on close (the Runtime's store does not outlive the process). */
|
|
29
|
+
readonly ephemeral: boolean;
|
|
30
|
+
readonly emit: (sessionId: string, turnId: string | null, type: string, payload: unknown) => void;
|
|
31
|
+
}
|
|
32
|
+
/** The sandbox capability that owns this tool call, when the call is a built-in sandbox tool. */
|
|
33
|
+
export declare function sandboxCapabilityOf(manifest: AgentManifest | undefined, capabilityId: string | undefined, toolName: string | undefined): CapabilityManifest | undefined;
|
|
34
|
+
export declare class SandboxManager {
|
|
35
|
+
private readonly options;
|
|
36
|
+
private selection?;
|
|
37
|
+
private readonly live;
|
|
38
|
+
private readonly prefix;
|
|
39
|
+
private closing;
|
|
40
|
+
constructor(options: SandboxManagerOptions);
|
|
41
|
+
/** Backends are probed once, on first use, and the choice holds for the life of the Runtime. */
|
|
42
|
+
get ready(): Promise<SandboxSelection>;
|
|
43
|
+
/** Whether any sandbox was ever recorded, so startup can skip probing for agents without one. */
|
|
44
|
+
hasRecords(): boolean;
|
|
45
|
+
keyOf(sessionId: string): string;
|
|
46
|
+
report(): Promise<SandboxSelectionReport & {
|
|
47
|
+
readonly defaultImage: string;
|
|
48
|
+
}>;
|
|
49
|
+
run(session: SandboxSessionRef, capability: CapabilityManifest, toolName: SandboxToolName, input: unknown, signal: AbortSignal): Promise<SandboxToolOutcome>;
|
|
50
|
+
private execute;
|
|
51
|
+
private open;
|
|
52
|
+
private armIdle;
|
|
53
|
+
private stop;
|
|
54
|
+
private record;
|
|
55
|
+
/** The sandbox record for a session, if one was ever created. */
|
|
56
|
+
status(sessionId: string): SandboxRecord | undefined;
|
|
57
|
+
/** Delete sandboxes this Runtime owns whose session no longer exists. */
|
|
58
|
+
reconcile(sessionExists: (sessionId: string) => boolean): Promise<void>;
|
|
59
|
+
close(): Promise<void>;
|
|
60
|
+
}
|
|
61
|
+
export {};
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sandbox Manager: owns sandbox lifecycle for the Runtime. One sandbox per session, created on
|
|
3
|
+
* the first sandbox tool call, stopped after its idle timeout, reattached on the next call.
|
|
4
|
+
* Commands against one sandbox run one at a time.
|
|
5
|
+
*/
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { isSandboxToolName, } from "@nylorun/core/define";
|
|
8
|
+
import { DEFAULT_SANDBOX_CPUS, DEFAULT_SANDBOX_IMAGE, describeNetwork, idleMsOf, memoryMiBOf, resolveNetwork, } from "./policy.js";
|
|
9
|
+
import { parseSandboxPreference, reportSelection, selectSandboxBackend, } from "./select.js";
|
|
10
|
+
import { runSandboxTool } from "./tools.js";
|
|
11
|
+
const READ_TOOLS = new Set(["read", "grep", "glob"]);
|
|
12
|
+
/** The sandbox capability that owns this tool call, when the call is a built-in sandbox tool. */
|
|
13
|
+
export function sandboxCapabilityOf(manifest, capabilityId, toolName) {
|
|
14
|
+
if (!manifest || !capabilityId || !toolName || !isSandboxToolName(toolName))
|
|
15
|
+
return undefined;
|
|
16
|
+
const capability = manifest.capabilities.find((item) => item.id === capabilityId);
|
|
17
|
+
return capability?.sandbox && capability.tools?.some((tool) => tool.name === toolName)
|
|
18
|
+
? capability
|
|
19
|
+
: undefined;
|
|
20
|
+
}
|
|
21
|
+
export class SandboxManager {
|
|
22
|
+
options;
|
|
23
|
+
selection;
|
|
24
|
+
live = new Map();
|
|
25
|
+
prefix;
|
|
26
|
+
closing = false;
|
|
27
|
+
constructor(options) {
|
|
28
|
+
this.options = options;
|
|
29
|
+
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
|
+
/** Backends are probed once, on first use, and the choice holds for the life of the Runtime. */
|
|
38
|
+
get ready() {
|
|
39
|
+
if (!this.selection) {
|
|
40
|
+
const preference = parseSandboxPreference(this.options.preference);
|
|
41
|
+
this.selection = preference
|
|
42
|
+
? selectSandboxBackend(this.options.backends, preference)
|
|
43
|
+
: Promise.resolve({
|
|
44
|
+
preference: "auto",
|
|
45
|
+
reason: `NYLORUN_SANDBOX='${this.options.preference}' is not valid; use auto, microsandbox or virtual`,
|
|
46
|
+
probes: [],
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return this.selection;
|
|
50
|
+
}
|
|
51
|
+
/** 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;
|
|
54
|
+
}
|
|
55
|
+
keyOf(sessionId) {
|
|
56
|
+
return this.prefix + createHash("sha256").update(sessionId).digest("hex").slice(0, 16);
|
|
57
|
+
}
|
|
58
|
+
async report() {
|
|
59
|
+
return { ...reportSelection(await this.ready), defaultImage: DEFAULT_SANDBOX_IMAGE };
|
|
60
|
+
}
|
|
61
|
+
async run(session, capability, toolName, input, signal) {
|
|
62
|
+
const selection = await this.ready;
|
|
63
|
+
const backend = selection.backend;
|
|
64
|
+
if (!backend)
|
|
65
|
+
return {
|
|
66
|
+
kind: "failed",
|
|
67
|
+
code: "sandbox.unavailable",
|
|
68
|
+
message: `No sandbox is available: ${selection.reason}. Run \`npx nylorun doctor sandbox\` for options.`,
|
|
69
|
+
};
|
|
70
|
+
const key = this.keyOf(session.id);
|
|
71
|
+
const spec = {
|
|
72
|
+
key,
|
|
73
|
+
image: capability.sandbox?.image ?? DEFAULT_SANDBOX_IMAGE,
|
|
74
|
+
cpus: capability.sandbox?.resources?.cpus ?? DEFAULT_SANDBOX_CPUS,
|
|
75
|
+
memoryMiB: memoryMiBOf(capability.sandbox),
|
|
76
|
+
network: resolveNetwork(capability.sandbox),
|
|
77
|
+
};
|
|
78
|
+
const unmet = backend.unmet(spec);
|
|
79
|
+
if (unmet)
|
|
80
|
+
return {
|
|
81
|
+
kind: "failed",
|
|
82
|
+
code: "sandbox.unavailable",
|
|
83
|
+
message: `This agent's sandbox cannot run on the ${backend.name} backend: ${unmet}.`,
|
|
84
|
+
};
|
|
85
|
+
let live = this.live.get(key);
|
|
86
|
+
if (!live) {
|
|
87
|
+
live = { sessionId: session.id, backend, idleMs: idleMsOf(capability.sandbox), tail: Promise.resolve() };
|
|
88
|
+
this.live.set(key, live);
|
|
89
|
+
}
|
|
90
|
+
const entry = live;
|
|
91
|
+
const task = entry.tail.then(() => this.execute(entry, session, spec, toolName, input, signal));
|
|
92
|
+
entry.tail = task.catch(() => undefined);
|
|
93
|
+
return task;
|
|
94
|
+
}
|
|
95
|
+
async execute(live, session, spec, toolName, input, signal) {
|
|
96
|
+
if (signal.aborted)
|
|
97
|
+
throw new Error("Turn cancelled");
|
|
98
|
+
if (this.closing)
|
|
99
|
+
throw new Error("Runtime is shutting down");
|
|
100
|
+
clearTimeout(live.timer);
|
|
101
|
+
if (!live.handle) {
|
|
102
|
+
try {
|
|
103
|
+
live.handle = await this.open(live, session, spec);
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
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", {
|
|
109
|
+
state: "stopped",
|
|
110
|
+
backend: live.backend.name,
|
|
111
|
+
error: message,
|
|
112
|
+
});
|
|
113
|
+
// Nothing ran yet, so a failed start is a definite outcome, not an uncertain one.
|
|
114
|
+
return {
|
|
115
|
+
kind: "failed",
|
|
116
|
+
code: "sandbox.start_failed",
|
|
117
|
+
message: `The sandbox could not start on ${live.backend.name} (image ${spec.image}): ${message}`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const started = Date.now();
|
|
122
|
+
let report = {};
|
|
123
|
+
try {
|
|
124
|
+
const outcome = await runSandboxTool(live.handle, toolName, (input ?? {}), signal, (value) => {
|
|
125
|
+
report = value;
|
|
126
|
+
});
|
|
127
|
+
this.options.emit(session.id, session.activeTurnId, "sandbox.exec", {
|
|
128
|
+
tool: toolName,
|
|
129
|
+
...report,
|
|
130
|
+
...(report.command ? { command: report.command.slice(0, 500) } : {}),
|
|
131
|
+
outcome: outcome.kind,
|
|
132
|
+
...(outcome.kind === "failed" ? { code: outcome.code } : {}),
|
|
133
|
+
durationMs: Date.now() - started,
|
|
134
|
+
});
|
|
135
|
+
return outcome;
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
// The backend itself failed; reopen on the next call.
|
|
139
|
+
live.handle = undefined;
|
|
140
|
+
if (signal.aborted || !READ_TOOLS.has(toolName))
|
|
141
|
+
throw error;
|
|
142
|
+
return {
|
|
143
|
+
kind: "failed",
|
|
144
|
+
code: "sandbox.error",
|
|
145
|
+
message: error instanceof Error ? error.message : String(error),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
this.armIdle(spec.key, live);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
async open(live, session, spec) {
|
|
153
|
+
const existing = this.options.store.get("sandboxes", spec.key);
|
|
154
|
+
const payload = {
|
|
155
|
+
backend: live.backend.name,
|
|
156
|
+
isolation: live.backend.isolation,
|
|
157
|
+
image: spec.image,
|
|
158
|
+
network: describeNetwork(spec.network),
|
|
159
|
+
};
|
|
160
|
+
this.record(spec.key, session.id, live.backend.name, spec.image, "creating", existing);
|
|
161
|
+
this.options.emit(session.id, session.activeTurnId, "sandbox.state", {
|
|
162
|
+
state: "creating",
|
|
163
|
+
...payload,
|
|
164
|
+
...(existing ? { reattach: true } : {}),
|
|
165
|
+
});
|
|
166
|
+
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 });
|
|
169
|
+
return handle;
|
|
170
|
+
}
|
|
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
|
+
async stop(key, live) {
|
|
181
|
+
const handle = live.handle;
|
|
182
|
+
if (!handle)
|
|
183
|
+
return;
|
|
184
|
+
live.handle = undefined;
|
|
185
|
+
try {
|
|
186
|
+
await handle.stop();
|
|
187
|
+
}
|
|
188
|
+
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 });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
record(key, sessionId, backend, image, state, existing = this.options.store.get("sandboxes", key)) {
|
|
194
|
+
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
|
+
}));
|
|
204
|
+
}
|
|
205
|
+
/** The sandbox record for a session, if one was ever created. */
|
|
206
|
+
status(sessionId) {
|
|
207
|
+
return this.options.store.get("sandboxes", this.keyOf(sessionId));
|
|
208
|
+
}
|
|
209
|
+
/** Delete sandboxes this Runtime owns whose session no longer exists. */
|
|
210
|
+
async reconcile(sessionExists) {
|
|
211
|
+
const { backend } = await this.ready;
|
|
212
|
+
if (!backend)
|
|
213
|
+
return;
|
|
214
|
+
for (const key of await backend.list(this.prefix)) {
|
|
215
|
+
const record = this.options.store.get("sandboxes", key);
|
|
216
|
+
if (record && sessionExists(record.sessionId))
|
|
217
|
+
continue;
|
|
218
|
+
await backend.remove(key);
|
|
219
|
+
this.options.store.tx(() => this.options.store.delete("sandboxes", key));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
async close() {
|
|
223
|
+
this.closing = true;
|
|
224
|
+
if (!this.selection)
|
|
225
|
+
return;
|
|
226
|
+
const tasks = [...this.live.entries()].map(async ([key, live]) => {
|
|
227
|
+
clearTimeout(live.timer);
|
|
228
|
+
await live.tail;
|
|
229
|
+
if (this.options.ephemeral) {
|
|
230
|
+
live.handle = undefined;
|
|
231
|
+
await live.backend.remove(key);
|
|
232
|
+
}
|
|
233
|
+
else
|
|
234
|
+
await this.stop(key, live);
|
|
235
|
+
});
|
|
236
|
+
await Promise.allSettled(tasks);
|
|
237
|
+
this.live.clear();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type SandboxManifest } from "@nylorun/core/define";
|
|
2
|
+
import type { ResolvedNetwork } from "./types.js";
|
|
3
|
+
/** Docker Official Image: Debian with bash, coreutils and Python 3; small first pull. */
|
|
4
|
+
export declare const DEFAULT_SANDBOX_IMAGE = "python:3.13-slim";
|
|
5
|
+
export declare const DEFAULT_SANDBOX_CPUS = 2;
|
|
6
|
+
export declare const DEFAULT_SANDBOX_MEMORY_MIB = 1024;
|
|
7
|
+
export declare const DEFAULT_SANDBOX_IDLE_MS: number;
|
|
8
|
+
/** Package registries and code hosts. Concrete names so every backend can express them. */
|
|
9
|
+
export declare const DEV_PRESET_HOSTS: readonly string[];
|
|
10
|
+
export declare function resolveNetwork(manifest: SandboxManifest | undefined): ResolvedNetwork;
|
|
11
|
+
export declare function idleMsOf(manifest: SandboxManifest | undefined): number;
|
|
12
|
+
export declare function memoryMiBOf(manifest: SandboxManifest | undefined): number;
|
|
13
|
+
export declare function describeNetwork(network: ResolvedNetwork): string;
|