Skip to content

Commit 27f577a

Browse files
fix(opencode): read /api/models with the admin token, not the admission key
1 parent 7a0513c commit 27f577a

2 files changed

Lines changed: 116 additions & 1 deletion

File tree

src/cli/opencode.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import type {
4040
OpencodeV2ProviderBlock,
4141
} from "../clients/config-export";
4242
import { filterCatalogVisibleModels, visibleNativeSlugs } from "../codex/catalog";
43+
import { configuredAdminToken } from "../lib/admin-secrets";
4344
import { commandInvocation } from "../lib/win-exec";
4445
import { loadServiceTokenFromFile, serviceApiTokenFilePath } from "../lib/service-secrets";
4546
import { providerCodexAccountMode } from "../providers/registry";
@@ -602,6 +603,19 @@ export function opencodeApiKey(config: OcxConfig, env: OpencodeLaunchEnv = proce
602603
return config.apiKeys?.[0]?.key || "ocx";
603604
}
604605

606+
/**
607+
* Credential for the launcher's `GET /api/models` read.
608+
*
609+
* That route is a management route, so `requireManagementAuth` only admits the admin credential —
610+
* the data-plane admission key {@link opencodeApiKey} returns for the child process is refused there
611+
* with `opencodex admin token required`. Prefer the configured admin token, the same credential every
612+
* other headless management caller sends (`runningProxyUpdateHeaders`), and keep the admission key as
613+
* the fallback for a host that has no admin token configured.
614+
*/
615+
export function opencodeManagementToken(config: OcxConfig, env: OpencodeLaunchEnv = process.env): string {
616+
return configuredAdminToken(undefined, env) ?? opencodeApiKey(config, env);
617+
}
618+
605619
async function ensureProxyForOpencode(config: OcxConfig): Promise<LiveProxy | null> {
606620
const live = await findLiveProxy();
607621
if (live) return live;
@@ -650,9 +664,10 @@ export async function cmdOpencode(args: string[]): Promise<number> {
650664
}
651665

652666
const apiKey = opencodeApiKey(startupConfig);
667+
const managementToken = opencodeManagementToken(startupConfig);
653668
let proxyModels: OpencodeProxyModelRow[];
654669
try {
655-
proxyModels = await fetchOpencodeProxyModels(live, apiKey);
670+
proxyModels = await fetchOpencodeProxyModels(live, managementToken);
656671
} catch (error) {
657672
const reason = error instanceof Error ? error.message : String(error);
658673
console.error(`❌ Could not fetch the model catalog from the proxy: ${reason}`);

tests/providers/opencode-cli.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
opencodeCatalogFromProxyRows,
2626
opencodeGlobalConfigPath,
2727
opencodeLaunchNativeSlugs,
28+
opencodeManagementToken,
2829
opencodeModelKey,
2930
opencodeNotFoundHint,
3031
opencodeProviderOverridePath,
@@ -718,6 +719,105 @@ describe("ocx opencode admission key", () => {
718719
});
719720
});
720721

722+
describe("ocx opencode management token", () => {
723+
// GET /api/models is a management route, so the launcher must present the admin credential there;
724+
// sending the data-plane admission key is what produced "opencodex admin token required" (401).
725+
const TOUCHED = ["OPENCODEX_ADMIN_AUTH_TOKEN", "OPENCODEX_HOME", "OPENCODEX_API_AUTH_TOKEN", "OCX_API_TOKEN_FILE"] as const;
726+
727+
function withEnv(overrides: Partial<Record<(typeof TOUCHED)[number], string>>, run: () => void): void {
728+
const saved = new Map<string, string | undefined>(TOUCHED.map(key => [key, process.env[key]]));
729+
try {
730+
for (const key of TOUCHED) delete process.env[key];
731+
for (const [key, value] of Object.entries(overrides)) process.env[key] = value;
732+
run();
733+
} finally {
734+
for (const [key, value] of saved) {
735+
if (value === undefined) delete process.env[key];
736+
else process.env[key] = value;
737+
}
738+
}
739+
}
740+
741+
const adminToken = `ocx_admin_${"a".repeat(43)}`;
742+
743+
test("the configured admin token wins over the admission key", () => {
744+
withEnv({}, () => {
745+
const config = cfg({ apiKeys: [{ id: "1", name: "main", key: "sk-cfg", createdAt: "2026-01-01" }] });
746+
expect(opencodeManagementToken(config, { OPENCODEX_ADMIN_AUTH_TOKEN: adminToken })).toBe(adminToken);
747+
});
748+
});
749+
750+
test("falls back to the admin-api-token file in OPENCODEX_HOME", () => {
751+
const dir = mkdtempSync(join(tmpdir(), "ocx-opencode-admin-"));
752+
writeFileSync(join(dir, "admin-api-token"), `${adminToken}\n`, { mode: 0o600 });
753+
try {
754+
withEnv({ OPENCODEX_HOME: dir }, () => {
755+
const config = cfg({ apiKeys: [{ id: "1", name: "main", key: "sk-cfg", createdAt: "2026-01-01" }] });
756+
expect(opencodeManagementToken(config, { OPENCODEX_ADMIN_AUTH_TOKEN: " " })).toBe(adminToken);
757+
});
758+
} finally {
759+
removeTreeWithRetry(dir);
760+
}
761+
});
762+
763+
test("falls back to the admission key when no admin token is configured", () => {
764+
// An explicit empty OPENCODEX_HOME keeps this independent of any admin token the runner's
765+
// sandbox home may already carry.
766+
const dir = mkdtempSync(join(tmpdir(), "ocx-opencode-no-admin-"));
767+
try {
768+
withEnv({ OPENCODEX_HOME: dir }, () => {
769+
const config = cfg({ apiKeys: [{ id: "1", name: "main", key: "sk-cfg", createdAt: "2026-01-01" }] });
770+
expect(opencodeManagementToken(config, {})).toBe("sk-cfg");
771+
});
772+
} finally {
773+
removeTreeWithRetry(dir);
774+
}
775+
});
776+
777+
test("cmdOpencode sends the admin token — not the admission key — on GET /api/models", async () => {
778+
const home = mkdtempSync(join(tmpdir(), "ocx-opencode-admin-read-"));
779+
const envKeys = [...TOUCHED, "CODEX_HOME", "XDG_CONFIG_HOME", OPENCODE_CONFIG_CONTENT_ENV];
780+
const previous = Object.fromEntries(envKeys.map(key => [key, process.env[key]]));
781+
const liveness = await import("../../src/server/proxy-liveness");
782+
const finder = spyOn(liveness, "findLiveProxy").mockResolvedValue({
783+
port: 10123, hostname: "127.0.0.1", pid: null, source: "config",
784+
});
785+
let sentKey: string | null = null;
786+
const fetcher = spyOn(globalThis, "fetch").mockImplementation(async (input, init) => {
787+
expect(String(input)).toBe("http://127.0.0.1:10123/api/models");
788+
sentKey = new Headers(init?.headers).get("X-OpenCodex-API-Key");
789+
return Response.json([]);
790+
});
791+
const spawn = spyOn(childProcess, "spawn").mockImplementation(() => {
792+
const child = new childProcess.ChildProcess();
793+
queueMicrotask(() => child.emit("exit", 0, null));
794+
return child;
795+
});
796+
const stderr = spyOn(console, "error").mockImplementation(() => {});
797+
try {
798+
for (const key of envKeys) delete process.env[key];
799+
process.env.OPENCODEX_HOME = home;
800+
process.env.CODEX_HOME = join(home, "codex");
801+
process.env.XDG_CONFIG_HOME = join(home, "xdg");
802+
process.env.OPENCODEX_ADMIN_AUTH_TOKEN = adminToken;
803+
mkdirSync(process.env.CODEX_HOME);
804+
writeFileSync(join(home, "config.json"), JSON.stringify(
805+
cfg({ apiKeys: [{ id: "1", name: "main", key: "sk-cfg", createdAt: "2026-01-01" }] }),
806+
));
807+
expect(await cmdOpencode([])).toBe(0);
808+
expect(sentKey).toBe(adminToken);
809+
expect(sentKey).not.toBe("sk-cfg");
810+
} finally {
811+
finder.mockRestore(); fetcher.mockRestore(); spawn.mockRestore(); stderr.mockRestore();
812+
for (const [key, value] of Object.entries(previous)) {
813+
if (value === undefined) delete process.env[key];
814+
else process.env[key] = value;
815+
}
816+
removeTreeWithRetry(home);
817+
}
818+
});
819+
});
820+
721821
describe("ocx opencode proxy auto-start env", () => {
722822
test("passes OCX_API_TOKEN_FILE to ocx start when only the hardened service token exists", () => {
723823
const dir = mkdtempSync(join(tmpdir(), "ocx-opencode-start-"));

0 commit comments

Comments
 (0)