import manifest from "../../python-runtime/manifest.json" with { type: "json" }; import { fnv1a64 } from "./replay.mjs"; export const PYTHON_BRIDGE_SCHEMA = "python-worker-bridge.v1"; const COMPAT_MODULES = Object.freeze({ tool: ["get", "set"], parameter: ["get", "set"], parameters: ["get", "set"], workspace: ["read", "write", "list", "stat", "hash"], "virtual-clock": ["now", "tick", "sleep"], }); const WORKSPACE_MAX_FILES = 256; const WORKSPACE_MAX_BYTES = 16 * 1024 * 1024; function clone(value) { return structuredClone(value); } function finiteJson(value) { if (value === null || typeof value !== "object") return true; if (typeof value === "number") return Number.isFinite(value); return Array.isArray(value) ? value.every(finiteJson) : Object.values(value).every(finiteJson); } function virtualPath(value) { if (typeof value !== "string" || !value.startsWith("/workspace/") || value.includes("..") || value.includes("\\") || value.length > 1024) return null; return value; } export class PythonBridge { constructor({ loadPyodide = globalThis.loadPyodide, maxSourceBytes = 65536, maxCalls = 256 } = {}) { this.loadPyodide = loadPyodide; this.maxSourceBytes = maxSourceBytes; this.maxCalls = maxCalls; this.pyodide = null; this.initialized = false; this.state = { imports: [], calls: [], errors: [], capabilities: [], tick: 0, api: { linuxcnc: { mode: "MANUAL", estop: false, task_state: "READY", last_command: null, ini: null }, gstat: { state: "READY", task: 0, motion: 0 }, hal: { components: {}, pins: {}, params: {}, signals: {}, threads: {} }, ini: { sections: {}, values: {} }, tools: {}, parameters: {}, workspace: { files: {}, bytes: 0 }, }, }; } capabilities() { return { schema: PYTHON_BRIDGE_SCHEMA, runtime: manifest.runtime, version: manifest.version, python_version: manifest.python_version, abi_version: manifest.abi_version, platform: manifest.platform, worker_only: manifest.worker_only, virtual_modules: clone(manifest.virtual_modules), compatibility_modules: clone(COMPAT_MODULES), denied_capabilities: [...manifest.denied_capabilities], initialized: this.initialized, }; } async initialize() { if (this.initialized) return { status: "verified", capabilities: this.capabilities() }; if (typeof this.loadPyodide !== "function") return this.fail("python_runtime_unavailable", "python.runtime_unavailable"); try { this.pyodide = await this.loadPyodide({ indexURL: manifest.index_url }); this.initialized = true; return { status: "verified", capabilities: this.capabilities() }; } catch (error) { return this.fail("python_runtime_init", String(error?.message ?? error)); } } importModule(module) { const symbols = manifest.virtual_modules[module] ?? COMPAT_MODULES[module]; if (!symbols) return this.fail("python_unsupported_module", "python.unsupported_module", { module }); if (!this.state.imports.includes(module)) this.state.imports.push(module); return { status: "verified", module, symbols: [...symbols], state_hash: this.hash() }; } checkCapability(capability) { if (manifest.denied_capabilities.includes(capability)) return this.fail("real_hardware_excluded", "python.host_capability_denied", { capability, status: "REAL-EXCLUDED" }); if (typeof capability !== "string" || capability.length === 0) return this.fail("python_capability_invalid", "python.capability_invalid"); if (!this.state.capabilities.includes(capability)) this.state.capabilities.push(capability); return { status: "verified", capability, state_hash: this.hash() }; } invoke(module, symbol, args = []) { const symbols = manifest.virtual_modules[module] ?? COMPAT_MODULES[module]; if (!symbols || !symbols.includes(symbol)) return this.fail("python_symbol_unsupported", "python.symbol_unsupported", { module, symbol }); if (!Array.isArray(args) || !finiteJson(args)) return this.fail("python_argument_invalid", "python.argument_invalid"); if (this.state.calls.length >= this.maxCalls) return this.fail("python_call_limit", "python.call_limit"); const call = { module, symbol, args: clone(args), tick: this.state.tick }; this.state.calls.push(call); if (!this.state.imports.includes(module)) this.state.imports.push(module); const apiResult = this.apiCall(module, symbol, args); if (apiResult) return apiResult; return { status: "verified", value: { module, symbol, args: clone(args) }, state_hash: this.hash() }; } apiCall(module, symbol, args = []) { const api = this.state.api; const stringArg = (value) => typeof value === "string" && value.length > 0 ? value : null; const finiteArg = (value) => typeof value === "number" && Number.isFinite(value) ? value : null; if (module === "linuxcnc") { if (symbol === "command") { const command = stringArg(args[0]); if (!command || !["mode", "estop", "abort", "run", "pause", "resume"].includes(command)) return this.fail("python_linuxcnc_command", "python.linuxcnc_command_invalid"); api.linuxcnc.last_command = command; if (command === "mode" && ["MANUAL", "MDI", "AUTO"].includes(args[1])) api.linuxcnc.mode = args[1]; else if (command === "mode") return this.fail("python_linuxcnc_mode", "python.linuxcnc_mode_invalid"); else if (command === "estop") api.linuxcnc.estop = Boolean(args[1]); else if (command === "abort") api.linuxcnc.task_state = "STOPPED"; else if (command === "run") api.linuxcnc.task_state = "RUNNING"; else if (command === "pause") api.linuxcnc.task_state = "PAUSED"; else if (command === "resume") api.linuxcnc.task_state = "RUNNING"; api.gstat.state = api.linuxcnc.task_state; return { status: "verified", value: clone(api.linuxcnc), state_hash: this.hash() }; } if (symbol === "stat") return { status: "verified", value: clone(api.linuxcnc), state_hash: this.hash() }; if (symbol === "ini") return { status: "verified", value: clone(api.ini), state_hash: this.hash() }; if (symbol === "error") return { status: "verified", value: { code: api.linuxcnc.estop ? "ESTOP" : null }, state_hash: this.hash() }; } if (module === "gstat") { if (!["state", "task", "motion"].includes(symbol)) return this.fail("python_gstat_symbol", "python.symbol_unsupported"); return { status: "verified", value: symbol === "state" ? clone(api.gstat) : api.gstat[symbol], state_hash: this.hash() }; } if (module === "hal") { const name = stringArg(args[0]); if (symbol === "component") { if (!name) return this.fail("python_hal_component", "python.hal_name_invalid"); api.hal.components[name] = { name, ready: true }; return { status: "verified", value: clone(api.hal.components[name]), state_hash: this.hash() }; } if (["pin", "param", "signal"].includes(symbol)) { if (!name) return this.fail("python_hal_name", "python.hal_name_invalid"); const collection = symbol === "param" ? api.hal.params : symbol === "signal" ? api.hal.signals : api.hal.pins; const value = args.length > 1 ? args[1] : (collection[name]?.value ?? 0); if (value !== undefined && value !== null && !finiteJson(value)) return this.fail("python_hal_value", "python.argument_invalid"); collection[name] = { name, kind: symbol, value }; return { status: "verified", value: clone(collection[name]), state_hash: this.hash() }; } if (symbol === "thread") { if (!name || !Number.isSafeInteger(args[1]) || args[1] < 1) return this.fail("python_hal_thread", "python.hal_thread_invalid"); api.hal.threads[name] = { name, period_ticks: args[1] }; return { status: "verified", value: clone(api.hal.threads[name]), state_hash: this.hash() }; } } if (module === "workspace") { const path = virtualPath(args[0] ?? ((symbol === "list" || symbol === "hash") ? "/workspace/" : undefined)); if (!path) return this.fail("python_workspace_path", "python.workspace_path_invalid"); if (symbol === "read") return { status: "verified", value: api.workspace.files[path] ?? null, state_hash: this.hash() }; if (symbol === "write") { if (typeof args[1] !== "string") return this.fail("python_workspace_data", "python.workspace_data_invalid"); const oldBytes = api.workspace.files[path] ? new TextEncoder().encode(api.workspace.files[path]).byteLength : 0; const bytes = new TextEncoder().encode(args[1]).byteLength; if (!api.workspace.files[path] && Object.keys(api.workspace.files).length >= WORKSPACE_MAX_FILES) return this.fail("python_workspace_file_limit", "python.workspace_file_limit"); if (api.workspace.bytes - oldBytes + bytes > WORKSPACE_MAX_BYTES) return this.fail("python_workspace_quota", "python.workspace_quota"); api.workspace.files[path] = args[1]; api.workspace.bytes = api.workspace.bytes - oldBytes + bytes; return { status: "verified", value: { path, bytes }, state_hash: this.hash() }; } if (symbol === "list") { const prefix = args[1] === undefined ? path : virtualPath(args[1]); if (!prefix) return this.fail("python_workspace_path", "python.workspace_path_invalid"); return { status: "verified", value: Object.keys(api.workspace.files).filter((item) => item.startsWith(prefix)).sort(), state_hash: this.hash() }; } if (symbol === "stat") { const data = api.workspace.files[path]; return { status: "verified", value: data === undefined ? null : { path, bytes: new TextEncoder().encode(data).byteLength }, state_hash: this.hash() }; } if (symbol === "hash") return { status: "verified", value: fnv1a64(api.workspace), state_hash: this.hash() }; } if (module === "virtual-clock" || module === "clock") { if (symbol === "now" || symbol === "tick") return { status: "verified", value: this.virtualClock(), state_hash: this.hash() }; if (symbol === "sleep") { const ticks = args[0]; if (!Number.isSafeInteger(ticks) || ticks < 0 || ticks > 1000000) return this.fail("python_clock_argument", "python.clock_argument_invalid"); return this.setTick(this.state.tick + ticks); } } if (module === "INI" || module === "ini") { const section = stringArg(args[0]); const key = stringArg(args[1]); if (symbol === "sections") return { status: "verified", value: Object.keys(api.ini.sections).sort(), state_hash: this.hash() }; if (symbol === "find" || symbol === "findall") { if (!section || !key) return this.fail("python_ini_arguments", "python.ini_arguments_invalid"); const values = api.ini.values[`${section}.${key}`] ?? []; return { status: "verified", value: symbol === "find" ? (values[0] ?? null) : [...values], state_hash: this.hash() }; } } if (module === "tool" || module === "tools") { const tool = args[0]; if (!Number.isSafeInteger(tool) || tool < 0 || tool > 1001) return this.fail("python_tool_number", "python.tool_number_invalid"); if (symbol === "set") { if (!finiteJson(args[1])) return this.fail("python_tool_value", "python.argument_invalid"); api.tools[String(tool)] = clone(args[1]); } return { status: "verified", value: clone(api.tools[String(tool)] ?? null), state_hash: this.hash() }; } if (module === "parameter" || module === "parameters") { const parameter = args[0]; if (!(Number.isSafeInteger(parameter) && parameter >= 1 && parameter <= 5399) && !(typeof parameter === "string" && parameter.length > 0)) return this.fail("python_parameter_name", "python.parameter_invalid"); if (symbol === "set") { if (!finiteArg(args[1])) return this.fail("python_parameter_value", "python.argument_invalid"); api.parameters[String(parameter)] = args[1]; } return { status: "verified", value: api.parameters[String(parameter)] ?? 0, state_hash: this.hash() }; } return null; } configureIni(sections = {}) { if (!sections || typeof sections !== "object" || Array.isArray(sections)) return this.fail("python_ini_shape", "python.ini_shape_invalid"); this.state.api.ini.sections = {}; this.state.api.ini.values = {}; for (const [section, values] of Object.entries(sections)) { if (typeof section !== "string" || !values || typeof values !== "object" || Array.isArray(values)) return this.fail("python_ini_shape", "python.ini_shape_invalid"); this.state.api.ini.sections[section] = true; for (const [key, value] of Object.entries(values)) { if (!finiteJson(value)) return this.fail("python_ini_value", "python.argument_invalid"); this.state.api.ini.values[`${section}.${key}`] = Array.isArray(value) ? clone(value) : [value]; } } this.state.api.linuxcnc.ini = clone(this.state.api.ini); return { status: "verified", state_hash: this.hash() }; } async execute(source) { if (typeof source !== "string" || new TextEncoder().encode(source).length > this.maxSourceBytes) return this.fail("python_source_limit", "python.source_limit"); const denied = manifest.denied_capabilities.find((capability) => new RegExp(`(?:^|[^A-Za-z0-9_])${capability.replaceAll(".", "\\.")}(?:$|[^A-Za-z0-9_])`).test(source)); if (denied) return this.fail("real_hardware_excluded", "python.host_capability_denied", { capability: denied, status: "REAL-EXCLUDED" }); if (!this.pyodide || typeof this.pyodide.runPythonAsync !== "function") return this.fail("python_runtime_unavailable", "python.runtime_unavailable"); try { const value = await this.pyodide.runPythonAsync(source); return { status: "verified", value: value === undefined ? null : value, state_hash: this.hash() }; } catch (error) { return this.fail("python_exception", "python.exception", { detail: String(error?.message ?? error) }); } } fail(code, message_key, extra = {}) { this.state.errors.push({ code, tick: this.state.tick }); return { status: extra.status ?? "blocked", code, message_key, retryable: false, ...extra, state_hash: this.hash() }; } setTick(tick) { if (!Number.isSafeInteger(tick) || tick < 0 || tick < this.state.tick) return this.fail("python_clock_regression", "python.clock_regression"); this.state.tick = tick; return { status: "verified", clock: this.virtualClock(), state_hash: this.hash() }; } virtualClock() { return { tick: this.state.tick, seconds: this.state.tick * 0.001, source: "logical_tick" }; } exportState() { return { schema: PYTHON_BRIDGE_SCHEMA, initialized: this.initialized, state: clone(this.state) }; } restoreState(value) { if (!value || value.schema !== PYTHON_BRIDGE_SCHEMA || !value.state) return this.fail("python_state_invalid", "python.state_invalid"); this.initialized = Boolean(value.initialized); this.state = clone(value.state); return { status: "verified", state_hash: this.hash() }; } hash() { return fnv1a64(this.state); } } export { manifest };