From 74fdf0ddc4d5f1df24171bcc64026949575afe24 Mon Sep 17 00:00:00 2001 From: Steven Welch Date: Sun, 4 Oct 2026 19:56:19 -0600 Subject: [PATCH 1/5] test: probe opencode-mem 2.28.3 on stock OpenCode V2 --- .github/tests/opencode-memory-v2/README.md | 162 +++++ .github/tests/opencode-memory-v2/runtime.py | 592 +++++++++++++++++ .../tests/opencode-memory-v2/test_runtime.py | 621 ++++++++++++++++++ .github/workflows/opencode-memory-v2.yml | 26 + 4 files changed, 1401 insertions(+) create mode 100644 .github/tests/opencode-memory-v2/README.md create mode 100644 .github/tests/opencode-memory-v2/runtime.py create mode 100644 .github/tests/opencode-memory-v2/test_runtime.py create mode 100644 .github/workflows/opencode-memory-v2.yml diff --git a/.github/tests/opencode-memory-v2/README.md b/.github/tests/opencode-memory-v2/README.md new file mode 100644 index 0000000..eed31ec --- /dev/null +++ b/.github/tests/opencode-memory-v2/README.md @@ -0,0 +1,162 @@ +# opencode-memory-v2 — CI runtime fixture (test-only) + +Maintainers: `makeitworkcloud/xnoto` · Branch `test/opencode-memory-v2-runtime` +(from charts `main` @ `2f5d497c72763d38fe9e7cfdabab1eabec6264a9`) · The +historical `#113` fixture files are untouched; this adds a new, separate +fixture. + +## Why this owned fixture exists + +Upstream `opencode-mem` validation targets Ubuntu/Windows/macOS package setups; +the chart's existing checks cover Helm configuration. Neither exercises the **stock +`ghcr.io/anomalyco/opencode` container** (Alpine/musl, non-root UID 1000, +read-only rootfs, dropped capabilities, no published ports, offline restart). +This fixture covers exactly that gap, with synthetic data on a disposable CI +runner only. + +## What a PASS means (scope) + +- The pinned image serves `GET /api/info` with `version == "2.0.22"`. +- `GET /api/plugin?location[directory]=/home/opencode` returns a `location` + envelope whose `directory` is exactly `/home/opencode`, and a plugin entry + with id exactly `opencode-mem`, `source.type == "package"`, target exactly + `opencode-mem@2.28.3` (or package target + resolved version `2.28.3`), and + `state.status == "active"`. List presence, wrong id/target/version, or a + `failed` state is rejected. +- Exactly one synthetic `POST /api/memories` with body + `{"content": , "containerTag": "sm_project_0c1f1a2b3c4d5e6f"}` + (both fields required by the maintained API) returns `success === true` + (strict; `1`/missing/`null` rejected) and a non-empty string `data.id`. + The POST gets a dedicated ≤600 s budget (cold model download) and is never + retried. +- `GET /api/search?q=...&tag=&page=1&pageSize=10` + returns `success === true` and `data.items` containing the exact id with + `type == "memory"`, byte-identical `content`, and a finite numeric + `similarity` ≥ 0.6 (lower bound only; no unsupported upper bound, no + alternative score keys). +- After a graceful stop+remove, an **offline** (`--network none`) replacement — + same image, same `--workdir /home/opencode`, same env, same persistent home + volume, **new freshly seeded config volume** — reproduces the same recall + with no further POST. +- `PASS` is printed (and exit code 0 returned) only when the lifecycle **and** + scoped cleanup both succeeded. + +**A PASS does NOT mean:** memory capture/native OpenCode tool dispatch, +permissions beyond the deny-all fixture config, LLM/provider behavior, +Kubernetes/Helm deployment, ARM, DR, or production-deployment acceptance. +Deliberately non-hermetic: the primary container performs cold public npm and +embedding-model downloads, and the plugin's transitive dependencies are not +pinned or verified (top-level pins only); the replacement proves the cached, +offline path. + +## Pinned inputs + +| Input | Value | +| --- | --- | +| Image | `ghcr.io/anomalyco/opencode:2.0.22@sha256:11f2b6c96d380867387fbee390c06cb47efffd9fdc37009b4cd40795b45dad19` (linux/amd64; `Dockerfile` at the vendor commit sets `ENTRYPOINT ["opencode"]`) | +| Plugin | `opencode-mem@2.28.3`, release `a24d79e34857aed9c5e708f5325714e6948bbfa7`; npm integrity `sha512-tIGQDODg9Taury02WbDYpNiKGOjwg5rudZADBxHR1FqDZYIjKmd8f77ZXLmfGSQYuPXIBK+jJG4u5yKLYvy2Gw==` | +| Vendor source | opencode `527f0b931d1f9b3ebd34e106c51b31ce5db5b075`: `packages/server/src/handlers/plugin.ts`, `packages/server/src/auth.ts`, `packages/cli/src/env.ts`, `packages/server/src/middleware/authorization.ts`, protocol groups `plugin.ts`/`location.ts`, `Dockerfile` | +| Plugin source | `src/config.ts`, `src/services/web-server.ts`, and **`src/services/api-handlers.ts`** (authoritative: `handleAddMemory` requires `content` + `containerTag`, returns `success:true, data.id`; `handleSearch(query, tag, page, pageSize)` returns `success:true, data.items:[{type:'memory', id, content, similarity}]`) plus `src/services/memory-scope.ts` (project-scope `sm_project_` containerTag shape) — all at the release above | +| API spec | https://opencode.ai/v2/openapi.json | + +## Flow (`runtime.py`) + +1. Global deadline (~22 min vs the 30-min job timeout) starts **before** the + pull; every docker operation is bounded by `min(requested, remaining)` so a + slow pull consumes — never exceeds — the overall budget. Diagnostics and + cleanup run on separate budgets (≤5 min combined), reserving three job + minutes for checkout, unit tests, and reporting. Readiness loops check + the deadline before probing and before sleeping, and a success arriving + after the deadline is rejected. +2. Pull + inspect with `--platform linux/amd64`: platform, `.Id`, + `RepoDigests` only — never `Config.Env`. +3. UUID-named resources are tracked **before** creation, so a docker CLI + timeout cannot orphan them; removal attempts `rm -f` even when `stop` + fails, and existence is probed with explicit `container`/`volume` inspect + kinds. Two seed files are generated world-readable (0755/0644) because the + capability-less root preparer cannot traverse a 0700 tempfile dir: + `opencode.json` (native `plugins`, `enabled_providers: []` — V1-compatible + disable normalized by the pinned OpenCode source — and + deny-all `permissions`) and `opencode-mem.jsonc` (absolute + `storagePath: /home/opencode/.opencode-mem` inside the persistent home + volume, pinned defaults, public fixture `webServerApiToken` on + 127.0.0.1:4747; no provider/model/API keys; `userProfileAnalysisInterval` + absent). +4. A named root preparer (same image, UID 0, `--cap-drop ALL --cap-add CHOWN`, + `--network none`, read-only, tiny seed dir mounted read-only) seeds each + fresh config volume; it uses `--entrypoint sh` (image entrypoint is + `opencode`) and orders `chmod` before `chown` (after chown, root no longer + owns the files and `chmod` would need the dropped `CAP_FOWNER`). The + replacement preparer touches only the new config volume — the retained home + volume is not even mounted. +5. App container: `--platform linux/amd64`, `--user 1000:1000 --read-only + --cap-drop ALL --security-opt no-new-privileges --tmpfs /tmp --workdir + /home/opencode`, no published ports, no docker socket or checkout mounts; + mounts are only the persistent home volume, the seeded writable config + volume, and a tiny read-only bind of the generated `opencode.json`. Env is + an explicit allow-list (`HOME`, XDG dirs, `OPENCODE_DB=opencode.db` — a + bare filename resolved inside the XDG data dir under the persistent home, + the canonical retained location — and `OPENCODE_PASSWORD`, the only + credential, a public synthetic value enabling Basic `opencode` on `/api/*`). + Command: `opencode serve --hostname 127.0.0.1 --port 4096`. +6. Security verification reads only selected inspect fields (user, + read-only rootfs, `CapDrop == ["ALL"]`, `CapAdd` null/empty, + no-new-privileges, empty port bindings, network mode) plus an in-container + `id -u` == 1000. +7. Readiness (`/api/info`), registration (`/api/plugin`), and plugin web + health (`/api/health`, `success === true` strictly) are distinct bounded + stages; retry loops issue read-only GETs only — the POST is never replayed. + All in-container HTTP uses stock `wget` via `docker exec` (10 s for GETs, + ≤600 s for the single POST, exec timeout = wget budget + margin) with the + Bearer fixture token for the plugin web API. +8. One synthetic POST + exact id/type/content/similarity search; graceful + stop + remove; offline replacement with a **new** seeded config volume and + the same home volume; identical recall asserted; exactly-one-POST enforced + (`http.posts == 1`). +9. Scoped cleanup always runs — even after global-deadline exhaustion — on its + own budget; cleanup failures fail the gate (no false PASS). Failure + diagnostics merge stdout+stderr from `docker logs`, keep only + allow-listed failure-signal lines, sanitize credential-like content, and + clip to a bounded tail; no raw logs, nothing uploaded, no caches/artifacts. + +Fixture credentials are public synthetic values; no production auth material +is used or mirrored. + +## Failure stages + +`startup` (image/platform/run/security/readiness/deadline) · `transport` +(docker CLI/exec/HTTP) · `registration` (plugin missing/failed/wrong +id/target/version, location mismatch) · `storage` (add/search id, type, or +content mismatch, duplicate POST) · `embedding` (similarity missing, +non-numeric, NaN, bool, or below 0.6) · `cleanup`. + +## Tests + +`test_runtime.py` (stdlib `unittest`, mocked docker/time) covers +malformed/wrapped/failed-state/wrong-id-target-version payloads, location +envelope exactness, strict `success is True` (rejecting `1`/missing), +`type == 'memory'`, similarity-only (no score fallback, no upper bound), +id/content/NaN/bool/threshold assertions, deadline behavior (pre-check, +delayed-success rejection, slow-pull budget, exhausted budget, separate +cleanup budget), cleanup semantics (stop failure still removes, rm failure +fails, explicit inspect kinds, track-before-create), sanitizer, diagnostics +filtering, single-POST enforcement with `containerTag`, seed-file modes, env +allow-list, and same workdir/env for both containers. CI is authoritative; the +fixture is not run locally. + +## Repo impact + +Test-only diff: the canonical changed-chart selector (first path components +intersecting directories with a direct `Chart.yaml`) yields `[]` ⇒ +`packages/updater` is skipped, no chart version bump, `charts/` unchanged. +The workflow pins `actions/checkout` v7.0.1 to the verified full 40-character +commit `3d3c42e5aac5ba805825da76410c181273ba90b1`. + +## Source references + +- [OpenCode plugin API](https://github.com/anomalyco/opencode/blob/527f0b931d1f9b3ebd34e106c51b31ce5db5b075/packages/protocol/src/groups/plugin.ts) +- [OpenCode Basic authentication](https://github.com/anomalyco/opencode/blob/527f0b931d1f9b3ebd34e106c51b31ce5db5b075/packages/server/src/middleware/authorization.ts) +- [OpenCode legacy provider normalization](https://github.com/anomalyco/opencode/blob/527f0b931d1f9b3ebd34e106c51b31ce5db5b075/packages/core/src/config/normalize.ts) +- [OpenCode database path resolution](https://github.com/anomalyco/opencode/blob/527f0b931d1f9b3ebd34e106c51b31ce5db5b075/packages/core/src/database/database.ts) +- [Plugin memory API handlers](https://github.com/tickernelz/opencode-mem/blob/a24d79e34857aed9c5e708f5325714e6948bbfa7/src/services/api-handlers.ts) +- [Plugin scope-tag parser](https://github.com/tickernelz/opencode-mem/blob/a24d79e34857aed9c5e708f5325714e6948bbfa7/src/services/memory-scope.ts) diff --git a/.github/tests/opencode-memory-v2/runtime.py b/.github/tests/opencode-memory-v2/runtime.py new file mode 100644 index 0000000..b719f2e --- /dev/null +++ b/.github/tests/opencode-memory-v2/runtime.py @@ -0,0 +1,592 @@ +#!/usr/bin/env python3 +"""Test-only CI fixture: opencode-mem v2 plugin in the stock OpenCode container. + +Synthetic data on a disposable runner; a PASS covers only what README.md +lists. Security rationale: UUID-named disposable resources; non-root, +read-only app containers with every capability dropped and no-new-privileges; +no published ports, socket, or checkout mounts; env allow-list; sanitized, +bounded diagnostics; scoped cleanup whose failure fails the gate. +""" +from __future__ import annotations + +import base64 +import json +import math +import os +import re +import shutil +import subprocess +import sys +import tempfile +import time +import uuid +from pathlib import Path +from urllib.parse import quote_plus + +IMAGE = ("ghcr.io/anomalyco/opencode:2.0.22@" + "sha256:11f2b6c96d380867387fbee390c06cb47efffd9fdc37009b4cd40795b45dad19") +PLATFORM = "linux/amd64" +OPENCODE_VERSION = "2.0.22" +PLUGIN_SPEC = "opencode-mem@2.28.3" +PLUGIN_ID = "opencode-mem" +PLUGIN_VERSION = "2.28.3" +PLUGIN_RELEASE = "a24d79e34857aed9c5e708f5325714e6948bbfa7" + +# Public synthetic fixture values only (never production material). +BASIC_USER = "opencode" +BASIC_PASSWORD = "opencode-ci-public-password" +MEM_TOKEN = "opencode-mem-ci-public-token" +# Project-scope tag shape parsed by plugin src/services/memory-scope.ts at PLUGIN_RELEASE. +CONTAINER_TAG = "sm_project_0c1f1a2b3c4d5e6f" + +HOME_PATH = "/home/opencode" +MEM_STORAGE = HOME_PATH + "/.opencode-mem" +SERVER_PORT = 4096 +MEM_PORT = 4747 +APP_USER = "1000:1000" +APP_UID = "1000" +MEMORY_CONTENT = ("OpenCode memory v2 CI fixture synthetic memory: the amber " + "lighthouse catalogs quartz sparrow reports.") +SIMILARITY_THRESHOLD = 0.6 + +GLOBAL_DEADLINE_S = 22 * 60 # reserves three job minutes beyond diagnostics and cleanup +DOCKER_TIMEOUT_S = 180 +PULL_TIMEOUT_S = 600 +PREP_TIMEOUT_S = 300 +HTTP_TIMEOUT_S = 10 # read-only GET probes +POST_TIMEOUT_S = 600 # single embedding POST; cold model download allowed +EXEC_MARGIN_S = 15 # docker exec timeout = wget budget + margin +POLL_INTERVAL_S = 3.0 +STOP_TIMEOUT_S = 30 +DIAGNOSTIC_BUDGET_S = 60 # separate budgets; <=5 min combined with cleanup +CLEANUP_BUDGET_S = 240 +LOG_TAIL_LINES = 40 +LOG_TAIL_BYTES = 8192 + +APP_ENV = [ + ("HOME", HOME_PATH), + ("XDG_CONFIG_HOME", HOME_PATH + "/.config"), + ("XDG_CACHE_HOME", HOME_PATH + "/.cache"), + ("XDG_DATA_HOME", HOME_PATH + "/.local/share"), + ("OPENCODE_DB", "opencode.db"), # bare filename; resolved inside XDG data dir (canonical) + ("OPENCODE_PASSWORD", BASIC_PASSWORD), +] + +OPENCODE_CONFIG = { + "$schema": "https://opencode.ai/config.json", + "plugins": [PLUGIN_SPEC], + "enabled_providers": [], # V1-compatible provider disable (approved plan) + "permissions": [{"action": "*", "resource": "*", "effect": "deny"}], +} + +# Plugin defaults pinned explicitly; absolute local storage under the persistent +# home volume; no provider/model/embedding API keys; userProfileAnalysisInterval +# deliberately absent (0 is invalid). +MEM_CONFIG = { + "storagePath": MEM_STORAGE, + "embeddingModel": "Xenova/nomic-embed-text-v1", + "embeddingDimensions": 768, + "similarityThreshold": SIMILARITY_THRESHOLD, + "autoCaptureEnabled": False, "autoCleanupEnabled": False, + "injectProfile": False, "userProfileAutoCleanupEnabled": False, + "userProfileValidationEnabled": False, + "chatMessage": {"enabled": False}, + "compaction": {"enabled": False}, + "autoUpdate": False, + "databaseEncryptionEnabled": False, + "webServerEnabled": True, + "webServerHost": "127.0.0.1", + "webServerPort": MEM_PORT, + "webServerApiToken": MEM_TOKEN, +} + +_REDACTIONS = [ + (re.compile(r"(?i)authorization\s*:\s*[^\s]+.*"), "authorization: [redacted]"), + (re.compile(r"(?i)bearer\s+[^\s]+"), "bearer [redacted]"), + (re.compile(r"(?i)x-opencode-mem-token\s*:\s*[^\s]+"), "x-opencode-mem-token: [redacted]"), + (re.compile(r"(?i)\b(token|secret|password|passwd|credential|api[_-]?key)\b\s*[=:]\s*[^\s]+"), + r"\1=[redacted]"), + (re.compile(r"(?i)\b([A-Z0-9_]*(PASSWORD|TOKEN|SECRET|API_?KEY|CREDENTIAL)[A-Z0-9_]*)=\S+"), + r"\1=[redacted]"), + (re.compile(r"(?i)[a-z][a-z0-9+.\-]*://[^\s/:@]+:[^\s/@]+@"), "[redacted-url]://"), +] + +_DIAG_ALLOW = re.compile( + r"(?i)\b(error|fail|fatal|panic|exception|eaddr|econn|timeout|timed out|denied|exit|crash)\b") + +def sanitize(text): + """Redact auth headers, tokens, passwords, env-style secrets, URL credentials.""" + out = text if isinstance(text, str) else repr(text) + for pattern, repl in _REDACTIONS: + out = pattern.sub(repl, out) + return out + +def clip(text, limit=LOG_TAIL_BYTES): + text = text or "" + if len(text) <= limit: + return text + return "...[clipped]..." + text[-limit:] + +class StageError(Exception): + """Failure attributed to a lifecycle stage (see README failure-stage table).""" + + def __init__(self, stage, message): + super().__init__(f"[{stage}] {clip(sanitize(message))}") + self.stage = stage + +_now = time.monotonic +_sleep = time.sleep + +def _subprocess_execute(args, timeout): + try: + proc = subprocess.run(["docker"] + list(args), capture_output=True, + text=True, timeout=timeout) + except subprocess.TimeoutExpired: + raise StageError("transport", f"docker {' '.join(map(str, args[:3]))} timed out after {timeout}s") + return proc.returncode, proc.stdout or "", proc.stderr or "" + +class Docker: + """Docker CLI adapter bounded by an optional deadline: every operation uses + min(requested, remaining) so slow steps consume, never exceed, the budget.""" + + def __init__(self, execute=None, deadline=None): + self.execute = execute or _subprocess_execute + self.deadline = deadline + + def set_deadline(self, deadline): + self.deadline = deadline + + def budget(self, requested, stage="transport"): + if self.deadline is None: + return requested + remaining = self.deadline - _now() + if remaining <= 0: + raise StageError(stage, "time budget exhausted before docker operation") + return min(requested, remaining) + + def run(self, args, timeout=DOCKER_TIMEOUT_S, stage="transport"): + rc, out, err = self.execute(list(args), self.budget(timeout, stage)) + if rc != 0: + raise StageError(stage, f"docker {list(args[:4])} rc={rc}: {clip(sanitize(err or out))}") + return out + + def try_run(self, args, timeout=DOCKER_TIMEOUT_S, stage="transport"): + return self.execute(list(args), self.budget(timeout, stage)) + + def exists(self, name, kind="container"): + args = (["volume", "inspect"] if kind == "volume" else ["container", "inspect"]) + [name] + rc, _out, err = self.execute(args, self.budget(DOCKER_TIMEOUT_S, "cleanup")) + if rc == 0: + return True + if "no such" in (err or "").lower(): + return False + raise StageError("cleanup", f"{kind} inspect {name} rc={rc}: {clip(sanitize(err))}") + +class Resources: + """Scoped registry; names are tracked BEFORE creation so a docker CLI + timeout (which may still have created the object) cannot orphan them.""" + + def __init__(self, docker): + self.docker = docker + self.containers = [] + self.volumes = [] + + def track_container(self, name): + self.containers.append(name) + + def track_volume(self, name): + self.volumes.append(name) + + def _remove_container(self, name): + if not self.docker.exists(name, "container"): + return + try: + self.docker.run(["stop", "-t", str(STOP_TIMEOUT_S), name], stage="cleanup") + except StageError: + pass # still attempt rm -f below; it tears down running containers too + self.docker.run(["rm", "-f", name], stage="cleanup") + + def _remove_volume(self, name): + if self.docker.exists(name, "volume"): + self.docker.run(["volume", "rm", "-f", name], stage="cleanup") + + def cleanup(self): + errors = [] + for remover, names in ((self._remove_container, self.containers), + (self._remove_volume, self.volumes)): + for name in names: + try: + remover(name) + except StageError as exc: + errors.append(str(exc)) + if errors: + raise StageError("cleanup", "; ".join(errors)) + +def _require_dict(obj, stage, what): + if not isinstance(obj, dict): + raise StageError(stage, f"{what}: expected JSON object, got {type(obj).__name__}") + return obj + +def check_info_payload(obj): + _require_dict(obj, "startup", "GET /api/info") + version = obj.get("version") + if version != OPENCODE_VERSION: + raise StageError("startup", f"server version {version!r} != pinned {OPENCODE_VERSION!r}") + for key in ("pid", "urls", "paths"): + if key not in obj: + raise StageError("startup", f"GET /api/info missing {key!r}") + return obj + +def check_plugin_payload(obj): + """Exact invariants: id, package source, pinned target/version, active state, HOME location envelope.""" + _require_dict(obj, "registration", "GET /api/plugin") + location = obj.get("location") + if not isinstance(location, dict) or location.get("directory") != HOME_PATH: + raise StageError("registration", f"location envelope missing or directory != {HOME_PATH!r}") + entries = obj.get("data") + if not isinstance(entries, list): + raise StageError("registration", "plugin payload malformed (wrapped or missing 'data' list)") + for entry in entries: + if not isinstance(entry, dict) or entry.get("id") != PLUGIN_ID: + continue + source = entry.get("source") + source = source if isinstance(source, dict) else {} + target, version = source.get("target"), source.get("version") + if version is not None and version != PLUGIN_VERSION: + raise StageError("registration", f"plugin resolved version mismatch: {version!r}") + if source.get("type") != "package": + raise StageError("registration", f"unexpected plugin source type {source.get('type')!r}") + if target != PLUGIN_SPEC and not (target == PLUGIN_ID and version == PLUGIN_VERSION): + raise StageError("registration", f"plugin target/version mismatch: {target!r}@{version!r}") + state = entry.get("state") + if not isinstance(state, dict) or "status" not in state: + raise StageError("registration", "plugin entry missing state.status") + if state.get("status") != "active": + detail = state.get("error") or state.get("status") + raise StageError("registration", f"plugin state {state.get('status')!r}: {clip(sanitize(str(detail)))}") + return entry + raise StageError("registration", f"{PLUGIN_ID} not present in plugin list ({len(entries)} entries)") + +def check_add_response(obj): + _require_dict(obj, "storage", "POST /api/memories") + if obj.get("success") is not True: + raise StageError("storage", f"add not successful: {clip(sanitize(str(obj.get('error') or obj)))}") + data = obj.get("data") + if not isinstance(data, dict): + raise StageError("storage", "add payload malformed (wrapped or missing 'data')") + memory_id = data.get("id") + if not isinstance(memory_id, str) or not memory_id: + raise StageError("storage", f"add returned invalid id {memory_id!r}") + return memory_id + +def check_search_response(obj, memory_id, content): + _require_dict(obj, "embedding", "GET /api/search") + if obj.get("success") is not True: + raise StageError("embedding", f"search not successful: {clip(sanitize(str(obj.get('error') or obj)))}") + data = obj.get("data") + if not isinstance(data, dict) or not isinstance(data.get("items"), list): + raise StageError("embedding", "search payload malformed (wrapped or missing 'data.items')") + match = None + for item in data["items"]: + if isinstance(item, dict) and item.get("id") == memory_id: + match = item + break + if match is None: + raise StageError("storage", f"memory id not found among {len(data['items'])} search results") + if match.get("type") != "memory": + raise StageError("storage", f"search item type {match.get('type')!r} != 'memory'") + if match.get("content") != content: + raise StageError("storage", "recalled content differs from stored content") + similarity = match.get("similarity") + # Lower bound only: cosine similarity >= threshold; no unsupported upper bound. + if isinstance(similarity, bool) or not isinstance(similarity, (int, float)): + raise StageError("embedding", f"similarity missing or non-numeric: {similarity!r}") + if not math.isfinite(similarity): + raise StageError("embedding", "similarity is not finite (NaN/inf)") + if similarity < SIMILARITY_THRESHOLD: + raise StageError("embedding", f"similarity {similarity} < threshold {SIMILARITY_THRESHOLD}") + return similarity + +def check_mem_health(obj): + _require_dict(obj, "registration", "GET /api/health") + if obj.get("success") is not True or obj.get("status") != "ok": + raise StageError("registration", f"plugin web health not ok: {clip(sanitize(str(obj)))}") + return obj + +def _parse_json(raw, path): + try: + return json.loads(raw) + except (json.JSONDecodeError, TypeError) as exc: + raise StageError("transport", f"non-JSON response from {path}: {exc}; body={clip(sanitize(str(raw)))}") + +class ContainerHttp: + """HTTP via `docker exec wget` (stock busybox wget); no ports published. + GETs use 10s; the single embedding POST gets a dedicated long budget and is + never retried.""" + + def __init__(self, docker): + self.docker = docker + self.posts = 0 + self.get_paths = [] + self.post_payloads = [] + + def _wget(self, cname, url, headers, body=None, timeout=HTTP_TIMEOUT_S): + args = ["exec", cname, "wget", "-q", "-O", "-", "-T", str(int(timeout))] + for header in headers: + args += ["--header", header] + if body is not None: + args += ["--header", "Content-Type: application/json", "--post-data", body] + args.append(url) + return self.docker.run(args, timeout=timeout + EXEC_MARGIN_S) + + def server_get(self, cname, path): + cred = base64.b64encode(f"{BASIC_USER}:{BASIC_PASSWORD}".encode()).decode() + raw = self._wget(cname, f"http://127.0.0.1:{SERVER_PORT}{path}", + [f"Authorization: Basic {cred}"]) + return _parse_json(raw, path) + + def mem_get(self, cname, path): + self.get_paths.append(path) + raw = self._wget(cname, f"http://127.0.0.1:{MEM_PORT}{path}", + [f"Authorization: Bearer {MEM_TOKEN}"]) + return _parse_json(raw, path) + + def mem_post_json(self, cname, path, payload): + self.posts += 1 + self.post_payloads.append(payload) + raw = self._wget(cname, f"http://127.0.0.1:{MEM_PORT}{path}", + [f"Authorization: Bearer {MEM_TOKEN}"], + body=json.dumps(payload), timeout=POST_TIMEOUT_S) + return _parse_json(raw, path) + +def write_seed_files(seed_dir): + """World-readable seed (tempfile defaults 0700 would block the capability-less + root preparer from reading the read-only bind).""" + root = Path(seed_dir) + for name, payload in (("opencode.json", OPENCODE_CONFIG), ("opencode-mem.jsonc", MEM_CONFIG)): + path = root / name + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + path.chmod(0o644) + os.chmod(root, 0o755) + +def create_volume(docker, res, name): + res.track_volume(name) # track before create: a CLI timeout may still create it + docker.run(["volume", "create", name], stage="startup") + +def pull_and_verify_image(docker): + docker.run(["image", "pull", "--platform", PLATFORM, IMAGE], timeout=PULL_TIMEOUT_S, stage="startup") + + def field(fmt): + return docker.run(["image", "inspect", IMAGE, "--format", fmt]).strip() + + os_name, arch = field("{{.Os}}"), field("{{.Architecture}}") + image_id = field("{{.Id}}") + digests = json.loads(field("{{json .RepoDigests}}") or "[]") + digest = IMAGE.split("@", 1)[1] + if os_name != "linux" or arch not in ("amd64", "x86_64"): + raise StageError("startup", f"unexpected image platform {os_name}/{arch}") + if not image_id.startswith("sha256:"): + raise StageError("startup", "image .Id missing sha256 digest") + if not any(str(entry).endswith(digest) for entry in digests): + raise StageError("startup", f"pinned digest absent from RepoDigests: {digests}") + +def seed_volume(docker, res, prep_name, seed_dir, cfg_vol, home_vol=None): + """Root preparer (same image, UID 0, CHOWN only, offline) seeds a fresh + config volume. Image ENTRYPOINT is `opencode`, so sh is forced via + --entrypoint; chmod precedes chown (CAP_FOWNER is dropped). home_vol=None + (replacement) leaves the retained home volume unmounted.""" + res.track_container(prep_name) + steps = ["mkdir -p /home/opencode/.config/opencode", + "cp /seed/opencode.json /seed/opencode-mem.jsonc /home/opencode/.config/opencode/"] + if home_vol is not None: + steps += ["chmod -R u+rwX /home/opencode", "chown -R 1000:1000 /home/opencode"] + else: + steps += ["chmod -R u+rwX /home/opencode/.config", "chown -R 1000:1000 /home/opencode/.config"] + args = ["run", "--rm", "--platform", PLATFORM, "--name", prep_name, + "--user", "0:0", "--network", "none", "--read-only", + "--cap-drop", "ALL", "--cap-add", "CHOWN", + "--security-opt", "no-new-privileges", "--tmpfs", "/tmp", + "-v", f"{seed_dir}:/seed:ro", + "-v", f"{cfg_vol}:/home/opencode/.config"] + if home_vol is not None: + args += ["-v", f"{home_vol}:/home/opencode"] + args += ["--entrypoint", "sh", IMAGE, "-c", " && ".join(steps)] + docker.run(args, timeout=PREP_TIMEOUT_S, stage="startup") + +def app_run_args(name, home_vol, cfg_vol, seed_dir, offline): + args = ["run", "-d", "--platform", PLATFORM, "--name", name, + "--user", APP_USER, "--read-only", "--cap-drop", "ALL", + "--security-opt", "no-new-privileges", "--tmpfs", "/tmp", + "--workdir", HOME_PATH] + if offline: + args += ["--network", "none"] + for key, value in APP_ENV: + args += ["-e", f"{key}={value}"] + args += ["-v", f"{home_vol}:{HOME_PATH}", + "-v", f"{cfg_vol}:{HOME_PATH}/.config", + "-v", f"{seed_dir}/opencode.json:{HOME_PATH}/.config/opencode/opencode.json:ro", + "--entrypoint", "opencode", + IMAGE, + "serve", "--hostname", "127.0.0.1", "--port", str(SERVER_PORT)] + return args + +def _inspect_field(docker, name, fmt): + return docker.run(["container", "inspect", name, "--format", fmt], stage="startup").strip() + +def verify_security(docker, name, expect_network): + """Selected inspect fields only (never Config.Env) plus in-container uid.""" + expectations = [ + ("user", _inspect_field(docker, name, "{{.Config.User}}"), APP_USER), + ("readonly-rootfs", _inspect_field(docker, name, "{{.HostConfig.ReadonlyRootfs}}"), "true"), + ("cap-drop", _inspect_field(docker, name, "{{json .HostConfig.CapDrop}}"), '["ALL"]'), + ("no-new-privileges", + "no-new-privileges" in _inspect_field(docker, name, "{{json .HostConfig.SecurityOpt}}"), True), + ("port-bindings", _inspect_field(docker, name, "{{json .HostConfig.PortBindings}}"), "{}"), + ("network", _inspect_field(docker, name, "{{.HostConfig.NetworkMode}}"), expect_network)] + for label, actual, expected in expectations: + if actual != expected: + raise StageError("startup", f"{name} security check {label!r}: {actual!r} != {expected!r}") + cap_add = _inspect_field(docker, name, "{{json .HostConfig.CapAdd}}") + if cap_add not in ("null", "[]"): + raise StageError("startup", f"{name} unexpected granted capabilities: {cap_add}") + uid = docker.run(["exec", name, "id", "-u"], stage="startup").strip() + if uid != APP_UID: + raise StageError("startup", f"{name} runs as uid {uid!r}, expected {APP_UID}") + +def add_memory(http, cname): + if http.posts: + raise StageError("storage", "refusing duplicate memory POST (one-shot by design)") + payload = {"content": MEMORY_CONTENT, "containerTag": CONTAINER_TAG} + return check_add_response(http.mem_post_json(cname, "/api/memories", payload)) + +def search_verify(http, cname, memory_id): + path = ("/api/search?q=" + quote_plus(MEMORY_CONTENT) + + "&tag=" + quote_plus(CONTAINER_TAG) + "&page=1&pageSize=10") + return check_search_response(http.mem_get(cname, path), memory_id, MEMORY_CONTENT) + +def wait_until(label, fn, deadline, interval=POLL_INTERVAL_S): + while True: + if _now() >= deadline: + raise StageError("startup", f"{label}: deadline exhausted before check") + try: + result = fn() + except StageError as exc: + if _now() >= deadline: + raise StageError(exc.stage, f"{label}: deadline exhausted; last error: {exc}") + _sleep(min(interval, max(0, deadline - _now()))) + continue + if _now() >= deadline: + raise StageError("startup", f"{label}: result arrived after deadline; rejected") + return result + +def bootstrap(http, cname, deadline): + wait_until("server readiness", + lambda: check_info_payload(http.server_get(cname, "/api/info")), deadline) + wait_until("plugin registration", + lambda: check_plugin_payload( + http.server_get(cname, "/api/plugin?location[directory]=/home/opencode")), deadline) + wait_until("plugin web health", + lambda: check_mem_health(http.mem_get(cname, "/api/health")), deadline) + +def diagnostics(docker, res): + """Bounded, allow-listed, sanitized failure lines only (docker logs writes + native errors to stderr; stdout and stderr are merged). No raw logs.""" + docker.set_deadline(_now() + DIAGNOSTIC_BUDGET_S) + chunks = [] + for name in res.containers: + try: + _rc, out, err = docker.try_run(["logs", "--tail", str(LOG_TAIL_LINES), name]) + except StageError: + continue + merged = sanitize(((out or "") + "\n" + (err or ""))) + hits = [line for line in merged.splitlines() if _DIAG_ALLOW.search(line)] + if hits: + chunks.append(f"--- {name} failure lines (sanitized, bounded) ---\n" + + clip("\n".join(hits))) + return "\n".join(chunks) + +def main(): + docker = Docker() + res = Resources(docker) + http = ContainerHttp(docker) + run_id = uuid.uuid4().hex[:10] + home_vol = f"ocmemci-home-{run_id}" + cfg_vols = [f"ocmemci-cfg-{run_id}-c1", f"ocmemci-cfg-{run_id}-c2"] + app_names = [f"ocmemci-app-{run_id}-c1", f"ocmemci-app-{run_id}-c2"] + prep_names = [f"ocmemci-prep-{run_id}-c1", f"ocmemci-prep-{run_id}-c2"] + seed_dir = None + lifecycle_ok = False + try: + deadline = _now() + GLOBAL_DEADLINE_S # global budget starts before pull + docker.set_deadline(deadline) + print(f"fixture run {run_id}: {IMAGE} ({PLATFORM})") + print(f"plugin {PLUGIN_SPEC} (release {PLUGIN_RELEASE})") + pull_and_verify_image(docker) + for volume in [home_vol, *cfg_vols]: + create_volume(docker, res, volume) + seed_dir = tempfile.mkdtemp(prefix="ocmemci-seed-") + write_seed_files(seed_dir) + + print("stage: primary container (cold network: plugin + embedding model fetch)") + seed_volume(docker, res, prep_names[0], seed_dir, cfg_vols[0], home_vol=home_vol) + res.track_container(app_names[0]) + docker.run(app_run_args(app_names[0], home_vol, cfg_vols[0], seed_dir, offline=False), + stage="startup") + verify_security(docker, app_names[0], "default") + primary = app_names[0] + bootstrap(http, primary, deadline) + + print("stage: storage + embedding (single POST, dedicated budget, no retry)") + memory_id = add_memory(http, primary) + print(f"memory id: {memory_id}") + similarity = search_verify(http, primary, memory_id) + print(f"primary recall similarity: {similarity}") + + print("stage: graceful stop + removal of primary") + docker.run(["stop", "-t", str(STOP_TIMEOUT_S), primary], stage="startup") + docker.run(["rm", primary], stage="startup") + + print("stage: offline replacement (fresh seeded config volume, persistent home)") + seed_volume(docker, res, prep_names[1], seed_dir, cfg_vols[1], home_vol=None) + res.track_container(app_names[1]) + docker.run(app_run_args(app_names[1], home_vol, cfg_vols[1], seed_dir, offline=True), + stage="startup") + verify_security(docker, app_names[1], "none") + replacement = app_names[1] + bootstrap(http, replacement, deadline) + if http.posts != 1: + raise StageError("storage", f"expected exactly one memory POST, performed {http.posts}") + recall = search_verify(http, replacement, memory_id) + print(f"offline replacement recall similarity: {recall}") + lifecycle_ok = True + except StageError as exc: + print(f"FAIL {exc}") + tail = diagnostics(docker, res) + if tail: + print(tail) + except Exception as exc: # unexpected: still sanitized, bounded, cleaned up + print(f"FAIL [internal] {clip(sanitize(repr(exc)))}") + tail = diagnostics(docker, res) + if tail: + print(tail) + finally: + if seed_dir: + try: + shutil.rmtree(seed_dir) + except OSError as exc: + print(f"FAIL [cleanup] seed removal: {clip(sanitize(str(exc)))}") + lifecycle_ok = False + docker.set_deadline(_now() + CLEANUP_BUDGET_S) # separate budget, not per-op 180s + try: + res.cleanup() + except StageError as exc: + print(f"FAIL {exc}") + lifecycle_ok = False + else: + print("cleanup: ok (scoped resources removed)") + if lifecycle_ok: # PASS only when lifecycle AND cleanup succeeded + print("PASS: opencode-mem v2 runtime fixture") + return 0 if lifecycle_ok else 1 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/tests/opencode-memory-v2/test_runtime.py b/.github/tests/opencode-memory-v2/test_runtime.py new file mode 100644 index 0000000..e711042 --- /dev/null +++ b/.github/tests/opencode-memory-v2/test_runtime.py @@ -0,0 +1,621 @@ +"""Unit tests for runtime.py (mocked docker/time; no docker daemon required). + +CI-only: these run in the workflow before the runtime fixture. stdlib unittest. +""" +import json +import os +import stat +import tempfile +import unittest +from pathlib import Path +from unittest import mock +from urllib.parse import quote_plus + +import runtime + +MID = "mem-fixture-id-1" + + +def make_plugin_entry(identifier="opencode-mem", target="opencode-mem@2.28.3", + version=None, status="active", error=None, source_type="package"): + source = {"type": source_type, "target": target} + if version is not None: + source["version"] = version + state = {"status": status} + if error is not None: + state["error"] = error + entry = {"source": source, "features": {}, "state": state} + if identifier is not None: + entry["id"] = identifier + return entry + + +def make_plugin_payload(entries, directory="/home/opencode", with_location=True): + payload = {"data": entries} + if with_location: + payload["location"] = {"directory": directory} + return payload + + +def make_search_item(**fields): + item = {"type": "memory", "id": MID, "content": runtime.MEMORY_CONTENT, "similarity": 0.9} + item.update(fields) + return item + + +def search_payload(items, success=True, error=None): + payload = {"success": success, "data": {"items": items}} + if error is not None: + payload["error"] = error + return payload + + +class FakeDocker: + """Sequential scripted executor: pops one (rc, stdout, stderr) per call. + Records (args, timeout) so budget behavior is assertable.""" + + def __init__(self, script=None): + self.script = list(script or []) + self.scripted = script is not None + self.calls = [] + + def execute(self, args, timeout): + self.calls.append((list(args), timeout)) + if self.script: + result = self.script.pop(0) + return result if len(result) == 3 else (*result, "") + if self.scripted: + raise AssertionError(f"Unexpected Docker call after script exhausted: {args!r}") + return 0, "", "" + + def docker(self): + return runtime.Docker(execute=self.execute) + + +class FakeHttp: + def __init__(self, add=None, search=None): + self.add = add if add is not None else {"success": True, "data": {"id": MID}} + self.search = search + self.posts = 0 + self.get_paths = [] + self.post_payloads = [] + + def server_get(self, cname, path): + return {"version": runtime.OPENCODE_VERSION, "pid": 1, "urls": {}, "paths": {}} + + def mem_get(self, cname, path): + self.get_paths.append(path) + if self.search is None: + self.search = search_payload([make_search_item()]) + return self.search + + def mem_post_json(self, cname, path, payload): + self.posts += 1 + self.post_payloads.append(payload) + return self.add + + +class SanitizerTests(unittest.TestCase): + def test_redacts_auth_material(self): + raw = ("Authorization: Basic dXNlcjpwYXNz\nBearer abc.def\n" + "x-opencode-mem-token: t123\npassword: hunter2\napi_key=zzz\n" + "OPENCODE_PASSWORD=sekret\nGET https://u:p@example.com/x\n") + out = runtime.sanitize(raw) + for secret in ("dXNlcjpwYXNz", "abc.def", "t123", "hunter2", "zzz", "sekret", "u:p@"): + self.assertNotIn(secret, out) + + def test_keeps_plain_diagnostics(self): + self.assertIn("Error: ENOENT /tmp/x", runtime.sanitize("Error: ENOENT /tmp/x")) + + def test_clip_bounds_length(self): + out = runtime.clip("x" * 10000, limit=100) + self.assertLessEqual(len(out), 130) + self.assertIn("[clipped]", out) + + +class InfoPayloadTests(unittest.TestCase): + def test_ok(self): + payload = {"version": runtime.OPENCODE_VERSION, "pid": 1, "urls": {}, "paths": {}} + self.assertEqual(runtime.check_info_payload(payload), payload) + + def test_wrong_version(self): + with self.assertRaises(runtime.StageError) as ctx: + runtime.check_info_payload({"version": "2.0.21", "pid": 1, "urls": {}, "paths": {}}) + self.assertEqual(ctx.exception.stage, "startup") + + def test_malformed_and_wrapped(self): + for payload in (["list"], {"data": {"version": runtime.OPENCODE_VERSION}}, + {"version": runtime.OPENCODE_VERSION}): + with self.assertRaises(runtime.StageError): + runtime.check_info_payload(payload) + + +class PluginPayloadTests(unittest.TestCase): + def test_ok_pinned_spec(self): + entry = runtime.check_plugin_payload(make_plugin_payload([make_plugin_entry()])) + self.assertEqual(entry["id"], runtime.PLUGIN_ID) + + def test_ok_package_target_with_resolved_version(self): + payload = make_plugin_payload([make_plugin_entry(target="opencode-mem", version="2.28.3")]) + self.assertEqual(runtime.check_plugin_payload(payload)["id"], runtime.PLUGIN_ID) + + def test_id_must_be_exact(self): + payload = make_plugin_payload([make_plugin_entry(identifier="other", target="opencode-mem@2.28.3")]) + with self.assertRaises(runtime.StageError) as ctx: + runtime.check_plugin_payload(payload) + self.assertEqual(ctx.exception.stage, "registration") + + def test_target_must_be_pinned(self): + payload = make_plugin_payload([make_plugin_entry(target="opencode-mem@9.9.9")]) + with self.assertRaises(runtime.StageError) as ctx: + runtime.check_plugin_payload(payload) + self.assertEqual(ctx.exception.stage, "registration") + + def test_unresolved_target_rejected(self): + payload = make_plugin_payload([make_plugin_entry(target="opencode-mem", version=None)]) + with self.assertRaises(runtime.StageError) as ctx: + runtime.check_plugin_payload(payload) + self.assertEqual(ctx.exception.stage, "registration") + + def test_wrong_version_rejected(self): + for target in ("opencode-mem@2.28.3", "opencode-mem"): + payload = make_plugin_payload([make_plugin_entry(target=target, version="2.28.2")]) + with self.assertRaises(runtime.StageError) as ctx: + runtime.check_plugin_payload(payload) + self.assertEqual(ctx.exception.stage, "registration") + + def test_failed_state_rejected_and_sanitized(self): + payload = make_plugin_payload([make_plugin_entry( + status="failed", error="init exploded after Bearer rawsecret was sent")]) + with self.assertRaises(runtime.StageError) as ctx: + runtime.check_plugin_payload(payload) + self.assertEqual(ctx.exception.stage, "registration") + self.assertIn("failed", str(ctx.exception)) + self.assertNotIn("rawsecret", str(ctx.exception)) + + def test_wrapped_rejected(self): + payload = {"location": {"directory": "/home/opencode"}, "data": {"data": [make_plugin_entry()]}} + with self.assertRaises(runtime.StageError) as ctx: + runtime.check_plugin_payload(payload) + self.assertEqual(ctx.exception.stage, "registration") + + def test_location_envelope_required_and_exact(self): + for payload in (make_plugin_payload([make_plugin_entry()], with_location=False), + make_plugin_payload([make_plugin_entry()], directory="/tmp"), + {"location": {"directory": "/home/opencode"}, "data": []}): + with self.assertRaises(runtime.StageError) as ctx: + runtime.check_plugin_payload(payload) + self.assertEqual(ctx.exception.stage, "registration") + + +class AddResponseTests(unittest.TestCase): + def test_ok(self): + self.assertEqual(runtime.check_add_response({"success": True, "data": {"id": MID}}), MID) + + def test_success_must_be_true_not_truthy(self): + for bad in (1, 0, "true", [], None): + payload = {"success": bad, "data": {"id": MID}} + if bad is None: + payload.pop("success") + with self.assertRaises(runtime.StageError) as ctx: + runtime.check_add_response(payload) + self.assertEqual(ctx.exception.stage, "storage") + + def test_invalid_ids_rejected(self): + for bad in ("", None, 123, []): + with self.assertRaises(runtime.StageError) as ctx: + runtime.check_add_response({"success": True, "data": {"id": bad}}) + self.assertEqual(ctx.exception.stage, "storage") + + def test_wrapped_and_malformed_rejected(self): + for payload in ({"data": {"data": {"id": MID}}}, {"success": True}, ["x"], {"data": "id"}): + with self.assertRaises(runtime.StageError): + runtime.check_add_response(payload) + + def test_error_message_sanitized(self): + payload = {"success": False, "error": "rejected token=sekrit"} + with self.assertRaises(runtime.StageError) as ctx: + runtime.check_add_response(payload) + self.assertNotIn("sekrit", str(ctx.exception)) + + +class SearchResponseTests(unittest.TestCase): + def test_ok(self): + raw = runtime.check_search_response(search_payload([make_search_item(similarity=0.61)]), + MID, runtime.MEMORY_CONTENT) + self.assertEqual(raw, 0.61) + + def test_int_similarity_ok_and_boundary(self): + self.assertEqual(runtime.check_search_response( + search_payload([make_search_item(similarity=1)]), MID, runtime.MEMORY_CONTENT), 1) + self.assertEqual(runtime.check_search_response( + search_payload([make_search_item(similarity=0.6)]), MID, runtime.MEMORY_CONTENT), 0.6) + + def test_above_one_not_rejected_no_upper_bound(self): + self.assertEqual(runtime.check_search_response( + search_payload([make_search_item(similarity=1.2)]), MID, runtime.MEMORY_CONTENT), 1.2) + + def test_below_threshold_rejected(self): + with self.assertRaises(runtime.StageError) as ctx: + runtime.check_search_response(search_payload([make_search_item(similarity=0.59)]), + MID, runtime.MEMORY_CONTENT) + self.assertEqual(ctx.exception.stage, "embedding") + + def test_nan_bool_and_non_numeric_rejected(self): + for bad in (float("nan"), float("inf"), True, False, "0.9", None, [0.9]): + with self.assertRaises(runtime.StageError) as ctx: + runtime.check_search_response(search_payload([make_search_item(similarity=bad)]), + MID, runtime.MEMORY_CONTENT) + self.assertEqual(ctx.exception.stage, "embedding") + + def test_similarity_only_no_score_fallback(self): + item = {"type": "memory", "id": MID, "content": runtime.MEMORY_CONTENT, "score": 0.9} + with self.assertRaises(runtime.StageError) as ctx: + runtime.check_search_response(search_payload([item]), MID, runtime.MEMORY_CONTENT) + self.assertEqual(ctx.exception.stage, "embedding") + + def test_item_type_must_be_memory(self): + with self.assertRaises(runtime.StageError) as ctx: + runtime.check_search_response(search_payload([make_search_item(type="summary")]), + MID, runtime.MEMORY_CONTENT) + self.assertEqual(ctx.exception.stage, "storage") + + def test_content_mismatch_rejected(self): + with self.assertRaises(runtime.StageError) as ctx: + runtime.check_search_response(search_payload([make_search_item(content="other")]), + MID, runtime.MEMORY_CONTENT) + self.assertEqual(ctx.exception.stage, "storage") + + def test_missing_id_rejected(self): + with self.assertRaises(runtime.StageError) as ctx: + runtime.check_search_response(search_payload( + [make_search_item(id="other-id")]), MID, runtime.MEMORY_CONTENT) + self.assertEqual(ctx.exception.stage, "storage") + + def test_wrapped_and_success_false_rejected(self): + wrapped = {"success": True, "data": {"items": {"data": []}}} + with self.assertRaises(runtime.StageError) as ctx: + runtime.check_search_response(wrapped, MID, runtime.MEMORY_CONTENT) + self.assertEqual(ctx.exception.stage, "embedding") + with self.assertRaises(runtime.StageError) as ctx: + runtime.check_search_response(search_payload([], success=False, error="boom"), + MID, runtime.MEMORY_CONTENT) + self.assertEqual(ctx.exception.stage, "embedding") + + +class HealthPayloadTests(unittest.TestCase): + def test_ok(self): + runtime.check_mem_health({"success": True, "status": "ok", "authEnabled": False}) + + def test_strict_success_and_status(self): + for payload in ({"success": 1, "status": "ok"}, + {"success": True, "status": "down"}, + {"status": "ok"}): + with self.assertRaises(runtime.StageError) as ctx: + runtime.check_mem_health(payload) + self.assertEqual(ctx.exception.stage, "registration") + + +class DeadlineTests(unittest.TestCase): + def test_wait_until_times_out_without_sleeping(self): + clock = {"now": 0.0} + sleeps = [] + + def fake_now(): + clock["now"] += 300.0 + return clock["now"] + + def failing(): + raise runtime.StageError("startup", "not ready") + + with mock.patch.object(runtime, "_now", fake_now), \ + mock.patch.object(runtime, "_sleep", lambda s: sleeps.append(s)): + with self.assertRaises(runtime.StageError) as ctx: + runtime.wait_until("readiness", failing, deadline=100.0, interval=1.0) + self.assertEqual(ctx.exception.stage, "startup") + self.assertIn("deadline", str(ctx.exception)) + self.assertEqual(sleeps, []) + + def test_exhausted_deadline_fails_before_check(self): + calls = [] + + def fn(): + calls.append(1) + return "late" + + with self.assertRaises(runtime.StageError) as ctx: + runtime.wait_until("poll", fn, deadline=runtime._now() - 10.0) + self.assertIn("before check", str(ctx.exception)) + self.assertEqual(calls, []) + + def test_delayed_success_after_deadline_rejected(self): + ticks = iter([0.0, 500.0]) + + def fn(): + return "delayed" + + with mock.patch.object(runtime, "_now", lambda: next(ticks)): + with self.assertRaises(runtime.StageError) as ctx: + runtime.wait_until("poll", fn, deadline=100.0) + self.assertIn("after deadline", str(ctx.exception)) + + def test_wait_until_returns_value(self): + attempts = [] + + def flaky(): + attempts.append(1) + if len(attempts) < 2: + raise runtime.StageError("transport", "warming up") + return 42 + + with mock.patch.object(runtime, "_now", lambda: 0.0), \ + mock.patch.object(runtime, "_sleep", lambda s: None): + self.assertEqual(runtime.wait_until("poll", flaky, deadline=100.0), 42) + + +class DockerBudgetTests(unittest.TestCase): + def test_scripted_mock_rejects_extra_calls(self): + fake = FakeDocker([(0, "ok", "")]) + fake.execute(["ps"], 10) + with self.assertRaises(AssertionError): + fake.execute(["ps"], 10) + + def test_slow_pull_consumes_overall_budget(self): + fake = FakeDocker() + docker = fake.docker() + docker.set_deadline(runtime._now() + 100) + docker.run(["image", "pull", "ref"], timeout=600, stage="startup") + _args, timeout = fake.calls[0] + self.assertTrue(0 < timeout <= 100) + + def test_no_deadline_passes_requested_timeout(self): + fake = FakeDocker() + fake.docker().run(["logs", "c"], timeout=600) + self.assertEqual(fake.calls[0][1], 600) + + def test_exhausted_budget_launches_no_subprocess(self): + fake = FakeDocker() + docker = fake.docker() + docker.set_deadline(runtime._now() - 1) + with self.assertRaises(runtime.StageError): + docker.run(["ps"], stage="cleanup") + self.assertEqual(fake.calls, []) + + def test_cleanup_uses_separate_reset_budget(self): + fake = FakeDocker([(1, "", "Error: No such object: c1")]) + docker = fake.docker() + docker.set_deadline(runtime._now() - 1) # global budget spent + docker.set_deadline(runtime._now() + runtime.CLEANUP_BUDGET_S) # cleanup reset + res = runtime.Resources(docker) + res.track_container("c1") + res.cleanup() # exhausted global budget does not block scoped cleanup + args, timeout = fake.calls[0] + self.assertEqual(args, ["container", "inspect", "c1"]) + self.assertTrue(0 < timeout <= runtime.CLEANUP_BUDGET_S) + + +class CleanupTests(unittest.TestCase): + def test_cleanup_failure_fails_gate(self): + fake = FakeDocker([(0, "", ""), (0, "", ""), (1, "", "Error: rm failed")]) + res = runtime.Resources(fake.docker()) + res.track_container("c1") + with self.assertRaises(runtime.StageError) as ctx: + res.cleanup() + self.assertEqual(ctx.exception.stage, "cleanup") + + def test_stop_failure_still_removes(self): + fake = FakeDocker([(0, "", ""), (1, "", "Error: stop timeout"), (0, "", ""), + (1, "", "Error: No such volume: v1")]) + res = runtime.Resources(fake.docker()) + res.track_container("c1") + res.track_volume("v1") + res.cleanup() # stop failed but rm -f succeeded -> not a failure + removed = [args for args, _t in fake.calls if args[:2] == ["rm", "-f"]] + self.assertEqual(removed, [["rm", "-f", "c1"]]) + + def test_missing_resources_are_not_failures(self): + fake = FakeDocker([(1, "", "Error: No such object: c1"), + (1, "", "Error: No such volume: v1")]) + res = runtime.Resources(fake.docker()) + res.track_container("c1") + res.track_volume("v1") + res.cleanup() + kinds = [args[:2] for args, _t in fake.calls] + self.assertEqual(kinds, [["container", "inspect"], ["volume", "inspect"]]) + + +class SecurityInspectTests(unittest.TestCase): + def script(self, cap_add): + return [(0, "1000:1000\n"), (0, "true\n"), (0, '["ALL"]\n'), + (0, '["no-new-privileges"]\n'), (0, "{}\n"), (0, "default\n"), + (0, cap_add + "\n"), (0, "1000\n")] + + def test_ok_null_and_empty_capadd(self): + for cap_add in ("null", "[]"): + fake = FakeDocker(self.script(cap_add)) + runtime.verify_security(fake.docker(), "app", "default") + + def test_granted_capabilities_rejected(self): + fake = FakeDocker(self.script('["NET_ADMIN"]')) + with self.assertRaises(runtime.StageError) as ctx: + runtime.verify_security(fake.docker(), "app", "default") + self.assertEqual(ctx.exception.stage, "startup") + + def test_user_mismatch_fails_startup(self): + script = self.script("null") + script[0] = (0, "0:0\n") + fake = FakeDocker(script) + with self.assertRaises(runtime.StageError) as ctx: + runtime.verify_security(fake.docker(), "app", "default") + self.assertEqual(ctx.exception.stage, "startup") + + +class SinglePostTests(unittest.TestCase): + def test_post_payload_carries_content_and_container_tag(self): + http = FakeHttp() + self.assertEqual(runtime.add_memory(http, "c"), MID) + self.assertEqual(http.posts, 1) + self.assertEqual(http.post_payloads, + [{"content": runtime.MEMORY_CONTENT, + "containerTag": runtime.CONTAINER_TAG}]) + + def test_duplicate_post_refused(self): + http = FakeHttp() + runtime.add_memory(http, "c") + with self.assertRaises(runtime.StageError) as ctx: + runtime.add_memory(http, "c") + self.assertEqual(ctx.exception.stage, "storage") + self.assertIn("duplicate", str(ctx.exception)) + self.assertEqual(http.posts, 1) + + def test_replacement_search_performs_no_post_and_tags_query(self): + http = FakeHttp() + similarity = runtime.search_verify(http, "c", MID) + self.assertEqual(similarity, 0.9) + self.assertEqual(http.posts, 0) + path = http.get_paths[-1] + self.assertIn("q=" + quote_plus(runtime.MEMORY_CONTENT), path) + self.assertIn("tag=" + quote_plus(runtime.CONTAINER_TAG), path) + self.assertIn("page=1", path) + + +class ConfigTests(unittest.TestCase): + def test_seed_writes_exactly_two_pinned_files(self): + with tempfile.TemporaryDirectory() as tmp: + runtime.write_seed_files(tmp) + self.assertEqual(sorted(os.listdir(tmp)), ["opencode-mem.jsonc", "opencode.json"]) + app = json.loads(Path(tmp, "opencode.json").read_text()) + self.assertEqual(app["plugins"], [runtime.PLUGIN_SPEC]) + self.assertEqual(app["enabled_providers"], []) + self.assertEqual(app["permissions"], + [{"action": "*", "resource": "*", "effect": "deny"}]) + mem = json.loads(Path(tmp, "opencode-mem.jsonc").read_text()) + self.assertEqual(mem["storagePath"], runtime.HOME_PATH + "/.opencode-mem") + self.assertEqual(mem["webServerApiToken"], runtime.MEM_TOKEN) + self.assertEqual(mem["webServerPort"], runtime.MEM_PORT) + for banned in ("opencodeProvider", "opencodeModel", "apiKey", "embeddingApiKey", + "userProfileAnalysisInterval"): + self.assertNotIn(banned, mem) + + def test_seed_files_world_readable(self): + with tempfile.TemporaryDirectory() as tmp: + runtime.write_seed_files(tmp) + self.assertEqual(stat.S_IMODE(Path(tmp).stat().st_mode), 0o755) + for name in ("opencode.json", "opencode-mem.jsonc"): + self.assertEqual(stat.S_IMODE(Path(tmp, name).stat().st_mode), 0o644) + + def test_app_env_allowlist_single_public_credential(self): + creds = {k: v for k, v in runtime.APP_ENV + if k.endswith(("PASSWORD", "TOKEN", "KEY", "SECRET"))} + self.assertEqual(creds, {"OPENCODE_PASSWORD": runtime.BASIC_PASSWORD}) + self.assertIn(("OPENCODE_DB", "opencode.db"), runtime.APP_ENV) + + def test_containers_share_workdir_and_env(self): + args1 = runtime.app_run_args("app1", "home-vol", "cfg-c1", "/tmp/seed", offline=False) + args2 = runtime.app_run_args("app2", "home-vol", "cfg-c2", "/tmp/seed", offline=True) + + def env_of(args): + return {args[i + 1] for i, a in enumerate(args) if a == "-e"} + + self.assertEqual(env_of(args1), env_of(args2)) + self.assertEqual(env_of(args1), {f"{k}={v}" for k, v in runtime.APP_ENV}) + self.assertEqual(args1[args1.index("--workdir") + 1], runtime.HOME_PATH) + self.assertEqual(args2[args2.index("--workdir") + 1], runtime.HOME_PATH) + + def test_app_args_hardened_platform_offline_fresh_config(self): + args1 = runtime.app_run_args("app1", "home-vol", "cfg-c1", "/tmp/seed", offline=False) + args2 = runtime.app_run_args("app2", "home-vol", "cfg-c2", "/tmp/seed", offline=True) + self.assertEqual(args2[args2.index("--network") + 1], "none") + self.assertNotIn("--network", args1) + self.assertNotIn("cfg-c1", args2) + for args in (args1, args2): + self.assertIn("--platform", args) + self.assertEqual(args[args.index("--platform") + 1], runtime.PLATFORM) + self.assertNotIn("-p", args) + self.assertFalse(any("/var/run/docker.sock" in a for a in args)) + self.assertNotIn("--privileged", args) + self.assertEqual(args[args.index("--user") + 1], runtime.APP_USER) + self.assertEqual(args[args.index("--cap-drop") + 1], "ALL") + self.assertIn("--read-only", args) + self.assertIn("--security-opt", args) + self.assertIn(f"home-vol:{runtime.HOME_PATH}", args) + self.assertEqual(len([a for a in args if a.endswith("/opencode.json:ro")]), 1) + self.assertEqual(args[args.index("--entrypoint") + 1], "opencode") + self.assertEqual(args[args.index("serve"):], + ["serve", "--hostname", "127.0.0.1", "--port", str(runtime.SERVER_PORT)]) + + def test_preparer_entrypoint_platform_chmod_before_chown(self): + fake = FakeDocker() + res = runtime.Resources(fake.docker()) + runtime.seed_volume(fake.docker(), res, "prep-c1", "/tmp/seed", "cfg-c1", home_vol="home-vol") + call = fake.calls[0][0] + self.assertEqual(call[call.index("--platform") + 1], runtime.PLATFORM) + self.assertEqual(call[call.index("--cap-add") + 1], "CHOWN") + self.assertEqual(call[call.index("--cap-drop") + 1], "ALL") + self.assertEqual(call[call.index("--network") + 1], "none") + self.assertIn("/tmp/seed:/seed:ro", call) + self.assertIn("home-vol:/home/opencode", call) + image_at = call.index(runtime.IMAGE) + self.assertLess(call.index("--entrypoint"), image_at) + self.assertEqual(call[image_at - 1], "sh") + self.assertEqual(call[image_at + 1], "-c") + script = call[image_at + 2] + self.assertLess(script.index("chmod"), script.index("chown")) + self.assertEqual(script.count("chmod"), 1) + self.assertEqual(script.count("chown"), 1) + self.assertIn("prep-c1", res.containers) + + def test_replacement_preparer_touches_config_volume_only(self): + fake = FakeDocker() + res = runtime.Resources(fake.docker()) + runtime.seed_volume(fake.docker(), res, "prep-c2", "/tmp/seed", "cfg-c2", home_vol=None) + call = fake.calls[0][0] + self.assertFalse(any(a.startswith("home-vol:") for a in call)) + script = call[call.index(runtime.IMAGE) + 2] + self.assertEqual(script.count("chmod"), 1) + self.assertEqual(script.count("chown"), 1) + self.assertTrue(script.endswith("/home/opencode/.config")) + + def test_resources_tracked_before_creation(self): + seen = {} + + def execute(args, timeout): + if args[:2] == ["volume", "create"]: + seen["volumes_at_create"] = list(res.volumes) + if args[0] == "run" and "--name" in args: + seen["containers_at_run"] = list(res.containers) + return 0, "", "" + + res = runtime.Resources(runtime.Docker(execute=execute)) + runtime.create_volume(res.docker, res, "v-new") + self.assertEqual(seen["volumes_at_create"], ["v-new"]) + runtime.seed_volume(res.docker, res, "prep-x", "/seed", "cfg-x") + self.assertEqual(seen["containers_at_run"], ["prep-x"]) + + +class DiagnosticsTests(unittest.TestCase): + def test_merges_stderr_filters_and_sanitizes(self): + fake = FakeDocker([(0, "ordinary startup line\nError: boom token=sekret\n", + "native stderr Error: wget exit 4\n")]) + res = runtime.Resources(fake.docker()) + res.track_container("c1") + out = runtime.diagnostics(fake.docker(), res) + self.assertIn("wget exit 4", out) # stderr merged despite rc 0 + self.assertIn("boom", out) + self.assertNotIn("sekret", out) # credential-like content redacted + self.assertNotIn("ordinary startup line", out) # allow-list only, no raw logs + self.assertIn("c1", out) + + +class ImagePinTests(unittest.TestCase): + def test_pins_are_consistent(self): + self.assertIn("2.0.22@", runtime.IMAGE) + self.assertTrue(runtime.IMAGE.split("@", 1)[1].startswith("sha256:")) + self.assertEqual(runtime.PLATFORM, "linux/amd64") + self.assertEqual(runtime.PLUGIN_SPEC, f"{runtime.PLUGIN_ID}@{runtime.PLUGIN_VERSION}") + self.assertTrue(runtime.CONTAINER_TAG.startswith("sm_project_")) + self.assertEqual(runtime.SIMILARITY_THRESHOLD, 0.6) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/opencode-memory-v2.yml b/.github/workflows/opencode-memory-v2.yml new file mode 100644 index 0000000..07db598 --- /dev/null +++ b/.github/workflows/opencode-memory-v2.yml @@ -0,0 +1,26 @@ +name: opencode-memory-v2 + +on: + pull_request: + paths: + - ".github/workflows/opencode-memory-v2.yml" + - ".github/tests/opencode-memory-v2/**" + +permissions: + contents: read + +jobs: + fixture: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + + - name: Unit tests (mocked docker/time) + run: python3 -m unittest discover -s .github/tests/opencode-memory-v2 -p 'test_*.py' -v + + - name: Runtime fixture (stock container, synthetic data only) + run: python3 .github/tests/opencode-memory-v2/runtime.py From fe79b83cb5eeed5bdb50c94dea61fe83764e667b Mon Sep 17 00:00:00 2001 From: Steven Welch Date: Sun, 4 Oct 2026 19:58:35 -0600 Subject: [PATCH 2/5] test: preserve exact reviewed runtime fixture bytes --- .github/tests/opencode-memory-v2/runtime.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/tests/opencode-memory-v2/runtime.py b/.github/tests/opencode-memory-v2/runtime.py index b719f2e..924235c 100644 --- a/.github/tests/opencode-memory-v2/runtime.py +++ b/.github/tests/opencode-memory-v2/runtime.py @@ -24,7 +24,7 @@ from urllib.parse import quote_plus IMAGE = ("ghcr.io/anomalyco/opencode:2.0.22@" - "sha256:11f2b6c96d380867387fbee390c06cb47efffd9fdc37009b4cd40795b45dad19") + "sha256:11f2b6c96d380867387fbee390c06cb47efffd9fdc37009b4cd40795b45dad19") PLATFORM = "linux/amd64" OPENCODE_VERSION = "2.0.22" PLUGIN_SPEC = "opencode-mem@2.28.3" From bebf4f27bededcc473ae49a89e40c6d71f6a5509 Mon Sep 17 00:00:00 2001 From: Steven Welch Date: Sun, 4 Oct 2026 20:03:17 -0600 Subject: [PATCH 3/5] test: assert explicit bridge networking for cold probe --- .github/tests/opencode-memory-v2/runtime.py | 5 ++--- .github/tests/opencode-memory-v2/test_runtime.py | 10 +++++----- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/.github/tests/opencode-memory-v2/runtime.py b/.github/tests/opencode-memory-v2/runtime.py index 924235c..00db71e 100644 --- a/.github/tests/opencode-memory-v2/runtime.py +++ b/.github/tests/opencode-memory-v2/runtime.py @@ -418,8 +418,7 @@ def app_run_args(name, home_vol, cfg_vol, seed_dir, offline): "--user", APP_USER, "--read-only", "--cap-drop", "ALL", "--security-opt", "no-new-privileges", "--tmpfs", "/tmp", "--workdir", HOME_PATH] - if offline: - args += ["--network", "none"] + args += ["--network", "none" if offline else "bridge"] for key, value in APP_ENV: args += ["-e", f"{key}={value}"] args += ["-v", f"{home_vol}:{HOME_PATH}", @@ -532,7 +531,7 @@ def main(): res.track_container(app_names[0]) docker.run(app_run_args(app_names[0], home_vol, cfg_vols[0], seed_dir, offline=False), stage="startup") - verify_security(docker, app_names[0], "default") + verify_security(docker, app_names[0], "bridge") primary = app_names[0] bootstrap(http, primary, deadline) diff --git a/.github/tests/opencode-memory-v2/test_runtime.py b/.github/tests/opencode-memory-v2/test_runtime.py index e711042..c8b98c1 100644 --- a/.github/tests/opencode-memory-v2/test_runtime.py +++ b/.github/tests/opencode-memory-v2/test_runtime.py @@ -427,18 +427,18 @@ def test_missing_resources_are_not_failures(self): class SecurityInspectTests(unittest.TestCase): def script(self, cap_add): return [(0, "1000:1000\n"), (0, "true\n"), (0, '["ALL"]\n'), - (0, '["no-new-privileges"]\n'), (0, "{}\n"), (0, "default\n"), + (0, '["no-new-privileges"]\n'), (0, "{}\n"), (0, "bridge\n"), (0, cap_add + "\n"), (0, "1000\n")] def test_ok_null_and_empty_capadd(self): for cap_add in ("null", "[]"): fake = FakeDocker(self.script(cap_add)) - runtime.verify_security(fake.docker(), "app", "default") + runtime.verify_security(fake.docker(), "app", "bridge") def test_granted_capabilities_rejected(self): fake = FakeDocker(self.script('["NET_ADMIN"]')) with self.assertRaises(runtime.StageError) as ctx: - runtime.verify_security(fake.docker(), "app", "default") + runtime.verify_security(fake.docker(), "app", "bridge") self.assertEqual(ctx.exception.stage, "startup") def test_user_mismatch_fails_startup(self): @@ -446,7 +446,7 @@ def test_user_mismatch_fails_startup(self): script[0] = (0, "0:0\n") fake = FakeDocker(script) with self.assertRaises(runtime.StageError) as ctx: - runtime.verify_security(fake.docker(), "app", "default") + runtime.verify_security(fake.docker(), "app", "bridge") self.assertEqual(ctx.exception.stage, "startup") @@ -526,7 +526,7 @@ def test_app_args_hardened_platform_offline_fresh_config(self): args1 = runtime.app_run_args("app1", "home-vol", "cfg-c1", "/tmp/seed", offline=False) args2 = runtime.app_run_args("app2", "home-vol", "cfg-c2", "/tmp/seed", offline=True) self.assertEqual(args2[args2.index("--network") + 1], "none") - self.assertNotIn("--network", args1) + self.assertEqual(args1[args1.index("--network") + 1], "bridge") self.assertNotIn("cfg-c1", args2) for args in (args1, args2): self.assertIn("--platform", args) From 80ec41b8996b3f89c40197cc6989c0b9ad949216 Mon Sep 17 00:00:00 2001 From: Steven Welch Date: Sun, 4 Oct 2026 20:31:31 -0600 Subject: [PATCH 4/5] test: retain failed plugin registration diagnostics --- .github/tests/opencode-memory-v2/README.md | 8 +++- .github/tests/opencode-memory-v2/runtime.py | 31 ++++++++++++++-- .../tests/opencode-memory-v2/test_runtime.py | 37 +++++++++++++++++++ 3 files changed, 70 insertions(+), 6 deletions(-) diff --git a/.github/tests/opencode-memory-v2/README.md b/.github/tests/opencode-memory-v2/README.md index eed31ec..63edd1b 100644 --- a/.github/tests/opencode-memory-v2/README.md +++ b/.github/tests/opencode-memory-v2/README.md @@ -104,8 +104,12 @@ offline path. no-new-privileges, empty port bindings, network mode) plus an in-container `id -u` == 1000. 7. Readiness (`/api/info`), registration (`/api/plugin`), and plugin web - health (`/api/health`, `success === true` strictly) are distinct bounded - stages; retry loops issue read-only GETs only — the POST is never replayed. + health (`/api/health`, `success === true` strictly) are distinct bounded + stages; retry loops issue read-only GETs only — the POST is never replayed. + A source-matched `failed` plugin state is terminal even if loading failed + before its ID became available. Missing entries report only bounded + identity/source/status fields; deadline errors retain the last sanitized + failure. An active entry without the exact ID still cannot pass. All in-container HTTP uses stock `wget` via `docker exec` (10 s for GETs, ≤600 s for the single POST, exec timeout = wget budget + margin) with the Bearer fixture token for the plugin web API. diff --git a/.github/tests/opencode-memory-v2/runtime.py b/.github/tests/opencode-memory-v2/runtime.py index 00db71e..5252822 100644 --- a/.github/tests/opencode-memory-v2/runtime.py +++ b/.github/tests/opencode-memory-v2/runtime.py @@ -137,6 +137,9 @@ def __init__(self, stage, message): _now = time.monotonic _sleep = time.sleep +class PluginFailedError(StageError): + pass + def _subprocess_execute(args, timeout): try: proc = subprocess.run(["docker"] + list(args), capture_output=True, @@ -247,25 +250,40 @@ def check_plugin_payload(obj): if not isinstance(entries, list): raise StageError("registration", "plugin payload malformed (wrapped or missing 'data' list)") for entry in entries: - if not isinstance(entry, dict) or entry.get("id") != PLUGIN_ID: + if not isinstance(entry, dict): continue source = entry.get("source") source = source if isinstance(source, dict) else {} target, version = source.get("target"), source.get("version") + state = entry.get("state") + source_matches = source.get("type") == "package" and target in (PLUGIN_ID, PLUGIN_SPEC) + if source_matches and isinstance(state, dict) and state.get("status") == "failed": + raise PluginFailedError("registration", f"plugin {target!r} failed: {state.get('error')!s}") + if entry.get("id") != PLUGIN_ID: + continue if version is not None and version != PLUGIN_VERSION: raise StageError("registration", f"plugin resolved version mismatch: {version!r}") if source.get("type") != "package": raise StageError("registration", f"unexpected plugin source type {source.get('type')!r}") if target != PLUGIN_SPEC and not (target == PLUGIN_ID and version == PLUGIN_VERSION): raise StageError("registration", f"plugin target/version mismatch: {target!r}@{version!r}") - state = entry.get("state") if not isinstance(state, dict) or "status" not in state: raise StageError("registration", "plugin entry missing state.status") if state.get("status") != "active": detail = state.get("error") or state.get("status") raise StageError("registration", f"plugin state {state.get('status')!r}: {clip(sanitize(str(detail)))}") return entry - raise StageError("registration", f"{PLUGIN_ID} not present in plugin list ({len(entries)} entries)") + inventory = [] + for entry in entries[:8]: + if isinstance(entry, dict): + source = entry.get("source") + source = source if isinstance(source, dict) else {} + state = entry.get("state") + state = state if isinstance(state, dict) else {} + inventory.append({"id": entry.get("id"), "type": source.get("type"), + "target": source.get("target"), "version": source.get("version"), + "status": state.get("status")}) + raise StageError("registration", f"{PLUGIN_ID} not present in plugin inventory: {inventory!r}") def check_add_response(obj): _require_dict(obj, "storage", "POST /api/memories") @@ -464,12 +482,17 @@ def search_verify(http, cname, memory_id): return check_search_response(http.mem_get(cname, path), memory_id, MEMORY_CONTENT) def wait_until(label, fn, deadline, interval=POLL_INTERVAL_S): + last = None while True: if _now() >= deadline: - raise StageError("startup", f"{label}: deadline exhausted before check") + stage = last.stage if last is not None else "startup" + raise StageError(stage, f"{label}: deadline exhausted before check; last error: {last}") try: result = fn() except StageError as exc: + last = exc + if isinstance(exc, PluginFailedError): + raise if _now() >= deadline: raise StageError(exc.stage, f"{label}: deadline exhausted; last error: {exc}") _sleep(min(interval, max(0, deadline - _now()))) diff --git a/.github/tests/opencode-memory-v2/test_runtime.py b/.github/tests/opencode-memory-v2/test_runtime.py index c8b98c1..83211f0 100644 --- a/.github/tests/opencode-memory-v2/test_runtime.py +++ b/.github/tests/opencode-memory-v2/test_runtime.py @@ -131,6 +131,18 @@ def test_malformed_and_wrapped(self): class PluginPayloadTests(unittest.TestCase): + def test_failed_package_without_id_reports_sanitized_cause(self): + payload = make_plugin_payload([make_plugin_entry(identifier=None, status="failed", + error="native load failed token=raw-secret")]) + with self.assertRaises(runtime.StageError) as ctx: + runtime.check_plugin_payload(payload) + self.assertIn("native load failed", str(ctx.exception)) + self.assertNotIn("raw-secret", str(ctx.exception)) + + def test_active_package_without_id_cannot_pass(self): + with self.assertRaises(runtime.StageError): + runtime.check_plugin_payload(make_plugin_payload([make_plugin_entry(identifier=None)])) + def test_ok_pinned_spec(self): entry = runtime.check_plugin_payload(make_plugin_payload([make_plugin_entry()])) self.assertEqual(entry["id"], runtime.PLUGIN_ID) @@ -297,6 +309,31 @@ def test_strict_success_and_status(self): class DeadlineTests(unittest.TestCase): + def test_deadline_preserves_last_registration_error(self): + clock = {"now": 0.0} + def fail(): + raise runtime.StageError("registration", "plugin inventory is empty") + def sleep(seconds): + clock["now"] += seconds + with mock.patch.object(runtime, "_now", lambda: clock["now"]), \ + mock.patch.object(runtime, "_sleep", sleep): + with self.assertRaises(runtime.StageError) as ctx: + runtime.wait_until("registration", fail, deadline=2.0, interval=3.0) + self.assertEqual(ctx.exception.stage, "registration") + self.assertIn("inventory is empty", str(ctx.exception)) + + def test_known_failed_registration_does_not_retry(self): + calls = [] + def fail(): + calls.append(1) + raise runtime.PluginFailedError("registration", "plugin failed: missing native library") + with mock.patch.object(runtime, "_now", lambda: 0.0), \ + mock.patch.object(runtime, "_sleep") as sleep: + with self.assertRaises(runtime.StageError): + runtime.wait_until("registration", fail, deadline=100.0) + self.assertEqual(len(calls), 1) + sleep.assert_not_called() + def test_wait_until_times_out_without_sleeping(self): clock = {"now": 0.0} sleeps = [] From 06bab9520689600c71259113cf0149e4a911a3e7 Mon Sep 17 00:00:00 2001 From: Steven Welch Date: Sun, 4 Oct 2026 21:34:08 -0600 Subject: [PATCH 5/5] test: expose vendor plugin-load diagnostics in synthetic CI --- .github/tests/opencode-memory-v2/README.md | 10 +++++++--- .github/tests/opencode-memory-v2/runtime.py | 2 ++ .github/tests/opencode-memory-v2/test_runtime.py | 2 ++ 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/tests/opencode-memory-v2/README.md b/.github/tests/opencode-memory-v2/README.md index 63edd1b..68fb3b1 100644 --- a/.github/tests/opencode-memory-v2/README.md +++ b/.github/tests/opencode-memory-v2/README.md @@ -119,9 +119,12 @@ offline path. (`http.posts == 1`). 9. Scoped cleanup always runs — even after global-deadline exhaustion — on its own budget; cleanup failures fail the gate (no false PASS). Failure - diagnostics merge stdout+stderr from `docker logs`, keep only - allow-listed failure-signal lines, sanitize credential-like content, and - clip to a bounded tail; no raw logs, nothing uploaded, no caches/artifacts. + diagnostics merge stdout+stderr from `docker logs`, keep only + allow-listed failure-signal lines, sanitize credential-like content, and + clip to a bounded tail; no raw logs, nothing uploaded, no caches/artifacts. + The synthetic server enables the vendor's `OPENCODE_PRINT_LOGS=1` at WARN + level so its otherwise file-only plugin-load cause reaches this existing + bounded failure capture. Production logging is not changed. Fixture credentials are public synthetic values; no production auth material is used or mirrored. @@ -162,5 +165,6 @@ commit `3d3c42e5aac5ba805825da76410c181273ba90b1`. - [OpenCode Basic authentication](https://github.com/anomalyco/opencode/blob/527f0b931d1f9b3ebd34e106c51b31ce5db5b075/packages/server/src/middleware/authorization.ts) - [OpenCode legacy provider normalization](https://github.com/anomalyco/opencode/blob/527f0b931d1f9b3ebd34e106c51b31ce5db5b075/packages/core/src/config/normalize.ts) - [OpenCode database path resolution](https://github.com/anomalyco/opencode/blob/527f0b931d1f9b3ebd34e106c51b31ce5db5b075/packages/core/src/database/database.ts) +- [OpenCode stderr logging option](https://github.com/anomalyco/opencode/blob/527f0b931d1f9b3ebd34e106c51b31ce5db5b075/packages/util/src/observability/logging.ts) - [Plugin memory API handlers](https://github.com/tickernelz/opencode-mem/blob/a24d79e34857aed9c5e708f5325714e6948bbfa7/src/services/api-handlers.ts) - [Plugin scope-tag parser](https://github.com/tickernelz/opencode-mem/blob/a24d79e34857aed9c5e708f5325714e6948bbfa7/src/services/memory-scope.ts) diff --git a/.github/tests/opencode-memory-v2/runtime.py b/.github/tests/opencode-memory-v2/runtime.py index 5252822..d20ae56 100644 --- a/.github/tests/opencode-memory-v2/runtime.py +++ b/.github/tests/opencode-memory-v2/runtime.py @@ -70,6 +70,8 @@ ("XDG_DATA_HOME", HOME_PATH + "/.local/share"), ("OPENCODE_DB", "opencode.db"), # bare filename; resolved inside XDG data dir (canonical) ("OPENCODE_PASSWORD", BASIC_PASSWORD), + ("OPENCODE_PRINT_LOGS", "1"), + ("OPENCODE_LOG_LEVEL", "WARN"), ] OPENCODE_CONFIG = { diff --git a/.github/tests/opencode-memory-v2/test_runtime.py b/.github/tests/opencode-memory-v2/test_runtime.py index 83211f0..4eb5d85 100644 --- a/.github/tests/opencode-memory-v2/test_runtime.py +++ b/.github/tests/opencode-memory-v2/test_runtime.py @@ -546,6 +546,8 @@ def test_app_env_allowlist_single_public_credential(self): if k.endswith(("PASSWORD", "TOKEN", "KEY", "SECRET"))} self.assertEqual(creds, {"OPENCODE_PASSWORD": runtime.BASIC_PASSWORD}) self.assertIn(("OPENCODE_DB", "opencode.db"), runtime.APP_ENV) + self.assertIn(("OPENCODE_PRINT_LOGS", "1"), runtime.APP_ENV) + self.assertIn(("OPENCODE_LOG_LEVEL", "WARN"), runtime.APP_ENV) def test_containers_share_workdir_and_env(self): args1 = runtime.app_run_args("app1", "home-vol", "cfg-c1", "/tmp/seed", offline=False)