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
12 changes: 11 additions & 1 deletion plugins/opencode/scripts/lib/opencode-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ export class OpencodeServerClient {
this.baseUrl = trimBaseUrl(baseUrl);
this.fetch = options.fetch ?? globalThis.fetch;
this.authorization = buildBasicAuthHeader(options);
this.directory = typeof options.directory === "string" && options.directory ? options.directory : null;
if (!this.baseUrl) {
throw new Error("OpenCode server URL is required.");
}
Expand All @@ -235,7 +236,16 @@ export class OpencodeServerClient {

url(path) {
const suffix = String(path ?? "").startsWith("/") ? path : `/${path}`;
return `${this.baseUrl}${suffix}`;
const base = `${this.baseUrl}${suffix}`;
// Bind project-scoped requests to the invoking workspace. Without the
// `directory` query an external server resolves them against its own
// launch directory (issue #29). Every non-/global/ route the client uses
// accepts it (verified against the 1.17.15 OpenAPI document).
if (!this.directory || suffix.startsWith("/global/")) {
return base;
}
const separator = suffix.includes("?") ? "&" : "?";
return `${base}${separator}directory=${encodeURIComponent(this.directory)}`;
}

authHeaders() {
Expand Down
69 changes: 52 additions & 17 deletions plugins/opencode/scripts/lib/opencode.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1040,12 +1040,29 @@ async function captureTurn(client, sessionID, startRequest, options = {}) {
}
}

// The canonical (symlink-resolved) workspace path is what OpenCode records as
// a session's directory, so use it for the client's directory scope.
function canonicalWorkspaceDirectory(cwd) {
try {
return fs.realpathSync(cwd);
} catch {
return cwd;
}
}

function buildServerClient(cwd, server) {
return new OpencodeServerClient(server.url, {
...serverSessionCredentials(server),
directory: canonicalWorkspaceDirectory(cwd)
});
}

async function withServer(cwd, fn) {
const server = await ensureServer(cwd);
if (!server?.url) {
throw new Error("OpenCode server did not become ready.");
}
return fn(new OpencodeServerClient(server.url, serverSessionCredentials(server)), server);
return fn(buildServerClient(cwd, server), server);
}

// A job record only stores the server URL, so a later cancel process must
Expand All @@ -1066,11 +1083,14 @@ function resolveCredentialsForServerUrl(cwd, serverUrl) {
return { password: null, username: undefined };
}

async function abortSessionAtUrl(serverUrl, threadId, timeoutMs = 1000, credentials = {}) {
async function abortSessionAtUrl(serverUrl, threadId, timeoutMs = 1000, credentials = {}, directory = null) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const client = new OpencodeServerClient(serverUrl, credentials);
const client = new OpencodeServerClient(serverUrl, {
...credentials,
...(directory ? { directory } : {})
});
await client.abort(threadId, { signal: controller.signal });
} finally {
clearTimeout(timeout);
Expand Down Expand Up @@ -1142,14 +1162,18 @@ export function getAvailability(cwd) {
}

export function getSessionRuntimeStatus(env = process.env, cwd = process.cwd()) {
const url = env?.[SERVER_URL_ENV] ?? loadServerSession(cwd)?.url ?? null;
const envUrl = env?.[SERVER_URL_ENV] ?? null;
const url = envUrl ?? loadServerSession(cwd)?.url ?? null;
if (url) {
return {
mode: "shared",
label: "shared OpenCode server",
detail: "This Claude session is configured to reuse one shared OpenCode server.",
endpoint: url,
url
url,
// Env-configured servers are user-managed; a persisted server session is
// one the plugin spawned and owns.
external: Boolean(envUrl)
};
}

Expand All @@ -1158,7 +1182,8 @@ export function getSessionRuntimeStatus(env = process.env, cwd = process.cwd())
label: "lazy OpenCode server startup",
detail: "No shared OpenCode server is active yet. The first review or task command will start one on demand.",
endpoint: null,
url: null
url: null,
external: false
};
}

Expand Down Expand Up @@ -1205,41 +1230,48 @@ export async function getAuthStatus(cwd) {
}
}

export async function interruptServerTurn(cwd, { threadId, serverUrl = null }) {
// `serverExternal` is the ownership flag persisted in the job record when the
// job started. Historical ownership must never be inferred from the current
// process environment — a cancel process without the job-start env would
// otherwise mistake a user-managed server for a plugin-owned one (issue #29).
export async function interruptServerTurn(cwd, { threadId, serverUrl = null, serverExternal = null }) {
const ownership = typeof serverExternal === "boolean" ? { serverExternal } : {};
if (!threadId) {
const serverExternal =
serverUrl && normalizeServerUrlForCompare(serverUrl) === normalizeServerUrlForCompare(process.env[SERVER_URL_ENV]);
return {
attempted: false,
interrupted: false,
transport: null,
detail: "missing OpenCode session id",
...(serverUrl ? { serverUrl } : {}),
...(serverExternal ? { serverExternal: true } : {})
...ownership
};
}

if (serverUrl) {
try {
await abortSessionAtUrl(serverUrl, threadId, 1000, resolveCredentialsForServerUrl(cwd, serverUrl));
const serverExternal = normalizeServerUrlForCompare(serverUrl) === normalizeServerUrlForCompare(process.env[SERVER_URL_ENV]);
await abortSessionAtUrl(
serverUrl,
threadId,
1000,
resolveCredentialsForServerUrl(cwd, serverUrl),
canonicalWorkspaceDirectory(cwd)
);
return {
attempted: true,
interrupted: true,
transport: "server",
detail: `Aborted OpenCode session ${threadId}.`,
serverUrl,
...(serverExternal ? { serverExternal: true } : {})
...ownership
};
} catch (error) {
const serverExternal = normalizeServerUrlForCompare(serverUrl) === normalizeServerUrlForCompare(process.env[SERVER_URL_ENV]);
return {
attempted: true,
interrupted: false,
transport: "server",
detail: error instanceof Error ? error.message : String(error),
serverUrl,
...(serverExternal ? { serverExternal: true } : {})
...ownership
};
}
}
Expand All @@ -1263,7 +1295,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, serverSessionCredentials(server));
const client = buildServerClient(cwd, server);
await client.abort(threadId);
return {
attempted: true,
Expand Down Expand Up @@ -1300,7 +1332,10 @@ export async function runServerTurn(cwd, options = {}) {

return withServer(cwd, async (client, server) => {
emitProgress(options.onProgress, "Using shared OpenCode server.", "starting", {
serverUrl: server.url
serverUrl: server.url,
// Recorded into the job so a later cancel process knows whether this
// server is plugin-owned without consulting its own environment.
serverExternal: Boolean(server.external)
});

let sessionID = options.resumeThreadId ?? options.resumeSessionId ?? null;
Expand Down
10 changes: 10 additions & 0 deletions plugins/opencode/scripts/lib/tracked-jobs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ function normalizeProgressEvent(value) {
threadId: typeof value.threadId === "string" && value.threadId.trim() ? value.threadId.trim() : null,
turnId: typeof value.turnId === "string" && value.turnId.trim() ? value.turnId.trim() : null,
serverUrl: typeof value.serverUrl === "string" && value.serverUrl.trim() ? value.serverUrl.trim() : null,
// Tri-state: only an explicit boolean is persisted into the job record.
serverExternal: typeof value.serverExternal === "boolean" ? value.serverExternal : null,
stderrMessage: value.stderrMessage == null ? null : String(value.stderrMessage).trim(),
logTitle: typeof value.logTitle === "string" && value.logTitle.trim() ? value.logTitle.trim() : null,
logBody: value.logBody == null ? null : String(value.logBody).trimEnd()
Expand All @@ -36,6 +38,7 @@ function normalizeProgressEvent(value) {
threadId: null,
turnId: null,
serverUrl: null,
serverExternal: null,
stderrMessage: String(value ?? "").trim(),
logTitle: null,
logBody: null
Expand Down Expand Up @@ -81,6 +84,7 @@ export function createJobProgressUpdater(workspaceRoot, jobId) {
let lastThreadId = null;
let lastTurnId = null;
let lastServerUrl = null;
let lastServerExternal = null;

return (event) => {
const normalized = normalizeProgressEvent(event);
Expand Down Expand Up @@ -111,6 +115,12 @@ export function createJobProgressUpdater(workspaceRoot, jobId) {
changed = true;
}

if (normalized.serverExternal != null && normalized.serverExternal !== lastServerExternal) {
lastServerExternal = normalized.serverExternal;
patch.serverExternal = normalized.serverExternal;
changed = true;
}

if (!changed) {
return;
}
Expand Down
20 changes: 16 additions & 4 deletions plugins/opencode/scripts/opencode-companion.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -790,10 +790,12 @@ async function handleTask(argv) {
ensureOpenCodeAvailable(cwd);
requireTaskRequest(prompt, resumeLast);

const currentServerUrl = getSessionRuntimeStatus(process.env, workspaceRoot).url ?? null;
const runtime = getSessionRuntimeStatus(process.env, workspaceRoot);
const job = {
...buildTaskJob(workspaceRoot, taskMetadata, write),
...(currentServerUrl ? { serverUrl: currentServerUrl } : {})
// Persist ownership with the URL at job start; a later cancel process
// must not re-derive it from its own environment (issue #29).
...(runtime.url ? { serverUrl: runtime.url, serverExternal: Boolean(runtime.external) } : {})
};
const request = buildTaskRequest({
cwd,
Expand Down Expand Up @@ -1086,8 +1088,9 @@ async function handleCancel(argv) {

const threadId = currentJob.threadId ?? null;
const serverUrl = currentJob.serverUrl ?? null;
const serverExternal = typeof currentJob.serverExternal === "boolean" ? currentJob.serverExternal : null;

const interrupt = await interruptServerTurn(cwd, { threadId, serverUrl });
const interrupt = await interruptServerTurn(cwd, { threadId, serverUrl, serverExternal });
const completedAt = nowIso();
const cancelResult = await cancelJobIfStillActive(workspaceRoot, currentJob, completedAt);
if (cancelResult.cancelled) {
Expand All @@ -1109,7 +1112,16 @@ async function handleCancel(argv) {
}
try {
const teardownServerUrl = interrupt.serverUrl ?? serverUrl;
if (!interrupt.serverExternal) {
// Tear down only a server the job explicitly recorded as plugin-owned
// (serverExternal === false). When the job recorded a URL but no
// ownership (legacy records), fail safe and leave the server running —
// it may be a shared, user-managed service (issue #29). Jobs without a
// recorded URL fall back to the workspace's own persisted server unless
// the interrupt just connected to an env-configured external one.
const ownedServerTeardown = serverUrl
? serverExternal === false
: interrupt.serverExternal !== true;
if (ownedServerTeardown) {
await teardownServerSession({
cwd: workspaceRoot,
ignoreCurrentProcessLease: Boolean(interrupt.serverUrl && !serverUrl),
Expand Down
20 changes: 17 additions & 3 deletions tests/fake-opencode-fixture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,12 @@ const server = http.createServer(async (req, res) => {
}

if (req.method === "GET" && url.pathname === "/event") {
// Record the workspace scope of each event subscription so tests can
// assert the client binds its stream to the invoking directory.
const eventState = loadState();
eventState.eventDirectories = eventState.eventDirectories || [];
eventState.eventDirectories.push(url.searchParams.get("directory"));
saveState(eventState);
res.writeHead(200, {
"content-type": "text/event-stream",
"cache-control": "no-cache",
Expand All @@ -529,22 +535,30 @@ const server = http.createServer(async (req, res) => {
}

if (req.method === "GET" && url.pathname === "/session") {
sendJson(res, loadState().sessions);
// Mirror the real server: a directory query scopes the listing to that
// workspace.
const directory = url.searchParams.get("directory");
const sessions = loadState().sessions.filter(
(session) => !directory || session.directory === directory
);
sendJson(res, sessions);
return;
}

if (req.method === "POST" && url.pathname === "/session") {
const body = await readJson(req);
// Mirror the real server: create-session rejects \`directory\` and \`model\`
// (additionalProperties:false / model belongs on the message) with 400.
// in the BODY (additionalProperties:false / model belongs on the message)
// with 400; the workspace is scoped via the \`directory\` QUERY parameter
// and otherwise inherits the server process launch directory.
if ("directory" in body || "model" in body) {
sendJson(res, { _tag: "BadRequest" }, 400);
return;
}
const state = loadState();
const session = {
id: "ses_" + state.nextSessionId++,
directory: body.directory || process.cwd(),
directory: url.searchParams.get("directory") || process.cwd(),
title: body.title || null,
agent: body.agent || null,
model: body.model || null,
Expand Down
35 changes: 35 additions & 0 deletions tests/opencode-server.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,41 @@ test("buildBasicAuthHeader follows OpenCode's basic-auth convention", () => {
assert.equal(buildBasicAuthHeader({ password: "" }), null);
});

test("client scopes project routes to the configured directory but never /global routes", async () => {
const seen = [];
const client = new OpencodeServerClient("http://opencode.test", {
directory: "/work/repo a",
fetch: async (requestUrl) => {
const url = new URL(String(requestUrl));
seen.push(url);
if (url.pathname === "/event") {
return new Response(":ok\n\n", {
status: 200,
headers: { "content-type": "text/event-stream" }
});
}
return new Response("{}", { status: 200, headers: { "content-type": "application/json" } });
}
});

await client.listSessions();
await client.createSession({ agent: "plan" });
await client.subscribeEvents(() => {});
await client.health();
await client.dispose();

assert.equal(seen[0].pathname, "/session");
assert.equal(seen[0].searchParams.get("directory"), "/work/repo a");
assert.equal(seen[1].pathname, "/session");
assert.equal(seen[1].searchParams.get("directory"), "/work/repo a");
assert.equal(seen[2].pathname, "/event");
assert.equal(seen[2].searchParams.get("directory"), "/work/repo a");
assert.equal(seen[3].pathname, "/global/health");
assert.equal(seen[3].searchParams.get("directory"), null);
assert.equal(seen[4].pathname, "/global/dispose");
assert.equal(seen[4].searchParams.get("directory"), null);
});

test("client sends Basic auth on requests, fresh connections, and the event stream", { skip: LOCAL_LISTEN_SKIP }, async () => {
const authorization = `Basic ${Buffer.from("opencode:secret").toString("base64")}`;
const seen = [];
Expand Down
Loading