Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
06ec553
Merge pull request #3678 from lidge-jun/codex/promote-main-243-01a07240
lidge-jun Sep 5, 2026
116c2ac
Merge commit '44ea9576e27c6be8be7f13a86e32bb349368c54d' into codex/re…
invalid-email-address Sep 6, 2026
07b48da
Merge pull request #3785 from lidge-jun/codex/release-244-main-07c0
lidge-jun Sep 6, 2026
bcdf559
chore(release): promote validated 2.45.0 to main [skip ci]
invalid-email-address Sep 6, 2026
b0900e5
chore(release): promote 2.45.0 to main (#3813)
lidge-jun Sep 6, 2026
3970601
chore(release): prepare 2.46.0 stable promotion
invalid-email-address Sep 7, 2026
bba6322
Merge pull request #3851 from lidge-jun/codex/release-246-main
lidge-jun Sep 7, 2026
3d53e5f
release: prepare 2.47.0 from audited regression candidate
invalid-email-address Sep 7, 2026
eda8754
Merge commit '48ab3e1e66cfa6e0c873de2fafa4540ac61d6c7d' into codex/re…
invalid-email-address Sep 7, 2026
f9e3515
Merge commit '57252193b' into codex/release-247-main
invalid-email-address Sep 7, 2026
6f71931
release: promote 2.47.0 to main (#3929)
lidge-jun Sep 7, 2026
9a60256
Merge commit 'd0737cff3' into codex/release-247-main-final
invalid-email-address Sep 7, 2026
9e9b1d3
Merge commit 'f48c322c0' into codex/release-247-main-final
invalid-email-address Sep 7, 2026
947bae9
Merge commit '0d7652ad1' into codex/release-247-main-final
invalid-email-address Sep 7, 2026
f7f890f
release: apply final roster correction to main (#3933)
lidge-jun Sep 7, 2026
544ebee
release: promote 2.48.0 to main
invalid-email-address Sep 8, 2026
d24ff57
release: set main channel version 2.48.0
invalid-email-address Sep 8, 2026
9a27e86
Merge pull request #4011 from lidge-jun/codex/release-248-main
lidge-jun Sep 8, 2026
62849df
release: promote verified 2.49.0 product tree to main
lidge-jun Sep 9, 2026
2f3f736
Merge pull request #4117 from lidge-jun/codex/release-249-main-01a08498
lidge-jun Sep 9, 2026
3a3de88
release: promote verified 2.50.0 product tree to main
lidge-jun Sep 10, 2026
2d4d7a2
Merge pull request #4195 from lidge-jun/codex/release-250-main-01a08a81
lidge-jun Sep 10, 2026
cf456e8
release: promote verified 2.51.0 product tree to main
lidge-jun Sep 11, 2026
c155cc7
Merge pull request #4271 from lidge-jun/codex/release-251-main
lidge-jun Sep 11, 2026
95c4875
release: promote verified 2.52.0 product tree to main
lidge-jun Sep 12, 2026
4d37c35
Merge pull request #4407 from lidge-jun/codex/release-2520-main
lidge-jun Sep 12, 2026
641b05a
release: promote verified 2.53.0 product tree to main
lidge-jun Sep 13, 2026
aa05b3e
Merge pull request #4507 from lidge-jun/codex/release-2530-main
lidge-jun Sep 13, 2026
8e532c5
release: promote verified 2.54.0 product tree to main
lidge-jun Sep 13, 2026
9f7397e
Merge pull request #4540 from lidge-jun/codex/release-2540-main
lidge-jun Sep 13, 2026
5575d5c
fix(opencode): scope catalog credential
luvs01 Sep 14, 2026
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
4 changes: 2 additions & 2 deletions src/cli/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import type {
} from "../clients/config-export";
import { filterCatalogVisibleModels, visibleNativeSlugs } from "../codex/catalog";
import { commandInvocation } from "../lib/win-exec";
import { configuredAdminToken } from "../lib/admin-secrets";
import { configuredAdminToken, opencodeCatalogToken } from "../lib/admin-secrets";
import { localManagementOrigin } from "../lib/local-destinations";
import { directLocalHttpFetch } from "../server/direct-local-http";
import { loadServiceTokenFromFile, serviceApiTokenFilePath } from "../lib/service-secrets";
Expand Down Expand Up @@ -684,7 +684,7 @@ export async function cmdOpencode(args: string[]): Promise<number> {
try {
const managementToken = configuredAdminToken();
if (!managementToken) throw new Error("No local admin token is available; check the running proxy's home.");
proxyModels = await fetchOpencodeProxyModels(live, managementToken, {
proxyModels = await fetchOpencodeProxyModels(live, opencodeCatalogToken(managementToken), {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle pre-catalog-token proxies explicitly

When the CLI has been upgraded but a proxy from before this commit is still running, ensureProxyForOpencode accepts that live proxy regardless of its reported version, but the old management gate recognizes only the raw admin token. Sending only this newly derived token therefore makes ocx opencode fail with 401 until the operator happens to restart the proxy. Detect the protocol/version mismatch and direct the operator to restart, without retrying with the reusable admin token.

AGENTS.md reference: src/AGENTS.md:L10-L10

Useful? React with 👍 / 👎.

managementOrigin: localManagementOrigin({ ...startupConfig, hostname: live.hostname }, live.port),
});
} catch (error) {
Expand Down
10 changes: 9 additions & 1 deletion src/lib/admin-secrets.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { timingSafeEqual } from "node:crypto";
import { createHmac, timingSafeEqual } from "node:crypto";
import { lstatSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { getConfigDir } from "../config";
Expand Down Expand Up @@ -27,6 +27,14 @@ export function configuredAdminToken(configDir = getConfigDir(), env: NodeJS.Pro

export const ADMIN_TOKEN_PREFIX = "ocx_admin_";

/**
* Derive the bearer used by the OpenCode launcher for its one management read.
* A listener that captures this value cannot recover or replay the administrator token.
*/
export function opencodeCatalogToken(adminToken: string): string {
return `ocx_catalog_${createHmac("sha256", adminToken).update("opencode:/api/models:v1").digest("base64url")}`;
}

function secretTextEquals(left: string, right: string): boolean {
const a = Buffer.from(left);
const b = Buffer.from(right);
Expand Down
16 changes: 15 additions & 1 deletion src/server/management-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
writeFileSync,
} from "node:fs";
import { dirname, join } from "node:path";
import { adminApiTokenFilePath } from "../lib/admin-secrets";
import { adminApiTokenFilePath, opencodeCatalogToken } from "../lib/admin-secrets";
import {
LOCAL_MANAGEMENT_CAPABILITY_HEADER,
LOCAL_MANAGEMENT_CAPABILITY_EXPIRES_AT_HEADER,
Expand Down Expand Up @@ -297,6 +297,7 @@ export function createManagementSessionControl(state: ManagementAuthState): Mana
*/
export type ManagementPrincipal =
| "admin-token"
| "opencode-catalog-token"
| "gui-session"
| "gui-pair-capability"
| "local-read-capability"
Expand Down Expand Up @@ -484,6 +485,16 @@ function requestManagementCredential(req: Request): string | null {
|| null;
}

function isOpencodeCatalogRequest(req: Request): boolean {
if (req.method !== "GET") return false;
try {
const url = new URL(req.url);
return url.pathname === "/api/models" && url.search === "";
} catch {
return false;
}
}

function resolveManagementAdmission(
req: Request,
state: ManagementAuthState,
Expand All @@ -500,6 +511,9 @@ function resolveManagementAdmission(
else if (state.available) {
const actual = requestManagementCredential(req);
if (actual && equalSecret(actual, state.token)) principal = "admin-token";
else if (actual && isOpencodeCatalogRequest(req) && equalSecret(actual, opencodeCatalogToken(state.token))) {
principal = "opencode-catalog-token";
}
else if (config && authorizeGuiSessionRequest(req, config, state).ok) principal = "gui-session";
}
if (principal) admittedManagementRequests.set(req, principal);
Expand Down
2 changes: 1 addition & 1 deletion structure/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,7 @@ Account quota surfaces use [safe probe diagnostics](transports/inventory.md#acco

Translated Chat request construction uses the [inline-image budget](transports/streaming-health.md#translated-chat-inline-image-budget); the shared normalizer counts retained bytes even when a wire-specific drop callback keeps the image attached.

OpenCode catalog discovery in `src/cli/opencode.ts` uses the local admin credential and a validated numeric-loopback management origin. It dials through `src/server/direct-local-http.ts`, rejects redirects and preserves the request/body deadline. Hub ingress selection stays separate from exported inference settings.
OpenCode catalog discovery in `src/cli/opencode.ts` derives a catalog-only bearer from the local admin credential and uses a validated numeric-loopback management origin. `src/server/management-auth.ts` accepts that derived bearer only for the exact `GET /api/models` read, so a spoofed listener cannot capture reusable administrator authority. The launcher dials through `src/server/direct-local-http.ts`, rejects redirects and preserves the request/body deadline. Hub ingress selection stays separate from exported inference settings.

The [explicit model-capability contract](config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior.

Expand Down
3 changes: 2 additions & 1 deletion tests/providers/opencode-cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, spyOn, test } from "bun:test";
import * as directHttp from "../../src/server/direct-local-http";
import { opencodeCatalogToken } from "../../src/lib/admin-secrets";
import * as childProcess from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
Expand Down Expand Up @@ -263,7 +264,7 @@ describe("ocx opencode proxy model catalog", () => {
port: 10123, hostname: "127.0.0.1", pid: null, source: "config",
});
const fetcher = spyOn(directHttp, "directLocalHttpFetch").mockImplementation(async (input, init) => {
expect(new Headers(init?.headers).get("x-opencodex-api-key")).toBe("fixture-admin-token");
expect(new Headers(init?.headers).get("x-opencodex-api-key")).toBe(opencodeCatalogToken("fixture-admin-token"));
expect(String(input)).toBe("http://127.0.0.1:10123/api/models");
expect(JSON.parse(readFileSync(configPath, "utf8")).providers.pending.initialModelSelection.status).toBe("pending");
writeFileSync(configPath, JSON.stringify(ready));
Expand Down
20 changes: 19 additions & 1 deletion tests/providers/opencode-management-transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import * as liveness from "../../src/server/proxy-liveness";
import { buildOpencodeEnv, buildOpencodeProviderBlocksFromCatalog, cmdOpencode, fetchOpencodeProxyModels } from "../../src/cli/opencode";
import { OPENCODE_API_KEY_ENV } from "../../src/clients/config-export";
import type { OcxConfig } from "../../src/types";
import { opencodeCatalogToken } from "../../src/lib/admin-secrets";
import { requireManagementAuth, type ManagementAuthState } from "../../src/server/management-auth";
import { removeTreeWithRetry } from "../helpers/remove-tree";
import { SERVER_BUDGET_MS } from "../helpers/test-budget";

Expand Down Expand Up @@ -113,7 +115,7 @@ test.each(["environment", "file", "missing", "unauthorized", "redirect", "ingres
const finder = spyOn(liveness, "findLiveProxy").mockResolvedValue({ port: 10123, hostname: mode === "ingress" ? "192.0.2.1" : "127.0.0.1", pid: null, source: "config" });
const request = spyOn(directHttp, "directLocalHttpFetch").mockImplementation(async (input, init) => {
expect(String(input)).toBe(`http://127.0.0.1:${mode === "ingress" ? 10124 : 10123}/api/models`);
expect(new Headers(init?.headers).get("x-opencodex-api-key")).toBe(mode === "file" ? fileAdmin : admin);
expect(new Headers(init?.headers).get("x-opencodex-api-key")).toBe(opencodeCatalogToken(mode === "file" ? fileAdmin : admin));
return mode === "unauthorized" ? new Response(null, { status: 401 }) : mode === "redirect" ? new Response(null, { status: 302 }) : Response.json(rows);
});
let childEnv: NodeJS.ProcessEnv | undefined;
Expand All @@ -138,6 +140,22 @@ test.each(["environment", "file", "missing", "unauthorized", "redirect", "ingres
} finally { err.mockRestore(); spawn.mockRestore(); request.mockRestore(); finder.mockRestore(); }
});

test("captured catalog credential cannot authorize another management route", () => {
const state: ManagementAuthState = {
available: true,
token: admin,
source: "environment",
sessions: new Map(),
pairingGrants: new Map(),
};
const credential = opencodeCatalogToken(admin);
const headers = { "x-opencodex-api-key": credential };
expect(requireManagementAuth(new Request("http://127.0.0.1/api/models", { headers }), state)).toBeNull();
expect(requireManagementAuth(new Request("http://127.0.0.1/api/models?extra=1", { headers }), state)?.status).toBe(401);
expect(requireManagementAuth(new Request("http://127.0.0.1/api/config", { headers }), state)?.status).toBe(401);
expect(requireManagementAuth(new Request("http://127.0.0.1/api/stop", { method: "POST", headers }), state)?.status).toBe(401);
});

test("case-insensitive inherited admin names are removed without changing other child variables", () => {
const blocks = buildOpencodeProviderBlocksFromCatalog(12345, [], undefined, config);
const env = buildOpencodeEnv(blocks, dataKey, { opencodex_admin_auth_token: admin, KEEP: "value" });
Expand Down
Loading