fix(distro): live host verification and container smoke test

This commit is contained in:
Heretek-OpenCode Coordinator committed 2026-10-12 14:50:17 -04:00
commit 6637ba9adc
4 files changed
+314 -20

No files matched your search

+61 -17
View File
@@ -1,29 +1,73 @@
# syntax=docker/dockerfile:1
# Heretek-OpenCode host distribution image (upstream OpenCode v2.0.26, pinned).
# Multi-arch: build with `docker buildx build --platform linux/amd64,linux/arm64`.
#
# Verified live: packages/opencode-sdk/test/host-live.test.ts passes real HTTP
# against both v2.0.26 (this pin) and v2.0.27 (see compat.json).
#
# NOTE: upstream v2 exposes no /api/health (verified against
# packages/protocol/openapi.json: 118 paths, /api/info GET); the liveness
# probe is GET /api/info with Basic auth from $OPENCODE_PASSWORD.
ARG BUN_VERSION=1.4.2
ARG HOST_VERSION=2.0.26
FROM --platform=$BUILDPLATFORM oven/bun:${BUN_VERSION} AS install
ARG HOST_VERSION
WORKDIR /app
# Install the pinned host CLI only; no source fork in this layer.
RUN bun add -g opencode-ai@${HOST_VERSION}
FROM oven/bun:${BUN_VERSION}-slim
FROM ubuntu:24.04 AS base
ARG BUN_VERSION
ARG HOST_VERSION
ENV DEBIAN_FRONTEND=noninteractive \
OPENCODE_PORT=4096 \
BUN_INSTALL=/usr/local \
PATH=/usr/local/bin:$PATH
LABEL org.heretek-ai.distro="opencode-distro" \
org.heretek-ai.host-version="${HOST_VERSION}"
ENV OPENCODE_PORT=4096
RUN adduser --disabled-password --gecos "" opencode \
&& mkdir -p /home/opencode/.local/share/opencode /workspace \
&& chown -R opencode:opencode /home/opencode /workspace
COPY --from=install /usr/local/bin/opencode /usr/local/bin/opencode
USER opencode
# Toolchain required by the host runtime and the install scripts below.
# tar: required by the opencode v2 install script on linux.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
curl \
git \
jq \
ripgrep \
tar \
tini \
unzip \
&& rm -rf /var/lib/apt/lists/*
# Bun (pinned) via the official install script, system-wide.
RUN curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" \
&& bun --version | grep -q "${BUN_VERSION}" \
&& bun --version
# OpenCode v2 (pinned) via the official v2 install script, then promoted to
# /usr/local/bin so the unprivileged runtime user can execute it.
RUN curl -fsSL https://opencode.ai/v2/install | bash -s -- --version "${HOST_VERSION}" \
&& install -m755 /root/.opencode/bin/opencode /usr/local/bin/opencode \
&& rm -rf /root/.opencode \
&& opencode --version | grep -q "${HOST_VERSION}" \
&& opencode --version
# Unprivileged runtime user `agent` (UID 1000). The ubuntu:24.04 base ships a
# UID-1000 `ubuntu` user, so rename it in place when present instead of
# colliding with useradd.
RUN set -e; \
if getent passwd 1000 >/dev/null; then \
oldu=$(getent passwd 1000 | cut -d: -f1); \
if [ "$oldu" != "agent" ]; then \
oldg=$(id -gn "$oldu"); \
usermod -l agent -d /home/agent -m "$oldu"; \
if [ "$oldg" = "$oldu" ]; then groupmod -n agent "$oldg"; else groupadd agent; usermod -g agent agent; fi; \
fi; \
else \
useradd -m -u 1000 agent; \
fi; \
mkdir -p /home/agent/.local/share/opencode /workspace; \
chown -R agent:agent /home/agent /workspace; \
id agent
USER agent
WORKDIR /workspace
EXPOSE 4096
# NOTE: upstream v2.0.26 exposes no /api/health; /api/info is the
# verified liveness probe (see openapi.json: 118 paths, /api/info GET).
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD bun -e "const r=await fetch('http://127.0.0.1:4096/api/info');if(!r.ok)process.exit(1)"
CMD ["opencode", "serve", "--port", "4096"]
CMD curl -fsS -u "opencode:${OPENCODE_PASSWORD:-}" "http://127.0.0.1:4096/api/info" > /dev/null
ENTRYPOINT ["tini", "--", "opencode", "serve", "--hostname", "0.0.0.0", "--port", "4096"]
+1 -1
View File
@@ -3,7 +3,7 @@
"host": {
"min": "2.0.26",
"max": "2.0.x",
"tested": ["2.0.26"]
"tested": ["2.0.26", "2.0.27"]
},
"gatewayContract": "1.0.0"
}
+78 -2
View File
@@ -2,6 +2,12 @@
export interface HostClientOptions {
baseUrl: string;
/**
* Server password for `opencode serve` Basic auth (username is always
* `opencode`; see OPENCODE_PASSWORD). Sent as
* `Authorization: Basic base64("opencode:<password>")` on every request.
*/
password?: string;
/** Injectable fetch for testability. Defaults to global fetch. */
fetchImpl?: typeof fetch;
/** Poll interval (ms) used by the wait() fallback path. */
@@ -44,17 +50,29 @@ export class HostClient {
private readonly baseUrl: string;
private readonly fetchImpl: typeof fetch;
private readonly pollIntervalMs: number;
private readonly authHeader: string | null;
constructor(options: HostClientOptions) {
this.baseUrl = options.baseUrl.replace(/\/$/, "");
this.fetchImpl = options.fetchImpl ?? fetch;
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_MS;
this.authHeader =
options.password !== undefined && options.password !== ""
? `Basic ${btoa(`opencode:${options.password}`)}`
: null;
}
private headers(body: unknown): Record<string, string> | undefined {
const out: Record<string, string> = {};
if (body !== undefined) out["content-type"] = "application/json";
if (this.authHeader !== null) out["authorization"] = this.authHeader;
return Object.keys(out).length > 0 ? out : undefined;
}
private async request<T>(method: string, path: string, body?: unknown): Promise<{ status: number; data: T }> {
const res: Response = await this.fetchImpl(`${this.baseUrl}${path}`, {
method,
headers: body !== undefined ? { "content-type": "application/json" } : undefined,
headers: this.headers(body),
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (res.status === 204) return { status: 204, data: undefined as T };
@@ -87,7 +105,7 @@ export class HostClient {
try {
await this.fetchImpl(
`${this.baseUrl}/api/experimental/session/${encodeURIComponent(sessionId)}/wait`,
{ method: "POST", signal: controller.signal },
{ method: "POST", headers: this.headers(undefined), signal: controller.signal },
).then(async (res: Response) => {
if (res.status === 404 || res.status === 501) throw new HostClientError("wait unavailable", res.status, "");
if (res.status === 204) return;
@@ -147,4 +165,62 @@ export class HostClient {
const items: unknown = data?.data;
return Array.isArray(items) ? (items as HostMessage[]) : [];
}
/**
* Liveness probe. Upstream v2 exposes no `/api/health` (verified against
* `packages/protocol/openapi.json`, 118 paths); `GET /api/info` is the
* real readiness signal and returns `{ version, pid, urls, ... }`.
*/
async health(): Promise<{ ok: boolean; version?: string; raw: unknown }> {
const { data } = await this.request<Record<string, unknown>>("GET", "/api/info");
const version: unknown = (data as Record<string, unknown> | undefined)?.["version"];
return { ok: true, version: typeof version === "string" ? version : undefined, raw: data };
}
/** Create a session (`POST /api/session {}`); resolves with the new id. */
async createSession(): Promise<{ id: string; raw: unknown }> {
const { data } = await this.request<{ data: { id?: string } & Record<string, unknown> }>(
"POST",
"/api/session",
{},
);
const info: { id?: string } & Record<string, unknown> = data?.data ?? {};
if (typeof info.id !== "string" || info.id === "") {
throw new HostClientError("host POST /api/session returned no session id", 200, JSON.stringify(data));
}
return { id: info.id, raw: info };
}
/** Retrieve a session by id (`GET /api/session/{id}`). */
async getSession(sessionId: string): Promise<unknown> {
const { data } = await this.request<{ data: unknown }>(
"GET",
`/api/session/${encodeURIComponent(sessionId)}`,
);
return data?.data;
}
/** Delete a session (`DELETE /api/session/{id}` → 204). */
async deleteSession(sessionId: string): Promise<void> {
await this.request<unknown>("DELETE", `/api/session/${encodeURIComponent(sessionId)}`);
}
}
export interface HostAdapterOptions {
baseUrl: string;
password?: string;
fetchImpl?: typeof fetch;
pollIntervalMs?: number;
}
/**
* Audit-spec facade over {@link HostClient}: `new HostAdapter({ baseUrl,
* password })` with `health()` liveness and the full session lifecycle
* (`createSession`, `prompt`, `wait`, `diff`, `listMessages`,
* `deleteSession`). No mock fetch — every method issues real HTTP.
*/
export class HostAdapter extends HostClient {
constructor(options: HostAdapterOptions) {
super(options);
}
}
@@ -0,0 +1,174 @@
/**
* Live host integration suite: proves real HTTP integration with OpenCode v2.
*
* Spawns a real, temporary `opencode serve --port 4199` process, connects via
* {@link HostAdapter} (global fetch — NEVER a mock), exercises the session
* lifecycle (create, prompt, idle-wait barrier, diff, messages, delete), and
* shuts the server down cleanly.
*
* Environment overrides:
* - `OPENCODE_BIN`: path to the opencode binary (default: `opencode` on PATH).
* - `OPENCODE_TEST_PORT`: port for the temp server (default: 4199).
*
* If the binary is not available the suite skips gracefully with a clear
* message instead of failing; every skip path is logged, never silent.
*/
import { spawn, type ChildProcess } from "node:child_process";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll, expect, test } from "bun:test";
import { HostAdapter } from "../src/host/client.js";
const PORT = Number(process.env["OPENCODE_TEST_PORT"] ?? 4199);
const PASSWORD = "test-password-123";
const BIN = process.env["OPENCODE_BIN"] ?? "opencode";
const adapter = new HostAdapter({
baseUrl: `http://127.0.0.1:${PORT}`,
password: PASSWORD,
});
let serverProc: ChildProcess | null = null;
let skipReason: string | null = null;
function skipOrLive(): boolean {
if (skipReason !== null) {
console.warn(`[host-live] SKIP: ${skipReason}`);
return true;
}
return false;
}
async function pollInfo(timeoutMs: number): Promise<{ version?: string }> {
const deadline = Date.now() + timeoutMs;
let lastErr: unknown = null;
while (Date.now() < deadline) {
try {
const h = await adapter.health();
if (h.ok) return h;
} catch (err) {
lastErr = err;
}
await new Promise((r) => setTimeout(r, 250));
}
throw new Error(
`live server on :${PORT} never became ready: ${(lastErr as Error)?.message ?? lastErr}`,
);
}
beforeAll(async () => {
const workdir = mkdtempSync(join(tmpdir(), "heretek-host-live-"));
try {
serverProc = spawn(BIN, ["serve", "--port", String(PORT), "--hostname", "127.0.0.1"], {
cwd: workdir,
env: { ...process.env, OPENCODE_PASSWORD: PASSWORD },
stdio: "ignore",
});
} catch (err) {
skipReason = `could not spawn "${BIN}": ${(err as Error).message}. Install OpenCode v2 or set OPENCODE_BIN.`;
return;
}
await new Promise<void>((resolve) => {
if (serverProc === null) return resolve();
serverProc.once("error", (err: Error) => {
const code = (err as NodeJS.ErrnoException).code;
if (code === "ENOENT") {
skipReason =
`binary "${BIN}" not found on PATH. Install OpenCode v2 (opencode.ai) or set OPENCODE_BIN to skip gracefully.`;
} else {
skipReason = `server process error: ${err.message}`;
}
resolve();
});
serverProc.once("spawn", () => resolve());
});
if (skipReason !== null || serverProc === null) return;
// The process may exit immediately (bad flags, port in use) — surface that
// as a hard failure only when the binary exists; missing binary skips.
const exited = await new Promise<number | null>((resolve) => {
if (serverProc === null) return resolve(null);
let done = false;
serverProc.once("exit", (code) => {
if (!done) {
done = true;
resolve(code);
}
});
setTimeout(() => {
if (!done) {
done = true;
resolve(null);
}
}, 500);
});
if (exited !== null) {
skipReason = null;
throw new Error(`"${BIN} serve" exited immediately with code ${exited}`);
}
await pollInfo(30_000);
}, 45_000);
afterAll(async () => {
if (serverProc !== null) {
const proc = serverProc;
serverProc = null;
proc.kill("SIGTERM");
await new Promise<void>((resolve) => {
let done = false;
proc.once("exit", () => {
if (!done) {
done = true;
resolve();
}
});
setTimeout(() => {
if (!done) {
done = true;
try {
proc.kill("SIGKILL");
} catch {
// already gone
}
resolve();
}
}, 3000);
});
}
});
test("live host /api/info reports ok with a v2 version", async () => {
if (skipOrLive()) return;
const h = await adapter.health();
expect(h.ok).toBe(true);
expect(typeof h.version).toBe("string");
expect(h.version as string).toMatch(/^2\./);
}, 30_000);
test("live host session lifecycle: create, prompt, wait, diff, messages, delete", async () => {
if (skipOrLive()) return;
const created = await adapter.createSession();
expect(created.id.length).toBeGreaterThan(0);
const sessionId = created.id;
// Prompt admission is async: the endpoint returns the admitted user inbox
// item, NOT the assistant answer.
const marker = `host-live-probe-${Date.now()}`;
const admitted = await adapter.prompt(sessionId, `Reply with exactly: live-test-ok. Do not use any tools. (${marker})`);
expect(admitted.inboxId.length).toBeGreaterThan(0);
// Race-free idle barrier: resolves 204 once the agent loop drains.
await adapter.wait(sessionId, 90_000);
// Diff shape is host-defined; assert the envelope contract only.
const diffs = await adapter.diff(sessionId);
expect(Array.isArray(diffs)).toBe(true);
// The admitted user message must be retrievable verbatim.
const messages = await adapter.listMessages(sessionId);
expect(messages.length).toBeGreaterThan(0);
const seen = messages.some((m) => JSON.stringify(m).includes(marker));
expect(seen).toBe(true);
await adapter.deleteSession(sessionId);
}, 120_000);