From 4b5aa52bf657f173d1f86c9c9da61d17cb0aa40d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 30 Aug 2026 22:50:30 -0700 Subject: [PATCH] Refresh permission identity and schemas on a live model switch A /model change rebuilt inference but left the permission gate, grant store key, and advertised tool schemas on the previous pair. Grants scoped to the old model still matched, new grants stored under the old key, and Kimi wire schemas did not apply until restart. --- CHANGELOG.md | 5 + docs/IMPLEMENTATION.md | 2 +- docs/PRODUCT.md | 2 +- src/exec/runner.ts | 3 +- src/permission/gate.ts | 9 +- src/session/live-model-switch.test.ts | 161 ++++++++++++++++++++++++++ src/session/live-model-switch.ts | 37 ++++++ src/session/runtime-assembly.test.ts | 18 ++- src/session/runtime-assembly.ts | 6 +- src/tui/runner.ts | 30 +++-- tests/unit/tui/agent-tools.test.ts | 1 + 11 files changed, 259 insertions(+), 15 deletions(-) create mode 100644 src/session/live-model-switch.test.ts create mode 100644 src/session/live-model-switch.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bf73f0dfa..28c277952 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,11 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename - `ask_operator` no longer pre-authorizes a model-authored shell command when the operator picks any option, including Reject. Clarification choices cannot mint shell grants. +- Live `/model` switches refresh inference, permission identity, grant + persistence identity, and advertised tool schemas together. A grant for the + previous model no longer covers the same action, new grants store under the + new pair, and Kimi/Moonshot sessions get non-recursive `present` schemas + immediately (canonical schemas restore when switching away). ## [0.3.10] - 2026-08-30 diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 96500bfb9..1ee1a1c97 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -303,7 +303,7 @@ Profiles supply per-project or named-profile overrides for `model` and `systemPr Providers and credentials are read exclusively from settings files: the global `~/.corbits/settings.json` (definitions + credentials) and the per-repo `.corbits/settings.json` (selection only). There are no `OPENAI_COMPATIBLE_*` environment-variable overrides, and `index.ts` does not load `.env` files — a deliberately stale or exported key can no longer shadow the configured provider. -**Models-first connect.** There is no standalone `/login` command. `/model` opens on a flat **models-only** list (Recent, Favorites, then connected provider/model rows) built by `buildModelsFirstList` (`src/tui/model-picker.ts`); type-to-filter owns printable keys. **Alt+A** opens Connect via `addProviderSelectorChoices` (`src/tui/provider-setup.ts`), which lists every first-class kind including Custom — never bare `c` / Ctrl+A, and never in-list “connect →” rows. First-class API-key rows use a named-instance + auth-only form (instance name, key; catalog base URL is display-only); Custom keeps the full manual form. **Alt+F** toggles favorites; recent/favorite pairs live in global settings (`recentModels` / `favoriteModels`). **Alt+D** sets the default via `setDefaultModel` (global `defaultProvider` + that provider's `defaultModel`) plus `persistConnectedSelection` without switching the live session. First-class providers ship from `packages/first-class-providers` (corbits-agnostic defs) and `packages/opencode-go` (Go catalog, auth validate, multi-protocol endpoints, usage). OAuth providers open the existing browser login modal with a named account step; API-key providers share the same multi-instance naming and pre-seed models on save so selection works without restart. Both OAuth and API-key (including Custom) connects share `persistConnectedSelection` in `provider-setup-submit.ts` so project-local provider/model selection is written alongside global credentials. OpenCode Go forces `OPENCODE_GO_BASE_URL` when `opencodeGo` is set so subscription traffic is not billed as Zen PAYG. +**Models-first connect.** There is no standalone `/login` command. `/model` opens on a flat **models-only** list (Recent, Favorites, then connected provider/model rows) built by `buildModelsFirstList` (`src/tui/model-picker.ts`); type-to-filter owns printable keys. Selecting a row runs `applyLiveModelSwitch` (`src/session/live-model-switch.ts`) so inference sources, permission-gate identity, grant persistence identity, and advertised tool schemas cut over together. **Alt+A** opens Connect via `addProviderSelectorChoices` (`src/tui/provider-setup.ts`), which lists every first-class kind including Custom — never bare `c` / Ctrl+A, and never in-list “connect →” rows. First-class API-key rows use a named-instance + auth-only form (instance name, key; catalog base URL is display-only); Custom keeps the full manual form. **Alt+F** toggles favorites; recent/favorite pairs live in global settings (`recentModels` / `favoriteModels`). **Alt+D** sets the default via `setDefaultModel` (global `defaultProvider` + that provider's `defaultModel`) plus `persistConnectedSelection` without switching the live session. First-class providers ship from `packages/first-class-providers` (corbits-agnostic defs) and `packages/opencode-go` (Go catalog, auth validate, multi-protocol endpoints, usage). OAuth providers open the existing browser login modal with a named account step; API-key providers share the same multi-instance naming and pre-seed models on save so selection works without restart. Both OAuth and API-key (including Custom) connects share `persistConnectedSelection` in `provider-setup-submit.ts` so project-local provider/model selection is written alongside global credentials. OpenCode Go forces `OPENCODE_GO_BASE_URL` when `opencodeGo` is set so subscription traffic is not billed as Zen PAYG. **OpenCode Go multi-protocol.** Each Go model carries protocol metadata (`chat-completions`, `responses`, or `messages`). `buildGoSource` / `resolveGoEndpoint` pick the adapter and base URL per model (not a single provider-wide OpenAI route). When Go is the active provider, subscription usage is fetched for the status bar and omitted on auth/network failure. diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index fb15a4528..9d5e6a805 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -107,7 +107,7 @@ The TUI has an extensible slash-command framework. Built-ins include `/help` (sh Providers are **models-first**: there is no standalone `/login` command. `/model` opens a **models-only list** (Recent, Favorites, then connected provider/model rows) — type-to-filter owns printable keys, so Connect is never a bare letter. **Alt+A** opens a dedicated add-provider selector over every first-class kind (OpenAI dual-path ChatGPT OAuth or API key, xAI, OpenCode Zen, Anthropic, Google, OpenCode Go, Z.AI Coding Plan, Custom), each annotated with its live account count and never filtered out for “already connected.” **Alt+F** toggles favorite on the highlighted model. **Alt+D** persists the highlighted pair as the default without switching the live session. Advanced provider drill-down (edit/delete/tiers) stays on the advanced surface, not a bare printable key while the model list is filtering. OAuth providers open their existing browser login with a named account step so multiple accounts per kind coexist (`codex/work`, …). API-key providers use the same named-instance step before the key (auth-only form: instance name + key + fixed catalog base URL), so personal and team keys land as distinct catalog rows (`openai/default`, `anthropic/work`, …); reusing a name re-keys that instance after confirm. Custom remains a free-form single endpoint (full manual form). Successful connect refreshes the catalog and reopens the model list focused on the new account’s default model. OpenCode Go routes each model by its protocol metadata (chat completions, OpenAI responses, or Anthropic messages) and can show subscription usage in the status bar when active (rolling 5h / weekly / monthly windows when the usage API responds; omitted on auth or network failure). When Go returns a quota or rate-limit error — including some HTTP 400 responses that carry limit payloads — Corbits classifies them so quota aborts cleanly and short provider rate limits remain retryable. On a free-tier or subscription quota hit, wait for the window to reset or use OpenCode Zen free models. -`/model` opens a dedicated full-screen modal — the single place agent configuration lives. The default view is models-only (Recent / Favorites / connected models); add-provider, tiers, and profiles remain reachable from the same surface without in-list “connect →” rows. A switch applies to the running session immediately (no restart), and can be saved as this project's default (written to the per-repo selection file). Recent and favorite model pairs are stored in global settings (no credentials). +`/model` opens a dedicated full-screen modal — the single place agent configuration lives. The default view is models-only (Recent / Favorites / connected models); add-provider, tiers, and profiles remain reachable from the same surface without in-list “connect →” rows. A switch applies to the running session immediately (no restart): inference, permission identity, grant persistence identity, and advertised tool schemas cut over together, and the choice can be saved as this project's default (written to the per-repo selection file). Recent and favorite model pairs are stored in global settings (no credentials). ## Lifecycle Hooks diff --git a/src/exec/runner.ts b/src/exec/runner.ts index 908209084..431278911 100644 --- a/src/exec/runner.ts +++ b/src/exec/runner.ts @@ -357,7 +357,6 @@ export async function runExec(config: Config): Promise { const sessionMode: SessionMode = resolveSessionMode(config.settings, localSettingsForMode) ?? "orchestrator"; - const activeProviderModel = `${config.providerName}:${config.model}`; const seededApprovals = await loadSeededApprovals(config.cwd, sessionId); const interactive = input.isTTY === true && output.isTTY === true; @@ -377,7 +376,7 @@ export async function runExec(config: Config): Promise { model: config.model, requestApproval: (request: PermissionRequest): Promise => promptPermission(request, interactive), - persist: createApprovalPersist(config.cwd, activeProviderModel), + persist: createApprovalPersist(config.cwd, () => `${config.providerName}:${config.model}`), approvalLog: createApprovalLog(sessionDir(config.cwd, sessionId)), interactive, skipPermissions: config.dangerouslySkipPermissions, diff --git a/src/permission/gate.ts b/src/permission/gate.ts index 64b649523..13f9f3856 100644 --- a/src/permission/gate.ts +++ b/src/permission/gate.ts @@ -327,6 +327,10 @@ export interface PermissionGate { // TUI wires the toggle here so a switch takes effect on the next tool call — // including pre-gate sandboxes that read getSkipPermissions live. setSkipPermissions: (value: boolean) => void; + // Point matching and newly minted provider-model grants at a different + // providerName:model. A live `/model` switch calls this so a grant scoped to + // the previous pair no longer auto-allows, and new grants tag the new pair. + setProviderIdentity: (providerName: string, model: string) => void; registerMcpClient: (client: MCPClient) => void; unregisterMcpServer: (serverName: string) => void; } @@ -359,7 +363,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission let skipPermissions = options.skipPermissions; // Own a private copy so evaluating a grant never mutates the caller's array. const approvals: Approval[] = [...options.approvals]; - const activeProviderModel = + let activeProviderModel = providerName !== undefined && model !== undefined ? `${providerName}:${model}` : undefined; // Session grants live only in this array; persisted grants are seeded in via // options.approvals and re-routed to a store by the persist callback. @@ -734,6 +738,9 @@ export function createPermissionGate(options: PermissionGateOptions): Permission setSkipPermissions: (value: boolean) => { skipPermissions = value; }, + setProviderIdentity: (nextProviderName: string, nextModel: string) => { + activeProviderModel = `${nextProviderName}:${nextModel}`; + }, registerMcpClient, unregisterMcpServer, }; diff --git a/src/session/live-model-switch.test.ts b/src/session/live-model-switch.test.ts new file mode 100644 index 000000000..f59e8577b --- /dev/null +++ b/src/session/live-model-switch.test.ts @@ -0,0 +1,161 @@ +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; +import type { ToolCall, ToolDefinition } from "@intx/types/runtime"; + +import { presentDefinition } from "../agent/director.js"; +import { normalizeToolDefinitionsForProvider } from "../agent/tool-schema-normalize.js"; +import { createPermissionGate } from "../permission/gate.js"; +import * as permissionStore from "../permission/store.js"; +import type { Approval } from "../permission/types.js"; +import { createApprovalPersist } from "./runtime-assembly.js"; +import { applyLiveModelSwitch, providerModelKey, type LiveModelRef } from "./live-model-switch.js"; + +const MODEL_A: LiveModelRef = { providerName: "openai", model: "gpt-5" }; +const MODEL_B: LiveModelRef = { providerName: "anthropic", model: "claude-opus" }; +const MODEL_KIMI: LiveModelRef = { providerName: "moonshot", model: "kimi-k2" }; + +const canonicalDefs: readonly ToolDefinition[] = [presentDefinition]; + +function schemaHasRef(value: unknown): boolean { + if (value === null || typeof value !== "object") return false; + if (Array.isArray(value)) return value.some(schemaHasRef); + const obj = value as Record; + if ("$ref" in obj) return true; + return Object.values(obj).some(schemaHasRef); +} + +const shellCall = (command: string): ToolCall => ({ + id: "c", + name: "run_shell", + arguments: { command }, +}); + +const providerModelScope = { + id: "provider-model", + label: "", + pattern: "npm *", + grant: "provider-model" as const, +}; + +function presentSchema(defs: readonly ToolDefinition[]): unknown { + return defs.find((d) => d.name === "present")?.inputSchema; +} + +/** + * Same collaborators the TUI `/model` handler wires through + * `applyLiveModelSwitch`: live identity (persist reads it), permission gate, + * inference rebuild, and canonical-then-family-gate advertise. + */ +function createProductionSwitch() { + let identity: LiveModelRef = MODEL_A; + let inference: LiveModelRef = MODEL_A; + let advertised = normalizeToolDefinitionsForProvider(canonicalDefs, MODEL_A); + + const persist = createApprovalPersist("/tmp/proj", () => providerModelKey(identity)); + const gate = createPermissionGate({ + approvals: [], + requestApproval: async () => ({ allow: true, persist: providerModelScope }), + persist, + interactive: true, + skipPermissions: false, + auto: false, + providerName: identity.providerName, + model: identity.model, + }); + + const switchTo = (next: LiveModelRef): void => { + applyLiveModelSwitch(next, { + applyIdentity: (ref) => { + identity = ref; + }, + setPermissionIdentity: (providerName, model) => { + gate.setProviderIdentity(providerName, model); + }, + rebuildInference: (ref) => { + inference = ref; + }, + refreshAdvertisedSchemas: (ref) => { + advertised = normalizeToolDefinitionsForProvider(canonicalDefs, ref); + }, + }); + }; + + return { + gate, + switchTo, + identity: () => identity, + inference: () => inference, + advertised: () => advertised, + }; +} + +describe("applyLiveModelSwitch", () => { + afterEach(() => { + mock.restore(); + }); + + test("refreshes identity, permission, inference, and schemas as one operation", () => { + const order: string[] = []; + applyLiveModelSwitch(MODEL_B, { + applyIdentity: () => { + order.push("identity"); + }, + setPermissionIdentity: () => { + order.push("permission"); + }, + rebuildInference: () => { + order.push("inference"); + }, + refreshAdvertisedSchemas: () => { + order.push("schemas"); + }, + }); + expect(order).toEqual(["identity", "permission", "inference", "schemas"]); + }); + + test("a grant scoped to A does not cover the action after switching to B; new grants store under B", async () => { + const saved: string[] = []; + spyOn(permissionStore, "saveProviderModelApproval").mockImplementation(async (key: string) => { + saved.push(key); + }); + spyOn(permissionStore, "saveProjectApproval").mockResolvedValue(undefined); + spyOn(permissionStore, "saveGlobalApproval").mockResolvedValue(undefined); + + const session = createProductionSwitch(); + + expect((await session.gate.evaluate(shellCall("npm test"))).allowed).toBe(true); + expect(saved).toEqual([providerModelKey(MODEL_A)]); + const grantA = session.gate + .getApprovals() + .find((a: Approval) => a.providerModel === providerModelKey(MODEL_A)); + expect(grantA).toBeDefined(); + + session.switchTo(MODEL_B); + + expect(session.identity()).toEqual(MODEL_B); + expect(session.inference()).toEqual(MODEL_B); + + expect((await session.gate.evaluate(shellCall("npm test"))).allowed).toBe(true); + expect(saved).toEqual([providerModelKey(MODEL_A), providerModelKey(MODEL_B)]); + expect( + session.gate.getApprovals().some((a) => a.providerModel === providerModelKey(MODEL_B)), + ).toBe(true); + }); + + test("non-kimi to kimi rewrites advertised present; switching away restores canonical", () => { + const session = createProductionSwitch(); + expect(schemaHasRef(presentSchema(session.advertised()))).toBe(true); + expect(presentSchema(session.advertised())).toBe(presentDefinition.inputSchema); + + session.switchTo(MODEL_KIMI); + + expect(session.inference()).toEqual(MODEL_KIMI); + expect(schemaHasRef(presentSchema(session.advertised()))).toBe(false); + expect(presentSchema(session.advertised())).not.toBe(presentDefinition.inputSchema); + + session.switchTo(MODEL_A); + + expect(session.inference()).toEqual(MODEL_A); + expect(schemaHasRef(presentSchema(session.advertised()))).toBe(true); + expect(presentSchema(session.advertised())).toBe(presentDefinition.inputSchema); + }); +}); diff --git a/src/session/live-model-switch.ts b/src/session/live-model-switch.ts new file mode 100644 index 000000000..24d91a5b1 --- /dev/null +++ b/src/session/live-model-switch.ts @@ -0,0 +1,37 @@ +/** + * One cutover for every model-scoped runtime fact a live session reads. + * `/model` (and tests) must go through this rather than refreshing inference + * alone — a missed step is how provider-model grants and kimi wire schemas + * went stale after a switch. + */ + +export interface LiveModelRef { + providerName: string; + model: string; +} + +export function providerModelKey(ref: LiveModelRef): string { + return `${ref.providerName}:${ref.model}`; +} + +export interface LiveModelSwitchHandles { + /** Session config / live identity that persist getters read. */ + applyIdentity: (next: LiveModelRef) => void; + /** Permission-gate matching and mint identity. */ + setPermissionIdentity: (providerName: string, model: string) => void; + /** Rebuild inference sources for the next turn. */ + rebuildInference: (next: LiveModelRef) => void; + /** + * Re-advertise family-gated tool schemas from canonical definitions. + * Must not re-normalize an already-rewritten advertise set — switching + * away from kimi would then keep the non-recursive present schema. + */ + refreshAdvertisedSchemas: (next: LiveModelRef) => void; +} + +export function applyLiveModelSwitch(next: LiveModelRef, handles: LiveModelSwitchHandles): void { + handles.applyIdentity(next); + handles.setPermissionIdentity(next.providerName, next.model); + handles.rebuildInference(next); + handles.refreshAdvertisedSchemas(next); +} diff --git a/src/session/runtime-assembly.test.ts b/src/session/runtime-assembly.test.ts index 4097bc2f8..efb6dd0d3 100644 --- a/src/session/runtime-assembly.test.ts +++ b/src/session/runtime-assembly.test.ts @@ -171,7 +171,7 @@ describe("createApprovalPersist", () => { undefined, ); - const persist = createApprovalPersist("/tmp/proj", "openai:gpt-5"); + const persist = createApprovalPersist("/tmp/proj", () => "openai:gpt-5"); const approval = { tool: "run_shell", pattern: "npm *" }; persist(approval, "project"); @@ -187,6 +187,22 @@ describe("createApprovalPersist", () => { expect(global).toHaveBeenCalledTimes(1); expect(providerModel).toHaveBeenCalledTimes(1); }); + + test("a live identity change stores the next provider-model grant under the new key", () => { + const providerModel = spyOn(permissionStore, "saveProviderModelApproval").mockResolvedValue( + undefined, + ); + let identity = "openai:gpt-5"; + const persist = createApprovalPersist("/tmp/proj", () => identity); + const approval = { tool: "run_shell", pattern: "npm *" }; + + persist(approval, "provider-model"); + identity = "anthropic:claude-opus"; + persist(approval, "provider-model"); + + expect(providerModel).toHaveBeenNthCalledWith(1, "openai:gpt-5", approval); + expect(providerModel).toHaveBeenNthCalledWith(2, "anthropic:claude-opus", approval); + }); }); describe("skillDirsFromEnabledPlugins", () => { diff --git a/src/session/runtime-assembly.ts b/src/session/runtime-assembly.ts index e2d2790a7..9a91a8481 100644 --- a/src/session/runtime-assembly.ts +++ b/src/session/runtime-assembly.ts @@ -122,16 +122,18 @@ export async function loadSeededApprovals( /** * Route a gate-persisted grant to the store its scope selects. * Session grants never reach here — the gate keeps those in memory only. + * `getActiveProviderModel` is read at persist time so a live model switch + * stores new provider-model grants under the pair now in use. */ export function createApprovalPersist( cwd: string, - activeProviderModel: string, + getActiveProviderModel: () => string, ): (approval: Approval, scope: GrantScope) => void { return (approval: Approval, scope: GrantScope) => { if (scope === "project") void saveProjectApproval(cwd, approval); else if (scope === "global") void saveGlobalApproval(approval); else if (scope === "provider-model") { - void saveProviderModelApproval(activeProviderModel, approval); + void saveProviderModelApproval(getActiveProviderModel(), approval); } }; } diff --git a/src/tui/runner.ts b/src/tui/runner.ts index a0933c609..e09ab6af0 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -249,6 +249,7 @@ import { loadSessionChatPrompt, skillDirsFromEnabledPlugins, } from "../session/runtime-assembly.js"; +import { applyLiveModelSwitch } from "../session/live-model-switch.js"; import { createAttachmentRehydrateTransform } from "../session/attachment-store.js"; import { createModelSummarizer, type SummaryContext } from "../session/summarizer.js"; import { COMMAND_NAME, ID_PREFIX, LOG_NAMESPACE_ROOT } from "../branding.js"; @@ -856,8 +857,6 @@ export async function runTUI(initialConfig: Config): Promise { const isXaiAuthError = (err: unknown): boolean => err instanceof Error && err.name === "XaiAuthError"; - const activeProviderModel = `${config.providerName}:${config.model}`; - // Shared by the permission gate and every operator-gate emission site: an // unattended auto-continue run must not park on any gate forever, whichever // kind it is. No caller arms this today — the goal subsystem was the only @@ -881,7 +880,7 @@ export async function runTUI(initialConfig: Config): Promise { emitGate: (event) => emitter.emit("permission.gate", event), approvalTimeout, }), - persist: createApprovalPersist(config.cwd, activeProviderModel), + persist: createApprovalPersist(config.cwd, () => `${config.providerName}:${config.model}`), approvalLog: createApprovalLog(sessionDir(config.cwd, sessionId)), interactive: true, skipPermissions: config.dangerouslySkipPermissions, @@ -2344,10 +2343,27 @@ export async function runTUI(initialConfig: Config): Promise { if (sep <= 0) return; const provider = id.slice(0, sep); const model = id.slice(sep + 1); - config = { ...config, providerName: provider, model }; - host.bridge.setInferenceProviderId(provider); - const bundle = buildSessionSources(); - agentProxy.setSources(bundle.sources, bundle.defaultSource); + applyLiveModelSwitch( + { providerName: provider, model }, + { + applyIdentity: (next) => { + config = { ...config, providerName: next.providerName, model: next.model }; + }, + setPermissionIdentity: (providerName, modelName) => { + permissionGate.setProviderIdentity(providerName, modelName); + }, + rebuildInference: (next) => { + host.bridge.setInferenceProviderId(next.providerName); + const bundle = buildSessionSources(); + agentProxy.setSources(bundle.sources, bundle.defaultSource); + }, + refreshAdvertisedSchemas: () => { + directorHolder.instance?.updateToolDefinitions( + computeAdvertised(toolset.dynamicRunner.currentDefinitions()), + ); + }, + }, + ); const ref: ModelRef = { provider, model }; void (async () => { diff --git a/tests/unit/tui/agent-tools.test.ts b/tests/unit/tui/agent-tools.test.ts index 294e07366..154eb456c 100644 --- a/tests/unit/tui/agent-tools.test.ts +++ b/tests/unit/tui/agent-tools.test.ts @@ -148,6 +148,7 @@ const fakePermissionGate: PermissionGate = { setAuto: () => {}, getSkipPermissions: () => false, setSkipPermissions: () => {}, + setProviderIdentity: () => {}, registerMcpClient: mock(() => {}), unregisterMcpServer: mock(() => {}), };