@alisio/core 0.5.1 → 0.6.0
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/dist/analysis/janitor.d.ts +48 -0
- package/dist/analysis/janitor.js +170 -18
- package/dist/analysis/jobs.d.ts +11 -2
- package/dist/analysis/jobs.js +13 -0
- package/dist/analysis/oci.d.ts +11 -2
- package/dist/analysis/oci.js +20 -4
- package/dist/analysis/python/sources.js +2 -1
- package/dist/analysis/rerun.d.ts +1 -1
- package/dist/analysis/status.d.ts +3 -1
- package/dist/analysis/status.js +3 -0
- package/dist/application.d.ts +38 -2
- package/dist/application.js +213 -4
- package/dist/changelog/data.js +64 -0
- package/dist/config.d.ts +110 -2
- package/dist/config.js +188 -6
- package/dist/core/call-internals.d.ts +22 -0
- package/dist/core/call-internals.js +9 -0
- package/dist/core/contracts.d.ts +2 -2
- package/dist/core/runner.js +111 -38
- package/dist/forge/broker.d.ts +51 -0
- package/dist/forge/broker.js +244 -0
- package/dist/forge/budget.d.ts +27 -0
- package/dist/forge/budget.js +60 -0
- package/dist/forge/catalog-view.d.ts +24 -0
- package/dist/forge/catalog-view.js +43 -0
- package/dist/forge/errors.d.ts +26 -0
- package/dist/forge/errors.js +49 -0
- package/dist/forge/guides/javascript.d.ts +25 -0
- package/dist/forge/guides/javascript.js +90 -0
- package/dist/forge/guides/provider.d.ts +42 -0
- package/dist/forge/guides/provider.js +96 -0
- package/dist/forge/guides/python-environment.d.ts +15 -0
- package/dist/forge/guides/python-environment.js +81 -0
- package/dist/forge/guides/python.d.ts +38 -0
- package/dist/forge/guides/python.js +170 -0
- package/dist/forge/javascript/engine.d.ts +44 -0
- package/dist/forge/javascript/engine.js +229 -0
- package/dist/forge/javascript/hosts/factory.d.ts +25 -0
- package/dist/forge/javascript/hosts/factory.js +21 -0
- package/dist/forge/javascript/hosts/framing.d.ts +17 -0
- package/dist/forge/javascript/hosts/framing.js +49 -0
- package/dist/forge/javascript/hosts/isolate-main.d.ts +9 -0
- package/dist/forge/javascript/hosts/isolate-main.js +63 -0
- package/dist/forge/javascript/hosts/process-entry.d.ts +1 -0
- package/dist/forge/javascript/hosts/process-entry.js +3 -0
- package/dist/forge/javascript/hosts/process-host.d.ts +33 -0
- package/dist/forge/javascript/hosts/process-host.js +160 -0
- package/dist/forge/javascript/hosts/worker-entry.d.ts +1 -0
- package/dist/forge/javascript/hosts/worker-entry.js +3 -0
- package/dist/forge/javascript/hosts/worker-host.d.ts +18 -0
- package/dist/forge/javascript/hosts/worker-host.js +101 -0
- package/dist/forge/javascript/prelude.d.ts +2 -0
- package/dist/forge/javascript/prelude.js +333 -0
- package/dist/forge/javascript/probe.d.ts +2 -0
- package/dist/forge/javascript/probe.js +53 -0
- package/dist/forge/javascript/runtime-manifest.d.ts +14 -0
- package/dist/forge/javascript/runtime-manifest.js +14 -0
- package/dist/forge/javascript/vm-runner.d.ts +21 -0
- package/dist/forge/javascript/vm-runner.js +301 -0
- package/dist/forge/javascript/wasm-asset.d.ts +8 -0
- package/dist/forge/javascript/wasm-asset.js +8 -0
- package/dist/forge/javascript/wasm.d.ts +15 -0
- package/dist/forge/javascript/wasm.js +54 -0
- package/dist/forge/journal.d.ts +105 -0
- package/dist/forge/journal.js +174 -0
- package/dist/forge/kernel.d.ts +76 -0
- package/dist/forge/kernel.js +338 -0
- package/dist/forge/limits.d.ts +38 -0
- package/dist/forge/limits.js +73 -0
- package/dist/forge/nested.d.ts +96 -0
- package/dist/forge/nested.js +214 -0
- package/dist/forge/ports.d.ts +120 -0
- package/dist/forge/ports.js +1 -0
- package/dist/forge/progress.d.ts +24 -0
- package/dist/forge/progress.js +76 -0
- package/dist/forge/protocol.d.ts +110 -0
- package/dist/forge/protocol.js +171 -0
- package/dist/forge/python/backend-selector.d.ts +38 -0
- package/dist/forge/python/backend-selector.js +111 -0
- package/dist/forge/python/backend.d.ts +89 -0
- package/dist/forge/python/backend.js +1 -0
- package/dist/forge/python/engine.d.ts +84 -0
- package/dist/forge/python/engine.js +279 -0
- package/dist/forge/python/frame-parser.d.ts +24 -0
- package/dist/forge/python/frame-parser.js +107 -0
- package/dist/forge/python/frame-transport.d.ts +40 -0
- package/dist/forge/python/frame-transport.js +101 -0
- package/dist/forge/python/import-scan.d.ts +22 -0
- package/dist/forge/python/import-scan.js +133 -0
- package/dist/forge/python/managed-backend.d.ts +30 -0
- package/dist/forge/python/managed-backend.js +111 -0
- package/dist/forge/python/oci-backend.d.ts +27 -0
- package/dist/forge/python/oci-backend.js +84 -0
- package/dist/forge/python/pyodide-backend.d.ts +40 -0
- package/dist/forge/python/pyodide-backend.js +289 -0
- package/dist/forge/python/pyodide-install.d.ts +41 -0
- package/dist/forge/python/pyodide-install.js +167 -0
- package/dist/forge/python/pyodide-manifest.d.ts +42 -0
- package/dist/forge/python/pyodide-manifest.js +376 -0
- package/dist/forge/python/pyodide-runner-source.d.ts +2 -0
- package/dist/forge/python/pyodide-runner-source.js +3 -0
- package/dist/forge/python/rss.d.ts +2 -0
- package/dist/forge/python/rss.js +33 -0
- package/dist/forge/python/scratch-copy.d.ts +6 -0
- package/dist/forge/python/scratch-copy.js +61 -0
- package/dist/forge/python/settle.d.ts +6 -0
- package/dist/forge/python/settle.js +21 -0
- package/dist/forge/python/status.d.ts +8 -0
- package/dist/forge/python/status.js +15 -0
- package/dist/forge/python/wasm-node.d.ts +59 -0
- package/dist/forge/python/wasm-node.js +220 -0
- package/dist/forge/python/wiring.d.ts +39 -0
- package/dist/forge/python/wiring.js +47 -0
- package/dist/forge/registry.d.ts +26 -0
- package/dist/forge/registry.js +83 -0
- package/dist/forge/result.d.ts +58 -0
- package/dist/forge/result.js +86 -0
- package/dist/forge/routing-rule.d.ts +19 -0
- package/dist/forge/routing-rule.js +31 -0
- package/dist/forge/runtime.d.ts +64 -0
- package/dist/forge/runtime.js +88 -0
- package/dist/forge/scratch-files.d.ts +36 -0
- package/dist/forge/scratch-files.js +94 -0
- package/dist/forge/scratch-provider.d.ts +41 -0
- package/dist/forge/scratch-provider.js +79 -0
- package/dist/forge/scratch-quota.d.ts +8 -0
- package/dist/forge/scratch-quota.js +25 -0
- package/dist/forge/selector.d.ts +35 -0
- package/dist/forge/selector.js +51 -0
- package/dist/forge/session-dir.d.ts +59 -0
- package/dist/forge/session-dir.js +239 -0
- package/dist/forge/session-path.d.ts +31 -0
- package/dist/forge/session-path.js +61 -0
- package/dist/forge/slots.d.ts +16 -0
- package/dist/forge/slots.js +47 -0
- package/dist/forge/state-delta.d.ts +54 -0
- package/dist/forge/state-delta.js +99 -0
- package/dist/forge/state-machine.d.ts +20 -0
- package/dist/forge/state-machine.js +40 -0
- package/dist/forge/state-store.d.ts +25 -0
- package/dist/forge/state-store.js +50 -0
- package/dist/forge/task-log.d.ts +9 -0
- package/dist/forge/task-log.js +42 -0
- package/dist/forge/verdict.d.ts +20 -0
- package/dist/forge/verdict.js +28 -0
- package/dist/forge-isolate.d.ts +6 -0
- package/dist/forge-isolate.js +6 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/permissions/decide.d.ts +42 -0
- package/dist/permissions/decide.js +24 -0
- package/dist/resources/context.d.ts +2 -1
- package/dist/resources/context.js +2 -1
- package/dist/runtime/access.d.ts +34 -0
- package/dist/runtime/access.js +80 -0
- package/dist/runtime/process.d.ts +2 -1
- package/dist/runtime/process.js +5 -5
- package/dist/runtime/store.d.ts +11 -0
- package/dist/runtime/store.js +103 -0
- package/dist/tools/analysis.d.ts +25 -2
- package/dist/tools/analysis.js +315 -267
- package/dist/tools/execute.js +6 -7
- package/dist/tools/forge-guide.d.ts +6 -0
- package/dist/tools/forge-guide.js +60 -0
- package/dist/tools/js-run.d.ts +19 -0
- package/dist/tools/js-run.js +126 -0
- package/dist/tools/standard.d.ts +7 -1
- package/dist/tools/standard.js +44 -43
- package/package.json +7 -2
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `node` that runs Pyodide for the `wasm` backend (Python spec §8.2) and the facts about it:
|
|
3
|
+
* which binary (the running Node, or under Bun the first supported `node` on PATH (absolute entries only), or the configured
|
|
4
|
+
* one), the Permission Model flags it is started with, and the network self-probe (D-32). The
|
|
5
|
+
* probe starts the REAL child with the same flags and tries to connect to a local port: whether
|
|
6
|
+
* the network is denied is observed, never deduced from a version number.
|
|
7
|
+
*/
|
|
8
|
+
import { execFile, spawn } from "node:child_process";
|
|
9
|
+
import { accessSync, constants } from "node:fs";
|
|
10
|
+
import { createServer } from "node:net";
|
|
11
|
+
import { delimiter, isAbsolute, join } from "node:path";
|
|
12
|
+
/**
|
|
13
|
+
* The first release of each line that fixes CVE-2025-55130 (December 2025 security releases): the
|
|
14
|
+
* Permission Model could be escaped with crafted symlinks, and this backend depends on it. Lines
|
|
15
|
+
* not listed (23.x, never fixed, and everything older) are refused; newer majors are accepted.
|
|
16
|
+
* Source: https://nodejs.org/en/blog/vulnerability/december-2025-security-releases
|
|
17
|
+
*/
|
|
18
|
+
const FIXED_LINES = { 22: 22, 24: 13, 25: 3 };
|
|
19
|
+
export const MIN_NODE = { major: 22, minor: 22 };
|
|
20
|
+
const MIN_TEXT = "22.22, 24.13, 25.3 or 26+";
|
|
21
|
+
const CVE_NOTE = "earlier releases have the Permission Model symlink bypass CVE-2025-55130";
|
|
22
|
+
export const currentNodeEnvironment = () => ({
|
|
23
|
+
isBun: !!process.versions.bun,
|
|
24
|
+
execPath: process.execPath,
|
|
25
|
+
version: process.version,
|
|
26
|
+
pathEnv: process.env.PATH ?? "",
|
|
27
|
+
platform: process.platform,
|
|
28
|
+
});
|
|
29
|
+
/** `v22.22.0` -> true: at least the release of its line that fixed CVE-2025-55130. */
|
|
30
|
+
export function isSupportedNode(version) {
|
|
31
|
+
const match = /^v?(\d+)\.(\d+)/.exec(version.trim());
|
|
32
|
+
if (!match)
|
|
33
|
+
return false;
|
|
34
|
+
const major = Number(match[1]);
|
|
35
|
+
const minor = Number(match[2]);
|
|
36
|
+
if (major > 25)
|
|
37
|
+
return true;
|
|
38
|
+
const fixed = FIXED_LINES[major];
|
|
39
|
+
return fixed !== undefined && minor >= fixed;
|
|
40
|
+
}
|
|
41
|
+
function versionOf(binary) {
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
execFile(binary, ["--version"], { timeout: 5000, env: {}, windowsHide: true }, (error, out) => resolve(error ? undefined : String(out).trim()));
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
const executable = (path) => {
|
|
47
|
+
try {
|
|
48
|
+
accessSync(path, constants.X_OK);
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
const cache = new Map();
|
|
56
|
+
/** Finds the node to run Pyodide with; only successes are remembered. */
|
|
57
|
+
export async function resolveNode(configured, environment = currentNodeEnvironment()) {
|
|
58
|
+
const key = `${configured ?? ""}|${environment.isBun}|${environment.execPath}|${environment.pathEnv}`;
|
|
59
|
+
const hit = cache.get(key);
|
|
60
|
+
if (hit)
|
|
61
|
+
return hit;
|
|
62
|
+
const found = await (async () => {
|
|
63
|
+
if (configured) {
|
|
64
|
+
const version = await versionOf(configured);
|
|
65
|
+
if (!version)
|
|
66
|
+
return { ok: false, reason: `analysis.wasm.nodePath (${configured}) did not run` };
|
|
67
|
+
return isSupportedNode(version)
|
|
68
|
+
? { ok: true, path: configured, version }
|
|
69
|
+
: {
|
|
70
|
+
ok: false,
|
|
71
|
+
reason: `analysis.wasm.nodePath is Node ${version}; Pyodide needs Node ${MIN_TEXT} (${CVE_NOTE})`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
if (!environment.isBun) {
|
|
75
|
+
return isSupportedNode(environment.version)
|
|
76
|
+
? { ok: true, path: environment.execPath, version: environment.version }
|
|
77
|
+
: {
|
|
78
|
+
ok: false,
|
|
79
|
+
reason: `Pyodide needs Node ${MIN_TEXT} (running ${environment.version}; ${CVE_NOTE})`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
const names = environment.platform === "win32" ? ["node.exe"] : ["node"];
|
|
83
|
+
let seen;
|
|
84
|
+
// A relative entry (`.`, `bin`) would run whatever the current folder holds.
|
|
85
|
+
for (const dir of environment.pathEnv.split(delimiter).filter(isAbsolute)) {
|
|
86
|
+
for (const name of names) {
|
|
87
|
+
const candidate = join(dir, name);
|
|
88
|
+
if (!executable(candidate))
|
|
89
|
+
continue;
|
|
90
|
+
const version = await versionOf(candidate);
|
|
91
|
+
if (!version)
|
|
92
|
+
continue;
|
|
93
|
+
if (isSupportedNode(version))
|
|
94
|
+
return { ok: true, path: candidate, version };
|
|
95
|
+
seen ??= version;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
ok: false,
|
|
100
|
+
reason: seen
|
|
101
|
+
? `the only node on PATH is ${seen}; Pyodide needs Node ${MIN_TEXT} (${CVE_NOTE}; or set analysis.wasm.nodePath)`
|
|
102
|
+
: `Pyodide runs in a Node child process and no node ${MIN_TEXT} was found on PATH (install Node.js or set analysis.wasm.nodePath)`,
|
|
103
|
+
};
|
|
104
|
+
})();
|
|
105
|
+
if (found.ok)
|
|
106
|
+
cache.set(key, found);
|
|
107
|
+
return found;
|
|
108
|
+
}
|
|
109
|
+
/** Clears remembered resolutions and probes (tests, and a changed PATH). */
|
|
110
|
+
export function resetNodeCaches() {
|
|
111
|
+
cache.clear();
|
|
112
|
+
probes.clear();
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* The flags of the restricted child: the Permission Model with ONE flag per path (a comma list is
|
|
116
|
+
* invalid in Node 22), reading only the distribution and the job, writing only `work/` and `out/`.
|
|
117
|
+
* No `--allow-child-process`, `--allow-worker`, `--allow-addons` or `--allow-wasi`; on Node >= 25
|
|
118
|
+
* `--allow-net` is likewise left out.
|
|
119
|
+
*/
|
|
120
|
+
export function permissionArgs(paths) {
|
|
121
|
+
return [
|
|
122
|
+
"--permission",
|
|
123
|
+
"--no-warnings",
|
|
124
|
+
...paths.read.map((p) => `--allow-fs-read=${p}`),
|
|
125
|
+
...paths.write.map((p) => `--allow-fs-write=${p}`),
|
|
126
|
+
];
|
|
127
|
+
}
|
|
128
|
+
/** Environment of the child: nothing but the job's `ALISIO_*` variables. */
|
|
129
|
+
export function childEnv(vars, platform = process.platform) {
|
|
130
|
+
const env = {};
|
|
131
|
+
for (const [key, value] of Object.entries(vars))
|
|
132
|
+
if (key.startsWith("ALISIO_"))
|
|
133
|
+
env[key] = value;
|
|
134
|
+
// Windows cannot start a process without these.
|
|
135
|
+
if (platform === "win32" && process.env.SystemRoot)
|
|
136
|
+
env.SystemRoot = process.env.SystemRoot;
|
|
137
|
+
return env;
|
|
138
|
+
}
|
|
139
|
+
const probes = new Map();
|
|
140
|
+
const PROBE_SCRIPT = `
|
|
141
|
+
import net from "node:net";
|
|
142
|
+
const denied = (e) => { console.log("DENIED " + (e && e.code)); process.exit(0); };
|
|
143
|
+
try {
|
|
144
|
+
const socket = net.connect(Number(process.argv[1]), "127.0.0.1");
|
|
145
|
+
socket.on("connect", () => { console.log("CONNECTED"); process.exit(0); });
|
|
146
|
+
socket.on("error", denied);
|
|
147
|
+
} catch (e) { denied(e); }
|
|
148
|
+
setTimeout(() => { console.log("TIMEOUT"); process.exit(0); }, 4000);
|
|
149
|
+
`;
|
|
150
|
+
/**
|
|
151
|
+
* Starts `node` with the real flags and tries to reach a port of ours. Remembered per binary and
|
|
152
|
+
* version (the flags do not change what the network flag does). Inconclusive means "not denied".
|
|
153
|
+
*/
|
|
154
|
+
export function probeNetwork(node, args) {
|
|
155
|
+
const key = `${node.path}@${node.version}`;
|
|
156
|
+
let probe = probes.get(key);
|
|
157
|
+
if (!probe) {
|
|
158
|
+
probe = runProbe(node.path, args);
|
|
159
|
+
probes.set(key, probe);
|
|
160
|
+
// Only a conclusive answer is remembered: an inconclusive one is asked again next time.
|
|
161
|
+
void probe.then((result) => {
|
|
162
|
+
if (!result.conclusive && probes.get(key) === probe)
|
|
163
|
+
probes.delete(key);
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
return probe;
|
|
167
|
+
}
|
|
168
|
+
async function runProbe(path, args) {
|
|
169
|
+
let hits = 0;
|
|
170
|
+
const server = createServer((socket) => {
|
|
171
|
+
hits++;
|
|
172
|
+
socket.destroy();
|
|
173
|
+
});
|
|
174
|
+
try {
|
|
175
|
+
await new Promise((resolve, reject) => {
|
|
176
|
+
server.once("error", reject);
|
|
177
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
178
|
+
});
|
|
179
|
+
const address = server.address();
|
|
180
|
+
const port = typeof address === "object" && address ? address.port : 0;
|
|
181
|
+
const output = await new Promise((resolve) => {
|
|
182
|
+
const child = spawn(path, [...args, "--input-type=module", "-e", PROBE_SCRIPT, String(port)], { env: childEnv({}), stdio: ["ignore", "pipe", "ignore"], windowsHide: true });
|
|
183
|
+
let text = "";
|
|
184
|
+
child.stdout.on("data", (chunk) => {
|
|
185
|
+
text += chunk;
|
|
186
|
+
});
|
|
187
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), 10_000);
|
|
188
|
+
timer.unref?.();
|
|
189
|
+
child.once("error", () => resolve(""));
|
|
190
|
+
child.once("close", () => {
|
|
191
|
+
clearTimeout(timer);
|
|
192
|
+
resolve(text.trim());
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
if (hits > 0)
|
|
196
|
+
return {
|
|
197
|
+
denied: false,
|
|
198
|
+
conclusive: true,
|
|
199
|
+
detail: "a connection to 127.0.0.1 from the child succeeded",
|
|
200
|
+
};
|
|
201
|
+
const code = /^DENIED (\S+)/.exec(output)?.[1];
|
|
202
|
+
if (code === "ERR_ACCESS_DENIED")
|
|
203
|
+
return { denied: true, conclusive: true, detail: `the Permission Model refused (${output})` };
|
|
204
|
+
return {
|
|
205
|
+
denied: false,
|
|
206
|
+
conclusive: false,
|
|
207
|
+
detail: `probe inconclusive: ${code ?? (output || "the child did not answer")}`,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
return {
|
|
212
|
+
denied: false,
|
|
213
|
+
conclusive: false,
|
|
214
|
+
detail: `probe inconclusive: ${error instanceof Error ? error.message : String(error)}`,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
finally {
|
|
218
|
+
server.close();
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Assembly of the Python side of Forja for `python_run` (Python spec §4.1): the two backends, the
|
|
3
|
+
* two engines registered in the kernel's registry and the selector. The application hands in the
|
|
4
|
+
* kernel it shares with `js_run` (one journal, one state store, one registry); an embedder that
|
|
5
|
+
* registers the analysis tools alone gets a private kernel over the same database.
|
|
6
|
+
*/
|
|
7
|
+
import type { AnalysisJobs } from "../../analysis/jobs.ts";
|
|
8
|
+
import { ExecutionKernel } from "../kernel.ts";
|
|
9
|
+
import { EngineRegistry } from "../registry.ts";
|
|
10
|
+
import type { PythonBackend, PythonBackendId } from "./backend.ts";
|
|
11
|
+
import { createPythonBackendSelector } from "./backend-selector.ts";
|
|
12
|
+
import { type ManagedBackendDeps } from "./managed-backend.ts";
|
|
13
|
+
import { type OciBackendDeps } from "./oci-backend.ts";
|
|
14
|
+
import { type PyodideBackendDeps } from "./pyodide-backend.ts";
|
|
15
|
+
export interface PythonBackendOptions {
|
|
16
|
+
runtime: ManagedBackendDeps["runtime"];
|
|
17
|
+
oci?: OciBackendDeps["oci"];
|
|
18
|
+
/** The Pyodide backend; without it `wasm` is not available in this process. */
|
|
19
|
+
wasm?: PyodideBackendDeps;
|
|
20
|
+
}
|
|
21
|
+
/** The backends of one process, in no particular order (the selector orders them). */
|
|
22
|
+
export declare function createPythonBackends(options: PythonBackendOptions): Partial<Record<PythonBackendId, PythonBackend>>;
|
|
23
|
+
export interface PythonForgeOptions extends PythonBackendOptions {
|
|
24
|
+
jobs: AnalysisJobs;
|
|
25
|
+
/** The application's kernel and registry. Without them a private pair is built. */
|
|
26
|
+
shared?: {
|
|
27
|
+
kernel: ExecutionKernel;
|
|
28
|
+
engines: EngineRegistry;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export interface PythonForge {
|
|
32
|
+
kernel: ExecutionKernel;
|
|
33
|
+
engines: EngineRegistry;
|
|
34
|
+
backends: Partial<Record<PythonBackendId, PythonBackend>>;
|
|
35
|
+
selector: ReturnType<typeof createPythonBackendSelector>;
|
|
36
|
+
/** Registers the engines in the registry (once). Await it before the first execution. */
|
|
37
|
+
ready(): Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
export declare function createPythonForge(options: PythonForgeOptions): PythonForge;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { SqliteExecutionJournal } from "../journal.js";
|
|
2
|
+
import { ExecutionKernel } from "../kernel.js";
|
|
3
|
+
import { EngineRegistry } from "../registry.js";
|
|
4
|
+
import { createJavaScriptSelector } from "../selector.js";
|
|
5
|
+
import { SlotTable } from "../slots.js";
|
|
6
|
+
import { SqliteStateStore } from "../state-store.js";
|
|
7
|
+
import { createPythonBackendSelector } from "./backend-selector.js";
|
|
8
|
+
import { PythonEngine } from "./engine.js";
|
|
9
|
+
import { ManagedBackend } from "./managed-backend.js";
|
|
10
|
+
import { OciBackend } from "./oci-backend.js";
|
|
11
|
+
import { PyodideBackend } from "./pyodide-backend.js";
|
|
12
|
+
/** The backends of one process, in no particular order (the selector orders them). */
|
|
13
|
+
export function createPythonBackends(options) {
|
|
14
|
+
return {
|
|
15
|
+
managed: new ManagedBackend({ runtime: options.runtime }),
|
|
16
|
+
...(options.oci ? { oci: new OciBackend({ oci: options.oci }) } : {}),
|
|
17
|
+
...(options.wasm ? { wasm: new PyodideBackend(options.wasm) } : {}),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export function createPythonForge(options) {
|
|
21
|
+
const backends = createPythonBackends(options);
|
|
22
|
+
const engines = options.shared?.engines ?? new EngineRegistry();
|
|
23
|
+
const kernel = options.shared?.kernel ??
|
|
24
|
+
new ExecutionKernel({
|
|
25
|
+
engines,
|
|
26
|
+
// Python hands the kernel its own selection; the JavaScript chain is never consulted.
|
|
27
|
+
selector: createJavaScriptSelector(),
|
|
28
|
+
journal: new SqliteExecutionJournal(options.jobs.database),
|
|
29
|
+
state: new SqliteStateStore(options.jobs.database),
|
|
30
|
+
slots: new SlotTable(),
|
|
31
|
+
config: () => ({ enabled: false, limits: {}, stateEnabled: true }),
|
|
32
|
+
});
|
|
33
|
+
let registered;
|
|
34
|
+
return {
|
|
35
|
+
kernel,
|
|
36
|
+
engines,
|
|
37
|
+
backends,
|
|
38
|
+
selector: createPythonBackendSelector(),
|
|
39
|
+
ready: () => {
|
|
40
|
+
registered ??= (async () => {
|
|
41
|
+
for (const backend of Object.values(backends))
|
|
42
|
+
await engines.register(new PythonEngine({ backend, jobs: options.jobs }));
|
|
43
|
+
})();
|
|
44
|
+
return registered;
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engine registry + null object (spec §4.2, D17). An engine is kept only when its self-description
|
|
3
|
+
* says it is ready; otherwise an `UnavailableEngine` takes its place, exposes the reason and never
|
|
4
|
+
* runs code. Fail closed: a throwing or mismatched self-check is "not ready".
|
|
5
|
+
*/
|
|
6
|
+
import type { EngineDescriptor, EngineId } from "@alisio/sdk";
|
|
7
|
+
import type { EngineOutcome, ExecutionEngine, ExecutionRequest, PreparedExecution, TerminationReport, TrustedExecutionContext } from "./ports.ts";
|
|
8
|
+
export declare class UnavailableEngine implements ExecutionEngine {
|
|
9
|
+
#private;
|
|
10
|
+
readonly id: EngineId;
|
|
11
|
+
constructor(descriptor: EngineDescriptor);
|
|
12
|
+
describe(): Promise<EngineDescriptor>;
|
|
13
|
+
prepare(_request: ExecutionRequest, _ctx: TrustedExecutionContext): Promise<PreparedExecution>;
|
|
14
|
+
run(): Promise<EngineOutcome>;
|
|
15
|
+
/** Nothing ever runs here, so there is nothing left to stop. */
|
|
16
|
+
terminate(): Promise<TerminationReport>;
|
|
17
|
+
dispose(): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
export declare class EngineRegistry {
|
|
20
|
+
#private;
|
|
21
|
+
/** Runs the engine's self-check and keeps it only if ready. Returns the descriptor in force. */
|
|
22
|
+
register(engine: ExecutionEngine): Promise<EngineDescriptor>;
|
|
23
|
+
/** The registered engine, or an unavailable one: never `undefined`, never a silent fallback. */
|
|
24
|
+
get(id: EngineId): ExecutionEngine;
|
|
25
|
+
descriptors(): Partial<Record<EngineId, EngineDescriptor>>;
|
|
26
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { ForgeError } from "./errors.js";
|
|
2
|
+
const languageOf = (id) => id.startsWith("javascript-") ? "javascript" : "python";
|
|
3
|
+
export class UnavailableEngine {
|
|
4
|
+
id;
|
|
5
|
+
#descriptor;
|
|
6
|
+
constructor(descriptor) {
|
|
7
|
+
this.id = descriptor.id;
|
|
8
|
+
this.#descriptor = descriptor;
|
|
9
|
+
}
|
|
10
|
+
async describe() {
|
|
11
|
+
return this.#descriptor;
|
|
12
|
+
}
|
|
13
|
+
#unavailable() {
|
|
14
|
+
return new ForgeError("ENGINE_UNAVAILABLE", this.#descriptor.reason ?? `engine ${this.id} is not ready`);
|
|
15
|
+
}
|
|
16
|
+
async prepare(_request, _ctx) {
|
|
17
|
+
throw this.#unavailable();
|
|
18
|
+
}
|
|
19
|
+
async run() {
|
|
20
|
+
throw this.#unavailable();
|
|
21
|
+
}
|
|
22
|
+
/** Nothing ever runs here, so there is nothing left to stop. */
|
|
23
|
+
async terminate() {
|
|
24
|
+
return { confirmed: true, method: "interrupt", elapsedMs: 0 };
|
|
25
|
+
}
|
|
26
|
+
async dispose() { }
|
|
27
|
+
}
|
|
28
|
+
/** A descriptor for an engine that cannot report one itself. */
|
|
29
|
+
function unavailableDescriptor(id, reason) {
|
|
30
|
+
return {
|
|
31
|
+
id,
|
|
32
|
+
language: languageOf(id),
|
|
33
|
+
version: "unavailable",
|
|
34
|
+
isolation: "none",
|
|
35
|
+
ready: false,
|
|
36
|
+
reason,
|
|
37
|
+
capabilities: {
|
|
38
|
+
cooperativeInterrupt: false,
|
|
39
|
+
hardTermination: false,
|
|
40
|
+
hardMemoryLimit: false,
|
|
41
|
+
networkDeniedByOS: false,
|
|
42
|
+
toolCalls: false,
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export class EngineRegistry {
|
|
47
|
+
#engines = new Map();
|
|
48
|
+
#descriptors = new Map();
|
|
49
|
+
/** Runs the engine's self-check and keeps it only if ready. Returns the descriptor in force. */
|
|
50
|
+
async register(engine) {
|
|
51
|
+
let descriptor;
|
|
52
|
+
try {
|
|
53
|
+
descriptor = await engine.describe();
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
57
|
+
descriptor = unavailableDescriptor(engine.id, `self-check failed: ${message}`);
|
|
58
|
+
}
|
|
59
|
+
if (descriptor.id !== engine.id) {
|
|
60
|
+
descriptor = unavailableDescriptor(engine.id, `self-check described ${descriptor.id} instead of ${engine.id}`);
|
|
61
|
+
}
|
|
62
|
+
if (!descriptor.ready) {
|
|
63
|
+
descriptor = {
|
|
64
|
+
...descriptor,
|
|
65
|
+
reason: descriptor.reason ?? `engine ${engine.id} is not ready`,
|
|
66
|
+
};
|
|
67
|
+
this.#engines.set(engine.id, new UnavailableEngine(descriptor));
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
this.#engines.set(engine.id, engine);
|
|
71
|
+
}
|
|
72
|
+
this.#descriptors.set(engine.id, descriptor);
|
|
73
|
+
return descriptor;
|
|
74
|
+
}
|
|
75
|
+
/** The registered engine, or an unavailable one: never `undefined`, never a silent fallback. */
|
|
76
|
+
get(id) {
|
|
77
|
+
return (this.#engines.get(id) ??
|
|
78
|
+
new UnavailableEngine(unavailableDescriptor(id, `engine ${id} is not registered`)));
|
|
79
|
+
}
|
|
80
|
+
descriptors() {
|
|
81
|
+
return Object.fromEntries(this.#descriptors);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The common result of a Forja execution (spec §5.3) and its compact text projection for the model.
|
|
3
|
+
* Pure: no I/O. `status: "succeeded"` means the program finished, not that every nested call did;
|
|
4
|
+
* `calls.failed` and `calls.denied` say the rest.
|
|
5
|
+
*/
|
|
6
|
+
import type { EngineId, IsolationLevel } from "@alisio/sdk";
|
|
7
|
+
import { type ForgeErrorCode, type GuideHint } from "./errors.ts";
|
|
8
|
+
import type { ForgeOutputItem } from "./protocol.ts";
|
|
9
|
+
export type ForgeStatus = "succeeded" | "failed" | "cancelled" | "timed_out";
|
|
10
|
+
export interface ForgeResult {
|
|
11
|
+
schemaVersion: 1;
|
|
12
|
+
executionId: string;
|
|
13
|
+
engine: EngineId;
|
|
14
|
+
isolation: IsolationLevel;
|
|
15
|
+
selectionReason: string;
|
|
16
|
+
status: ForgeStatus;
|
|
17
|
+
/** Text and JSON the script emitted explicitly, up to the retained-output limit. */
|
|
18
|
+
output: ForgeOutputItem[];
|
|
19
|
+
/** The script's `return` value. */
|
|
20
|
+
value?: unknown;
|
|
21
|
+
error?: {
|
|
22
|
+
code: ForgeErrorCode;
|
|
23
|
+
message: string;
|
|
24
|
+
hint?: GuideHint;
|
|
25
|
+
};
|
|
26
|
+
/** `failed` counts calls that errored; `denied` counts calls the permission gate refused. */
|
|
27
|
+
calls: {
|
|
28
|
+
total: number;
|
|
29
|
+
failed: number;
|
|
30
|
+
denied: number;
|
|
31
|
+
};
|
|
32
|
+
/** What the model sees was cut to the output limit. */
|
|
33
|
+
truncated: boolean;
|
|
34
|
+
metrics: {
|
|
35
|
+
wallMs: number;
|
|
36
|
+
queueMs: number;
|
|
37
|
+
outputBytes: number;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** Cuts `text` to `max` characters on a character boundary; reports whether it did. */
|
|
41
|
+
export declare function clipText(text: string, max: number): {
|
|
42
|
+
text: string;
|
|
43
|
+
clipped: boolean;
|
|
44
|
+
};
|
|
45
|
+
/** The text of the emitted items, in order; JSON items are rendered as one line each. */
|
|
46
|
+
export declare function outputText(items: readonly ForgeOutputItem[]): string;
|
|
47
|
+
/** Which guide topic explains how to recover from an error, when the cause is known. */
|
|
48
|
+
export declare function hintFor(code: ForgeErrorCode, message: string, source?: string): GuideHint | undefined;
|
|
49
|
+
/** Where a Python failure with a known cause points the model (spec §8.4). */
|
|
50
|
+
export declare function pythonHintFor(code: "PACKAGE_UNAVAILABLE" | "RUNTIME_UNAVAILABLE"): GuideHint;
|
|
51
|
+
/**
|
|
52
|
+
* The text the model receives. The value and the emitted output share the `maxChars` budget; the
|
|
53
|
+
* rest of the report (status, calls, error) is small and always kept.
|
|
54
|
+
*/
|
|
55
|
+
export declare function renderForgeResult(result: ForgeResult, maxChars: number): {
|
|
56
|
+
text: string;
|
|
57
|
+
truncated: boolean;
|
|
58
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { renderHint } from "./errors.js";
|
|
2
|
+
/** Cuts `text` to `max` characters on a character boundary; reports whether it did. */
|
|
3
|
+
export function clipText(text, max) {
|
|
4
|
+
if (text.length <= max)
|
|
5
|
+
return { text, clipped: false };
|
|
6
|
+
let end = max;
|
|
7
|
+
// Do not leave half of a surrogate pair at the cut.
|
|
8
|
+
const last = text.charCodeAt(end - 1);
|
|
9
|
+
if (last >= 0xd800 && last <= 0xdbff)
|
|
10
|
+
end -= 1;
|
|
11
|
+
return { text: text.slice(0, end), clipped: true };
|
|
12
|
+
}
|
|
13
|
+
/** The text of the emitted items, in order; JSON items are rendered as one line each. */
|
|
14
|
+
export function outputText(items) {
|
|
15
|
+
return items
|
|
16
|
+
.map((item) => (item.type === "text" ? item.text : JSON.stringify(item.value)))
|
|
17
|
+
.join("");
|
|
18
|
+
}
|
|
19
|
+
/** Which guide topic explains how to recover from an error, when the cause is known. */
|
|
20
|
+
export function hintFor(code, message, source = "") {
|
|
21
|
+
if (code === "SCRIPT_SYNTAX" &&
|
|
22
|
+
(/\bimport\b|\brequire\b|\bexport\b/.test(message) ||
|
|
23
|
+
/^\s*(import|export)\b|\brequire\s*\(/m.test(source)))
|
|
24
|
+
return {
|
|
25
|
+
topic: "js.api",
|
|
26
|
+
reason: "imports and require are not available; call tools with tools.call",
|
|
27
|
+
};
|
|
28
|
+
if (code === "TOOL_UNAVAILABLE")
|
|
29
|
+
return { topic: "js.tools", reason: "list the tools you can call with catalog.search" };
|
|
30
|
+
if (code === "OUTPUT_LIMIT")
|
|
31
|
+
return {
|
|
32
|
+
topic: "js.patterns#filtering",
|
|
33
|
+
reason: "filter, aggregate or paginate before returning data",
|
|
34
|
+
};
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
/** Where a Python failure with a known cause points the model (spec §8.4). */
|
|
38
|
+
export function pythonHintFor(code) {
|
|
39
|
+
return code === "PACKAGE_UNAVAILABLE"
|
|
40
|
+
? {
|
|
41
|
+
topic: "python.environment",
|
|
42
|
+
reason: "a package is missing in the backend that runs Python",
|
|
43
|
+
}
|
|
44
|
+
: {
|
|
45
|
+
topic: "python.environment",
|
|
46
|
+
reason: "no Python backend can run now; the guide lists the shortest fix",
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The text the model receives. The value and the emitted output share the `maxChars` budget; the
|
|
51
|
+
* rest of the report (status, calls, error) is small and always kept.
|
|
52
|
+
*/
|
|
53
|
+
export function renderForgeResult(result, maxChars) {
|
|
54
|
+
const head = [`status: ${result.status}`];
|
|
55
|
+
const tail = [
|
|
56
|
+
`calls: ${result.calls.total} total, ${result.calls.failed} failed, ${result.calls.denied} denied`,
|
|
57
|
+
];
|
|
58
|
+
let errorClipped = false;
|
|
59
|
+
if (result.error) {
|
|
60
|
+
// The message can come from the script (an error it threw): bound it like any other text.
|
|
61
|
+
const message = clipText(result.error.message, maxChars);
|
|
62
|
+
errorClipped = message.clipped;
|
|
63
|
+
tail.push(`error: ${result.error.code}: ${message.text}`);
|
|
64
|
+
if (message.clipped)
|
|
65
|
+
tail.push(`[error message truncated at ${maxChars} characters]`);
|
|
66
|
+
if (result.error.hint)
|
|
67
|
+
tail.push(`hint: ${renderHint(result.error.hint)}`);
|
|
68
|
+
}
|
|
69
|
+
const body = [];
|
|
70
|
+
if (result.value !== undefined)
|
|
71
|
+
body.push(`value: ${JSON.stringify(result.value)}`);
|
|
72
|
+
const emitted = outputText(result.output);
|
|
73
|
+
if (emitted !== "")
|
|
74
|
+
body.push(`output:\n${emitted}`);
|
|
75
|
+
const clipped = clipText(body.join("\n"), maxChars);
|
|
76
|
+
const lines = [...head];
|
|
77
|
+
if (clipped.text !== "")
|
|
78
|
+
lines.push(clipped.text);
|
|
79
|
+
if (clipped.clipped)
|
|
80
|
+
lines.push(`[output truncated at ${maxChars} characters; return less: filter, aggregate or paginate]`);
|
|
81
|
+
lines.push(...tail);
|
|
82
|
+
return {
|
|
83
|
+
text: lines.join("\n"),
|
|
84
|
+
truncated: clipped.clipped || errorClipped || result.truncated,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer 0 of the incremental context (spec §8.1, D12): a short routing rule, present in the system
|
|
3
|
+
* prompt only when a Forja engine is registered and ready. With every engine off the prompt stays
|
|
4
|
+
* byte for byte what it was. The rule is built, never stored: the lines follow the tools actually
|
|
5
|
+
* registered and the session's real scratchpad path.
|
|
6
|
+
*/
|
|
7
|
+
/** Hard bound of the rule (spec §8.1). */
|
|
8
|
+
export declare const ROUTING_RULE_MAX_CHARS = 900;
|
|
9
|
+
export interface RoutingRuleInput {
|
|
10
|
+
/** `js_run` is registered. */
|
|
11
|
+
javascript: boolean;
|
|
12
|
+
/** `python_run` is registered (Forja line for it only then). */
|
|
13
|
+
python: boolean;
|
|
14
|
+
/** `forge_guide` is registered: the rule tells the model to call it. */
|
|
15
|
+
guide?: boolean;
|
|
16
|
+
/** Absolute path of this session's scratchpad, when there is one. */
|
|
17
|
+
scratchpad?: string | undefined;
|
|
18
|
+
}
|
|
19
|
+
export declare function buildRoutingRule(input: RoutingRuleInput): string;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer 0 of the incremental context (spec §8.1, D12): a short routing rule, present in the system
|
|
3
|
+
* prompt only when a Forja engine is registered and ready. With every engine off the prompt stays
|
|
4
|
+
* byte for byte what it was. The rule is built, never stored: the lines follow the tools actually
|
|
5
|
+
* registered and the session's real scratchpad path.
|
|
6
|
+
*/
|
|
7
|
+
/** Hard bound of the rule (spec §8.1). */
|
|
8
|
+
export const ROUTING_RULE_MAX_CHARS = 900;
|
|
9
|
+
export function buildRoutingRule(input) {
|
|
10
|
+
if (!input.javascript && !input.python)
|
|
11
|
+
return "";
|
|
12
|
+
const lines = ["# Code execution"];
|
|
13
|
+
lines.push("- One tool call, or a few you can chain: call the tools directly.");
|
|
14
|
+
if (input.javascript)
|
|
15
|
+
lines.push("- Many reads, paging, filtering or aggregating tool results, JSON/text transforms, light maths: `js_run` (sandboxed JS: `await tools.call(name, input)`, `return` a small JSON value).");
|
|
16
|
+
if (input.python)
|
|
17
|
+
lines.push("- Data analysis, statistics, ML, charts, XLSX/DOCX/PDF, special dashboards: `python_run`.");
|
|
18
|
+
if (input.guide)
|
|
19
|
+
lines.push("- Call `forge_guide` before writing non-trivial code.");
|
|
20
|
+
if (input.scratchpad) {
|
|
21
|
+
// Which saved scripts the tools can run: only the tools that exist are named.
|
|
22
|
+
const runs = input.javascript && input.python
|
|
23
|
+
? "; saved scripts: `js_run` `codePath` (.js), `python_run` `codePath` (.py)"
|
|
24
|
+
: input.javascript
|
|
25
|
+
? "; saved scripts run with `js_run` `codePath`"
|
|
26
|
+
: "; saved scripts run with `python_run` `codePath`";
|
|
27
|
+
lines.push(`- Scratchpad (no approval for writes): ${input.scratchpad}${runs}.`);
|
|
28
|
+
}
|
|
29
|
+
const text = lines.join("\n");
|
|
30
|
+
return text.length <= ROUTING_RULE_MAX_CHARS ? text : text.slice(0, ROUTING_RULE_MAX_CHARS);
|
|
31
|
+
}
|