From 8a167068e8911fa36a8739e6a239d8abb3379789 Mon Sep 17 00:00:00 2001 From: Lokkuchakreshkumar Date: Sun, 11 Oct 2026 14:28:30 +0530 Subject: [PATCH] fix(acp): let configured custom models bypass the advertised-choices guard Custom model ids (e.g. Cline's cline-free/* free tier) are merged into the composer catalog but rejected client-side by validateConfigOptionValue, which allowlists session/set_config_option values against agent-advertised choices only. Thread the instance's customModels through as passthrough values so the agent validates them; the guard still applies to everything else. --- .../src/server/adapter.ts | 8 ++++ .../src/server/AcpSessionRuntime.ts | 12 +++++- .../src/server/runtimeModel.test.ts | 40 +++++++++++++++++++ .../provider-acp/src/server/runtimeModel.ts | 18 +++++++++ 4 files changed, 77 insertions(+), 1 deletion(-) diff --git a/packages/provider-acp-registry/src/server/adapter.ts b/packages/provider-acp-registry/src/server/adapter.ts index 0fa9a83d05c7..5c88756552ce 100644 --- a/packages/provider-acp-registry/src/server/adapter.ts +++ b/packages/provider-acp-registry/src/server/adapter.ts @@ -9,6 +9,7 @@ import { makeCopilotSubagentRouting, } from "./copilotAcp.ts"; import { defaultInstanceIdForDriver, ProviderDriverKind } from "@t3tools/contracts"; +import { readCustomModelEntries } from "@t3tools/shared/model"; import { AcpRegistrySettings } from "../settings.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { resolveSelfInvocation, type SelfInvocation } from "@t3tools/shared/nodeRuntime"; @@ -168,6 +169,13 @@ function makeAcpRegistryRuntime( env: { ...resolved.spawn.env, ...processEnvironment }, }, ...(options.settings.authMethodId ? { authMethodId: options.settings.authMethodId } : {}), + // Custom model ids never appear in the agent-advertised choices the + // session runtime allowlists against. Let them through so the agent + // validates them (e.g. Cline accepts `cline-free/*`, which its + // discovery never advertises). + passthroughConfigValues: new Set( + readCustomModelEntries(options.settings.customModels).map((entry) => entry.slug), + ), }), ); return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( diff --git a/packages/provider-acp/src/server/AcpSessionRuntime.ts b/packages/provider-acp/src/server/AcpSessionRuntime.ts index 683bf8c90f41..7f13becf3c50 100644 --- a/packages/provider-acp/src/server/AcpSessionRuntime.ts +++ b/packages/provider-acp/src/server/AcpSessionRuntime.ts @@ -37,6 +37,7 @@ import { decideToolCallUpdateEmission, extractModelConfigId, findSessionConfigOption, + isAllowedSessionConfigOptionValue, mergeToolCallState, toolCallProgressLength, parseSessionModeState, @@ -155,6 +156,13 @@ export interface AcpSessionRuntimeOptions { readonly onTermination?: (error: EffectAcpErrors.AcpError) => Effect.Effect; readonly onOutgoingResponseFailure?: EffectAcpClient.AcpClientOptions["onOutgoingResponseFailure"]; readonly onOutgoingResponse?: EffectAcpClient.AcpClientOptions["onOutgoingResponse"]; + /** + * Config values the provider instance explicitly configured (custom model + * ids). They bypass the advertised-choices allowlist in + * `validateConfigOptionValue` and are sent to the agent, which validates + * them. Keeps the guard for everything else. + */ + readonly passthroughConfigValues?: ReadonlySet; } export interface AcpSessionRequestLogEvent { @@ -1967,7 +1975,9 @@ export const make = ( }); } const allowedValues = collectSessionConfigOptionValues(configOption); - if (allowedValues.includes(value)) { + if ( + isAllowedSessionConfigOptionValue(configOption, value, options.passthroughConfigValues) + ) { return; } return yield* new EffectAcpErrors.AcpRequestError({ diff --git a/packages/provider-acp/src/server/runtimeModel.test.ts b/packages/provider-acp/src/server/runtimeModel.test.ts index dab23a53fead..38632ccae2eb 100644 --- a/packages/provider-acp/src/server/runtimeModel.test.ts +++ b/packages/provider-acp/src/server/runtimeModel.test.ts @@ -9,6 +9,7 @@ import { embeddedTerminalIdsFromSessionUpdate, extractMcpToolCallIdentity, extractModelConfigId, + isAllowedSessionConfigOptionValue, mergeToolCallState, parsePermissionRequest, parseSessionModeState, @@ -1722,3 +1723,42 @@ describe("embeddedTerminalIdsFromSessionUpdate", () => { ).toBeUndefined(); }); }); + +describe("isAllowedSessionConfigOptionValue", () => { + const modelOption = { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "paid/model-a", + options: [ + { value: "paid/model-a", name: "Model A" }, + { value: "paid/model-b", name: "Model B" }, + ], + } as const; + + it("allows advertised values without a passthrough set", () => { + expect(isAllowedSessionConfigOptionValue(modelOption, "paid/model-a", undefined)).toBe(true); + expect(isAllowedSessionConfigOptionValue(modelOption, "paid/model-a", new Set())).toBe(true); + }); + + it("rejects unadvertised values without a passthrough set", () => { + expect(isAllowedSessionConfigOptionValue(modelOption, "free/custom-model", undefined)).toBe( + false, + ); + expect(isAllowedSessionConfigOptionValue(modelOption, "free/custom-model", new Set())).toBe( + false, + ); + }); + + it("lets configured custom ids through even when the agent never advertises them", () => { + const passthrough = new Set(["free/custom-model"]); + expect(isAllowedSessionConfigOptionValue(modelOption, "free/custom-model", passthrough)).toBe( + true, + ); + expect(isAllowedSessionConfigOptionValue(modelOption, "paid/model-a", passthrough)).toBe(true); + expect(isAllowedSessionConfigOptionValue(modelOption, "nope/elsewhere", passthrough)).toBe( + false, + ); + }); +}); diff --git a/packages/provider-acp/src/server/runtimeModel.ts b/packages/provider-acp/src/server/runtimeModel.ts index f4172d17089b..4343819832c6 100644 --- a/packages/provider-acp/src/server/runtimeModel.ts +++ b/packages/provider-acp/src/server/runtimeModel.ts @@ -425,6 +425,24 @@ export function collectSessionConfigOptionValues( ); } +/** + * Whether a select config value may be sent to the agent. Values the provider + * instance explicitly configured (custom models) bypass the advertised-choices + * allowlist: they are provider-owned, so the agent validates them. This keeps + * the guard for everything else while allowing ids the discovery never + * advertises but the agent accepts (e.g. Cline's `cline-free/*` free tier). + */ +export function isAllowedSessionConfigOptionValue( + configOption: EffectAcpSchema.SessionConfigOption, + value: string, + passthroughValues: ReadonlySet | undefined, +): boolean { + if (passthroughValues?.has(value)) { + return true; + } + return collectSessionConfigOptionValues(configOption).includes(value); +} + export function parseSessionModeState( sessionResponse: AcpSessionSetupResponse, ): AcpSessionModeState | undefined {