@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,67 @@
|
|
|
1
|
+
import { parseSandboxDuration, parseSandboxSize, } from "@nylorun/core/define";
|
|
2
|
+
/** Docker Official Image: Debian with bash, coreutils and Python 3; small first pull. */
|
|
3
|
+
export const DEFAULT_SANDBOX_IMAGE = "python:3.13-slim";
|
|
4
|
+
export const DEFAULT_SANDBOX_CPUS = 2;
|
|
5
|
+
export const DEFAULT_SANDBOX_MEMORY_MIB = 1024;
|
|
6
|
+
export const DEFAULT_SANDBOX_IDLE_MS = 15 * 60_000;
|
|
7
|
+
/** Package registries and code hosts. Concrete names so every backend can express them. */
|
|
8
|
+
export const DEV_PRESET_HOSTS = Object.freeze([
|
|
9
|
+
// npm and yarn
|
|
10
|
+
"registry.npmjs.org",
|
|
11
|
+
"registry.yarnpkg.com",
|
|
12
|
+
// Python
|
|
13
|
+
"pypi.org",
|
|
14
|
+
"files.pythonhosted.org",
|
|
15
|
+
// Rust
|
|
16
|
+
"crates.io",
|
|
17
|
+
"index.crates.io",
|
|
18
|
+
"static.crates.io",
|
|
19
|
+
// Go
|
|
20
|
+
"proxy.golang.org",
|
|
21
|
+
"sum.golang.org",
|
|
22
|
+
// Debian and Ubuntu package mirrors
|
|
23
|
+
"deb.debian.org",
|
|
24
|
+
"security.debian.org",
|
|
25
|
+
"archive.ubuntu.com",
|
|
26
|
+
"security.ubuntu.com",
|
|
27
|
+
"ports.ubuntu.com",
|
|
28
|
+
// Code hosts
|
|
29
|
+
"github.com",
|
|
30
|
+
"api.github.com",
|
|
31
|
+
"codeload.github.com",
|
|
32
|
+
"objects.githubusercontent.com",
|
|
33
|
+
"raw.githubusercontent.com",
|
|
34
|
+
"gitlab.com",
|
|
35
|
+
]);
|
|
36
|
+
export function resolveNetwork(manifest) {
|
|
37
|
+
const preset = manifest?.network?.preset ?? "dev";
|
|
38
|
+
const hosts = new Set(preset === "dev" ? DEV_PRESET_HOSTS : []);
|
|
39
|
+
const suffixes = new Set();
|
|
40
|
+
for (const entry of manifest?.network?.allow ?? []) {
|
|
41
|
+
const host = entry.toLowerCase();
|
|
42
|
+
if (host.startsWith("*."))
|
|
43
|
+
suffixes.add(host.slice(1));
|
|
44
|
+
else
|
|
45
|
+
hosts.add(host);
|
|
46
|
+
}
|
|
47
|
+
return Object.freeze({
|
|
48
|
+
preset,
|
|
49
|
+
hosts: Object.freeze([...hosts].sort()),
|
|
50
|
+
suffixes: Object.freeze([...suffixes].sort()),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
export function idleMsOf(manifest) {
|
|
54
|
+
return (manifest?.idle && parseSandboxDuration(manifest.idle)) || DEFAULT_SANDBOX_IDLE_MS;
|
|
55
|
+
}
|
|
56
|
+
export function memoryMiBOf(manifest) {
|
|
57
|
+
const bytes = manifest?.resources?.memory
|
|
58
|
+
? parseSandboxSize(manifest.resources.memory)
|
|
59
|
+
: undefined;
|
|
60
|
+
return bytes ? Math.max(128, Math.ceil(bytes / 1024 ** 2)) : DEFAULT_SANDBOX_MEMORY_MIB;
|
|
61
|
+
}
|
|
62
|
+
export function describeNetwork(network) {
|
|
63
|
+
const extra = network.preset === "dev"
|
|
64
|
+
? network.hosts.length - DEV_PRESET_HOSTS.length + network.suffixes.length
|
|
65
|
+
: network.hosts.length + network.suffixes.length;
|
|
66
|
+
return extra > 0 ? `${network.preset} +${extra} host(s)` : network.preset;
|
|
67
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { SandboxBackend, SandboxBackendName, SandboxIsolation, SandboxProbe } from "./types.js";
|
|
2
|
+
export type SandboxPreference = "auto" | SandboxBackendName;
|
|
3
|
+
export interface SandboxSelection {
|
|
4
|
+
readonly preference: SandboxPreference;
|
|
5
|
+
readonly backend?: SandboxBackend;
|
|
6
|
+
/** Why this backend was chosen, or why none could be. */
|
|
7
|
+
readonly reason: string;
|
|
8
|
+
readonly probes: readonly SandboxProbe[];
|
|
9
|
+
}
|
|
10
|
+
/** Serializable view of a selection for the CLI and `GET /v1/host/sandbox`. */
|
|
11
|
+
export interface SandboxSelectionReport {
|
|
12
|
+
readonly preference: SandboxPreference;
|
|
13
|
+
readonly backend: SandboxBackendName | null;
|
|
14
|
+
readonly isolation: SandboxIsolation | null;
|
|
15
|
+
readonly reason: string;
|
|
16
|
+
readonly probes: readonly SandboxProbe[];
|
|
17
|
+
}
|
|
18
|
+
/** The default order: strongest isolation first, then the backend that always works. */
|
|
19
|
+
export declare function defaultSandboxBackends(options: {
|
|
20
|
+
readonly root: string;
|
|
21
|
+
}): SandboxBackend[];
|
|
22
|
+
export declare function parseSandboxPreference(value: string | undefined): SandboxPreference | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* Probe backends in order and pick one. An explicit preference never falls back, so a
|
|
25
|
+
* production Runtime cannot silently run with weaker isolation than it was configured for.
|
|
26
|
+
*/
|
|
27
|
+
export declare function selectSandboxBackend(backends: readonly SandboxBackend[], preference: SandboxPreference): Promise<SandboxSelection>;
|
|
28
|
+
export declare function reportSelection(selection: SandboxSelection): SandboxSelectionReport;
|
|
29
|
+
/**
|
|
30
|
+
* Probe this machine the way a Runtime would at startup. Used by `nylorun doctor sandbox`.
|
|
31
|
+
* Nothing is created or booted.
|
|
32
|
+
*/
|
|
33
|
+
export declare function probeSandboxBackends(options?: {
|
|
34
|
+
readonly preference?: string;
|
|
35
|
+
}): Promise<SandboxSelectionReport>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { tmpdir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { microsandboxBackend } from "../adapters/sandbox/microsandbox.js";
|
|
4
|
+
import { virtualBackend } from "../adapters/sandbox/virtual.js";
|
|
5
|
+
const PREFERENCES = ["auto", "microsandbox", "virtual"];
|
|
6
|
+
/** The default order: strongest isolation first, then the backend that always works. */
|
|
7
|
+
export function defaultSandboxBackends(options) {
|
|
8
|
+
return [microsandboxBackend(), virtualBackend({ root: options.root })];
|
|
9
|
+
}
|
|
10
|
+
export function parseSandboxPreference(value) {
|
|
11
|
+
const normalized = (value ?? "auto").trim().toLowerCase() || "auto";
|
|
12
|
+
return PREFERENCES.includes(normalized)
|
|
13
|
+
? normalized
|
|
14
|
+
: undefined;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Probe backends in order and pick one. An explicit preference never falls back, so a
|
|
18
|
+
* production Runtime cannot silently run with weaker isolation than it was configured for.
|
|
19
|
+
*/
|
|
20
|
+
export async function selectSandboxBackend(backends, preference) {
|
|
21
|
+
const probes = [];
|
|
22
|
+
for (const backend of backends)
|
|
23
|
+
probes.push(await backend.probe());
|
|
24
|
+
const probeOf = (name) => probes.find((probe) => probe.name === name);
|
|
25
|
+
if (preference !== "auto") {
|
|
26
|
+
const backend = backends.find((item) => item.name === preference);
|
|
27
|
+
const probe = probeOf(preference);
|
|
28
|
+
if (backend && probe?.available)
|
|
29
|
+
return { preference, backend, reason: `${preference} selected by NYLORUN_SANDBOX`, probes };
|
|
30
|
+
return {
|
|
31
|
+
preference,
|
|
32
|
+
reason: `NYLORUN_SANDBOX=${preference} but ${preference} is unavailable: ${probe?.reason ?? "unknown backend"}. Fix it, or unset NYLORUN_SANDBOX to use the best available backend`,
|
|
33
|
+
probes,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
const index = backends.findIndex((_, i) => probes[i].available);
|
|
37
|
+
if (index < 0)
|
|
38
|
+
return {
|
|
39
|
+
preference,
|
|
40
|
+
reason: `no sandbox backend is available (${probes.map((probe) => `${probe.name}: ${probe.reason}`).join("; ")})`,
|
|
41
|
+
probes,
|
|
42
|
+
};
|
|
43
|
+
const skipped = probes.slice(0, index).map((probe) => `${probe.name} unavailable: ${probe.reason}`);
|
|
44
|
+
return {
|
|
45
|
+
preference,
|
|
46
|
+
backend: backends[index],
|
|
47
|
+
reason: skipped.length ? skipped.join("; ") : probes[index].reason ?? backends[index].name,
|
|
48
|
+
probes,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export function reportSelection(selection) {
|
|
52
|
+
return {
|
|
53
|
+
preference: selection.preference,
|
|
54
|
+
backend: selection.backend?.name ?? null,
|
|
55
|
+
isolation: selection.backend?.isolation ?? null,
|
|
56
|
+
reason: selection.reason,
|
|
57
|
+
probes: selection.probes,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Probe this machine the way a Runtime would at startup. Used by `nylorun doctor sandbox`.
|
|
62
|
+
* Nothing is created or booted.
|
|
63
|
+
*/
|
|
64
|
+
export async function probeSandboxBackends(options = {}) {
|
|
65
|
+
const preference = parseSandboxPreference(options.preference ?? process.env.NYLORUN_SANDBOX);
|
|
66
|
+
const backends = defaultSandboxBackends({ root: join(tmpdir(), "nylorun-sandbox-probe") });
|
|
67
|
+
if (!preference)
|
|
68
|
+
return {
|
|
69
|
+
preference: "auto",
|
|
70
|
+
backend: null,
|
|
71
|
+
isolation: null,
|
|
72
|
+
reason: "NYLORUN_SANDBOX must be auto, microsandbox or virtual",
|
|
73
|
+
probes: await Promise.all(backends.map((backend) => backend.probe())),
|
|
74
|
+
};
|
|
75
|
+
return reportSelection(await selectSandboxBackend(backends, preference));
|
|
76
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type SandboxToolName } from "@nylorun/core/define";
|
|
2
|
+
import type { SandboxHandle } from "./types.js";
|
|
3
|
+
export type SandboxToolOutcome = {
|
|
4
|
+
readonly kind: "completed";
|
|
5
|
+
readonly output: unknown;
|
|
6
|
+
} | {
|
|
7
|
+
readonly kind: "failed";
|
|
8
|
+
readonly code: string;
|
|
9
|
+
readonly message: string;
|
|
10
|
+
};
|
|
11
|
+
/** What the Runtime reports about a tool call on the session stream. */
|
|
12
|
+
export interface SandboxToolReport {
|
|
13
|
+
readonly command?: string;
|
|
14
|
+
readonly path?: string;
|
|
15
|
+
readonly exitCode?: number;
|
|
16
|
+
readonly killed?: boolean;
|
|
17
|
+
}
|
|
18
|
+
export declare function quote(value: string): string;
|
|
19
|
+
export declare function resolvePath(path: string): string;
|
|
20
|
+
/** Keep the head and tail of long output so the model sees both the start and the error. */
|
|
21
|
+
export declare function truncateMiddle(value: string, limit?: number): {
|
|
22
|
+
text: string;
|
|
23
|
+
truncated: boolean;
|
|
24
|
+
};
|
|
25
|
+
export declare function runSandboxTool(handle: SandboxHandle, name: SandboxToolName, input: Record<string, any>, signal: AbortSignal, report: (value: SandboxToolReport) => void): Promise<SandboxToolOutcome>;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The six built-in sandbox tools, implemented once over `SandboxHandle` so every backend behaves
|
|
3
|
+
* the same. Expected problems are returned as failed outcomes; only infrastructure errors throw.
|
|
4
|
+
*/
|
|
5
|
+
import { posix } from "node:path";
|
|
6
|
+
import { SANDBOX_WORKSPACE } from "@nylorun/core/define";
|
|
7
|
+
const OUTPUT_LIMIT = 30_000;
|
|
8
|
+
const DEFAULT_TIMEOUT_S = 120;
|
|
9
|
+
const DEFAULT_READ_LINES = 2000;
|
|
10
|
+
const LINE_LIMIT = 2000;
|
|
11
|
+
const DEFAULT_GREP_RESULTS = 200;
|
|
12
|
+
const GLOB_LIMIT = 500;
|
|
13
|
+
const GLOB_SCAN_LIMIT = 20_000;
|
|
14
|
+
export function quote(value) {
|
|
15
|
+
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
16
|
+
}
|
|
17
|
+
export function resolvePath(path) {
|
|
18
|
+
return posix.normalize(path.startsWith("/") ? path : posix.join(SANDBOX_WORKSPACE, path));
|
|
19
|
+
}
|
|
20
|
+
/** Keep the head and tail of long output so the model sees both the start and the error. */
|
|
21
|
+
export function truncateMiddle(value, limit = OUTPUT_LIMIT) {
|
|
22
|
+
if (value.length <= limit)
|
|
23
|
+
return { text: value, truncated: false };
|
|
24
|
+
const half = Math.floor(limit / 2);
|
|
25
|
+
const omitted = value.length - half * 2;
|
|
26
|
+
return {
|
|
27
|
+
text: `${value.slice(0, half)}\n… [${omitted} characters omitted] …\n${value.slice(-half)}`,
|
|
28
|
+
truncated: true,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const failed = (code, message) => ({
|
|
32
|
+
kind: "failed",
|
|
33
|
+
code,
|
|
34
|
+
message,
|
|
35
|
+
});
|
|
36
|
+
export async function runSandboxTool(handle, name, input, signal, report) {
|
|
37
|
+
const exec = (command, timeoutMs = 60_000) => handle.exec({ command, cwd: SANDBOX_WORKSPACE, timeoutMs }, signal);
|
|
38
|
+
switch (name) {
|
|
39
|
+
case "bash": {
|
|
40
|
+
const command = String(input.command);
|
|
41
|
+
const timeout = (input.timeout ?? DEFAULT_TIMEOUT_S) * 1000;
|
|
42
|
+
const result = await exec(command, timeout);
|
|
43
|
+
report({ command, exitCode: result.exitCode, killed: result.killed });
|
|
44
|
+
const stdout = truncateMiddle(result.stdout);
|
|
45
|
+
const stderr = truncateMiddle(result.stderr);
|
|
46
|
+
return {
|
|
47
|
+
kind: "completed",
|
|
48
|
+
output: {
|
|
49
|
+
exitCode: result.exitCode,
|
|
50
|
+
stdout: stdout.text,
|
|
51
|
+
stderr: result.timedOut
|
|
52
|
+
? `${stderr.text}${stderr.text && !stderr.text.endsWith("\n") ? "\n" : ""}Command timed out after ${timeout / 1000}s and was killed.`
|
|
53
|
+
: stderr.text,
|
|
54
|
+
...(stdout.truncated || stderr.truncated ? { truncated: true } : {}),
|
|
55
|
+
...(result.timedOut ? { timedOut: true } : {}),
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
case "read": {
|
|
60
|
+
const path = resolvePath(String(input.path));
|
|
61
|
+
report({ path });
|
|
62
|
+
const content = await handle.readFile(path);
|
|
63
|
+
if (content === undefined)
|
|
64
|
+
return failed("sandbox.not_found", `No file at ${path}`);
|
|
65
|
+
const lines = content.split("\n");
|
|
66
|
+
if (lines.at(-1) === "")
|
|
67
|
+
lines.pop();
|
|
68
|
+
const start = (input.offset ?? 1) - 1;
|
|
69
|
+
const limit = input.limit ?? DEFAULT_READ_LINES;
|
|
70
|
+
const slice = lines.slice(start, start + limit);
|
|
71
|
+
const width = String(start + slice.length).length;
|
|
72
|
+
const text = slice
|
|
73
|
+
.map((line, index) => {
|
|
74
|
+
const clipped = line.length > LINE_LIMIT ? `${line.slice(0, LINE_LIMIT)}…` : line;
|
|
75
|
+
return `${String(start + index + 1).padStart(width, " ")}\t${clipped}`;
|
|
76
|
+
})
|
|
77
|
+
.join("\n");
|
|
78
|
+
const remaining = lines.length - (start + slice.length);
|
|
79
|
+
return {
|
|
80
|
+
kind: "completed",
|
|
81
|
+
output: lines.length === 0
|
|
82
|
+
? `${path} is empty.`
|
|
83
|
+
: remaining > 0
|
|
84
|
+
? `${text}\n… ${remaining} more line(s); read again with offset ${start + slice.length + 1}.`
|
|
85
|
+
: text,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
case "write": {
|
|
89
|
+
const path = resolvePath(String(input.path));
|
|
90
|
+
const content = String(input.content);
|
|
91
|
+
report({ path });
|
|
92
|
+
const parent = await exec(`mkdir -p ${quote(posix.dirname(path))}`);
|
|
93
|
+
if (parent.exitCode !== 0)
|
|
94
|
+
return failed("sandbox.write_failed", parent.stderr.trim() || `Could not create ${posix.dirname(path)}`);
|
|
95
|
+
await handle.writeFile(path, content);
|
|
96
|
+
return { kind: "completed", output: { path, bytes: Buffer.byteLength(content) } };
|
|
97
|
+
}
|
|
98
|
+
case "edit": {
|
|
99
|
+
const path = resolvePath(String(input.path));
|
|
100
|
+
report({ path });
|
|
101
|
+
const content = await handle.readFile(path);
|
|
102
|
+
if (content === undefined)
|
|
103
|
+
return failed("sandbox.not_found", `No file at ${path}`);
|
|
104
|
+
const oldString = String(input.old_string);
|
|
105
|
+
const newString = String(input.new_string);
|
|
106
|
+
if (oldString === newString)
|
|
107
|
+
return failed("sandbox.edit_noop", "old_string and new_string are identical");
|
|
108
|
+
const count = content.split(oldString).length - 1;
|
|
109
|
+
if (count === 0)
|
|
110
|
+
return failed("sandbox.edit_no_match", `old_string was not found in ${path}. Read the file and copy the text exactly.`);
|
|
111
|
+
if (count > 1 && input.replace_all !== true)
|
|
112
|
+
return failed("sandbox.edit_not_unique", `old_string appears ${count} times in ${path}. Add surrounding context to make it unique, or set replace_all.`);
|
|
113
|
+
await handle.writeFile(path, content.replaceAll(oldString, () => newString));
|
|
114
|
+
return { kind: "completed", output: { path, replacements: input.replace_all === true ? count : 1 } };
|
|
115
|
+
}
|
|
116
|
+
case "grep": {
|
|
117
|
+
const max = input.max_results ?? DEFAULT_GREP_RESULTS;
|
|
118
|
+
const target = input.path ? String(input.path) : ".";
|
|
119
|
+
const flags = ["-rnE", ...(input.ignore_case ? ["-i"] : [])];
|
|
120
|
+
const include = input.glob ? ` --include=${quote(String(input.glob))}` : "";
|
|
121
|
+
const command = `grep ${flags.join(" ")}${include} -e ${quote(String(input.pattern))} -- ${quote(target)} | head -n ${max + 1}`;
|
|
122
|
+
report({ command });
|
|
123
|
+
const result = await exec(command);
|
|
124
|
+
// The pipeline's exit code is head's, so a grep error shows up as stderr with no matches.
|
|
125
|
+
const lines = result.stdout.split("\n").filter(Boolean);
|
|
126
|
+
if (lines.length === 0)
|
|
127
|
+
return result.stderr.trim()
|
|
128
|
+
? failed("sandbox.grep_failed", result.stderr.trim())
|
|
129
|
+
: { kind: "completed", output: "No matches." };
|
|
130
|
+
const shown = lines.slice(0, max).map((line) => (line.length > LINE_LIMIT ? `${line.slice(0, LINE_LIMIT)}…` : line));
|
|
131
|
+
return {
|
|
132
|
+
kind: "completed",
|
|
133
|
+
output: lines.length > max ? `${shown.join("\n")}\n… more matches omitted; narrow the search.` : shown.join("\n"),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
case "glob": {
|
|
137
|
+
const root = resolvePath(input.path ? String(input.path) : ".");
|
|
138
|
+
const pattern = String(input.pattern);
|
|
139
|
+
const command = `find ${quote(root)} -type f -not -path '*/.git/*' 2>/dev/null | head -n ${GLOB_SCAN_LIMIT}`;
|
|
140
|
+
report({ command });
|
|
141
|
+
const result = await exec(command);
|
|
142
|
+
const byBasename = !pattern.includes("/");
|
|
143
|
+
const matches = [];
|
|
144
|
+
for (const file of result.stdout.split("\n")) {
|
|
145
|
+
if (!file)
|
|
146
|
+
continue;
|
|
147
|
+
const relative = posix.relative(root, file);
|
|
148
|
+
if (posix.matchesGlob(byBasename ? posix.basename(relative) : relative, pattern)) {
|
|
149
|
+
matches.push(file);
|
|
150
|
+
if (matches.length > GLOB_LIMIT)
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (matches.length === 0)
|
|
155
|
+
return { kind: "completed", output: "No files matched." };
|
|
156
|
+
matches.sort();
|
|
157
|
+
return {
|
|
158
|
+
kind: "completed",
|
|
159
|
+
output: matches.length > GLOB_LIMIT
|
|
160
|
+
? `${matches.slice(0, GLOB_LIMIT).join("\n")}\n… more files omitted; narrow the pattern.`
|
|
161
|
+
: matches.join("\n"),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backend contract for Runtime-owned sandboxes. Only files under `adapters/sandbox/` implement it
|
|
3
|
+
* and import a substrate SDK; everything else in the Runtime talks to this interface.
|
|
4
|
+
*/
|
|
5
|
+
import type { SandboxNetworkPreset } from "@nylorun/core/define";
|
|
6
|
+
export type SandboxBackendName = "microsandbox" | "virtual";
|
|
7
|
+
export type SandboxIsolation = "vm" | "process";
|
|
8
|
+
export interface SandboxProbe {
|
|
9
|
+
readonly name: SandboxBackendName;
|
|
10
|
+
readonly available: boolean;
|
|
11
|
+
readonly isolation: SandboxIsolation;
|
|
12
|
+
/** Why the backend is unavailable, or a short description when it is. */
|
|
13
|
+
readonly reason?: string;
|
|
14
|
+
readonly version?: string;
|
|
15
|
+
}
|
|
16
|
+
/** Egress policy after presets are expanded. Always-blocked ranges are the backend's job. */
|
|
17
|
+
export interface ResolvedNetwork {
|
|
18
|
+
readonly preset: SandboxNetworkPreset;
|
|
19
|
+
/** Exact host names allowed over HTTPS/HTTP. */
|
|
20
|
+
readonly hosts: readonly string[];
|
|
21
|
+
/** Domain suffixes such as ".pythonhosted.org", from "*.pythonhosted.org". */
|
|
22
|
+
readonly suffixes: readonly string[];
|
|
23
|
+
}
|
|
24
|
+
export interface SandboxSpec {
|
|
25
|
+
/** Stable backend name for this sandbox; unique per Runtime scope and session. */
|
|
26
|
+
readonly key: string;
|
|
27
|
+
readonly image: string;
|
|
28
|
+
readonly cpus: number;
|
|
29
|
+
readonly memoryMiB: number;
|
|
30
|
+
readonly network: ResolvedNetwork;
|
|
31
|
+
}
|
|
32
|
+
export interface ExecRequest {
|
|
33
|
+
readonly command: string;
|
|
34
|
+
readonly cwd: string;
|
|
35
|
+
readonly timeoutMs: number;
|
|
36
|
+
}
|
|
37
|
+
export interface ExecResult {
|
|
38
|
+
readonly exitCode: number;
|
|
39
|
+
readonly stdout: string;
|
|
40
|
+
readonly stderr: string;
|
|
41
|
+
/** The process was actually stopped (timeout or cancellation). */
|
|
42
|
+
readonly killed: boolean;
|
|
43
|
+
readonly timedOut: boolean;
|
|
44
|
+
}
|
|
45
|
+
export interface SandboxHandle {
|
|
46
|
+
exec(request: ExecRequest, signal: AbortSignal): Promise<ExecResult>;
|
|
47
|
+
/** Returns undefined when the file does not exist. */
|
|
48
|
+
readFile(path: string): Promise<string | undefined>;
|
|
49
|
+
/** Parent directories must already exist. */
|
|
50
|
+
writeFile(path: string, content: string): Promise<void>;
|
|
51
|
+
/** Release compute; files persist. */
|
|
52
|
+
stop(): Promise<void>;
|
|
53
|
+
}
|
|
54
|
+
export interface SandboxBackend {
|
|
55
|
+
readonly name: SandboxBackendName;
|
|
56
|
+
readonly isolation: SandboxIsolation;
|
|
57
|
+
probe(): Promise<SandboxProbe>;
|
|
58
|
+
/** A reason this backend cannot meet the spec, or undefined when it can. */
|
|
59
|
+
unmet(spec: SandboxSpec): string | undefined;
|
|
60
|
+
/** Reattach to an existing sandbox (starting it if stopped) or create a new one. */
|
|
61
|
+
open(spec: SandboxSpec): Promise<SandboxHandle>;
|
|
62
|
+
/** Delete compute and files. Missing sandboxes are ignored. */
|
|
63
|
+
remove(key: string): Promise<void>;
|
|
64
|
+
/** Keys of sandboxes this backend holds whose key starts with the prefix. */
|
|
65
|
+
list(prefix: string): Promise<readonly string[]>;
|
|
66
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare class VaultCryptoError extends Error {
|
|
2
|
+
constructor();
|
|
3
|
+
}
|
|
4
|
+
export declare function kekId(kek: Buffer): string;
|
|
5
|
+
export declare function parseKek(value: Buffer | string): Buffer;
|
|
6
|
+
export declare function encryptSecret(kek: Buffer, aad: Buffer, plaintext: Buffer): {
|
|
7
|
+
nonce: Buffer;
|
|
8
|
+
ciphertext: Buffer;
|
|
9
|
+
wrappedDek: Buffer;
|
|
10
|
+
kekId: string;
|
|
11
|
+
};
|
|
12
|
+
export declare function decryptSecret(kek: Buffer, aad: Buffer, nonce: Buffer, ciphertext: Buffer, wrappedDek: Buffer, expectedKekId: string): Buffer;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes, } from "node:crypto";
|
|
2
|
+
const IV_BYTES = 12;
|
|
3
|
+
const TAG_BYTES = 16;
|
|
4
|
+
export class VaultCryptoError extends Error {
|
|
5
|
+
constructor() {
|
|
6
|
+
super("Vault credential could not be read");
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export function kekId(kek) {
|
|
10
|
+
return createHash("sha256").update(kek).digest("hex");
|
|
11
|
+
}
|
|
12
|
+
export function parseKek(value) {
|
|
13
|
+
const bytes = typeof value === "string" ? Buffer.from(value.trim(), "base64") : value;
|
|
14
|
+
if (bytes.length !== 32)
|
|
15
|
+
throw new Error("Vault key-encryption key must be 32 bytes");
|
|
16
|
+
return Buffer.from(bytes);
|
|
17
|
+
}
|
|
18
|
+
export function encryptSecret(kek, aad, plaintext) {
|
|
19
|
+
const dek = randomBytes(32);
|
|
20
|
+
try {
|
|
21
|
+
const nonce = randomBytes(IV_BYTES);
|
|
22
|
+
const cipher = createCipheriv("aes-256-gcm", dek, nonce);
|
|
23
|
+
cipher.setAAD(aad);
|
|
24
|
+
const body = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
25
|
+
const tag = cipher.getAuthTag();
|
|
26
|
+
return {
|
|
27
|
+
nonce,
|
|
28
|
+
ciphertext: Buffer.concat([body, tag]),
|
|
29
|
+
wrappedDek: wrapKey(kek, dek),
|
|
30
|
+
kekId: kekId(kek),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
dek.fill(0);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function decryptSecret(kek, aad, nonce, ciphertext, wrappedDek, expectedKekId) {
|
|
38
|
+
if (expectedKekId !== kekId(kek))
|
|
39
|
+
throw new VaultCryptoError();
|
|
40
|
+
const dek = unwrapKey(kek, wrappedDek);
|
|
41
|
+
try {
|
|
42
|
+
if (ciphertext.length < TAG_BYTES)
|
|
43
|
+
throw new VaultCryptoError();
|
|
44
|
+
const tag = ciphertext.subarray(ciphertext.length - TAG_BYTES);
|
|
45
|
+
const body = ciphertext.subarray(0, ciphertext.length - TAG_BYTES);
|
|
46
|
+
const decipher = createDecipheriv("aes-256-gcm", dek, nonce);
|
|
47
|
+
decipher.setAAD(aad);
|
|
48
|
+
decipher.setAuthTag(tag);
|
|
49
|
+
return Buffer.concat([decipher.update(body), decipher.final()]);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (error instanceof VaultCryptoError)
|
|
53
|
+
throw error;
|
|
54
|
+
throw new VaultCryptoError();
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
dek.fill(0);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function wrapKey(kek, dek) {
|
|
61
|
+
const nonce = randomBytes(IV_BYTES);
|
|
62
|
+
const cipher = createCipheriv("aes-256-gcm", kek, nonce);
|
|
63
|
+
const body = Buffer.concat([cipher.update(dek), cipher.final()]);
|
|
64
|
+
return Buffer.concat([nonce, body, cipher.getAuthTag()]);
|
|
65
|
+
}
|
|
66
|
+
function unwrapKey(kek, wrapped) {
|
|
67
|
+
try {
|
|
68
|
+
if (wrapped.length < IV_BYTES + TAG_BYTES)
|
|
69
|
+
throw new VaultCryptoError();
|
|
70
|
+
const nonce = wrapped.subarray(0, IV_BYTES);
|
|
71
|
+
const tag = wrapped.subarray(wrapped.length - TAG_BYTES);
|
|
72
|
+
const body = wrapped.subarray(IV_BYTES, wrapped.length - TAG_BYTES);
|
|
73
|
+
const decipher = createDecipheriv("aes-256-gcm", kek, nonce);
|
|
74
|
+
decipher.setAuthTag(tag);
|
|
75
|
+
return Buffer.concat([decipher.update(body), decipher.final()]);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
throw new VaultCryptoError();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { parseKek } from "./crypto.js";
|
|
5
|
+
export function defaultKekPath() {
|
|
6
|
+
return join(process.cwd(), ".nylorun", "vault-kek");
|
|
7
|
+
}
|
|
8
|
+
export function readVaultKek(options) {
|
|
9
|
+
if (options.vaultKek === null)
|
|
10
|
+
return undefined;
|
|
11
|
+
if (options.vaultKek !== undefined)
|
|
12
|
+
return parseKek(options.vaultKek);
|
|
13
|
+
const fromEnv = process.env.NYLORUN_VAULT_KEK;
|
|
14
|
+
if (fromEnv)
|
|
15
|
+
return parseKek(fromEnv);
|
|
16
|
+
const path = options.vaultKekPath ?? defaultKekPath();
|
|
17
|
+
if (!existsSync(path))
|
|
18
|
+
return undefined;
|
|
19
|
+
return parseKek(readFileSync(path, "utf8"));
|
|
20
|
+
}
|
|
21
|
+
export function createKekFile(path) {
|
|
22
|
+
const bytes = randomBytes(32);
|
|
23
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
24
|
+
writeFileSync(path, `${bytes.toString("base64")}\n`, { mode: 0o600 });
|
|
25
|
+
return bytes;
|
|
26
|
+
}
|