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
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';

import { classifyClaudeSubagentModelAccess } from '../subagent-model-access-policy.js';

const xdModel = (id: string) => ({ id, agents: ['claude-code' as const] });

function classify(overrides: Partial<Parameters<typeof classifyClaudeSubagentModelAccess>[0]> = {}) {
return classifyClaudeSubagentModelAccess({
providerId: 'xd',
credentialMode: 'gateway-key',
model: 'sonnet',
gatewayKeyAvailable: true,
xdSnapshot: { authoritative: true, models: [xdModel('codex/gpt-5.6-sol')] },
providers: [],
...overrides,
});
}

describe('Claude subagent model access', () => {
it('denies an absent alias only from the current authoritative XD snapshot', () => {
expect(classify()).toEqual({ status: 'denied' });
expect(classify({
xdSnapshot: { authoritative: false, models: [xdModel('claude-sonnet-4-6')] },
}))
.toEqual({ status: 'unknown' });
});

it('accepts aliases, full ids, and the [1m] suffix from the XD snapshot', () => {
const snapshot = {
authoritative: true,
models: [xdModel('claude-sonnet-4-6'), xdModel('xai/grok-4.6')],
};
expect(classify({ model: 'sonnet', xdSnapshot: snapshot })).toEqual({ status: 'allowed' });
expect(classify({ model: 'CLAUDE-SONNET-4-6[1m]', xdSnapshot: snapshot }))
.toEqual({ status: 'allowed' });
expect(classify({ model: 'xai/grok-4.6', xdSnapshot: snapshot }))
.toEqual({ status: 'allowed' });
});

it('does not treat a static or stale non-XD catalog absence as denial', () => {
expect(classify({
providerId: 'anthropic',
credentialMode: 'oauth-bearer',
gatewayKeyAvailable: true,
providers: [{
id: 'anthropic',
connected: true,
models: { 'claude-code': [] },
}],
})).toEqual({ status: 'unknown' });
});

it('re-evaluates sequential account snapshots instead of retaining a denial', () => {
expect(classify()).toEqual({ status: 'denied' });
expect(classify({
xdSnapshot: { authoritative: true, models: [xdModel('claude-sonnet-4-6')] },
})).toEqual({ status: 'allowed' });
});

it('uses the default XD route for OAuth spawn when a gateway key is active', () => {
expect(classify({
providerId: null,
credentialMode: 'oauth-bearer',
gatewayKeyAvailable: true,
})).toEqual({ status: 'denied' });
});
});
23 changes: 22 additions & 1 deletion apps/desktop/src/main/maker-host/active-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,8 @@ export interface XdGatewayModelInfo {
* v3 必需字段在协议边界严格校验;这里不读取公共 Catalog,也不按模型 id 或固定常量补值。
*/
let xdGatewayModels: XdGatewayModelInfo[] = [];
/** 当前账号最近一次 `/models` 成功响应;false 时空/旧数组都不能作为 deny 证据。 */
let xdGatewayModelsAuthoritative = false;
/**
* XD 模型里「由客户端投影给 Codex、但走 Anthropic Messages bridge」的 id 集合。
* Responses → Anthropic Messages bridge,不能误用 XD 的原生 Responses 路由。
Expand Down Expand Up @@ -1411,8 +1413,14 @@ export function setDiscoveredProviderMediaModels(
* 注入 XD 网关权威模型清单(model-access 拉取流程写入,重建逻辑见 computeMerged)。
* 传空数组 = 实时清单不可用,此时 XD 供应商保留但不暴露任何模型。
*/
export function setXdGatewayModels(models: XdGatewayModelInfo[]): void {
export function setXdGatewayModels(
models: XdGatewayModelInfo[],
options?: { authoritative?: boolean },
): void {
xdGatewayModels = [...models];
if (options?.authoritative !== undefined) {
xdGatewayModelsAuthoritative = options.authoritative;
}
xdCodexAnthropicBridgeModelIds = deriveXdCodexAnthropicBridgeModelIds(models);
markChanged();
}
Expand All @@ -1422,6 +1430,19 @@ export function getXdGatewayModels(): readonly XdGatewayModelInfo[] {
return xdGatewayModels;
}

/** 子代理模型预检只在此标记为 true 时,才可把清单缺席解释为权威拒绝。 */
export function getXdGatewayModelAccessSnapshot(): {
authoritative: boolean;
models: readonly XdGatewayModelInfo[];
} {
return { authoritative: xdGatewayModelsAuthoritative, models: xdGatewayModels };
}

/** 新一轮 `/models` 未完成或失败后撤销负向证明,但保留 LKG 供 UI 展示。 */
export function markXdGatewayModelAccessUnknown(): void {
xdGatewayModelsAuthoritative = false;
}

/** 返回当前 active catalog 的单调递增修订号。 */
export function getActiveCatalogRevision(): number {
return revision;
Expand Down
20 changes: 20 additions & 0 deletions apps/desktop/src/main/maker-host/cc-manager-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import type {
ApprovalRequestResult,
OAuthRefreshParams,
OAuthRefreshResult,
SubagentModelAccessParams,
SubagentModelAccessResult,
} from '@cindy/maker-cc-manager';
import {
REMOTE_CC_MGR_BUNDLE_PATH,
Expand Down Expand Up @@ -290,6 +292,10 @@ export async function openCcManagerSession(opts: {
* are denied (same as the old hardcoded acceptEdits behavior).
*/
onApprovalRequest?: (params: ApprovalRequestParams) => Promise<ApprovalRequestResult>;
/** Remote Agent/Task PreToolUse 的实时账号模型准入回调。 */
onSubagentModelAccessRequest?: (
params: SubagentModelAccessParams,
) => Promise<SubagentModelAccessResult>;
/**
* 强制 fresh start:daemon 侧 session alive 时也先 kill 再走 start 路径
* (而非 attach)。用于本机 HTTP MCP bridge 重启 (app 重启) 后首轮注入:
Expand Down Expand Up @@ -362,6 +368,20 @@ export async function openCcManagerSession(opts: {
return { kind: p.kind, behavior: 'deny', reason: 'no approval handler registered' } satisfies ApprovalRequestResult;
});

client.setRequestHandler(SERVER_METHODS.SUBAGENT_MODEL_ACCESS, async (params) => {
const p = params as SubagentModelAccessParams;
if (!opts.onSubagentModelAccessRequest) return { status: 'unknown' };
try {
return await opts.onSubagentModelAccessRequest(p);
} catch (err) {
log.warn('subagent model access handler rejected; allowing as unknown', {
sessionId: p.sessionId,
error: (err as Error)?.message,
});
return { status: 'unknown' } satisfies SubagentModelAccessResult;
}
});

// OAuth refresh handler — daemon sends these when the remote cc SDK's
// getOAuthToken fires (subscription token expired mid-turn). token=null
// on any failure: the daemon passes it to the SDK, which surfaces the
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/main/maker-host/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ import {
setClaudeProxyOAuthSpawnChecker,
} from './anthropic-compat-proxy-host.js';
import { resolveRemoteClaudeRoute } from './remote-claude-route.js';
import { resolveDesktopClaudeSubagentModelAccess } from './subagent-model-access.js';
import { claudeSubagentUsageBridge } from './claude-subagent-usage-bridge.js';
import { createAutoPermissionReviewer } from './auto-permission-reviewer.js';
import {
Expand Down Expand Up @@ -967,6 +968,7 @@ export function getMaker(): Maker {
// (浏览器自动化等高频入口)会逐次弹窗, 与 Codex 侧的静默执行行为分叉。
getMcpToolApprovalPolicy: getDesktopMcpToolApprovalPolicy,
getMcpToolApprovalPresentation: getDesktopMcpToolApprovalPresentation,
resolveClaudeSubagentModelAccess: resolveDesktopClaudeSubagentModelAccess,
// 模型清单 SSoT = 目录(providers.json,OSS 运行时真源 / bundled 兜底)。maker-core 的
// CLAUDE_MODELS 已删、availableModels 起始为空;host 从账号可选目录派生 cc 列表注入
// (含 claude 订阅模型 + XD 网关路由的 gpt / 国产 / gemini 等)。active catalog 已在 splash 期
Expand Down Expand Up @@ -1022,6 +1024,7 @@ export function getMaker(): Maker {
startParams,
vendorOptions,
onApprovalRequest,
onSubagentModelAccessRequest,
onOAuthRefresh,
makerMemoryEnabled,
}) => {
Expand Down Expand Up @@ -1156,6 +1159,9 @@ export function getMaker(): Maker {
onApprovalRequest: onApprovalRequest as Parameters<
typeof openCcManagerSession
>[0]['onApprovalRequest'],
onSubagentModelAccessRequest: onSubagentModelAccessRequest as Parameters<
typeof openCcManagerSession
>[0]['onSubagentModelAccessRequest'],
onOAuthRefresh: onOAuthRefresh as Parameters<
typeof openCcManagerSession
>[0]['onOAuthRefresh'],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import type {
AgentCredentialMode,
ClaudeSubagentModelAccessResult,
} from '@cindy/maker-core';
import type { ProviderView } from '@cindy/model-providers';

const CLAUDE_ALIASES = ['sonnet', 'opus', 'haiku', 'fable'] as const;

function normalizeModel(model: string): string {
const normalized = model.trim().toLowerCase();
return normalized.endsWith('[1m]')
? normalized.slice(0, -'[1m]'.length)
: normalized;
}

function modelMatches(requested: string, candidate: string): boolean {
const normalizedCandidate = normalizeModel(candidate);
if (requested === normalizedCandidate) return true;
return CLAUDE_ALIASES.some(
(alias) => requested === alias && normalizedCandidate.includes(`claude-${alias}-`),
);
}

interface XdAccessModel {
id: string;
agents?: readonly string[];
}

interface XdAccessSnapshot {
authoritative: boolean;
models: readonly XdAccessModel[];
}

function xdClaudeCodeModels(models: readonly XdAccessModel[]): readonly string[] {
return models
.filter((model) => model.agents?.includes('claude-code'))
.map((model) => model.id);
}

export function classifyClaudeSubagentModelAccess(input: {
providerId?: string | null;
credentialMode?: AgentCredentialMode;
model: string;
gatewayKeyAvailable: boolean;
xdSnapshot: XdAccessSnapshot;
providers: readonly Pick<ProviderView, 'id' | 'connected' | 'suspended' | 'models'>[];
}): ClaudeSubagentModelAccessResult {
const requested = normalizeModel(input.model);
if (!requested) return { status: 'unknown' };

const providerId = input.providerId?.trim() || null;
const usesXdGateway = providerId === 'xd'
|| (!providerId && input.credentialMode === 'gateway-key')
// OAuth spawn 有 XD key 时,本地 compat proxy 的默认路由同样换成网关 key。
|| (!providerId && input.gatewayKeyAvailable);

if (usesXdGateway) {
if (!input.xdSnapshot.authoritative) return { status: 'unknown' };
return xdClaudeCodeModels(input.xdSnapshot.models).some((id) => modelMatches(requested, id))
? { status: 'allowed' }
: { status: 'denied' };
Comment thread
MagicLizi marked this conversation as resolved.
}

// 非 XD 来源没有统一的权威“负清单”:当前连接来源的正命中可以放行,
// 缺席只能说明目录未发现/过期,绝不能据此硬阻断。
const candidates = providerId
? input.providers.filter((provider) => provider.id === providerId)
: input.providers;
const positivelyAvailable = candidates.some((provider) =>
provider.connected
&& !provider.suspended
&& (provider.models['claude-code'] ?? []).some(
(model) => !model.disabled && modelMatches(requested, model.id),
),
);
return positivelyAvailable ? { status: 'allowed' } : { status: 'unknown' };
}
40 changes: 40 additions & 0 deletions apps/desktop/src/main/maker-host/subagent-model-access.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type {
AgentCredentialMode,
ClaudeSubagentModelAccessResult,
} from '@cindy/maker-core';

import { readClaudeApiKey } from './auth-adapters.js';
import {
getActiveCatalog,
getXdGatewayModelAccessSnapshot,
} from './active-catalog.js';
import { getDesktopProviderService } from './createDesktopProviderService.js';
import { classifyClaudeSubagentModelAccess } from './subagent-model-access-policy.js';

/**
* ClaudeCodeAgent 的 host 注入入口。每次 PreToolUse 都现场读取 key、权威 XD
* 快照和 ProviderView,因此账号切换不会复用会话启动时冻结的权限结论。
*/
export async function resolveDesktopClaudeSubagentModelAccess(context: {
providerId?: string | null;
parentModel: string;
credentialMode?: AgentCredentialMode;
model: string;
}): Promise<ClaudeSubagentModelAccessResult> {
try {
const providers = await getDesktopProviderService().listProviders({
allowSideEffects: false,
catalog: getActiveCatalog(),
});
return classifyClaudeSubagentModelAccess({
providerId: context.providerId,
credentialMode: context.credentialMode,
model: context.model,
gatewayKeyAvailable: Boolean(readClaudeApiKey()),
xdSnapshot: getXdGatewayModelAccessSnapshot(),
providers,
});
} catch {
return { status: 'unknown' };
}
}
20 changes: 15 additions & 5 deletions apps/desktop/src/main/model-access/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ import {
finalizeCodexAfterAuthModeChange,
cancelCodexAuthModeChange,
} from '../maker-host/index.js';
import { setXdGatewayModels } from '../maker-host/active-catalog.js';
import {
markXdGatewayModelAccessUnknown,
setXdGatewayModels,
} from '../maker-host/active-catalog.js';
import { replaceGatewayModelPricing, trackGatewayModelPricingSync } from '../usage/modelPricing.js';
import { isPricedGatewayModel } from '../../shared/modelPriceQuote.js';
import { throwIpcError } from '../utils/ipcValidate.js';
Expand Down Expand Up @@ -152,7 +155,11 @@ let authGeneration = 0;
let lastAuthUserId: string | null = null;
let lastAuthRealm: ReturnType<typeof authManager.getActiveAuthRealm> | null = null;

function applyGatewayModels(models: ModelAccessGatewayModel[], authenticatedUserId?: string): void {
function applyGatewayModels(
models: ModelAccessGatewayModel[],
authenticatedUserId?: string,
authoritative = false,
): void {
// 同一次 /models 响应建立 XD 模型与价格投影。空成功响应会同时清空模型和价格;请求失败不会调用本函数,
// 因而保留上一份完整成功快照。
const pricing = replaceGatewayModelPricing(models, authenticatedUserId);
Expand All @@ -172,14 +179,17 @@ function applyGatewayModels(models: ModelAccessGatewayModel[], authenticatedUser
// 同一含义只下发一个字段。这里直接用下发值,唯一事实源在服务端。
// active-catalog 统一收口会原地刷新 Maker capabilities,再广播同一 revision。
resetExecutableMediaModelCache();
setXdGatewayModels(models);
setXdGatewayModels(models, { authoritative });
}

async function runModelsSync(
myGen: number,
authenticatedUserId: string,
myAttempt: number,
): Promise<void> {
// 新请求开始后,旧 LKG 仍可展示但不再能证明“当前账号明确没有某模型”。
// 只有本次同认证世代的成功响应会重新把三态提升为 authoritative。
markXdGatewayModelAccessUnknown();
let models: ModelAccessGatewayModel[];
try {
const request = buildModelsSyncRequest(() => getClientEndpoint('modelAccessApiBaseUrl'));
Expand All @@ -203,12 +213,12 @@ async function runModelsSync(
if (myGen !== authGeneration) return; // 响应归属旧账号,丢弃
if (models.length === 0) {
log.warn('xd gateway models fetch returned empty list; clearing current list');
applyGatewayModels([], authenticatedUserId);
applyGatewayModels([], authenticatedUserId, true);
lastModelsSyncSucceededAttempt = myAttempt;
return;
}
log.info(`xd gateway models synced: ${models.length}`);
applyGatewayModels(models, authenticatedUserId);
applyGatewayModels(models, authenticatedUserId, true);
try {
const availability = await listExecutableMediaModels([], {
includeDisabled: true,
Expand Down
9 changes: 7 additions & 2 deletions packages/maker-cc-manager/__tests__/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
PROTOCOL_VERSION,
METHODS,
NOTIFICATIONS,
SERVER_METHODS,
isRpcMessage,
isRpcRequest,
isRpcResponse,
Expand All @@ -17,8 +18,8 @@ describe('protocol constants', () => {
expect(PROTOCOL_VERSION).toBeGreaterThan(0);
});

it('requires v3 so old daemons cannot ignore root-only host tool guards', () => {
expect(PROTOCOL_VERSION).toBe(3);
it('requires v4 so old daemons cannot ignore subagent model preflight', () => {
expect(PROTOCOL_VERSION).toBe(4);
});

it('METHODS has expected method names', () => {
Expand All @@ -36,6 +37,10 @@ describe('protocol constants', () => {
expect(NOTIFICATIONS.SESSION_CLOSED).toBe('session/closed');
expect(NOTIFICATIONS.CLIENT_REPLACED).toBe('client/replaced');
});

it('declares the live subagent model access reverse request', () => {
expect(SERVER_METHODS.SUBAGENT_MODEL_ACCESS).toBe('subagent/model-access');
});
});

describe('isRpcMessage', () => {
Expand Down
Loading