Files
openwork/evals/specs/opencode-mcp-agent-oauth.e2e.test.ts

654 lines
32 KiB
TypeScript

import { spawn, type ChildProcess } from "node:child_process";
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { expect } from "vitest";
import { denFetch, signIn } from "@openwork/behaviors";
import type { DenSession } from "@openwork/behaviors";
import { needs, server, SkipError, test, unmetNeeds } from "@openwork/testkit";
import type { TestNeeds } from "@openwork/testkit";
// Proves the docs' "Verified" claim for OpenCode: the real `opencode` CLI
// completes the full public OAuth path against the Den MCP gateway — RFC9728
// discovery, dynamic client registration, PKCE authorize + consent, loopback
// callback, token exchange, credential storage, transparent refresh with
// rotation, and logout — and the minted token works on the /mcp/agent surface
// without reopening the optional standalone SSE listener.
const MCP_NAME = "openwork";
const ORGANIZATION_NAME = "OAuth Lab";
const EXPECTED_TOOLS = ["create_skill", "execute_capability", "search_capabilities"] as const;
const OPENCODE_BIN = process.env.OPENWORK_EVAL_OPENCODE_BIN?.trim() || "opencode";
const COMMAND_TIMEOUT_MS = 60_000;
const AUTHORIZE_URL_TIMEOUT_MS = 90_000;
const AUTH_EXIT_TIMEOUT_MS = 60_000;
const REQUEST_TIMEOUT_MS = 30_000;
// Launcher names opencode may shell out to for opening the authorize URL.
const BROWSER_LAUNCHERS = ["open", "xdg-open", "sensible-browser", "x-www-browser", "www-browser"];
const POISONED_ACCESS_TOKEN = "eyJhbGciOiJFZERTQSJ9.eyJleHAiOjF9.invalid";
const placementCommand = process.env.OPENWORK_EVAL_DAYTONA?.trim() === "1" ? "daytona" : "docker";
const requirements: TestNeeds = {
optIn: ["OPENWORK_EVAL_E2E_TESTS"],
commands: [placementCommand, OPENCODE_BIN],
};
const missingRequirements = unmetNeeds(requirements, process.env);
const title = missingRequirements.length > 0
? `opencode OAuth connection to the Den MCP agent skipped — needs: ${missingRequirements.join(", ")}`
: "opencode connects to the Den MCP agent through the full OAuth flow and survives token rotation";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function stripAnsi(value: string): string {
// eslint-disable-next-line no-control-regex
return value.replace(/\u001b\[[0-9;]*[A-Za-z]/g, "").replace(/\u001b\][^\u0007]*\u0007/g, "");
}
function containsTokenMaterial(value: string): boolean {
return value.includes("ow_mcp_at_") || value.includes("ow_mcp_rt_");
}
interface CommandResult {
label: string;
exitCode: number | null;
stdout: string;
stderr: string;
combined: string;
}
interface OpencodeHome {
root: string;
env: NodeJS.ProcessEnv;
authFilePath: string;
capturedUrlsPath: string;
}
function childEnvironment(home: { root: string; binDir: string; browserShim: string }): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {};
for (const [key, value] of Object.entries(process.env)) {
// The spec may itself run under an opencode session; parent OPENCODE_*
// variables must not redirect the child CLI away from the isolated home.
if (key.startsWith("OPENCODE")) continue;
// The CLI under test needs no credentials beyond its own OAuth flow;
// runner and CI secrets stay out of its process environment.
if (/API_KEY|TOKEN|SECRET|PASSWORD/i.test(key)) continue;
env[key] = value;
}
env.HOME = path.join(home.root, "home");
env.XDG_CONFIG_HOME = path.join(home.root, "xdg-config");
env.XDG_DATA_HOME = path.join(home.root, "xdg-data");
env.XDG_CACHE_HOME = path.join(home.root, "xdg-cache");
env.PATH = [home.binDir, process.env.PATH ?? ""].filter(Boolean).join(path.delimiter);
env.BROWSER = home.browserShim;
env.NO_COLOR = "1";
return env;
}
async function prepareOpencodeHome(mcpUrl: string): Promise<OpencodeHome> {
const root = await mkdtemp(path.join(os.tmpdir(), "openwork-opencode-oauth-"));
const binDir = path.join(root, "bin");
const configDir = path.join(root, "xdg-config", "opencode");
const dataDir = path.join(root, "xdg-data", "opencode");
const capturedUrlsPath = path.join(root, "captured-urls.txt");
await mkdir(binDir, { recursive: true });
await mkdir(configDir, { recursive: true });
await mkdir(dataDir, { recursive: true });
await mkdir(path.join(root, "xdg-cache", "opencode"), { recursive: true });
await mkdir(path.join(root, "home"), { recursive: true });
await writeFile(capturedUrlsPath, "", "utf8");
// Browser launchers are intercepted so no real browser opens and the
// authorize URL becomes observable even if stdout formatting changes.
const shim = `#!/bin/sh\nprintf '%s\\n' "$@" >> ${JSON.stringify(capturedUrlsPath)}\nexit 0\n`;
for (const launcher of BROWSER_LAUNCHERS) {
const shimPath = path.join(binDir, launcher);
await writeFile(shimPath, shim, "utf8");
await chmod(shimPath, 0o755);
}
const browserShim = path.join(binDir, BROWSER_LAUNCHERS[0]);
const config = {
$schema: "https://opencode.ai/config.json",
mcp: {
[MCP_NAME]: {
type: "remote",
url: mcpUrl,
enabled: true,
oauth: {},
},
},
};
await writeFile(path.join(configDir, "opencode.json"), `${JSON.stringify(config, null, 2)}\n`, "utf8");
return {
root,
env: childEnvironment({ root, binDir, browserShim }),
authFilePath: path.join(dataDir, "mcp-auth.json"),
capturedUrlsPath,
};
}
function runOpencode(home: OpencodeHome, args: string[], label: string, timeoutMs = COMMAND_TIMEOUT_MS): Promise<CommandResult> {
return new Promise((resolve) => {
const child = spawn(OPENCODE_BIN, args, {
cwd: home.root,
env: home.env,
stdio: ["ignore", "pipe", "pipe"],
});
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk));
child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk));
const timer = setTimeout(() => child.kill("SIGTERM"), timeoutMs);
const finish = (exitCode: number | null) => {
clearTimeout(timer);
const out = stripAnsi(Buffer.concat(stdout).toString("utf8"));
const err = stripAnsi(Buffer.concat(stderr).toString("utf8"));
resolve({ label, exitCode, stdout: out, stderr: err, combined: `${out}\n${err}` });
};
child.on("error", () => finish(null));
child.on("close", (code) => finish(code));
});
}
interface AuthProcess {
exited: Promise<CommandResult>;
liveOutput(): string;
kill(): void;
}
function startAuthProcess(home: OpencodeHome): AuthProcess {
const child: ChildProcess = spawn(OPENCODE_BIN, ["mcp", "auth", MCP_NAME], {
cwd: home.root,
env: home.env,
stdio: ["ignore", "pipe", "pipe"],
});
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
child.stdout?.on("data", (chunk: Buffer) => stdout.push(chunk));
child.stderr?.on("data", (chunk: Buffer) => stderr.push(chunk));
const exited = new Promise<CommandResult>((resolve) => {
const finish = (exitCode: number | null) => {
const out = stripAnsi(Buffer.concat(stdout).toString("utf8"));
const err = stripAnsi(Buffer.concat(stderr).toString("utf8"));
resolve({ label: "opencode mcp auth", exitCode, stdout: out, stderr: err, combined: `${out}\n${err}` });
};
child.on("error", () => finish(null));
child.on("close", (code) => finish(code));
});
return {
exited,
liveOutput: () => stripAnsi(`${Buffer.concat(stdout).toString("utf8")}\n${Buffer.concat(stderr).toString("utf8")}`),
kill: () => {
if (child.exitCode === null && !child.killed) child.kill("SIGTERM");
},
};
}
function authorizeUrlFrom(text: string): string {
for (const raw of text.match(/https?:\/\/[^\s"'<>]+/g) ?? []) {
let candidate = raw;
while (/[),.;\]]$/.test(candidate)) candidate = candidate.slice(0, -1);
try {
const url = new URL(candidate);
if (url.pathname.includes("/oauth2/authorize")) return url.toString();
} catch {
// Ignore fragments that only look like URLs.
}
}
return "";
}
async function waitForAuthorizeUrl(home: OpencodeHome, auth: AuthProcess): Promise<string> {
const deadline = Date.now() + AUTHORIZE_URL_TIMEOUT_MS;
let lastOutput = "";
while (Date.now() < deadline) {
const captured = await readFile(home.capturedUrlsPath, "utf8").catch(() => "");
const fromShim = authorizeUrlFrom(captured);
if (fromShim) return fromShim;
lastOutput = auth.liveOutput();
const fromStdout = authorizeUrlFrom(lastOutput);
if (fromStdout) return fromStdout;
await delay(250);
}
throw new Error(`opencode mcp auth never surfaced an authorize URL. Output so far:\n${lastOutput.slice(0, 2_000)}`);
}
class CookieJar {
private readonly cookies = new Map<string, string>();
absorb(response: Response): void {
for (const header of response.headers.getSetCookie()) {
const [pair] = header.split(";");
const separator = pair.indexOf("=");
if (separator <= 0) continue;
this.cookies.set(pair.slice(0, separator).trim(), pair.slice(separator + 1).trim());
}
}
header(): string {
return [...this.cookies.entries()].map(([name, value]) => `${name}=${value}`).join("; ");
}
}
interface ApprovalOutcome {
consentPageUrl: string;
callbackUrl: URL;
callbackStatus: number;
}
// Replays exactly what the Den consent page (app/mcp/select-organization)
// does in a real browser: session sign-in, authorize redirect, set-active
// organization, consent accept, then the front-channel hop to opencode's
// loopback callback server.
async function approveAuthorization(
authorizeUrl: URL,
member: DenSession,
organization: { id: string; slug: string | null },
): Promise<ApprovalOutcome> {
const origin = authorizeUrl.origin;
const jar = new CookieJar();
const signInResponse = await fetch(`${origin}/api/auth/sign-in/email`, {
method: "POST",
headers: { "content-type": "application/json", origin },
body: JSON.stringify({ email: member.email, password: member.password }),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!signInResponse.ok) {
throw new Error(`OAuth browser sign-in failed: HTTP ${signInResponse.status} ${(await signInResponse.text()).slice(0, 500)}`);
}
jar.absorb(signInResponse);
const authorizeResponse = await fetch(authorizeUrl, {
redirect: "manual",
headers: { cookie: jar.header() },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
jar.absorb(authorizeResponse);
const consentLocation = authorizeResponse.headers.get("location") ?? "";
if (authorizeResponse.status < 300 || authorizeResponse.status >= 400 || !consentLocation) {
throw new Error(`Authorize did not redirect to consent: HTTP ${authorizeResponse.status} ${(await authorizeResponse.text()).slice(0, 500)}`);
}
const consentPage = new URL(consentLocation, origin);
if (!consentPage.pathname.includes("/mcp/select-organization")) {
throw new Error(`Authorize redirected somewhere other than organization consent: ${consentPage.toString()}`);
}
const oauthQuery = consentPage.search.replace(/^\?/, "");
const requestedScope = consentPage.searchParams.get("scope") ?? "openid profile email mcp:read";
const setActiveResponse = await fetch(`${origin}/api/auth/organization/set-active`, {
method: "POST",
headers: { "content-type": "application/json", origin, cookie: jar.header() },
body: JSON.stringify({ organizationId: organization.id, organizationSlug: organization.slug }),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!setActiveResponse.ok) {
throw new Error(`Selecting the organization during consent failed: HTTP ${setActiveResponse.status} ${(await setActiveResponse.text()).slice(0, 500)}`);
}
jar.absorb(setActiveResponse);
const consentResponse = await fetch(`${origin}/api/auth/oauth2/consent`, {
method: "POST",
headers: { "content-type": "application/json", origin, cookie: jar.header() },
body: JSON.stringify({ accept: true, scope: requestedScope, oauth_query: oauthQuery }),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
const consentText = await consentResponse.text();
let consentPayload: unknown = null;
try {
consentPayload = consentText.trim() ? JSON.parse(consentText) : null;
} catch {
consentPayload = consentText;
}
const callbackTarget = isRecord(consentPayload) && typeof consentPayload.url === "string" ? consentPayload.url : "";
if (!consentResponse.ok || !callbackTarget) {
throw new Error(`OAuth consent accept failed: HTTP ${consentResponse.status} ${consentText.slice(0, 500)}`);
}
const callbackUrl = new URL(callbackTarget);
const callbackResponse = await fetch(callbackUrl, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
await callbackResponse.text();
return { consentPageUrl: consentPage.toString(), callbackUrl, callbackStatus: callbackResponse.status };
}
interface StoredCredential {
raw: string;
parsed: unknown;
accessToken: string;
refreshToken: string;
}
function firstStringDeep(value: unknown, matches: (key: string) => boolean, depth = 0): string {
if (!isRecord(value) || depth > 8) return "";
for (const [key, entry] of Object.entries(value)) {
if (matches(key.toLowerCase()) && typeof entry === "string" && entry.length > 0) return entry;
}
for (const entry of Object.values(value)) {
const found = firstStringDeep(entry, matches, depth + 1);
if (found) return found;
}
return "";
}
function overwriteFirstDeep(value: unknown, matches: (key: string) => boolean, next: string | number, depth = 0): boolean {
if (!isRecord(value) || depth > 8) return false;
for (const [key, entry] of Object.entries(value)) {
if (matches(key.toLowerCase()) && (typeof entry === "string" || typeof entry === "number")) {
value[key] = typeof entry === "number" && typeof next === "string" ? Date.now() - 60_000 : next;
return true;
}
}
for (const entry of Object.values(value)) {
if (overwriteFirstDeep(entry, matches, next, depth + 1)) return true;
}
return false;
}
async function readStoredCredential(home: OpencodeHome): Promise<StoredCredential> {
const raw = await readFile(home.authFilePath, "utf8");
const parsed: unknown = JSON.parse(raw);
return {
raw,
parsed,
accessToken: firstStringDeep(parsed, (key) => key === "accesstoken" || key === "access_token" || key === "access"),
refreshToken: firstStringDeep(parsed, (key) => key === "refreshtoken" || key === "refresh_token" || key === "refresh"),
};
}
async function poisonStoredCredential(home: OpencodeHome, credential: StoredCredential): Promise<{ expiryPoisoned: boolean; accessPoisoned: boolean }> {
const expiryPoisoned = overwriteFirstDeep(
credential.parsed,
(key) => key === "expiresat" || key === "expires_at" || key === "expiry" || key === "expires",
Date.now() - 60_000,
);
const accessPoisoned = overwriteFirstDeep(
credential.parsed,
(key) => key === "accesstoken" || key === "access_token" || key === "access",
POISONED_ACCESS_TOKEN,
);
await writeFile(home.authFilePath, `${JSON.stringify(credential.parsed, null, 2)}\n`, "utf8");
return { expiryPoisoned, accessPoisoned };
}
let requestId = 0;
async function mcpRequest(mcpUrl: string, token: string, method: string, params: unknown): Promise<{ status: number; payload: unknown }> {
const response = await fetch(mcpUrl, {
method: "POST",
headers: {
authorization: `Bearer ${token}`,
"content-type": "application/json",
accept: "application/json, text/event-stream",
},
body: JSON.stringify({ jsonrpc: "2.0", id: ++requestId, method, params }),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
const raw = await response.text();
if (!response.ok) return { status: response.status, payload: raw };
const dataLine = raw.split("\n").find((line) => line.startsWith("data:"));
if (!dataLine) throw new Error(`MCP ${method} returned no SSE data frame: ${raw.slice(0, 500)}`);
const payload: unknown = JSON.parse(dataLine.slice(5));
if (isRecord(payload) && payload.error) throw new Error(`MCP ${method} returned JSON-RPC error: ${JSON.stringify(payload.error)}`);
return { status: response.status, payload: isRecord(payload) ? payload.result : payload };
}
async function listToolNames(mcpUrl: string, token: string): Promise<string[]> {
const { payload } = await mcpRequest(mcpUrl, token, "tools/list", {});
const tools = isRecord(payload) && Array.isArray(payload.tools) ? payload.tools.filter(isRecord) : [];
return tools.flatMap((tool) => (typeof tool.name === "string" ? [tool.name] : [])).sort();
}
function toolJson(result: unknown): unknown {
const content = isRecord(result) && Array.isArray(result.content) ? result.content[0] : null;
if (!isRecord(content) || typeof content.text !== "string") {
throw new Error(`MCP tool result had no text content: ${JSON.stringify(result).slice(0, 500)}`);
}
return JSON.parse(content.text);
}
async function callAgentTool(mcpUrl: string, token: string, name: string, args: Record<string, unknown>): Promise<unknown> {
const { payload } = await mcpRequest(mcpUrl, token, "tools/call", { name, arguments: args });
return payload;
}
async function activeOrganization(admin: DenSession): Promise<{ id: string; slug: string | null }> {
const result = await denFetch(admin, "/v1/me/orgs", {
headers: { authorization: `Bearer ${admin.token}` },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
const organizations = isRecord(result.body) && Array.isArray(result.body.orgs)
? result.body.orgs.filter(isRecord)
: [];
const organization = organizations.find((entry) => entry.name === ORGANIZATION_NAME);
const id = organization && typeof organization.id === "string" ? organization.id : "";
if (!result.response.ok || !id) {
throw new Error(`Finding the test organization failed: HTTP ${result.response.status} ${result.text.slice(0, 500)}`);
}
const slug = organization && typeof organization.slug === "string" ? organization.slug : null;
return { id, slug };
}
test.skipIf(missingRequirements.length > 0)(title, { timeout: 30 * 60_000 }, async ({ evidence, place }) => {
needs(requirements);
if (process.env.OPENWORK_EVAL_DEN_API_URL?.trim()) {
throw new SkipError("The opencode OAuth proof requires a cold managed Den");
}
await using den = await server({
place,
org: {
name: ORGANIZATION_NAME,
admin: { name: "OAuth Admin" },
members: {},
},
});
const mcpUrl = `${den.ref.apiUrl.replace(/\/+$/, "")}/mcp/agent`;
const organization = await activeOrganization(den.admin);
// Claim 1 — anonymous requests are rejected, the challenge advertises
// discoverable OAuth metadata naming a real authorization server, and the
// advertised resource identity matches the URL clients actually connect to.
// opencode validates that match per RFC 9728 and refuses drifted hosts, so
// an identity mismatch here breaks every strict OAuth client.
const anonymous = await fetch(mcpUrl, {
method: "POST",
headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "eval", version: "0" } } }),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
await anonymous.text();
const challenge = anonymous.headers.get("www-authenticate") ?? "";
const metadataUrl = /resource_metadata="([^"]+)"/.exec(challenge)?.[1] ?? "";
let issuer = "";
let advertisedResource = "";
if (metadataUrl) {
const metadataResponse = await fetch(metadataUrl, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
const metadata: unknown = metadataResponse.ok ? await metadataResponse.json() : null;
const servers = isRecord(metadata) && Array.isArray(metadata.authorization_servers) ? metadata.authorization_servers : [];
issuer = typeof servers[0] === "string" ? servers[0] : "";
advertisedResource = isRecord(metadata) && typeof metadata.resource === "string" ? metadata.resource : "";
}
const challengeSound = anonymous.status === 401
&& metadataUrl.length > 0
&& issuer.length > 0
&& advertisedResource === mcpUrl;
evidence.recordAssertionEvidence(
"The MCP agent endpoint rejects anonymous clients with a discoverable OAuth challenge whose resource identity matches the connect URL",
`POST initialize without a bearer returned HTTP ${anonymous.status}; resource metadata ${metadataUrl || "(missing)"} advertised issuer ${issuer || "(missing)"} and resource ${advertisedResource || "(missing)"} for connect URL ${mcpUrl}.`,
challengeSound,
);
expect(anonymous.status).toBe(401);
expect(metadataUrl.length).toBeGreaterThan(0);
expect(issuer.length).toBeGreaterThan(0);
expect(advertisedResource, "The advertised MCP resource identity must match the URL clients connect to (RFC 9728); strict OAuth clients like opencode refuse drifted hosts.").toBe(mcpUrl);
const home = await prepareOpencodeHome(mcpUrl);
await using homeCleanup = {
async [Symbol.asyncDispose]() {
await rm(home.root, { recursive: true, force: true }).catch(() => undefined);
},
};
// Claim 2 — the real opencode binary drives a spec-compliant PKCE
// authorization and completes once the member approves consent.
const authProcess = startAuthProcess(home);
await using authProcessCleanup = {
async [Symbol.asyncDispose]() {
authProcess.kill();
},
};
const authorizeUrl = new URL(await waitForAuthorizeUrl(home, authProcess));
const redirectUri = authorizeUrl.searchParams.get("redirect_uri") ?? "";
const oauthState = authorizeUrl.searchParams.get("state") ?? "";
const authorizeShape = authorizeUrl.searchParams.get("response_type") === "code"
&& (authorizeUrl.searchParams.get("client_id") ?? "").length > 0
&& /^http:\/\/(127\.0\.0\.1|localhost):\d+\//.test(redirectUri)
&& oauthState.length > 10
&& (authorizeUrl.searchParams.get("code_challenge") ?? "").length > 20
&& authorizeUrl.searchParams.get("code_challenge_method") === "S256"
&& authorizeUrl.searchParams.get("resource") === mcpUrl;
expect(authorizeShape, `Authorize URL was not a compliant PKCE request: ${authorizeUrl.toString()}`).toBe(true);
const approval = await approveAuthorization(authorizeUrl, den.admin, organization);
const callbackParams = approval.callbackUrl.searchParams;
const frontChannelClean = (callbackParams.get("code") ?? "").length > 0
&& callbackParams.get("state") === oauthState
&& !callbackParams.has("access_token")
&& !callbackParams.has("refresh_token")
&& !callbackParams.has("id_token");
const authResult = await Promise.race([
authProcess.exited,
delay(AUTH_EXIT_TIMEOUT_MS).then(() => null),
]);
if (!authResult) {
authProcess.kill();
throw new Error(`opencode mcp auth did not exit after the loopback callback. Output:\n${authProcess.liveOutput().slice(0, 2_000)}`);
}
const authClean = authResult.exitCode === 0 && !containsTokenMaterial(authResult.combined);
evidence.recordAssertionEvidence(
"opencode completed the full OAuth PKCE flow after real consent approval",
`Authorize URL used S256 + loopback ${redirectUri} pinned to ${mcpUrl}; consent hop ${approval.consentPageUrl} redirected to ${approval.callbackUrl.origin}${approval.callbackUrl.pathname} (HTTP ${approval.callbackStatus}) carrying only code+state; opencode exited ${String(authResult.exitCode)} without leaking token material.`,
authorizeShape && frontChannelClean && authClean,
);
expect(frontChannelClean, `Front channel leaked token material or broke state: ${approval.callbackUrl.toString()}`).toBe(true);
expect(authResult.exitCode, `opencode mcp auth failed:\n${authResult.combined.slice(0, 2_000)}`).toBe(0);
expect(containsTokenMaterial(authResult.combined)).toBe(false);
// Claim 3 — opencode persisted rotating OAuth credentials, accepts Den's
// standards-based standalone-SSE rejection, and reports the connection as
// authenticated instead of treating 405 as a connection failure.
const credential = await readStoredCredential(home);
const storedShape = credential.accessToken.split(".").length === 3
&& credential.refreshToken.startsWith("ow_mcp_rt_");
const standaloneGet = await fetch(mcpUrl, {
method: "GET",
headers: {
accept: "text/event-stream",
authorization: `Bearer ${credential.accessToken}`,
},
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
const standaloneBody = await standaloneGet.text();
const standaloneRejected = standaloneGet.status === 405
&& standaloneGet.headers.get("allow") === "POST"
&& standaloneBody === "";
const authList = await runOpencode(home, ["mcp", "auth", "list"], "opencode mcp auth list");
const mcpList = await runOpencode(home, ["mcp", "list"], "opencode mcp list");
const authListSound = authList.exitCode === 0
&& /\bopenwork\b/i.test(authList.combined)
&& /authenticated/i.test(authList.combined)
&& !/not authenticated|expired/i.test(authList.combined)
&& !containsTokenMaterial(authList.combined);
const mcpListSound = mcpList.exitCode === 0
&& /\bopenwork\b/i.test(mcpList.combined)
&& /connected/i.test(mcpList.combined)
&& !/failed|needs authentication/i.test(mcpList.combined)
&& !containsTokenMaterial(mcpList.combined);
evidence.recordAssertionEvidence(
"opencode accepts Den's 405 standalone-SSE rejection and remains authenticated and connected",
`mcp-auth.json held a ${credential.accessToken.split(".").length}-part access token and an ${credential.refreshToken.slice(0, 10)}… refresh token; authenticated GET returned HTTP ${standaloneGet.status}, Allow ${standaloneGet.headers.get("allow") ?? "(missing)"}, and ${standaloneBody.length} body bytes; auth list said: ${authList.combined.trim().slice(0, 300)}; mcp list said: ${mcpList.combined.trim().slice(0, 300)}.`,
storedShape && standaloneRejected && authListSound && mcpListSound,
);
expect(storedShape, `Stored credential shape was wrong: ${credential.raw.slice(0, 300)}`).toBe(true);
expect(standaloneGet.status).toBe(405);
expect(standaloneGet.headers.get("allow")).toBe("POST");
expect(standaloneBody).toBe("");
expect(authListSound, `opencode mcp auth list did not report authenticated:\n${authList.combined.slice(0, 1_000)}`).toBe(true);
expect(mcpListSound, `opencode mcp list did not report connected:\n${mcpList.combined.slice(0, 1_000)}`).toBe(true);
// Claim 4 — the opencode-minted OAuth token works on the MCP surface:
// the three tools this journey depends on are present, and a real capability
// executes. Additional catalog-backed tools may be added independently.
const toolNames = await listToolNames(mcpUrl, credential.accessToken);
const expectedToolsPresent = EXPECTED_TOOLS.every((name) => toolNames.includes(name));
const skillSearch = await callAgentTool(mcpUrl, credential.accessToken, "search_capabilities", {
query: "create skill",
limit: 20,
type: "skills",
});
const searchPayload = toolJson(skillSearch);
const matches = isRecord(searchPayload) && Array.isArray(searchPayload.matches) ? searchPayload.matches.filter(isRecord) : [];
const createSkillMatch = matches.find((entry) => typeof entry.name === "string" && entry.name.includes("create-skill"));
const createSkillName = createSkillMatch && typeof createSkillMatch.name === "string" ? createSkillMatch.name : "";
expect(createSkillName, `search_capabilities never surfaced the builtin create-skill: ${JSON.stringify(searchPayload).slice(0, 500)}`).not.toBe("");
const execution = toolJson(await callAgentTool(mcpUrl, credential.accessToken, "execute_capability", { name: createSkillName }));
const executionSound = isRecord(execution)
&& execution.kind === "skill"
&& typeof execution.content === "string"
&& execution.content.includes("name: create-skill");
evidence.recordAssertionEvidence(
"The OAuth token opencode obtained drives the real capability surface",
`tools/list returned ${JSON.stringify(toolNames)}; search_capabilities matched ${createSkillName}; execute_capability returned its SKILL.md (kind=${isRecord(execution) ? String(execution.kind) : "?"}).`,
expectedToolsPresent && executionSound,
);
expect(expectedToolsPresent, `tools/list omitted a required agent tool: ${JSON.stringify(toolNames)}`).toBe(true);
expect(executionSound, `execute_capability did not return the builtin skill: ${JSON.stringify(execution).slice(0, 500)}`).toBe(true);
// Claim 5 — a stale local credential is refreshed transparently with
// rotation, while the stale token itself stays rejected.
const poisoned = await poisonStoredCredential(home, credential);
expect(poisoned.expiryPoisoned && poisoned.accessPoisoned, `mcp-auth.json no longer exposes expiry/access fields: ${credential.raw.slice(0, 300)}`).toBe(true);
const staleRejected = await mcpRequest(mcpUrl, POISONED_ACCESS_TOKEN, "tools/list", {});
const mcpListAfterExpiry = await runOpencode(home, ["mcp", "list"], "opencode mcp list after forced expiry");
const refreshed = await readStoredCredential(home);
const rotationSound = refreshed.accessToken.split(".").length === 3
&& refreshed.accessToken !== credential.accessToken
&& refreshed.accessToken !== POISONED_ACCESS_TOKEN
&& refreshed.refreshToken.startsWith("ow_mcp_rt_")
&& refreshed.refreshToken !== credential.refreshToken;
const refreshedToolNames = await listToolNames(mcpUrl, refreshed.accessToken);
const refreshedExpectedToolsPresent = EXPECTED_TOOLS.every((name) => refreshedToolNames.includes(name));
const refreshSound = mcpListAfterExpiry.exitCode === 0
&& /connected/i.test(mcpListAfterExpiry.combined)
&& rotationSound
&& staleRejected.status === 401
&& refreshedExpectedToolsPresent;
evidence.recordAssertionEvidence(
"opencode transparently refreshes an expired credential with refresh-token rotation while the stale token stays rejected",
`After poisoning the stored credential, opencode mcp list exited ${String(mcpListAfterExpiry.exitCode)} and reported connected; the stale bearer got HTTP ${staleRejected.status}; the store rotated to a new access token (changed: ${String(refreshed.accessToken !== credential.accessToken)}) and new refresh token (changed: ${String(refreshed.refreshToken !== credential.refreshToken)}); tools/list with the refreshed token returned ${JSON.stringify(refreshedToolNames)}.`,
refreshSound,
);
expect(staleRejected.status).toBe(401);
expect(mcpListAfterExpiry.exitCode, `opencode mcp list failed after forced expiry:\n${mcpListAfterExpiry.combined.slice(0, 1_000)}`).toBe(0);
expect(rotationSound, `Refresh did not rotate credentials: before=${credential.refreshToken.slice(0, 14)}… after=${refreshed.refreshToken.slice(0, 14)}…`).toBe(true);
expect(refreshedExpectedToolsPresent, `refreshed tools/list omitted a required agent tool: ${JSON.stringify(refreshedToolNames)}`).toBe(true);
// Claim 6 — logout removes the stored credential and opencode stops
// reporting the server as authenticated.
const logout = await runOpencode(home, ["mcp", "logout", MCP_NAME], "opencode mcp logout");
const authListAfterLogout = await runOpencode(home, ["mcp", "auth", "list"], "opencode mcp auth list after logout");
const storedAfterLogout = await readFile(home.authFilePath, "utf8").catch(() => "");
const logoutSound = logout.exitCode === 0
&& authListAfterLogout.exitCode === 0
&& !/\bopenwork\b.*\bauthenticated\b|✓ openwork/i.test(authListAfterLogout.combined.replace(/not authenticated/gi, ""))
&& !storedAfterLogout.includes("ow_mcp_rt_");
evidence.recordAssertionEvidence(
"Logout removes the stored OAuth credential and the server is no longer authenticated",
`opencode mcp logout exited ${String(logout.exitCode)}; auth list then said: ${authListAfterLogout.combined.trim().slice(0, 300)}; the credential store no longer contains a refresh token.`,
logoutSound,
);
expect(logout.exitCode, `opencode mcp logout failed:\n${logout.combined.slice(0, 1_000)}`).toBe(0);
expect(storedAfterLogout.includes("ow_mcp_rt_")).toBe(false);
expect(logoutSound, `Auth list still reports authenticated after logout:\n${authListAfterLogout.combined.slice(0, 1_000)}`).toBe(true);
});