diff --git a/.github/tests/opencode-init-runtime/README.md b/.github/tests/opencode-init-runtime/README.md new file mode 100644 index 0000000..5a005b9 --- /dev/null +++ b/.github/tests/opencode-init-runtime/README.md @@ -0,0 +1,152 @@ +# Init Runtime Feasibility + +Owner: makeitworkcloud. Isolated prototype, not chart adoption or release approval. +The PR-only workflow uses hosted Docker and the unchanged upstream +`ghcr.io/anomalyco/opencode:1.18.29@sha256:ecc3bf96ee55dad226d9cde50d79aaa8a1215c47860c0fcdc71570461bf438b8` +on linux/amd64. No Dockerfile, image build, image push, registry login, custom +ONNX/glibc, provider fallback, production mount, or cluster operation exists. + +## Lifecycle and Gate + +One root init helper extracts official packages into a UUID runtime volume, +using the stock image's trusted public apk keys and strictly matched Alpine +3.24 repositories. Node >=22.13, gcompat, libgcc/libstdc++ and apk-resolved +dependencies are prepared once. Binutils lives in a separate inspection volume, +never the application. Applications mount runtime dependencies read-only. + +Image pull/identity checks, dependency provisioning, and setup inspection share +one 10-minute setup budget; provisioning does not receive an additional budget. +Successful pull/identity and provisioning durations are reported separately. + +A uses PATH and LD_LIBRARY_PATH only. B adds +`LD_PRELOAD=/opt/runtime/lib/libgcompat.so.0`. Both use distinct fresh UUID HOME +volumes; application paths are identical. Each has its own 15-minute budget and +B runs even if A fails. Common provisioning failure prevents both and is red. +Control A is evidence, never the gate. Green requires B's complete cold and warm +sequence AND successful cleanup. Any B failure ends this experiment. + +Applications retain the upstream entrypoint, use numeric UID/GID 1000, read-only +rootfs, dropped capabilities and no-new-privileges. HOME/config/tmp are writable; +no host ports are published. Test-owned root helpers have only CHOWN added and +repair permissions only on fresh volumes, not persisted warm HOME trees. + +OpenCode automatically installs context-mode@1.0.169 and opencode-mem@2.26.0 +under HOME/.cache/opencode/packages//node_modules/, with lifecycle +scripts disabled by its upstream loader. No external npm install is added. +Provider list is empty, permissions deny, MCP empty, capture/extraction, +chat/compaction injection and profile features disabled. Context storage lives +under HOME/.local/share/context-mode. API authentication is a public synthetic +fixture, not a credential. Cold egress is needed for public packages/models; +this is not an egress allowlist or a malicious-dependency sandbox. + +Readiness requires exact configured plugins and native tool registration, not +context tool dispatch. B must create one synthetic memory through the real +localhost API and recall identical id/content with finite similarity >=0.6. +HTTP 200 with success=false fails. It then stops/removes the application, +discards config/tmp, seeds a fresh config volume, and starts the same stock image +with retained HOME/deps and network=none. The same memory must be recalled. +Security prerequisites remain before each real operation. Mapping is observed +before the operation, then checked again after successful recall; B cannot pass +without gcompat mapped in the compiled host. ONNX DT_NEEDED is required only +after successful write/recall, never as a prerequisite to the first write. +Failed operations retain their original failure and best-effort ELF inspection +in cleanup, rather than being preempted by asset timing/layout assumptions. +No model inference request to an LLM, custom plugin bridge, or remote provider is +used; the memory path does execute the default local CPU embedding model. + +## Evidence and Limits + +CI reports exact installed apk name/version pairs via +`apk list --installed --manifest`, skipped package script names when the apk +database exposes a script archive, provisioning/bootstrap/write/recall timings, +OpenCode and Node PT_INTERP, gcompat dynamic __vsnprintf_chk export, ONNX +DT_NEEDED, actual PID 1 gcompat mapping, security and Node SQLite FTS5 checks, +and detected absolute/broken dependency symlinks. No symlink, interpreter or +resource-path relocation is performed. A loader stub named by DT_NEEDED may not +be a loadable ELF library; presence or symbol export is not compatibility proof. +First-write latency includes remaining background model warmup, not just compute. +No warm success/timing is claimed when cold write fails. + +Before readiness and before cleanup, bounded observations capture only Docker +State Status/Running/ExitCode/OOMKilled and a sanitized Error, PID 1 gcompat mapping +as a boolean, and available cgroup memory peak/current and OOM counters. The +mapping/counter reader uses stock shell builtins, not Node; unavailable samples +are not zero or negative evidence. Exited containers may no longer expose their +cgroup. Exit code 137 alone is not an OOM or native-compatibility diagnosis. + +Only the HTTP measurement client uses `docker exec -e LD_PRELOAD=` so a client +loader failure cannot be confused with the application's response. This creates +a process-local environment override, not a container configuration change; PID 1 +retains the exact candidate B preload. Stock-shell observations and application +security/post-recall probes retain their original environment. This is measurement +isolation, not a compatibility fallback: B still requires actual application +security, mapped gcompat, real embeddings, and offline replacement recall to pass. + +After A/B, a fresh memory-only case adds the official `GLIBC_FAKE_DEBUG=1` +diagnostic setting. It requests /config and samples process state for up to +120 seconds including setup, reserving ten seconds for final observations. +The previously tested no-configured-plugin and context-only cases are not +repeated. At most twenty sanitized gcompat namespace/version lookup messages +are retained. The setting traces lookups; it is not a compatibility fix. + +Four independent Node containers then reuse that diagnostic fixture's public +package/model cache read-only with network=none and the same prepared runtime, +UID1000, rootfs hardening, gcompat preload and debug setting. They separately +exercise libsql file-backed CRUD in fresh /tmp, a one-pixel Sharp PNG only when +Sharp is an actual Transformers dependency, direct ONNX 1.20.1 import/session +creation using an existing selected-model artifact if present, and Transformers +CJS initialization through the plugin's existing ONNX shim followed by local-only +embedding initialization. No packages/models are installed or downloaded by +these Node cases, and no model is generated. Missing artifacts are reported, +not fetched. Session probing accepts only existing nomic model/model_quantized +ONNX files up to 1 GiB. Each case has 45 seconds; diagnostic work shares a +six-minute budget, with bounded cleanup separate. The job cap stays 50 minutes. + +Synchronous before/after markers, resolved package names/versions/relative +entrypoints, selected native-library mappings, exit state, and sanitized errors +identify the failing stage. Node success is NOT compiled-Bun compatibility and +never substitutes for B's actual write/recall/warm gate. A/B use their original +separate HOME volumes, configurations, timeouts, and untouched package/model +caches. Native diagnostic mutations are confined to fresh /tmp, not source HOME. + +Source contracts: opencode-mem 2.26.0 `src/services/embedding.ts`, +`onnxruntime-resolve.ts`, `runtime-require.ts`, and tsconfig map the published +dist paths and resolve-before-shim CJS ordering. Transformers 4.2.0's published +manifest declares Sharp and a nested ONNX version; diagnostics record actual +installed versions and require the plugin's direct ONNX to remain 1.20.1. +ONNX v1.20.1's `InferenceSession.create/release`, libsql-client v0.17.4's local +createClient/execute/close, and Sharp's documented create/png/toBuffer APIs are +used without changing the plugin or native binaries. The existing model remains +Xenova/nomic-embed-text-v1; native probes disable remote models and use +local_files_only, so absent cache files are a diagnostic limitation. + +Core dumps are disabled. Last-80-line synthetic logs are filtered to relevant +assertion/panic/crash/illegal-instruction/segmentation/Bun/ONNX/plugin/stack-frame +messages, excluding sensitive-keyword lines, then sanitized. At most 16 lines +of 384 characters are retained per observation. No raw log/config/environment, +core/binary dump, or host kernel log is collected. Missing excerpts are not proof +of no crash; a signal-shaped exit code is not a root-cause diagnosis. + +`--no-scripts` is deliberate fresh binary/library extraction, not a complete +bootable alternate root. Official 3.24 gcompat has no install hooks; Node's +post-upgrade script only warns about obsolete npm. Runtime dependencies may have +additional hooks; extraction success is not proof those are unnecessary. Actual +Node/native API execution is the gate; missing setup ends the experiment rather +than adding trust bypasses, chroot escapes, or undocumented relocation fixes. +See [apk options](https://github.com/alpinelinux/apk-tools/blob/master/doc/apk.8.scd), +[gcompat](https://github.com/alpinelinux/aports/blob/3.24-stable/main/gcompat/APKBUILD), +and [Node hook](https://github.com/alpinelinux/aports/blob/3.24-stable/main/nodejs/nodejs.post-upgrade). + +Package revisions/transitive npm ranges and model revision are not frozen. +Cold dependency resolution is not hermetic. Offline replacement tests reuse, +not cache immutability, Kubernetes storage, ARM64, concurrency, DR, context host +dispatch, real conversation learning, or production safety. No raw maps, auth +files, response bodies or raw log dumps are printed; only diagnostic markers, +symbol names, selected sanitized crash lines, and bounded sanitized command/HTTP +error fields are emitted. + +API/helper provenance: charts PR 113 at a34d8ef1646470c5d24275660c72d314f63b617b; +images PR 58 at 4aa256927c02577f253b2cbb8a09652c021e99ff. Prior run 36366947984 +failed first write on __vsnprintf_chk; it is not a passing baseline. CI is the +only validation environment. No dispatch/rerun, +merge, publication, chart bump, GitOps rollout or production changes are implied. diff --git a/.github/tests/opencode-init-runtime/diagnostics.py b/.github/tests/opencode-init-runtime/diagnostics.py new file mode 100644 index 0000000..54716bc --- /dev/null +++ b/.github/tests/opencode-init-runtime/diagnostics.py @@ -0,0 +1,119 @@ +"""Bounded diagnostic-only cases; never substitute for the required B gate.""" + +import json +import re +import time + + +def evidence(r, name): + logs = r.run(["logs", "--tail=80", name], timeout=10, check=False, cleanup=True) + text = (logs.stdout + logs.stderr)[-65536:] + result = r.native_evidence(text) + lookups = re.findall(r"symbol [A-Za-z_][A-Za-z0-9_]* with version [A-Za-z0-9_.-]+ is being redirected|loading library [^\r\n]{1,200}? was requested in namespace -?\d+", text) + result["gcompat_lookups"] = [r.sanitize(line)[:256] for line in lookups[-20:]] + result["stages"] = [] + for line in logs.stdout.splitlines(): + if not line.startswith("NATIVE ") or len(result["stages"]) >= 40: + continue + try: + item = json.loads(line[7:]) + # Only fixture-authored metadata, never arbitrary stdout/response data. + clean = {} + for key in ("stage", "mode", "node", "name", "version", "entry", "artifact", "error", "bytes", "inputs", "dimensions", "libraries"): + if key not in item: + continue + value = item[key] + if key in ("entry", "artifact", "libraries"): + values = value if isinstance(value, list) else [value] + if not all(isinstance(v, str) and len(v) <= 512 and re.fullmatch(r"[A-Za-z0-9_@./+-]+", v) and not v.startswith(("/", "..")) for v in values): + continue + clean[key] = value + elif isinstance(value, str): + clean[key] = r.sanitize(value) + elif isinstance(value, int): + clean[key] = value + result["stages"].append(clean) + except (ValueError, TypeError): + result["malformed_marker"] = True + return result + + +def run_cases(r, runtime): + overall = time.monotonic() + 360 + started = time.monotonic() + r.DEADLINE = started + 120 + case = {"plugins": [r.PLUGINS[1]], "config_ready": False, "samples": []} + r.REPORT["diagnostic_cases"] = {"mem-only-debug": case} + r.CURRENT = case + name = r.PREFIX + "-diagnostic-mem-debug" + home = None + try: + home = r.volume("diagnostic-mem-debug-home") + config = r.volume("diagnostic-mem-debug-config") + mounts = r.prepare_home(home, config, True, [r.PLUGINS[1]]) + r.CONTAINERS.append(name) + r.run(["run", "-d", "--name", name, "--pull=never", "--platform=linux/amd64", + "--network=bridge", "--user=1000:1000", *r.HARDEN, *r.ENV, + "-e", "LD_PRELOAD=/opt/runtime/lib/libgcompat.so.0", "-e", "GLIBC_FAKE_DEBUG=1", *mounts, + "-v", runtime + ":/opt/runtime:ro", "-v", r.FIXTURES + ":/probe:ro", + "-w", r.HOME, r.IMAGE, "web", "--hostname", "127.0.0.1", "--port", "4096"]) + next_sample = 0 + while time.monotonic() < r.DEADLINE - 10: + elapsed = time.monotonic() - started + if elapsed >= next_sample and len(case["samples"]) < 8: + sample = r.observe(name) + sample["elapsed_seconds"] = round(elapsed, 3) + case["samples"].append(sample) + next_sample = elapsed + 15 + if sample.get("state", {}).get("Running") is False: + break + if not case["config_ready"]: + value = r.request(name, 4096, "/config", timeout=5) + if isinstance(value, dict): + specs = value.get("plugin") + if not isinstance(specs, list): + raise r.Failure("diagnostic malformed plugin list") + specs = [p[0] if isinstance(p, list) and len(p) == 2 and isinstance(p[1], dict) else p for p in specs] + if specs != [r.PLUGINS[1]] or value.get("enabled_providers") != [] or value.get("mcp"): + raise r.Failure("diagnostic config mismatch") + case["config_ready"] = True + case["config_seconds"] = round(time.monotonic() - started, 3) + time.sleep(2) + except Exception as error: + case["failure"] = str(error) if isinstance(error, r.Failure) else type(error).__name__ + finally: + case["before_cleanup"] = r.observe(name) + case["observed_seconds"] = round(time.monotonic() - started, 3) + try: + case["evidence"] = evidence(r, name) + r.run(["rm", "-f", name], timeout=20, check=False, cleanup=True) + except r.Failure: + case["diagnostics_incomplete"] = True + if home is None: + return + # Separate Node processes, no network and read-only source HOME/cache. All + # synthetic mutations (including SQLite CRUD) stay in each fresh /tmp. + for mode in ("libsql", "sharp", "onnx", "transformers"): + r.DEADLINE = min(overall, time.monotonic() + 45) + name = r.PREFIX + "-native-" + mode + result = {"runtime": "Node diagnostic, not OpenCode compatibility"} + r.REPORT.setdefault("native_cases", {})[mode] = result + r.CONTAINERS.append(name) + try: + r.run(["run", "-d", "--name", name, "--pull=never", "--platform=linux/amd64", + "--network=none", "--user=1000:1000", *r.HARDEN, *r.ENV, + "-e", "LD_PRELOAD=/opt/runtime/lib/libgcompat.so.0", "-e", "GLIBC_FAKE_DEBUG=1", + "-v", home + ":" + r.HOME + ":ro", "-v", runtime + ":/opt/runtime:ro", + "-v", r.FIXTURES + ":/probe:ro", "-w", "/tmp", "--entrypoint=node", + r.IMAGE, "/probe/native.mjs", mode]) + waited = r.run(["wait", name], timeout=45, check=False) + result["wait_returncode"] = waited.returncode + except r.Failure as error: + result["failure"] = str(error) + finally: + result["before_cleanup"] = r.observe(name) + try: + result["evidence"] = evidence(r, name) + r.run(["rm", "-f", name], timeout=20, check=False, cleanup=True) + except r.Failure: + result["diagnostics_incomplete"] = True diff --git a/.github/tests/opencode-init-runtime/http.mjs b/.github/tests/opencode-init-runtime/http.mjs new file mode 100644 index 0000000..cf31675 --- /dev/null +++ b/.github/tests/opencode-init-runtime/http.mjs @@ -0,0 +1,55 @@ +// Adapted from images@4aa2569 tests/http.mjs. Never stream response bodies. +import fs from "node:fs"; + +function sanitize(value) { + return String(value).slice(0, 8192) + .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "") + .replace(/\b(?:https?|ftp|file):\/\/[^\s<>"']+/gi, "[url]") + .replace(/\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, "[credential]") + .replace(/\b(?:password|passwd|token|api[_-]?key|authorization|secret|cookie)["']?\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;]+)/gi, "[credential]") + .replace(/\b(?:sk-|ghp_|github_pat_|hf_)[A-Za-z0-9_-]+/g, "[credential]") + .replace(/\b[^\s:@/]+:[^\s@/]+@[^\s/]+/g, "[credential]") + .replace(/\?[^\s<>"']+/g, "[query]") + .replace(/(?:[A-Za-z]:[\\/]|~\/|\.\.?\/|\/)[^\s<>"'()]+/g, "[path]") + .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").slice(0, 768); +} + +function diagnostic(error, depth = 0) { + if (depth > 3 || error == null) return []; + if (typeof error === "string") return [{ message: sanitize(error) }]; + if (typeof error !== "object") return []; + const item = {}; + for (const field of ["name", "message", "code", "status"]) + if (["string", "number"].includes(typeof error[field])) item[field] = sanitize(error[field]); + return [item, ...diagnostic(error.error, depth + 1), ...diagnostic(error.cause, depth + 1)].slice(0, 6); +} + +let status = null; +try { + const input = JSON.parse(fs.readFileSync(0, "utf8")); + const route = input.route.split("?")[0]; + const allowed = input.port === 4096 ? ["/config", "/experimental/tool/ids"] + : input.port === 4747 ? ["/api/health", "/api/memories", "/api/search"] : []; + if (!allowed.includes(route)) throw new Error("unsupported fixture route"); + const response = await fetch(`http://127.0.0.1:${input.port}${input.route}`, { + method: input.post === null ? "GET" : "POST", headers: input.headers, + body: input.post === null ? undefined : JSON.stringify(input.post), + redirect: "error", signal: AbortSignal.timeout(input.timeout * 1000), + }); + status = response.status; + const chunks = []; + let bytes = 0; + for await (const chunk of response.body) { + bytes += chunk.length; + if (bytes > 1024 * 1024) throw new Error("response exceeds 1 MiB"); + chunks.push(chunk); + } + let body; + try { body = JSON.parse(Buffer.concat(chunks).toString("utf8")); } + catch { throw new Error("non-JSON response"); } + const failed = !response.ok || body?.success === false; + console.log(JSON.stringify({ status, body: failed ? null : body, + error: failed ? diagnostic(input.port === 4747 ? body : "request rejected") : null })); +} catch (error) { + console.log(JSON.stringify({ status, body: null, error: diagnostic(error) })); +} diff --git a/.github/tests/opencode-init-runtime/native.mjs b/.github/tests/opencode-init-runtime/native.mjs new file mode 100644 index 0000000..78720c2 --- /dev/null +++ b/.github/tests/opencode-init-runtime/native.mjs @@ -0,0 +1,127 @@ +// CI-only probes against the existing public plugin cache; no installation. +import fs from "node:fs"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { pathToFileURL } from "node:url"; +import assert from "node:assert/strict"; + +const cache = `${process.env.HOME}/.cache/opencode/packages/opencode-mem@2.26.0`; +const root = `${cache}/node_modules/opencode-mem`; +const requirePlugin = createRequire(`${root}/package.json`); +const model = "Xenova/nomic-embed-text-v1"; +const modelCache = `${process.env.HOME}/.opencode-mem/data/.cache`; +let stage = "start"; +function mark(next, fields = {}) { + stage = next; + fs.writeSync(1, `NATIVE ${JSON.stringify({ stage, ...fields })}\n`); +} +function resolve(request, name) { + const entry = fs.realpathSync(request.resolve(name)); + const relative = path.relative(cache, entry); + assert(!relative.startsWith("..") && !path.isAbsolute(relative), "resolution escaped fixture cache"); + let dir = path.dirname(entry); + for (let i = 0; i < 8 && dir.startsWith(cache + "/"); i++, dir = path.dirname(dir)) { + const manifest = path.join(dir, "package.json"); + if (!fs.existsSync(manifest)) continue; + const data = JSON.parse(fs.readFileSync(manifest, "utf8")); + if (data.name !== name) continue; + mark("resolved", { name, version: data.version, entry: relative }); + return { entry, manifest: data }; + } + throw new Error("resolved package manifest not found"); +} +function mapped() { + const names = fs.readFileSync("/proc/self/maps", "utf8").split("\n") + .map(line => line.trim().split(/\s+/).at(-1)) + .filter(name => name?.startsWith(cache + "/") && /\.(node|so)(\.|$)/.test(name)) + .map(name => path.relative(cache, name)); + mark("native-maps", { libraries: [...new Set(names)].sort().slice(0, 16) }); +} + +try { + assert.equal(JSON.parse(fs.readFileSync(`${root}/package.json`, "utf8")).version, "2.26.0"); + const mode = process.argv[2]; + mark("start", { mode, node: process.versions.node }); + if (mode === "libsql") { + const { entry } = resolve(requirePlugin, "@libsql/client"); + mark("libsql.import.before"); + const { createClient } = requirePlugin(entry); + mark("libsql.import.after"); + mapped(); + mark("libsql.crud.before"); + const db = createClient({ url: "file:/tmp/native-diagnostic.db" }); + try { + await db.execute("CREATE TABLE evidence (id INTEGER PRIMARY KEY, value TEXT)"); + await db.execute({ sql: "INSERT INTO evidence VALUES (?, ?)", args: [1, "synthetic"] }); + assert.equal((await db.execute("SELECT value FROM evidence WHERE id=1")).rows[0].value, "synthetic"); + await db.execute("UPDATE evidence SET value='updated' WHERE id=1"); + assert.equal((await db.execute("SELECT value FROM evidence WHERE id=1")).rows[0].value, "updated"); + await db.execute("DELETE FROM evidence WHERE id=1"); + assert.equal((await db.execute("SELECT * FROM evidence")).rows.length, 0); + } finally { db.close(); } + mark("libsql.crud.after"); + } else if (mode === "sharp") { + const transformers = resolve(requirePlugin, "@huggingface/transformers"); + if (!transformers.manifest.dependencies?.sharp) { + mark("sharp.not-a-dependency"); + } else { + const request = createRequire(transformers.entry); + const { entry } = resolve(request, "sharp"); + mark("sharp.import.before"); + const sharp = request(entry); + mark("sharp.import.after"); + mapped(); + mark("sharp.pixel.before"); + const output = await sharp({ create: { width: 1, height: 1, channels: 3, + background: { r: 1, g: 2, b: 3 } } }).png().toBuffer(); + assert(output.length > 0); + mark("sharp.pixel.after", { bytes: output.length }); + } + } else if (mode === "onnx") { + const { entry, manifest } = resolve(requirePlugin, "onnxruntime-node"); + assert.equal(manifest.version, "1.20.1"); + mark("onnx.import.before"); + const ort = requirePlugin(entry); + mark("onnx.import.after"); + mapped(); + const file = ["model_quantized.onnx", "model.onnx"].map(name => `${modelCache}/${model}/onnx/${name}`) + .find(name => fs.existsSync(name) && fs.statSync(name).size <= 1024 ** 3); + if (!file) mark("onnx.model.unavailable"); + else { + mark("onnx.session.before", { artifact: path.relative(modelCache, file) }); + const session = await ort.InferenceSession.create(file, { executionProviders: ["cpu"] }); + mark("onnx.session.after", { inputs: session.inputNames.length }); + await session.release(); + mark("onnx.release.after"); + } + } else if (mode === "transformers") { + // Match the published plugin's resolve-before-shim, CJS import ordering. + const { entry } = resolve(requirePlugin, "@huggingface/transformers"); + assert.equal(resolve(requirePlugin, "onnxruntime-node").manifest.version, "1.20.1"); + mark("transformers.shim.before"); + const shim = await import(pathToFileURL(`${root}/dist/services/onnxruntime-resolve.js`).href); + shim.prepareOnnxruntimeForTransformers(); + mark("transformers.shim.after"); + mark("transformers.import.before"); + const { pipeline, env } = requirePlugin(entry); + mark("transformers.import.after"); + mapped(); + env.allowLocalModels = true; + env.allowRemoteModels = false; + env.cacheDir = modelCache; + env.backends.onnx.wasm.numThreads = 1; + mark("transformers.pipeline.before"); + const pipe = await pipeline("feature-extraction", model, { local_files_only: true }); + mark("transformers.pipeline.after"); + const result = await pipe("search_document: synthetic native diagnostic", { pooling: "mean", normalize: true }); + assert.equal(result.data.length, 768); + assert([...result.data].every(Number.isFinite)); + mark("transformers.embedding.after", { dimensions: result.data.length }); + await pipe.dispose(); + } else throw new Error("unknown diagnostic case"); + mark("complete"); +} catch (error) { + // Captured by the orchestrator, which sanitizes this before printing. + fs.writeSync(1, `NATIVE ${JSON.stringify({ stage, error: String(error) })}\n`); + process.exitCode = 1; +} diff --git a/.github/tests/opencode-init-runtime/prepare.sh b/.github/tests/opencode-init-runtime/prepare.sh new file mode 100644 index 0000000..76b1d9c --- /dev/null +++ b/.github/tests/opencode-init-runtime/prepare.sh @@ -0,0 +1,26 @@ +#!/bin/sh +set -eu +test "$(cat /etc/alpine-release)" = 3.24.1 +test "$(apk --print-arch)" = x86_64 +count=0 +while IFS= read -r repo || [ -n "$repo" ]; do + case "$repo" in + ''|'#'*) continue ;; + https://dl-cdn.alpinelinux.org/alpine/v3.24/main|https://dl-cdn.alpinelinux.org/alpine/v3.24/community) ;; + *) printf '%s\n' 'unexpected repository; refusing substitution' >&2; exit 1 ;; + esac + count=$((count + 1)) +done < /etc/apk/repositories +test "$count" -gt 0 +for root in /opt/runtime /opt/inspect; do + mkdir -p "$root/etc/apk/keys" + cp /etc/apk/repositories "$root/etc/apk/repositories" + cp /etc/apk/keys/*.pub "$root/etc/apk/keys/" +done +# Fresh binary/library extraction, not a bootable root. Never run hooks outside +# their root; never rewrite absolute symlinks, interpreters, or resource paths. +apk --root /opt/runtime --initdb --no-cache --no-scripts add 'nodejs>=22.13' gcompat libgcc libstdc++ +apk --root /opt/inspect --initdb --no-cache --no-scripts add binutils +test -f /opt/runtime/lib/libgcompat.so.0 +test -x /opt/runtime/usr/bin/node +test -x /opt/inspect/usr/bin/readelf diff --git a/.github/tests/opencode-init-runtime/probe.mjs b/.github/tests/opencode-init-runtime/probe.mjs new file mode 100644 index 0000000..64f09b8 --- /dev/null +++ b/.github/tests/opencode-init-runtime/probe.mjs @@ -0,0 +1,56 @@ +import fs from "node:fs"; +import path from "node:path"; +import assert from "node:assert/strict"; +import { DatabaseSync } from "node:sqlite"; + +const home = process.env.HOME; +const status = fs.readFileSync("/proc/1/status", "utf8"); +assert.equal(process.getuid(), 1000); +assert.equal(process.getgid(), 1000); +for (const key of ["Uid", "Gid"]) + assert.match(status, new RegExp(`^${key}:\\s+1000\\s+1000\\s+1000\\s+1000$`, "m")); +assert.match(status, /^CapEff:\s+0+$/m); +assert.match(status, /^NoNewPrivs:\s+1$/m); +const mounts = fs.readFileSync("/proc/1/mountinfo", "utf8").split("\n").map(line => line.split(" ")); +for (const target of ["/", "/opt/runtime"]) + assert(mounts.some(fields => fields[4] === target && fields[5].split(",").includes("ro"))); +assert.throws(() => fs.writeFileSync("/rootfs-write-must-fail", "test")); +assert.throws(() => fs.writeFileSync("/opt/runtime/write-must-fail", "test")); +for (const dir of [home, `${home}/.config/opencode`, `${home}/.cache`, + `${home}/.local/share/context-mode`, `${home}/.local/state`, "/tmp"]) { + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, "init-runtime-write-test"); + fs.writeFileSync(file, "test"); + assert.equal(fs.readFileSync(file, "utf8"), "test"); + fs.unlinkSync(file); +} +const [major, minor] = process.versions.node.split(".").map(Number); +assert(major > 22 || (major === 22 && minor >= 13)); +const db = new DatabaseSync(":memory:"); +db.exec("CREATE VIRTUAL TABLE evidence USING fts5(content)"); +db.close(); +const mapped = fs.readFileSync("/proc/1/maps", "utf8").includes("/opt/runtime/lib/libgcompat.so.0"); +const packages = {}; +for (const [name, version] of [["context-mode", "1.0.169"], ["opencode-mem", "2.26.0"]]) { + const manifest = JSON.parse(fs.readFileSync( + `${home}/.cache/opencode/packages/${name}@${version}/node_modules/${name}/package.json`, "utf8")); + assert.equal(manifest.version, version); + packages[name] = version; +} +// Report relocation hazards without repairing them or reading arbitrary targets. +const links = []; +function walk(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const file = path.join(dir, entry.name); + if (entry.isDirectory()) walk(file); + else if (entry.isSymbolicLink()) { + const target = fs.readlinkSync(file); + if (target.startsWith("/") || !fs.existsSync(file)) + links.push({ name: path.relative("/opt/runtime", file), absolute: target.startsWith("/"), broken: !fs.existsSync(file) }); + } + } +} +walk("/opt/runtime"); +console.log(JSON.stringify({ security: true, node: process.versions.node, sqlite_fts5: true, + host_gcompat_mapped: mapped, packages, relocation_hazards: links.slice(0, 30), + relocation_hazard_count: links.length, context_host_dispatch: "not tested; registration only" })); diff --git a/.github/tests/opencode-init-runtime/runtime.py b/.github/tests/opencode-init-runtime/runtime.py new file mode 100644 index 0000000..c2d329c --- /dev/null +++ b/.github/tests/opencode-init-runtime/runtime.py @@ -0,0 +1,419 @@ +#!/usr/bin/env python3 +"""CI-only stock-image init-volume feasibility; candidate B is the sole runtime gate.""" + +import hashlib +import json +import math +import os +from pathlib import Path +import re +import signal +import subprocess +import sys +import time +from urllib.parse import quote +import uuid + +from diagnostics import run_cases + +IMAGE = "ghcr.io/anomalyco/opencode:1.18.29@sha256:ecc3bf96ee55dad226d9cde50d79aaa8a1215c47860c0fcdc71570461bf438b8" +HOME = "/home/opencode" +CONFIG = HOME + "/.config/opencode" +PLUGINS = ["context-mode@1.0.169", "opencode-mem@2.26.0"] +TOOLS = {"memory", "ctx_execute", "ctx_batch_execute", "ctx_index", "ctx_search", "ctx_stats"} +TOKEN = "ci-fixture-not-a-secret" +SENTENCE = "The synthetic runtime probe stores amber-harbor-7f31d2 evidence." +TAG = "opencode_project_" + hashlib.sha256(("path:" + HOME).encode()).hexdigest()[:16] +PREFIX = "opencode-init-" + uuid.uuid4().hex +FIXTURES = str(Path(__file__).resolve().parent) +VOLUMES, CONTAINERS = [], [] +DEADLINE = time.monotonic() + 600 # Shared setup budget: pull, identity, provisioning, inspection. +REPORT = {"arms": {}, "context_host_dispatch": "not tested; registration only"} +HARDEN = ["--read-only", "--cap-drop=ALL", "--security-opt=no-new-privileges", "--ulimit=core=0", + "--tmpfs=/tmp:rw,size=512m,mode=1777"] +ENV = ["-e", "HOME=" + HOME, "-e", "XDG_CONFIG_HOME=" + HOME + "/.config", + "-e", "XDG_CACHE_HOME=" + HOME + "/.cache", "-e", "XDG_DATA_HOME=" + HOME + "/.local/share", + "-e", "XDG_STATE_HOME=" + HOME + "/.local/state", + "-e", "CONTEXT_MODE_DIR=" + HOME + "/.local/share/context-mode", + "-e", "PATH=/opt/runtime/usr/bin:/opt/runtime/bin:/usr/local/bin:/usr/bin:/bin", + "-e", "LD_LIBRARY_PATH=/opt/runtime/lib:/opt/runtime/usr/lib"] +OPENCODE = {"plugin": PLUGINS, "enabled_providers": [], "permission": {"*": "deny"}, "mcp": {}} +MEMORY = { + "storagePath": HOME + "/.opencode-mem/data", "embeddingModel": "Xenova/nomic-embed-text-v1", + "embeddingDimensions": 768, "embeddingUseTaskPrefixes": True, + "autoCaptureEnabled": False, "autoCleanupEnabled": False, "injectProfile": False, + "userProfileAutoCleanupEnabled": False, "userProfileValidationEnabled": False, + "chatMessage": {"enabled": False}, "compaction": {"enabled": False}, + "containerTagPrefix": "opencode", "webServerEnabled": True, "webServerHost": "127.0.0.1", + "webServerPort": 4747, "webServerApiToken": TOKEN, "similarityThreshold": 0.6, +} + + +class Failure(Exception): + pass + + +def native_evidence(text): + # Only bounded, relevant synthetic crash context; never a raw log dump. + text = text[-65536:] + phrases = [p for p in ["error loading shared library", "undefined symbol", "symbol not found", + "invalid ELF", "Exec format error", "Permission denied", "Read-only file system", + "certificate", "ENOTFOUND", "ECONNREFUSED", "timed out"] if p.lower() in text.lower()] + symbols = re.findall(r"(?:undefined symbol[: ]+|Error relocating [^\n:]+: )([A-Za-z_][A-Za-z0-9_]{0,100})", text) + relevant = re.compile(r"assert|panic|crash|illegal.?instruction|segmentation|\bbun\b|onnx|plugin|^\s*at ", re.I) + sensitive = re.compile(r"authorization|bearer|token|password|secret|cookie|environment|config(?:uration)?\s*[:=]", re.I) + lines = [sanitize(line)[:384] for line in text.splitlines()[-80:] + if relevant.search(line) and not sensitive.search(line)] + return {"markers": phrases, "unresolved_symbols": sorted(set(symbols))[:12], + "crash_lines": lines[-16:]} + + +def sanitize(text): + detail = text[-8192:] + detail = re.sub(r'\x1b\[[0-?]*[ -/]*[@-~]', '', detail) + detail = re.sub(r'(?i)\b(?:https?|ftp|file)://[^\s<>"\x27]+', '[url]', detail) + detail = re.sub(r'(?i)\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+', '[credential]', detail) + detail = re.sub(r'(?i)\b(?:password|passwd|token|api[_-]?key|authorization|secret|cookie)["\x27]?\s*[:=]\s*(?:"[^"]*"|\x27[^\x27]*\x27|[^\s,;]+)', '[credential]', detail) + detail = re.sub(r'\b(?:sk-|ghp_|github_pat_|hf_)[A-Za-z0-9_-]+', '[credential]', detail) + detail = re.sub(r'\b[^\s:@/]+:[^\s@/]+@[^\s/]+', '[credential]', detail) + detail = re.sub(r'\?[^\s<>"\x27]+', '[query]', detail) + detail = re.sub(r'(?:[A-Za-z]:[\\/]|~/|\.\.?/|/)[^\s<>"\x27()]+', '[path]', detail) + return re.sub(r'[\x00-\x1f\x7f-\x9f]', ' ', detail)[:768] + + +def run(args, timeout=60, check=True, cleanup=False, data=None): + budget = timeout if cleanup else min(timeout, DEADLINE - time.monotonic()) + if budget <= 0: + raise Failure("phase deadline exhausted") + try: + result = subprocess.run(["docker", *args], input=data, text=True, capture_output=True, timeout=budget) + except (subprocess.TimeoutExpired, OSError): + raise Failure("container command timeout or unavailable") from None + if check and result.returncode: + raise Failure("docker " + args[0] + " failed rc=" + str(result.returncode) + " " + + json.dumps({"error": sanitize(result.stderr), **native_evidence(result.stderr + result.stdout)})) + return result + + +def observe(name): + evidence = {} + try: + template = '{' + ','.join('"' + key + '":{{json .State.' + key + '}}' + for key in ["Status", "Running", "ExitCode", "OOMKilled", "Error"]) + '}' + result = run(["inspect", "--format", template, name], timeout=15, check=False, cleanup=True) + evidence["inspect_returncode"] = result.returncode + if result.returncode: + return evidence + state = json.loads(result.stdout) + state["Error"] = sanitize(state["Error"]) + evidence["state"] = state + if not state["Running"]: + return evidence + # Stock shell builtins only; no Node, raw maps, environment, or config. + script = '''mapped=unavailable +if test -r /proc/1/maps; then + mapped=false + while IFS= read -r line; do + case "$line" in *"/opt/runtime/lib/libgcompat.so.0"*) mapped=true; break ;; esac + done < /proc/1/maps +fi +printf 'gcompat %s\\n' "$mapped" +for file in /sys/fs/cgroup/memory.peak /sys/fs/cgroup/memory.current /sys/fs/cgroup/memory.events /sys/fs/cgroup/memory.events.local /sys/fs/cgroup/memory/memory.max_usage_in_bytes /sys/fs/cgroup/memory/memory.oom_control; do + test -r "$file" || continue + count=0 + while read -r key value rest; do + count=$((count + 1)) + test "$count" -le 16 || break + printf '%s %s %s\\n' "${file##*/}" "$key" "$value" + done < "$file" +done''' + result = run(["exec", name, "/bin/sh", "-c", script], timeout=15, check=False, cleanup=True) + evidence["shell_returncode"] = result.returncode + for line in result.stdout[:4096].splitlines(): + fields = line.split() + if len(fields) == 2 and fields[0] == "gcompat" and fields[1] in ("true", "false", "unavailable"): + evidence["host_gcompat_mapped"] = {"true": True, "false": False, "unavailable": None}[fields[1]] + elif len(fields) == 2 and fields[0] in ("memory.peak", "memory.current", "memory.max_usage_in_bytes") and fields[1].isdigit(): + evidence.setdefault("cgroup", {})[fields[0]] = int(fields[1]) + elif len(fields) == 3 and fields[0] in ("memory.events", "memory.events.local", "memory.oom_control") and fields[1] in ("oom", "oom_kill", "oom_group_kill", "under_oom") and fields[2].isdigit(): + evidence.setdefault("cgroup", {})[fields[0] + "." + fields[1]] = int(fields[2]) + except (Failure, ValueError, KeyError, TypeError) as error: + evidence["unavailable"] = type(error).__name__ + return evidence + + +def volume(suffix): + name = PREFIX + "-" + suffix + VOLUMES.append(name) + run(["volume", "create", name]) + return name + + +def helper(args, command, timeout=120, data=None, cleanup=False): + name = PREFIX + "-helper-" + str(len(CONTAINERS)) + CONTAINERS.append(name) + return run(["run", "--rm", "-i", "--name", name, "--pull=never", "--platform=linux/amd64", + *HARDEN, *args, "--entrypoint=/bin/sh", IMAGE, "-ec", command], + timeout=timeout, data=data, cleanup=cleanup) + + +def request(name, port, route, post=None, timeout=30): + headers = {"x-opencode-directory": HOME} + if port == 4747: + headers["Authorization"] = "Bearer " + TOKEN + if post is not None: + headers["Content-Type"] = "application/json" + # Per-exec client override does not modify PID 1 or the container's config. + result = run(["exec", "-i", "-e", "LD_PRELOAD=", name, "node", "/probe/http.mjs"], timeout=timeout + 10, + data=json.dumps({"port": port, "route": route, "post": post, + "headers": headers, "timeout": timeout})) + envelope = json.loads(result.stdout) + CURRENT["last_request"] = {"route": route.split("?")[0], "status": envelope["status"], + "error": envelope["error"]} + if envelope["error"] is not None or not isinstance(envelope["status"], int) or not 200 <= envelope["status"] < 300: + return None + return envelope["body"] + + +def wait(name, port, route, accept, window=300): + end = min(DEADLINE, time.monotonic() + window) + while time.monotonic() < end: + payload = request(name, port, route, timeout=max(1, min(25, int(end - time.monotonic())))) + if payload is not None and accept(payload): + return payload + if run(["inspect", "--format", "{{.State.Status}}", name]).stdout.strip() != "running": + raise Failure("application exited before readiness") + time.sleep(2) + raise Failure("readiness deadline exhausted") + + +def elf(runtime, inspect, home=None, cleanup=False): + mounts = ["--network=none", "--user=1000:1000", "-v", runtime + ":/opt/runtime:ro", + "-v", inspect + ":/opt/inspect:ro", "-e", "LD_LIBRARY_PATH=/opt/inspect/lib:/opt/inspect/usr/lib"] + if home is None: + command = ("/opt/inspect/usr/bin/readelf -l /usr/local/bin/opencode; printf 'NODE_ELF\n'; " + "/opt/inspect/usr/bin/readelf -l /opt/runtime/usr/bin/node; " + "/opt/inspect/usr/bin/readelf --dyn-syms --wide /opt/runtime/lib/libgcompat.so.0") + else: + mounts += ["-v", home + ":" + HOME + ":ro"] + command = ("find " + HOME + "/.cache/opencode/packages/opencode-mem@2.26.0/node_modules " + "-type f -path '*/linux/x64/*' \\( -name 'libonnxruntime.so*' -o -name 'onnxruntime_binding.node' \\) " + "-exec /opt/inspect/usr/bin/readelf -d '{}' ';'") + output = helper(mounts, command, cleanup=cleanup).stdout + if home is not None: + return {"needed": sorted(set(re.findall(r"\(NEEDED\).*?\[(.*?)\]", output))), + "dynamic_sections": output.count("Dynamic section at offset")} + interpreters = [re.findall(r"Requesting program interpreter: ([^\]]+)", part) + for part in output.split("NODE_ELF\n", 1)] + exported = any("__vsnprintf_chk" in line and " UND " not in line and "GLOBAL" in line for line in output.splitlines()) + if len(interpreters) != 2 or not exported: + raise Failure("ELF inspection or gcompat dynamic symbol evidence absent") + return {"opencode_interpreter": interpreters[0] or "no PT_INTERP", + "node_interpreter": interpreters[1] or "no PT_INTERP", + "gcompat_exports___vsnprintf_chk": exported} + + +def prepare_home(home, config, cold, plugins=PLUGINS): + mounts = ["-v", home + ":" + HOME, "-v", config + ":" + CONFIG] + helper(["--network=none", "--user=0:0", "--cap-add=CHOWN", *mounts], + "chown -R 1000:1000 " + (HOME if cold else CONFIG)) + seed = "mkdir -p " + HOME + "/.cache " + HOME + "/.local/share/context-mode " + HOME + "/.local/state; " + if cold: + seed += "test ! -e " + HOME + "/.cache/opencode/packages; test ! -e " + HOME + "/.opencode-mem; " + # stdin contains only synthetic config; no external installer or package scripts. + seed += "IFS= read -r config; printf '%s\\n' \"$config\" > " + CONFIG + "/opencode.json; " + seed += "IFS= read -r memory; printf '%s\\n' \"$memory\" > " + CONFIG + "/opencode-mem.jsonc" + helper(["--network=none", "--user=1000:1000", *mounts], seed, + data=json.dumps({**OPENCODE, "plugin": plugins}) + "\n" + json.dumps(MEMORY) + "\n") + return mounts + + +def ready(name, arm): + def config_ok(value): + if not isinstance(value, dict): + return False + plugins = value.get("plugin") + if not isinstance(plugins, list): + raise Failure("malformed configured plugin list") + specs = [] + for plugin in plugins: + if isinstance(plugin, list): + if len(plugin) != 2 or not isinstance(plugin[1], dict): + raise Failure("malformed configured plugin tuple") + plugin = plugin[0] + if not isinstance(plugin, str) or not plugin: + raise Failure("malformed configured plugin spec") + specs.append(plugin) + if sorted(specs) != sorted(PLUGINS): + raise Failure("configured plugin specs do not exactly match fixture") + return value.get("enabled_providers") == [] and not value.get("mcp") + wait(name, 4096, "/config", config_ok, window=420) + ids = wait(name, 4096, "/experimental/tool/ids", lambda v: isinstance(v, list) and TOOLS.issubset(set(v))) + if any(ids.count(tool) != 1 for tool in TOOLS): + raise Failure("duplicate tool registration") + wait(name, 4747, "/api/health", lambda v: isinstance(v, dict) and v.get("success") is True and v.get("status") == "ok") + return json.loads(run(["exec", name, "node", "/probe/probe.mjs", arm]).stdout) + + +def recall(name, memory_id): + payload = request(name, 4747, "/api/search?q=" + quote(SENTENCE) + "&tag=" + TAG + "&pageSize=20", timeout=180) + if not isinstance(payload, dict) or payload.get("success") is not True: + raise Failure("real embedding recall failed") + hits = [item for item in (payload.get("data") or {}).get("items", []) + if item.get("type") == "memory" and item.get("id") == memory_id and item.get("content") == SENTENCE] + similarity = hits[0].get("similarity") if hits else None + if isinstance(similarity, bool) or not isinstance(similarity, (int, float)) or not math.isfinite(similarity) or similarity < 0.6: + raise Failure("same id/content/finite similarity >=0.6 not recalled") + return similarity + + +def arm_test(arm, runtime, inspect): + global DEADLINE, CURRENT + DEADLINE = time.monotonic() + 15 * 60 + CURRENT = {"status": "FAIL", "seconds": {}, "phase": "fresh HOME"} + REPORT["arms"][arm] = CURRENT + names = [] + home = None + try: + home = volume(arm + "-home") + memory_id = None + for lifecycle in ["cold", "warm"]: + CURRENT["phase"] = lifecycle + " config preparation" + config = volume(arm + "-config-" + lifecycle) + mounts = prepare_home(home, config, lifecycle == "cold") + name = PREFIX + "-" + arm + "-" + lifecycle + names.append(name) + CONTAINERS.append(name) + CURRENT["phase"] = lifecycle + " bootstrap and host registration" + start = time.monotonic() + preload = ["-e", "LD_PRELOAD=" + ("/opt/runtime/lib/libgcompat.so.0" if arm == "B" else "")] + run(["run", "-d", "--name", name, "--pull=never", "--platform=linux/amd64", + "--network=" + ("bridge" if lifecycle == "cold" else "none"), "--user=1000:1000", + *HARDEN, *ENV, *preload, *mounts, "-v", runtime + ":/opt/runtime:ro", + "-v", FIXTURES + ":/probe:ro", "-w", HOME, + IMAGE, "web", "--hostname", "127.0.0.1", "--port", "4096"]) + CURRENT[lifecycle + "_before_readiness"] = observe(name) + CURRENT[lifecycle + "_probe"] = ready(name, arm) + CURRENT["seconds"][lifecycle + "_bootstrap"] = round(time.monotonic() - start, 3) + if lifecycle == "cold": + CURRENT["phase"] = "first real embedding write" + first = time.monotonic() + payload = request(name, 4747, "/api/memories", {"content": SENTENCE, "containerTag": TAG}, timeout=300) + CURRENT["seconds"]["first_write_attempt"] = round(time.monotonic() - first, 3) + if not isinstance(payload, dict) or payload.get("success") is not True: + raise Failure("first real embedding write failed; no fallback") + memory_id = (payload.get("data") or {}).get("id") + if not isinstance(memory_id, str) or not memory_id: + raise Failure("memory write returned no id") + CURRENT["seconds"]["first_write"] = round(time.monotonic() - first, 3) + CURRENT["seconds"]["cold_to_write"] = round(time.monotonic() - start, 3) + CURRENT["phase"] = lifecycle + " real embedding recall" + first = time.monotonic() + CURRENT[lifecycle + "_similarity"] = recall(name, memory_id) + CURRENT["seconds"][lifecycle + "_recall"] = round(time.monotonic() - first, 3) + CURRENT["same_memory_id"] = memory_id + CURRENT["phase"] = lifecycle + " post-operation inspection" + CURRENT[lifecycle + "_post_recall_probe"] = json.loads( + run(["exec", name, "node", "/probe/probe.mjs", arm]).stdout) + if arm == "B" and not CURRENT[lifecycle + "_post_recall_probe"]["host_gcompat_mapped"]: + raise Failure("candidate gcompat absent from compiled host mappings after real embedding recall") + CURRENT["onnx_elf"] = elf(runtime, inspect, home) + if not CURRENT["onnx_elf"]["needed"]: + raise Failure("ONNX DT_NEEDED evidence absent after real embedding recall") + CURRENT[lifecycle + "_before_stop"] = observe(name) + run(["stop", "--time=20", name], timeout=45) + run(["rm", name]) + run(["volume", "rm", config]) + VOLUMES.remove(config) + CURRENT["status"] = "PASS" + except Exception as error: + CURRENT["failure"] = str(error) if isinstance(error, Failure) else type(error).__name__ + finally: + for name in names: + CURRENT.setdefault("before_cleanup", []).append(observe(name)) + try: + logs = run(["logs", "--tail=80", name], check=False, cleanup=True, timeout=10) + CURRENT.setdefault("native_diagnostics", []).append(native_evidence(logs.stdout + logs.stderr)) + run(["rm", "-f", name], check=False, cleanup=True, timeout=20) + except Failure: + CURRENT["diagnostics_incomplete"] = True + if home and "onnx_elf" not in CURRENT: + try: + CURRENT["onnx_elf"] = elf(runtime, inspect, home, cleanup=True) + except Failure: + CURRENT["onnx_elf_unavailable"] = True + + +def main(): + global DEADLINE + try: + started = time.monotonic() + run(["pull", "--platform=linux/amd64", IMAGE], timeout=300) + identity = run(["image", "inspect", "--format", + "{{json .RepoDigests}}|{{.Architecture}}|{{.Os}}|{{json .Config.Entrypoint}}", IMAGE]).stdout.strip().split("|") + if len(identity) != 4 or identity[1:3] != ["amd64", "linux"] or json.loads(identity[3]) != ["opencode"]: + raise Failure("stock image identity or entrypoint mismatch") + if not any(item.endswith("@" + IMAGE.split("@")[1]) for item in json.loads(identity[0])): + raise Failure("stock image digest mismatch") + REPORT["image"] = IMAGE + REPORT["pull_identity_seconds"] = round(time.monotonic() - started, 3) + runtime, inspect = volume("runtime"), volume("inspect") + started = time.monotonic() + helper(["--network=bridge", "--user=0:0", "--cap-add=CHOWN", + "-v", runtime + ":/opt/runtime", "-v", inspect + ":/opt/inspect", + "-v", FIXTURES + ":/probe:ro"], "sh /probe/prepare.sh", timeout=300) + REPORT["provision_seconds"] = round(time.monotonic() - started, 3) + for label, root in [("runtime", runtime), ("inspect", inspect)]: + output = helper(["--network=none", "--user=1000:1000", "-v", root + ":/opt/deps:ro"], + "apk --root /opt/deps list --installed --manifest").stdout + packages = [line.split() for line in output.splitlines() if line.strip()] + if not packages or any(len(p) != 2 or not all(re.fullmatch(r'[A-Za-z0-9_.+~-]+', s) for s in p) for p in packages): + raise Failure("installed package manifest malformed") + REPORT[label + "_packages"] = packages + scripts = helper(["--network=none", "--user=0:0", "-v", root + ":/opt/deps:ro"], + "for f in /opt/deps/lib/apk/db/scripts.tar /opt/deps/lib/apk/db/scripts.tar.gz; " + "do if test -f \"$f\"; then tar -tf \"$f\"; fi; done").stdout.splitlines() + if any(not re.fullmatch(r'[A-Za-z0-9_.+~/-]+', item) for item in scripts): + raise Failure("unexpected package script inventory") + REPORT[label + "_skipped_scripts"] = scripts[:100] + REPORT[label + "_skipped_script_count"] = len(scripts) + REPORT["elf"] = elf(runtime, inspect) + # Independent budgets; the expected control failure cannot starve/skip B. + arm_test("A", runtime, inspect) + arm_test("B", runtime, inspect) + run_cases(sys.modules[__name__], runtime) + except Exception as error: + REPORT["setup_failure"] = str(error) if isinstance(error, Failure) else type(error).__name__ + finally: + cleanup_ok = True + for kind, names in [("container", CONTAINERS), ("volume", VOLUMES)]: + for name in reversed(names): + try: + result = run([kind, "inspect", "--format", "{{.Name}}", name], check=False, cleanup=True, timeout=10) + if result.returncode: + # Distinguish absence from daemon failure without dumping inspect data. + missing = "no such" in result.stderr.lower() + cleanup_ok = cleanup_ok and missing + continue + run([kind, "rm", "-f", name], cleanup=True, timeout=20) + except Failure: + cleanup_ok = False + REPORT["cleanup_ok"] = cleanup_ok + passed = cleanup_ok and "setup_failure" not in REPORT and REPORT["arms"].get("B", {}).get("status") == "PASS" + REPORT["status"] = "PASS" if passed else "FAIL" + output = json.dumps(REPORT, indent=2) + print(output, flush=True) + if os.environ.get("GITHUB_STEP_SUMMARY"): + with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf8") as summary: + summary.write("## Init runtime feasibility\n\n```json\n" + output + "\n```\n") + return 0 if passed else 1 + + +def interrupted(_signum, _frame): + raise Failure("interrupted") + + +if __name__ == "__main__": + signal.signal(signal.SIGTERM, interrupted) + signal.signal(signal.SIGINT, interrupted) + sys.exit(main()) diff --git a/.github/workflows/opencode-init-runtime.yml b/.github/workflows/opencode-init-runtime.yml new file mode 100644 index 0000000..3c934ed --- /dev/null +++ b/.github/workflows/opencode-init-runtime.yml @@ -0,0 +1,22 @@ +name: opencode-init-runtime + +on: + pull_request: + paths: + - .github/workflows/opencode-init-runtime.yml + - .github/tests/opencode-init-runtime/** + +permissions: + contents: read + +jobs: + init-runtime-feasibility: + runs-on: ubuntu-latest + timeout-minutes: 50 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Test control A and required candidate B + run: python3 .github/tests/opencode-init-runtime/runtime.py