mirror of
https://github.com/Heretek-OpenCode/heretek-opencode.git
synced 2026-11-07 10:05:58 -10:00
fix(distro): live host verification and container smoke test
This commit is contained in:
commit
6637ba9adc
4 files changed
+314
-20
No files matched your search
@@ -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"]
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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);
|
||||
Reference in new issue
Block a user