@nylorun/runtime 0.7.0-beta → 0.9.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 +50 -0
- package/README.md +7 -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 +24 -1
- package/dist/core/runtime.js +364 -43
- package/dist/core/store.d.ts +14 -1
- package/dist/core/store.js +59 -3
- package/dist/index.d.ts +0 -1
- package/dist/index.js +0 -1
- package/dist/mcp/pool.d.ts +13 -1
- package/dist/mcp/pool.js +53 -19
- package/dist/mcp/snapshot.d.ts +4 -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/service.d.ts +41 -1
- package/dist/vault/service.js +291 -4
- package/dist/version.d.ts +1 -0
- package/dist/version.js +3 -0
- package/package.json +9 -4
- 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,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
|
+
}
|
package/dist/vault/service.d.ts
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
import type { DatabaseSync } from "node:sqlite";
|
|
2
|
-
import type { CreateCredentialRequest, CreateVaultRequest, CredentialInfo, CredentialSelection, RotateCredentialRequest, VaultInfo } from "@nylorun/core/contracts";
|
|
2
|
+
import type { CreateCredentialRequest, CreateVaultRequest, CredentialInfo, CredentialSelection, HostModelProviderInfo, HostModelView, PutHostModelRequest, RotateCredentialRequest, SelectHostModelRequest, VaultInfo } from "@nylorun/core/contracts";
|
|
3
|
+
export type HostModelSecret = {
|
|
4
|
+
provider: string;
|
|
5
|
+
model: string;
|
|
6
|
+
baseUrl?: string;
|
|
7
|
+
authType: "api_key" | "oauth";
|
|
8
|
+
credential: {
|
|
9
|
+
type: "api_key" | "oauth";
|
|
10
|
+
key?: string;
|
|
11
|
+
env?: Record<string, string>;
|
|
12
|
+
refresh?: string;
|
|
13
|
+
access?: string;
|
|
14
|
+
expires?: number;
|
|
15
|
+
[key: string]: unknown;
|
|
16
|
+
};
|
|
17
|
+
};
|
|
3
18
|
export type AuthorizeResult = {
|
|
4
19
|
status: "unauthenticated";
|
|
5
20
|
url: string;
|
|
@@ -44,6 +59,21 @@ export declare class VaultService {
|
|
|
44
59
|
url: string;
|
|
45
60
|
serverName?: string;
|
|
46
61
|
}): Promise<AuthorizeResult>;
|
|
62
|
+
getHostModel(): HostModelView;
|
|
63
|
+
listHostProviders(): {
|
|
64
|
+
providers: HostModelProviderInfo[];
|
|
65
|
+
};
|
|
66
|
+
putHostModel(body: PutHostModelRequest): HostModelView;
|
|
67
|
+
selectHostModel(body: SelectHostModelRequest): HostModelView;
|
|
68
|
+
readHostModel(): HostModelSecret | undefined;
|
|
69
|
+
updateHostCredential(credential: {
|
|
70
|
+
type: "api_key" | "oauth";
|
|
71
|
+
key?: string;
|
|
72
|
+
env?: Record<string, string>;
|
|
73
|
+
refresh?: string;
|
|
74
|
+
access?: string;
|
|
75
|
+
expires?: number;
|
|
76
|
+
}): void;
|
|
47
77
|
reject(route: string): void;
|
|
48
78
|
private refresh;
|
|
49
79
|
private replay;
|
|
@@ -52,6 +82,16 @@ export declare class VaultService {
|
|
|
52
82
|
private readPayload;
|
|
53
83
|
private vaultInfo;
|
|
54
84
|
private vaultRow;
|
|
85
|
+
private ensureHostVault;
|
|
86
|
+
private validateHostModel;
|
|
87
|
+
private insertHostCredential;
|
|
88
|
+
private readHostPayload;
|
|
89
|
+
private hostModelRows;
|
|
90
|
+
private hostCredentialRowFor;
|
|
91
|
+
private activeHostCredentialRow;
|
|
92
|
+
private activeProviderId;
|
|
93
|
+
private setActiveProvider;
|
|
94
|
+
private deleteHostProviderRows;
|
|
55
95
|
private credentialInfo;
|
|
56
96
|
private credentialRow;
|
|
57
97
|
private rowsForVault;
|
package/dist/vault/service.js
CHANGED
|
@@ -2,8 +2,11 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
2
2
|
import { canonical } from "../core/store.js";
|
|
3
3
|
import { decryptSecret, encryptSecret, VaultCryptoError, } from "./crypto.js";
|
|
4
4
|
import { VaultError } from "./error.js";
|
|
5
|
+
import { hostModelCatalog } from "../model/catalog.js";
|
|
5
6
|
import { normalizeVaultUrl } from "./url.js";
|
|
6
7
|
const REFRESH_SKEW_MS = 60_000;
|
|
8
|
+
const HOST_VAULT_ID = "host";
|
|
9
|
+
const HOST_MODEL_ID = "host-model";
|
|
7
10
|
export class VaultService {
|
|
8
11
|
db;
|
|
9
12
|
tx;
|
|
@@ -34,7 +37,7 @@ export class VaultService {
|
|
|
34
37
|
}
|
|
35
38
|
listVaults(ownerUserId) {
|
|
36
39
|
const rows = this.db
|
|
37
|
-
.prepare(`SELECT id FROM vaults WHERE owner_user_id=? ORDER BY created_at, id`)
|
|
40
|
+
.prepare(`SELECT id FROM vaults WHERE owner_user_id=? AND scope='user' ORDER BY created_at, id`)
|
|
38
41
|
.all(ownerUserId);
|
|
39
42
|
return rows.map((row) => this.vaultInfo(row.id));
|
|
40
43
|
}
|
|
@@ -156,6 +159,8 @@ export class VaultService {
|
|
|
156
159
|
const vault = this.vaultRow(id);
|
|
157
160
|
if (!vault)
|
|
158
161
|
throw new VaultError(404, "Vault not found");
|
|
162
|
+
if (vault.scope === "host")
|
|
163
|
+
throw new VaultError(400, "Host vault cannot be attached to a session");
|
|
159
164
|
if (vault.owner_user_id !== ownerUserId)
|
|
160
165
|
throw new VaultError(403, "Vault belongs to another user");
|
|
161
166
|
}
|
|
@@ -274,6 +279,142 @@ export class VaultService {
|
|
|
274
279
|
headers: { authorization: `Bearer ${token}` },
|
|
275
280
|
};
|
|
276
281
|
}
|
|
282
|
+
getHostModel() {
|
|
283
|
+
const row = this.activeHostCredentialRow();
|
|
284
|
+
if (!row)
|
|
285
|
+
return { configured: false };
|
|
286
|
+
const binding = modelBinding(row);
|
|
287
|
+
return {
|
|
288
|
+
configured: true,
|
|
289
|
+
provider: binding.provider,
|
|
290
|
+
model: binding.model,
|
|
291
|
+
authType: binding.authType,
|
|
292
|
+
...(binding.baseUrl ? { baseUrl: binding.baseUrl } : {}),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
listHostProviders() {
|
|
296
|
+
const active = this.activeProviderId();
|
|
297
|
+
const catalog = new Map(hostModelCatalog().providers.map((provider) => [provider.id, provider.name]));
|
|
298
|
+
const providers = this.hostModelRows().map((row) => {
|
|
299
|
+
const binding = modelBinding(row);
|
|
300
|
+
return {
|
|
301
|
+
id: binding.provider,
|
|
302
|
+
name: catalog.get(binding.provider) ??
|
|
303
|
+
(binding.provider === "custom"
|
|
304
|
+
? "Custom OpenAI-compatible"
|
|
305
|
+
: binding.provider),
|
|
306
|
+
model: binding.model,
|
|
307
|
+
authType: binding.authType,
|
|
308
|
+
...(binding.baseUrl ? { baseUrl: binding.baseUrl } : {}),
|
|
309
|
+
lastUpdated: row.rotated_at ?? row.created_at,
|
|
310
|
+
active: binding.provider === active,
|
|
311
|
+
};
|
|
312
|
+
});
|
|
313
|
+
providers.sort((left, right) => left.name.localeCompare(right.name));
|
|
314
|
+
return { providers };
|
|
315
|
+
}
|
|
316
|
+
putHostModel(body) {
|
|
317
|
+
return this.tx(() => this.replay(`host-model:${body.idempotencyKey}`, body, () => {
|
|
318
|
+
this.validateHostModel(body);
|
|
319
|
+
this.ensureHostVault();
|
|
320
|
+
const credentialId = hostModelCredentialId(body.provider);
|
|
321
|
+
this.deleteHostProviderRows(body.provider);
|
|
322
|
+
const payload = body.auth.type === "api_key"
|
|
323
|
+
? {
|
|
324
|
+
apiKey: body.auth.key,
|
|
325
|
+
...(body.auth.env ? { env: body.auth.env } : {}),
|
|
326
|
+
}
|
|
327
|
+
: { oauth: body.auth };
|
|
328
|
+
this.insertHostCredential({
|
|
329
|
+
id: credentialId,
|
|
330
|
+
provider: body.provider,
|
|
331
|
+
model: body.model,
|
|
332
|
+
baseUrl: body.baseUrl,
|
|
333
|
+
authType: body.auth.type,
|
|
334
|
+
payload,
|
|
335
|
+
});
|
|
336
|
+
this.setActiveProvider(body.provider);
|
|
337
|
+
this.audit({
|
|
338
|
+
actor: "application",
|
|
339
|
+
action: "rotate",
|
|
340
|
+
vaultId: HOST_VAULT_ID,
|
|
341
|
+
credentialId,
|
|
342
|
+
outcome: "rotated",
|
|
343
|
+
});
|
|
344
|
+
return this.getHostModel();
|
|
345
|
+
}));
|
|
346
|
+
}
|
|
347
|
+
selectHostModel(body) {
|
|
348
|
+
return this.tx(() => this.replay(`host-model-select:${body.idempotencyKey}`, body, () => {
|
|
349
|
+
this.validateHostModel(body);
|
|
350
|
+
const row = this.hostCredentialRowFor(body.provider);
|
|
351
|
+
if (!row)
|
|
352
|
+
throw new VaultError(404, "Model provider is not configured");
|
|
353
|
+
const binding = modelBinding(row);
|
|
354
|
+
const payload = this.readHostPayload(row, binding);
|
|
355
|
+
const credentialId = hostModelCredentialId(body.provider);
|
|
356
|
+
this.deleteHostProviderRows(body.provider);
|
|
357
|
+
this.insertHostCredential({
|
|
358
|
+
id: credentialId,
|
|
359
|
+
provider: body.provider,
|
|
360
|
+
model: body.model,
|
|
361
|
+
baseUrl: body.baseUrl,
|
|
362
|
+
authType: binding.authType,
|
|
363
|
+
payload,
|
|
364
|
+
});
|
|
365
|
+
this.setActiveProvider(body.provider);
|
|
366
|
+
this.audit({
|
|
367
|
+
actor: "application",
|
|
368
|
+
action: "rotate",
|
|
369
|
+
vaultId: HOST_VAULT_ID,
|
|
370
|
+
credentialId,
|
|
371
|
+
outcome: "rotated",
|
|
372
|
+
});
|
|
373
|
+
return this.getHostModel();
|
|
374
|
+
}));
|
|
375
|
+
}
|
|
376
|
+
readHostModel() {
|
|
377
|
+
const row = this.activeHostCredentialRow();
|
|
378
|
+
if (!row)
|
|
379
|
+
return undefined;
|
|
380
|
+
const binding = modelBinding(row);
|
|
381
|
+
const payload = this.readHostPayload(row, binding);
|
|
382
|
+
const credential = binding.authType === "oauth"
|
|
383
|
+
? { type: "oauth", ...payload.oauth }
|
|
384
|
+
: {
|
|
385
|
+
type: "api_key",
|
|
386
|
+
key: payload.apiKey,
|
|
387
|
+
...(payload.env ? { env: payload.env } : {}),
|
|
388
|
+
};
|
|
389
|
+
return {
|
|
390
|
+
provider: binding.provider,
|
|
391
|
+
model: binding.model,
|
|
392
|
+
...(binding.baseUrl ? { baseUrl: binding.baseUrl } : {}),
|
|
393
|
+
authType: binding.authType,
|
|
394
|
+
credential,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
updateHostCredential(credential) {
|
|
398
|
+
this.tx(() => {
|
|
399
|
+
const row = this.activeHostCredentialRow();
|
|
400
|
+
if (!row)
|
|
401
|
+
throw new VaultError(404, "Model provider is not configured");
|
|
402
|
+
const binding = modelBinding(row);
|
|
403
|
+
const payload = credential.type === "oauth"
|
|
404
|
+
? { oauth: credential }
|
|
405
|
+
: {
|
|
406
|
+
apiKey: credential.key,
|
|
407
|
+
...(credential.env ? { env: credential.env } : {}),
|
|
408
|
+
};
|
|
409
|
+
const aad = hostModelAad(binding.provider, binding.model);
|
|
410
|
+
const sealed = encryptSecret(this.kek(), aad, Buffer.from(JSON.stringify(payload), "utf8"));
|
|
411
|
+
this.db
|
|
412
|
+
.prepare(`UPDATE vault_credentials
|
|
413
|
+
SET type='model', binding_json=?, kek_id=?, nonce=?, ciphertext=?, wrapped_dek=?, rotated_at=?
|
|
414
|
+
WHERE id=?`)
|
|
415
|
+
.run(JSON.stringify({ ...binding, authType: credential.type }), sealed.kekId, sealed.nonce, sealed.ciphertext, sealed.wrappedDek, new Date().toISOString(), row.id);
|
|
416
|
+
});
|
|
417
|
+
}
|
|
277
418
|
reject(route) {
|
|
278
419
|
this.audit({
|
|
279
420
|
actor: "executor",
|
|
@@ -413,7 +554,7 @@ export class VaultService {
|
|
|
413
554
|
}
|
|
414
555
|
vaultInfo(id) {
|
|
415
556
|
const row = this.vaultRow(id);
|
|
416
|
-
if (!row)
|
|
557
|
+
if (!row || row.scope === "host")
|
|
417
558
|
throw new VaultError(404, "Vault not found");
|
|
418
559
|
return {
|
|
419
560
|
id: row.id,
|
|
@@ -427,9 +568,134 @@ export class VaultService {
|
|
|
427
568
|
}
|
|
428
569
|
vaultRow(id) {
|
|
429
570
|
return this.db
|
|
430
|
-
.prepare(`SELECT id, name, owner_user_id, metadata_json, created_at FROM vaults WHERE id=?`)
|
|
571
|
+
.prepare(`SELECT id, name, owner_user_id, metadata_json, created_at, scope FROM vaults WHERE id=?`)
|
|
431
572
|
.get(id);
|
|
432
573
|
}
|
|
574
|
+
ensureHostVault() {
|
|
575
|
+
const existing = this.vaultRow(HOST_VAULT_ID);
|
|
576
|
+
if (existing?.scope === "host")
|
|
577
|
+
return;
|
|
578
|
+
if (existing)
|
|
579
|
+
throw new VaultError(409, "Host vault id is already used");
|
|
580
|
+
this.db
|
|
581
|
+
.prepare(`INSERT INTO vaults(id,name,owner_user_id,metadata_json,created_at,scope) VALUES(?,?,?,?,?,?)`)
|
|
582
|
+
.run(HOST_VAULT_ID, "Host", "host", null, new Date().toISOString(), "host");
|
|
583
|
+
}
|
|
584
|
+
validateHostModel(body) {
|
|
585
|
+
if (body.provider === "custom") {
|
|
586
|
+
if (!body.baseUrl)
|
|
587
|
+
throw new VaultError(400, "A base URL is required for a custom provider.");
|
|
588
|
+
let url;
|
|
589
|
+
try {
|
|
590
|
+
url = new URL(body.baseUrl);
|
|
591
|
+
}
|
|
592
|
+
catch {
|
|
593
|
+
throw new VaultError(400, "Base URL must be an HTTP(S) URL.");
|
|
594
|
+
}
|
|
595
|
+
if (!["http:", "https:"].includes(url.protocol))
|
|
596
|
+
throw new VaultError(400, "Base URL must be an HTTP(S) URL.");
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
if (body.baseUrl)
|
|
600
|
+
throw new VaultError(400, "A base URL is only valid for a custom provider.");
|
|
601
|
+
const provider = hostModelCatalog().providers.find((item) => item.id === body.provider);
|
|
602
|
+
if (!provider)
|
|
603
|
+
throw new VaultError(400, "Unknown model provider.");
|
|
604
|
+
if (!provider.models.some((item) => item.id === body.model))
|
|
605
|
+
throw new VaultError(400, "Unknown model.");
|
|
606
|
+
}
|
|
607
|
+
insertHostCredential(input) {
|
|
608
|
+
const aad = hostModelAad(input.provider, input.model);
|
|
609
|
+
const sealed = encryptSecret(this.kek(), aad, Buffer.from(JSON.stringify(input.payload), "utf8"));
|
|
610
|
+
this.db
|
|
611
|
+
.prepare(`INSERT INTO vault_credentials(
|
|
612
|
+
id, vault_id, name, type, binding_json, expires_at, created_at, rotated_at,
|
|
613
|
+
kek_id, nonce, ciphertext, wrapped_dek
|
|
614
|
+
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`)
|
|
615
|
+
.run(input.id, HOST_VAULT_ID, input.provider, "model", JSON.stringify({
|
|
616
|
+
provider: input.provider,
|
|
617
|
+
model: input.model,
|
|
618
|
+
...(input.baseUrl ? { baseUrl: input.baseUrl } : {}),
|
|
619
|
+
authType: input.authType,
|
|
620
|
+
}), null, new Date().toISOString(), null, sealed.kekId, sealed.nonce, sealed.ciphertext, sealed.wrappedDek);
|
|
621
|
+
}
|
|
622
|
+
readHostPayload(row, binding) {
|
|
623
|
+
const plaintext = decryptSecret(this.kek(), hostModelAad(binding.provider, binding.model), Buffer.from(row.nonce), Buffer.from(row.ciphertext), Buffer.from(row.wrapped_dek), row.kek_id);
|
|
624
|
+
try {
|
|
625
|
+
return JSON.parse(plaintext.toString("utf8"));
|
|
626
|
+
}
|
|
627
|
+
finally {
|
|
628
|
+
plaintext.fill(0);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
hostModelRows() {
|
|
632
|
+
return this.db
|
|
633
|
+
.prepare(`SELECT id, vault_id, name, type, binding_json, expires_at, created_at, rotated_at,
|
|
634
|
+
kek_id, nonce, ciphertext, wrapped_dek
|
|
635
|
+
FROM vault_credentials WHERE vault_id=? AND type='model'
|
|
636
|
+
ORDER BY created_at, id`)
|
|
637
|
+
.all(HOST_VAULT_ID).filter((row) => {
|
|
638
|
+
try {
|
|
639
|
+
modelBinding(row);
|
|
640
|
+
return true;
|
|
641
|
+
}
|
|
642
|
+
catch {
|
|
643
|
+
return false;
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
hostCredentialRowFor(provider) {
|
|
648
|
+
const preferred = hostModelCredentialId(provider);
|
|
649
|
+
const rows = this.hostModelRows();
|
|
650
|
+
return (rows.find((row) => row.id === preferred) ??
|
|
651
|
+
rows.find((row) => modelBinding(row).provider === provider));
|
|
652
|
+
}
|
|
653
|
+
activeHostCredentialRow() {
|
|
654
|
+
const active = this.activeProviderId();
|
|
655
|
+
if (!active)
|
|
656
|
+
return undefined;
|
|
657
|
+
return this.hostCredentialRowFor(active);
|
|
658
|
+
}
|
|
659
|
+
activeProviderId() {
|
|
660
|
+
const vault = this.vaultRow(HOST_VAULT_ID);
|
|
661
|
+
if (vault?.metadata_json) {
|
|
662
|
+
try {
|
|
663
|
+
const metadata = JSON.parse(vault.metadata_json);
|
|
664
|
+
if (typeof metadata.activeProvider === "string" &&
|
|
665
|
+
metadata.activeProvider &&
|
|
666
|
+
this.hostCredentialRowFor(metadata.activeProvider))
|
|
667
|
+
return metadata.activeProvider;
|
|
668
|
+
}
|
|
669
|
+
catch {
|
|
670
|
+
/* Fall through to the only configured provider. */
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
const rows = this.hostModelRows();
|
|
674
|
+
if (rows.length === 0)
|
|
675
|
+
return undefined;
|
|
676
|
+
if (rows.length === 1)
|
|
677
|
+
return modelBinding(rows[0]).provider;
|
|
678
|
+
const legacy = rows.find((row) => row.id === HOST_MODEL_ID);
|
|
679
|
+
if (legacy)
|
|
680
|
+
return modelBinding(legacy).provider;
|
|
681
|
+
return modelBinding(rows[0]).provider;
|
|
682
|
+
}
|
|
683
|
+
setActiveProvider(provider) {
|
|
684
|
+
this.ensureHostVault();
|
|
685
|
+
this.db
|
|
686
|
+
.prepare(`UPDATE vaults SET metadata_json=? WHERE id=?`)
|
|
687
|
+
.run(JSON.stringify({ activeProvider: provider }), HOST_VAULT_ID);
|
|
688
|
+
}
|
|
689
|
+
deleteHostProviderRows(provider) {
|
|
690
|
+
const ids = this.hostModelRows()
|
|
691
|
+
.filter((row) => modelBinding(row).provider === provider)
|
|
692
|
+
.map((row) => row.id);
|
|
693
|
+
ids.push(hostModelCredentialId(provider));
|
|
694
|
+
for (const id of new Set(ids))
|
|
695
|
+
this.db
|
|
696
|
+
.prepare(`DELETE FROM vault_credentials WHERE id=? AND vault_id=?`)
|
|
697
|
+
.run(id, HOST_VAULT_ID);
|
|
698
|
+
}
|
|
433
699
|
credentialInfo(vaultId, id) {
|
|
434
700
|
const row = this.credentialRow(vaultId, id);
|
|
435
701
|
return {
|
|
@@ -449,7 +715,7 @@ export class VaultService {
|
|
|
449
715
|
kek_id, nonce, ciphertext, wrapped_dek
|
|
450
716
|
FROM vault_credentials WHERE id=? AND vault_id=?`)
|
|
451
717
|
.get(id, vaultId);
|
|
452
|
-
if (!row ||
|
|
718
|
+
if (!row || row.type === "model")
|
|
453
719
|
throw new VaultError(404, "Credential not found");
|
|
454
720
|
return row;
|
|
455
721
|
}
|
|
@@ -529,6 +795,27 @@ function requireTimestamp(value) {
|
|
|
529
795
|
throw new VaultError(400, "Credential expiry is invalid");
|
|
530
796
|
return new Date(Date.parse(value)).toISOString();
|
|
531
797
|
}
|
|
798
|
+
function modelBinding(row) {
|
|
799
|
+
const binding = JSON.parse(row.binding_json);
|
|
800
|
+
if (!binding.provider || !binding.model || !binding.authType)
|
|
801
|
+
throw new VaultError(500, "Host model credential is unreadable");
|
|
802
|
+
return binding;
|
|
803
|
+
}
|
|
804
|
+
function hostModelCredentialId(provider) {
|
|
805
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(provider))
|
|
806
|
+
throw new VaultError(400, "Invalid provider id");
|
|
807
|
+
return `${HOST_MODEL_ID}:${provider}`;
|
|
808
|
+
}
|
|
809
|
+
function hostModelAad(provider, model) {
|
|
810
|
+
return Buffer.from(canonical({
|
|
811
|
+
scope: "host",
|
|
812
|
+
vaultId: HOST_VAULT_ID,
|
|
813
|
+
credentialId: HOST_MODEL_ID,
|
|
814
|
+
type: "model",
|
|
815
|
+
provider,
|
|
816
|
+
model,
|
|
817
|
+
}), "utf8");
|
|
818
|
+
}
|
|
532
819
|
function credentialAad(vaultId, credentialId, type, url) {
|
|
533
820
|
return Buffer.from(canonical({ vaultId, credentialId, type, url }), "utf8");
|
|
534
821
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const RUNTIME_VERSION = "0.9.0-beta";
|
package/dist/version.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nylorun/runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0-beta",
|
|
4
4
|
"description": "SQLite agent execution host and model providers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
"./configuration": {
|
|
30
30
|
"types": "./dist/configuration.d.ts",
|
|
31
31
|
"import": "./dist/configuration.js"
|
|
32
|
-
}
|
|
32
|
+
},
|
|
33
|
+
"./package.json": "./package.json"
|
|
33
34
|
},
|
|
34
35
|
"files": [
|
|
35
36
|
"dist",
|
|
@@ -54,8 +55,12 @@
|
|
|
54
55
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
55
56
|
"@earendil-works/pi-ai": "0.85.1",
|
|
56
57
|
"@hono/node-server": "^2.1.1",
|
|
57
|
-
"@nylorun/harness": "0.
|
|
58
|
-
"@nylorun/core": "0.
|
|
58
|
+
"@nylorun/harness": "0.18.0-beta",
|
|
59
|
+
"@nylorun/core": "0.4.0-beta",
|
|
60
|
+
"just-bash": "3.4.2"
|
|
61
|
+
},
|
|
62
|
+
"optionalDependencies": {
|
|
63
|
+
"microsandbox": "0.7.2"
|
|
59
64
|
},
|
|
60
65
|
"devDependencies": {
|
|
61
66
|
"@types/node": "^26.5.0",
|