Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ they already have.
- Usage is billed by whichever OpenCode provider/model you select.
- **Node.js 18.18 or later**

The plugin starts its own local `opencode serve` on demand, bound to `127.0.0.1` and protected with a per-server random password (HTTP Basic auth), so other local processes cannot reach its API. If you point the plugin at your own server via `OPENCODE_COMPANION_SERVER_URL` and that server is password-protected, also export `OPENCODE_SERVER_PASSWORD` (and `OPENCODE_SERVER_USERNAME` if you customized it).

## Install

Add the marketplace in Claude Code:
Expand Down
28 changes: 26 additions & 2 deletions plugins/opencode/scripts/lib/opencode-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,20 @@ function trimBaseUrl(url) {
return String(url ?? "").replace(/\/+$/, "");
}

// Matches OpenCode's own client convention: HTTP Basic with the password from
// OPENCODE_SERVER_PASSWORD and a username defaulting to "opencode". On a
// password-protected server (v1.17.15) every route requires this header,
// including /global/health and the /event stream.
export function buildBasicAuthHeader(credentials = {}) {
const password = typeof credentials.password === "string" && credentials.password ? credentials.password : null;
if (!password) {
return null;
}
const username =
typeof credentials.username === "string" && credentials.username ? credentials.username : "opencode";
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
}

function encodePathSegment(value) {
return encodeURIComponent(String(value));
}
Expand Down Expand Up @@ -210,6 +224,7 @@ export class OpencodeServerClient {
constructor(baseUrl, options = {}) {
this.baseUrl = trimBaseUrl(baseUrl);
this.fetch = options.fetch ?? globalThis.fetch;
this.authorization = buildBasicAuthHeader(options);
if (!this.baseUrl) {
throw new Error("OpenCode server URL is required.");
}
Expand All @@ -223,13 +238,20 @@ export class OpencodeServerClient {
return `${this.baseUrl}${suffix}`;
}

authHeaders() {
return this.authorization ? { authorization: this.authorization } : {};
}

async request(method, path, options = {}) {
const url = this.url(path);
if (options.freshConnection) {
return requestWithFreshConnection(new URL(url), {
method,
path,
headers: options.headers,
headers: {
...this.authHeaders(),
...(options.headers ?? {})
},
body: options.body,
signal: options.signal,
requestTimeoutMs: options.requestTimeoutMs
Expand All @@ -238,6 +260,7 @@ export class OpencodeServerClient {

const headers = {
...(options.body == null ? {} : { "content-type": "application/json" }),
...this.authHeaders(),
...(options.headers ?? {})
};
const response = await this.fetch(url, {
Expand Down Expand Up @@ -333,7 +356,8 @@ export class OpencodeServerClient {
const response = await this.fetch(this.url("/event"), {
method: "GET",
headers: {
accept: "text/event-stream"
accept: "text/event-stream",
...this.authHeaders()
},
signal: options.signal
});
Expand Down
37 changes: 31 additions & 6 deletions plugins/opencode/scripts/lib/opencode.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@ import path from "node:path";
import { buildOpenCodeImportDocumentFromClaudeJsonl } from "./claude-session-transfer.mjs";
import { createTempDir, readJsonFile, writeJsonFile } from "./fs.mjs";
import { OpencodeHttpError, OpencodeServerClient } from "./opencode-server.mjs";
import { SERVER_URL_ENV, ensureServer, loadServerSession } from "./server-lifecycle.mjs";
import {
SERVER_PASSWORD_ENV,
SERVER_URL_ENV,
SERVER_USERNAME_ENV,
ensureServer,
loadServerSession,
serverSessionCredentials
} from "./server-lifecycle.mjs";
import { binaryAvailable, runCommandChecked } from "./process.mjs";

const TASK_SESSION_PREFIX = "OpenCode Companion Task";
Expand Down Expand Up @@ -844,14 +851,32 @@ async function withServer(cwd, fn) {
if (!server?.url) {
throw new Error("OpenCode server did not become ready.");
}
return fn(new OpencodeServerClient(server.url), server);
return fn(new OpencodeServerClient(server.url, serverSessionCredentials(server)), server);
}

async function abortSessionAtUrl(serverUrl, threadId, timeoutMs = 1000) {
// A job record only stores the server URL, so a later cancel process must
// re-derive credentials: the workspace's persisted owned-server session first,
// then the ambient external-server variables.
function resolveCredentialsForServerUrl(cwd, serverUrl) {
const normalized = normalizeServerUrlForCompare(serverUrl);
const session = loadServerSession(cwd);
if (session?.url && normalizeServerUrlForCompare(session.url) === normalized) {
return serverSessionCredentials(session);
}
if (normalizeServerUrlForCompare(process.env[SERVER_URL_ENV]) === normalized) {
return {
password: process.env[SERVER_PASSWORD_ENV] || null,
username: process.env[SERVER_USERNAME_ENV] || undefined
};
}
return { password: null, username: undefined };
}

async function abortSessionAtUrl(serverUrl, threadId, timeoutMs = 1000, credentials = {}) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const client = new OpencodeServerClient(serverUrl);
const client = new OpencodeServerClient(serverUrl, credentials);
await client.abort(threadId, { signal: controller.signal });
} finally {
clearTimeout(timeout);
Expand Down Expand Up @@ -1002,7 +1027,7 @@ export async function interruptServerTurn(cwd, { threadId, serverUrl = null }) {

if (serverUrl) {
try {
await abortSessionAtUrl(serverUrl, threadId);
await abortSessionAtUrl(serverUrl, threadId, 1000, resolveCredentialsForServerUrl(cwd, serverUrl));
const serverExternal = normalizeServerUrlForCompare(serverUrl) === normalizeServerUrlForCompare(process.env[SERVER_URL_ENV]);
return {
attempted: true,
Expand Down Expand Up @@ -1044,7 +1069,7 @@ export async function interruptServerTurn(cwd, { threadId, serverUrl = null }) {
if (!usedServerUrl) {
throw new Error("OpenCode server did not become ready.");
}
const client = new OpencodeServerClient(usedServerUrl);
const client = new OpencodeServerClient(usedServerUrl, serverSessionCredentials(server));
await client.abort(threadId);
return {
attempted: true,
Expand Down
102 changes: 85 additions & 17 deletions plugins/opencode/scripts/lib/server-lifecycle.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import crypto from "node:crypto";
import fs from "node:fs";
import net from "node:net";
import os from "node:os";
Expand All @@ -11,6 +12,12 @@ import { atomicWriteFile, resolveStateDir } from "./state.mjs";
export const SERVER_URL_ENV = "OPENCODE_COMPANION_SERVER_URL";
export const PID_FILE_ENV = "OPENCODE_COMPANION_SERVER_PID_FILE";
export const LOG_FILE_ENV = "OPENCODE_COMPANION_SERVER_LOG_FILE";
// OpenCode's own server-auth variables (not plugin-specific): when the
// password is set, `opencode serve` requires HTTP Basic auth on every route.
export const SERVER_PASSWORD_ENV = "OPENCODE_SERVER_PASSWORD";
export const SERVER_USERNAME_ENV = "OPENCODE_SERVER_USERNAME";

const OWNED_SERVER_USERNAME = "opencode";

const SERVER_STATE_FILE = "server.json";
const SERVER_LOCK_DIR = "server.lock";
Expand Down Expand Up @@ -57,7 +64,19 @@ export function loadServerSession(cwd) {
export function saveServerSession(cwd, session) {
const stateDir = resolveStateDir(cwd);
fs.mkdirSync(stateDir, { recursive: true });
atomicWriteFile(resolveServerStateFile(cwd), `${JSON.stringify(session, null, 2)}\n`);
// server.json carries the owned server's password; keep it owner-only.
atomicWriteFile(resolveServerStateFile(cwd), `${JSON.stringify(session, null, 2)}\n`, { mode: 0o600 });
}

export function serverSessionCredentials(session) {
return {
password: typeof session?.password === "string" && session.password ? session.password : null,
username: typeof session?.username === "string" && session.username ? session.username : undefined
};
}

function generateServerPassword() {
return crypto.randomBytes(24).toString("base64url");
}

export function clearServerSession(cwd) {
Expand All @@ -77,25 +96,25 @@ async function withTimeout(fn, timeoutMs) {
}
}

export async function isServerHealthy(url, timeoutMs = 500) {
export async function isServerHealthy(url, timeoutMs = 500, credentials = {}) {
const normalized = normalizeUrl(url);
if (!normalized) {
return false;
}

try {
const client = new OpencodeServerClient(normalized);
const client = new OpencodeServerClient(normalized, credentials);
await withTimeout((signal) => client.health({ signal }), timeoutMs);
return true;
} catch {
return false;
}
}

async function waitForServerHealth(url, timeoutMs = 10000) {
async function waitForServerHealth(url, timeoutMs = 10000, credentials = {}) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await isServerHealthy(url, 500)) {
if (await isServerHealthy(url, 500, credentials)) {
return true;
}
await sleep(100);
Expand Down Expand Up @@ -189,7 +208,7 @@ function releaseServerLock(lockDir, token) {

async function loadHealthyServerSession(cwd, healthTimeoutMs) {
const existing = loadServerSession(cwd);
if (existing?.url && (await isServerHealthy(existing.url, healthTimeoutMs))) {
if (existing?.url && (await isServerHealthy(existing.url, healthTimeoutMs, serverSessionCredentials(existing)))) {
return existing;
}
return null;
Expand Down Expand Up @@ -315,11 +334,17 @@ function findOpenPort(hostname = DEFAULT_HOSTNAME) {
});
}

export function spawnServerProcess({ cwd, port, hostname = DEFAULT_HOSTNAME, pidFile, logFile, env = process.env }) {
export function spawnServerProcess({ cwd, port, hostname = DEFAULT_HOSTNAME, pidFile, logFile, env = process.env, password = null }) {
// Pass the generated password via the child environment only — never argv,
// which any local user could read from the process list. Pin the username so
// an ambient OPENCODE_SERVER_USERNAME cannot desynchronize server and client.
const childEnv = password
? { ...env, [SERVER_PASSWORD_ENV]: password, [SERVER_USERNAME_ENV]: OWNED_SERVER_USERNAME }
: env;
const logFd = fs.openSync(logFile, "a");
const child = spawn("opencode", ["serve", "--hostname", hostname, "--port", String(port)], {
cwd,
env,
env: childEnv,
detached: true,
stdio: ["ignore", logFd, logFd],
windowsHide: true,
Expand Down Expand Up @@ -360,15 +385,35 @@ function killServerPid(pid, killProcess = null) {
}

export async function ensureServer(cwd, options = {}) {
const overrideUrl = normalizeUrl(options.serverUrl ?? options.env?.[SERVER_URL_ENV] ?? process.env[SERVER_URL_ENV]);
const envSource = options.env ?? process.env;
const overrideUrl = normalizeUrl(options.serverUrl ?? envSource[SERVER_URL_ENV] ?? process.env[SERVER_URL_ENV]);
if (overrideUrl) {
if (!(await isServerHealthy(overrideUrl, options.healthTimeoutMs ?? 1000))) {
// A password-protected external server uses the same variables OpenCode's
// own tooling reads, so a user who secured their server has already
// exported them.
const credentials = {
password: envSource[SERVER_PASSWORD_ENV] || null,
username: envSource[SERVER_USERNAME_ENV] || undefined
};
try {
const client = new OpencodeServerClient(overrideUrl, credentials);
await withTimeout((signal) => client.health({ signal }), options.healthTimeoutMs ?? 1000);
} catch (error) {
if (error?.status === 401) {
throw new Error(
credentials.password
? `Configured OpenCode server rejected the provided credentials (HTTP 401): ${overrideUrl}. Check ${SERVER_PASSWORD_ENV} and ${SERVER_USERNAME_ENV}.`
: `Configured OpenCode server requires authentication: ${overrideUrl}. Export ${SERVER_PASSWORD_ENV} (and ${SERVER_USERNAME_ENV} unless it is "opencode") so the plugin can connect.`
);
}
throw new Error(`Configured OpenCode server is not healthy: ${overrideUrl}`);
}
return {
url: overrideUrl,
pid: null,
external: true
external: true,
password: credentials.password,
username: credentials.username ?? null
};
}

Expand All @@ -387,7 +432,7 @@ export async function ensureServer(cwd, options = {}) {

const staleExisting = loadServerSession(cwd);
if (staleExisting) {
const { url, pidFile, logFile, sessionDir, pid, external } = staleExisting;
const { url, pidFile, logFile, sessionDir, pid, external, password, username } = staleExisting;
// The server lock is already held here; intentionally omit cwd so teardown
// uses the unlocked path even if the persisted session schema grows.
await teardownServerSession({
Expand All @@ -397,6 +442,8 @@ export async function ensureServer(cwd, options = {}) {
sessionDir,
pid,
external,
password,
username,
killProcess: options.killProcess ?? null
});
clearServerSession(cwd);
Expand All @@ -408,23 +455,32 @@ export async function ensureServer(cwd, options = {}) {
const sessionDir = createServerSessionDir();
const pidFile = path.join(sessionDir, "opencode-server.pid");
const logFile = path.join(sessionDir, "opencode-server.log");
// Every plugin-owned server gets its own random password so no other local
// process can reach the API on the loopback port (issue #27).
const password = generateServerPassword();
const child = spawnServerProcess({
cwd,
hostname,
port,
pidFile,
logFile,
env: options.env ?? process.env
env: options.env ?? process.env,
password
});

const ready = await waitForServerHealth(url, options.timeoutMs ?? 10000);
const ready = await waitForServerHealth(url, options.timeoutMs ?? 10000, {
password,
username: OWNED_SERVER_USERNAME
});
if (!ready) {
await teardownServerSession({
url,
pidFile,
logFile,
sessionDir,
pid: child.pid ?? null,
password,
username: OWNED_SERVER_USERNAME,
killProcess: options.killProcess ?? null
});
return null;
Expand All @@ -436,7 +492,9 @@ export async function ensureServer(cwd, options = {}) {
pidFile,
logFile,
sessionDir,
external: false
external: false,
password,
username: OWNED_SERVER_USERNAME
};
const leasedSession = addServerLease(session, options);
saveServerSession(cwd, leasedSession);
Expand All @@ -453,14 +511,18 @@ async function teardownServerSessionUnlocked({
sessionDir = null,
pid = null,
external = false,
password = null,
username = null,
killProcess = null
} = {}) {
if (url && !external) {
try {
const client = new OpencodeServerClient(url);
const client = new OpencodeServerClient(url, { password, username: username ?? undefined });
// Dispose only cleans up instance state; on 1.17.15 it does NOT stop the
// HTTP listener, so the PID termination below is the actual shutdown.
await withTimeout((signal) => client.dispose({ signal }), 1000);
} catch {
// Fall back to process termination below.
// Instance cleanup is best-effort; process termination below still runs.
}
}

Expand Down Expand Up @@ -504,6 +566,8 @@ export async function teardownServerSession({
sessionDir = null,
pid = null,
external = false,
password = null,
username = null,
killProcess = null
} = {}) {
if (!cwd) {
Expand All @@ -514,6 +578,8 @@ export async function teardownServerSession({
sessionDir,
pid,
external,
password,
username,
killProcess
});
}
Expand Down Expand Up @@ -547,6 +613,8 @@ export async function teardownServerSession({
sessionDir: session?.sessionDir ?? sessionDir,
pid: session?.pid ?? pid,
external: Boolean(session?.external ?? external),
password: session?.password ?? password,
username: session?.username ?? username,
killProcess
};
const result = await teardownServerSessionUnlocked(teardownTarget);
Expand Down
Loading