@qilin-agent/kylin-host-runner 0.0.0-stage → 3.1.3
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/LICENSE +21 -0
- package/README.i18n.yaml +70 -0
- package/README.md +150 -2
- package/README.zh.md +151 -0
- package/lib/index.js +2619 -0
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +2483 -0
- package/lib/typert.remote-client.d.ts +56 -0
- package/lib/typert.remote-client.js +819 -0
- package/lib/types/guard.d.ts +71 -0
- package/lib/types/guard.js +766 -0
- package/lib/types/index.d.ts +280 -0
- package/lib/types/index.js +1150 -0
- package/lib/types/inspect-registry.d.ts +78 -0
- package/lib/types/inspect-registry.js +237 -0
- package/lib/types/lifecycle.d.ts +29 -0
- package/lib/types/lifecycle.js +49 -0
- package/lib/types/registry.d.ts +225 -0
- package/lib/types/registry.js +131 -0
- package/lib/types/sandbox.d.ts +99 -0
- package/lib/types/sandbox.js +251 -0
- package/lib/types/types.d.ts +363 -0
- package/lib/types/types.js +6 -0
- package/package.json +73 -3
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-local dynamic Plugin registry and its opaque identity mints.
|
|
3
|
+
* @module @qilin-agent/kylin-host-runner/registry
|
|
4
|
+
*/
|
|
5
|
+
/** Registry, identity mints, and pending approval index. */
|
|
6
|
+
export class DynamicCordisRegistry {
|
|
7
|
+
plugins = new Map();
|
|
8
|
+
pendingRequests = new Map();
|
|
9
|
+
nextPlugin = 1;
|
|
10
|
+
nextPackage = 1;
|
|
11
|
+
nextRun = 1;
|
|
12
|
+
nextApproval = 1;
|
|
13
|
+
/**
|
|
14
|
+
* Mint a semantic plugin ID without reusing a prior suffix.
|
|
15
|
+
* @param prefix - validated lowercase semantic prefix proposed by the model.
|
|
16
|
+
* @returns a process-unique Plugin ID.
|
|
17
|
+
*/
|
|
18
|
+
mintPluginId(prefix) {
|
|
19
|
+
let id;
|
|
20
|
+
do
|
|
21
|
+
id = `${prefix}-${this.nextPlugin++}`;
|
|
22
|
+
while (this.plugins.has(id));
|
|
23
|
+
return id;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Mint an immutable package ID.
|
|
27
|
+
* @returns a process-unique Package ID.
|
|
28
|
+
*/
|
|
29
|
+
mintPackageId() {
|
|
30
|
+
return `pkg-${this.nextPackage++}`;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Mint an activation ID.
|
|
34
|
+
* @returns a process-unique Plugin Run ID.
|
|
35
|
+
*/
|
|
36
|
+
mintPluginRunId() {
|
|
37
|
+
return `run-${this.nextRun++}`;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Mint an approval ID.
|
|
41
|
+
* @returns a process-unique approval request ID.
|
|
42
|
+
*/
|
|
43
|
+
mintApprovalRequestId() {
|
|
44
|
+
return `approval-${this.nextApproval++}`;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Add one stable plugin.
|
|
48
|
+
* @param plugin - Plugin record to retain under its stable ID.
|
|
49
|
+
*/
|
|
50
|
+
add(plugin) {
|
|
51
|
+
this.plugins.set(plugin.pluginId, plugin);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Read one plugin.
|
|
55
|
+
* @param id - stable Plugin ID.
|
|
56
|
+
* @returns the Plugin record, or `undefined` when absent.
|
|
57
|
+
*/
|
|
58
|
+
get(id) {
|
|
59
|
+
return this.plugins.get(id);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Delete one plugin and all package versions.
|
|
63
|
+
* @param id - stable Plugin ID to remove.
|
|
64
|
+
* @returns whether a Plugin record was removed.
|
|
65
|
+
*/
|
|
66
|
+
delete(id) {
|
|
67
|
+
return this.plugins.delete(id);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Read all plugins in creation order.
|
|
71
|
+
* @returns a snapshot of every Plugin record.
|
|
72
|
+
*/
|
|
73
|
+
all() {
|
|
74
|
+
return [...this.plugins.values()];
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Read one session's plugins in creation order.
|
|
78
|
+
* @param sessionId - owning session to filter by.
|
|
79
|
+
* @returns a snapshot of matching Plugin records.
|
|
80
|
+
*/
|
|
81
|
+
ofSession(sessionId) {
|
|
82
|
+
return this.all().filter(plugin => plugin.sessionId === sessionId);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Publish one pending approval.
|
|
86
|
+
* @param id - approval request ID.
|
|
87
|
+
* @param pending - resolver and Plugin metadata retained until settlement.
|
|
88
|
+
*/
|
|
89
|
+
armRequest(id, pending) {
|
|
90
|
+
this.pendingRequests.set(id, pending);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Read one pending approval without claiming it.
|
|
94
|
+
* @param id - approval request ID.
|
|
95
|
+
* @returns the pending request, or `undefined` when absent.
|
|
96
|
+
*/
|
|
97
|
+
peekRequest(id) {
|
|
98
|
+
return this.pendingRequests.get(id);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Claim one pending approval; first answer wins.
|
|
102
|
+
* @param id - approval request ID.
|
|
103
|
+
* @returns the claimed request, or `undefined` when already settled.
|
|
104
|
+
*/
|
|
105
|
+
claimRequest(id) {
|
|
106
|
+
const pending = this.pendingRequests.get(id);
|
|
107
|
+
if (pending !== undefined)
|
|
108
|
+
this.pendingRequests.delete(id);
|
|
109
|
+
return pending;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Cancel one pending approval.
|
|
113
|
+
* @param id - approval request ID to remove.
|
|
114
|
+
*/
|
|
115
|
+
disarmRequest(id) {
|
|
116
|
+
this.pendingRequests.delete(id);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Find a pending approval for one Plugin.
|
|
120
|
+
* @param pluginId - stable Plugin ID.
|
|
121
|
+
* @returns its approval request ID, or `undefined` when none is pending.
|
|
122
|
+
*/
|
|
123
|
+
pendingRequestFor(pluginId) {
|
|
124
|
+
for (const [requestId, request] of this.pendingRequests) {
|
|
125
|
+
if (request.pluginId === pluginId)
|
|
126
|
+
return requestId;
|
|
127
|
+
}
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
//# sourceMappingURL=registry.js.map
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `node:vm` sandbox a dynamic package's HOST half evaluates in: a fresh realm whose globals
|
|
3
|
+
* are a tagged write-through console, the `harness` registration helpers, the encoding primitives
|
|
4
|
+
* a bare vm context lacks, and callable traps over the Node APIs the sandbox deliberately
|
|
5
|
+
* withholds. Traps steer filesystem, network, process, and timer work to `ctx.fs`, `ctx.web`,
|
|
6
|
+
* `ctx.bash`, and Cordis timers. This keeps cooperative packages inspectable and disposable but
|
|
7
|
+
* is not containment: host-realm helper functions remain an escape route.
|
|
8
|
+
*
|
|
9
|
+
* The browser half never reaches this module — it is evaluated by the client-side runner in a
|
|
10
|
+
* closure, with its own facade.
|
|
11
|
+
* @module @qilin-agent/kylin-host-runner/sandbox
|
|
12
|
+
*/
|
|
13
|
+
/** Exact Host closure symbols exposed by the sandbox and guarded Context. */
|
|
14
|
+
export declare const HOST_BUILTIN_INSPECTION: readonly [{
|
|
15
|
+
readonly name: "ctx";
|
|
16
|
+
readonly description: "Restricted Cordis Context. Prefer ctx.get(name) with an undefined check; use inject for hard dependencies.";
|
|
17
|
+
readonly signatures: readonly ["ctx.get(name: string): unknown | undefined", "ctx.on(name: string, listener: Function): () => void", "ctx.provide(name: string, value: unknown): () => void", "ctx.effect(callback: Function, label?: string): () => void"];
|
|
18
|
+
}, {
|
|
19
|
+
readonly name: "harness";
|
|
20
|
+
readonly description: "Host helpers for Package-private Client RPC and model-visible dynamic Tools.";
|
|
21
|
+
readonly signatures: readonly ["harness.handle(method: string, handler: (args: JsonValue) => JsonValue | Promise<JsonValue>): () => void", "harness.defineTool(definition: ToolDefinition): ToolDefinition", "harness.registerTool(ctx: Context, tool: ToolDefinition): () => void"];
|
|
22
|
+
}, {
|
|
23
|
+
readonly name: "console";
|
|
24
|
+
readonly description: "Package-tagged Host logging.";
|
|
25
|
+
readonly signatures: readonly ["console.log(...values): void", "console.error(...values): void"];
|
|
26
|
+
}, {
|
|
27
|
+
readonly name: "btoa";
|
|
28
|
+
readonly description: "Encode UTF-8 text as base64.";
|
|
29
|
+
readonly signatures: readonly ["btoa(value: string): string"];
|
|
30
|
+
}, {
|
|
31
|
+
readonly name: "atob";
|
|
32
|
+
readonly description: "Decode base64 as UTF-8 text.";
|
|
33
|
+
readonly signatures: readonly ["atob(value: string): string"];
|
|
34
|
+
}, {
|
|
35
|
+
readonly name: "TextEncoder";
|
|
36
|
+
readonly description: "Standard UTF-8 encoder constructor.";
|
|
37
|
+
readonly signatures: readonly ["new TextEncoder()"];
|
|
38
|
+
}, {
|
|
39
|
+
readonly name: "TextDecoder";
|
|
40
|
+
readonly description: "Standard text decoder constructor.";
|
|
41
|
+
readonly signatures: readonly ["new TextDecoder(label?: string)"];
|
|
42
|
+
}];
|
|
43
|
+
/**
|
|
44
|
+
* Build the vm context one host half evaluates in: the tagged console, the
|
|
45
|
+
* `harness` registration helpers, the encoding primitives, the Node-API traps,
|
|
46
|
+
* and the dual-realm `instanceof` patch, already `createContext`-ed.
|
|
47
|
+
* @param id - the package id (`dyn-<n>`), used as the console tag and filename stem.
|
|
48
|
+
* @param harnessExtras - per-package `harness` verbs beyond the registration pair (`handle`).
|
|
49
|
+
* @returns the contextified sandbox object to pass to {@link evaluateHostCode}.
|
|
50
|
+
*/
|
|
51
|
+
export declare function createSandbox(id: string, harnessExtras?: Record<string, unknown>): object;
|
|
52
|
+
/**
|
|
53
|
+
* The parse-failure context a vm `SyntaxError` carries: the vm prints the
|
|
54
|
+
* offending source line and a caret before the message, which is exactly what
|
|
55
|
+
* a model needs to self-correct — surface it instead of the bare message.
|
|
56
|
+
* Falls back to `String(error)` when the stack carries no such prelude.
|
|
57
|
+
* @param error - the `SyntaxError` (host- or sandbox-realm) thrown while compiling package code.
|
|
58
|
+
* @returns the stack prefix up to and including the `SyntaxError: …` line.
|
|
59
|
+
*/
|
|
60
|
+
export declare function syntaxErrorContext(error: Error): string;
|
|
61
|
+
/**
|
|
62
|
+
* The teaching text one parse failure produces, shared by the define-time
|
|
63
|
+
* precheck and the run-time evaluation so a model reads the same diagnosis
|
|
64
|
+
* whichever verb caught it.
|
|
65
|
+
* @param half - which half failed to parse, named as the define argument that carried it.
|
|
66
|
+
* @param context - the {@link syntaxErrorContext} of the failure.
|
|
67
|
+
* @returns the model-facing error message.
|
|
68
|
+
*/
|
|
69
|
+
export declare function parseErrorMessage(half: 'code.host' | 'code.client', context: string): string;
|
|
70
|
+
/**
|
|
71
|
+
* Parse one half's source without running it: the define-time precheck that
|
|
72
|
+
* keeps unparseable code out of the registry, so a model fixes it and defines
|
|
73
|
+
* again instead of discovering the failure at run time. `new Function` is the
|
|
74
|
+
* gate — hosts without a real `node:vm` (the browser worker) still refuse
|
|
75
|
+
* unparseable code — and `vm.Script` is only the best-effort prettifier: on a
|
|
76
|
+
* Node host its failure carries the source-line-and-caret prelude the
|
|
77
|
+
* teaching text builds on, and where the vm is a stub the message stays bare.
|
|
78
|
+
* The two parsers' syntax faces differ at the margin (`new.target` parses in
|
|
79
|
+
* a function body but not at the vm wrapper's top level), an accepted cost of
|
|
80
|
+
* a vm-free gate; and under a page CSP without `'unsafe-eval'`, `new Function`
|
|
81
|
+
* throws `EvalError`, which propagates unwrapped.
|
|
82
|
+
* @param code - the model-written function body.
|
|
83
|
+
* @param half - which define argument carried it, for the error text.
|
|
84
|
+
* @throws when the body does not parse, with the offending line and a teaching hint.
|
|
85
|
+
*/
|
|
86
|
+
export declare function precheckCode(code: string, half: 'code.host' | 'code.client'): void;
|
|
87
|
+
/**
|
|
88
|
+
* Evaluate a host half as the body of an async function inside the sandbox. `vmTimeoutMs` only
|
|
89
|
+
* bounds the SYNCHRONOUS portion; an async body escapes it — acceptable under the module's
|
|
90
|
+
* trust stance. Parse errors include the offending line and a TypeScript-removal or bracket-
|
|
91
|
+
* balance hint.
|
|
92
|
+
* @param sandbox - the contextified object from {@link createSandbox}.
|
|
93
|
+
* @param code - the model-written function body; must `return` a plugin.
|
|
94
|
+
* @param id - the package id, used as the vm filename (`cordis-dyn-<id>.js`).
|
|
95
|
+
* @param vmTimeoutMs - the synchronous evaluation bound in milliseconds.
|
|
96
|
+
* @returns whatever the code returned, still un-narrowed (the run lifecycle checks plugin shape).
|
|
97
|
+
*/
|
|
98
|
+
export declare function evaluateHostCode(sandbox: object, code: string, id: string, vmTimeoutMs: number): Promise<unknown>;
|
|
99
|
+
//# sourceMappingURL=sandbox.d.ts.map
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `node:vm` sandbox a dynamic package's HOST half evaluates in: a fresh realm whose globals
|
|
3
|
+
* are a tagged write-through console, the `harness` registration helpers, the encoding primitives
|
|
4
|
+
* a bare vm context lacks, and callable traps over the Node APIs the sandbox deliberately
|
|
5
|
+
* withholds. Traps steer filesystem, network, process, and timer work to `ctx.fs`, `ctx.web`,
|
|
6
|
+
* `ctx.bash`, and Cordis timers. This keeps cooperative packages inspectable and disposable but
|
|
7
|
+
* is not containment: host-realm helper functions remain an escape route.
|
|
8
|
+
*
|
|
9
|
+
* The browser half never reaches this module — it is evaluated by the client-side runner in a
|
|
10
|
+
* closure, with its own facade.
|
|
11
|
+
* @module @qilin-agent/kylin-host-runner/sandbox
|
|
12
|
+
*/
|
|
13
|
+
import { createContext, runInContext, Script } from 'node:vm';
|
|
14
|
+
import { sandboxDefineTool, sandboxRegisterTool } from "./guard.js";
|
|
15
|
+
/** Exact Host closure symbols exposed by the sandbox and guarded Context. */
|
|
16
|
+
export const HOST_BUILTIN_INSPECTION = [
|
|
17
|
+
{
|
|
18
|
+
name: 'ctx',
|
|
19
|
+
description: 'Restricted Cordis Context. Prefer ctx.get(name) with an undefined check; use inject for hard dependencies.',
|
|
20
|
+
signatures: [
|
|
21
|
+
'ctx.get(name: string): unknown | undefined',
|
|
22
|
+
'ctx.on(name: string, listener: Function): () => void',
|
|
23
|
+
'ctx.provide(name: string, value: unknown): () => void',
|
|
24
|
+
'ctx.effect(callback: Function, label?: string): () => void',
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: 'harness',
|
|
29
|
+
description: 'Host helpers for Package-private Client RPC and model-visible dynamic Tools.',
|
|
30
|
+
signatures: [
|
|
31
|
+
'harness.handle(method: string, handler: (args: JsonValue) => JsonValue | Promise<JsonValue>): () => void',
|
|
32
|
+
'harness.defineTool(definition: ToolDefinition): ToolDefinition',
|
|
33
|
+
'harness.registerTool(ctx: Context, tool: ToolDefinition): () => void',
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
{ name: 'console', description: 'Package-tagged Host logging.', signatures: ['console.log(...values): void', 'console.error(...values): void'] },
|
|
37
|
+
{ name: 'btoa', description: 'Encode UTF-8 text as base64.', signatures: ['btoa(value: string): string'] },
|
|
38
|
+
{ name: 'atob', description: 'Decode base64 as UTF-8 text.', signatures: ['atob(value: string): string'] },
|
|
39
|
+
{ name: 'TextEncoder', description: 'Standard UTF-8 encoder constructor.', signatures: ['new TextEncoder()'] },
|
|
40
|
+
{ name: 'TextDecoder', description: 'Standard text decoder constructor.', signatures: ['new TextDecoder(label?: string)'] },
|
|
41
|
+
];
|
|
42
|
+
/**
|
|
43
|
+
* A write-through console for one package, tagging every line with the package
|
|
44
|
+
* id. Write-through (host stdout/stderr), NOT buffered into the tool result:
|
|
45
|
+
* a registered listener fires long after the run call returned, and its output
|
|
46
|
+
* must land somewhere the user can see — for a terminal entry point, the host terminal.
|
|
47
|
+
*/
|
|
48
|
+
function taggedConsole(id) {
|
|
49
|
+
const tag = `[cordis:${id}]`;
|
|
50
|
+
const log = (...args) => { console.log(tag, ...args); };
|
|
51
|
+
const error = (...args) => { console.error(tag, ...args); };
|
|
52
|
+
return { log, info: log, warn: log, debug: log, error };
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Patch only VM constructors so `instanceof` accepts both VM values and host values passed as
|
|
56
|
+
* arguments, events, or service results; host intrinsics remain untouched.
|
|
57
|
+
*/
|
|
58
|
+
const DUAL_REALM_INSTANCEOF_PRELUDE = `
|
|
59
|
+
(hostIntrinsics) => {
|
|
60
|
+
'use strict'
|
|
61
|
+
const ordinary = Function.prototype[Symbol.hasInstance]
|
|
62
|
+
for (const name of Object.keys(hostIntrinsics)) {
|
|
63
|
+
const VmCtor = globalThis[name]
|
|
64
|
+
const HostCtor = hostIntrinsics[name]
|
|
65
|
+
if (typeof VmCtor !== 'function' || typeof HostCtor !== 'function') continue
|
|
66
|
+
Object.defineProperty(VmCtor, Symbol.hasInstance, {
|
|
67
|
+
value: (instance) => ordinary.call(VmCtor, instance) || ordinary.call(HostCtor, instance),
|
|
68
|
+
configurable: true,
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
`;
|
|
73
|
+
/** Run {@link DUAL_REALM_INSTANCEOF_PRELUDE} in a freshly created sandbox, handing it the host intrinsics to pair up. */
|
|
74
|
+
function patchDualRealmInstanceof(sandbox) {
|
|
75
|
+
const patch = runInContext(DUAL_REALM_INSTANCEOF_PRELUDE, sandbox);
|
|
76
|
+
patch({ Object, Array, Function, Error, TypeError, RangeError, SyntaxError, Promise, RegExp, Date, Map, Set });
|
|
77
|
+
}
|
|
78
|
+
const TIMER_REDIRECT = 'Node timers are unavailable. Use the cordis timer service instead: declare inject: [\'timer\'] on your plugin '
|
|
79
|
+
+ 'and call ctx.timeout / ctx.interval after querying Host Service.listService for the exact overloads. '
|
|
80
|
+
+ 'Those calls are fiber effects, cleaned up automatically when stopped.';
|
|
81
|
+
/**
|
|
82
|
+
* The callable Node APIs the sandbox deliberately disables, each mapped to the
|
|
83
|
+
* cordis alternative its trap error names. Only function-valued globals are
|
|
84
|
+
* trapped; a data-valued global such as `process` stays `undefined`, because a
|
|
85
|
+
* throwing accessor would detonate the common `typeof process` feature probe
|
|
86
|
+
* at resolution time.
|
|
87
|
+
*/
|
|
88
|
+
const NODE_API_REDIRECTS = {
|
|
89
|
+
require: 'Node modules are unavailable. Use the cordis services on ctx instead — e.g. inject: [\'fs\'] for files, '
|
|
90
|
+
+ '[\'web\'] for HTTP, [\'bash\'] for processes; query Service.listService with cordis_inspect_query first.',
|
|
91
|
+
setTimeout: TIMER_REDIRECT,
|
|
92
|
+
setInterval: TIMER_REDIRECT,
|
|
93
|
+
setImmediate: TIMER_REDIRECT,
|
|
94
|
+
clearTimeout: TIMER_REDIRECT,
|
|
95
|
+
clearInterval: TIMER_REDIRECT,
|
|
96
|
+
fetch: 'Network access goes through the cordis web service: declare inject: [\'web\'] and call ctx.web '
|
|
97
|
+
+ '(query Host Service.listService with cordis_inspect_query for its methods).',
|
|
98
|
+
};
|
|
99
|
+
/** Build the trap functions for {@link NODE_API_REDIRECTS}: calling one throws the redirect. */
|
|
100
|
+
function nodeApiTraps() {
|
|
101
|
+
const traps = {};
|
|
102
|
+
for (const [name, redirect] of Object.entries(NODE_API_REDIRECTS)) {
|
|
103
|
+
traps[name] = () => {
|
|
104
|
+
throw new Error(`${name} is not available in the dynamic package sandbox — ${redirect}`);
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
return traps;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Build the vm context one host half evaluates in: the tagged console, the
|
|
111
|
+
* `harness` registration helpers, the encoding primitives, the Node-API traps,
|
|
112
|
+
* and the dual-realm `instanceof` patch, already `createContext`-ed.
|
|
113
|
+
* @param id - the package id (`dyn-<n>`), used as the console tag and filename stem.
|
|
114
|
+
* @param harnessExtras - per-package `harness` verbs beyond the registration pair (`handle`).
|
|
115
|
+
* @returns the contextified sandbox object to pass to {@link evaluateHostCode}.
|
|
116
|
+
*/
|
|
117
|
+
export function createSandbox(id, harnessExtras = {}) {
|
|
118
|
+
const sandbox = {
|
|
119
|
+
...nodeApiTraps(),
|
|
120
|
+
console: taggedConsole(id),
|
|
121
|
+
harness: { defineTool: sandboxDefineTool, registerTool: sandboxRegisterTool, ...harnessExtras },
|
|
122
|
+
// Web APIs absent from fresh vm contexts — made available so the model
|
|
123
|
+
// can encode/decode base64 without Buffer (which is also absent). Host
|
|
124
|
+
// closures over Buffer, never Buffer itself.
|
|
125
|
+
btoa: (s) => Buffer.from(s, 'utf-8').toString('base64'),
|
|
126
|
+
atob: (s) => Buffer.from(s, 'base64').toString('utf-8'),
|
|
127
|
+
TextEncoder,
|
|
128
|
+
TextDecoder,
|
|
129
|
+
};
|
|
130
|
+
createContext(sandbox);
|
|
131
|
+
patchDualRealmInstanceof(sandbox);
|
|
132
|
+
return sandbox;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Cross-realm SyntaxError detection: a compile failure inside `runInContext`
|
|
136
|
+
* constructs its error in the SANDBOX realm, so a host `instanceof
|
|
137
|
+
* SyntaxError` is silently false — the `name` property is the realm-safe tag.
|
|
138
|
+
*/
|
|
139
|
+
function isSyntaxError(error) {
|
|
140
|
+
return typeof error === 'object' && error !== null && error.name === 'SyntaxError';
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* The parse-failure context a vm `SyntaxError` carries: the vm prints the
|
|
144
|
+
* offending source line and a caret before the message, which is exactly what
|
|
145
|
+
* a model needs to self-correct — surface it instead of the bare message.
|
|
146
|
+
* Falls back to `String(error)` when the stack carries no such prelude.
|
|
147
|
+
* @param error - the `SyntaxError` (host- or sandbox-realm) thrown while compiling package code.
|
|
148
|
+
* @returns the stack prefix up to and including the `SyntaxError: …` line.
|
|
149
|
+
*/
|
|
150
|
+
export function syntaxErrorContext(error) {
|
|
151
|
+
const lines = (error.stack ?? '').split('\n');
|
|
152
|
+
const messageIndex = lines.findIndex(line => line.startsWith('SyntaxError'));
|
|
153
|
+
if (messageIndex === -1)
|
|
154
|
+
return String(error);
|
|
155
|
+
return lines.slice(0, messageIndex + 1).join('\n');
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The teaching text one parse failure produces, shared by the define-time
|
|
159
|
+
* precheck and the run-time evaluation so a model reads the same diagnosis
|
|
160
|
+
* whichever verb caught it.
|
|
161
|
+
* @param half - which half failed to parse, named as the define argument that carried it.
|
|
162
|
+
* @param context - the {@link syntaxErrorContext} of the failure.
|
|
163
|
+
* @returns the model-facing error message.
|
|
164
|
+
*/
|
|
165
|
+
export function parseErrorMessage(half, context) {
|
|
166
|
+
// Scope the TypeScript heuristic to the OFFENDING line, not the whole code:
|
|
167
|
+
// an ` as ` inside an ordinary description string must not turn a plain
|
|
168
|
+
// syntax error into a misleading remove-annotations message.
|
|
169
|
+
const offendingLine = context.split('\n')[1] ?? '';
|
|
170
|
+
if (/\bas\b/.test(offendingLine)) {
|
|
171
|
+
return `dynamic package \`${half}\` failed to parse:\n${context}\n`
|
|
172
|
+
+ 'The sandbox runs plain JavaScript, not TypeScript. Remove type annotations:\n'
|
|
173
|
+
+ ' ✗ { type: \'text\' as const, text: x }\n'
|
|
174
|
+
+ ' ✓ { type: \'text\', text: x }';
|
|
175
|
+
}
|
|
176
|
+
return `dynamic package \`${half}\` failed to parse:\n${context}\n`
|
|
177
|
+
+ 'Note: it runs as the BODY of an async function (line numbers are offset by the 1-line wrapper). '
|
|
178
|
+
+ 'Check bracket balance — ending the returned plugin object with `});` closes a call that was never opened; '
|
|
179
|
+
+ 'a plain `return { … }` ends with `}` (an optional `;`), never `)`.';
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Parse one half's source without running it: the define-time precheck that
|
|
183
|
+
* keeps unparseable code out of the registry, so a model fixes it and defines
|
|
184
|
+
* again instead of discovering the failure at run time. `new Function` is the
|
|
185
|
+
* gate — hosts without a real `node:vm` (the browser worker) still refuse
|
|
186
|
+
* unparseable code — and `vm.Script` is only the best-effort prettifier: on a
|
|
187
|
+
* Node host its failure carries the source-line-and-caret prelude the
|
|
188
|
+
* teaching text builds on, and where the vm is a stub the message stays bare.
|
|
189
|
+
* The two parsers' syntax faces differ at the margin (`new.target` parses in
|
|
190
|
+
* a function body but not at the vm wrapper's top level), an accepted cost of
|
|
191
|
+
* a vm-free gate; and under a page CSP without `'unsafe-eval'`, `new Function`
|
|
192
|
+
* throws `EvalError`, which propagates unwrapped.
|
|
193
|
+
* @param code - the model-written function body.
|
|
194
|
+
* @param half - which define argument carried it, for the error text.
|
|
195
|
+
* @throws when the body does not parse, with the offending line and a teaching hint.
|
|
196
|
+
*/
|
|
197
|
+
export function precheckCode(code, half) {
|
|
198
|
+
const wrapped = `(async () => {\n${code}\n})()`;
|
|
199
|
+
try {
|
|
200
|
+
// Compile-only: constructing the function parses the source and runs nothing.
|
|
201
|
+
// oxlint-disable-next-line typescript/no-implied-eval -- parse gate over model-written code; nothing is invoked
|
|
202
|
+
new Function(wrapped);
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
if (!isSyntaxError(error))
|
|
206
|
+
throw error;
|
|
207
|
+
throw new Error(parseErrorMessage(half, prettyParseContext(wrapped, half, error)));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Best-effort vm recompile of a body `new Function` already refused, for the
|
|
212
|
+
* source-line-and-caret prelude only.
|
|
213
|
+
* @param wrapped - the wrapped source that failed to parse.
|
|
214
|
+
* @param half - which define argument carried it, for the vm filename.
|
|
215
|
+
* @param refusal - the gate's own `SyntaxError`, the fallback context source.
|
|
216
|
+
* @returns the vm prelude when a real vm produced one, else the bare refusal.
|
|
217
|
+
*/
|
|
218
|
+
function prettyParseContext(wrapped, half, refusal) {
|
|
219
|
+
try {
|
|
220
|
+
new Script(wrapped, { filename: `cordis-dyn-${half}.js` });
|
|
221
|
+
}
|
|
222
|
+
catch (vmError) {
|
|
223
|
+
if (isSyntaxError(vmError))
|
|
224
|
+
return syntaxErrorContext(vmError);
|
|
225
|
+
// A stubbed vm (the browser worker) refuses Script itself; the gate's
|
|
226
|
+
// error is the only context there is.
|
|
227
|
+
}
|
|
228
|
+
return String(refusal);
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Evaluate a host half as the body of an async function inside the sandbox. `vmTimeoutMs` only
|
|
232
|
+
* bounds the SYNCHRONOUS portion; an async body escapes it — acceptable under the module's
|
|
233
|
+
* trust stance. Parse errors include the offending line and a TypeScript-removal or bracket-
|
|
234
|
+
* balance hint.
|
|
235
|
+
* @param sandbox - the contextified object from {@link createSandbox}.
|
|
236
|
+
* @param code - the model-written function body; must `return` a plugin.
|
|
237
|
+
* @param id - the package id, used as the vm filename (`cordis-dyn-<id>.js`).
|
|
238
|
+
* @param vmTimeoutMs - the synchronous evaluation bound in milliseconds.
|
|
239
|
+
* @returns whatever the code returned, still un-narrowed (the run lifecycle checks plugin shape).
|
|
240
|
+
*/
|
|
241
|
+
export async function evaluateHostCode(sandbox, code, id, vmTimeoutMs) {
|
|
242
|
+
try {
|
|
243
|
+
return await runInContext(`(async () => {\n${code}\n})()`, sandbox, { filename: `cordis-dyn-${id}.js`, timeout: vmTimeoutMs });
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
if (!isSyntaxError(error))
|
|
247
|
+
throw error;
|
|
248
|
+
throw new Error(parseErrorMessage('code.host', syntaxErrorContext(error)));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
//# sourceMappingURL=sandbox.js.map
|