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
4 changes: 4 additions & 0 deletions docs-site/src/content/docs/guides/remote-hub.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ listener, not the management port. Local clients use the configured loopback com
service definition, support bundle, screenshot, or command line.
- The data admission token is delivered through the owner-only `service-api-token` file or
`OCX_API_TOKEN_FILE`. It is not a management credential.
- A configured client's `allowedModels` must include both the executor and any separately billed
Advisor model used by managed native Anthropic Messages. Bare model IDs and the hub's configured
`provider/model` names are accepted. A disallowed or unnamed Advisor destination returns HTTP 403
before provider dispatch; provider-only and unrestricted keys retain their existing scope.
- A raw management admin token can perform ordinary administration, but it cannot mint a browser
session or authorize consent-bearing actions such as starring the repository. Those actions
require a server-issued `gui-session`, matching browser origin, and CSRF token.
Expand Down
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"version": 1,
"root": "tests",
"explicit": {
"messages-native-scope.test.ts": "claude-integration",
"cli-log-view-filter.test.ts": "cli", "cli-usage-model-search.test.ts": "cli", "cli-companion-usage.test.ts": "cli", "cli-account-key-quota.test.ts": "cli",
"cli-observe-snapshot.test.ts": "cli", "cli-access-data-plane.test.ts": "cli", "cli-access-key-guard.test.ts": "cli", "cli-access-rename.test.ts": "cli",
"cli-access-audio.test.ts": "cli", "cli-access-audio-live.test.ts": "cli", "cli-log-follow.test.ts": "cli", "cli-injection-follow.test.ts": "cli",
Expand Down
1 change: 1 addition & 0 deletions src/server/claude-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1205,6 +1205,7 @@ async function handleClaudeMessagesWithBudget(
return await handleNativeMessages({
req, config, logCtx, ...(logIds ? { logIds } : {}),
route: nativeMessagesRoute, body: nativeBody, requestedModel, translatorBudget, selector: nativeSelector,
modelScope: resolveAdmissionModelScope(config, logIds?.admission),
// Compatibility identity is an opaque request-local handle, separate from credentials.
clientIdentity: captureAnthropicClientIdentity(req.headers),
callerAnthropicBeta: req.headers.get("anthropic-beta"),
Expand Down
57 changes: 57 additions & 0 deletions src/server/messages-native-scope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import {
AdmissionModelDeniedError, routeAllowedByScope, UNNAMED_DESTINATION_MODEL, type AdmissionModelScope,
} from "./admission-model-scope";

type Rec = Record<string, unknown>;
const isRec = (value: unknown): value is Rec => value !== null && typeof value === "object" && !Array.isArray(value);
const isAdvisor = (tool: Rec): boolean => typeof tool.type === "string" && /^advisor_\d{8}$/.test(tool.type);

/** Only executable declarations are policy inputs; schemas, arguments and results stay opaque. */
export function nativeMessagesToolScopeDenial(
scope: AdmissionModelScope | undefined,
providerName: string,
requestedModel: string,
body: Readonly<Rec>,
): AdmissionModelDeniedError | undefined {
if (!scope || scope.models.length === 0) return undefined;
const definitions = new Map<string, Rec[]>();
const withdrawn = new Set<string>();
const anonymous: Rec[] = [];
if (Array.isArray(body.tools)) for (const tool of body.tools) {
if (!isRec(tool)) continue;
if (typeof tool.name !== "string") { anonymous.push(tool); continue; }
const declarations = definitions.get(tool.name) ?? [];
declarations.push(tool);
definitions.set(tool.name, declarations);
}
if (Array.isArray(body.messages)) for (const message of body.messages) {
if (!isRec(message) || message.role !== "system" || !Array.isArray(message.content)) continue;
// Upstream rejects tool changes in turn-scoped messages; do not depend on that. An addition
// always counts, and only a permanent removal withdraws a declaration (fail closed).
const permanent = message.clear_at === undefined || message.clear_at === "never";
for (const block of message.content) {
if (!isRec(block) || !isRec(block.tool)) continue;
const tool = block.tool;
if (block.type === "tool_addition" && tool.type === "tool_definition" && isRec(tool.definition)) {
const definition = tool.definition;
if (typeof definition.name !== "string") { anonymous.push(definition); continue; }
// A temporary replacement keeps the declaration it shadows in scope as well.
definitions.set(definition.name, permanent ? [definition] : [...definitions.get(definition.name) ?? [], definition]);
withdrawn.delete(definition.name);
} else if (tool.type === "tool_reference" && typeof tool.name === "string") {
if (block.type === "tool_removal" && permanent) withdrawn.add(tool.name);
if (block.type === "tool_addition") withdrawn.delete(tool.name);
}
}
}
// Deferred definitions can still be loaded by tool search in the current turn.
const active = [...anonymous, ...[...definitions].flatMap(([name, tools]) => withdrawn.has(name) ? [] : tools)];
for (const tool of active) {
if (!isAdvisor(tool)) continue;
const modelId = typeof tool.model === "string" && tool.model.trim() ? tool.model : undefined;
if (modelId === undefined || !routeAllowedByScope(scope, { providerName, modelId })) {
return new AdmissionModelDeniedError(requestedModel, { providerName, modelId: modelId ?? UNNAMED_DESTINATION_MODEL });
}
}
return undefined;
}
9 changes: 9 additions & 0 deletions src/server/messages-native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ import { linkAbortSignal } from "./responses/core-lifetime";
import { attachRequestSpendTracker } from "./responses/request-spend";
import { workflowRefusalResponse } from "./workflow-refusal";
import { sseFieldValue } from "../lib/sse-decoder";
import { admissionModelDeniedResponse, type AdmissionModelScope } from "./admission-model-scope";
import { nativeMessagesToolScopeDenial } from "./messages-native-scope";

export {
isNativeMessagesRouteEligible,
Expand Down Expand Up @@ -159,6 +161,7 @@ export interface HandleNativeMessagesOptions {
callerAnthropicBeta?: string | null;
clientIdentity?: AnthropicClientIdentity;
sessionKey?: string | null;
modelScope?: AdmissionModelScope;
}

type FinishLog = (status: number, message?: string, meta?: FinalRequestLogMeta) => void;
Expand Down Expand Up @@ -350,6 +353,12 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
return null;
};

const toolScopeDenial = nativeMessagesToolScopeDenial(options.modelScope, route.providerName, requestedModel, body);
if (toolScopeDenial) {
finishLog(403, toolScopeDenial.message);
return admissionModelDeniedResponse(toolScopeDenial);
}

try {
await prepareNativeBody(body, req.signal);
} catch (error) {
Expand Down
12 changes: 12 additions & 0 deletions structure/data-planes/protocol-paths.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,18 @@ and returns before this point; the two branches share no credential and no heade
is `envelope.freshBody()` when a source envelope exists, otherwise the ingress's own body.
`src/server/messages-native.ts` is imported lazily, only for an eligible route.

Configured admission-key model scope also applies to separately billed Advisor models before
managed native key or OAuth dispatch. `src/server/messages-native-scope.ts` resolves active
top-level and system-message tool declarations, including replacement, removal and re-offer;
deferred definitions remain scoped because tool search can load them. Missing Advisor model
identity cannot satisfy a model list. Anthropic rejects tool changes in a turn-scoped system
message (`clear_at` other than `never`); the scope check does not rely on that refusal. Additions
in such a message are always checked, and only a permanent removal withdraws a declaration.
Ordinary custom schemas, arguments and historical tool
results remain opaque. Provider-only and unrestricted admissions retain their existing scope.
`tests/claude-integration/messages-native-scope.test.ts` covers refusal with zero upstream sends
and byte-preserving allowed tool definitions through both credential lanes.

`buildAnthropicMessagesPassthroughRequest` in `src/adapters/anthropic/passthrough.ts` builds the
request from that body: the top-level allowlist (`model, messages, system, max_tokens, metadata,
stop_sequences, stream, temperature, top_p, top_k, tools, tool_choice, thinking, output_config,
Expand Down
170 changes: 170 additions & 0 deletions tests/claude-integration/messages-native-scope.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { saveConfig } from "../../src/config";
import { saveCredential } from "../../src/oauth/store";
import { clearAnthropicAccountPoolState, forgetAnthropicFailoverQuorum } from "../../src/oauth/anthropic-routing";
import { handleClaudeMessages } from "../../src/server/claude-messages";
import type { OcxConfig, OcxProviderConfig } from "../../src/types";
import { acquireOwnedSpendHome } from "../helpers/owned-spend-home";
import { removeTreeWithRetry } from "../helpers/remove-tree";

type Rec = Record<string, unknown>;
// This pair supports both Advisor and inline system-message tool changes.
const EXECUTOR = "claude-sonnet-5-5";
const ADVISOR = "claude-opus-5-5";
const advisor = (model: unknown = ADVISOR): Rec => ({ type: "advisor_20260301", name: "advisor", model });
const custom = { name: "lookup", input_schema: { type: "object", properties: {} } };
const user = { role: "user", content: "fixture question" };
const system = (type: string, tool: Rec): Rec => ({ role: "system", content: [{ type, tool }] });
const addition = (definition: Rec): Rec => system("tool_addition", { type: "tool_definition", definition });
const reference = (type: string): Rec => system(type, { type: "tool_reference", name: "advisor" });

let home: string;
let oldHome: string | undefined;
let originalFetch: typeof fetch;
let release: (() => void) | undefined;
let sent: Rec[];

beforeEach(() => {
oldHome = process.env.OPENCODEX_HOME;
home = mkdtempSync(join(tmpdir(), "ocx-native-scope-"));
process.env.OPENCODEX_HOME = home;
release = acquireOwnedSpendHome();
clearAnthropicAccountPoolState();
forgetAnthropicFailoverQuorum();
sent = [];
originalFetch = globalThis.fetch;
globalThis.fetch = (async () => { throw new Error("unexpected real transport"); }) as typeof fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
release?.();
clearAnthropicAccountPoolState();
forgetAnthropicFailoverQuorum();
if (oldHome === undefined) delete process.env.OPENCODEX_HOME;
else process.env.OPENCODEX_HOME = oldHome;
removeTreeWithRetry(home);
});

async function send(body: Rec, options: {
oauth?: boolean; models?: string[]; providers?: string[]; configured?: boolean;
} = {}) {
const name = options.oauth ? "anthropic" : "operator-anth";
if (options.oauth) await saveCredential("anthropic", {
access: "synthetic-advisor-scope-access", refresh: "synthetic-advisor-scope-refresh",
expires: Date.now() + 3_600_000, accountId: "synthetic-advisor-scope-account", source: "oauth",
});
const transport = (async (_input, init) => {
expect(new Headers(init?.headers).get("anthropic-beta")).toContain("advisor-tool-2026-03-01");
sent.push(JSON.parse(String(init?.body)));
return Response.json({ id: "msg_scope", type: "message", role: "assistant", model: EXECUTOR,
content: [{ type: "text", text: "fixture reply" }], stop_reason: "end_turn",
usage: { input_tokens: 2, output_tokens: 1 } });
}) as typeof fetch;
const provider: OcxProviderConfig & { fetch: typeof fetch } = {
adapter: "anthropic", baseUrl: "https://api.anthropic.com", models: [EXECUTOR],
authMode: options.oauth ? "oauth" : "key", apiKey: "synthetic-advisor-scope-key", fetch: transport,
headers: { "anthropic-beta": "advisor-tool-2026-03-01,inline-tools-2026-09-15,mid-conversation-system-clear-at-2026-08-21" },
};
const config = { port: 0, defaultProvider: name, providers: { [name]: provider },
anthropicAccountPool: { enabled: false },
protocols: { rollout: { managedMessagesNative: true, managedMessagesNativeOAuth: true } },
apiKeys: [{ id: "scoped", name: "fixture", key: "ocx_data_" + "b".repeat(40), createdAt: "2026-01-01T00:00:00.000Z",
allowedModels: options.models ?? [EXECUTOR], allowedProviders: options.providers ?? [] }],
} as OcxConfig;
saveConfig(config);
const source = { model: `${name}/${EXECUTOR}`, max_tokens: 64, stream: false, messages: [user], ...body };
const snapshot = JSON.stringify(source);
const response = await handleClaudeMessages(new Request("http://localhost/v1/messages", {
method: "POST", headers: { "content-type": "application/json", "anthropic-beta": "advisor-tool-2026-03-01,inline-tools-2026-09-15" },
body: snapshot,
}), config, { model: "", provider: "" }, { requestId: crypto.randomUUID(), start: Date.now(),
admission: options.configured === false ? { kind: "loopback", source: "loopback" }
: { kind: "configured", keyId: "scoped", source: "bearer" } });
expect(JSON.stringify(source)).toBe(snapshot);
return { response, text: await response.text() };
}

describe("managed native Messages billed tool scope", () => {
for (const oauth of [false, true]) {
test(`denies a separate unlisted advisor destination before dispatch (OAuth=${oauth})`, async () => {
const { response, text } = await send({ tools: [advisor()] }, { oauth });
expect(response.status, text).toBe(403);
expect(text).toContain("model_not_allowed_for_key");
expect(sent).toHaveLength(0);
});
test(`denies an inline advisor declaration before dispatch (OAuth=${oauth})`, async () => {
const { response, text } = await send({ tools: [custom], messages: [user, addition(advisor())] }, { oauth });
expect(response.status, text).toBe(403);
expect(sent).toHaveLength(0);
});
test(`an allowed advisor remains byte-equivalent on the wire (OAuth=${oauth})`, async () => {
const tool = advisor();
const { response, text } = await send({ tools: [tool] }, { oauth, models: [EXECUTOR, ADVISOR] });
expect(response.status, text).toBe(200);
expect(sent).toHaveLength(1);
expect(sent[0]!.tools).toEqual([tool]);
});
test(`clear_at never retains inline scope enforcement (OAuth=${oauth})`, async () => {
const { response, text } = await send({ tools: [custom], messages: [user, { ...addition(advisor()), clear_at: "never" }] }, { oauth });
expect(response.status, text).toBe(403);
expect(sent).toHaveLength(0);
});
test(`a turn-scoped inline advisor declaration is refused defensively (OAuth=${oauth})`, async () => {
const { response, text } = await send({ tools: [custom], messages: [user, { ...addition(advisor()), clear_at: "next_user_message" }] }, { oauth });
expect(response.status, text).toBe(403);
expect(sent).toHaveLength(0);
});
}

test("clear_at never retains a legitimate removal", async () => {
const { response, text } = await send({ tools: [advisor()], messages: [user, { ...reference("tool_removal"), clear_at: "never" }] });
expect(response.status, text).toBe(200);
expect(sent).toHaveLength(1);
});

test.each([
["unrestricted key", { models: [] }],
["provider-only key", { models: [], providers: ["operator-anth"] }],
["loopback admission", { configured: false }],
["operator-qualified model", { models: [EXECUTOR, `operator-anth/${ADVISOR}`] }],
])("preserves %s", async (_name, options) => {
const { response, text } = await send({ tools: [advisor()] }, options);
expect(response.status, text).toBe(200);
expect(sent).toHaveLength(1);
});

test.each([
["removed top-level declaration", { tools: [advisor()], messages: [user, reference("tool_removal")] }],
["removed inline declaration", { tools: [custom], messages: [user, addition(advisor()), reference("tool_removal")] }],
["allowed replacement", { tools: [advisor()], messages: [user, addition(advisor(EXECUTOR))] }],
["completed historic result", { tools: [custom], messages: [user, { role: "assistant", content: [
{ type: "advisor_tool_result", tool_use_id: "srvtoolu_fixture", content: { type: "advisor_result", text: "historic advice" } },
] }, user] }],
["opaque custom schema", { tools: [{ ...custom, input_schema: { type: "object", properties: { example: advisor() } } }] }],
])("preserves %s without invoking a denied destination", async (_name, body) => {
const { response, text } = await send(body);
expect(response.status, text).toBe(200);
expect(sent).toHaveLength(1);
});

test.each([
["re-offered tool", { tools: [advisor()], messages: [user, reference("tool_removal"), reference("tool_addition")] }],
["denied replacement", { tools: [advisor(EXECUTOR)], messages: [user, addition(advisor())] }],
["deferred declaration", { tools: [{ ...advisor(), defer_loading: true }] }],
["missing model", { tools: [{ type: "advisor_20260301", name: "advisor" }] }],
["nonstring model", { tools: [advisor(7)] }],
["temporary removal", { tools: [advisor()], messages: [user, { ...reference("tool_removal"), clear_at: "next_user_message" }] }],
["temporary re-offer", { tools: [advisor()], messages: [user, reference("tool_removal"), { ...reference("tool_addition"), clear_at: "next_user_message" }] }],
["temporary allowed replacement", { tools: [advisor()], messages: [user, { ...addition(advisor(EXECUTOR)), clear_at: "next_user_message" }] }],
["paused advisor resume", { tools: [advisor()], messages: [user, { role: "assistant", content: [
{ type: "server_tool_use", id: "srvtoolu_fixture", name: "advisor", input: {} },
] }] }],
])("denies %s", async (_name, body) => {
const { response, text } = await send(body);
expect(response.status, text).toBe(403);
expect(sent).toHaveLength(0);
});
});
1 change: 1 addition & 0 deletions tests/fixtures/test-layout-expected.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"messages-native-scope.test.ts": "claude-integration",
"cli-log-view-filter.test.ts": "cli",
"cli-usage-model-search.test.ts": "cli",
"cli-companion-usage.test.ts": "cli",
Expand Down
Loading