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
13 changes: 10 additions & 3 deletions packages/adapters/fixtures/harness/opencode.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
{
"adapter": "opencode",
"harness_versions": null,
"verified_at": "2026-08-29",
"verified_at": "2026-09-06",
"command": "opencode",
"env_vars": ["ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "OPENAI_API_KEY", "OPENAI_BASE_URL"],
"env_vars": [
"ANTHROPIC_API_KEY",
"ANTHROPIC_BASE_URL",
"OPENAI_API_KEY",
"OPENAI_BASE_URL",
"OPENCODE_CONFIG_CONTENT",
"SHELL"
],
"base_urls": { "OPENAI_BASE_URL": "/v1", "ANTHROPIC_BASE_URL": "" },
"optional_env_vars": [],
"note": "Both origins are redirected because OpenCode picks its provider per model. Credentials pass through when the user has either one; the placeholders recorded here appear only when the user has neither, so recording never makes a provider look configured that is not."
"note": "Both origins are redirected because OpenCode picks its provider per model. The config overlay adds OPENCODE_CONFIG_CONTENT, pointing OpenCode's first-party providers (opencode, opencode-go) at the proxy with their real base encoded into the path — no environment variable names those origins. SHELL points at a run shim so the shell tool, which execs $SHELL by absolute path, is captured; the shim stands in for a shell OpenCode would resolve anyway. Credentials pass through when the user has either one; the placeholders recorded here appear only when the user has neither, so recording never makes a provider look configured that is not."
}
4 changes: 3 additions & 1 deletion packages/adapters/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
],
"dependencies": {
"@orcareplay/node-instrument": "0.1.2",
"@orcareplay/plugin-api": "0.1.2"
"@orcareplay/plugin-api": "0.1.2",
"@orcareplay/proxy": "0.1.2",
"@orcareplay/shell-shim": "0.1.2"
}
}
209 changes: 209 additions & 0 deletions packages/adapters/src/opencode-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import { readFile, stat } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';

/**
* What OpenCode's own configuration says about a provider's base URL.
*
* The adapter redirects OpenCode's first-party providers through the proxy by writing a
* `provider.<id>.options.baseURL` override, and OpenCode merges config sources in an order that
* puts that override last — over a base URL the user configured for the same provider. Overriding
* someone's deliberate routing would make the recorded run talk to a host they never named, which
* is the one capture bug worse than an empty trace. So the adapter reads the same files OpenCode
* reads and carries the configured base URL *through* the redirect instead of replacing it.
*
* The files are JSONC — comments and trailing commas are allowed and the user's own config uses
* both — so a small stripper runs before `JSON.parse`. A file that still will not parse marks the
* whole scan untrusted: without knowing what the user intended, the adapter must not touch their
* routing at all, and the run degrades to the pre-override behaviour (uncaptured, with the
* end-of-run warning saying so) rather than to a captured run aimed at the wrong origin.
*/

export interface OpenCodeConfigScan {
/** Provider id → the `options.baseURL` the user configured, when they configured one. */
overrides: Map<string, string>;
/** False when a config file existed but could not be parsed, so no override can be trusted. */
trusted: boolean;
}

/**
* Strip what JSONC allows and JSON does not: comments and trailing commas.
*
* Strings are copied verbatim with their escapes, because a config comment is prose that may
* contain `//` — a URL, say — and stripping inside a string would corrupt the value while the
* file still parsed. Trailing commas are removed only when the next significant character closes
* a value, which a comma inside a string never is.
*/
export function stripJsonc(text: string): string {
let out = '';
let i = 0;
while (i < text.length) {
const ch = text[i]!;
if (ch === '"') {
const end = stringEnd(text, i);
out += text.slice(i, end);
i = end;
continue;
}
if (ch === '/' && text[i + 1] === '/') {
while (i < text.length && text[i] !== '\n') i += 1;
continue;
}
if (ch === '/' && text[i + 1] === '*') {
const end = text.indexOf('*/', i + 2);
i = end === -1 ? text.length : end + 2;
out += ' ';
continue;
}
out += ch;
i += 1;
}
return stripTrailingCommas(out);
}

/** Index just past the closing quote of the string starting at `start`, or end of input. */
function stringEnd(text: string, start: number): number {
let i = start + 1;
while (i < text.length) {
const ch = text[i]!;
if (ch === '\\') {
i += 2;
continue;
}
if (ch === '"') return i + 1;
i += 1;
}
return text.length;
}

function stripTrailingCommas(text: string): string {
let out = '';
let i = 0;
while (i < text.length) {
const ch = text[i]!;
if (ch === '"') {
const end = stringEnd(text, i);
out += text.slice(i, end);
i = end;
continue;
}
if (ch === ',') {
let look = i + 1;
while (look < text.length && /\s/.test(text[look]!)) look += 1;
const next = text[look];
if (next === '}' || next === ']') {
i += 1;
continue;
}
}
out += ch;
i += 1;
}
return out;
}

/** Every `provider.<id>.options.baseURL` in one parsed config. */
function providerBaseURLs(config: unknown): Map<string, string> {
const out = new Map<string, string>();
if (config === null || typeof config !== 'object' || Array.isArray(config)) return out;
const provider = (config as Record<string, unknown>)['provider'];
if (provider === null || typeof provider !== 'object' || Array.isArray(provider)) return out;
for (const [id, entry] of Object.entries(provider as Record<string, unknown>)) {
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) continue;
const options = (entry as Record<string, unknown>)['options'];
if (options === null || typeof options !== 'object' || Array.isArray(options)) continue;
const baseURL = (options as Record<string, unknown>)['baseURL'];
if (typeof baseURL === 'string' && baseURL.trim() !== '') out.set(id, baseURL.trim());
}
return out;
}

/**
* The config files OpenCode reads that could set a provider base URL.
*
* Global, then `OPENCODE_CONFIG`, then project-level `.opencode` directories walking up the way
* OpenCode's own loader walks — bounded at the git root or the home directory, because above
* those, neither OpenCode nor anyone else looks. Order does not matter here: the scan collects
* every override it can see, and a later source winning the merge is the same override either way.
*/
async function openCodeConfigFiles(
env: Record<string, string | undefined>,
cwd: string,
): Promise<string[]> {
const files: string[] = [];
const globalDir =
readEnvValue(env, 'OPENCODE_CONFIG_DIR') ??
(readEnvValue(env, 'XDG_CONFIG_HOME') !== undefined
? join(readEnvValue(env, 'XDG_CONFIG_HOME')!, 'opencode')
: join(homeOf(env), '.config', 'opencode'));
for (const file of ['config.json', 'opencode.json', 'opencode.jsonc']) {
files.push(join(globalDir, file));
}

const custom = readEnvValue(env, 'OPENCODE_CONFIG');
if (custom !== undefined) files.push(custom);

const stop = new Set([homeOf(env)]);
let at = cwd;
for (let depth = 0; depth < 64; depth += 1) {
for (const file of ['opencode.json', 'opencode.jsonc']) {
files.push(join(at, '.opencode', file));
}
if (stop.has(at)) break;
if (await isDirectory(join(at, '.git'))) break;
const parent = at.slice(0, at.lastIndexOf('/'));
if (parent === '' || parent === at) break;
at = parent;
}
return files;
}

function homeOf(env: Record<string, string | undefined>): string {
return readEnvValue(env, 'HOME') ?? homedir();
}

function readEnvValue(env: Record<string, string | undefined>, name: string): string | undefined {
const value = env[name];
return value !== undefined && value !== '' ? value : undefined;
}

async function isDirectory(path: string): Promise<boolean> {
try {
return (await stat(path)).isDirectory();
} catch {
return false;
}
}

/**
* Read the base URLs the user's OpenCode configuration sets per provider.
*
* A missing file is not a failure — most of these do not exist. A file that exists and will not
* parse is: the adapter then knows less than it must to rewrite routing safely, and reports that
* by clearing `trusted`.
*/
export async function openCodeConfiguredBaseURLs(
env: Record<string, string | undefined>,
cwd: string,
): Promise<OpenCodeConfigScan> {
const overrides = new Map<string, string>();
let trusted = true;
for (const file of await openCodeConfigFiles(env, cwd)) {
let text: string;
try {
text = await readFile(file, 'utf8');
} catch {
continue;
}
if (text.trim() === '') continue;
let parsed: unknown;
try {
parsed = JSON.parse(stripJsonc(text));
} catch {
trusted = false;
continue;
}
for (const [id, url] of providerBaseURLs(parsed)) overrides.set(id, url);
}
return { overrides, trusted };
}
114 changes: 113 additions & 1 deletion packages/adapters/src/opencode.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { basename, join } from 'node:path';
import type { Adapter, Launch, RecordContext } from '@orcareplay/plugin-api';
import { forwardBasePath } from '@orcareplay/proxy';
import { resolveRealBinary } from '@orcareplay/shell-shim';
import { detectAgent, homeDirHas } from './detect.js';
import { openCodeConfiguredBaseURLs } from './opencode-config.js';
import { passKey, passThrough, proxyBase, readEnv } from './env.js';

/**
Expand All @@ -15,9 +19,108 @@ export function opencodeHasOwnAuth(): boolean {
return OPENCODE_AUTH_PATHS.some(homeDirHas);
}

/**
* OpenCode's first-party providers, and the base URL the models.dev catalog gives each.
*
* OpenCode resolves its API origin per model, and only the OpenAI and Anthropic origins can be
* named with an environment variable — so a run on one of these talked straight to its provider
* while the proxy saw nothing, and the trace came out empty while the agent answered happily.
* A config overlay (see `prepare`) points each at the proxy carrying its own destination, which
* is one mechanism for both and for any later first-party provider, at the cost of this table
* going stale: a provider added after it was written keeps bypassing capture the way it did
* before, which the end-of-run `capture.empty` warning is what says out loud.
*/
const OPENCODE_FIRST_PARTY_BASE: Record<string, string> = {
opencode: 'https://opencode.ai/zen/v1',
'opencode-go': 'https://opencode.ai/zen/go/v1',
};

/** The environment variable the overlay rides in on, and the one it must never clobber. */
const CONFIG_CONTENT_VAR = 'OPENCODE_CONFIG_CONTENT';

/**
* Shells whose real binary a shim can find again, from OpenCode's own acceptable set — the
* POSIX shells it runs commands with, minus the ones it refuses outright.
*/
const SHIMMABLE_SHELLS = ['zsh', 'bash', 'sh', 'ksh', 'dash'] as const;

/**
* The shell OpenCode falls back to when `SHELL` is unset or one it denies, which is what a
* recorded run should resolve through the shim so that it behaves like the unrecorded one.
*/
function fallbackShells(): string[] {
return process.platform === 'darwin' ? ['zsh', 'bash', 'sh'] : ['bash', 'sh'];
}

/**
* Route OpenCode's shell tool through the shim, by pointing `SHELL` at one.
*
* OpenCode resolves its shell from this variable and then execs it *by absolute path*, so the
* PATH shim in front of `bash` and `sh` never engaged: on macOS every command ran under
* `/bin/zsh` and the frames file stayed empty while the trace showed shell tool calls. The shim
* named after the real shell keeps every flag OpenCode passes (`-c`, and the login wrappers)
* byte-identical — it is argv-transparent — so the run behaves the same and the shim sees it.
*
* The name is resolved before the run starts, because a shim whose real binary cannot be found
* on PATH would answer every command 127 and break the run instead of under-recording it.
* `installShellShim` writes one shim per name in its default set; a name that is not there
* simply never gets picked, and the end-of-run `shell.ineffective` warning is what reports the
* layer as unused.
*/
async function shellThroughShim(
env: Record<string, string | undefined>,
runDir: string,
): Promise<string | undefined> {
if (process.platform === 'win32') return undefined;
const shimDir = join(runDir, 'shims');
const current = basename(readEnv(env, 'SHELL') ?? '').toLowerCase();
const candidates = SHIMMABLE_SHELLS.includes(current as (typeof SHIMMABLE_SHELLS)[number])
? [current, ...fallbackShells()]
: fallbackShells();
for (const name of new Set(candidates)) {
if ((await resolveRealBinary(name, env['PATH'] ?? '', shimDir)) !== undefined) {
return join(shimDir, name);
}
}
return undefined;
}

/**
* The config overlay that rewrites OpenCode's per-provider origins through the proxy.
*
* `provider.<id>.options.baseURL` is the one lever OpenCode honours over its catalog's origin,
* and `OPENCODE_CONFIG_CONTENT` is the config source merged last, so the overlay decides the
* final URL without touching the user's files. Each base URL is rewritten to
* `<proxy>/forward/<encoded base>`: the request arrives at the proxy naming where it was headed,
* the proxy records the exchange and forwards to that base — so an OpenAI-compatible provider
* whose origin is neither api.openai.com nor api.anthropic.com is captured, not bypassed.
*
* The user's own `options.baseURL` for the same provider is carried through rather than replaced.
* A config that could not be parsed clears the whole overlay: an unreadable intent is not a
* licence to reroute someone's provider. And an `OPENCODE_CONFIG_CONTENT` the user already set is
* relayed untouched — there is no way to merge two sources of one variable, and clobbering theirs
* to add capture would change more than the capture.
*/
async function baseURLsThroughProxy(ctx: RecordContext): Promise<Record<string, string>> {
// There is no way to merge two sources of one variable, and clobbering theirs to add capture
// would change more than the capture — so it is relayed exactly as it arrived.
const theirs = readEnv(ctx.env, CONFIG_CONTENT_VAR);
if (theirs !== undefined) return { [CONFIG_CONTENT_VAR]: theirs };
const scan = await openCodeConfiguredBaseURLs(ctx.env, ctx.cwd);
if (!scan.trusted) return {};
const provider: Record<string, { options: { baseURL: string } }> = {};
for (const [id, catalogBase] of Object.entries(OPENCODE_FIRST_PARTY_BASE)) {
const base = scan.overrides.get(id) ?? catalogBase;
provider[id] = { options: { baseURL: `${proxyBase(ctx.proxyUrl)}${forwardBasePath(base)}` } };
}
return { [CONFIG_CONTENT_VAR]: JSON.stringify({ provider }) };
}

/**
* OpenCode picks its provider per model, so both origins are redirected: whichever protocol the
* chosen model speaks, the traffic lands on the proxy.
* chosen model speaks, the traffic lands on the proxy. Providers whose origin is neither of the
* two — OpenCode's own first-party ones, most visibly — are redirected by the config overlay
* below, because no environment variable can name their origin for them.
*/
export const openCodeAdapter: Adapter = {
id: 'opencode',
Expand Down Expand Up @@ -52,6 +155,15 @@ export const openCodeAdapter: Adapter = {
passKey(env, ctx.env, 'OPENAI_API_KEY');
passKey(env, ctx.env, 'ANTHROPIC_API_KEY');
}
Object.assign(env, await baseURLsThroughProxy(ctx));

// `SHELL` overrides the user's own only to a shim standing in for a shell OpenCode would have
// picked anyway. With `--no-shell` there is no shim directory, OpenCode's own resolution
// finds nothing there and falls back exactly as it would unrecorded — so the wrong variable
// costs nothing, and the right one is what makes the frames file non-empty.
const shell = await shellThroughShim(ctx.env, ctx.runDir);
if (shell !== undefined) env['SHELL'] = shell;

return { command: 'opencode', args: [...ctx.userArgs], env };
},
};
Loading