@alisio/core 0.1.0-alpha.20 → 0.1.0-alpha.22

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.
Files changed (104) hide show
  1. package/dist/agents/active.d.ts +19 -0
  2. package/dist/agents/active.js +41 -0
  3. package/dist/agents/capabilities.d.ts +26 -0
  4. package/dist/agents/capabilities.js +120 -0
  5. package/dist/agents/create-agent-skill.d.ts +11 -0
  6. package/dist/agents/create-agent-skill.js +60 -0
  7. package/dist/agents/definitions.d.ts +88 -0
  8. package/dist/agents/definitions.js +404 -0
  9. package/dist/agents/draft.d.ts +41 -0
  10. package/dist/agents/draft.js +133 -0
  11. package/dist/agents/service.d.ts +57 -0
  12. package/dist/agents/service.js +86 -0
  13. package/dist/agents/templates.d.ts +8 -0
  14. package/dist/agents/templates.js +102 -0
  15. package/dist/analysis/capabilities.d.ts +48 -0
  16. package/dist/analysis/capabilities.js +90 -0
  17. package/dist/analysis/data/csv.d.ts +46 -0
  18. package/dist/analysis/data/csv.js +240 -0
  19. package/dist/analysis/data/datasets.d.ts +132 -0
  20. package/dist/analysis/data/datasets.js +441 -0
  21. package/dist/analysis/data/describe.d.ts +27 -0
  22. package/dist/analysis/data/describe.js +103 -0
  23. package/dist/analysis/data/engine-client.d.ts +58 -0
  24. package/dist/analysis/data/engine-client.js +304 -0
  25. package/dist/analysis/data/engine-entry.d.ts +1 -0
  26. package/dist/analysis/data/engine-entry.js +59 -0
  27. package/dist/analysis/data/engine-source.d.ts +4 -0
  28. package/dist/analysis/data/engine-source.js +6 -0
  29. package/dist/analysis/data/engine-types.d.ts +86 -0
  30. package/dist/analysis/data/engine-types.js +7 -0
  31. package/dist/analysis/data/infer.d.ts +42 -0
  32. package/dist/analysis/data/infer.js +118 -0
  33. package/dist/analysis/data/ingest-worker.d.ts +17 -0
  34. package/dist/analysis/data/ingest-worker.js +365 -0
  35. package/dist/analysis/data/json.d.ts +19 -0
  36. package/dist/analysis/data/json.js +44 -0
  37. package/dist/analysis/data/query-worker.d.ts +12 -0
  38. package/dist/analysis/data/query-worker.js +75 -0
  39. package/dist/analysis/data/rows.d.ts +39 -0
  40. package/dist/analysis/data/rows.js +120 -0
  41. package/dist/analysis/data/sql-guard.d.ts +20 -0
  42. package/dist/analysis/data/sql-guard.js +129 -0
  43. package/dist/analysis/data/stats.d.ts +18 -0
  44. package/dist/analysis/data/stats.js +77 -0
  45. package/dist/analysis/install-hints.d.ts +33 -0
  46. package/dist/analysis/install-hints.js +144 -0
  47. package/dist/analysis/janitor.d.ts +60 -0
  48. package/dist/analysis/janitor.js +264 -0
  49. package/dist/analysis/jobs.d.ts +94 -0
  50. package/dist/analysis/jobs.js +165 -0
  51. package/dist/analysis/oci.d.ts +129 -0
  52. package/dist/analysis/oci.js +330 -0
  53. package/dist/analysis/python/sources.d.ts +2 -0
  54. package/dist/analysis/python/sources.js +10 -0
  55. package/dist/analysis/requirements.d.ts +32 -0
  56. package/dist/analysis/requirements.js +73 -0
  57. package/dist/analysis/rerun.d.ts +40 -0
  58. package/dist/analysis/rerun.js +136 -0
  59. package/dist/analysis/runtime-manager.d.ts +146 -0
  60. package/dist/analysis/runtime-manager.js +472 -0
  61. package/dist/analysis/status.d.ts +21 -0
  62. package/dist/analysis/status.js +52 -0
  63. package/dist/application.d.ts +48 -0
  64. package/dist/application.js +228 -0
  65. package/dist/artifacts/kinds.d.ts +35 -0
  66. package/dist/artifacts/kinds.js +177 -0
  67. package/dist/artifacts/publisher.d.ts +7 -0
  68. package/dist/artifacts/publisher.js +29 -0
  69. package/dist/artifacts/store.d.ts +119 -0
  70. package/dist/artifacts/store.js +581 -0
  71. package/dist/commands/builtins.js +13 -0
  72. package/dist/commands/catalog.js +13 -1
  73. package/dist/config.d.ts +102 -0
  74. package/dist/config.js +167 -6
  75. package/dist/core/contracts.d.ts +69 -1
  76. package/dist/core/runner.d.ts +34 -4
  77. package/dist/core/runner.js +388 -130
  78. package/dist/index.d.ts +30 -5
  79. package/dist/index.js +29 -4
  80. package/dist/mcp/rich.js +4 -0
  81. package/dist/plugins/host.js +14 -6
  82. package/dist/runtime/blobs.d.ts +22 -0
  83. package/dist/runtime/blobs.js +84 -1
  84. package/dist/runtime/ids.d.ts +8 -0
  85. package/dist/runtime/ids.js +32 -0
  86. package/dist/runtime/paths.d.ts +5 -0
  87. package/dist/runtime/paths.js +6 -0
  88. package/dist/runtime/process.d.ts +12 -0
  89. package/dist/runtime/process.js +64 -4
  90. package/dist/runtime/sqlite.d.ts +2 -0
  91. package/dist/runtime/sqlite.js +18 -0
  92. package/dist/runtime/store.d.ts +5 -0
  93. package/dist/runtime/store.js +75 -0
  94. package/dist/runtime/zip.d.ts +24 -0
  95. package/dist/runtime/zip.js +126 -0
  96. package/dist/sessions/children.js +4 -0
  97. package/dist/tools/analysis.d.ts +37 -0
  98. package/dist/tools/analysis.js +572 -0
  99. package/dist/tools/artifacts.d.ts +14 -0
  100. package/dist/tools/artifacts.js +143 -0
  101. package/dist/tools/data.d.ts +7 -0
  102. package/dist/tools/data.js +160 -0
  103. package/dist/tools/execute.js +6 -2
  104. package/package.json +2 -2
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Internal job folders of `python_run`, outside the repository and outside the artifact store:
3
+ * `<root>/analysis/jobs/<workspaceKey>/<root session>/<executionId>/` with `job.json`,
4
+ * `script/main.py` (+ the `alisio_runtime` package), `input/`, `work/`, `staging/` and `logs/`.
5
+ * Nothing here is published automatically: only `staging/` is read by the artifact publisher.
6
+ */
7
+ import { createHash } from "node:crypto";
8
+ import { constants } from "node:fs";
9
+ import { copyFile, lstat, mkdir, readFile, rename, writeFile } from "node:fs/promises";
10
+ import { basename, isAbsolute, join, relative, sep } from "node:path";
11
+ import { newId } from "../runtime/ids.js";
12
+ import { workspaceKey } from "../runtime/paths.js";
13
+ import { ALISIO_RUNTIME_FILES } from "./python/sources.js";
14
+ const sha256 = (text) => createHash("sha256").update(text).digest("hex");
15
+ /** Executions of `python_run` (`analysis_executions`) and their job folders. */
16
+ export class AnalysisJobs {
17
+ options;
18
+ constructor(options) {
19
+ this.options = options;
20
+ }
21
+ now() {
22
+ return (this.options.now ?? Date.now)();
23
+ }
24
+ /** Creates the job folder (script, runtime helpers, empty input/work/staging/logs) and its row. */
25
+ async create(input) {
26
+ const id = newId("exec", this.now());
27
+ const root = join(this.options.root, "analysis", "jobs", workspaceKey(input.workspace), input.rootSessionId, id);
28
+ const paths = {
29
+ id,
30
+ root,
31
+ script: join(root, "script"),
32
+ main: join(root, "script", "main.py"),
33
+ input: join(root, "input"),
34
+ work: join(root, "work"),
35
+ staging: join(root, "staging"),
36
+ logs: join(root, "logs"),
37
+ };
38
+ for (const dir of [paths.script, paths.input, paths.work, paths.staging, paths.logs])
39
+ await mkdir(dir, { recursive: true });
40
+ await writeFile(paths.main, input.code, { flag: "wx" });
41
+ const runtimeDir = join(paths.script, "alisio_runtime");
42
+ await mkdir(runtimeDir);
43
+ for (const [name, text] of Object.entries(ALISIO_RUNTIME_FILES))
44
+ await writeFile(join(runtimeDir, name), text, { flag: "wx" });
45
+ const scriptSha256 = sha256(input.code);
46
+ const createdAt = this.now();
47
+ await writeFile(join(root, "job.json"), `${JSON.stringify({
48
+ schemaVersion: 1,
49
+ id,
50
+ sessionId: input.sessionId,
51
+ rootSessionId: input.rootSessionId,
52
+ runId: input.runId,
53
+ callId: input.callId,
54
+ runtime: input.runtime,
55
+ scriptSha256,
56
+ ...(input.rerunOf ? { rerunOf: input.rerunOf } : {}),
57
+ createdAt,
58
+ }, null, 2)}\n`);
59
+ this.options.db
60
+ .prepare(`INSERT INTO analysis_executions(id,session,root_session,workspace,run_id,call_id,runtime,
61
+ status,script_sha256,rel_dir,rerun_of,created_at) VALUES(?,?,?,?,?,?,?,'running',?,?,?,?)`)
62
+ .run(id, input.sessionId, input.rootSessionId, input.workspace, input.runId ?? null, input.callId ?? null, input.runtime, scriptSha256, relative(this.options.root, root), input.rerunOf ?? null, createdAt);
63
+ return { ...paths, scriptSha256 };
64
+ }
65
+ /**
66
+ * Copies an input file (already resolved by the path policy) into `input/`, as `name` (default:
67
+ * its own name). Copy-on-write clones are used where the file system has them; the copy is
68
+ * never a link, so a script cannot alter the original.
69
+ */
70
+ async addInput(job, source, preferred) {
71
+ const info = await lstat(source);
72
+ if (!info.isFile())
73
+ throw new Error(`Input ${source} is not a regular file`);
74
+ const wanted = preferred ?? basename(source);
75
+ let name = wanted;
76
+ for (let n = 2; await exists(join(job.input, name)); n++)
77
+ name = wanted.replace(/(\.[^.]*)?$/, (ext) => `-${n}${ext}`);
78
+ const target = join(job.input, name);
79
+ await copyFile(source, target, constants.COPYFILE_EXCL | constants.COPYFILE_FICLONE);
80
+ const bytes = await readFile(target);
81
+ return { name, sha256: sha256(bytes), bytes: bytes.byteLength };
82
+ }
83
+ /** `input/inputs.json`: what each input is (the script reads it with `alisio_runtime.datasets.inputs()`). */
84
+ async writeInputsManifest(job, entries) {
85
+ await writeFile(join(job.input, "inputs.json"), `${JSON.stringify(entries, null, 2)}\n`);
86
+ }
87
+ /** Adds the inputs to `job.json` (atomically), once they are copied into `input/`. */
88
+ async recordInputs(job, recorded) {
89
+ const file = join(job.root, "job.json");
90
+ const current = JSON.parse(await readFile(file, "utf8"));
91
+ const temporary = `${file}.${process.pid}.tmp`;
92
+ await writeFile(temporary, `${JSON.stringify({ ...current, ...recorded }, null, 2)}\n`);
93
+ await rename(temporary, file);
94
+ }
95
+ /** The inputs a finished job recorded; `undefined` when `job.json` is missing or has none. */
96
+ async recordedInputs(relDir) {
97
+ try {
98
+ const value = JSON.parse(await readFile(join(this.options.root, relDir, "job.json"), "utf8"));
99
+ return Array.isArray(value.inputs)
100
+ ? {
101
+ inputs: value.inputs,
102
+ manifest: Array.isArray(value.manifest) ? value.manifest : [],
103
+ ...(Array.isArray(value.extras) ? { extras: value.extras } : {}),
104
+ }
105
+ : undefined;
106
+ }
107
+ catch {
108
+ return undefined;
109
+ }
110
+ }
111
+ /** Absolute folder of an execution (`rel_dir` is always relative to the state root). */
112
+ folder(relDir) {
113
+ const folder = join(this.options.root, relDir);
114
+ const inside = relative(this.options.root, folder);
115
+ if (!inside || inside.startsWith("..") || isAbsolute(inside) || inside.split(sep)[0] === "..")
116
+ throw new Error("The execution folder is outside the state folder");
117
+ return folder;
118
+ }
119
+ /** The whole row of an execution. */
120
+ details(id) {
121
+ const row = this.options.db.prepare("SELECT * FROM analysis_executions WHERE id=?").get(id);
122
+ if (!row)
123
+ return undefined;
124
+ return {
125
+ id: String(row.id),
126
+ sessionId: String(row.session),
127
+ rootSessionId: String(row.root_session),
128
+ workspace: String(row.workspace),
129
+ runtime: String(row.runtime),
130
+ status: String(row.status),
131
+ scriptSha256: String(row.script_sha256),
132
+ ...(row.exit_code != null ? { exitCode: Number(row.exit_code) } : {}),
133
+ relDir: String(row.rel_dir),
134
+ ...(row.rerun_of != null ? { rerunOf: String(row.rerun_of) } : {}),
135
+ createdAt: Number(row.created_at),
136
+ ...(row.ended_at != null ? { endedAt: Number(row.ended_at) } : {}),
137
+ };
138
+ }
139
+ finish(id, status, details = {}) {
140
+ this.options.db
141
+ .prepare("UPDATE analysis_executions SET status=?, exit_code=?, error=?, ended_at=? WHERE id=?")
142
+ .run(status, details.exitCode ?? null, details.error ?? null, this.now(), id);
143
+ }
144
+ get(id) {
145
+ const row = this.options.db
146
+ .prepare("SELECT status, exit_code, rel_dir FROM analysis_executions WHERE id=?")
147
+ .get(id);
148
+ return row
149
+ ? {
150
+ status: row.status,
151
+ ...(row.exit_code != null ? { exitCode: row.exit_code } : {}),
152
+ relDir: row.rel_dir,
153
+ }
154
+ : undefined;
155
+ }
156
+ }
157
+ async function exists(path) {
158
+ try {
159
+ await lstat(path);
160
+ return true;
161
+ }
162
+ catch {
163
+ return false;
164
+ }
165
+ }
@@ -0,0 +1,129 @@
1
+ import { type ProcessResult } from "../runtime/process.ts";
2
+ export type OciEngine = "docker" | "podman";
3
+ export interface OciSettings {
4
+ engine: OciEngine;
5
+ /** `name@sha256:<digest>`; required to run. */
6
+ image?: string;
7
+ memoryMb: number;
8
+ cpus: number;
9
+ }
10
+ /** The job folders mounted into the container (host paths). */
11
+ export interface OciMounts {
12
+ script: string;
13
+ input: string;
14
+ work: string;
15
+ staging: string;
16
+ }
17
+ /** Paths inside the container (spec §8.3). */
18
+ export declare const CONTAINER: {
19
+ readonly script: "/job/script";
20
+ readonly input: "/job/input";
21
+ readonly work: "/job/work";
22
+ readonly out: "/job/out";
23
+ };
24
+ export declare const PIDS_LIMIT = 256;
25
+ export declare const TMPFS_SIZE = "256m";
26
+ /** `alisio-exec_01J…`: the container of one execution. */
27
+ export declare function containerName(executionId: string): string;
28
+ export interface OciHost {
29
+ platform: NodeJS.Platform;
30
+ uid?: number;
31
+ gid?: number;
32
+ /** The engine runs without root privileges (rootless Docker or Podman). */
33
+ rootless?: boolean;
34
+ /** SELinux is enforcing: bind mounts need a relabel option on the host. */
35
+ selinux?: boolean;
36
+ }
37
+ /** A host path that can be passed to `-v`; throws when the engine would misparse it. */
38
+ export declare function mountSource(path: string, platform: NodeJS.Platform): string;
39
+ /**
40
+ * `<engine> run` arguments for one execution (spec §8.3). The container name is
41
+ * `alisio-<executionId>` so a cancellation can `kill` it by name.
42
+ */
43
+ export declare function ociRunArgs(input: {
44
+ settings: OciSettings & {
45
+ image: string;
46
+ };
47
+ executionId: string;
48
+ mounts: OciMounts;
49
+ host: OciHost;
50
+ /** Extra environment variables for the script (the ALISIO_* ones are always set). */
51
+ env?: Record<string, string>;
52
+ }): string[];
53
+ /** Collaborators of the runtime, replaceable in tests (a fake container CLI). */
54
+ export interface OciDeps {
55
+ /** Resolves the engine executable; `undefined` when it is not installed. */
56
+ locate(engine: OciEngine): Promise<string | undefined>;
57
+ /** Arguments placed before the engine's own (test launchers only). */
58
+ prefixArgs?: string[];
59
+ host(): Promise<OciHost>;
60
+ /** Runs one engine command to completion. */
61
+ exec(command: string, args: string[], options: {
62
+ signal: AbortSignal;
63
+ timeoutMs: number;
64
+ }): Promise<{
65
+ stdout: string;
66
+ stderr: string;
67
+ exitCode: number;
68
+ }>;
69
+ }
70
+ export declare function defaultOciDeps(): OciDeps;
71
+ export type OciStatus = {
72
+ ok: true;
73
+ engine: OciEngine;
74
+ path: string;
75
+ version: string;
76
+ rootless: boolean;
77
+ } | {
78
+ ok: false;
79
+ engine: OciEngine;
80
+ reason: string;
81
+ /** The other engine is installed: switching `analysis.oci.engine` is a way out. */
82
+ alternative?: OciEngine;
83
+ };
84
+ /** Container runtime of `python_run`; all engine calls go through `OciDeps`. */
85
+ export declare class OciRuntime {
86
+ readonly settings: OciSettings;
87
+ readonly deps: OciDeps;
88
+ constructor(settings: OciSettings, deps?: Partial<OciDeps>);
89
+ private command;
90
+ private call;
91
+ /**
92
+ * Is the engine installed and its daemon reachable? Optional by design: a missing engine is a
93
+ * status, not an error of Alisio. Also reports rootless mode, which changes the `--user` flags.
94
+ */
95
+ status(signal?: AbortSignal): Promise<OciStatus>;
96
+ /** The reason a run cannot start (`undefined` = ready), checked before any job is created. */
97
+ unavailable(signal?: AbortSignal): Promise<string | undefined>;
98
+ /**
99
+ * Runs the job's script in a container. Cancelling or timing out `kills` the container by name
100
+ * (killing the client process does not always stop it) and no `alisio-*` container is left.
101
+ */
102
+ run(input: {
103
+ executionId: string;
104
+ mounts: OciMounts;
105
+ signal: AbortSignal;
106
+ timeoutMs: number;
107
+ maxBytes: number;
108
+ onData?: (chunk: string) => void;
109
+ logFiles: {
110
+ stdout: string;
111
+ stderr: string;
112
+ };
113
+ maxLogBytes: number;
114
+ }): Promise<ProcessResult & {
115
+ containerName: string;
116
+ }>;
117
+ /** `<engine> kill <name>`; a container that is already gone is not an error. */
118
+ kill(name: string): Promise<void>;
119
+ /** Names of the `alisio-*` containers that still exist (running or not). */
120
+ leftovers(): Promise<string[]>;
121
+ /** Pulls the image (once, from `alisio analysis setup --oci`); a digest reference verifies itself. */
122
+ pull(image: string, signal: AbortSignal): Promise<{
123
+ digest?: string;
124
+ }>;
125
+ /** True when the image is already in the local store (so a run never needs the network). */
126
+ hasImage(image: string, signal?: AbortSignal): Promise<boolean>;
127
+ }
128
+ /** `["repo@sha256:…"]` → `["repo@sha256:…"]` (JSON from `--format {{json .RepoDigests}}`). */
129
+ export declare function parseDigests(output: string): string[];
@@ -0,0 +1,330 @@
1
+ /**
2
+ * Optional container runtime for `python_run` (`analysis.runtime: "oci"`): Docker or Podman with a
3
+ * digest-pinned image. Nothing is built or pulled during a run (`alisio analysis setup --oci` pulls
4
+ * once); the run only starts `<engine> run --rm …`. The isolation is partial and documented: the
5
+ * container sees only the job folders, has no network, a read-only root, no capabilities and
6
+ * memory/CPU/process limits, but a container is not a boundary against a hostile kernel exploit.
7
+ *
8
+ * Every argument goes in an array (no shell), so paths with spaces and non-ASCII names are fine.
9
+ * Host paths that the engine would parse as mount syntax (a `:` outside a Windows drive prefix) are
10
+ * refused with a clear message instead of being passed through.
11
+ */
12
+ import { readFile } from "node:fs/promises";
13
+ import { OCI_IMAGE_PATTERN } from "../config.js";
14
+ import { which } from "../runtime/fs.js";
15
+ import { runProcess } from "../runtime/process.js";
16
+ /** Paths inside the container (spec §8.3). */
17
+ export const CONTAINER = {
18
+ script: "/job/script",
19
+ input: "/job/input",
20
+ work: "/job/work",
21
+ out: "/job/out",
22
+ };
23
+ export const PIDS_LIMIT = 256;
24
+ export const TMPFS_SIZE = "256m";
25
+ /** `alisio-exec_01J…`: the container of one execution. */
26
+ export function containerName(executionId) {
27
+ const safe = executionId.replace(/[^a-zA-Z0-9_.-]/g, "-");
28
+ return `alisio-${safe}`;
29
+ }
30
+ /** Windows drive prefix (`C:\` or `C:/`), which a `-v` source may legitimately contain. */
31
+ const DRIVE = /^[A-Za-z]:[\\/]/;
32
+ /** A host path that can be passed to `-v`; throws when the engine would misparse it. */
33
+ export function mountSource(path, platform) {
34
+ const rest = platform === "win32" && DRIVE.test(path) ? path.slice(2) : path;
35
+ if (rest.includes(":") || /[\r\n\0]/.test(path))
36
+ throw new Error(`The job folder ${path} contains a character the container runtime cannot mount (":"). ` +
37
+ "Move the Alisio state folder (ALISIO_STATE_HOME or --db) to a path without it, or use the managed runtime.");
38
+ return path;
39
+ }
40
+ /**
41
+ * `<engine> run` arguments for one execution (spec §8.3). The container name is
42
+ * `alisio-<executionId>` so a cancellation can `kill` it by name.
43
+ */
44
+ export function ociRunArgs(input) {
45
+ const { settings, host } = input;
46
+ const mount = (source, target, mode) => `${mountSource(source, host.platform)}:${target}:${mode}${host.selinux ? ",z" : ""}`;
47
+ const args = [
48
+ "run",
49
+ "--rm",
50
+ "--name",
51
+ containerName(input.executionId),
52
+ "--label",
53
+ "alisio=analysis",
54
+ "--network=none",
55
+ "--read-only",
56
+ "--cap-drop=ALL",
57
+ "--security-opt=no-new-privileges",
58
+ `--pids-limit=${PIDS_LIMIT}`,
59
+ `--memory=${settings.memoryMb}m`,
60
+ `--memory-swap=${settings.memoryMb}m`,
61
+ `--cpus=${settings.cpus}`,
62
+ ];
63
+ // Files the script writes must belong to the user on Linux. Docker Desktop (macOS, Windows)
64
+ // maps ownership itself, so `--user` is omitted there; a rootless engine already maps the user
65
+ // to root inside the container (Podman needs keep-id to keep the owner on the mounts).
66
+ if (host.platform === "linux" && host.uid !== undefined && host.gid !== undefined) {
67
+ if (settings.engine === "podman" && host.rootless && host.uid !== 0)
68
+ args.push("--userns=keep-id", "--user", `${host.uid}:${host.gid}`);
69
+ else if (!host.rootless && host.uid !== 0)
70
+ args.push("--user", `${host.uid}:${host.gid}`);
71
+ }
72
+ args.push("-v", mount(input.mounts.script, CONTAINER.script, "ro"), "-v", mount(input.mounts.input, CONTAINER.input, "ro"), "-v", mount(input.mounts.work, CONTAINER.work, "rw"), "-v", mount(input.mounts.staging, CONTAINER.out, "rw"), "--tmpfs", `/tmp:size=${TMPFS_SIZE}`);
73
+ const env = {
74
+ ALISIO_INPUT_DIR: CONTAINER.input,
75
+ ALISIO_OUTPUT_DIR: CONTAINER.out,
76
+ ALISIO_WORK_DIR: CONTAINER.work,
77
+ ALISIO_EXECUTION_ID: input.executionId,
78
+ HOME: CONTAINER.work,
79
+ TMPDIR: "/tmp",
80
+ MPLCONFIGDIR: `${CONTAINER.work}/.matplotlib`,
81
+ MPLBACKEND: "Agg",
82
+ PYTHONDONTWRITEBYTECODE: "1",
83
+ PYTHONUNBUFFERED: "1",
84
+ PYTHONNOUSERSITE: "1",
85
+ PYTHONIOENCODING: "utf-8",
86
+ ...input.env,
87
+ };
88
+ for (const [key, value] of Object.entries(env))
89
+ args.push("-e", `${key}=${value}`);
90
+ args.push("-w", CONTAINER.work, settings.image, "python", "-E", "-s", "-B", "-u", "-X", "utf8", `${CONTAINER.script}/main.py`);
91
+ return args;
92
+ }
93
+ async function selinuxEnforcing() {
94
+ try {
95
+ return (await readFile("/sys/fs/selinux/enforce", "utf8")).trim() === "1";
96
+ }
97
+ catch {
98
+ return false;
99
+ }
100
+ }
101
+ export function defaultOciDeps() {
102
+ return {
103
+ locate: (engine) => which(engine),
104
+ async host() {
105
+ return {
106
+ platform: process.platform,
107
+ ...(typeof process.getuid === "function" ? { uid: process.getuid() } : {}),
108
+ ...(typeof process.getgid === "function" ? { gid: process.getgid() } : {}),
109
+ ...(process.platform === "linux" ? { selinux: await selinuxEnforcing() } : {}),
110
+ };
111
+ },
112
+ async exec(command, args, options) {
113
+ const result = await runProcess(command, args, {
114
+ cwd: process.cwd(),
115
+ signal: options.signal,
116
+ timeoutMs: options.timeoutMs,
117
+ maxBytes: 256 * 1024,
118
+ onOverflow: "truncate",
119
+ });
120
+ return { stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode };
121
+ },
122
+ };
123
+ }
124
+ const other = (engine) => (engine === "docker" ? "podman" : "docker");
125
+ /** Container runtime of `python_run`; all engine calls go through `OciDeps`. */
126
+ export class OciRuntime {
127
+ settings;
128
+ deps;
129
+ constructor(settings, deps = {}) {
130
+ this.settings = settings;
131
+ this.deps = { ...defaultOciDeps(), ...deps };
132
+ }
133
+ async command() {
134
+ return this.deps.locate(this.settings.engine);
135
+ }
136
+ async call(args, options = {}) {
137
+ const command = await this.command();
138
+ if (!command)
139
+ throw new Error(`${this.settings.engine} is not installed`);
140
+ return this.deps.exec(command, [...(this.deps.prefixArgs ?? []), ...args], {
141
+ signal: options.signal ?? AbortSignal.timeout(options.timeoutMs ?? 30_000),
142
+ timeoutMs: options.timeoutMs ?? 30_000,
143
+ });
144
+ }
145
+ /**
146
+ * Is the engine installed and its daemon reachable? Optional by design: a missing engine is a
147
+ * status, not an error of Alisio. Also reports rootless mode, which changes the `--user` flags.
148
+ */
149
+ async status(signal) {
150
+ const engine = this.settings.engine;
151
+ const path = await this.command();
152
+ if (!path) {
153
+ const alternative = (await this.deps.locate(other(engine))) ? other(engine) : undefined;
154
+ return {
155
+ ok: false,
156
+ engine,
157
+ reason: `${engine} was not found on PATH`,
158
+ ...(alternative ? { alternative } : {}),
159
+ };
160
+ }
161
+ try {
162
+ const version = await this.call(["version", "--format", engine === "docker" ? "{{.Server.Version}}" : "{{.Version}}"], signal ? { signal } : {});
163
+ if (version.exitCode !== 0)
164
+ return {
165
+ ok: false,
166
+ engine,
167
+ reason: `${engine} is installed but not usable: ${(version.stderr || version.stdout).trim().split(/\r?\n/)[0] ?? "no answer"}`,
168
+ };
169
+ let rootless = false;
170
+ try {
171
+ const info = await this.call([
172
+ "info",
173
+ "--format",
174
+ engine === "docker" ? "{{json .SecurityOptions}}" : "{{.Host.Security.Rootless}}",
175
+ ], signal ? { signal } : {});
176
+ rootless =
177
+ info.exitCode === 0 &&
178
+ (engine === "docker" ? /name=rootless/.test(info.stdout) : info.stdout.trim() === "true");
179
+ }
180
+ catch {
181
+ /* Rootless detection is best effort; the default flags are the rootful ones. */
182
+ }
183
+ return { ok: true, engine, path, version: version.stdout.trim(), rootless };
184
+ }
185
+ catch (error) {
186
+ return {
187
+ ok: false,
188
+ engine,
189
+ reason: `${engine} did not answer: ${error instanceof Error ? error.message : String(error)}`,
190
+ };
191
+ }
192
+ }
193
+ /** The reason a run cannot start (`undefined` = ready), checked before any job is created. */
194
+ async unavailable(signal) {
195
+ const { image } = this.settings;
196
+ if (!image)
197
+ return ('analysis.runtime is "oci" but analysis.oci.image is not set. Put a digest-pinned image ' +
198
+ "(name@sha256:…) in your user configuration; `alisio analysis setup --oci --image <name>` prints the value.");
199
+ if (!OCI_IMAGE_PATTERN.test(image))
200
+ return "analysis.oci.image must be pinned by digest";
201
+ const status = await this.status(signal);
202
+ if (!status.ok)
203
+ return `${status.reason}${status.alternative ? `; ${status.alternative} is installed (set analysis.oci.engine to "${status.alternative}")` : ""}. The container runtime is optional: remove analysis.runtime "oci" to use managed Python.`;
204
+ return undefined;
205
+ }
206
+ /**
207
+ * Runs the job's script in a container. Cancelling or timing out `kills` the container by name
208
+ * (killing the client process does not always stop it) and no `alisio-*` container is left.
209
+ */
210
+ async run(input) {
211
+ const image = this.settings.image;
212
+ if (!image)
213
+ throw new Error("analysis.oci.image is not set");
214
+ const command = await this.command();
215
+ if (!command)
216
+ throw new Error(`${this.settings.engine} is not installed`);
217
+ const status = await this.status(input.signal);
218
+ const host = await this.deps.host();
219
+ const args = ociRunArgs({
220
+ settings: { ...this.settings, image },
221
+ executionId: input.executionId,
222
+ mounts: input.mounts,
223
+ host: { ...host, rootless: status.ok ? status.rootless : false },
224
+ });
225
+ const name = containerName(input.executionId);
226
+ const stop = AbortSignal.any([input.signal, AbortSignal.timeout(input.timeoutMs)]);
227
+ const onAbort = () => void this.kill(name);
228
+ stop.addEventListener("abort", onAbort, { once: true });
229
+ try {
230
+ const result = await runProcess(command, [...(this.deps.prefixArgs ?? []), ...args], {
231
+ cwd: input.mounts.work,
232
+ signal: input.signal,
233
+ timeoutMs: input.timeoutMs,
234
+ maxBytes: input.maxBytes,
235
+ onOverflow: "truncate",
236
+ logFiles: input.logFiles,
237
+ maxLogBytes: input.maxLogBytes,
238
+ // The engine client only needs to find its own daemon: no variables of the script.
239
+ env: engineEnv(),
240
+ ...(input.onData ? { onData: input.onData } : {}),
241
+ });
242
+ return { ...result, containerName: name };
243
+ }
244
+ finally {
245
+ stop.removeEventListener("abort", onAbort);
246
+ // After an abort the client may have exited before the daemon stopped the container.
247
+ if (stop.aborted)
248
+ await this.kill(name);
249
+ }
250
+ }
251
+ /** `<engine> kill <name>`; a container that is already gone is not an error. */
252
+ async kill(name) {
253
+ try {
254
+ await this.call(["kill", name], { timeoutMs: 15_000 });
255
+ }
256
+ catch {
257
+ /* Already gone, or the engine stopped answering: nothing else to do. */
258
+ }
259
+ }
260
+ /** Names of the `alisio-*` containers that still exist (running or not). */
261
+ async leftovers() {
262
+ const result = await this.call([
263
+ "ps",
264
+ "-a",
265
+ "--filter",
266
+ "label=alisio=analysis",
267
+ "--format",
268
+ "{{.Names}}",
269
+ ]);
270
+ return result.stdout
271
+ .split(/\r?\n/)
272
+ .map((line) => line.trim())
273
+ .filter(Boolean);
274
+ }
275
+ /** Pulls the image (once, from `alisio analysis setup --oci`); a digest reference verifies itself. */
276
+ async pull(image, signal) {
277
+ const pulled = await this.call(["pull", image], { signal, timeoutMs: 30 * 60_000 });
278
+ if (pulled.exitCode !== 0)
279
+ throw new Error(`${this.settings.engine} pull ${image} failed: ${(pulled.stderr || pulled.stdout).trim().slice(-2000)}`);
280
+ const inspected = await this.call(["image", "inspect", "--format", "{{json .RepoDigests}}", image], { signal });
281
+ const digests = parseDigests(inspected.stdout);
282
+ return { ...(digests[0] ? { digest: digests[0] } : {}) };
283
+ }
284
+ /** True when the image is already in the local store (so a run never needs the network). */
285
+ async hasImage(image, signal) {
286
+ try {
287
+ const result = await this.call(["image", "inspect", "--format", "{{.Id}}", image], signal ? { signal } : {});
288
+ return result.exitCode === 0;
289
+ }
290
+ catch {
291
+ return false;
292
+ }
293
+ }
294
+ }
295
+ /** `["repo@sha256:…"]` → `["repo@sha256:…"]` (JSON from `--format {{json .RepoDigests}}`). */
296
+ export function parseDigests(output) {
297
+ try {
298
+ const value = JSON.parse(output.trim());
299
+ return Array.isArray(value)
300
+ ? value.filter((v) => typeof v === "string" && OCI_IMAGE_PATTERN.test(v))
301
+ : [];
302
+ }
303
+ catch {
304
+ return [];
305
+ }
306
+ }
307
+ /** The environment of the engine client: where to find the daemon, nothing else. */
308
+ function engineEnv() {
309
+ const keep = [
310
+ "PATH",
311
+ "HOME",
312
+ "USERPROFILE",
313
+ "TMPDIR",
314
+ "TEMP",
315
+ "SystemRoot",
316
+ "COMSPEC",
317
+ "LANG",
318
+ "DOCKER_HOST",
319
+ "DOCKER_CONTEXT",
320
+ "DOCKER_CONFIG",
321
+ "XDG_RUNTIME_DIR",
322
+ "CONTAINER_HOST",
323
+ "APPDATA",
324
+ "LOCALAPPDATA",
325
+ "ProgramData",
326
+ "ProgramFiles",
327
+ "DBUS_SESSION_BUS_ADDRESS",
328
+ ];
329
+ return Object.fromEntries(keep.flatMap((key) => (process.env[key] ? [[key, process.env[key]]] : [])));
330
+ }
@@ -0,0 +1,2 @@
1
+ /** The pure-Python `alisio_runtime` package copied next to every python_run script. */
2
+ export declare const ALISIO_RUNTIME_FILES: Record<string, string>;