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
40 changes: 40 additions & 0 deletions plugins/opencode/scripts/lib/process.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,46 @@ export function commandLineLooksLikeTaskWorker(commandLine, { jobId } = {}) {
);
}

function tokensContainPort(tokens, port) {
const expected = String(port ?? "");
if (!expected) {
return false;
}

for (let index = 0; index < tokens.length; index += 1) {
if (tokens[index] === "--port" && tokens[index + 1] === expected) {
return true;
}
if (tokens[index] === `--port=${expected}`) {
return true;
}
}
return false;
}

export function commandLineLooksLikeOpencodeServe(commandLine, { port } = {}) {
// On win32 the server is spawned through a shell, so `ps`/PowerShell can
// report the whole invocation as one quoted blob (`cmd.exe /c "opencode
// serve --port N"`). Re-split compound tokens so the matcher sees the real
// arguments on every platform.
const tokens = commandLineTokens(commandLine).flatMap((token) =>
/\s/.test(token) ? token.split(/\s+/).filter(Boolean) : [token]
);
// Require an opencode-ish executable token in addition to `serve --port N`.
// Matches the real binary (/opt/homebrew/bin/opencode), test fixtures
// (<tmp>/opencode), and Windows shims (opencode.cmd).
if (!tokens.some((token) => path.basename(token).toLowerCase().startsWith("opencode"))) {
return false;
}
if (!tokens.includes("serve")) {
return false;
}
if (!tokensContainPort(tokens, port)) {
return false;
}
return true;
}

export function readProcessCommandLine(pid, options = {}) {
if (!Number.isFinite(pid)) {
return null;
Expand Down
92 changes: 80 additions & 12 deletions plugins/opencode/scripts/lib/server-lifecycle.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import process from "node:process";
import { spawn } from "node:child_process";

import { OpencodeServerClient } from "./opencode-server.mjs";
import { commandLineLooksLikeOpencodeServe, readProcessCommandLine } from "./process.mjs";
import { atomicWriteFile, resolveStateDir } from "./state.mjs";

export const SERVER_URL_ENV = "OPENCODE_COMPANION_SERVER_URL";
Expand Down Expand Up @@ -432,7 +433,7 @@ export async function ensureServer(cwd, options = {}) {

const staleExisting = loadServerSession(cwd);
if (staleExisting) {
const { url, pidFile, logFile, sessionDir, pid, external, password, username } = staleExisting;
const { url, pidFile, logFile, sessionDir, pid, external, password, username, port } = 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 @@ -444,7 +445,9 @@ export async function ensureServer(cwd, options = {}) {
external,
password,
username,
killProcess: options.killProcess ?? null
port,
killProcess: options.killProcess ?? null,
readProcessCommandLineImpl: options.readProcessCommandLineImpl ?? null
});
clearServerSession(cwd);
}
Expand All @@ -467,6 +470,10 @@ export async function ensureServer(cwd, options = {}) {
env: options.env ?? process.env,
password
});
// Recorded for forensics (compare against the live command line when an
// identity-mismatch teardown skip is investigated); verification itself
// matches the LIVE command line against `opencode serve --port <port>`.
const pidCommandLine = readProcessCommandLine(child.pid, options);

const ready = await waitForServerHealth(url, options.timeoutMs ?? 10000, {
password,
Expand All @@ -481,7 +488,9 @@ export async function ensureServer(cwd, options = {}) {
pid: child.pid ?? null,
password,
username: OWNED_SERVER_USERNAME,
killProcess: options.killProcess ?? null
port,
killProcess: options.killProcess ?? null,
readProcessCommandLineImpl: options.readProcessCommandLineImpl ?? null
});
return null;
}
Expand All @@ -494,7 +503,9 @@ export async function ensureServer(cwd, options = {}) {
sessionDir,
external: false,
password,
username: OWNED_SERVER_USERNAME
username: OWNED_SERVER_USERNAME,
port,
pidCommandLine
};
const leasedSession = addServerLease(session, options);
saveServerSession(cwd, leasedSession);
Expand All @@ -504,6 +515,37 @@ export async function ensureServer(cwd, options = {}) {
}
}

function parseServerUrlPort(url) {
try {
const port = Number(new URL(String(url ?? "")).port);
return Number.isInteger(port) && port > 0 ? port : null;
} catch {
return null;
}
}

// Fail-closed PID identity check (issue #31): only signal a PID when its live
// command line still looks like the plugin-owned `opencode serve` instance for
// this session's port. The port always exists for owned sessions — it is part
// of the persisted URL — so records written before the explicit identity
// fields existed remain verifiable. Anything unverifiable is left untouched;
// only the stale metadata is cleared.
function verifyServerPidIdentity(pid, { url = null, port = null, readProcessCommandLineImpl = null } = {}) {
const expectedPort = Number.isFinite(port) ? Number(port) : parseServerUrlPort(url);
if (!Number.isFinite(expectedPort)) {
return { verified: false, reason: "identity-unverified" };
}
const readCommandLine = readProcessCommandLineImpl ?? readProcessCommandLine;
const commandLine = readCommandLine(pid);
if (!commandLine) {
return { verified: false, reason: "identity-unverified" };
}
if (!commandLineLooksLikeOpencodeServe(commandLine, { port: expectedPort })) {
return { verified: false, reason: "identity-mismatch" };
}
return { verified: true, reason: null };
}

async function teardownServerSessionUnlocked({
url = null,
pidFile = null,
Expand All @@ -513,7 +555,9 @@ async function teardownServerSessionUnlocked({
external = false,
password = null,
username = null,
killProcess = null
killProcess = null,
port = null,
readProcessCommandLineImpl = null
} = {}) {
if (url && !external) {
try {
Expand All @@ -526,11 +570,17 @@ async function teardownServerSessionUnlocked({
}
}

let killSkippedReason = null;
if (!external && Number.isFinite(pid)) {
try {
killServerPid(pid, killProcess);
} catch {
// Ignore teardown failures during Claude session shutdown.
const identity = verifyServerPidIdentity(pid, { url, port, readProcessCommandLineImpl });
if (identity.verified) {
try {
killServerPid(pid, killProcess);
} catch {
// Ignore teardown failures during Claude session shutdown.
}
} else {
killSkippedReason = identity.reason;
}
}

Expand All @@ -550,6 +600,18 @@ async function teardownServerSessionUnlocked({
}
}

if (killSkippedReason) {
// Metadata is cleared (so callers still clear the session record), but the
// process was deliberately left untouched. `skipped` keeps its existing
// meaning of "teardown did not run at all" (leases / lock timeouts).
return {
skipped: false,
killSkipped: true,
reason: killSkippedReason,
diagnostic: `Left PID ${pid} untouched (${killSkippedReason}); cleared stale OpenCode server metadata only.`
};
}

return { skipped: false };
}

Expand All @@ -568,7 +630,9 @@ export async function teardownServerSession({
external = false,
password = null,
username = null,
killProcess = null
killProcess = null,
port = null,
readProcessCommandLineImpl = null
} = {}) {
if (!cwd) {
return teardownServerSessionUnlocked({
Expand All @@ -580,7 +644,9 @@ export async function teardownServerSession({
external,
password,
username,
killProcess
killProcess,
port,
readProcessCommandLineImpl
});
}

Expand Down Expand Up @@ -615,7 +681,9 @@ export async function teardownServerSession({
external: Boolean(session?.external ?? external),
password: session?.password ?? password,
username: session?.username ?? username,
killProcess
killProcess,
port: session?.port ?? port,
readProcessCommandLineImpl
};
const result = await teardownServerSessionUnlocked(teardownTarget);
if (session) {
Expand Down
5 changes: 4 additions & 1 deletion plugins/opencode/scripts/opencode-companion.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1122,11 +1122,14 @@ async function handleCancel(argv) {
? serverExternal === false
: interrupt.serverExternal !== true;
if (ownedServerTeardown) {
await teardownServerSession({
const teardownResult = await teardownServerSession({
cwd: workspaceRoot,
ignoreCurrentProcessLease: Boolean(interrupt.serverUrl && !serverUrl),
...(teardownServerUrl ? { url: teardownServerUrl } : {})
});
if (teardownResult?.killSkipped) {
appendLogLine(cancelLogFile, `Skipped OpenCode server kill: ${teardownResult.reason}.`);
}
}
} catch (error) {
appendLogLine(
Expand Down
Loading