diff --git a/apps/desktop/src/main/maker-host/__tests__/subagentModelAccess.test.ts b/apps/desktop/src/main/maker-host/__tests__/subagentModelAccess.test.ts new file mode 100644 index 0000000000..0058d08bf8 --- /dev/null +++ b/apps/desktop/src/main/maker-host/__tests__/subagentModelAccess.test.ts @@ -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[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' }); + }); +}); diff --git a/apps/desktop/src/main/maker-host/active-catalog.ts b/apps/desktop/src/main/maker-host/active-catalog.ts index 1599580caf..48ec9a45e8 100644 --- a/apps/desktop/src/main/maker-host/active-catalog.ts +++ b/apps/desktop/src/main/maker-host/active-catalog.ts @@ -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 路由。 @@ -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(); } @@ -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; diff --git a/apps/desktop/src/main/maker-host/cc-manager-client.ts b/apps/desktop/src/main/maker-host/cc-manager-client.ts index c815cce041..ecd76f98f5 100644 --- a/apps/desktop/src/main/maker-host/cc-manager-client.ts +++ b/apps/desktop/src/main/maker-host/cc-manager-client.ts @@ -40,6 +40,8 @@ import type { ApprovalRequestResult, OAuthRefreshParams, OAuthRefreshResult, + SubagentModelAccessParams, + SubagentModelAccessResult, } from '@cindy/maker-cc-manager'; import { REMOTE_CC_MGR_BUNDLE_PATH, @@ -290,6 +292,10 @@ export async function openCcManagerSession(opts: { * are denied (same as the old hardcoded acceptEdits behavior). */ onApprovalRequest?: (params: ApprovalRequestParams) => Promise; + /** Remote Agent/Task PreToolUse 的实时账号模型准入回调。 */ + onSubagentModelAccessRequest?: ( + params: SubagentModelAccessParams, + ) => Promise; /** * 强制 fresh start:daemon 侧 session alive 时也先 kill 再走 start 路径 * (而非 attach)。用于本机 HTTP MCP bridge 重启 (app 重启) 后首轮注入: @@ -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 diff --git a/apps/desktop/src/main/maker-host/index.ts b/apps/desktop/src/main/maker-host/index.ts index e1b29bd02e..fed794ec07 100644 --- a/apps/desktop/src/main/maker-host/index.ts +++ b/apps/desktop/src/main/maker-host/index.ts @@ -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 { @@ -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 期 @@ -1022,6 +1024,7 @@ export function getMaker(): Maker { startParams, vendorOptions, onApprovalRequest, + onSubagentModelAccessRequest, onOAuthRefresh, makerMemoryEnabled, }) => { @@ -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'], diff --git a/apps/desktop/src/main/maker-host/subagent-model-access-policy.ts b/apps/desktop/src/main/maker-host/subagent-model-access-policy.ts new file mode 100644 index 0000000000..ba1d11230f --- /dev/null +++ b/apps/desktop/src/main/maker-host/subagent-model-access-policy.ts @@ -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[]; +}): 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' }; + } + + // 非 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' }; +} diff --git a/apps/desktop/src/main/maker-host/subagent-model-access.ts b/apps/desktop/src/main/maker-host/subagent-model-access.ts new file mode 100644 index 0000000000..e518cafbad --- /dev/null +++ b/apps/desktop/src/main/maker-host/subagent-model-access.ts @@ -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 { + 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' }; + } +} diff --git a/apps/desktop/src/main/model-access/index.ts b/apps/desktop/src/main/model-access/index.ts index ed0d14e7e7..7bbbda1ae1 100644 --- a/apps/desktop/src/main/model-access/index.ts +++ b/apps/desktop/src/main/model-access/index.ts @@ -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'; @@ -152,7 +155,11 @@ let authGeneration = 0; let lastAuthUserId: string | null = null; let lastAuthRealm: ReturnType | 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); @@ -172,7 +179,7 @@ function applyGatewayModels(models: ModelAccessGatewayModel[], authenticatedUser // 同一含义只下发一个字段。这里直接用下发值,唯一事实源在服务端。 // active-catalog 统一收口会原地刷新 Maker capabilities,再广播同一 revision。 resetExecutableMediaModelCache(); - setXdGatewayModels(models); + setXdGatewayModels(models, { authoritative }); } async function runModelsSync( @@ -180,6 +187,9 @@ async function runModelsSync( authenticatedUserId: string, myAttempt: number, ): Promise { + // 新请求开始后,旧 LKG 仍可展示但不再能证明“当前账号明确没有某模型”。 + // 只有本次同认证世代的成功响应会重新把三态提升为 authoritative。 + markXdGatewayModelAccessUnknown(); let models: ModelAccessGatewayModel[]; try { const request = buildModelsSyncRequest(() => getClientEndpoint('modelAccessApiBaseUrl')); @@ -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, diff --git a/packages/maker-cc-manager/__tests__/protocol.test.ts b/packages/maker-cc-manager/__tests__/protocol.test.ts index 5ba23ee8f4..91075c8c7e 100644 --- a/packages/maker-cc-manager/__tests__/protocol.test.ts +++ b/packages/maker-cc-manager/__tests__/protocol.test.ts @@ -4,6 +4,7 @@ import { PROTOCOL_VERSION, METHODS, NOTIFICATIONS, + SERVER_METHODS, isRpcMessage, isRpcRequest, isRpcResponse, @@ -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', () => { @@ -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', () => { diff --git a/packages/maker-cc-manager/__tests__/session-registry.test.ts b/packages/maker-cc-manager/__tests__/session-registry.test.ts index 37b213c238..c8f7281d4f 100644 --- a/packages/maker-cc-manager/__tests__/session-registry.test.ts +++ b/packages/maker-cc-manager/__tests__/session-registry.test.ts @@ -245,6 +245,50 @@ describe('SessionRegistry', () => { ).resolves.toEqual({ continue: true }); }); + it('enforces subagent model access in remote Full access sessions', async () => { + const { factory: baseFactory } = buildFakeFactory(); + let captured: SdkQueryFactoryOptions | undefined; + let accessStatus: 'denied' | 'unknown' = 'denied'; + const onSubagentModelAccessRequest = vi.fn(async () => ({ status: accessStatus })); + const registry = new SessionRegistry({ + sdkQueryFactory: (opts) => { + captured = opts; + return baseFactory(opts); + }, + onSubagentModelAccessRequest, + }); + registry.create({ + sessionId: 's-subagent-model', + cwd: '/x', + model: 'codex/gpt-5.6-sol', + env: {}, + permissionMode: 'bypassPermissions', + }); + + const preToolUse = captured?.hooks?.PreToolUse?.[0]?.hooks[0]; + expect(preToolUse).toBeDefined(); + await expect(preToolUse!({ + hook_event_name: 'PreToolUse', + tool_name: 'Agent', + tool_input: { model: 'sonnet', run_in_background: true }, + })).resolves.toMatchObject({ + hookSpecificOutput: { + permissionDecision: 'deny', + permissionDecisionReason: expect.stringContaining('sonnet'), + }, + }); + expect(onSubagentModelAccessRequest).toHaveBeenCalledWith( + 's-subagent-model', + { sessionId: 's-subagent-model', model: 'sonnet' }, + ); + accessStatus = 'unknown'; + await expect(preToolUse!({ + hook_event_name: 'PreToolUse', + tool_name: 'Task', + tool_input: { model: 'haiku' }, + })).resolves.toEqual({ continue: true }); + }); + it('allows the exact Orca report tool for the root and denies nested Claude agents', async () => { const { factory: baseFactory } = buildFakeFactory(); let captured: SdkQueryFactoryOptions | undefined; diff --git a/packages/maker-cc-manager/src/bin/cc-mgr.ts b/packages/maker-cc-manager/src/bin/cc-mgr.ts index 1872ff5e13..1c99e42e67 100644 --- a/packages/maker-cc-manager/src/bin/cc-mgr.ts +++ b/packages/maker-cc-manager/src/bin/cc-mgr.ts @@ -21,7 +21,17 @@ import { ManagerServer } from '../server.js'; import { SessionRegistry, type SdkQueryFactoryOptions, type SdkQueryLike } from '../session-registry.js'; import { wireSdkHandlers } from '../sdk-handlers.js'; import { ensureRemoteClaudeConfigDir } from '../remote-claude-env.js'; -import { CC_MGR_BUNDLE_VERSION, PROTOCOL_VERSION, SERVER_METHODS, type ApprovalRequestParams, type ApprovalRequestResult, type OAuthRefreshParams, type OAuthRefreshResult } from '../protocol.js'; +import { + CC_MGR_BUNDLE_VERSION, + PROTOCOL_VERSION, + SERVER_METHODS, + type ApprovalRequestParams, + type ApprovalRequestResult, + type OAuthRefreshParams, + type OAuthRefreshResult, + type SubagentModelAccessParams, + type SubagentModelAccessResult, +} from '../protocol.js'; const MANAGER_VERSION = CC_MGR_BUNDLE_VERSION; @@ -61,6 +71,7 @@ const SENSITIVE_ANTHROPIC_ENV_KEYS = [ 'CLAUDE_CODE_OAUTH_SCOPES', 'CLAUDE_CODE_SUBSCRIPTION_TYPE', 'CLAUDE_CODE_RATE_LIMIT_TIER', + 'CLAUDE_CODE_SUBAGENT_MODEL', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_UNIX_SOCKET', 'ANTHROPIC_CUSTOM_HEADERS', @@ -374,6 +385,19 @@ async function runDaemon(socketPath: string): Promise { { timeoutMs: 120_000 }, ); }, + onSubagentModelAccessRequest: async ( + sessionId: string, + params: SubagentModelAccessParams, + ): Promise => { + const ctx = getAttachedClientCtx?.(sessionId); + if (!ctx) return { status: 'unknown' }; + return await server.sendRequest( + ctx, + SERVER_METHODS.SUBAGENT_MODEL_ACCESS, + params, + { timeoutMs: 15_000 }, + ); + }, onOAuthRefresh: async (sessionId: string, params: OAuthRefreshParams): Promise => { const ctx = getAttachedClientCtx?.(sessionId); if (!ctx) { diff --git a/packages/maker-cc-manager/src/index.ts b/packages/maker-cc-manager/src/index.ts index e68d900da8..0e398630b5 100644 --- a/packages/maker-cc-manager/src/index.ts +++ b/packages/maker-cc-manager/src/index.ts @@ -61,6 +61,8 @@ export type { ApprovalRequestResult, OAuthRefreshParams, OAuthRefreshResult, + SubagentModelAccessParams, + SubagentModelAccessResult, QueryEventNotification, SessionClosedNotification, ClientReplacedNotification, diff --git a/packages/maker-cc-manager/src/protocol.ts b/packages/maker-cc-manager/src/protocol.ts index 22ec3580d4..79e2d1e0e6 100644 --- a/packages/maker-cc-manager/src/protocol.ts +++ b/packages/maker-cc-manager/src/protocol.ts @@ -26,12 +26,16 @@ * v3: toolGuards 增加 root-only,使用 SDK agent_id 阻止远端 subagent * 调用受保护工具。旧 daemon 无法证明调用来源,因此必须升级协议。 * + * v4: 增加 subagent/model-access 反向请求,并要求 daemon 在 Full access + * 之前执行 Agent/Task 实时模型准入预检。旧 daemon 没有该执行边界, + * 会继续发起无权限请求,因此必须升级协议。 + * * v1 (redesign): 删除 dead-session drain/archive 握手,对齐 codex 模式。 * reattach 只接新 events (live-only subscription),不 replay 旧 ring buffer。 * ring buffer 降级为纯内存 fast-path(同一 daemon 进程生命周期内的 mid-turn 续流)。 * 断开期间跑完的输出暂不自动补回 chat(follow-up: jsonl recovery 统一 cc + codex)。 */ -export const PROTOCOL_VERSION = 3 as const; +export const PROTOCOL_VERSION = 4 as const; /** * cc-mgr bundle 版本号 — 手动 bump。 @@ -40,7 +44,7 @@ export const PROTOCOL_VERSION = 3 as const; * 无关依赖变化而变。desktop 用这个(而非 bundle sha256)判断远端 daemon * 是否需要 upgrade,避免无关的 pnpm install 触发全量远端重装。 */ -export const CC_MGR_BUNDLE_VERSION = '0.0.8' as const; +export const CC_MGR_BUNDLE_VERSION = '0.0.9' as const; export type RpcId = number; @@ -137,6 +141,8 @@ export const NOTIFICATIONS = { export const SERVER_METHODS = { /** SDK's canUseTool fired — daemon needs desktop to approve/deny a tool use. */ APPROVAL_REQUEST: 'approval/request', + /** Agent/Task PreToolUse 向 desktop 查询当前账号与路由的模型准入状态。 */ + SUBAGENT_MODEL_ACCESS: 'subagent/model-access', /** * SDK's getOAuthToken fired — remote cc hit a 401 on its subscription OAuth * token and the daemon needs desktop to mint a fresh one @@ -227,6 +233,17 @@ export interface QueryStartResult { sdkSessionId?: string; } +export interface SubagentModelAccessParams { + sessionId: string; + /** 已按 CLAUDE_CODE_SUBAGENT_MODEL 优先级及 [1m] 后缀归一化。 */ + model: string; +} + +export interface SubagentModelAccessResult { + status: 'allowed' | 'denied' | 'unknown'; + reason?: string; +} + export interface QuerySendParams { sessionId: string; /** diff --git a/packages/maker-cc-manager/src/session-registry.ts b/packages/maker-cc-manager/src/session-registry.ts index 0fadaeaa38..b93415829f 100644 --- a/packages/maker-cc-manager/src/session-registry.ts +++ b/packages/maker-cc-manager/src/session-registry.ts @@ -17,6 +17,7 @@ import type { QueryEventNotification, QueryToolGuard, + SubagentModelAccessResult, SessionClosedNotification, SessionListEntry, ClientReplacedNotification, @@ -258,6 +259,11 @@ export type OAuthRefreshForwarder = ( params: import('./protocol.js').OAuthRefreshParams, ) => Promise; +export type SubagentModelAccessForwarder = ( + sessionId: string, + params: import('./protocol.js').SubagentModelAccessParams, +) => Promise; + export interface SessionRegistryOptions { sdkQueryFactory: SdkQueryFactory; /** @@ -283,6 +289,8 @@ export interface SessionRegistryOptions { * provided (or if no client is attached), the registry defaults to 'deny'. */ onApprovalRequest?: ApprovalRequestForwarder; + /** Agent/Task 执行前向当前 desktop 查询实时账号模型准入。异常必须降级 unknown。 */ + onSubagentModelAccessRequest?: SubagentModelAccessForwarder; /** * Called when a session's SDK getOAuthToken fires (401 on the subscription * token). Implementation should send a reverse-request RPC to the client. @@ -307,6 +315,7 @@ export class SessionRegistry { private readonly logger: NonNullable; private readonly bufferCapacity: number; private readonly onApprovalRequest?: ApprovalRequestForwarder; + private readonly onSubagentModelAccessRequest?: SubagentModelAccessForwarder; private readonly onOAuthRefresh?: OAuthRefreshForwarder; private readonly killSettleWatchdogMs: number; private readonly killCloseGraceMs: number; @@ -315,6 +324,7 @@ export class SessionRegistry { this.factory = opts.sdkQueryFactory; this.bufferCapacity = opts.bufferCapacity ?? DEFAULT_BUFFER_CAPACITY; this.onApprovalRequest = opts.onApprovalRequest; + this.onSubagentModelAccessRequest = opts.onSubagentModelAccessRequest; this.onOAuthRefresh = opts.onOAuthRefresh; this.killSettleWatchdogMs = opts.killSettleWatchdogMs ?? DEFAULT_KILL_SETTLE_WATCHDOG_MS; this.killCloseGraceMs = opts.killCloseGraceMs ?? DEFAULT_KILL_CLOSE_GRACE_MS; @@ -484,8 +494,34 @@ export class SessionRegistry { } : undefined; + const resolveSubagentModelAccess = this.onSubagentModelAccessRequest + ? async (toolName: string, toolInput: unknown): Promise => { + const model = effectiveSubagentModel( + opts.env.CLAUDE_CODE_SUBAGENT_MODEL, + toolName, + toolInput, + ); + if (!model) return { status: 'unknown' }; + try { + const result = await this.onSubagentModelAccessRequest!(opts.sessionId, { + sessionId: opts.sessionId, + model, + }); + return { ...result, model }; + } catch (err) { + this.logger.warn('subagent model access preflight unavailable — allowing', { + sessionId: opts.sessionId, + model, + error: (err as Error).message, + }); + return { status: 'unknown', model }; + } + } + : undefined; + const hooks = createToolGuardHooks( opts.toolGuards, + resolveSubagentModelAccess, () => sessionRef?.toolGuardSelectionText ?? '', () => sessionRef?.toolGuardMcpServerNames ?? EMPTY_MCP_SERVER_NAMES, (toolName, guard) => { @@ -1160,13 +1196,42 @@ function findDeniedToolGuard( return guard; } +function normalizeSubagentModel(model: string): string { + const normalized = model.trim().toLowerCase(); + return normalized.endsWith('[1m]') + ? normalized.slice(0, -'[1m]'.length) + : normalized; +} + +function effectiveSubagentModel( + forcedModel: string | undefined, + toolName: string, + toolInput: unknown, +): string | undefined { + if (toolName !== 'Agent' && toolName !== 'Task') return undefined; + const input = typeof toolInput === 'object' && toolInput !== null + ? toolInput as Record + : {}; + const requested = typeof input.model === 'string' ? input.model : ''; + const effectiveModel = normalizeSubagentModel(forcedModel ?? requested); + return !effectiveModel || effectiveModel === 'inherit' ? undefined : effectiveModel; +} + +function subagentModelDenialReason(model: string): string { + return `Subagent model "${model}" is not available from the current account and provider. Choose an available model, or remove the unavailable override from the Agent call or Subagent Model setting.`; +} + function createToolGuardHooks( toolGuards: readonly QueryToolGuard[] | undefined, + resolveSubagentModelAccess: (( + toolName: string, + toolInput: unknown, + ) => Promise) | undefined, getSelectionText: () => string, getMcpServerNames: () => ReadonlySet, onDeny: (toolName: string, guard: QueryToolGuard) => void, ): SdkHooks | undefined { - if (!toolGuards || toolGuards.length === 0) return undefined; + if ((!toolGuards || toolGuards.length === 0) && !resolveSubagentModelAccess) return undefined; const guardTool: SdkHookCallback = async (rawInput) => { if (typeof rawInput !== 'object' || rawInput === null) return { continue: true }; @@ -1175,6 +1240,18 @@ function createToolGuardHooks( return { continue: true }; } const toolName = input.tool_name; + const modelAccess = await resolveSubagentModelAccess?.(toolName, input.tool_input); + if (modelAccess?.status === 'denied' && modelAccess.model) { + return { + continue: true, + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: modelAccess.reason?.trim() + || subagentModelDenialReason(modelAccess.model), + }, + }; + } const guard = findDeniedToolGuard( toolGuards, toolName, diff --git a/packages/maker-core/src/agents/base-agent.ts b/packages/maker-core/src/agents/base-agent.ts index 266e7eeb90..82e0c41568 100644 --- a/packages/maker-core/src/agents/base-agent.ts +++ b/packages/maker-core/src/agents/base-agent.ts @@ -79,6 +79,7 @@ import type { PiRuntimeCapabilityManifest } from '../types/pi-runtime-capabiliti import type { PiProjectTrustInputSnapshot } from '../types/pi-project-trust.js'; import { scanWorkspaceFileResources } from './shared/palette-scanner.js'; import type { AutoReviewDelegate } from './shared/auto-review-decision.js'; +import type { ClaudeSubagentModelAccessResult } from './claude-code/subagent-model-access.js'; export interface AgentCapabilityAdditions { /** Extra models exposed by the host for this agent. Existing built-in ids are ignored. */ @@ -468,6 +469,18 @@ export interface AgentDeps { binaryPath: string; logger: Logger; + /** + * Claude Code 专用:按本次会话的当前 provider、账号与父模型实时判定显式 + * Agent/Task 模型是否可路由。只有 `denied` 会形成产品硬阻断;目录未就绪、 + * 非权威快照或读取失败必须返回 `unknown`,不能把静态 catalog 当权限清单。 + */ + resolveClaudeSubagentModelAccess?: (context: { + providerId?: string | null; + parentModel: string; + credentialMode?: AgentCredentialMode; + model: string; + }) => ClaudeSubagentModelAccessResult | Promise; + /** * 解析某 session 的 cc-debug raw 文件落盘路径 (host 注入)。host 用 logger 的 logDir 拼 * sessions//cc-debug.raw.log 并 mkdir, 返回绝对路径; ClaudeCodeAgent 在开 @@ -1116,6 +1129,11 @@ export interface AgentDeps { * but typed as unknown here to avoid cross-package dependency. */ onApprovalRequest?: (params: unknown) => Promise; + /** + * 远端 daemon 的 PreToolUse 通过反向 RPC 调回同一个实时模型准入 resolver。 + * Params/Result 采用 maker-cc-manager 的同名协议形状,但保持 unknown 以免耦包。 + */ + onSubagentModelAccessRequest?: (params: unknown) => Promise; /** * 本 session 的 Maker Memory 注入开关 (startSession 时已按 per-session flag * + manager 就绪归一)。host 据此决定是否把 cindy_memory 以 http 形态经 diff --git a/packages/maker-core/src/agents/claude-code/__tests__/mcp-approval-policy.test.ts b/packages/maker-core/src/agents/claude-code/__tests__/mcp-approval-policy.test.ts index 80cd1211e6..872559c3ce 100644 --- a/packages/maker-core/src/agents/claude-code/__tests__/mcp-approval-policy.test.ts +++ b/packages/maker-core/src/agents/claude-code/__tests__/mcp-approval-policy.test.ts @@ -206,6 +206,7 @@ async function startSession( }>; turnChangeCapture?: AgentDeps['turnChangeCapture']; getMcpToolApprovalPresentation?: AgentDeps['getMcpToolApprovalPresentation']; + resolveClaudeSubagentModelAccess?: AgentDeps['resolveClaudeSubagentModelAccess']; }, ) { const configDir = await makeTempDir(); @@ -223,6 +224,7 @@ async function startSession( deps.capabilityRouting = options?.capabilityRouting; deps.turnChangeCapture = options?.turnChangeCapture; deps.getMcpToolApprovalPresentation = options?.getMcpToolApprovalPresentation; + deps.resolveClaudeSubagentModelAccess = options?.resolveClaudeSubagentModelAccess; const agent = new ClaudeCodeAgent(deps); const handle = await agent.startSession({ sessionId: 'session-mcp-policy', @@ -374,6 +376,27 @@ describe('ClaudeCodeAgent canUseTool honors the host MCP approval policy', () => await handle.close(); }); + it('denies an unavailable Agent model in Full access before canUseTool', async () => { + const { handle, hooks } = await startSession(undefined, { + permissionMode: 'bypassPermissions', + resolveClaudeSubagentModelAccess: async () => ({ status: 'denied' }), + }); + const preToolUse = hooks?.PreToolUse?.[0]?.hooks[0]; + if (!preToolUse) throw new Error('expected subagent model access hook'); + + await expect(preToolUse({ + hook_event_name: 'PreToolUse', + tool_name: 'Agent', + tool_input: { model: 'sonnet', run_in_background: true }, + })).resolves.toMatchObject({ + hookSpecificOutput: { + permissionDecision: 'deny', + permissionDecisionReason: expect.stringContaining('sonnet'), + }, + }); + await handle.close(); + }); + it('keeps a normalized-prefix-colliding user MCP on the normal permission chain', async () => { const capabilityRouting = { overrides: [ @@ -1036,6 +1059,7 @@ describe('remote sessions share the same permission semantics', () => { failedInitMcpServerNames?: readonly string[]; getGhostRosterPrompt?: AgentDeps['getGhostRosterPrompt']; getMcpToolApprovalPresentation?: AgentDeps['getMcpToolApprovalPresentation']; + resolveClaudeSubagentModelAccess?: AgentDeps['resolveClaudeSubagentModelAccess']; }, ) { const configDir = await makeTempDir(); @@ -1043,10 +1067,12 @@ describe('remote sessions share the same permission semantics', () => { const workingDir = await makeTempDir(); let onApprovalRequest: ((raw: unknown) => Promise<{ behavior?: string }>) | undefined; + let onSubagentModelAccessRequest: ((raw: unknown) => Promise<{ status?: string }>) | undefined; const deps = createDeps(policy); deps.getGhostRosterPrompt = options?.getGhostRosterPrompt; deps.getMcpToolApprovalPresentation = options?.getMcpToolApprovalPresentation; deps.capabilityRouting = options?.capabilityRouting; + deps.resolveClaudeSubagentModelAccess = options?.resolveClaudeSubagentModelAccess; // 远端只装得到 stdio / sse / http 类 server —— in-process 的会被 filter 掉。 deps.mcpProviders = ( options?.mcpServerNames ?? ['cindy_browser', 'cindy_contacts'] @@ -1060,9 +1086,11 @@ describe('remote sessions share the same permission semantics', () => { sessionId: string; sessionInstanceId?: string; onApprovalRequest: (raw: unknown) => Promise<{ behavior?: string }>; + onSubagentModelAccessRequest: (raw: unknown) => Promise<{ status?: string }>; startParams: Record; }) => { onApprovalRequest = args.onApprovalRequest; + onSubagentModelAccessRequest = args.onSubagentModelAccessRequest; remoteStartParams = args.startParams; remoteIdentity = { sessionId: args.sessionId, @@ -1093,7 +1121,15 @@ describe('remote sessions share the same permission semantics', () => { }); } if (!onApprovalRequest) throw new Error('expected remote onApprovalRequest'); - return { handle, onApprovalRequest, seen, remoteStartParams, remoteIdentity }; + if (!onSubagentModelAccessRequest) throw new Error('expected remote model access callback'); + return { + handle, + onApprovalRequest, + onSubagentModelAccessRequest, + seen, + remoteStartParams, + remoteIdentity, + }; } it('passes the runtime session instance id into the remote Claude factory', async () => { @@ -1106,6 +1142,26 @@ describe('remote sessions share the same permission semantics', () => { await handle.close(); }); + it('keeps remote Full access on the same live tri-state resolver', async () => { + const resolveClaudeSubagentModelAccess = vi.fn(async () => ({ status: 'denied' as const })); + const { handle, onSubagentModelAccessRequest, remoteStartParams } = await startRemoteSession( + () => 'auto-approve', + { + permissionMode: 'bypassPermissions', + resolveClaudeSubagentModelAccess, + }, + ); + + await expect(onSubagentModelAccessRequest({ model: 'sonnet' })) + .resolves.toEqual({ status: 'denied' }); + expect(resolveClaudeSubagentModelAccess).toHaveBeenCalledWith(expect.objectContaining({ + model: 'sonnet', + parentModel: 'claude-opus-4-6', + })); + expect(remoteStartParams).not.toHaveProperty('subagentModelPolicy'); + await handle.close(); + }); + it('does not inject the local ghost roster into remote Claude sessions', async () => { const getGhostRosterPrompt = vi.fn(() => 'GHOST ROSTER PROMPT'); const { handle, remoteStartParams } = await startRemoteSession(() => 'auto-approve', { diff --git a/packages/maker-core/src/agents/claude-code/__tests__/subagent-model-access.test.ts b/packages/maker-core/src/agents/claude-code/__tests__/subagent-model-access.test.ts new file mode 100644 index 0000000000..2cf2baac37 --- /dev/null +++ b/packages/maker-core/src/agents/claude-code/__tests__/subagent-model-access.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + buildClaudeSubagentModelGuardHooks, + effectiveClaudeSubagentModel, +} from '../subagent-model-access.js'; + +function agentInput(model?: string) { + return { + hook_event_name: 'PreToolUse' as const, + session_id: 'session-subagent-model', + transcript_path: '/tmp/transcript.jsonl', + cwd: '/tmp/project', + tool_name: 'Agent', + tool_input: model === undefined ? {} : { model, run_in_background: true }, + tool_use_id: 'tool-subagent-model', + }; +} + +describe('Claude subagent model access guard', () => { + it('denies only an authoritative denial before Full access can bypass canUseTool', async () => { + const resolveAccess = vi.fn(async () => ({ status: 'denied' as const })); + const hook = buildClaudeSubagentModelGuardHooks(resolveAccess) + .PreToolUse?.[0]?.hooks[0]; + if (!hook) throw new Error('expected subagent model guard'); + + await expect(hook( + agentInput('sonnet'), + undefined, + { signal: new AbortController().signal }, + )).resolves.toMatchObject({ + hookSpecificOutput: { + permissionDecision: 'deny', + permissionDecisionReason: expect.stringContaining('sonnet'), + }, + }); + expect(resolveAccess).toHaveBeenCalledWith('sonnet'); + }); + + it.each(['allowed', 'unknown'] as const)('allows a %s decision', async (status) => { + const hook = buildClaudeSubagentModelGuardHooks(async () => ({ status })) + .PreToolUse?.[0]?.hooks[0]; + if (!hook) throw new Error('expected subagent model guard'); + await expect(hook( + agentInput('opus'), + undefined, + { signal: new AbortController().signal }, + )).resolves.toEqual({ continue: true }); + }); + + it('fails open when the live resolver throws', async () => { + const hook = buildClaudeSubagentModelGuardHooks(async () => { + throw new Error('account switched while reading access'); + }).PreToolUse?.[0]?.hooks[0]; + if (!hook) throw new Error('expected subagent model guard'); + await expect(hook( + agentInput('haiku'), + undefined, + { signal: new AbortController().signal }, + )).resolves.toEqual({ continue: true }); + }); + + it('normalizes full ids and the [1m] suffix before resolving', async () => { + const resolveAccess = vi.fn(async () => ({ status: 'allowed' as const })); + const hook = buildClaudeSubagentModelGuardHooks(resolveAccess) + .PreToolUse?.[0]?.hooks[0]; + if (!hook) throw new Error('expected subagent model guard'); + await hook( + agentInput(' Claude-Sonnet-4-6[1m] '), + undefined, + { signal: new AbortController().signal }, + ); + expect(resolveAccess).toHaveBeenCalledWith('claude-sonnet-4-6'); + }); + + it('uses the forced env model and skips omitted or inherited native defaults', async () => { + const forcedResolver = vi.fn(async () => ({ status: 'allowed' as const })); + const forcedHook = buildClaudeSubagentModelGuardHooks(forcedResolver, ' OPUS[1m] ') + .PreToolUse?.[0]?.hooks[0]; + if (!forcedHook) throw new Error('expected forced model guard'); + await forcedHook( + agentInput(), + undefined, + { signal: new AbortController().signal }, + ); + expect(forcedResolver).toHaveBeenCalledWith('opus'); + + expect(effectiveClaudeSubagentModel(undefined, 'Agent', {})).toBeUndefined(); + expect(effectiveClaudeSubagentModel(undefined, 'Task', { model: 'inherit' })).toBeUndefined(); + expect(effectiveClaudeSubagentModel(undefined, 'Read', { model: 'sonnet' })).toBeUndefined(); + }); +}); diff --git a/packages/maker-core/src/agents/claude-code/index.ts b/packages/maker-core/src/agents/claude-code/index.ts index 23f1807d83..8e10586d34 100644 --- a/packages/maker-core/src/agents/claude-code/index.ts +++ b/packages/maker-core/src/agents/claude-code/index.ts @@ -46,6 +46,9 @@ import { resolveSubagentModelDefault, type ResolveSubagentModelDefaultResult, } from './subagent-model-default.js'; +import { + buildClaudeSubagentModelGuardHooks, +} from './subagent-model-access.js'; import Anthropic, { APIError } from '@anthropic-ai/sdk'; import { @@ -1164,6 +1167,16 @@ export class ClaudeCodeAgent extends BaseAgent { } // 判定落到 env(唯一写入点,见 env-builder.applySubagentModelEnv)。 applySubagentModelEnv(env, subagentDefault.envSubagentModel ?? null); + // 每次 Agent/Task 调用都重新读取 host 的当前账号与路由事实。静态 capabilities + // 只负责展示,不参与 deny;账号切换时 resolver 会立即看到新快照或 unknown。 + const resolveSubagentModelAccess = this.deps.resolveClaudeSubagentModelAccess + ? (model: string) => this.deps.resolveClaudeSubagentModelAccess!({ + providerId: opts.providerId ?? null, + parentModel: opts.model, + credentialMode: effectiveCredentialMode, + model, + }) + : undefined; // 远端单独一份 env:用 'remote' 模式从空字典起(不继承 desktop OS env),否则 // Windows HOME=C:\Users\Lizi 之类污染远端 cc CLI 的 ~ 展开(session/memory // 落怪路径)。详见 env-builder.ts buildClaudeEnv 文档。 @@ -1530,6 +1543,15 @@ export class ClaudeCodeAgent extends BaseAgent { const localClaudeHooks = reviewMode ? { PreToolUse: [{ hooks: [reviewReadOnlyHook] }] } : mergeClaudeHookSets( + // 账号/模型准入是执行前提,不是用户工具权限。放在 PreToolUse,确保 + // Full access 也无法绕过。 + buildClaudeSubagentModelGuardHooks( + resolveSubagentModelAccess, + subagentDefault.envSubagentModel, + (model) => { + log.warn('subagent model denied by account access preflight', { model }); + }, + ), buildClaudeLocalToolGuardHooks( this.deps.capabilityRouting, () => activeCapabilitySelectionText, @@ -3124,6 +3146,18 @@ export class ClaudeCodeAgent extends BaseAgent { reason: decision.reason, }; }, + onSubagentModelAccessRequest: async (rawParams: unknown) => { + const params = typeof rawParams === 'object' && rawParams !== null + ? rawParams as Record + : {}; + const model = typeof params.model === 'string' ? params.model : ''; + if (!model || !resolveSubagentModelAccess) return { status: 'unknown' }; + try { + return await resolveSubagentModelAccess(model); + } catch { + return { status: 'unknown' }; + } + }, // 订阅 token 到期续命(远端版,对齐本地 SDK options 的 getOAuthToken 回调, // 见 buildQuery 本地分支):远端 cc 中途 401 → daemon 发 oauth/refresh 反向 // RPC → 这里向 host 要新 token。接线条件与本地同款:env 实际带订阅 token 且 diff --git a/packages/maker-core/src/agents/claude-code/subagent-model-access.ts b/packages/maker-core/src/agents/claude-code/subagent-model-access.ts new file mode 100644 index 0000000000..ce0f808671 --- /dev/null +++ b/packages/maker-core/src/agents/claude-code/subagent-model-access.ts @@ -0,0 +1,88 @@ +import type { + HookCallback, + HookCallbackMatcher, + HookEvent, + PreToolUseHookInput, +} from '@anthropic-ai/claude-agent-sdk'; + +export type ClaudeSubagentModelAccessStatus = 'allowed' | 'denied' | 'unknown'; + +export interface ClaudeSubagentModelAccessResult { + status: ClaudeSubagentModelAccessStatus; + /** 可选的 host 诊断;只有 denied 会展示给用户。 */ + reason?: string; +} + +export type ResolveClaudeSubagentModelAccess = ( + model: string, +) => ClaudeSubagentModelAccessResult | Promise; + +export function normalizeClaudeSubagentModel(model: string): string { + const normalized = model.trim().toLowerCase(); + return normalized.endsWith('[1m]') + ? normalized.slice(0, -'[1m]'.length) + : normalized; +} + +/** + * 返回 Agent/Task 实际会用的显式模型。平台 env 覆写优先于 tool input; + * 缺省和 inherit 都交给 Claude 自己解析,不对无法证明的隐式默认值做拦截。 + */ +export function effectiveClaudeSubagentModel( + forcedModel: string | undefined, + toolName: string, + toolInput: unknown, +): string | undefined { + if (toolName !== 'Agent' && toolName !== 'Task') return undefined; + const input = typeof toolInput === 'object' && toolInput !== null + ? toolInput as Record + : {}; + const requested = typeof input.model === 'string' ? input.model : ''; + const effective = normalizeClaudeSubagentModel(forcedModel ?? requested); + return !effective || effective === 'inherit' ? undefined : effective; +} + +export function claudeSubagentModelDenialReason(model: string, detail?: string): string { + return detail?.trim() + || `Subagent model "${model}" is not available from the current account and provider. Choose an available model, or remove the unavailable override from the Agent call or Subagent Model setting.`; +} + +/** + * PreToolUse 先于权限模式执行,因此 Full access 也无法绕过。resolver 每次调用都 + * 现场读取 host 的当前账号/路由状态;缺失、异常或 unknown 一律放行,防止静态目录 + * 或旧快照被误当成权限拒绝。 + */ +export function buildClaudeSubagentModelGuardHooks( + resolveAccess: ResolveClaudeSubagentModelAccess | undefined, + forcedModel?: string, + onDeny?: (model: string) => void, +): Partial> { + if (!resolveAccess) return {}; + + const guard: HookCallback = async (input) => { + if (input.hook_event_name !== 'PreToolUse') return { continue: true }; + const pre = input as PreToolUseHookInput; + const model = effectiveClaudeSubagentModel(forcedModel, pre.tool_name, pre.tool_input); + if (!model) return { continue: true }; + + let access: ClaudeSubagentModelAccessResult; + try { + access = await resolveAccess(model); + } catch { + return { continue: true }; + } + if (access.status !== 'denied') return { continue: true }; + + onDeny?.(model); + return { + continue: true, + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: claudeSubagentModelDenialReason(model, access.reason), + }, + }; + }; + + return { PreToolUse: [{ hooks: [guard] }] }; +} diff --git a/packages/maker-core/src/agents/index.ts b/packages/maker-core/src/agents/index.ts index a2cbac80b4..b307b0b4ce 100644 --- a/packages/maker-core/src/agents/index.ts +++ b/packages/maker-core/src/agents/index.ts @@ -2,6 +2,10 @@ export * from './base-agent.js'; // toSdkModelString: host 侧标题 oneShot 需要把 catalog model id 还原成 Anthropic wire 串 // (claude-haiku-4-5 → claude-haiku-4-5-20251001),复用 SSoT 映射,避免在 host 硬编码 dated id。 export { ClaudeCodeAgent, toSdkModelString, setClaudeSupportedModelsListener } from './claude-code/index.js'; +export type { + ClaudeSubagentModelAccessResult, + ClaudeSubagentModelAccessStatus, +} from './claude-code/subagent-model-access.js'; export { CodexAgent } from './codex/index.js'; // host 导入本地 Codex rollout 历史时也要做 citation 归一化(流式路径在 translator // 内部做,导入路径拿到的是 rollout 原文),复用同一实现避免口径分叉。